// Package auth provides session management and brute-force protection. package auth import ( "context" "database/sql" "fmt" "net" "net/http" "strings" "time" "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto" "ghb.freebede.com/nahakubuilder/mailgosend/internal/db" "ghb.freebede.com/nahakubuilder/mailgosend/internal/models" ) const ( sessionCookieName = "mgs_session" sessionPurpose = "session" ) // SessionStore manages user sessions stored in the database. type SessionStore struct { db *db.DB maxAge int // seconds secureCookie bool } // NewSessionStore creates a session store. func NewSessionStore(database *db.DB, maxAge int, secureCookie bool) *SessionStore { return &SessionStore{ db: database, maxAge: maxAge, secureCookie: secureCookie, } } // Create creates a new session for userID, sets the cookie, and returns the session. func (s *SessionStore) Create(w http.ResponseWriter, r *http.Request, userID int64) (*models.Session, error) { raw, hash, err := crypto.NewToken() if err != nil { return nil, fmt.Errorf("session token: %w", err) } now := time.Now().UTC() expires := now.Add(time.Duration(s.maxAge) * time.Second) ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() _, err = s.db.SQL().ExecContext(ctx, ` INSERT INTO sessions (user_id, token_hash, ip, user_agent, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)`, userID, hash, realIP(r), truncate(r.UserAgent(), 512), now, expires, ) if err != nil { return nil, fmt.Errorf("insert session: %w", err) } http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: raw, Path: "/", MaxAge: s.maxAge, HttpOnly: true, Secure: s.secureCookie, SameSite: http.SameSiteStrictMode, }) return &models.Session{ UserID: userID, TokenHash: hash, IP: realIP(r), CreatedAt: now, ExpiresAt: expires, }, nil } // Get validates the session cookie and returns the session + user. // Returns (nil, nil, nil) if no valid session exists. func (s *SessionStore) Get(r *http.Request) (*models.Session, *models.User, error) { cookie, err := r.Cookie(sessionCookieName) if err != nil { return nil, nil, nil // no cookie = not logged in } raw := cookie.Value if len(raw) == 0 { return nil, nil, nil } hash := crypto.HashToken(raw) ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() row := s.db.SQL().QueryRowContext(ctx, ` SELECT s.id, s.user_id, s.token_hash, s.ip, s.user_agent, s.created_at, s.expires_at, u.id, u.domain_id, u.username, u.email, u.display_name, u.quota_bytes, u.used_bytes, u.enabled, u.admin, u.domain_admin, u.mfa_enabled, u.created_at, u.last_login FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ? AND s.expires_at > ? AND u.enabled = 1`, hash, time.Now().UTC()) var sess models.Session var user models.User var lastLogin sql.NullTime var createdAt sql.NullTime err = row.Scan( &sess.ID, &sess.UserID, &sess.TokenHash, &sess.IP, &sess.UserAgent, &sess.CreatedAt, &sess.ExpiresAt, &user.ID, &user.DomainID, &user.Username, &user.Email, &user.DisplayName, &user.QuotaBytes, &user.UsedBytes, &user.Enabled, &user.Admin, &user.DomainAdmin, &user.MFAEnabled, &createdAt, &lastLogin, ) if err == sql.ErrNoRows { return nil, nil, nil } if err != nil { return nil, nil, fmt.Errorf("session lookup: %w", err) } if createdAt.Valid { user.CreatedAt = createdAt.Time } if lastLogin.Valid { user.LastLogin = lastLogin.Time } return &sess, &user, nil } // Destroy deletes the session from the database and clears the cookie. func (s *SessionStore) Destroy(w http.ResponseWriter, r *http.Request) error { cookie, err := r.Cookie(sessionCookieName) if err != nil { return nil // no session to destroy } hash := crypto.HashToken(cookie.Value) ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() _, err = s.db.SQL().ExecContext(ctx, "DELETE FROM sessions WHERE token_hash = ?", hash) if err != nil { return fmt.Errorf("delete session: %w", err) } http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: s.secureCookie, SameSite: http.SameSiteStrictMode, }) return nil } // DestroyAll deletes all sessions for a user (logout everywhere). func (s *SessionStore) DestroyAll(ctx context.Context, userID int64) error { _, err := s.db.SQL().ExecContext(ctx, "DELETE FROM sessions WHERE user_id = ?", userID) return err } // PurgeExpired deletes all expired sessions. Call periodically (e.g. hourly). func (s *SessionStore) PurgeExpired(ctx context.Context) error { _, err := s.db.SQL().ExecContext(ctx, "DELETE FROM sessions WHERE expires_at <= ?", time.Now().UTC()) return err } // UpdateLastLogin updates the user's last_login timestamp. func UpdateLastLogin(ctx context.Context, database *db.DB, userID int64) { database.SQL().ExecContext(ctx, // nolint: errcheck — best effort "UPDATE users SET last_login = ? WHERE id = ?", time.Now().UTC(), userID) } // ---- User lookup ---- // GetUserByEmail returns the user matching email (case-insensitive), or nil. func GetUserByEmail(ctx context.Context, database *db.DB, email string) (*models.User, error) { row := database.SQL().QueryRowContext(ctx, ` SELECT id, domain_id, username, email, password_hash, display_name, quota_bytes, used_bytes, enabled, admin, domain_admin, mfa_secret_enc, mfa_enabled, recovery_codes_enc, created_at, last_login FROM users WHERE lower(email) = lower(?)`, email) return scanUser(row) } // GetUserByID returns the user by ID, or nil. func GetUserByID(ctx context.Context, database *db.DB, id int64) (*models.User, error) { row := database.SQL().QueryRowContext(ctx, ` SELECT id, domain_id, username, email, password_hash, display_name, quota_bytes, used_bytes, enabled, admin, domain_admin, mfa_secret_enc, mfa_enabled, recovery_codes_enc, created_at, last_login FROM users WHERE id = ?`, id) return scanUser(row) } func scanUser(row *sql.Row) (*models.User, error) { var u models.User var mfaSecretEnc, recoveryCodesEnc []byte var lastLogin sql.NullTime err := row.Scan( &u.ID, &u.DomainID, &u.Username, &u.Email, &u.PasswordHash, &u.DisplayName, &u.QuotaBytes, &u.UsedBytes, &u.Enabled, &u.Admin, &u.DomainAdmin, &mfaSecretEnc, &u.MFAEnabled, &recoveryCodesEnc, &u.CreatedAt, &lastLogin, ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("scan user: %w", err) } u.MFASecretEnc = mfaSecretEnc u.RecoveryCodesEnc = recoveryCodesEnc if lastLogin.Valid { u.LastLogin = lastLogin.Time } return &u, nil } // ---- Helpers ---- func realIP(r *http.Request) string { if xri := r.Header.Get("X-Real-IP"); xri != "" { if parsed := net.ParseIP(strings.TrimSpace(xri)); parsed != nil { return parsed.String() } } if xff := r.Header.Get("X-Forwarded-For"); xff != "" { for _, part := range strings.Split(xff, ",") { trimmed := strings.TrimSpace(part) if parsed := net.ParseIP(trimmed); parsed != nil { return parsed.String() } } } host, _, err := parseHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host } func parseHostPort(addr string) (string, string, error) { // net.SplitHostPort returns error for bare IPs without port. for i := len(addr) - 1; i >= 0; i-- { if addr[i] == ':' { return addr[:i], addr[i+1:], nil } } return addr, "", fmt.Errorf("no port") } func truncate(s string, max int) string { if len(s) <= max { return s } return s[:max] }