Files
2026-08-09 18:03:09 +01:00

84 lines
2.7 KiB
Go

// 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"