84 lines
2.7 KiB
Go
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"
|