add MFA, user web mail portal
This commit is contained in:
@@ -82,6 +82,11 @@ var defaults = []struct {
|
||||
{"rp_display_name", "mailgoserver", ""},
|
||||
{"", "", `Full origin (scheme+host+port) the dashboard is served at, e.g. "https://mail.example.com"`},
|
||||
{"rp_origin", "http://localhost:5000", ""},
|
||||
{"", "", "Require every admin account (global or domain-scoped) to set up TOTP/passkey MFA"},
|
||||
{"enforce_admin_mfa", "false", ""},
|
||||
{"", "", "Require every mailbox's self-service webmail login to have TOTP/passkey MFA"},
|
||||
{"", "", "(overridable per-domain or per-mailbox — see the Domains/Mailboxes edit pages)"},
|
||||
{"enforce_mailbox_mfa", "false", ""},
|
||||
}},
|
||||
{"IMAP", []defaultKV{
|
||||
{"", "", "IMAP server configuration for mailbox retrieval (Thunderbird, etc.)"},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -17,7 +17,9 @@ import (
|
||||
func (a *App) accountPage(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFromContext(r)
|
||||
creds, _ := a.DB.ListWebAuthnCredentials(user.ID)
|
||||
a.render(w, r, "account.html", M{"active": "account", "user": user, "passkeys": creds})
|
||||
hasMFA := user.TOTPEnabled || len(creds) > 0
|
||||
mfaRequired := !hasMFA && a.Cfg.Section("Auth").Key("enforce_admin_mfa").MustBool(false)
|
||||
a.render(w, r, "account.html", M{"active": "account", "user": user, "passkeys": creds, "mfa_required": mfaRequired})
|
||||
}
|
||||
|
||||
// changePassword mirrors a normal (not forced) password change from account settings.
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
// TestResetAdminMFA confirms a manager can clear another admin's TOTP and passkeys
|
||||
// (e.g. after a lost device), and that only an admin who could otherwise manage that
|
||||
// target (per the existing delegation rule) is allowed to.
|
||||
func TestResetAdminMFA(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
cookie := loginSession(t, app)
|
||||
|
||||
targetID, err := app.DB.CreateAdminUser("has-mfa-admin", mustHash(t), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.DB.SetAdminTOTPSecret(targetID, "JBSWY3DPEHPK3PXP", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.DB.CreateWebAuthnCredential(targetID, "yubikey", "cred-id-1", "cred-data-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, Prefix+"/admins/"+strconv.FormatInt(targetID, 10)+"/reset_mfa", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
target, err := app.DB.GetAdminUserByID(targetID)
|
||||
if err != nil || target == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if target.TOTPEnabled || target.TOTPSecret != "" {
|
||||
t.Error("TOTP should be cleared")
|
||||
}
|
||||
creds, err := app.DB.ListWebAuthnCredentials(targetID)
|
||||
if err != nil || len(creds) != 0 {
|
||||
t.Errorf("expected no passkeys left, got %d (err=%v)", len(creds), err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResetAdminMFADeniedOutsideDelegationScope confirms a scoped admin can't reset
|
||||
// MFA for an admin outside their delegation scope (mirrors the existing remove/edit
|
||||
// access checks — resetting someone's MFA is just as sensitive an action).
|
||||
func TestResetAdminMFADeniedOutsideDelegationScope(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
domainA, domainB, _, _ := setupTwoTenants(t, app)
|
||||
_ = domainA
|
||||
|
||||
cookieA := scopedLogin(t, app, "tenant-a-admin", []int64{domainA.ID})
|
||||
targetID, err := app.DB.CreateScopedAdminUser("tenant-b-admin", mustHash(t), 0, []int64{domainB.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.DB.SetAdminTOTPSecret(targetID, "JBSWY3DPEHPK3PXP", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, Prefix+"/admins/"+strconv.FormatInt(targetID, 10)+"/reset_mfa", nil)
|
||||
req.AddCookie(cookieA)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 for an out-of-scope target, got %d", rec.Code)
|
||||
}
|
||||
|
||||
target, err := app.DB.GetAdminUserByID(targetID)
|
||||
if err != nil || target == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !target.TOTPEnabled {
|
||||
t.Error("TOTP should NOT have been reset for an out-of-scope admin")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCannotRemoveOrResetOwnAccount confirms the existing self-management
|
||||
// blocks (canManageAdmin already rejects target.ID == user.ID) also apply to the new
|
||||
// reset_mfa route, and that the admins.html list hides both actions for your own row.
|
||||
func TestAdminCannotRemoveOrResetOwnAccount(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
cookie := loginSession(t, app)
|
||||
|
||||
sess, err := app.DB.GetSession(cookie.Value)
|
||||
if err != nil || sess == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
selfID := strconv.FormatInt(sess.UserID, 10)
|
||||
|
||||
for _, action := range []string{"remove", "reset_mfa"} {
|
||||
req := httptest.NewRequest(http.MethodPost, Prefix+"/admins/"+selfID+"/"+action, nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s on own account: status=%d, want 404", action, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
stillThere, err := app.DB.GetAdminUserByID(sess.UserID)
|
||||
if err != nil || stillThere == nil {
|
||||
t.Fatal("own account should not have been removed")
|
||||
}
|
||||
|
||||
// The admins list must not render a Remove/Reset MFA button for your own row.
|
||||
req := httptest.NewRequest(http.MethodGet, Prefix+"/admins", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("/admins: status=%d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if strings.Contains(body, "/admins/"+selfID+"/remove") {
|
||||
t.Error("admins.html should not render a Remove action for the signed-in admin's own row")
|
||||
}
|
||||
if strings.Contains(body, "/admins/"+selfID+"/reset_mfa") {
|
||||
t.Error("admins.html should not render a Reset MFA action for the signed-in admin's own row")
|
||||
}
|
||||
if !strings.Contains(body, "This is you") {
|
||||
t.Error("expected the signed-in admin's own row to be marked, not just have its buttons hidden")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResetMailboxMFA confirms an admin can clear a mailbox owner's TOTP and passkeys.
|
||||
func TestResetMailboxMFA(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
cookie := loginSession(t, app)
|
||||
|
||||
mailboxes, err := app.DB.ListMailboxes()
|
||||
if err != nil || len(mailboxes) == 0 {
|
||||
t.Fatal("no seeded mailbox")
|
||||
}
|
||||
mboxID := mailboxes[0].ID
|
||||
if err := app.DB.SetMailboxTOTPSecret(mboxID, "JBSWY3DPEHPK3PXP", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.DB.CreateMailboxWebAuthnCredential(mboxID, "phone", "mcred-1", "mcred-data-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, Prefix+"/mailboxes/"+strconv.FormatInt(mboxID, 10)+"/reset_mfa", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
updated, err := app.DB.GetMailboxByID(mboxID)
|
||||
if err != nil || updated == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.TOTPEnabled || updated.TOTPSecret != "" {
|
||||
t.Error("TOTP should be cleared")
|
||||
}
|
||||
creds, err := app.DB.ListMailboxWebAuthnCredentials(mboxID)
|
||||
if err != nil || len(creds) != 0 {
|
||||
t.Errorf("expected no passkeys left, got %d (err=%v)", len(creds), err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustHash(t *testing.T) string {
|
||||
t.Helper()
|
||||
hash, err := db.HashPassword("some-strong-password-1!")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return hash
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func (a *App) adminsList(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
rows = append(rows, M{"user": u, "domain_names": domainNames})
|
||||
}
|
||||
a.render(w, r, "admins.html", M{"active": "admins", "rows": rows})
|
||||
a.render(w, r, "admins.html", M{"active": "admins", "rows": rows, "current_user_id": userFromContext(r).ID})
|
||||
}
|
||||
|
||||
func (a *App) addAdminForm(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -221,6 +221,22 @@ func (a *App) adminWithManageAccess(w http.ResponseWriter, r *http.Request) (*db
|
||||
return target, true
|
||||
}
|
||||
|
||||
// resetAdminMFA clears a target admin's TOTP and passkeys — e.g. after a lost device
|
||||
// — so they can sign back in without a second factor (or under enforce_admin_mfa,
|
||||
// re-enroll from /account on their next login) without needing database access.
|
||||
func (a *App) resetAdminMFA(w http.ResponseWriter, r *http.Request) {
|
||||
target, ok := a.adminWithManageAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := a.DB.ResetAdminMFA(target.ID); err != nil {
|
||||
setFlash(w, "error", "Error resetting MFA")
|
||||
} else {
|
||||
setFlash(w, "success", "MFA reset for "+target.Username)
|
||||
}
|
||||
http.Redirect(w, r, Prefix+"/admins", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *App) removeAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
target, ok := a.adminWithManageAccess(w, r)
|
||||
if !ok {
|
||||
|
||||
+34
-4
@@ -3,6 +3,7 @@ package webui
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
@@ -53,6 +54,21 @@ func scopeFromContext(r *http.Request) accessScope {
|
||||
return s
|
||||
}
|
||||
|
||||
// requireGlobalAdmin gates a handler behind the current admin's scope being global —
|
||||
// used for server-wide settings (Server Settings, Let's Encrypt) that a domain-scoped
|
||||
// admin has no business reading or changing, even if they can guess the URL. 404 (not
|
||||
// 403) matches requireDomainAccess's reasoning: a scoped admin shouldn't be able to
|
||||
// tell "doesn't exist" from "exists but isn't mine" by probing.
|
||||
func (a *App) requireGlobalAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !scopeFromContext(r).Global {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// requireDomainAccess checks the current admin's scope covers domainID; if not, it
|
||||
// writes a 404 (not 403 — a scoped admin shouldn't be able to distinguish "doesn't
|
||||
// exist" from "exists but isn't mine" by probing IDs) and returns false, matching the
|
||||
@@ -138,13 +154,16 @@ func (a *App) requireAuth(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
needsMFA := user.TOTPEnabled
|
||||
if !needsMFA {
|
||||
// hasMFA: this account already has a second factor configured (TOTP or a
|
||||
// passkey) — distinct from sess.MFAVerified, which is about *this session*
|
||||
// having satisfied it.
|
||||
hasMFA := user.TOTPEnabled
|
||||
if !hasMFA {
|
||||
if n, _ := a.DB.CountWebAuthnCredentials(user.ID); n > 0 {
|
||||
needsMFA = true
|
||||
hasMFA = true
|
||||
}
|
||||
}
|
||||
if needsMFA && !sess.MFAVerified {
|
||||
if hasMFA && !sess.MFAVerified {
|
||||
http.Redirect(w, r, Prefix+"/login/mfa", http.StatusFound)
|
||||
return
|
||||
}
|
||||
@@ -154,6 +173,17 @@ func (a *App) requireAuth(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
// enforce_admin_mfa applies to every admin, global or scoped — force setup at
|
||||
// /account (which has the TOTP/passkey enrollment forms) before anything else
|
||||
// is reachable, mirroring the must_change_password gate above. Checked after
|
||||
// must_change_password so a brand-new admin sets a real password first.
|
||||
if !hasMFA && !user.MustChangePassword && a.Cfg.Section("Auth").Key("enforce_admin_mfa").MustBool(false) {
|
||||
if r.URL.Path != Prefix+"/account" && !strings.HasPrefix(r.URL.Path, Prefix+"/account/") {
|
||||
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
scope, err := a.buildAccessScope(user)
|
||||
if err != nil {
|
||||
a.Logger.Error("build access scope: %v", err)
|
||||
|
||||
@@ -141,6 +141,11 @@ func (a *App) editDomain(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := a.DB.SetDomainMFAExempt(id, r.FormValue("mfa_exempt") == "on"); err != nil {
|
||||
setFlash(w, "error", "Error updating domain")
|
||||
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
|
||||
return
|
||||
}
|
||||
setFlash(w, "success", "Domain updated successfully")
|
||||
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
// TestSeedDefaultAdminMustChangeUsername confirms the seeded "admin" account is still
|
||||
// forced through the full username+password change on first login.
|
||||
func TestSeedDefaultAdminMustChangeUsername(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
if err := app.DB.SeedDefaultAdminIfEmpty(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := app.Mux()
|
||||
|
||||
form := url.Values{"username": {db.DefaultAdminUsername}, "password": {db.DefaultAdminPassword}}
|
||||
req := httptest.NewRequest(http.MethodPost, Prefix+"/login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
cookie := sessionCookieFrom(t, rec)
|
||||
|
||||
// The forced-redirect page must show a username field.
|
||||
req = httptest.NewRequest(http.MethodGet, Prefix+"/", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != Prefix+"/first-login" {
|
||||
t.Fatalf("expected redirect to /first-login, got %d Location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, Prefix+"/first-login", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if !strings.Contains(rec.Body.String(), `name="username"`) {
|
||||
t.Error("default admin's first-login page should still ask for a new username")
|
||||
}
|
||||
|
||||
// Submitting without a username must fail — it's still required for this account.
|
||||
form = url.Values{"password": {"BrandNewPassw0rd!"}, "password_confirm": {"BrandNewPassw0rd!"}}
|
||||
req = httptest.NewRequest(http.MethodPost, Prefix+"/first-login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Choose a username") {
|
||||
t.Fatalf("expected a 'choose a username' validation error, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// With a username, it succeeds and the account is fully usable.
|
||||
form = url.Values{"username": {"realadmin"}, "password": {"BrandNewPassw0rd!"}, "password_confirm": {"BrandNewPassw0rd!"}}
|
||||
req = httptest.NewRequest(http.MethodPost, Prefix+"/first-login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != Prefix+"/" {
|
||||
t.Fatalf("expected redirect to dashboard, got %d Location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDelegatedAdminOnlyChangesPassword confirms an admin created through the
|
||||
// delegation flow (addAdmin) — who already picked their own username at creation
|
||||
// time — is only ever asked for a new password on first login, never a username.
|
||||
func TestDelegatedAdminOnlyChangesPassword(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
|
||||
hash, err := db.HashPassword("initial-temp-password-1!")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userID, err := app.DB.CreateScopedAdminUser("delegate-bob", hash, 0, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err := app.DB.CreateSession(userID, true, sessionTTL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cookie := &http.Cookie{Name: sessionCookieName, Value: token}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, Prefix+"/first-login", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("first-login page: status=%d", rec.Code)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), `name="username"`) {
|
||||
t.Error("a delegated admin's first-login page should not ask for a new username")
|
||||
}
|
||||
|
||||
form := url.Values{"password": {"BrandNewPassw0rd!"}, "password_confirm": {"BrandNewPassw0rd!"}}
|
||||
req = httptest.NewRequest(http.MethodPost, Prefix+"/first-login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != Prefix+"/" {
|
||||
t.Fatalf("expected redirect to dashboard, got %d Location=%q body=%s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
|
||||
}
|
||||
|
||||
updated, err := app.DB.GetAdminUserByID(userID)
|
||||
if err != nil || updated == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Username != "delegate-bob" {
|
||||
t.Errorf("username changed unexpectedly to %q", updated.Username)
|
||||
}
|
||||
if updated.MustChangePassword {
|
||||
t.Error("must_change_password should be cleared after first login")
|
||||
}
|
||||
if !db.CheckPassword("BrandNewPassw0rd!", updated.PasswordHash) {
|
||||
t.Error("password was not actually updated")
|
||||
}
|
||||
}
|
||||
|
||||
func sessionCookieFrom(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie {
|
||||
t.Helper()
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == sessionCookieName {
|
||||
return c
|
||||
}
|
||||
}
|
||||
t.Fatal("no session cookie set")
|
||||
return nil
|
||||
}
|
||||
+29
-13
@@ -155,25 +155,25 @@ func (a *App) logout(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) firstLoginForm(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFromContext(r)
|
||||
a.render(w, r, "first_login.html", M{"username": user.Username})
|
||||
a.render(w, r, "first_login.html", M{"username": user.Username, "must_change_username": user.MustChangeUsername})
|
||||
}
|
||||
|
||||
// firstLoginSubmit mirrors the forced "you can't keep the default credentials" flow:
|
||||
// require a new username and password before must_change_password clears.
|
||||
// firstLoginSubmit mirrors the forced credential-change flow: always require a new
|
||||
// password before must_change_password clears; only the seeded default admin
|
||||
// (MustChangeUsername) is additionally required to pick a new username — a delegated
|
||||
// admin already chose their own username when the account was created, so re-asking
|
||||
// for one here would just be busywork with no security purpose.
|
||||
func (a *App) firstLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFromContext(r)
|
||||
newUsername := strings.TrimSpace(r.FormValue("username"))
|
||||
newPassword := r.FormValue("password")
|
||||
confirm := r.FormValue("password_confirm")
|
||||
|
||||
fail := func(msg string) {
|
||||
a.render(w, r, "first_login.html", M{"username": newUsername, "error": msg})
|
||||
a.render(w, r, "first_login.html", M{
|
||||
"username": r.FormValue("username"), "must_change_username": user.MustChangeUsername, "error": msg,
|
||||
})
|
||||
}
|
||||
|
||||
if newUsername == "" {
|
||||
fail("Choose a username.")
|
||||
return
|
||||
}
|
||||
if !isStrongPassword(newPassword) {
|
||||
fail("Password must be at least 10 characters and include a letter, a number, and a symbol.")
|
||||
return
|
||||
@@ -182,16 +182,32 @@ func (a *App) firstLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
fail("Passwords don't match.")
|
||||
return
|
||||
}
|
||||
if existing, _ := a.DB.GetAdminUserByUsername(newUsername); existing != nil && existing.ID != user.ID {
|
||||
fail("That username is already taken.")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := db.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
fail("Something went wrong. Try again.")
|
||||
return
|
||||
}
|
||||
|
||||
if !user.MustChangeUsername {
|
||||
if err := a.DB.UpdateAdminPasswordClearMustChange(user.ID, hash); err != nil {
|
||||
fail("Something went wrong. Try again.")
|
||||
return
|
||||
}
|
||||
setFlash(w, "success", "Password updated. Welcome to your dashboard.")
|
||||
http.Redirect(w, r, Prefix+"/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
newUsername := strings.TrimSpace(r.FormValue("username"))
|
||||
if newUsername == "" {
|
||||
fail("Choose a username.")
|
||||
return
|
||||
}
|
||||
if existing, _ := a.DB.GetAdminUserByUsername(newUsername); existing != nil && existing.ID != user.ID {
|
||||
fail("That username is already taken.")
|
||||
return
|
||||
}
|
||||
if err := a.DB.UpdateAdminCredentials(user.ID, newUsername, hash); err != nil {
|
||||
fail("Something went wrong. Try again.")
|
||||
return
|
||||
|
||||
@@ -129,6 +129,23 @@ func (a *App) mailboxWithAccess(w http.ResponseWriter, r *http.Request) (mailbox
|
||||
return mailbox, true
|
||||
}
|
||||
|
||||
// resetMailboxMFA clears a mailbox owner's TOTP and passkeys — e.g. after a lost
|
||||
// device, or (under enforce_mailbox_mfa) to unblock their webmail login without
|
||||
// needing a domain/mailbox exemption — so they can sign back in and, if MFA is
|
||||
// enforced, re-enroll from the webmail portal on their next login.
|
||||
func (a *App) resetMailboxMFA(w http.ResponseWriter, r *http.Request) {
|
||||
mailbox, ok := a.mailboxWithAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := a.DB.ResetMailboxMFA(mailbox.ID); err != nil {
|
||||
setFlash(w, "error", "Error resetting MFA")
|
||||
} else {
|
||||
setFlash(w, "success", "MFA reset for "+mailbox.Email)
|
||||
}
|
||||
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *App) disableMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
mailbox, ok := a.mailboxWithAccess(w, r)
|
||||
if !ok {
|
||||
@@ -226,6 +243,11 @@ func (a *App) editMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := a.DB.SetMailboxMFAExempt(mailbox.ID, r.FormValue("mfa_exempt") == "on"); err != nil {
|
||||
setFlash(w, "error", "Error updating mailbox")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
|
||||
return
|
||||
}
|
||||
setFlash(w, "success", "Mailbox updated successfully")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
// TestAdminMFAEnforcementForcesSetupThenReleases confirms enforce_admin_mfa blocks
|
||||
// every other admin page — redirecting to /account, which has the TOTP/passkey
|
||||
// enrollment forms — until the admin actually sets up a second factor, after which
|
||||
// normal access resumes.
|
||||
func TestAdminMFAEnforcementForcesSetupThenReleases(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
app.Cfg.Section("Auth").Key("enforce_admin_mfa").SetValue("true")
|
||||
mux := app.Mux()
|
||||
|
||||
hash, err := db.HashPassword("no-mfa-yet-password-1!")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userID, err := app.DB.CreateAdminUser("no-mfa-admin", hash, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err := app.DB.CreateSession(userID, true, sessionTTL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cookie := &http.Cookie{Name: sessionCookieName, Value: token}
|
||||
|
||||
// Blocked from an ordinary page, redirected to /account.
|
||||
req := httptest.NewRequest(http.MethodGet, Prefix+"/domains", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != Prefix+"/account" {
|
||||
t.Fatalf("expected redirect to /account, got %d Location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
|
||||
// /account itself must be reachable (that's where MFA setup happens).
|
||||
req = httptest.NewRequest(http.MethodGet, Prefix+"/account", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("/account: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "requires two-factor authentication") {
|
||||
t.Error("expected the MFA-required banner on /account")
|
||||
}
|
||||
|
||||
// Once TOTP is enabled, other pages become reachable again.
|
||||
if err := app.DB.SetAdminTOTPSecret(userID, "JBSWY3DPEHPK3PXP", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, Prefix+"/domains", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected /domains reachable after enabling MFA, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMFAEnforcementOffByDefault confirms nothing changes for existing installs
|
||||
// unless the admin explicitly turns enforcement on.
|
||||
func TestAdminMFAEnforcementOffByDefault(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
cookie := loginSession(t, app)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, Prefix+"/domains", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected /domains reachable with enforcement off, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailboxMFAEnforcementBlocksLogin confirms enforce_mailbox_mfa blocks the
|
||||
// self-service webmail login outright (no session is ever created) for a mailbox with
|
||||
// no MFA configured, and that a mailbox-level or domain-level exemption lets the login
|
||||
// through instead — the bootstrap path for a mailbox to set up its own MFA under
|
||||
// enforcement. App-password creation/use is deliberately untouched by any of this;
|
||||
// see mailboxNeedsMFASetup's doc comment.
|
||||
func TestMailboxMFAEnforcementBlocksLogin(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
app.Cfg.Section("Auth").Key("enforce_mailbox_mfa").SetValue("true")
|
||||
mux := app.Mux()
|
||||
|
||||
domainID, err := app.DB.CreateDomain("mfatest.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mhash, err := db.HashPassword("mailbox-owner-password-1!")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dek := make([]byte, 32)
|
||||
mboxID, err := app.DB.CreateMailbox("owner@mfatest.example", mhash, domainID, 1<<30, dek, dek)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tryLogin := func() (status int, sessionCookieSet bool) {
|
||||
form := url.Values{"email": {"owner@mfatest.example"}, "password": {"mailbox-owner-password-1!"}}
|
||||
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == mailboxSessionCookieName && c.Value != "" {
|
||||
sessionCookieSet = true
|
||||
}
|
||||
}
|
||||
return rec.Code, sessionCookieSet
|
||||
}
|
||||
|
||||
status, gotSession := tryLogin()
|
||||
if status != http.StatusOK || gotSession {
|
||||
t.Fatalf("expected login rejected with no session, got status=%d session=%v", status, gotSession)
|
||||
}
|
||||
|
||||
// Mailbox-level exemption lets the login through.
|
||||
if err := app.DB.SetMailboxMFAExempt(mboxID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, gotSession = tryLogin()
|
||||
if status != http.StatusFound || !gotSession {
|
||||
t.Fatalf("expected login to succeed once mailbox-exempt, got status=%d session=%v", status, gotSession)
|
||||
}
|
||||
|
||||
// Un-exempt the mailbox but exempt its domain instead — still overrides.
|
||||
if err := app.DB.SetMailboxMFAExempt(mboxID, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.DB.SetDomainMFAExempt(domainID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, gotSession = tryLogin()
|
||||
if status != http.StatusFound || !gotSession {
|
||||
t.Fatalf("expected login to succeed once domain-exempt, got status=%d session=%v", status, gotSession)
|
||||
}
|
||||
|
||||
// Un-exempt everything, but set up TOTP MFA on the mailbox directly — login
|
||||
// succeeds (goes to the pending-MFA step) without needing any exemption at all.
|
||||
if err := app.DB.SetDomainMFAExempt(domainID, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.DB.SetMailboxTOTPSecret(mboxID, "JBSWY3DPEHPK3PXP", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
form := url.Values{"email": {"owner@mfatest.example"}, "password": {"mailbox-owner-password-1!"}}
|
||||
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != MailboxPrefix+"/login/mfa" {
|
||||
t.Fatalf("expected redirect to MFA step once TOTP is configured, got %d Location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
@@ -196,6 +196,12 @@ func (a *App) render(w http.ResponseWriter, r *http.Request, page string, data M
|
||||
}
|
||||
data["flashes"] = popFlashes(w, r)
|
||||
data["health"] = a.checkHealth()
|
||||
// Drives the sidebar hiding Server Settings/Let's Encrypt for scoped admins (see
|
||||
// requireGlobalAdmin, which is the actual enforcement — this only controls the
|
||||
// link's visibility).
|
||||
if u := userFromContext(r); u != nil {
|
||||
data["is_global_admin"] = u.IsGlobalAdmin
|
||||
}
|
||||
// Sidebar badge counts (Domains/Senders/Mailboxes/IPs/DKIM Keys) — computed here,
|
||||
// centrally, so every authenticated page shows them, not just the dashboard (which
|
||||
// used to compute these itself and nowhere else did).
|
||||
|
||||
@@ -42,6 +42,25 @@ func (a *App) settingsUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, Prefix+"/settings", http.StatusFound)
|
||||
return
|
||||
}
|
||||
// Turning on enforce_admin_mfa immediately blocks every admin route except
|
||||
// /account for any admin without MFA configured — including /settings itself.
|
||||
// Without this precondition, an admin who enables enforcement before setting up
|
||||
// their own MFA would lock themselves out with no way back in (short of editing
|
||||
// the database directly), since requireAuth's gate applies to this very handler.
|
||||
if r.FormValue("Auth.enforce_admin_mfa") == "true" && a.Cfg.Section("Auth").Key("enforce_admin_mfa").Value() != "true" {
|
||||
user := userFromContext(r)
|
||||
hasMFA := user.TOTPEnabled
|
||||
if !hasMFA {
|
||||
if n, _ := a.DB.CountWebAuthnCredentials(user.ID); n > 0 {
|
||||
hasMFA = true
|
||||
}
|
||||
}
|
||||
if !hasMFA {
|
||||
setFlash(w, "error", "Set up your own two-factor authentication (see Account) before enforcing it for all admins — otherwise you'd lock yourself out.")
|
||||
http.Redirect(w, r, Prefix+"/settings", http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
changed := false
|
||||
for _, name := range a.Cfg.SectionStrings() {
|
||||
sec := a.Cfg.Section(name)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSettingsRejectsEnablingAdminMFAWithoutOwnMFA guards against a real self-lockout
|
||||
// bug: enabling enforce_admin_mfa immediately blocks every admin route (including
|
||||
// /settings itself) for any admin without their own MFA configured. Without this
|
||||
// precondition, an admin could flip the toggle on and lock themselves out with no way
|
||||
// back in short of editing the database directly.
|
||||
func TestSettingsRejectsEnablingAdminMFAWithoutOwnMFA(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
cookie := loginSession(t, app) // loginSession's admin has no MFA configured
|
||||
|
||||
form := baseSettingsForm()
|
||||
form.Set("Auth.enforce_admin_mfa", "true")
|
||||
req := httptest.NewRequest(http.MethodPost, Prefix+"/settings_update", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := app.Cfg.Section("Auth").Key("enforce_admin_mfa").Value(); got != "false" {
|
||||
t.Fatalf("enforce_admin_mfa = %q, want unchanged (still false) since the admin has no MFA", got)
|
||||
}
|
||||
|
||||
// The same admin must still be able to reach every other page — the whole point
|
||||
// of rejecting the save is that nothing actually changed.
|
||||
req = httptest.NewRequest(http.MethodGet, Prefix+"/domains", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected /domains still reachable, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsAllowsEnablingAdminMFAWithOwnMFA confirms the precondition isn't just a
|
||||
// blanket rejection — an admin who already has MFA set up can turn enforcement on.
|
||||
func TestSettingsAllowsEnablingAdminMFAWithOwnMFA(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
cookie := loginSession(t, app)
|
||||
|
||||
sess, err := app.DB.GetSession(cookie.Value)
|
||||
if err != nil || sess == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.DB.SetAdminTOTPSecret(sess.UserID, "JBSWY3DPEHPK3PXP", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := baseSettingsForm()
|
||||
form.Set("Auth.enforce_admin_mfa", "true")
|
||||
req := httptest.NewRequest(http.MethodPost, Prefix+"/settings_update", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := app.Cfg.Section("Auth").Key("enforce_admin_mfa").Value(); got != "true" {
|
||||
t.Fatalf("enforce_admin_mfa = %q, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
// baseSettingsForm returns a full, valid settings_update submission (every field
|
||||
// unchanged from newTestApp's fixture) — settingsUpdate iterates every existing ini
|
||||
// key and only touches ones present in the form, but real form submissions always
|
||||
// include every field on the page, so tests should too.
|
||||
func baseSettingsForm() url.Values {
|
||||
return url.Values{
|
||||
"Server.smtp_port": {"4025"}, "Server.smtp_tls_port": {"40465"},
|
||||
"Server.web_http_port": {"5000"}, "Server.web_https_port": {"5001"},
|
||||
"Server.bind_ip": {"0.0.0.0"}, "Server.time_zone": {"UTC"},
|
||||
"Server.hostname": {"mail.example.com"}, "Server.helo_hostname": {"mail.example.com"},
|
||||
"Server.server_banner": {""},
|
||||
"Database.database_url": {"sqlite:///server_data/smtp_server.db"},
|
||||
"Logging.log_level": {"INFO"}, "Logging.hide_info_aiosmtpd": {"true"},
|
||||
"Relay.relay_timeout": {"30"},
|
||||
"TLS.tls_cert_file": {"ssl_certs/server.crt"}, "TLS.tls_key_file": {"ssl_certs/server.key"},
|
||||
"DKIM.dkim_key_size": {"2048"}, "DKIM.spf_server_ip": {"192.168.1.1"},
|
||||
"Attachments.attachments_path": {"server_data/attachments"},
|
||||
"IMAP.imap_port": {"1143"}, "IMAP.imap_tls_port": {"1993"},
|
||||
"Auth.enforce_admin_mfa": {"false"}, "Auth.enforce_mailbox_mfa": {"false"},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestScopedAdminCannotAccessServerSettingsOrLetsEncrypt guards against a regression
|
||||
// where a domain-scoped admin (delegated access to specific domains only, not a
|
||||
// global admin) could still read/change server-wide config that has nothing to do
|
||||
// with any one domain — the admin dashboard's own port, TLS certs, database URL,
|
||||
// Let's Encrypt/ACME credentials, etc.
|
||||
func TestScopedAdminCannotAccessServerSettingsOrLetsEncrypt(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
domains, err := app.DB.ListDomains()
|
||||
if err != nil || len(domains) == 0 {
|
||||
t.Fatal("no seeded domain")
|
||||
}
|
||||
cookie := scopedLogin(t, app, "scoped-settings-admin", []int64{domains[0].ID})
|
||||
|
||||
routes := []struct {
|
||||
method, path string
|
||||
}{
|
||||
{http.MethodGet, "/pymta-manager/settings"},
|
||||
{http.MethodPost, "/pymta-manager/settings_update"},
|
||||
{http.MethodGet, "/pymta-manager/letsencrypt"},
|
||||
{http.MethodPost, "/pymta-manager/letsencrypt/save"},
|
||||
{http.MethodPost, "/pymta-manager/letsencrypt/obtain"},
|
||||
{http.MethodGet, "/pymta-manager/api/settings/get_public_ip"},
|
||||
}
|
||||
for _, rt := range routes {
|
||||
req := httptest.NewRequest(rt.method, rt.path, nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s %s: status=%d, want 404 for a scoped admin", rt.method, rt.path, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A global admin must still reach these routes normally.
|
||||
globalCookie := loginSession(t, app)
|
||||
req := httptest.NewRequest(http.MethodGet, "/pymta-manager/settings", nil)
|
||||
req.AddCookie(globalCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("global admin GET /settings: status=%d, want 200", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScopedAdminSidebarHidesServerWideLinks confirms the sidebar doesn't even show
|
||||
// Server Settings/Let's Encrypt to a scoped admin (backend enforcement is the real
|
||||
// gate, this is just the corresponding UI affordance).
|
||||
func TestScopedAdminSidebarHidesServerWideLinks(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
domains, _ := app.DB.ListDomains()
|
||||
cookie := scopedLogin(t, app, "scoped-sidebar-admin", []int64{domains[0].ID})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/pymta-manager/", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("dashboard: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if strings.Contains(body, `href="/pymta-manager/settings"`) {
|
||||
t.Error("scoped admin's sidebar should not link to Server Settings")
|
||||
}
|
||||
if strings.Contains(body, `href="/pymta-manager/letsencrypt"`) {
|
||||
t.Error("scoped admin's sidebar should not link to Let's Encrypt")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,12 @@
|
||||
{{define "page_title"}}Account Settings{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{if .mfa_required}}
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-shield-exclamation me-2"></i>
|
||||
Your administrator requires two-factor authentication for all admin accounts. Set up an authenticator app or a passkey below to continue using the dashboard.
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="row">
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card">
|
||||
|
||||
@@ -35,14 +35,21 @@
|
||||
</td>
|
||||
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" $u.CreatedAt}}</small></td>
|
||||
<td>
|
||||
{{if eq $u.ID $.current_user_id}}
|
||||
<span class="text-muted small"><i class="bi bi-person-check me-1"></i>This is you</span>
|
||||
{{else}}
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
{{if not $u.IsGlobalAdmin}}
|
||||
<a href="/pymta-manager/admins/{{$u.ID}}/edit" class="btn btn-outline-primary" title="Edit Domain Access"><i class="bi bi-pencil"></i></a>
|
||||
{{end}}
|
||||
<form method="post" action="/pymta-manager/admins/{{$u.ID}}/reset_mfa" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-warning" data-confirm="Reset MFA for {{$u.Username}}? They will need to re-enroll an authenticator app or passkey." title="Reset MFA"><i class="bi bi-shield-x"></i></button>
|
||||
</form>
|
||||
<form method="post" action="/pymta-manager/admins/{{$u.ID}}/remove" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-danger" data-confirm="Permanently remove admin {{$u.Username}}? This cannot be undone." title="Remove Admin"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
||||
@@ -190,7 +190,11 @@
|
||||
<div class="col-md-3 mb-3"><div class="d-grid"><a href="/pymta-manager/domains/add" class="btn btn-outline-primary"><i class="bi bi-plus-circle me-2"></i>Add Domain</a></div></div>
|
||||
<div class="col-md-3 mb-3"><div class="d-grid"><a href="/pymta-manager/senders/add" class="btn btn-outline-success"><i class="bi bi-person-plus me-2"></i>Add Sender</a></div></div>
|
||||
<div class="col-md-3 mb-3"><div class="d-grid"><a href="/pymta-manager/ips/add" class="btn btn-outline-warning"><i class="bi bi-shield-plus me-2"></i>Whitelist IP</a></div></div>
|
||||
{{if dget . "is_global_admin"}}
|
||||
<div class="col-md-3 mb-3"><div class="d-grid"><a href="/pymta-manager/settings" class="btn btn-outline-info"><i class="bi bi-gear me-2"></i>Settings</a></div></div>
|
||||
{{else}}
|
||||
<div class="col-md-3 mb-3"><div class="d-grid"><a href="/pymta-manager/mailboxes/add" class="btn btn-outline-info"><i class="bi bi-mailbox me-2"></i>Add Mailbox</a></div></div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
<div class="form-text"><i class="bi bi-info-circle me-1"></i>Enter a fully qualified domain name (e.g., example.com)</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="mfa_exempt" name="mfa_exempt" {{if .domain.MFAExempt}}checked{{end}}>
|
||||
<label class="form-check-label" for="mfa_exempt">Exempt this domain's mailboxes from mandatory MFA</label>
|
||||
<div class="form-text">Only relevant if "Enforce MFA for Mailboxes" is turned on in Server Settings.</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="alert alert-info">
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
<label for="quota_gb" class="form-label">Storage Quota (GB)</label>
|
||||
<input type="number" class="form-control" id="quota_gb" name="quota_gb" min="0.1" step="0.1" value="{{printf "%.2f" .quota_gb}}">
|
||||
</div>
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="mfa_exempt" name="mfa_exempt" {{if .mailbox.MFAExempt}}checked{{end}}>
|
||||
<label class="form-check-label" for="mfa_exempt">Exempt this mailbox from mandatory MFA</label>
|
||||
<div class="form-text">Only relevant if "Enforce MFA for Mailboxes" is turned on in Server Settings.</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Update Mailbox</button>
|
||||
<a href="/pymta-manager/mailboxes" class="btn btn-secondary"><i class="bi bi-x-lg me-1"></i>Cancel</a>
|
||||
|
||||
@@ -4,22 +4,31 @@
|
||||
{{define "content"}}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
{{if .must_change_username}}
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
You're signed in with the default admin account. Choose a new username and password before continuing.
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
Set your own password before continuing.
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-person-gear me-2"></i>Choose your credentials</h5></div>
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-person-gear me-2"></i>Choose your {{if .must_change_username}}credentials{{else}}password{{end}}</h5></div>
|
||||
<div class="card-body">
|
||||
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
|
||||
<form method="POST" action="/pymta-manager/first-login">
|
||||
{{if .must_change_username}}
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">New username</label>
|
||||
<input type="text" class="form-control" id="username" name="username" value="{{.username}}" required autofocus>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">New password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required minlength="10">
|
||||
<input type="password" class="form-control" id="password" name="password" required minlength="10" {{if not .must_change_username}}autofocus{{end}}>
|
||||
<div class="form-text">At least 10 characters, with a letter, a number, and a symbol.</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
|
||||
@@ -37,6 +37,10 @@
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-box-arrow-in-right me-1"></i>Sign in</button>
|
||||
</div>
|
||||
</form>
|
||||
<hr>
|
||||
<div class="d-grid">
|
||||
<a href="/webmail/login" class="btn btn-outline-secondary btn-sm"><i class="bi bi-inbox me-1"></i>Login as Mailbox User</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
<a href="/pymta-manager/mailboxes/add" class="btn btn-primary"><i class="bi bi-mailbox me-2"></i>Add Mailbox</a>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-2"></i>Mailbox owners manage their own password, MFA, and IMAP/SMTP app passwords at
|
||||
<a href="/webmail/login" target="_blank" class="alert-link">/webmail/login</a> — using their mailbox email and password, not an admin account.
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>All Mailboxes</h5></div>
|
||||
<div class="card-body p-0">
|
||||
@@ -38,6 +43,9 @@
|
||||
<a href="/pymta-manager/mailboxes/{{$mailbox.ID}}/apppasswords" class="btn btn-outline-secondary btn-sm" title="App Passwords"><i class="bi bi-key"></i></a>
|
||||
<a href="/pymta-manager/mailboxes/{{$mailbox.ID}}/aliases" class="btn btn-outline-secondary btn-sm" title="Aliases"><i class="bi bi-signpost-split"></i></a>
|
||||
<a href="/pymta-manager/mailboxes/{{$mailbox.ID}}/edit" class="btn btn-outline-primary btn-sm" title="Edit Mailbox"><i class="bi bi-pencil"></i></a>
|
||||
<form method="post" action="/pymta-manager/mailboxes/{{$mailbox.ID}}/reset_mfa" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-warning btn-sm" data-confirm="Reset MFA for {{$mailbox.Email}}? They will need to re-enroll an authenticator app or passkey." title="Reset MFA"><i class="bi bi-shield-x"></i></button>
|
||||
</form>
|
||||
{{if $mailbox.IsActive}}
|
||||
<form method="post" action="/pymta-manager/mailboxes/{{$mailbox.ID}}/delete" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-warning btn-sm" title="Disable Mailbox" data-confirm="Disable mailbox {{$mailbox.Email}}?"><i class="bi bi-pause-circle"></i></button>
|
||||
|
||||
@@ -159,6 +159,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-shield-check me-2"></i>Two-Factor Authentication (MFA)</h5></div>
|
||||
<div class="card-body">
|
||||
<div class="setting-section">
|
||||
<div class="row">
|
||||
<div class="col-md-6"><div class="mb-3"><label class="form-label">Enforce MFA for Admins</label>
|
||||
<div class="setting-description">Require every admin account (global or domain-scoped) to set up TOTP/passkey MFA before using the dashboard</div>
|
||||
<select class="form-select" name="Auth.enforce_admin_mfa">
|
||||
<option value="true" {{if eq .settings.Auth.enforce_admin_mfa "true"}}selected{{end}}>Yes</option>
|
||||
<option value="false" {{if eq .settings.Auth.enforce_admin_mfa "false"}}selected{{end}}>No</option>
|
||||
</select>
|
||||
</div></div>
|
||||
<div class="col-md-6"><div class="mb-3"><label class="form-label">Enforce MFA for Mailboxes</label>
|
||||
<div class="setting-description">Require MFA before a mailbox owner can create new app passwords in the self-service portal — overridable per-domain or per-mailbox</div>
|
||||
<select class="form-select" name="Auth.enforce_mailbox_mfa">
|
||||
<option value="true" {{if eq .settings.Auth.enforce_mailbox_mfa "true"}}selected{{end}}>Yes</option>
|
||||
<option value="false" {{if eq .settings.Auth.enforce_mailbox_mfa "false"}}selected{{end}}>No</option>
|
||||
</select>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>TLS/SSL Configuration</h5></div>
|
||||
<div class="card-body">
|
||||
|
||||
@@ -70,12 +70,14 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{{if dget . "is_global_admin"}}
|
||||
<li class="nav-item mb-1">
|
||||
<a href="/pymta-manager/letsencrypt" class="nav-link text-white {{if eq (dget . "active") "letsencrypt"}}active{{end}}">
|
||||
<i class="bi bi-patch-check me-2"></i>
|
||||
Let's Encrypt
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
|
||||
<li class="nav-item mb-1">
|
||||
<a href="/pymta-manager/logs" class="nav-link text-white {{if eq (dget . "active") "logs"}}active{{end}}">
|
||||
@@ -91,12 +93,14 @@
|
||||
</h6>
|
||||
</li>
|
||||
|
||||
{{if dget . "is_global_admin"}}
|
||||
<li class="nav-item mb-1">
|
||||
<a href="/pymta-manager/settings" class="nav-link text-white {{if eq (dget . "active") "settings"}}active{{end}}">
|
||||
<i class="bi bi-sliders me-2"></i>
|
||||
Server Settings
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
|
||||
<li class="nav-item mb-1">
|
||||
<a href="/pymta-manager/admins" class="nav-link text-white {{if eq (dget . "active") "admins"}}active{{end}}">
|
||||
@@ -112,6 +116,14 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item mb-1">
|
||||
<a href="/webmail/login" target="_blank" class="nav-link text-white" title="Give this link to mailbox owners for self-service password/MFA/app-password management">
|
||||
<i class="bi bi-mailbox2-flag me-2"></i>
|
||||
Webmail Portal
|
||||
<i class="bi bi-box-arrow-up-right ms-1 small"></i>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item mb-1">
|
||||
<form method="post" action="/pymta-manager/logout">
|
||||
<button type="submit" class="nav-link text-white w-100 text-start border-0 bg-transparent">
|
||||
|
||||
@@ -37,6 +37,10 @@
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-box-arrow-in-right me-1"></i>Sign in</button>
|
||||
</div>
|
||||
</form>
|
||||
<hr>
|
||||
<div class="d-grid">
|
||||
<a href="/pymta-manager/login" class="btn btn-outline-secondary btn-sm"><i class="bi bi-shield-lock me-1"></i>Login as Admin</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -92,3 +92,29 @@ func (a *App) requireMailboxAuth(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// mailboxNeedsMFASetup reports whether [Auth] enforce_mailbox_mfa applies to this
|
||||
// mailbox and it doesn't have a second factor configured yet — false if enforcement
|
||||
// is off, MFA is already set up, or the mailbox/its domain is explicitly exempt.
|
||||
// Used at webmail login time (see webmailLoginSubmit) to block the login outright,
|
||||
// not any specific action once logged in — app passwords (creating or using them)
|
||||
// are never gated by this, since IMAP/SMTP AUTH has no interactive MFA step to
|
||||
// enforce one on regardless.
|
||||
func (a *App) mailboxNeedsMFASetup(mbox *db.Mailbox) bool {
|
||||
if !a.Cfg.Section("Auth").Key("enforce_mailbox_mfa").MustBool(false) {
|
||||
return false
|
||||
}
|
||||
hasMFA := mbox.TOTPEnabled
|
||||
if !hasMFA {
|
||||
if n, _ := a.DB.CountMailboxWebAuthnCredentials(mbox.ID); n > 0 {
|
||||
hasMFA = true
|
||||
}
|
||||
}
|
||||
if hasMFA || mbox.MFAExempt {
|
||||
return false
|
||||
}
|
||||
if dom, err := a.DB.GetDomainByID(mbox.DomainID); err == nil && dom != nil && dom.MFAExempt {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -60,6 +60,19 @@ func (a *App) webmailLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// enforce_mailbox_mfa blocks login entirely — not just app-password creation —
|
||||
// for a mailbox with no MFA configured and no domain/mailbox-level exemption. This
|
||||
// is a hard gate: since the mailbox owner can't reach any page (including a
|
||||
// self-service TOTP/passkey setup form) without a session in the first place, an
|
||||
// admin must either set up MFA on their behalf or grant a (typically temporary)
|
||||
// exemption from the Edit Mailbox / Edit Domain pages to let them in and set it up
|
||||
// themselves. This never applies to IMAP/SMTP app-password auth, which has no
|
||||
// interactive step to enforce MFA on regardless.
|
||||
if a.mailboxNeedsMFASetup(mbox) {
|
||||
fail("Two-factor authentication is required for this mailbox but hasn't been set up yet. Contact your administrator.")
|
||||
return
|
||||
}
|
||||
|
||||
needsMFA := mbox.TOTPEnabled
|
||||
if !needsMFA {
|
||||
if n, _ := a.DB.CountMailboxWebAuthnCredentials(mbox.ID); n > 0 {
|
||||
|
||||
+15
-11
@@ -133,6 +133,7 @@ func (a *App) Mux() *http.ServeMux {
|
||||
mux.HandleFunc("POST "+Prefix+"/admins/add", a.addAdmin)
|
||||
mux.HandleFunc("GET "+Prefix+"/admins/{id}/edit", a.editAdminDomainsForm)
|
||||
mux.HandleFunc("POST "+Prefix+"/admins/{id}/edit", a.editAdminDomains)
|
||||
mux.HandleFunc("POST "+Prefix+"/admins/{id}/reset_mfa", a.resetAdminMFA)
|
||||
mux.HandleFunc("POST "+Prefix+"/admins/{id}/remove", a.removeAdmin)
|
||||
|
||||
mux.HandleFunc("GET "+Prefix+"/domains", a.domainsList)
|
||||
@@ -159,6 +160,7 @@ func (a *App) Mux() *http.ServeMux {
|
||||
mux.HandleFunc("POST "+Prefix+"/mailboxes/add", a.addMailbox)
|
||||
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/delete", a.disableMailbox)
|
||||
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/enable", a.enableMailbox)
|
||||
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/reset_mfa", a.resetMailboxMFA)
|
||||
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/remove", a.removeMailbox)
|
||||
mux.HandleFunc("GET "+Prefix+"/mailboxes/{id}/edit", a.editMailboxForm)
|
||||
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/edit", a.editMailbox)
|
||||
@@ -194,20 +196,22 @@ func (a *App) Mux() *http.ServeMux {
|
||||
mux.HandleFunc("POST "+Prefix+"/dkim/check_dns", a.checkDKIMDNS)
|
||||
mux.HandleFunc("POST "+Prefix+"/dkim/check_spf", a.checkSPFDNS)
|
||||
|
||||
mux.HandleFunc("GET "+Prefix+"/letsencrypt", a.letsEncryptPage)
|
||||
mux.HandleFunc("POST "+Prefix+"/letsencrypt/save", a.letsEncryptSave)
|
||||
mux.HandleFunc("POST "+Prefix+"/letsencrypt/obtain", a.letsEncryptObtainNow)
|
||||
mux.HandleFunc("POST "+Prefix+"/api/letsencrypt/upload_gcloud_key", a.uploadGCloudServiceAccount)
|
||||
// Server-wide config, not scoped to any domain — a domain-scoped admin has no
|
||||
// business reading or changing these, so every route here is global-admin-only.
|
||||
mux.HandleFunc("GET "+Prefix+"/letsencrypt", a.requireGlobalAdmin(a.letsEncryptPage))
|
||||
mux.HandleFunc("POST "+Prefix+"/letsencrypt/save", a.requireGlobalAdmin(a.letsEncryptSave))
|
||||
mux.HandleFunc("POST "+Prefix+"/letsencrypt/obtain", a.requireGlobalAdmin(a.letsEncryptObtainNow))
|
||||
mux.HandleFunc("POST "+Prefix+"/api/letsencrypt/upload_gcloud_key", a.requireGlobalAdmin(a.uploadGCloudServiceAccount))
|
||||
|
||||
mux.HandleFunc("GET "+Prefix+"/logs", a.logs)
|
||||
|
||||
mux.HandleFunc("GET "+Prefix+"/settings", a.settingsPage)
|
||||
mux.HandleFunc("POST "+Prefix+"/settings_update", a.settingsUpdate)
|
||||
mux.HandleFunc("POST "+Prefix+"/api/settings/test_database", a.testDatabaseConnection)
|
||||
mux.HandleFunc("POST "+Prefix+"/api/settings/upload_cert", a.uploadCert)
|
||||
mux.HandleFunc("POST "+Prefix+"/api/settings/upload_key", a.uploadKey)
|
||||
mux.HandleFunc("GET "+Prefix+"/api/settings/get_public_ip", a.getServerIP)
|
||||
mux.HandleFunc("POST "+Prefix+"/test_attachments_path", a.testAttachmentsPath)
|
||||
mux.HandleFunc("GET "+Prefix+"/settings", a.requireGlobalAdmin(a.settingsPage))
|
||||
mux.HandleFunc("POST "+Prefix+"/settings_update", a.requireGlobalAdmin(a.settingsUpdate))
|
||||
mux.HandleFunc("POST "+Prefix+"/api/settings/test_database", a.requireGlobalAdmin(a.testDatabaseConnection))
|
||||
mux.HandleFunc("POST "+Prefix+"/api/settings/upload_cert", a.requireGlobalAdmin(a.uploadCert))
|
||||
mux.HandleFunc("POST "+Prefix+"/api/settings/upload_key", a.requireGlobalAdmin(a.uploadKey))
|
||||
mux.HandleFunc("GET "+Prefix+"/api/settings/get_public_ip", a.requireGlobalAdmin(a.getServerIP))
|
||||
mux.HandleFunc("POST "+Prefix+"/test_attachments_path", a.requireGlobalAdmin(a.testAttachmentsPath))
|
||||
|
||||
mux.HandleFunc("GET "+Prefix+"/msg/content/{id}", a.viewMessageContent)
|
||||
mux.HandleFunc("GET "+Prefix+"/msg/attachment/{id}/download", a.downloadAttachment)
|
||||
|
||||
@@ -99,6 +99,9 @@ func newTestApp(t *testing.T) *App {
|
||||
imapSec, _ := cfg.NewSection("IMAP")
|
||||
imapSec.NewKey("imap_port", "1143")
|
||||
imapSec.NewKey("imap_tls_port", "1993")
|
||||
authSec, _ := cfg.NewSection("Auth")
|
||||
authSec.NewKey("enforce_admin_mfa", "false")
|
||||
authSec.NewKey("enforce_mailbox_mfa", "false")
|
||||
rspamdSec, _ := cfg.NewSection("Rspamd")
|
||||
rspamdSec.NewKey("enabled", "false")
|
||||
rspamdSec.NewKey("url", "http://127.0.0.1:11333")
|
||||
|
||||
@@ -224,8 +224,11 @@ func main() {
|
||||
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeHealthJSON(w, database, smtpRunning.Load())
|
||||
})
|
||||
// Most visitors are mailbox owners, not admins — default the bare root to the
|
||||
// self-service webmail login, with a "Login as Admin" button there for the admin
|
||||
// dashboard's login instead of requiring the admin URL to be typed by hand.
|
||||
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, webui.Prefix+"/", http.StatusFound)
|
||||
http.Redirect(w, r, webui.MailboxPrefix+"/login", http.StatusFound)
|
||||
})
|
||||
|
||||
if !*webOnly {
|
||||
|
||||
Reference in New Issue
Block a user