diff --git a/gearbox/internal/framework/auth/adapter.go b/gearbox/internal/framework/auth/adapter.go new file mode 100644 index 0000000..08a1d40 --- /dev/null +++ b/gearbox/internal/framework/auth/adapter.go @@ -0,0 +1,129 @@ +package auth + +import ( + "errors" + "net/http" + "time" + + webcoreauth "github.com/sarg3nt/webcore/core/auth" + "github.com/sarg3nt/gearbox/internal/framework/database" + "github.com/sarg3nt/gearbox/internal/framework/models" +) + +// authUser adapts *models.User to webcore's auth.AuthUser interface. An +// adapter struct (rather than methods on models.User) is required because +// User.ID is a field and the interface wants an ID() method — Go forbids a +// field and method sharing a name. +type authUser struct{ u *models.User } + +func (a authUser) ID() string { return a.u.ID } +func (a authUser) Email() string { return a.u.Email } +func (a authUser) PasswordHash() string { return a.u.PasswordHash } +func (a authUser) IsLocked() bool { return a.u.IsLocked() } +func (a authUser) MustChangePassword() bool { return a.u.MustChangePassword } + +// StatusError preserves gearbox's historical login messages for non-active +// accounts. These are user-facing and disclosed pre-password-check by design +// (see the webcore AuthUser contract's enumeration note). +func (a authUser) StatusError() error { + switch a.u.Status { + case models.UserStatusActive: + return nil + case models.UserStatusPending: + return errors.New("account is pending approval") + default: + return errors.New("account is disabled") + } +} + +// unwrapUser recovers the concrete *models.User from a webcore AuthUser +// produced by our store. Returns nil for a nil interface. +func unwrapUser(u webcoreauth.AuthUser) *models.User { + if u == nil { + return nil + } + if a, ok := u.(authUser); ok { + return a.u + } + return nil +} + +// userStore implements webcore's auth.UserStore over gearbox's database. +// Lookups return the untyped-nil interface on not-found, per the contract. +type userStore struct{ db *database.DB } + +func (s userStore) GetUserByEmail(email string) (webcoreauth.AuthUser, error) { + u, err := s.db.GetUserByEmail(email) + if err != nil || u == nil { + return nil, err + } + return authUser{u}, nil +} + +func (s userStore) GetUserByID(id string) (webcoreauth.AuthUser, error) { + u, err := s.db.GetUserByID(id) + if err != nil || u == nil { + return nil, err + } + return authUser{u}, nil +} + +func (s userStore) GetUserByResetToken(token string) (webcoreauth.AuthUser, error) { + u, err := s.db.GetUserByResetToken(token) + if err != nil || u == nil { + return nil, err + } + return authUser{u}, nil +} + +func (s userStore) RecordLoginAttempt(id string, success bool) error { + return s.db.RecordLoginAttempt(id, success) +} + +func (s userStore) SetSessionToken(id, token, ip, userAgent string) error { + return s.db.SetUserSessionToken(id, token, ip, userAgent) +} + +func (s userStore) ValidateSessionToken(id, token string) (bool, error) { + return s.db.ValidateSessionToken(id, token) +} + +func (s userStore) ClearSessionToken(id string) error { + return s.db.ClearUserSessionToken(id) +} + +func (s userStore) UpdatePassword(id, hash string, mustChange bool) error { + return s.db.UpdateUserPassword(id, hash, mustChange) +} + +func (s userStore) SetPasswordResetToken(id, token string, expiresAt time.Time) error { + return s.db.SetPasswordResetToken(id, token, expiresAt) +} + +// auditLogger implements webcore's auth.AuditLogger over gearbox's audit_logs +// table. webcore's Action* strings are identical to gearbox's AuditAction* +// values ("login", "login_failed", …), so no mapping is needed. +type auditLogger struct { + db *database.DB + logger interface { + Error(msg string, args ...any) + } +} + +func (a auditLogger) LogAudit(r *http.Request, userID *string, action, details string) { + log := &models.AuditLog{ + UserID: userID, + Action: action, + Details: details, + IPAddress: getClientIP(r), + UserAgent: r.UserAgent(), + } + if err := a.db.CreateAuditLog(log); err != nil { + a.logger.Error("failed to create audit log", "error", err, "action", action, "details", details) + } +} + +// getClientIP extracts the client IP, honoring reverse-proxy headers. +func getClientIP(r *http.Request) string { + return webcoreauth.ClientIP(r) +} diff --git a/gearbox/internal/framework/auth/auth.go b/gearbox/internal/framework/auth/auth.go index 1d622ed..9d32e30 100644 --- a/gearbox/internal/framework/auth/auth.go +++ b/gearbox/internal/framework/auth/auth.go @@ -1,658 +1,184 @@ +// Package auth is gearbox's authentication layer. Since the webcore adoption +// it is a thin wrapper: the security-critical mechanics (DB-validated session +// tokens, constant-time login, CSRF, password change/reset, the dev loopback +// bypass) live in github.com/sarg3nt/webcore/core/auth, driven through the +// adapters in adapter.go. Gearbox-specific concerns — RBAC/permissions, the +// gear/box request context, WebAuthn (kept local for passkey-ID +// compatibility), and audit-log storage — remain here. package auth import ( - "crypto/rand" - "crypto/subtle" - "encoding/base64" "fmt" "log/slog" "net/http" "time" - "github.com/gorilla/sessions" "github.com/sarg3nt/gearbox/internal/framework/database" "github.com/sarg3nt/gearbox/internal/framework/models" + webcoreauth "github.com/sarg3nt/webcore/core/auth" ) const ( - sessionName = "gearbox-session" - sessionUserIDKey = "user_id" - sessionTokenKey = "session_token" // OWASP 2026: Server-side session validation - sessionLoginKey = "login_time" - // sessionStartKey is the absolute session start timestamp. Set ONCE at - // login and never touched by ExtendSession, so a heavily-used session - // can still hit the hard TTL. See 2026-05 audit P2-3. - sessionStartKey = "session_start" - csrfTokenKey = "csrf_token" - MaxFailedAttempts = 5 - LockoutDuration = 15 * time.Minute + sessionName = "gearbox-session" + MaxFailedAttempts = 5 + LockoutDuration = 15 * time.Minute + + // devBypassEnvVar gates the dev-only loopback auto-login (webcore + // implements the bypass; it only exists in `-tags dev` builds). + devBypassEnvVar = "GEARBOX_DEV_AUTO_LOGIN" + // devBypassEmail is the seeded account the bypass logs in as. + devBypassEmail = "dev" ) -// dummyPasswordHash is a bcrypt hash used to keep the Login() codepath -// taking constant bcrypt time even when the user does not exist. Without -// it, "user missing" returns in microseconds while "user exists + wrong -// password" runs a ~100ms bcrypt compare — an obvious timing oracle for -// account enumeration. Generated once at package init; the underlying -// plaintext is never used. See 2026-05 audit P2-4 follow-up. -var dummyPasswordHash string - -func init() { - h, err := HashPassword("timing-equivalence-dummy-not-a-real-password") - if err != nil { - panic(fmt.Sprintf("auth init: failed to generate dummy password hash: %v", err)) - } - dummyPasswordHash = h -} - -// Manager handles authentication and session management. +// Manager handles authentication and session management by delegating to a +// webcore auth.Manager. The public API is unchanged from the pre-webcore +// implementation; sessions issued by the old code remain valid (same cookie +// name, same session keys, same secret). type Manager struct { - db *database.DB - sessionStore *sessions.CookieStore - timeout time.Duration // sliding idle timeout (extended on activity) - // absoluteTimeout is the hard upper bound on a session's lifetime, - // measured from the original login (not extended on activity). A zero - // value disables the hard TTL — only the sliding window applies. See - // 2026-05 audit P2-3. - absoluteTimeout time.Duration - logger *slog.Logger + wc *webcoreauth.Manager + db *database.DB + logger *slog.Logger } // NewManager creates a new authentication manager. // absoluteTimeout=0 disables the hard TTL. func NewManager(db *database.DB, sessionSecret string, timeout, absoluteTimeout time.Duration, logger *slog.Logger) (*Manager, error) { - if len(sessionSecret) < 32 { - return nil, fmt.Errorf("session secret must be at least 32 characters") - } - if absoluteTimeout > 0 && absoluteTimeout < timeout { - return nil, fmt.Errorf("absoluteTimeout (%s) must be >= sliding timeout (%s)", absoluteTimeout, timeout) - } - - // Create cookie store. The store-wide MaxAge is the sliding timeout — - // this is the value used at Login when the session is fresh and the - // hard TTL has its full window remaining. ExtendSession overrides - // MaxAge per-save to min(sliding, remaining-absolute) so the browser - // also drops the cookie at the hard TTL boundary as it approaches. - store := sessions.NewCookieStore([]byte(sessionSecret)) - store.Options = &sessions.Options{ - Path: "/", - MaxAge: int(timeout.Seconds()), - HttpOnly: true, - Secure: true, // Default to secure; SetSecure(false) only for non-TLS dev environments - SameSite: http.SameSiteStrictMode, + wc, err := webcoreauth.NewManager(webcoreauth.ManagerConfig{ + Store: userStore{db: db}, + SessionSecret: sessionSecret, + Timeout: timeout, + AbsoluteTimeout: absoluteTimeout, + Secure: true, // default secure; SetSecure(false) only for non-TLS dev + SessionName: sessionName, + Audit: auditLogger{db: db, logger: logger}, + Logger: logger, + LoginPath: "/login", + DevBypassEnvVar: devBypassEnvVar, + DevBypassEmail: devBypassEmail, + }) + if err != nil { + return nil, err } - - return &Manager{ - db: db, - sessionStore: store, - timeout: timeout, - absoluteTimeout: absoluteTimeout, - logger: logger, - }, nil + return &Manager{wc: wc, db: db, logger: logger}, nil } // SetSecure enables secure cookies (for HTTPS). -func (m *Manager) SetSecure(secure bool) { - m.sessionStore.Options.Secure = secure -} +func (m *Manager) SetSecure(secure bool) { m.wc.SetSecure(secure) } // Login authenticates a user and creates a session. func (m *Manager) Login(w http.ResponseWriter, r *http.Request, email, password string) (*models.User, error) { - // Get user by email - user, err := m.db.GetUserByEmail(email) - if err != nil { - return nil, fmt.Errorf("database error: %w", err) - } - - // Run CheckPassword exactly once on every Login call regardless of - // user state. Without this, "user missing" and "account locked" return - // in microseconds while "exists + wrong password" runs a ~100ms bcrypt - // compare — a timing oracle that defeats the generic error message. - // See 2026-05 audit P2-4 follow-up. - passwordHash := dummyPasswordHash - if user != nil { - passwordHash = user.PasswordHash - } - passwordOK := CheckPassword(password, passwordHash) - - if user == nil { - // Don't reveal that the user doesn't exist - return nil, fmt.Errorf("invalid credentials") - } - - // Check if account is locked. Return the SAME generic error as the - // "user not found" and "wrong password" paths so an attacker can't - // distinguish "this email is locked" (which implies it exists) from - // "this email doesn't exist" via the error message. The lockout itself - // is still enforced — the attacker just doesn't learn it from the - // response. See 2026-05 audit P2-4. - if user.IsLocked() { - // Record the attempt (counts against the window) so an attacker - // can't bypass the rate limit by repeatedly probing a locked - // account with no cost. - if err := m.db.RecordLoginAttempt(user.ID, false); err != nil { - m.logger.Error("failed to record locked-attempt", "error", err, "user_id", user.ID) - } - m.logAudit(r, &user.ID, models.AuditActionLoginFailed, "") - return nil, fmt.Errorf("invalid credentials") - } - - // Check if account is active - if user.Status != models.UserStatusActive { - if user.Status == models.UserStatusPending { - return nil, fmt.Errorf("account is pending approval") - } - return nil, fmt.Errorf("account is disabled") - } - - // Validate password (result computed above for constant-time path) - if !passwordOK { - // Record failed attempt - if err := m.db.RecordLoginAttempt(user.ID, false); err != nil { - m.logger.Error("failed to record login attempt", "error", err, "user_id", user.ID) - } - - // Log the attempt - m.logAudit(r, &user.ID, models.AuditActionLoginFailed, "") - - return nil, fmt.Errorf("invalid credentials") - } - - // Record successful login - if err := m.db.RecordLoginAttempt(user.ID, true); err != nil { - m.logger.Error("failed to record login", "error", err, "user_id", user.ID) - } - - // OWASP 2026: Generate cryptographically secure session token - sessionToken, err := GenerateSessionToken() - if err != nil { - return nil, fmt.Errorf("failed to generate session token: %w", err) - } - - // Store session token in database for server-side validation - // This prevents session fixation and allows immediate invalidation - ip := r.RemoteAddr - userAgent := r.UserAgent() - if err := m.db.SetUserSessionToken(user.ID, sessionToken, ip, userAgent); err != nil { - m.logger.Error("failed to store session token", "error", err, "user_id", user.ID) - return nil, fmt.Errorf("failed to create session: %w", err) - } - - // Create session cookie - session, err := m.sessionStore.Get(r, sessionName) + u, err := m.wc.Login(w, r, email, password) if err != nil { - return nil, fmt.Errorf("failed to get session: %w", err) - } - - // Generate CSRF token - csrfToken, err := GenerateCSRFToken() - if err != nil { - return nil, fmt.Errorf("failed to generate CSRF token: %w", err) - } - - // Store user ID and session token in cookie. sessionLoginKey is the - // sliding-window timestamp that ExtendSession refreshes on each - // request; sessionStartKey is the absolute-start timestamp that is - // NEVER refreshed, so a long-active session still hits the hard TTL - // (2026-05 audit P2-3). - now := time.Now().Unix() - session.Values[sessionUserIDKey] = user.ID - session.Values[sessionTokenKey] = sessionToken // CRITICAL: Validated on every request - session.Values[sessionLoginKey] = now - session.Values[sessionStartKey] = now - session.Values[csrfTokenKey] = csrfToken - - // Save session - if err := session.Save(r, w); err != nil { - return nil, fmt.Errorf("failed to save session: %w", err) + return nil, err } - - // Log the successful login - m.logAudit(r, &user.ID, models.AuditActionLogin, "") - - m.logger.Info("user logged in", "user_id", user.ID, "ip", ip) - - return user, nil + return unwrapUser(u), nil } -// Logout destroys the user session. -// OWASP 2026: Invalidates server-side session token to prevent replay attacks. +// Logout destroys the user session and invalidates the server-side token. func (m *Manager) Logout(w http.ResponseWriter, r *http.Request) error { - // Get current user for audit log - user, _ := m.GetUser(r) - - // CRITICAL SECURITY: Clear session token from database - // This invalidates the session server-side immediately - if user != nil { - if err := m.db.ClearUserSessionToken(user.ID); err != nil { - m.logger.Error("failed to clear session token", "error", err, "user_id", user.ID) - } - m.logAudit(r, &user.ID, models.AuditActionLogout, "") - m.logger.Info("user logged out", "user_id", user.ID) - } - - // Clear client-side cookie - session, err := m.sessionStore.Get(r, sessionName) - if err != nil { - return err - } - - session.Values = make(map[interface{}]interface{}) - session.Options.MaxAge = -1 - - return session.Save(r, w) + return m.wc.Logout(w, r) } -// GetUser retrieves the authenticated user from the session. -// OWASP 2026: Validates session token stored in database to prevent session fixation. +// GetUser retrieves the authenticated user, validating the server-side +// session token and both timeouts. func (m *Manager) GetUser(r *http.Request) (*models.User, error) { - session, err := m.sessionStore.Get(r, sessionName) + u, err := m.wc.GetUser(r) if err != nil { - m.logger.Debug("failed to get session", "error", err) return nil, err } - - // Check if user ID is in session (now string/UUID) - userID, ok := session.Values[sessionUserIDKey].(string) - if !ok || userID == "" { - return nil, fmt.Errorf("not authenticated") - } - - // CRITICAL SECURITY: Validate session token from cookie against database - sessionToken, ok := session.Values[sessionTokenKey].(string) - if !ok || sessionToken == "" { - m.logger.Warn("no session token in cookie", "user_id", userID) - return nil, fmt.Errorf("invalid session: missing token") - } - - // Check sliding idle timeout: time since the most recent extend or login. - loginTime, ok := session.Values[sessionLoginKey].(int64) - if !ok { - return nil, fmt.Errorf("invalid session") - } - if time.Since(time.Unix(loginTime, 0)) > m.timeout { - m.logger.Debug("session expired (idle timeout)", "user_id", userID) - return nil, fmt.Errorf("session expired") - } - - // Check absolute hard TTL: time since the original login. Independent of - // activity, so a heavily-used session still has to re-auth once it hits - // this cap. Zero disables this check (legacy sliding-only behavior). - // See 2026-05 audit P2-3. - if m.absoluteTimeout > 0 { - startUnix, ok := session.Values[sessionStartKey].(int64) - if !ok { - // Older sessions written before this field existed don't have - // sessionStartKey. Treat them as if they just started — they'll - // hit the hard TTL one window later than fresh sessions, but - // the alternative is logging existing users out at deploy time. - startUnix = loginTime - } - if time.Since(time.Unix(startUnix, 0)) > m.absoluteTimeout { - m.logger.Debug("session expired (absolute timeout)", "user_id", userID) - return nil, fmt.Errorf("session expired") - } - } - - // Get user from database - user, err := m.db.GetUserByID(userID) - if err != nil { - m.logger.Error("failed to get user from DB", "error", err, "user_id", userID) - return nil, fmt.Errorf("failed to get user: %w", err) - } - + user := unwrapUser(u) if user == nil { - m.logger.Warn("user not found", "user_id", userID) return nil, fmt.Errorf("user not found") } - - // CRITICAL SECURITY: Validate session token against database - // This prevents old cookies from working after DB wipe or logout - valid, err := m.db.ValidateSessionToken(userID, sessionToken) - if err != nil { - m.logger.Error("session token validation error", "error", err, "user_id", userID) - return nil, fmt.Errorf("session validation failed") - } - - if !valid { - m.logger.Warn("session token invalid in DB", "user_id", userID) - return nil, fmt.Errorf("session invalid: please log in again") - } - - // Verify user is still active - if user.Status != models.UserStatusActive { - m.logger.Warn("user not active", "user_id", userID, "status", user.Status) - return nil, fmt.Errorf("account is no longer active") - } - return user, nil } // GetCSRFToken retrieves the CSRF token from the session. -func (m *Manager) GetCSRFToken(r *http.Request) (string, error) { - session, err := m.sessionStore.Get(r, sessionName) - if err != nil { - return "", err - } - - token, ok := session.Values[csrfTokenKey].(string) - if !ok || token == "" { - return "", fmt.Errorf("no CSRF token in session") - } - - return token, nil -} - -// ValidateCSRFToken validates a CSRF token from the request. -func (m *Manager) ValidateCSRFToken(r *http.Request) error { - // Get token from session - sessionToken, err := m.GetCSRFToken(r) - if err != nil { - return fmt.Errorf("failed to get session CSRF token: %w", err) - } +func (m *Manager) GetCSRFToken(r *http.Request) (string, error) { return m.wc.GetCSRFToken(r) } - // Get token from request (header or form) - requestToken := r.Header.Get("X-CSRF-Token") - if requestToken == "" { - requestToken = r.FormValue("csrf_token") - } - - if requestToken == "" { - return fmt.Errorf("no CSRF token in request") - } - - // Constant-time compare so that a network observer can't recover the - // session-bound CSRF token byte by byte from response-timing variance. - // The values are both 256-bit randoms generated via crypto/rand, so the - // practical risk on a non-pathological network was always low — but - // every other secret comparison in this codebase uses subtle, and CSRF - // should match (2026-05 audit P2-1). - if subtle.ConstantTimeCompare([]byte(requestToken), []byte(sessionToken)) != 1 { - return fmt.Errorf("CSRF token mismatch") - } - - return nil -} +// ValidateCSRFToken validates a CSRF token from the request (header or form) +// in constant time. +func (m *Manager) ValidateCSRFToken(r *http.Request) error { return m.wc.ValidateCSRFToken(r) } // IsAuthenticated checks if the request has a valid session. -func (m *Manager) IsAuthenticated(r *http.Request) bool { - _, err := m.GetUser(r) - return err == nil -} +func (m *Manager) IsAuthenticated(r *http.Request) bool { return m.wc.IsAuthenticated(r) } -// ExtendSession refreshes the session login time to prevent timeout. -// -// Two extra behaviors on top of the simple "bump sessionLoginKey" path, -// both addressing follow-up review on the 2026-05 audit P2-3 fix: -// -// 1. **Anchor legacy sessions.** If a session predates this commit it -// won't have sessionStartKey set. Without anchoring, GetUser's fallback -// would compare against the freshly-refreshed sessionLoginKey forever -// and the absolute hard TTL would never fire. We capture the CURRENT -// (pre-refresh) sessionLoginKey as the anchor, giving the legacy -// session one full absoluteTimeout window from now — then it -// re-authenticates like everyone else. -// -// 2. **Shrink the cookie MaxAge as the hard TTL approaches.** Every Save -// re-emits the Set-Cookie header. If we kept the store's default -// MaxAge, the browser would see "this cookie is good for sliding- -// timeout from right now" on every request, and the cookie would -// never reflect the server-side hard TTL. Per-save we set the cookie -// MaxAge to min(sliding-timeout, remaining-absolute-TTL) so the -// browser ALSO drops the cookie at the absolute boundary. The -// override goes on a copied Options struct so we don't mutate the -// shared store-wide options. +// ExtendSession refreshes the sliding window (never the absolute TTL). func (m *Manager) ExtendSession(w http.ResponseWriter, r *http.Request) error { - session, err := m.sessionStore.Get(r, sessionName) - if err != nil { - return err - } - - // Only extend if user is logged in (userID is now string/UUID, not int64) - userID, ok := session.Values[sessionUserIDKey].(string) - if !ok || userID == "" { - return fmt.Errorf("not authenticated") - } - - // (1) Anchor legacy sessions. Set sessionStartKey to the CURRENT - // sessionLoginKey before we overwrite it below. Idempotent for - // already-anchored sessions (we only set it when absent). - if _, anchored := session.Values[sessionStartKey].(int64); !anchored { - if loginTime, ok := session.Values[sessionLoginKey].(int64); ok { - session.Values[sessionStartKey] = loginTime - } - } - - // (2) Override per-save MaxAge to track the hard TTL. Default is the - // sliding timeout; if we're inside the absolute TTL window, use - // whichever is smaller. - if m.absoluteTimeout > 0 { - if startUnix, ok := session.Values[sessionStartKey].(int64); ok { - remaining := time.Until(time.Unix(startUnix, 0).Add(m.absoluteTimeout)) - if remaining <= 0 { - // Past the hard TTL — don't extend. Caller path will fail - // on the next GetUser and force re-auth. - return fmt.Errorf("session past absolute timeout") - } - maxAge := m.timeout - if remaining < maxAge { - maxAge = remaining - } - // Copy Options so we don't mutate the shared store struct - // (which is the cookie-default applied to every other session). - opts := *m.sessionStore.Options - opts.MaxAge = int(maxAge.Seconds()) - session.Options = &opts - } - } - - // Update login time to now (slides the idle window forward). - session.Values[sessionLoginKey] = time.Now().Unix() - - return session.Save(r, w) + return m.wc.ExtendSession(w, r) } -// GetSessionExpirationTime returns the time when the current session will expire. +// GetSessionExpirationTime returns when the current session's sliding window +// expires. func (m *Manager) GetSessionExpirationTime(r *http.Request) (time.Time, error) { - session, err := m.sessionStore.Get(r, sessionName) - if err != nil { - return time.Time{}, err - } - - // Check if user is logged in (userID is now string/UUID, not int64) - userID, ok := session.Values[sessionUserIDKey].(string) - if !ok || userID == "" { - return time.Time{}, fmt.Errorf("not authenticated") - } - - // Get login time - loginTime, ok := session.Values[sessionLoginKey].(int64) - if !ok { - return time.Time{}, fmt.Errorf("invalid session") - } - - // Calculate expiration time - expiresAt := time.Unix(loginTime, 0).Add(m.timeout) - return expiresAt, nil + return m.wc.GetSessionExpirationTime(r) } -// ChangePassword changes a user's password. -func (m *Manager) ChangePassword(r *http.Request, userID string, currentPassword, newPassword string) error { - // Get user - user, err := m.db.GetUserByID(userID) - if err != nil { - return fmt.Errorf("failed to get user: %w", err) - } - if user == nil { - return fmt.Errorf("user not found") - } - - // Verify current password - if !CheckPassword(currentPassword, user.PasswordHash) { - return fmt.Errorf("current password is incorrect") - } - - // Validate new password - if err := ValidatePassword(newPassword); err != nil { - return err - } - - // Check that new password is different from current - if CheckPassword(newPassword, user.PasswordHash) { - return fmt.Errorf("new password must be different from current password") - } - - // Hash new password - hash, err := HashPassword(newPassword) - if err != nil { - return fmt.Errorf("failed to hash password: %w", err) - } - - // Update password - if err := m.db.UpdateUserPassword(userID, hash, false); err != nil { - return fmt.Errorf("failed to update password: %w", err) - } - - // OWASP 2026: Invalidate session on password change (force re-authentication) - if err := m.db.ClearUserSessionToken(userID); err != nil { - m.logger.Error("failed to clear session after password change", "error", err) - } - - // Log the change - m.logAudit(r, &userID, models.AuditActionPasswordChange, "") - - return nil +// ChangePassword changes a user's password after verifying the current one, +// invalidating the session server-side. +func (m *Manager) ChangePassword(r *http.Request, userID, currentPassword, newPassword string) error { + return m.wc.ChangePassword(r, userID, currentPassword, newPassword) } // SetPassword sets a user's password (used during forced password change). -func (m *Manager) SetPassword(r *http.Request, userID string, newPassword string) error { - // Validate new password - if err := ValidatePassword(newPassword); err != nil { - return err - } - - // Hash new password - hash, err := HashPassword(newPassword) - if err != nil { - return fmt.Errorf("failed to hash password: %w", err) - } - - // Update password - if err := m.db.UpdateUserPassword(userID, hash, false); err != nil { - return fmt.Errorf("failed to update password: %w", err) - } - - // Log the change - m.logAudit(r, &userID, models.AuditActionPasswordChange, "forced password change") - - return nil +func (m *Manager) SetPassword(r *http.Request, userID, newPassword string) error { + return m.wc.SetPassword(r, userID, newPassword) } -// SetPasswordAndEmail sets a user's password and email (used during initial admin setup). -func (m *Manager) SetPasswordAndEmail(r *http.Request, userID string, newPassword, newEmail string) error { - // Validate new password +// SetPasswordAndEmail sets a user's password and email (initial admin setup). +// Gearbox-specific: webcore has no combined update, and splitting it would +// leave a window where one write lands without the other. +func (m *Manager) SetPasswordAndEmail(r *http.Request, userID, newPassword, newEmail string) error { if err := ValidatePassword(newPassword); err != nil { return err } - - // Hash new password hash, err := HashPassword(newPassword) if err != nil { return fmt.Errorf("failed to hash password: %w", err) } - - // Update password and email if err := m.db.UpdateUserPasswordAndEmail(userID, hash, newEmail); err != nil { return fmt.Errorf("failed to update password and email: %w", err) } - - // Log the change - m.logAudit(r, &userID, models.AuditActionPasswordChange, "initial account setup with email update") - + m.LogAudit(r, &userID, models.AuditActionPasswordChange, "initial account setup with email update") return nil } -// RequestPasswordReset initiates a password reset. +// RequestPasswordReset initiates a password reset. Returns ("", nil, nil) +// for unknown emails so callers can't reveal account existence. func (m *Manager) RequestPasswordReset(email string) (string, *models.User, error) { - user, err := m.db.GetUserByEmail(email) - if err != nil { + token, u, err := m.wc.RequestPasswordReset(email) + if err != nil || u == nil { return "", nil, err } - if user == nil { - // Don't reveal that the user doesn't exist - return "", nil, nil - } - - // Generate reset token - token, err := GenerateSecureToken(32) - if err != nil { - return "", nil, fmt.Errorf("failed to generate reset token: %w", err) - } - - // Set expiry (1 hour) - expiresAt := time.Now().Add(1 * time.Hour) - - // Save token - if err := m.db.SetPasswordResetToken(user.ID, token, expiresAt); err != nil { - return "", nil, fmt.Errorf("failed to save reset token: %w", err) - } - - return token, user, nil + return token, unwrapUser(u), nil } -// ResetPassword completes a password reset. +// ResetPassword completes a password reset via token. func (m *Manager) ResetPassword(r *http.Request, token, newPassword string) error { - // Get user by token - user, err := m.db.GetUserByResetToken(token) - if err != nil { - return fmt.Errorf("database error: %w", err) - } - if user == nil { - return fmt.Errorf("invalid or expired reset token") - } - - // Validate new password - if err := ValidatePassword(newPassword); err != nil { - return err - } - - // Hash new password - hash, err := HashPassword(newPassword) - if err != nil { - return fmt.Errorf("failed to hash password: %w", err) - } - - // Update password and clear token - if err := m.db.UpdateUserPassword(user.ID, hash, false); err != nil { - return fmt.Errorf("failed to update password: %w", err) - } - - // Log the reset - m.logAudit(r, &user.ID, models.AuditActionPasswordReset, "via reset link") + return m.wc.ResetPassword(r, token, newPassword) +} - return nil +// CreateSessionForUser creates a session without password validation (used +// for passkey login where the user is already verified). +func (m *Manager) CreateSessionForUser(w http.ResponseWriter, r *http.Request, user *models.User) error { + return m.wc.CreateSessionForUser(w, r, authUser{user}) } // GetDB returns the database instance. -func (m *Manager) GetDB() *database.DB { - return m.db -} +func (m *Manager) GetDB() *database.DB { return m.db } -// GetUserPermissions retrieves permissions for the current user. -// -// Prefers the user placed in the request context by RequireAuth (which is -// also where the dev-loopback bypass injects its synthetic user). Falls back -// to the session-cookie lookup so callers that build a request without the -// auth middleware in front of them — e.g. SSE setup, tests — still resolve. +// GetUserPermissions retrieves permissions for the current user. Prefers the +// user placed in the request context by RequireAuth; falls back to the +// session lookup for callers without the middleware in front of them (SSE +// setup, tests). func (m *Manager) GetUserPermissions(r *http.Request) (*models.UserPermissions, error) { if user, ok := GetUserFromContext(r.Context()); ok && user != nil { return m.db.GetUserPermissions(user.ID) } - user, err := m.GetUser(r) if err != nil { return nil, err } - return m.db.GetUserPermissions(user.ID) } @@ -663,7 +189,6 @@ func (m *Manager) HasPermission(r *http.Request, component models.Component, per m.logger.Debug("GetUserPermissions error", "error", err) return false } - result := perms.HasPermission(component, permission) m.logger.Debug("permission check", "user_id", perms.UserID, @@ -674,7 +199,8 @@ func (m *Manager) HasPermission(r *http.Request, component models.Component, per return result } -// RequirePermission returns an error if the user doesn't have the required permission. +// RequirePermission returns an error if the user doesn't have the required +// permission. func (m *Manager) RequirePermission(r *http.Request, component models.Component, permission models.Permission) error { if !m.HasPermission(r, component, permission) { return fmt.Errorf("permission denied: %s on %s", permission, component) @@ -682,146 +208,7 @@ func (m *Manager) RequirePermission(r *http.Request, component models.Component, return nil } -// CreateSessionForUser creates a session for a user without password validation. -// This is used for passkey authentication where the user has already been verified. -func (m *Manager) CreateSessionForUser(w http.ResponseWriter, r *http.Request, user *models.User) error { - // Generate cryptographically secure session token - sessionToken, err := GenerateSessionToken() - if err != nil { - return fmt.Errorf("failed to generate session token: %w", err) - } - - // Store session token in database for server-side validation - ip := r.RemoteAddr - userAgent := r.UserAgent() - if err := m.db.SetUserSessionToken(user.ID, sessionToken, ip, userAgent); err != nil { - m.logger.Error("failed to store session token", "error", err, "user_id", user.ID) - return fmt.Errorf("failed to create session: %w", err) - } - - // Create session cookie - session, err := m.sessionStore.Get(r, sessionName) - if err != nil { - return fmt.Errorf("failed to get session: %w", err) - } - - // Generate CSRF token - csrfToken, err := GenerateCSRFToken() - if err != nil { - return fmt.Errorf("failed to generate CSRF token: %w", err) - } - - // Store user ID and session token in cookie. sessionLoginKey is the - // sliding-window timestamp that ExtendSession refreshes on each - // request; sessionStartKey is the absolute-start timestamp that is - // NEVER refreshed, so a long-active session still hits the hard TTL - // (2026-05 audit P2-3). - now := time.Now().Unix() - session.Values[sessionUserIDKey] = user.ID - session.Values[sessionTokenKey] = sessionToken // CRITICAL: Validated on every request - session.Values[sessionLoginKey] = now - session.Values[sessionStartKey] = now - session.Values[csrfTokenKey] = csrfToken - - // Save session - if err := session.Save(r, w); err != nil { - m.logger.Error("failed to save session cookie", "error", err) - return fmt.Errorf("failed to save session: %w", err) - } - - m.logger.Info("session created for user", "user_id", user.ID, "ip", ip) - return nil -} - // LogAudit creates an audit log entry. func (m *Manager) LogAudit(r *http.Request, userID *string, action, details string) { - m.logAudit(r, userID, action, details) -} - -func (m *Manager) logAudit(r *http.Request, userID *string, action, details string) { - log := &models.AuditLog{ - UserID: userID, - Action: action, - Details: details, - IPAddress: getClientIP(r), - UserAgent: r.UserAgent(), - } - - if err := m.db.CreateAuditLog(log); err != nil { - m.logger.Error("failed to create audit log", - "error", err, - "action", action, - "details", details) - } -} - -// generateToken generates a random token for CSRF protection. -func generateToken() (string, error) { - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - return "", err - } - return base64.URLEncoding.EncodeToString(b), nil -} - -// getClientIP extracts the client IP from the request. -func getClientIP(r *http.Request) string { - // Check X-Forwarded-For header first (for reverse proxy) - xff := r.Header.Get("X-Forwarded-For") - if xff != "" { - // Take the first IP in the chain - ips := splitAndTrim(xff, ",") - if len(ips) > 0 { - return ips[0] - } - } - - // Check X-Real-IP header - xri := r.Header.Get("X-Real-IP") - if xri != "" { - return xri - } - - // Fall back to RemoteAddr - return r.RemoteAddr -} - -func splitAndTrim(s, sep string) []string { - parts := make([]string, 0) - for _, part := range splitString(s, sep) { - trimmed := trimString(part) - if trimmed != "" { - parts = append(parts, trimmed) - } - } - return parts -} - -func splitString(s, sep string) []string { - if sep == "" { - return []string{s} - } - result := make([]string, 0) - start := 0 - for i := 0; i < len(s); i++ { - if i+len(sep) <= len(s) && s[i:i+len(sep)] == sep { - result = append(result, s[start:i]) - start = i + len(sep) - i += len(sep) - 1 - } - } - result = append(result, s[start:]) - return result -} - -func trimString(s string) string { - start := 0 - end := len(s) - for start < end && (s[start] == ' ' || s[start] == '\t' || s[start] == '\n' || s[start] == '\r') { - start++ - } - for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\n' || s[end-1] == '\r') { - end-- - } - return s[start:end] + auditLogger{db: m.db, logger: m.logger}.LogAudit(r, userID, action, details) } diff --git a/gearbox/internal/framework/auth/auth_test.go b/gearbox/internal/framework/auth/auth_test.go index 184ea6a..84a17a0 100644 --- a/gearbox/internal/framework/auth/auth_test.go +++ b/gearbox/internal/framework/auth/auth_test.go @@ -2,6 +2,7 @@ package auth import ( "log/slog" + "net/http" "net/http/httptest" "os" "strings" @@ -115,139 +116,13 @@ func TestNewManager(t *testing.T) { } } -// 2026-05 audit P2-3: a session that's been kept warm by activity must -// still be expired once its absolute lifetime passes. This test mirrors -// the existing TestManager_Login_Success pattern, then rewinds the -// session-start timestamp in the cookie store and verifies that GetUser -// rejects the request despite the sliding-window timestamp being fresh. -func TestManager_GetUser_AbsoluteTimeout(t *testing.T) { - manager, db, cleanup := setupTestManager(t) - defer cleanup() - // Override timeouts after construction: a generous 24h sliding window - // but a 1-minute hard TTL. The sliding window never trips here, so a - // failure must be the hard TTL doing its job. - manager.timeout = 24 * time.Hour - manager.absoluteTimeout = 1 * time.Minute - - passwordHash, err := HashPassword("correct_password") - if err != nil { - t.Fatalf("Failed to hash password: %v", err) - } - if _, _, err := db.EnsureAdminExists(passwordHash, false); err != nil { - t.Fatalf("Failed to create admin: %v", err) - } - - // Log in and capture the session cookie. - loginReq := httptest.NewRequest("GET", "/login", nil) - loginResp := httptest.NewRecorder() - if _, err := manager.Login(loginResp, loginReq, "admin", "correct_password"); err != nil { - t.Fatalf("Login failed: %v", err) - } - if len(loginResp.Result().Cookies()) == 0 { - t.Fatal("Login produced no cookie") - } - cookie := loginResp.Result().Cookies()[0] - - // Sanity: GetUser succeeds right after login. - check := httptest.NewRequest("GET", "/", nil) - check.AddCookie(cookie) - if _, err := manager.GetUser(check); err != nil { - t.Fatalf("GetUser right after login failed: %v", err) - } - - // Rewind sessionStartKey to 2 minutes ago (past the 1-minute hard TTL) - // while keeping the sliding-window timestamp fresh. - rewind := httptest.NewRequest("GET", "/", nil) - rewind.AddCookie(cookie) - sess, err := manager.sessionStore.Get(rewind, sessionName) - if err != nil { - t.Fatalf("could not read session: %v", err) - } - sess.Values[sessionStartKey] = time.Now().Add(-2 * time.Minute).Unix() - sess.Values[sessionLoginKey] = time.Now().Unix() - rewindResp := httptest.NewRecorder() - if err := sess.Save(rewind, rewindResp); err != nil { - t.Fatalf("could not save rewound session: %v", err) - } - expired := rewindResp.Result().Cookies()[0] - - expiredReq := httptest.NewRequest("GET", "/", nil) - expiredReq.AddCookie(expired) - if _, err := manager.GetUser(expiredReq); err == nil { - t.Errorf("GetUser returned no error past the absolute hard TTL; want an error") - } -} - -// Follow-up to P2-3 (PR #50 Copilot review): a legacy session that -// pre-dates the sessionStartKey field must get anchored on the next -// ExtendSession call, not slide forever via the loginTime fallback. -func TestManager_ExtendSession_AnchorsLegacySession(t *testing.T) { - manager, db, cleanup := setupTestManager(t) - defer cleanup() - manager.timeout = 24 * time.Hour - manager.absoluteTimeout = 1 * time.Hour - - passwordHash, err := HashPassword("correct_password") - if err != nil { - t.Fatalf("HashPassword: %v", err) - } - if _, _, err := db.EnsureAdminExists(passwordHash, false); err != nil { - t.Fatalf("EnsureAdminExists: %v", err) - } - - // Log in to get a real cookie, then strip the sessionStartKey to - // simulate a legacy session. - loginReq := httptest.NewRequest("GET", "/login", nil) - loginResp := httptest.NewRecorder() - if _, err := manager.Login(loginResp, loginReq, "admin", "correct_password"); err != nil { - t.Fatalf("Login: %v", err) - } - cookie := loginResp.Result().Cookies()[0] - - stripReq := httptest.NewRequest("GET", "/", nil) - stripReq.AddCookie(cookie) - sess, err := manager.sessionStore.Get(stripReq, sessionName) - if err != nil { - t.Fatalf("read session: %v", err) - } - delete(sess.Values, sessionStartKey) - stripResp := httptest.NewRecorder() - if err := sess.Save(stripReq, stripResp); err != nil { - t.Fatalf("save stripped session: %v", err) - } - legacyCookie := stripResp.Result().Cookies()[0] - - // First ExtendSession on the legacy cookie must anchor sessionStartKey. - extendReq := httptest.NewRequest("POST", "/", nil) - extendReq.AddCookie(legacyCookie) - extendResp := httptest.NewRecorder() - if err := manager.ExtendSession(extendResp, extendReq); err != nil { - t.Fatalf("ExtendSession: %v", err) - } - anchoredCookie := extendResp.Result().Cookies()[0] - - // Read back: sessionStartKey must now be present. - readReq := httptest.NewRequest("GET", "/", nil) - readReq.AddCookie(anchoredCookie) - anchored, err := manager.sessionStore.Get(readReq, sessionName) - if err != nil { - t.Fatalf("read anchored session: %v", err) - } - if _, ok := anchored.Values[sessionStartKey].(int64); !ok { - t.Errorf("sessionStartKey missing after ExtendSession; legacy session is unanchored") - } -} +// NOTE: the absolute-timeout, legacy-session-anchoring, and cookie-MaxAge +// tests moved to webcore/core/auth (manager_timeout_test.go) along with the +// session implementation they poke at. -// Follow-up to P2-3 (PR #50 Copilot review): the per-save cookie MaxAge -// shrinks as the hard TTL approaches, so the browser drops the cookie -// at the absolute boundary instead of after each save's sliding window. -func TestManager_ExtendSession_CookieMaxAgeShrinks(t *testing.T) { +func TestManager_SetSecure(t *testing.T) { manager, db, cleanup := setupTestManager(t) defer cleanup() - // Sliding window 1h, hard TTL 1h (degenerate but unambiguous: any - // remaining absolute < 1h must cap MaxAge below 1h). - manager.timeout = 1 * time.Hour - manager.absoluteTimeout = 1 * time.Hour passwordHash, err := HashPassword("correct_password") if err != nil { @@ -257,57 +132,24 @@ func TestManager_ExtendSession_CookieMaxAgeShrinks(t *testing.T) { t.Fatalf("EnsureAdminExists: %v", err) } - loginReq := httptest.NewRequest("GET", "/login", nil) - loginResp := httptest.NewRecorder() - if _, err := manager.Login(loginResp, loginReq, "admin", "correct_password"); err != nil { - t.Fatalf("Login: %v", err) - } - cookie := loginResp.Result().Cookies()[0] - - // Rewind sessionStartKey to 30 minutes ago: 30m left on the hard TTL. - rewind := httptest.NewRequest("GET", "/", nil) - rewind.AddCookie(cookie) - sess, err := manager.sessionStore.Get(rewind, sessionName) - if err != nil { - t.Fatalf("read session: %v", err) - } - sess.Values[sessionStartKey] = time.Now().Add(-30 * time.Minute).Unix() - rewindResp := httptest.NewRecorder() - if err := sess.Save(rewind, rewindResp); err != nil { - t.Fatalf("save rewound session: %v", err) - } - midCookie := rewindResp.Result().Cookies()[0] - - // ExtendSession should now emit a Set-Cookie with MaxAge ~30 minutes - // (the remaining absolute), NOT the 1h sliding window. - extendReq := httptest.NewRequest("POST", "/", nil) - extendReq.AddCookie(midCookie) - extendResp := httptest.NewRecorder() - if err := manager.ExtendSession(extendResp, extendReq); err != nil { - t.Fatalf("ExtendSession: %v", err) - } - out := extendResp.Result().Cookies()[0] - - // MaxAge in seconds; allow a 10-second jitter for test execution time. - if out.MaxAge < 1700 || out.MaxAge > 1810 { // 28m20s..30m10s - t.Errorf("Set-Cookie MaxAge=%d, want ~1800 (= remaining absolute TTL); should not be 3600 (= sliding)", out.MaxAge) + // Behavioral check (the cookie store moved into webcore): the Secure + // attribute on the session Set-Cookie must track SetSecure. + login := func() *http.Cookie { + req := httptest.NewRequest("POST", "/login", nil) + w := httptest.NewRecorder() + if _, err := manager.Login(w, req, "admin", "correct_password"); err != nil { + t.Fatalf("Login: %v", err) + } + return w.Result().Cookies()[0] } -} -func TestManager_SetSecure(t *testing.T) { - manager, _, cleanup := setupTestManager(t) - defer cleanup() - - // Test setting secure to true manager.SetSecure(true) - if !manager.sessionStore.Options.Secure { - t.Error("SetSecure(true) did not set Secure option") + if !login().Secure { + t.Error("SetSecure(true): session cookie not marked Secure") } - - // Test setting secure to false manager.SetSecure(false) - if manager.sessionStore.Options.Secure { - t.Error("SetSecure(false) did not unset Secure option") + if login().Secure { + t.Error("SetSecure(false): session cookie still marked Secure") } } @@ -441,24 +283,24 @@ func TestManager_IsAuthenticated(t *testing.T) { func TestGenerateToken(t *testing.T) { // Generate multiple tokens - token1, err := generateToken() + token1, err := GenerateCSRFToken() if err != nil { - t.Fatalf("generateToken() error = %v", err) + t.Fatalf("GenerateCSRFToken() error = %v", err) } - token2, err := generateToken() + token2, err := GenerateCSRFToken() if err != nil { - t.Fatalf("generateToken() error = %v", err) + t.Fatalf("GenerateCSRFToken() error = %v", err) } // Verify tokens are not empty if token1 == "" { - t.Error("generateToken() returned empty token") + t.Error("GenerateCSRFToken() returned empty token") } // Verify tokens are unique if token1 == token2 { - t.Error("generateToken() returned duplicate tokens") + t.Error("GenerateCSRFToken() returned duplicate tokens") } // Verify token is base64 encoded (should not contain invalid characters) diff --git a/gearbox/internal/framework/auth/dev_bypass_off.go b/gearbox/internal/framework/auth/dev_bypass_off.go index 508cbfc..6b439b5 100644 --- a/gearbox/internal/framework/auth/dev_bypass_off.go +++ b/gearbox/internal/framework/auth/dev_bypass_off.go @@ -1,24 +1,17 @@ //go:build !dev -// Production sibling to dev_bypass_on.go. Compiled in for every build -// that does NOT specify `-tags dev`. All entry points are no-ops, so the -// dev auto-login bypass is not present in the resulting binary at all — -// no codepath, no env-var check, no loopback check, nothing to exploit. +// Production siblings to dev_bypass_on.go — no-ops, so no dev-login codepath +// exists in release binaries. The webcore-side bypass is likewise compiled +// out without `-tags dev`. package auth import ( "log/slog" - "net/http" "github.com/sarg3nt/gearbox/internal/framework/database" - "github.com/sarg3nt/gearbox/internal/framework/models" ) -func tryDevBypass(_ *Manager, _ *http.Request) (*models.User, bool) { - return nil, false -} - func SeedDevUserIfEnabled(_ *database.DB, _ *slog.Logger) error { return nil } func LogDevBypassStartupBanner(_ *slog.Logger) {} diff --git a/gearbox/internal/framework/auth/dev_bypass_on.go b/gearbox/internal/framework/auth/dev_bypass_on.go index a604298..b6d55f3 100644 --- a/gearbox/internal/framework/auth/dev_bypass_on.go +++ b/gearbox/internal/framework/auth/dev_bypass_on.go @@ -1,120 +1,57 @@ //go:build dev -// Dev-only loopback auto-login bypass. Compiled in only when the binary -// is built with `-tags dev`. The tag is set by the `dev:` target in -// gearbox/Makefile via `air --build.cmd "$(DEV_BUILD_CMD)"`; the -// production build paths (`make build`, `make deploy-build`) deliberately -// omit it, so this file and its symbols are not in release binaries at -// all. See issue #83. +// Dev-only support for the loopback auto-login bypass. The bypass itself is +// implemented in webcore/core/auth (compiled in only with `-tags dev`, enabled +// only when GEARBOX_DEV_AUTO_LOGIN=1 and the request is loopback); this file +// keeps the gearbox-side pieces: seeding the `dev` account and the startup +// banner. Production builds compile the no-op siblings in dev_bypass_off.go. package auth import ( "log/slog" - "net" - "net/http" "os" "sync" "github.com/sarg3nt/gearbox/internal/framework/database" - "github.com/sarg3nt/gearbox/internal/framework/models" -) - -const ( - // devBypassEnvVar gates whether the bypass is allowed to fire even - // when the binary was built with `-tags dev`. Set to "1" to enable. - devBypassEnvVar = "GEARBOX_DEV_AUTO_LOGIN" - - // devBypassEmail is the email/username of the seeded dev account that - // the bypass auto-authenticates as. The account must exist (and be - // active) in the users table; the bypass never creates sessions or - // auto-promotes a non-existent user. - devBypassEmail = "dev" ) var devBypassBannerOnce sync.Once -// tryDevBypass returns the seeded `dev` user when ALL of these hold: -// -// 1. The binary was built with `-tags dev` (gearbox/Makefile's `dev:` -// target adds the tag via `air --build.cmd "$(DEV_BUILD_CMD)"`; this -// file is compiled in. The production sibling dev_bypass_off.go is -// compiled in for tag-less builds and provides a no-op stub.). -// 2. GEARBOX_DEV_AUTO_LOGIN=1 is set in the process environment. -// 3. r.RemoteAddr is a loopback address (127.0.0.0/8 or ::1). -// -// In production builds the bypass never enters the binary at all — the -// sibling dev_bypass_off.go provides a hard-coded `nil, false` stub. -func tryDevBypass(m *Manager, r *http.Request) (*models.User, bool) { - if os.Getenv(devBypassEnvVar) != "1" { - return nil, false - } - if !requestIsLoopback(r) { - return nil, false - } - user, err := m.db.GetUserByEmail(devBypassEmail) - if err != nil || user == nil { - m.logger.Warn("dev auto-login: dev user missing from database; bypass inactive", - "user", devBypassEmail, "error", err) - return nil, false - } - if user.Status != models.UserStatusActive { - m.logger.Warn("dev auto-login: dev user is not active; bypass inactive", - "status", user.Status) - return nil, false - } - return user, true -} - -// requestIsLoopback reports whether r.RemoteAddr resolves to a loopback IP. -// chi.middleware.RealIP rewrites RemoteAddr from X-Forwarded-For / X-Real-IP, -// so a proxy between the browser and gearbox would surface the proxy's IP -// here, not the browser's — that's the intended behavior: requests routed -// through any proxy aren't loopback and the bypass declines. -func requestIsLoopback(r *http.Request) bool { - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - host = r.RemoteAddr - } - ip := net.ParseIP(host) - if ip == nil { - return false - } - return ip.IsLoopback() -} - // SeedDevUserIfEnabled creates the `dev` user used by the loopback bypass -// when GEARBOX_DEV_AUTO_LOGIN=1. The user is given an unusable bcrypt hash -// (the package-level dummyPasswordHash) so the form-login path can never -// authenticate as it — only the loopback bypass can. -// -// Production builds replace this with a no-op (dev_bypass_off.go). +// when GEARBOX_DEV_AUTO_LOGIN=1. The account gets an unusable random-password +// bcrypt hash so the form-login path can never authenticate as it — only the +// loopback bypass can. func SeedDevUserIfEnabled(db *database.DB, logger *slog.Logger) error { if os.Getenv(devBypassEnvVar) != "1" { return nil } - created, err := db.EnsureDevUserExists(devBypassEmail, dummyPasswordHash) + // An unusable-but-valid hash: random 24-char password, immediately + // discarded. CheckPassword against it can only succeed by guessing the + // discarded random value. + pw, err := GenerateRandomPassword() + if err != nil { + return err + } + hash, err := HashPassword(pw) + if err != nil { + return err + } + created, err := db.EnsureDevUserExists(devBypassEmail, hash) if err != nil { return err } if created { - logger.Info("dev auto-login: seeded `dev` user for loopback bypass", - "email", devBypassEmail) + logger.Info("dev auto-login: seeded `dev` user for loopback bypass", "email", devBypassEmail) } return nil } -// LogDevBypassStartupBanner emits a loud warning at startup whenever the -// dev auto-login bypass is potentially active in the running process. -// Designed to be impossible to miss in logs so an operator never confuses -// a dev binary for a production one. -// -// Production builds replace this with a no-op (dev_bypass_off.go) — the -// production build targets in gearbox/Makefile (`build`, `deploy-build`) -// omit `-tags dev`, so this banner cannot fire on a release artifact. +// LogDevBypassStartupBanner emits a loud warning at startup whenever the dev +// auto-login bypass is potentially active in the running process. func LogDevBypassStartupBanner(logger *slog.Logger) { if os.Getenv(devBypassEnvVar) != "1" { - logger.Info("dev auto-login: bypass compiled in (`-tags dev`) but disabled — set GEARBOX_DEV_AUTO_LOGIN=1 to enable") + logger.Info("dev auto-login: bypass compiled in (`-tags dev`) but disabled — set " + devBypassEnvVar + "=1 to enable") return } devBypassBannerOnce.Do(func() { diff --git a/gearbox/internal/framework/auth/middleware.go b/gearbox/internal/framework/auth/middleware.go index b693eac..7b08cda 100644 --- a/gearbox/internal/framework/auth/middleware.go +++ b/gearbox/internal/framework/auth/middleware.go @@ -3,9 +3,9 @@ package auth import ( "context" "net/http" - "net/url" "github.com/sarg3nt/gearbox/internal/framework/models" + webcoreauth "github.com/sarg3nt/webcore/core/auth" ) type contextKey string @@ -24,98 +24,37 @@ type SidebarIntegration struct { SortOrder int } -// RequireAuth is middleware that requires authentication. +// RequireAuth is middleware that requires authentication. It delegates to +// webcore's RequireAuth (which owns the session validation, the /login +// redirect with return URL, and the dev-only loopback bypass in `-tags dev` +// builds), then re-maps the authenticated user from webcore's context slot +// into gearbox's as a concrete *models.User for the 60+ GetUserFromContext +// call sites. func (m *Manager) RequireAuth(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Dev-only loopback bypass — short-circuits the session check when - // the binary is built with `-tags dev`, GEARBOX_DEV_AUTO_LOGIN=1, - // and the request originates from a loopback IP. In production - // builds tryDevBypass is a hard-coded `nil, false` stub (see - // dev_bypass_off.go); no codepath exists to enable the bypass. - if devUser, ok := tryDevBypass(m, r); ok { - ctx := context.WithValue(r.Context(), userContextKey, devUser) - next.ServeHTTP(w, r.WithContext(ctx)) - return - } - - user, err := m.GetUser(r) - if err != nil { - // Not authenticated, redirect to login with return URL - returnURL := r.URL.Path - if r.URL.RawQuery != "" { - returnURL += "?" + r.URL.RawQuery + remap := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if wcUser, ok := webcoreauth.GetUserFromContext(r.Context()); ok { + if user := unwrapUser(wcUser); user != nil { + r = r.WithContext(context.WithValue(r.Context(), userContextKey, user)) } - - // Construct redirect URL with return parameter - // Note: Don't show "session expired" message here - on first visit there's no session - // Messages are handled by specific handlers (logout, etc.) - redirectURL := "/login" - if returnURL != "/" && returnURL != "" { - redirectURL += "?return=" + url.QueryEscape(returnURL) - } - - http.Redirect(w, r, redirectURL, http.StatusSeeOther) - return } - - // Add user to request context - ctx := context.WithValue(r.Context(), userContextKey, user) - next.ServeHTTP(w, r.WithContext(ctx)) + next.ServeHTTP(w, r) }) + return m.wc.RequireAuth(remap) } -// RequirePasswordChange is middleware that enforces password change before allowing any other action. -// This MUST be placed after RequireAuth in the middleware chain. +// RequirePasswordChange is middleware that enforces password change before +// allowing any other action. MUST be placed after RequireAuth in the chain +// (it reads the webcore context RequireAuth populates). Delegates to webcore +// with gearbox's historical allowlist: the account-setup page, /logout, and +// /static/* (always allowed by webcore). func (m *Manager) RequirePasswordChange(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user, ok := GetUserFromContext(r.Context()) - if !ok { - // Should not happen if RequireAuth is before this middleware - http.Redirect(w, r, "/login", http.StatusSeeOther) - return - } - - // Allow access to account setup, logout, and static assets - allowedPaths := map[string]bool{ - "/settings/complete-account-setup": true, - "/logout": true, - "/static/": true, // Allow static assets - } - - // Check if the current path is allowed - for allowedPath := range allowedPaths { - if r.URL.Path == allowedPath || (allowedPath == "/static/" && len(r.URL.Path) > 8 && r.URL.Path[:8] == "/static/") { - next.ServeHTTP(w, r) - return - } - } - - // If user must change password, redirect to account setup page - if user.MustChangePassword { - // Only redirect if not already on the account setup page - if r.URL.Path != "/settings/complete-account-setup" { - http.Redirect(w, r, "/settings/complete-account-setup", http.StatusSeeOther) - return - } - } - - next.ServeHTTP(w, r) - }) + return m.wc.RequirePasswordChange("/settings/complete-account-setup", "/logout")(next) } -// RequireCSRF is middleware that validates CSRF tokens for POST requests. +// RequireCSRF is middleware that validates CSRF tokens for state-changing +// requests (POST/PUT/DELETE/PATCH). func (m *Manager) RequireCSRF(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Only check CSRF for state-changing methods - if r.Method == "POST" || r.Method == "PUT" || r.Method == "DELETE" || r.Method == "PATCH" { - if err := m.ValidateCSRFToken(r); err != nil { - http.Error(w, "Invalid CSRF token", http.StatusForbidden) - return - } - } - - next.ServeHTTP(w, r) - }) + return m.wc.RequireCSRF(next) } // RequireAdmin is middleware that requires admin role. diff --git a/gearbox/internal/framework/auth/password.go b/gearbox/internal/framework/auth/password.go index 25fc12d..a654d9c 100644 --- a/gearbox/internal/framework/auth/password.go +++ b/gearbox/internal/framework/auth/password.go @@ -1,238 +1,31 @@ package auth import ( - "crypto/rand" - "encoding/base64" - "errors" - "fmt" - "net/mail" - "strings" - - passwordvalidator "github.com/wagslane/go-password-validator" - "golang.org/x/crypto/bcrypt" + webcoreauth "github.com/sarg3nt/webcore/core/auth" ) -// Password policy constants +// Password policy and helpers now live in webcore/core/auth; these re-exports +// keep gearbox's historical auth.* call sites (handlers, templates, main.go) +// compiling unchanged. The policy values are identical to the pre-webcore +// implementation (50-bit entropy floor, 8..128 length, bcrypt cost 12). const ( - // MinEntropyBits is the minimum entropy required for a password. - // 50 bits is a reasonable balance between security and usability. - // - A 4-word passphrase like "correct horse battery staple" scores ~66 bits - // - A 12-char complex password like "P@ssw0rd123!" scores ~50 bits - // - A weak password like "password123" scores ~28 bits (fails) - MinEntropyBits = 50 - - // MinPasswordLength is the absolute minimum length (NIST recommends 8) - MinPasswordLength = 8 - - // MaxPasswordLength prevents DoS via bcrypt - MaxPasswordLength = 128 - - // BcryptCost is the bcrypt hashing cost - BcryptCost = 12 - - // GeneratedPasswordLen is the length of auto-generated passwords - GeneratedPasswordLen = 24 + MinEntropyBits = webcoreauth.MinEntropyBits + MinPasswordLength = webcoreauth.MinPasswordLength + MaxPasswordLength = webcoreauth.MaxPasswordLength + BcryptCost = webcoreauth.BcryptCost + GeneratedPasswordLen = webcoreauth.GeneratedPasswordLen + MinTokenBytes = webcoreauth.MinTokenBytes ) -// Common weak passwords to reject regardless of entropy -// (these might score okay due to length but are still bad choices) -var commonPasswords = map[string]bool{ - "password": true, - "password123": true, - "password1234": true, - "123456789012": true, - "qwertyuiop": true, - "qwerty123456": true, - "admin123456": true, - "letmein12345": true, - "welcome12345": true, - "changeme1234": true, - "iloveyou1234": true, - "trustno1234": true, -} - -// PasswordValidationError represents a password validation failure. -type PasswordValidationError struct { - Errors []string -} - -func (e *PasswordValidationError) Error() string { - return "password validation failed: " + strings.Join(e.Errors, "; ") -} - -// ValidatePassword checks if a password meets security requirements using entropy. -// This approach supports both traditional passwords AND passphrases. -// Returns nil if valid, or a PasswordValidationError with all violations. -func ValidatePassword(password string) error { - var errs []string - - // Check minimum length - if len(password) < MinPasswordLength { - errs = append(errs, fmt.Sprintf("password must be at least %d characters long", MinPasswordLength)) - } - - // Check maximum length (prevent bcrypt DoS) - if len(password) > MaxPasswordLength { - errs = append(errs, fmt.Sprintf("password must be no more than %d characters long", MaxPasswordLength)) - } - - // Check for common passwords (case-insensitive) - lower := strings.ToLower(password) - if commonPasswords[lower] { - errs = append(errs, "password is too common") - } - - // Use entropy-based validation - // This naturally supports passphrases - longer passwords = more entropy - err := passwordvalidator.Validate(password, MinEntropyBits) - if err != nil { - // The library returns a helpful message about what's wrong - errs = append(errs, err.Error()) - } - - if len(errs) > 0 { - return &PasswordValidationError{Errors: errs} - } - - return nil -} - -// GetPasswordEntropy returns the entropy score for a password. -// Higher is better. 50+ bits is considered secure. -func GetPasswordEntropy(password string) float64 { - return passwordvalidator.GetEntropy(password) -} - -// ValidatePasswordStrength returns a simple strength score (0-100) based on entropy. -func ValidatePasswordStrength(password string) int { - entropy := passwordvalidator.GetEntropy(password) - - // Map entropy to 0-100 score - // 0-30 bits: 0-30 score (weak) - // 30-50 bits: 30-60 score (moderate) - // 50-70 bits: 60-80 score (good) - // 70+ bits: 80-100 score (excellent) - var score int - switch { - case entropy < 30: - score = int(entropy) - case entropy < 50: - score = 30 + int((entropy-30)*1.5) - case entropy < 70: - score = 60 + int(entropy-50) - default: - score = 80 + int((entropy-70)*0.5) - } - - // Penalty for common passwords - if commonPasswords[strings.ToLower(password)] { - score -= 50 - } - - // Clamp to 0-100 - if score < 0 { - score = 0 - } - if score > 100 { - score = 100 - } - - return score -} - -// HashPassword creates a bcrypt hash of the password. -func HashPassword(password string) (string, error) { - hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost) - if err != nil { - return "", err - } - return string(hash), nil -} - -// CheckPassword compares a password against a hash. -func CheckPassword(password, hash string) bool { - err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) - return err == nil -} - -// GenerateRandomPassword generates a secure random password. -func GenerateRandomPassword() (string, error) { - // Use a character set that's easy to type but secure - const charset = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%^&*" - - b := make([]byte, GeneratedPasswordLen) - if _, err := rand.Read(b); err != nil { - return "", err - } - - for i := range b { - b[i] = charset[int(b[i])%len(charset)] - } - - // Verify it passes validation (it should with this length and charset) - password := string(b) - if err := ValidatePassword(password); err != nil { - // If somehow it doesn't pass, try again (very unlikely) - return GenerateRandomPassword() - } - - return password, nil -} - -// MinTokenBytes is the minimum number of random bytes for a secure token (256 bits). -const MinTokenBytes = 32 - -// GenerateSecureToken generates a cryptographically secure random token. -// length is the number of random bytes; it must be at least MinTokenBytes (32). -func GenerateSecureToken(length int) (string, error) { - if length < MinTokenBytes { - length = MinTokenBytes - } - b := make([]byte, length) - if _, err := rand.Read(b); err != nil { - return "", err - } - return base64.URLEncoding.EncodeToString(b), nil -} - -// ValidateEmail checks if an email address is valid. -func ValidateEmail(email string) error { - if email == "" { - return errors.New("email is required") - } - - // Allow "admin" as a special case for the admin user - if email == "admin" { - return nil - } - - // SECURITY: Use net/mail for robust email validation instead of regex - // This handles RFC 5322 compliant email addresses and catches edge cases - // that simple regex patterns might miss (quoted strings, comments, etc.) - addr, err := mail.ParseAddress(email) - if err != nil { - return errors.New("invalid email format") - } - - // Ensure the parsed address matches the input (no display name) - if addr.Address != email { - return errors.New("invalid email format: display name not allowed") - } - - // Check for reasonable length (RFC 5321 limit is 254 for the path) - if len(email) > 254 { - return errors.New("email is too long") - } - - return nil -} - -// GetPasswordRequirements returns a human-readable list of password requirements. -func GetPasswordRequirements() []string { - return []string{ - "At least 8 characters long", - "Strong enough to resist guessing attacks (use length or variety)", - "Cannot be a commonly used password", - "Passphrases like \"correct horse battery staple\" are encouraged", - } -} +// PasswordValidationError aggregates password policy violations. +type PasswordValidationError = webcoreauth.PasswordValidationError + +var ( + ValidatePassword = webcoreauth.ValidatePassword + GetPasswordEntropy = webcoreauth.GetPasswordEntropy + ValidatePasswordStrength = webcoreauth.ValidatePasswordStrength + HashPassword = webcoreauth.HashPassword + CheckPassword = webcoreauth.CheckPassword + GenerateRandomPassword = webcoreauth.GenerateRandomPassword + GetPasswordRequirements = webcoreauth.GetPasswordRequirements +) diff --git a/gearbox/internal/framework/auth/security.go b/gearbox/internal/framework/auth/security.go index 7f3dd77..d88edeb 100644 --- a/gearbox/internal/framework/auth/security.go +++ b/gearbox/internal/framework/auth/security.go @@ -1,37 +1,15 @@ package auth import ( - "crypto/rand" - "encoding/hex" - "fmt" - - "github.com/google/uuid" + webcoreauth "github.com/sarg3nt/webcore/core/auth" ) -// GenerateUUID generates a new UUID v4 for user IDs. -// UUIDs prevent user enumeration attacks and ID collision. -func GenerateUUID() string { - return uuid.New().String() -} - -// GenerateSessionToken generates a cryptographically secure 128-bit session token. -// This token is stored in the database and validated on each request. -// OWASP 2026 recommendation: minimum 128 bits for session tokens. -func GenerateSessionToken() (string, error) { - // 16 bytes = 128 bits - b := make([]byte, 16) - if _, err := rand.Read(b); err != nil { - return "", fmt.Errorf("failed to generate session token: %w", err) - } - return hex.EncodeToString(b), nil -} - -// GenerateCSRFToken generates a cryptographically secure CSRF token. -func GenerateCSRFToken() (string, error) { - // 32 bytes = 256 bits for CSRF tokens - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - return "", fmt.Errorf("failed to generate CSRF token: %w", err) - } - return hex.EncodeToString(b), nil -} +// Token, UUID, and email helpers now live in webcore/core/auth; re-exported +// so gearbox call sites compile unchanged. +var ( + GenerateUUID = webcoreauth.GenerateUUID + GenerateSessionToken = webcoreauth.GenerateSessionToken + GenerateCSRFToken = webcoreauth.GenerateCSRFToken + GenerateSecureToken = webcoreauth.GenerateSecureToken + ValidateEmail = webcoreauth.ValidateEmail +)