50ed8abe95
- Full file browser page at /files with lazy-load, breadcrumbs, drag&drop - FileBrowserModal component for path selection from Samba/NFS forms - PathField component replaces bare inputs in share/export forms - Backend: /api/files/* routes with List, Mkdir, Rename, Delete, Chmod, Chown, Upload, Download, Preview, Search - Reuses NASCTL_ALLOWED_ROOTS for path validation - NASCTL_UPLOAD_MAX_BYTES (100MB) and NASCTL_PREVIEW_MAX_BYTES (256KB) env vars - Capabilities endpoint returns chmod/chown availability (requires root) - Version bump: 0.3.2 -> 0.4.0
234 lines
5.9 KiB
Go
234 lines
5.9 KiB
Go
package web
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"github.com/darroyo/nasctl/internal/db"
|
|
"github.com/darroyo/nasctl/internal/engine"
|
|
)
|
|
|
|
const (
|
|
sessionCookieName = "nasctl_session"
|
|
sessionTTL = 12 * time.Hour
|
|
signingKeySetting = "session_signing_key"
|
|
)
|
|
|
|
type AuthService struct {
|
|
db *db.DB
|
|
secret []byte
|
|
}
|
|
|
|
func NewAuthService(database *db.DB) (*AuthService, error) {
|
|
secret, err := loadOrCreateSecret(database)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &AuthService{db: database, secret: secret}, nil
|
|
}
|
|
|
|
func loadOrCreateSecret(database *db.DB) ([]byte, error) {
|
|
value, ok, err := database.GetSetting(signingKeySetting)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if ok {
|
|
return hex.DecodeString(value)
|
|
}
|
|
buf := make([]byte, 32)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return nil, fmt.Errorf("generate signing key: %w", err)
|
|
}
|
|
if err := database.SetSetting(signingKeySetting, hex.EncodeToString(buf)); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf, nil
|
|
}
|
|
|
|
// EnsureAdmin creates an initial admin if none exists.
|
|
func (a *AuthService) EnsureAdmin(username, password string) (bool, error) {
|
|
count, err := a.db.CountAdmins()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if count > 0 {
|
|
return false, nil
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return false, fmt.Errorf("hash password: %w", err)
|
|
}
|
|
if _, err := a.db.CreateAdmin(username, string(hash)); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (a *AuthService) Authenticate(username, password string) bool {
|
|
admin, err := a.db.GetAdminByUsername(username)
|
|
if err != nil {
|
|
_ = bcrypt.CompareHashAndPassword([]byte("$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinv"), []byte(password))
|
|
return false
|
|
}
|
|
return bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)) == nil
|
|
}
|
|
|
|
func (a *AuthService) ChangePassword(username, oldPassword, newPassword string) error {
|
|
if len(newPassword) < 8 {
|
|
return fmt.Errorf("la nueva contraseña debe tener al menos 8 caracteres")
|
|
}
|
|
admin, err := a.db.GetAdminByUsername(username)
|
|
if err != nil {
|
|
return fmt.Errorf("admin no encontrado")
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(oldPassword)); err != nil {
|
|
return fmt.Errorf("contraseña actual incorrecta")
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return fmt.Errorf("hash password: %w", err)
|
|
}
|
|
if err := a.db.UpdateAdminPasswordHash(admin.ID, string(hash)); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *AuthService) issueToken(username string) string {
|
|
expiry := time.Now().Add(sessionTTL).Unix()
|
|
payload := fmt.Sprintf("%s|%d", username, expiry)
|
|
sig := a.sign(payload)
|
|
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + sig
|
|
}
|
|
|
|
func (a *AuthService) sign(payload string) string {
|
|
mac := hmac.New(sha256.New, a.secret)
|
|
mac.Write([]byte(payload))
|
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
func (a *AuthService) verifyToken(token string) (string, bool) {
|
|
parts := strings.SplitN(token, ".", 2)
|
|
if len(parts) != 2 {
|
|
return "", false
|
|
}
|
|
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
payload := string(payloadBytes)
|
|
expected := a.sign(payload)
|
|
if subtle.ConstantTimeCompare([]byte(expected), []byte(parts[1])) != 1 {
|
|
return "", false
|
|
}
|
|
segs := strings.SplitN(payload, "|", 2)
|
|
if len(segs) != 2 {
|
|
return "", false
|
|
}
|
|
expiry, err := strconv.ParseInt(segs[1], 10, 64)
|
|
if err != nil || time.Now().Unix() > expiry {
|
|
return "", false
|
|
}
|
|
return segs[0], true
|
|
}
|
|
|
|
func (a *AuthService) setSessionCookie(w http.ResponseWriter, username string) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: a.issueToken(username),
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
Expires: time.Now().Add(sessionTTL),
|
|
MaxAge: int(sessionTTL.Seconds()),
|
|
})
|
|
}
|
|
|
|
func (a *AuthService) clearSessionCookie(w http.ResponseWriter) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: -1,
|
|
})
|
|
}
|
|
|
|
func (a *AuthService) currentUser(r *http.Request) (string, bool) {
|
|
cookie, err := r.Cookie(sessionCookieName)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
return a.verifyToken(cookie.Value)
|
|
}
|
|
|
|
// RequireAuth wraps API handlers that must be authenticated.
|
|
func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if s.Auth == nil {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
if _, ok := s.Auth.currentUser(r); !ok {
|
|
writeError(w, http.StatusUnauthorized, "authentication required")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
type Server struct {
|
|
DB *db.DB
|
|
Engine *engine.Engine
|
|
AllowedRoots []string
|
|
Auth *AuthService
|
|
SMBConfPath string
|
|
ExportsPath string
|
|
UploadMaxBytes int64
|
|
PreviewMaxBytes int64
|
|
}
|
|
|
|
type Options struct {
|
|
AllowedRoots []string
|
|
Auth *AuthService
|
|
SMBConfPath string
|
|
ExportsPath string
|
|
UploadMaxBytes int64
|
|
PreviewMaxBytes int64
|
|
}
|
|
|
|
func NewServer(database *db.DB, eng *engine.Engine, opts Options) *Server {
|
|
if opts.UploadMaxBytes == 0 {
|
|
opts.UploadMaxBytes = 100 << 20 // 100 MB
|
|
}
|
|
if opts.PreviewMaxBytes == 0 {
|
|
opts.PreviewMaxBytes = 256 << 10 // 256 KB
|
|
}
|
|
return &Server{
|
|
DB: database,
|
|
Engine: eng,
|
|
AllowedRoots: opts.AllowedRoots,
|
|
Auth: opts.Auth,
|
|
SMBConfPath: opts.SMBConfPath,
|
|
ExportsPath: opts.ExportsPath,
|
|
UploadMaxBytes: opts.UploadMaxBytes,
|
|
PreviewMaxBytes: opts.PreviewMaxBytes,
|
|
}
|
|
}
|
|
|
|
func (s *Server) Handler() http.Handler {
|
|
return NewRouter(s)
|
|
}
|