add MFA, user web mail portal

This commit is contained in:
2026-08-13 08:07:19 +01:00
parent 70c05cc777
commit bc4bbe6e56
38 changed files with 1072 additions and 59 deletions
+10 -3
View File
@@ -3,10 +3,17 @@ package db
import "time"
type AdminUser struct {
ID int64
Username string
PasswordHash string
ID int64
Username string
PasswordHash string
// MustChangePassword forces a password change before this admin can use the rest
// of the dashboard — true for the seeded default account and every newly-created
// admin (delegated or not). MustChangeUsername additionally forces choosing a new
// username too — true only for the seeded default account (username "admin"),
// never for admins created via the delegation flow, who pick their own username
// up front (see addAdmin/CreateScopedAdminUser).
MustChangePassword bool
MustChangeUsername bool
TOTPSecret string
TOTPEnabled bool
IsGlobalAdmin bool
+49 -13
View File
@@ -8,13 +8,13 @@ import (
"time"
)
const adminUserColumns = `id, username, password_hash, must_change_password, totp_secret, totp_enabled, is_global_admin, created_by, created_at`
const adminUserColumns = `id, username, password_hash, must_change_password, must_change_username, totp_secret, totp_enabled, is_global_admin, created_by, created_at`
func scanAdminUser(row *sql.Row) (*AdminUser, error) {
var u AdminUser
var createdAt string
var createdBy sql.NullInt64
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.MustChangePassword, &u.TOTPSecret, &u.TOTPEnabled, &u.IsGlobalAdmin, &createdBy, &createdAt); err != nil {
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.MustChangePassword, &u.MustChangeUsername, &u.TOTPSecret, &u.TOTPEnabled, &u.IsGlobalAdmin, &createdBy, &createdAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
@@ -42,8 +42,11 @@ const (
)
// SeedDefaultAdminIfEmpty creates the default admin account on a brand-new install
// (no admin users yet at all) with must_change_password set, so the default
// credentials can never be left in place silently.
// (no admin users yet at all) with must_change_password AND must_change_username set,
// so the well-known default credentials (username "admin") can never be left in place
// silently. This is the one and only place must_change_username is ever set — every
// other admin (delegated, or a global admin created via the delegation flow) picks
// their own username up front and only needs to set their own password.
func (d *DB) SeedDefaultAdminIfEmpty() error {
n, err := d.CountAdminUsers()
if err != nil {
@@ -56,15 +59,19 @@ func (d *DB) SeedDefaultAdminIfEmpty() error {
if err != nil {
return err
}
_, err = d.CreateAdminUser(DefaultAdminUsername, hash, true)
_, err = d.Exec(`INSERT INTO esrv_admin_users (username, password_hash, must_change_password, must_change_username, is_global_admin) VALUES (?, ?, 1, 1, 1)`,
DefaultAdminUsername, hash)
return err
}
// CreateAdminUser inserts a new global-admin account (full access, no domain
// restriction). mustChangePassword should be true for the seeded default account so
// it can't keep running on default credentials.
// restriction) — used by the delegation flow when a global admin grants another user
// global access. mustChangePassword should be true so the admin who set the initial
// password isn't the only one who knows it; must_change_username is always false
// here, since the account was created with the username the new admin will actually
// use (see SeedDefaultAdminIfEmpty for the one exception).
func (d *DB) CreateAdminUser(username, passwordHash string, mustChangePassword bool) (int64, error) {
res, err := d.Exec(`INSERT INTO esrv_admin_users (username, password_hash, must_change_password, is_global_admin) VALUES (?, ?, ?, 1)`,
res, err := d.Exec(`INSERT INTO esrv_admin_users (username, password_hash, must_change_password, must_change_username, is_global_admin) VALUES (?, ?, ?, 0, 1)`,
username, passwordHash, mustChangePassword)
if err != nil {
return 0, err
@@ -82,7 +89,7 @@ func (d *DB) CreateScopedAdminUser(username, passwordHash string, createdBy int6
}
defer tx.Rollback()
res, err := tx.Exec(`INSERT INTO esrv_admin_users (username, password_hash, must_change_password, is_global_admin, created_by) VALUES (?, ?, 1, 0, ?)`,
res, err := tx.Exec(`INSERT INTO esrv_admin_users (username, password_hash, must_change_password, must_change_username, is_global_admin, created_by) VALUES (?, ?, 1, 0, 0, ?)`,
username, passwordHash, createdBy)
if err != nil {
return 0, err
@@ -128,7 +135,7 @@ func scanAdminUsers(rows *sql.Rows) ([]AdminUser, error) {
var u AdminUser
var createdAt string
var createdBy sql.NullInt64
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.MustChangePassword, &u.TOTPSecret, &u.TOTPEnabled, &u.IsGlobalAdmin, &createdBy, &createdAt); err != nil {
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.MustChangePassword, &u.MustChangeUsername, &u.TOTPSecret, &u.TOTPEnabled, &u.IsGlobalAdmin, &createdBy, &createdAt); err != nil {
return nil, err
}
u.CreatedAt, _ = parseTime(createdAt)
@@ -214,10 +221,19 @@ func (d *DB) GetAdminUserByID(id int64) (*AdminUser, error) {
return scanAdminUser(row)
}
// UpdateAdminCredentials mirrors the forced first-login change: new username,
// password hash, and clears must_change_password in one step.
// UpdateAdminCredentials mirrors the forced first-login change for the seeded default
// admin: new username, password hash, and clears must_change_password/
// must_change_username in one step.
func (d *DB) UpdateAdminCredentials(id int64, username, passwordHash string) error {
_, err := d.Exec(`UPDATE esrv_admin_users SET username = ?, password_hash = ?, must_change_password = 0 WHERE id = ?`, username, passwordHash, id)
_, err := d.Exec(`UPDATE esrv_admin_users SET username = ?, password_hash = ?, must_change_password = 0, must_change_username = 0 WHERE id = ?`, username, passwordHash, id)
return err
}
// UpdateAdminPasswordClearMustChange mirrors the forced first-login change for a
// delegated admin: password hash only (the username was already chosen when the
// account was created), clearing must_change_password.
func (d *DB) UpdateAdminPasswordClearMustChange(id int64, passwordHash string) error {
_, err := d.Exec(`UPDATE esrv_admin_users SET password_hash = ?, must_change_password = 0, must_change_username = 0 WHERE id = ?`, passwordHash, id)
return err
}
@@ -236,6 +252,26 @@ func (d *DB) DisableAdminTOTP(id int64) error {
return err
}
// ResetAdminMFA clears every second factor an admin has enrolled — TOTP and every
// registered passkey — e.g. after a lost device, so they can re-enroll from scratch.
// Distinct from DisableAdminTOTP (TOTP only, self-service from /account): this is the
// admin-management action a manager takes on someone else's account (see
// adminWithManageAccess's delegation rule for who's allowed to).
func (d *DB) ResetAdminMFA(id int64) error {
tx, err := d.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`UPDATE esrv_admin_users SET totp_secret = '', totp_enabled = 0 WHERE id = ?`, id); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM esrv_webauthn_credentials WHERE user_id = ?`, id); err != nil {
return err
}
return tx.Commit()
}
// --- Sessions ---
func newSessionToken() string {
+8 -1
View File
@@ -17,7 +17,7 @@ func (d *DB) ListDomains() ([]Domain, error) {
var dm Domain
var createdAt string
var verifiedAt *string
if err := rows.Scan(&dm.ID, &dm.DomainName, &dm.IsActive, &createdAt, &dm.VerificationToken, &dm.IsVerified, &verifiedAt); err != nil {
if err := rows.Scan(&dm.ID, &dm.DomainName, &dm.IsActive, &createdAt, &dm.VerificationToken, &dm.IsVerified, &verifiedAt, &dm.MFAExempt); err != nil {
return nil, err
}
dm.CreatedAt, _ = parseTime(createdAt)
@@ -81,6 +81,13 @@ func (d *DB) SetDomainActive(id int64, active bool) error {
return err
}
// SetDomainMFAExempt overrides [Auth] enforce_mailbox_mfa off for every mailbox under
// this domain (regardless of each mailbox's own MFAExempt).
func (d *DB) SetDomainMFAExempt(id int64, exempt bool) error {
_, err := d.Exec(`UPDATE esrv_domains SET mfa_exempt = ? WHERE id = ?`, exempt, id)
return err
}
// SetDomainVerified mirrors marking a domain as DNS-ownership-verified (or reverting
// it, e.g. if an admin wants to force re-verification).
func (d *DB) SetDomainVerified(id int64, verified bool) error {
+32 -5
View File
@@ -5,13 +5,13 @@ import (
"errors"
)
const mailboxColumns = `id, email, domain_id, password_hash, is_active, quota_bytes, used_bytes, dek_wrapped, dek_nonce, created_at, created_by, totp_secret, totp_enabled`
const mailboxColumns = `id, email, domain_id, password_hash, is_active, quota_bytes, used_bytes, dek_wrapped, dek_nonce, created_at, created_by, totp_secret, totp_enabled, mfa_exempt`
func scanMailbox(row *sql.Row) (*Mailbox, error) {
var m Mailbox
var createdAt string
var createdBy sql.NullInt64
if err := row.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled); err != nil {
if err := row.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
@@ -31,7 +31,7 @@ type MailboxWithDomain struct {
}
func (d *DB) ListMailboxes() ([]MailboxWithDomain, error) {
rows, err := d.Query(`SELECT m.id, m.email, m.domain_id, m.password_hash, m.is_active, m.quota_bytes, m.used_bytes, m.dek_wrapped, m.dek_nonce, m.created_at, m.created_by, m.totp_secret, m.totp_enabled, dm.domain_name
rows, err := d.Query(`SELECT m.id, m.email, m.domain_id, m.password_hash, m.is_active, m.quota_bytes, m.used_bytes, m.dek_wrapped, m.dek_nonce, m.created_at, m.created_by, m.totp_secret, m.totp_enabled, m.mfa_exempt, dm.domain_name
FROM esrv_mailboxes m JOIN esrv_domains dm ON dm.id = m.domain_id ORDER BY m.email`)
if err != nil {
return nil, err
@@ -42,7 +42,7 @@ func (d *DB) ListMailboxes() ([]MailboxWithDomain, error) {
var m MailboxWithDomain
var createdAt string
var createdBy sql.NullInt64
if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.DomainName); err != nil {
if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.DomainName); err != nil {
return nil, err
}
m.CreatedAt, _ = parseTime(createdAt)
@@ -65,7 +65,7 @@ func (d *DB) ListMailboxesForDomain(domainID int64) ([]Mailbox, error) {
var m Mailbox
var createdAt string
var createdBy sql.NullInt64
if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled); err != nil {
if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt); err != nil {
return nil, err
}
m.CreatedAt, _ = parseTime(createdAt)
@@ -113,6 +113,13 @@ func (d *DB) SetMailboxActive(id int64, active bool) error {
return err
}
// SetMailboxMFAExempt overrides [Auth] enforce_mailbox_mfa off for this mailbox
// specifically, even if its domain isn't exempt.
func (d *DB) SetMailboxMFAExempt(id int64, exempt bool) error {
_, err := d.Exec(`UPDATE esrv_mailboxes SET mfa_exempt = ? WHERE id = ?`, exempt, id)
return err
}
func (d *DB) SetMailboxQuota(id int64, quotaBytes int64) error {
_, err := d.Exec(`UPDATE esrv_mailboxes SET quota_bytes = ? WHERE id = ?`, quotaBytes, id)
return err
@@ -133,6 +140,26 @@ func (d *DB) DisableMailboxTOTP(id int64) error {
return err
}
// ResetMailboxMFA clears every second factor a mailbox owner has enrolled — TOTP and
// every registered passkey — e.g. after a lost device, or (under enforce_mailbox_mfa)
// to let an admin get them unstuck without needing a domain/mailbox exemption. Distinct
// from DisableMailboxTOTP (TOTP only, self-service from the webmail portal): this is
// the admin-management action taken from the Mailboxes list on someone else's account.
func (d *DB) ResetMailboxMFA(id int64) error {
tx, err := d.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`UPDATE esrv_mailboxes SET totp_secret = '', totp_enabled = 0 WHERE id = ?`, id); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM esrv_mailbox_webauthn_credentials WHERE mailbox_id = ?`, id); err != nil {
return err
}
return tx.Commit()
}
// AddMailboxUsedBytes adjusts the cached running total by delta (positive on store,
// negative on delete) in a single statement, avoiding a read-modify-write race.
func (d *DB) AddMailboxUsedBytes(id int64, delta int64) error {
+3
View File
@@ -19,6 +19,9 @@ type Mailbox struct {
CreatedBy *int64
TOTPSecret string
TOTPEnabled bool
// MFAExempt overrides [Auth] enforce_mailbox_mfa off for this mailbox specifically,
// even if its domain isn't exempt.
MFAExempt bool
}
// MailboxSession is a self-service webmail portal login — a parallel schema to
+3
View File
@@ -10,6 +10,9 @@ type Domain struct {
VerificationToken string
IsVerified bool
VerifiedAt *time.Time
// MFAExempt overrides [Auth] enforce_mailbox_mfa off for every mailbox under this
// domain, regardless of that mailbox's own MFAExempt.
MFAExempt bool
}
type Sender struct {
+2 -2
View File
@@ -54,14 +54,14 @@ func (d *DB) GetSenderByEmail(email string) (*Sender, error) {
return &s, nil
}
const domainColumns = `id, domain_name, is_active, created_at, verification_token, is_verified, verified_at`
const domainColumns = `id, domain_name, is_active, created_at, verification_token, is_verified, verified_at, mfa_exempt`
// scanDomain scans a row selected with domainColumns, in that order.
func scanDomain(row *sql.Row) (*Domain, error) {
var dom Domain
var createdAt string
var verifiedAt sql.NullString
if err := row.Scan(&dom.ID, &dom.DomainName, &dom.IsActive, &createdAt, &dom.VerificationToken, &dom.IsVerified, &verifiedAt); err != nil {
if err := row.Scan(&dom.ID, &dom.DomainName, &dom.IsActive, &createdAt, &dom.VerificationToken, &dom.IsVerified, &verifiedAt, &dom.MFAExempt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
+13 -2
View File
@@ -22,7 +22,8 @@ CREATE TABLE IF NOT EXISTS esrv_domains (
verification_token TEXT NOT NULL DEFAULT '',
is_verified INTEGER NOT NULL DEFAULT 0,
verified_at DATETIME,
default_mailbox_quota_bytes INTEGER NOT NULL DEFAULT 5368709120
default_mailbox_quota_bytes INTEGER NOT NULL DEFAULT 5368709120,
mfa_exempt INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS esrv_senders (
@@ -119,6 +120,7 @@ CREATE TABLE IF NOT EXISTS esrv_admin_users (
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
must_change_password INTEGER NOT NULL DEFAULT 0,
must_change_username INTEGER NOT NULL DEFAULT 0,
totp_secret TEXT NOT NULL DEFAULT '',
totp_enabled INTEGER NOT NULL DEFAULT 0,
is_global_admin INTEGER NOT NULL DEFAULT 0,
@@ -171,7 +173,8 @@ CREATE TABLE IF NOT EXISTS esrv_mailboxes (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES esrv_admin_users(id),
totp_secret TEXT NOT NULL DEFAULT '',
totp_enabled INTEGER NOT NULL DEFAULT 0
totp_enabled INTEGER NOT NULL DEFAULT 0,
mfa_exempt INTEGER NOT NULL DEFAULT 0
);
-- Self-service webmail portal sessions — deliberately a parallel schema to
@@ -286,10 +289,18 @@ func migrateAddedColumns(db *sql.DB) {
`ALTER TABLE esrv_mailboxes ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE esrv_mailboxes ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE esrv_mailbox_app_passwords ADD COLUMN expires_at DATETIME`,
`ALTER TABLE esrv_admin_users ADD COLUMN must_change_username INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE esrv_domains ADD COLUMN mfa_exempt INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE esrv_mailboxes ADD COLUMN mfa_exempt INTEGER NOT NULL DEFAULT 0`,
}
for _, stmt := range stmts {
db.Exec(stmt)
}
// Backfill for installs that already have a still-pending default admin (username
// "admin", never completed the forced first-login yet): must_change_username
// defaults to 0 for every pre-existing row above, which would otherwise let that
// account skip its username change entirely once it re-hits /first-login next.
db.Exec(`UPDATE esrv_admin_users SET must_change_username = 1 WHERE username = ? AND must_change_password = 1`, DefaultAdminUsername)
}
// DB wraps *sql.DB with the query helpers below.