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) }