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
+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 {