// Package auth provides shared credential verification for every protocol // that needs it (SMTP AUTH, IMAP LOGIN, POP3 USER/PASS) — a single source of // truth for how a username/password pair maps to a user, so a future change // (MFA enforcement, passkey-only accounts, lockout policy) only needs to // land in one place. package auth import ( "log/slog" "strings" "time" "gomail/internal/db" "golang.org/x/crypto/bcrypt" ) // Scope identifies which protocol is authenticating — checked against an app // password's comma-separated scopes column so a password minted for "imap" // can't be used to relay outbound SMTP, etc. type Scope string const ( ScopeSMTP Scope = "smtp" ScopeIMAP Scope = "imap" ScopePOP3 Scope = "pop3" ScopeCalDAV Scope = "caldav" ScopeCardDAV Scope = "carddav" ) // Authenticate verifies a username/password against either the user's main // account password or one of their active, non-expired app passwords scoped // for the given protocol. Returns the user and true on success. func Authenticate(database *db.DB, username, password string, scope Scope) (*db.User, bool) { user, err := database.LookupUserByEmail(strings.ToLower(strings.TrimSpace(username))) if err != nil { // Always compare against a dummy hash even on lookup failure — avoids // leaking "user exists vs doesn't" via response timing. bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(password)) return nil, false } if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) == nil { return user, true } if checkAppPassword(database, user.ID, password, scope) { return user, true } return nil, false } func checkAppPassword(database *db.DB, userID, password string, scope Scope) bool { rows, err := database.Query(` SELECT id, password_hash, scopes, expires_at FROM app_passwords WHERE user_id = ? AND (expires_at IS NULL OR expires_at > ?) `, userID, time.Now().UTC()) if err != nil { slog.Error("app password lookup failed", "err", err) return false } defer rows.Close() for rows.Next() { var id, hash, scopes string var expiresAt *time.Time if err := rows.Scan(&id, &hash, &scopes, &expiresAt); err != nil { continue } if !strings.Contains(scopes, string(scope)) && !strings.Contains(scopes, "all") { continue } if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil { go database.Exec(`UPDATE app_passwords SET last_used_at = ? WHERE id = ?`, time.Now().UTC(), id) return true } } return false } // dummyHash is a valid bcrypt hash of a random unguessable string, used only // to equalize timing when a username lookup fails. const dummyHash = "$2a$12$gT3vXk8yZ1pQzM4nR7wS8eK9vL2mN5oP1qR3sT6uV8wX0yZ2aB4cD"