update
This commit is contained in:
+79
-10
@@ -288,9 +288,9 @@ func (db *DB) NextMailboxUID(userID, mailbox string) (int, error) {
|
||||
// InsertMailboxEntry records a delivered message in a user's mailbox index.
|
||||
func (db *DB) InsertMailboxEntry(e *MailboxEntry) error {
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO mailbox_index (id, user_id, mailbox, uid, eml_path, flags, size_bytes, received_at, internal_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, e.ID, e.UserID, e.Mailbox, e.UID, e.EMLPath, e.Flags, e.SizeBytes, e.ReceivedAt, e.InternalDate)
|
||||
INSERT INTO mailbox_index (id, user_id, mailbox, uid, eml_path, flags, size_bytes, received_at, internal_date, header_enc)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, e.ID, e.UserID, e.Mailbox, e.UID, e.EMLPath, e.Flags, e.SizeBytes, e.ReceivedAt, e.InternalDate, e.HeaderEnc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert mailbox entry: %w", err)
|
||||
}
|
||||
@@ -301,7 +301,7 @@ func (db *DB) InsertMailboxEntry(e *MailboxEntry) error {
|
||||
// UID ascending — the order IMAP sequence numbers are defined against.
|
||||
func (db *DB) ListMailboxEntries(userID, mailbox string) ([]MailboxEntry, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT id, user_id, mailbox, uid, eml_path, flags, size_bytes, received_at, internal_date
|
||||
SELECT id, user_id, mailbox, uid, eml_path, flags, size_bytes, received_at, internal_date, header_enc
|
||||
FROM mailbox_index WHERE user_id = ? AND mailbox = ? ORDER BY uid ASC
|
||||
`, userID, mailbox)
|
||||
if err != nil {
|
||||
@@ -312,7 +312,7 @@ func (db *DB) ListMailboxEntries(userID, mailbox string) ([]MailboxEntry, error)
|
||||
var entries []MailboxEntry
|
||||
for rows.Next() {
|
||||
var e MailboxEntry
|
||||
if err := rows.Scan(&e.ID, &e.UserID, &e.Mailbox, &e.UID, &e.EMLPath, &e.Flags, &e.SizeBytes, &e.ReceivedAt, &e.InternalDate); err != nil {
|
||||
if err := rows.Scan(&e.ID, &e.UserID, &e.Mailbox, &e.UID, &e.EMLPath, &e.Flags, &e.SizeBytes, &e.ReceivedAt, &e.InternalDate, &e.HeaderEnc); err != nil {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, e)
|
||||
@@ -584,11 +584,11 @@ func (db *DB) DeleteUser(id string) error {
|
||||
|
||||
func (db *DB) GetUser(id string) (*User, error) {
|
||||
row := db.QueryRow(`SELECT id, tenant_id, domain_id, email, password_hash, display_name, role, active,
|
||||
mfa_enabled, totp_secret_enc, recovery_email FROM users WHERE id = ?`, id)
|
||||
mfa_enabled, totp_secret_enc, passkey_credentials_json, recovery_email FROM users WHERE id = ?`, id)
|
||||
var u User
|
||||
var displayName, recoveryEmail sql.NullString
|
||||
err := row.Scan(&u.ID, &u.TenantID, &u.DomainID, &u.Email, &u.PasswordHash, &displayName, &u.Role, &u.Active,
|
||||
&u.MFAEnabled, &u.TOTPSecretEnc, &recoveryEmail)
|
||||
&u.MFAEnabled, &u.TOTPSecretEnc, &u.PasskeyCredentialsJSON, &recoveryEmail)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -1009,6 +1009,38 @@ func (db *DB) SetACMEAccountKey(domain string, keyEnc []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ── MTA-STS ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetMTASTSPolicy returns the cached policy for domain, or ErrNotFound if
|
||||
// none is cached (including an expired one — callers re-fetch on expiry,
|
||||
// there is no separate "expired but present" state to distinguish).
|
||||
func (db *DB) GetMTASTSPolicy(domain string) (*MTASTSPolicy, error) {
|
||||
row := db.QueryRow(`SELECT id, domain, policy_id, mode, mx_patterns, max_age, fetched_at, expires_at
|
||||
FROM mta_sts_policies WHERE domain = ?`, domain)
|
||||
var p MTASTSPolicy
|
||||
err := row.Scan(&p.ID, &p.Domain, &p.PolicyID, &p.Mode, &p.MXPatterns, &p.MaxAge, &p.FetchedAt, &p.ExpiresAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get mta-sts policy: %w", err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// UpsertMTASTSPolicy creates or updates the cached policy for p.Domain.
|
||||
func (db *DB) UpsertMTASTSPolicy(p *MTASTSPolicy) error {
|
||||
existing, err := db.GetMTASTSPolicy(p.Domain)
|
||||
if err == nil {
|
||||
_, err := db.Exec(`UPDATE mta_sts_policies SET policy_id = ?, mode = ?, mx_patterns = ?, max_age = ?, fetched_at = ?, expires_at = ? WHERE id = ?`,
|
||||
p.PolicyID, p.Mode, p.MXPatterns, p.MaxAge, p.FetchedAt, p.ExpiresAt, existing.ID)
|
||||
return err
|
||||
}
|
||||
_, err = db.Exec(`INSERT INTO mta_sts_policies (id, domain, policy_id, mode, mx_patterns, max_age, fetched_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
uuidNew(), p.Domain, p.PolicyID, p.Mode, p.MXPatterns, p.MaxAge, p.FetchedAt, p.ExpiresAt)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── MFA ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
// SetPendingTOTPSecret stores an encrypted TOTP secret WITHOUT enabling
|
||||
@@ -1024,11 +1056,39 @@ func (db *DB) SetMFAEnabled(userID string, enabled bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ClearTOTPSecret removes TOTP specifically — it does NOT touch
|
||||
// mfa_enabled, since a user with passkeys registered must keep MFA
|
||||
// enabled after disabling just TOTP. Callers should follow this with
|
||||
// RecomputeMFAEnabled.
|
||||
func (db *DB) ClearTOTPSecret(userID string) error {
|
||||
_, err := db.Exec(`UPDATE users SET mfa_enabled = 0, totp_secret_enc = NULL WHERE id = ?`, userID)
|
||||
_, err := db.Exec(`UPDATE users SET totp_secret_enc = NULL WHERE id = ?`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetPasskeyCredentials replaces the user's stored passkey credential list
|
||||
// (a JSON array — see webauthn.StoredCredential) wholesale; callers read-
|
||||
// modify-write the current list rather than this doing any partial update.
|
||||
func (db *DB) SetPasskeyCredentials(userID, credentialsJSON string) error {
|
||||
_, err := db.Exec(`UPDATE users SET passkey_credentials_json = ? WHERE id = ?`, credentialsJSON, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// RecomputeMFAEnabled sets mfa_enabled based on whether the user currently
|
||||
// has any working second factor (TOTP confirmed, or at least one passkey)
|
||||
// — called after ClearTOTPSecret or a passkey deletion, so removing one
|
||||
// factor while the other remains doesn't silently disable the MFA
|
||||
// requirement, and removing the last factor doesn't silently leave it
|
||||
// enabled with nothing to satisfy it.
|
||||
func (db *DB) RecomputeMFAEnabled(userID string) error {
|
||||
user, err := db.GetUser(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasTOTP := user.TOTPSecretEnc != nil
|
||||
hasPasskey := user.PasskeyCredentialsJSON != "" && user.PasskeyCredentialsJSON != "[]"
|
||||
return db.SetMFAEnabled(userID, hasTOTP || hasPasskey)
|
||||
}
|
||||
|
||||
// ReplaceBackupCodes deletes any existing backup codes for the user and
|
||||
// inserts a fresh set — called once at MFA confirm time; codes are shown to
|
||||
// the user exactly once, matching how app passwords are handled.
|
||||
@@ -1078,10 +1138,19 @@ func (db *DB) ConsumeBackupCode(userID, candidateHash string) (bool, error) {
|
||||
if matchID == "" {
|
||||
return false, nil
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE mfa_backup_codes SET used_at = ? WHERE id = ?`, time.Now().UTC(), matchID); err != nil {
|
||||
// AND used_at IS NULL + RowsAffected close the race: without it, two
|
||||
// concurrent requests could both pass the SELECT scan above for the
|
||||
// same code and both "successfully" consume it once MaxOpenConns > 1
|
||||
// lets them run concurrently instead of serializing by accident.
|
||||
res, err := db.Exec(`UPDATE mfa_backup_codes SET used_at = ? WHERE id = ? AND used_at IS NULL`, time.Now().UTC(), matchID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
func (db *DB) SetRecoveryEmail(userID, email string) error {
|
||||
|
||||
Reference in New Issue
Block a user