add MFA, user web mail portal
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user