add MFA, user web mail portal
This commit is contained in:
@@ -82,6 +82,11 @@ var defaults = []struct {
|
|||||||
{"rp_display_name", "mailgoserver", ""},
|
{"rp_display_name", "mailgoserver", ""},
|
||||||
{"", "", `Full origin (scheme+host+port) the dashboard is served at, e.g. "https://mail.example.com"`},
|
{"", "", `Full origin (scheme+host+port) the dashboard is served at, e.g. "https://mail.example.com"`},
|
||||||
{"rp_origin", "http://localhost:5000", ""},
|
{"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", []defaultKV{
|
||||||
{"", "", "IMAP server configuration for mailbox retrieval (Thunderbird, etc.)"},
|
{"", "", "IMAP server configuration for mailbox retrieval (Thunderbird, etc.)"},
|
||||||
|
|||||||
@@ -3,10 +3,17 @@ package db
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type AdminUser struct {
|
type AdminUser struct {
|
||||||
ID int64
|
ID int64
|
||||||
Username string
|
Username string
|
||||||
PasswordHash 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
|
MustChangePassword bool
|
||||||
|
MustChangeUsername bool
|
||||||
TOTPSecret string
|
TOTPSecret string
|
||||||
TOTPEnabled bool
|
TOTPEnabled bool
|
||||||
IsGlobalAdmin bool
|
IsGlobalAdmin bool
|
||||||
|
|||||||
+49
-13
@@ -8,13 +8,13 @@ import (
|
|||||||
"time"
|
"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) {
|
func scanAdminUser(row *sql.Row) (*AdminUser, error) {
|
||||||
var u AdminUser
|
var u AdminUser
|
||||||
var createdAt string
|
var createdAt string
|
||||||
var createdBy sql.NullInt64
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -42,8 +42,11 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// SeedDefaultAdminIfEmpty creates the default admin account on a brand-new install
|
// 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
|
// (no admin users yet at all) with must_change_password AND must_change_username set,
|
||||||
// credentials can never be left in place silently.
|
// 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 {
|
func (d *DB) SeedDefaultAdminIfEmpty() error {
|
||||||
n, err := d.CountAdminUsers()
|
n, err := d.CountAdminUsers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -56,15 +59,19 @@ func (d *DB) SeedDefaultAdminIfEmpty() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateAdminUser inserts a new global-admin account (full access, no domain
|
// CreateAdminUser inserts a new global-admin account (full access, no domain
|
||||||
// restriction). mustChangePassword should be true for the seeded default account so
|
// restriction) — used by the delegation flow when a global admin grants another user
|
||||||
// it can't keep running on default credentials.
|
// 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) {
|
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)
|
username, passwordHash, mustChangePassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -82,7 +89,7 @@ func (d *DB) CreateScopedAdminUser(username, passwordHash string, createdBy int6
|
|||||||
}
|
}
|
||||||
defer tx.Rollback()
|
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)
|
username, passwordHash, createdBy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -128,7 +135,7 @@ func scanAdminUsers(rows *sql.Rows) ([]AdminUser, error) {
|
|||||||
var u AdminUser
|
var u AdminUser
|
||||||
var createdAt string
|
var createdAt string
|
||||||
var createdBy sql.NullInt64
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
u.CreatedAt, _ = parseTime(createdAt)
|
u.CreatedAt, _ = parseTime(createdAt)
|
||||||
@@ -214,10 +221,19 @@ func (d *DB) GetAdminUserByID(id int64) (*AdminUser, error) {
|
|||||||
return scanAdminUser(row)
|
return scanAdminUser(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateAdminCredentials mirrors the forced first-login change: new username,
|
// UpdateAdminCredentials mirrors the forced first-login change for the seeded default
|
||||||
// password hash, and clears must_change_password in one step.
|
// 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 {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +252,26 @@ func (d *DB) DisableAdminTOTP(id int64) error {
|
|||||||
return err
|
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 ---
|
// --- Sessions ---
|
||||||
|
|
||||||
func newSessionToken() string {
|
func newSessionToken() string {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ func (d *DB) ListDomains() ([]Domain, error) {
|
|||||||
var dm Domain
|
var dm Domain
|
||||||
var createdAt string
|
var createdAt string
|
||||||
var verifiedAt *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
|
return nil, err
|
||||||
}
|
}
|
||||||
dm.CreatedAt, _ = parseTime(createdAt)
|
dm.CreatedAt, _ = parseTime(createdAt)
|
||||||
@@ -81,6 +81,13 @@ func (d *DB) SetDomainActive(id int64, active bool) error {
|
|||||||
return err
|
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
|
// SetDomainVerified mirrors marking a domain as DNS-ownership-verified (or reverting
|
||||||
// it, e.g. if an admin wants to force re-verification).
|
// it, e.g. if an admin wants to force re-verification).
|
||||||
func (d *DB) SetDomainVerified(id int64, verified bool) error {
|
func (d *DB) SetDomainVerified(id int64, verified bool) error {
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import (
|
|||||||
"errors"
|
"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) {
|
func scanMailbox(row *sql.Row) (*Mailbox, error) {
|
||||||
var m Mailbox
|
var m Mailbox
|
||||||
var createdAt string
|
var createdAt string
|
||||||
var createdBy sql.NullInt64
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,7 @@ type MailboxWithDomain struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) ListMailboxes() ([]MailboxWithDomain, error) {
|
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`)
|
FROM esrv_mailboxes m JOIN esrv_domains dm ON dm.id = m.domain_id ORDER BY m.email`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -42,7 +42,7 @@ func (d *DB) ListMailboxes() ([]MailboxWithDomain, error) {
|
|||||||
var m MailboxWithDomain
|
var m MailboxWithDomain
|
||||||
var createdAt string
|
var createdAt string
|
||||||
var createdBy sql.NullInt64
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
m.CreatedAt, _ = parseTime(createdAt)
|
m.CreatedAt, _ = parseTime(createdAt)
|
||||||
@@ -65,7 +65,7 @@ func (d *DB) ListMailboxesForDomain(domainID int64) ([]Mailbox, error) {
|
|||||||
var m Mailbox
|
var m Mailbox
|
||||||
var createdAt string
|
var createdAt string
|
||||||
var createdBy sql.NullInt64
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
m.CreatedAt, _ = parseTime(createdAt)
|
m.CreatedAt, _ = parseTime(createdAt)
|
||||||
@@ -113,6 +113,13 @@ func (d *DB) SetMailboxActive(id int64, active bool) error {
|
|||||||
return err
|
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 {
|
func (d *DB) SetMailboxQuota(id int64, quotaBytes int64) error {
|
||||||
_, err := d.Exec(`UPDATE esrv_mailboxes SET quota_bytes = ? WHERE id = ?`, quotaBytes, id)
|
_, err := d.Exec(`UPDATE esrv_mailboxes SET quota_bytes = ? WHERE id = ?`, quotaBytes, id)
|
||||||
return err
|
return err
|
||||||
@@ -133,6 +140,26 @@ func (d *DB) DisableMailboxTOTP(id int64) error {
|
|||||||
return err
|
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,
|
// AddMailboxUsedBytes adjusts the cached running total by delta (positive on store,
|
||||||
// negative on delete) in a single statement, avoiding a read-modify-write race.
|
// negative on delete) in a single statement, avoiding a read-modify-write race.
|
||||||
func (d *DB) AddMailboxUsedBytes(id int64, delta int64) error {
|
func (d *DB) AddMailboxUsedBytes(id int64, delta int64) error {
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ type Mailbox struct {
|
|||||||
CreatedBy *int64
|
CreatedBy *int64
|
||||||
TOTPSecret string
|
TOTPSecret string
|
||||||
TOTPEnabled bool
|
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
|
// MailboxSession is a self-service webmail portal login — a parallel schema to
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ type Domain struct {
|
|||||||
VerificationToken string
|
VerificationToken string
|
||||||
IsVerified bool
|
IsVerified bool
|
||||||
VerifiedAt *time.Time
|
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 {
|
type Sender struct {
|
||||||
|
|||||||
@@ -54,14 +54,14 @@ func (d *DB) GetSenderByEmail(email string) (*Sender, error) {
|
|||||||
return &s, nil
|
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.
|
// scanDomain scans a row selected with domainColumns, in that order.
|
||||||
func scanDomain(row *sql.Row) (*Domain, error) {
|
func scanDomain(row *sql.Row) (*Domain, error) {
|
||||||
var dom Domain
|
var dom Domain
|
||||||
var createdAt string
|
var createdAt string
|
||||||
var verifiedAt sql.NullString
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-2
@@ -22,7 +22,8 @@ CREATE TABLE IF NOT EXISTS esrv_domains (
|
|||||||
verification_token TEXT NOT NULL DEFAULT '',
|
verification_token TEXT NOT NULL DEFAULT '',
|
||||||
is_verified INTEGER NOT NULL DEFAULT 0,
|
is_verified INTEGER NOT NULL DEFAULT 0,
|
||||||
verified_at DATETIME,
|
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 (
|
CREATE TABLE IF NOT EXISTS esrv_senders (
|
||||||
@@ -119,6 +120,7 @@ CREATE TABLE IF NOT EXISTS esrv_admin_users (
|
|||||||
username TEXT NOT NULL UNIQUE,
|
username TEXT NOT NULL UNIQUE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
must_change_password INTEGER NOT NULL DEFAULT 0,
|
must_change_password INTEGER NOT NULL DEFAULT 0,
|
||||||
|
must_change_username INTEGER NOT NULL DEFAULT 0,
|
||||||
totp_secret TEXT NOT NULL DEFAULT '',
|
totp_secret TEXT NOT NULL DEFAULT '',
|
||||||
totp_enabled INTEGER NOT NULL DEFAULT 0,
|
totp_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
is_global_admin 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_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_by INTEGER REFERENCES esrv_admin_users(id),
|
created_by INTEGER REFERENCES esrv_admin_users(id),
|
||||||
totp_secret TEXT NOT NULL DEFAULT '',
|
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
|
-- 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_secret TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE esrv_mailboxes ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0`,
|
`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_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 {
|
for _, stmt := range stmts {
|
||||||
db.Exec(stmt)
|
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.
|
// DB wraps *sql.DB with the query helpers below.
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ import (
|
|||||||
func (a *App) accountPage(w http.ResponseWriter, r *http.Request) {
|
func (a *App) accountPage(w http.ResponseWriter, r *http.Request) {
|
||||||
user := userFromContext(r)
|
user := userFromContext(r)
|
||||||
creds, _ := a.DB.ListWebAuthnCredentials(user.ID)
|
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.
|
// 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})
|
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) {
|
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
|
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) {
|
func (a *App) removeAdmin(w http.ResponseWriter, r *http.Request) {
|
||||||
target, ok := a.adminWithManageAccess(w, r)
|
target, ok := a.adminWithManageAccess(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
+34
-4
@@ -3,6 +3,7 @@ package webui
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"mailgoserver/internal/db"
|
"mailgoserver/internal/db"
|
||||||
@@ -53,6 +54,21 @@ func scopeFromContext(r *http.Request) accessScope {
|
|||||||
return s
|
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
|
// 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
|
// 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
|
// 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
needsMFA := user.TOTPEnabled
|
// hasMFA: this account already has a second factor configured (TOTP or a
|
||||||
if !needsMFA {
|
// 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 {
|
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)
|
http.Redirect(w, r, Prefix+"/login/mfa", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -154,6 +173,17 @@ func (a *App) requireAuth(next http.Handler) http.Handler {
|
|||||||
return
|
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)
|
scope, err := a.buildAccessScope(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.Logger.Error("build access scope: %v", err)
|
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)
|
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
|
||||||
return
|
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")
|
setFlash(w, "success", "Domain updated successfully")
|
||||||
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
|
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) {
|
func (a *App) firstLoginForm(w http.ResponseWriter, r *http.Request) {
|
||||||
user := userFromContext(r)
|
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:
|
// firstLoginSubmit mirrors the forced credential-change flow: always require a new
|
||||||
// require a new username and password before must_change_password clears.
|
// 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) {
|
func (a *App) firstLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
user := userFromContext(r)
|
user := userFromContext(r)
|
||||||
newUsername := strings.TrimSpace(r.FormValue("username"))
|
|
||||||
newPassword := r.FormValue("password")
|
newPassword := r.FormValue("password")
|
||||||
confirm := r.FormValue("password_confirm")
|
confirm := r.FormValue("password_confirm")
|
||||||
|
|
||||||
fail := func(msg string) {
|
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) {
|
if !isStrongPassword(newPassword) {
|
||||||
fail("Password must be at least 10 characters and include a letter, a number, and a symbol.")
|
fail("Password must be at least 10 characters and include a letter, a number, and a symbol.")
|
||||||
return
|
return
|
||||||
@@ -182,16 +182,32 @@ func (a *App) firstLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
|||||||
fail("Passwords don't match.")
|
fail("Passwords don't match.")
|
||||||
return
|
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)
|
hash, err := db.HashPassword(newPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail("Something went wrong. Try again.")
|
fail("Something went wrong. Try again.")
|
||||||
return
|
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 {
|
if err := a.DB.UpdateAdminCredentials(user.ID, newUsername, hash); err != nil {
|
||||||
fail("Something went wrong. Try again.")
|
fail("Something went wrong. Try again.")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -129,6 +129,23 @@ func (a *App) mailboxWithAccess(w http.ResponseWriter, r *http.Request) (mailbox
|
|||||||
return mailbox, true
|
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) {
|
func (a *App) disableMailbox(w http.ResponseWriter, r *http.Request) {
|
||||||
mailbox, ok := a.mailboxWithAccess(w, r)
|
mailbox, ok := a.mailboxWithAccess(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -226,6 +243,11 @@ func (a *App) editMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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")
|
setFlash(w, "success", "Mailbox updated successfully")
|
||||||
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
|
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["flashes"] = popFlashes(w, r)
|
||||||
data["health"] = a.checkHealth()
|
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,
|
// Sidebar badge counts (Domains/Senders/Mailboxes/IPs/DKIM Keys) — computed here,
|
||||||
// centrally, so every authenticated page shows them, not just the dashboard (which
|
// centrally, so every authenticated page shows them, not just the dashboard (which
|
||||||
// used to compute these itself and nowhere else did).
|
// 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)
|
http.Redirect(w, r, Prefix+"/settings", http.StatusFound)
|
||||||
return
|
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
|
changed := false
|
||||||
for _, name := range a.Cfg.SectionStrings() {
|
for _, name := range a.Cfg.SectionStrings() {
|
||||||
sec := a.Cfg.Section(name)
|
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 "page_title"}}Account Settings{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{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="row">
|
||||||
<div class="col-lg-6 mb-4">
|
<div class="col-lg-6 mb-4">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
@@ -35,14 +35,21 @@
|
|||||||
</td>
|
</td>
|
||||||
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" $u.CreatedAt}}</small></td>
|
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" $u.CreatedAt}}</small></td>
|
||||||
<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">
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
{{if not $u.IsGlobalAdmin}}
|
{{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>
|
<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}}
|
{{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">
|
<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>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{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/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/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>
|
<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>
|
<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>
|
</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 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>
|
||||||
|
|
||||||
|
<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="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
|
|||||||
@@ -21,6 +21,11 @@
|
|||||||
<label for="quota_gb" class="form-label">Storage Quota (GB)</label>
|
<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}}">
|
<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>
|
||||||
|
<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">
|
<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>
|
<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>
|
<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"}}
|
{{define "content"}}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
|
{{if .must_change_username}}
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
<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.
|
You're signed in with the default admin account. Choose a new username and password before continuing.
|
||||||
</div>
|
</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">
|
||||||
<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">
|
<div class="card-body">
|
||||||
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
|
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
|
||||||
<form method="POST" action="/pymta-manager/first-login">
|
<form method="POST" action="/pymta-manager/first-login">
|
||||||
|
{{if .must_change_username}}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="username" class="form-label">New username</label>
|
<label for="username" class="form-label">New username</label>
|
||||||
<input type="text" class="form-control" id="username" name="username" value="{{.username}}" required autofocus>
|
<input type="text" class="form-control" id="username" name="username" value="{{.username}}" required autofocus>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="password" class="form-label">New password</label>
|
<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 class="form-text">At least 10 characters, with a letter, a number, and a symbol.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<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>
|
<button type="submit" class="btn btn-primary"><i class="bi bi-box-arrow-in-right me-1"></i>Sign in</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
</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>
|
<a href="/pymta-manager/mailboxes/add" class="btn btn-primary"><i class="bi bi-mailbox me-2"></i>Add Mailbox</a>
|
||||||
</div>
|
</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">
|
||||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>All Mailboxes</h5></div>
|
<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">
|
<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}}/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}}/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>
|
<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}}
|
{{if $mailbox.IsActive}}
|
||||||
<form method="post" action="/pymta-manager/mailboxes/{{$mailbox.ID}}/delete" class="d-inline">
|
<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>
|
<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>
|
</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 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-header"><h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>TLS/SSL Configuration</h5></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|||||||
@@ -70,12 +70,14 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
{{if dget . "is_global_admin"}}
|
||||||
<li class="nav-item mb-1">
|
<li class="nav-item mb-1">
|
||||||
<a href="/pymta-manager/letsencrypt" class="nav-link text-white {{if eq (dget . "active") "letsencrypt"}}active{{end}}">
|
<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>
|
<i class="bi bi-patch-check me-2"></i>
|
||||||
Let's Encrypt
|
Let's Encrypt
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<li class="nav-item mb-1">
|
<li class="nav-item mb-1">
|
||||||
<a href="/pymta-manager/logs" class="nav-link text-white {{if eq (dget . "active") "logs"}}active{{end}}">
|
<a href="/pymta-manager/logs" class="nav-link text-white {{if eq (dget . "active") "logs"}}active{{end}}">
|
||||||
@@ -91,12 +93,14 @@
|
|||||||
</h6>
|
</h6>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
{{if dget . "is_global_admin"}}
|
||||||
<li class="nav-item mb-1">
|
<li class="nav-item mb-1">
|
||||||
<a href="/pymta-manager/settings" class="nav-link text-white {{if eq (dget . "active") "settings"}}active{{end}}">
|
<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>
|
<i class="bi bi-sliders me-2"></i>
|
||||||
Server Settings
|
Server Settings
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<li class="nav-item mb-1">
|
<li class="nav-item mb-1">
|
||||||
<a href="/pymta-manager/admins" class="nav-link text-white {{if eq (dget . "active") "admins"}}active{{end}}">
|
<a href="/pymta-manager/admins" class="nav-link text-white {{if eq (dget . "active") "admins"}}active{{end}}">
|
||||||
@@ -112,6 +116,14 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</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">
|
<li class="nav-item mb-1">
|
||||||
<form method="post" action="/pymta-manager/logout">
|
<form method="post" action="/pymta-manager/logout">
|
||||||
<button type="submit" class="nav-link text-white w-100 text-start border-0 bg-transparent">
|
<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>
|
<button type="submit" class="btn btn-primary"><i class="bi bi-box-arrow-in-right me-1"></i>Sign in</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -92,3 +92,29 @@ func (a *App) requireMailboxAuth(next http.Handler) http.Handler {
|
|||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
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
|
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
|
needsMFA := mbox.TOTPEnabled
|
||||||
if !needsMFA {
|
if !needsMFA {
|
||||||
if n, _ := a.DB.CountMailboxWebAuthnCredentials(mbox.ID); n > 0 {
|
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("POST "+Prefix+"/admins/add", a.addAdmin)
|
||||||
mux.HandleFunc("GET "+Prefix+"/admins/{id}/edit", a.editAdminDomainsForm)
|
mux.HandleFunc("GET "+Prefix+"/admins/{id}/edit", a.editAdminDomainsForm)
|
||||||
mux.HandleFunc("POST "+Prefix+"/admins/{id}/edit", a.editAdminDomains)
|
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("POST "+Prefix+"/admins/{id}/remove", a.removeAdmin)
|
||||||
|
|
||||||
mux.HandleFunc("GET "+Prefix+"/domains", a.domainsList)
|
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/add", a.addMailbox)
|
||||||
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/delete", a.disableMailbox)
|
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/delete", a.disableMailbox)
|
||||||
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/enable", a.enableMailbox)
|
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("POST "+Prefix+"/mailboxes/{id}/remove", a.removeMailbox)
|
||||||
mux.HandleFunc("GET "+Prefix+"/mailboxes/{id}/edit", a.editMailboxForm)
|
mux.HandleFunc("GET "+Prefix+"/mailboxes/{id}/edit", a.editMailboxForm)
|
||||||
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/edit", a.editMailbox)
|
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_dns", a.checkDKIMDNS)
|
||||||
mux.HandleFunc("POST "+Prefix+"/dkim/check_spf", a.checkSPFDNS)
|
mux.HandleFunc("POST "+Prefix+"/dkim/check_spf", a.checkSPFDNS)
|
||||||
|
|
||||||
mux.HandleFunc("GET "+Prefix+"/letsencrypt", a.letsEncryptPage)
|
// Server-wide config, not scoped to any domain — a domain-scoped admin has no
|
||||||
mux.HandleFunc("POST "+Prefix+"/letsencrypt/save", a.letsEncryptSave)
|
// business reading or changing these, so every route here is global-admin-only.
|
||||||
mux.HandleFunc("POST "+Prefix+"/letsencrypt/obtain", a.letsEncryptObtainNow)
|
mux.HandleFunc("GET "+Prefix+"/letsencrypt", a.requireGlobalAdmin(a.letsEncryptPage))
|
||||||
mux.HandleFunc("POST "+Prefix+"/api/letsencrypt/upload_gcloud_key", a.uploadGCloudServiceAccount)
|
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+"/logs", a.logs)
|
||||||
|
|
||||||
mux.HandleFunc("GET "+Prefix+"/settings", a.settingsPage)
|
mux.HandleFunc("GET "+Prefix+"/settings", a.requireGlobalAdmin(a.settingsPage))
|
||||||
mux.HandleFunc("POST "+Prefix+"/settings_update", a.settingsUpdate)
|
mux.HandleFunc("POST "+Prefix+"/settings_update", a.requireGlobalAdmin(a.settingsUpdate))
|
||||||
mux.HandleFunc("POST "+Prefix+"/api/settings/test_database", a.testDatabaseConnection)
|
mux.HandleFunc("POST "+Prefix+"/api/settings/test_database", a.requireGlobalAdmin(a.testDatabaseConnection))
|
||||||
mux.HandleFunc("POST "+Prefix+"/api/settings/upload_cert", a.uploadCert)
|
mux.HandleFunc("POST "+Prefix+"/api/settings/upload_cert", a.requireGlobalAdmin(a.uploadCert))
|
||||||
mux.HandleFunc("POST "+Prefix+"/api/settings/upload_key", a.uploadKey)
|
mux.HandleFunc("POST "+Prefix+"/api/settings/upload_key", a.requireGlobalAdmin(a.uploadKey))
|
||||||
mux.HandleFunc("GET "+Prefix+"/api/settings/get_public_ip", a.getServerIP)
|
mux.HandleFunc("GET "+Prefix+"/api/settings/get_public_ip", a.requireGlobalAdmin(a.getServerIP))
|
||||||
mux.HandleFunc("POST "+Prefix+"/test_attachments_path", a.testAttachmentsPath)
|
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/content/{id}", a.viewMessageContent)
|
||||||
mux.HandleFunc("GET "+Prefix+"/msg/attachment/{id}/download", a.downloadAttachment)
|
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, _ := cfg.NewSection("IMAP")
|
||||||
imapSec.NewKey("imap_port", "1143")
|
imapSec.NewKey("imap_port", "1143")
|
||||||
imapSec.NewKey("imap_tls_port", "1993")
|
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, _ := cfg.NewSection("Rspamd")
|
||||||
rspamdSec.NewKey("enabled", "false")
|
rspamdSec.NewKey("enabled", "false")
|
||||||
rspamdSec.NewKey("url", "http://127.0.0.1:11333")
|
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) {
|
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
writeHealthJSON(w, database, smtpRunning.Load())
|
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) {
|
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 {
|
if !*webOnly {
|
||||||
|
|||||||
Reference in New Issue
Block a user