diff --git a/internal/webui/account.go b/internal/webui/account.go index 92426a4..660a0d4 100644 --- a/internal/webui/account.go +++ b/internal/webui/account.go @@ -13,13 +13,13 @@ import ( // accountPage shows the admin their own profile: password change, TOTP MFA // enable/disable, and registered passkeys (passkey registration itself is wired up -// in webauthn.go). +// in webauthn.go). Only ever reached with MFA already satisfying enforce_admin_mfa +// (or enforcement off) — requireAuth redirects everywhere else, including here, to +// the isolated /mfa-setup page otherwise (see mfaSetupRequiredPage). func (a *App) accountPage(w http.ResponseWriter, r *http.Request) { user := userFromContext(r) creds, _ := a.DB.ListWebAuthnCredentials(user.ID) - 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}) + a.render(w, r, "account.html", M{"active": "account", "user": user, "passkeys": creds}) } // changePassword mirrors a normal (not forced) password change from account settings. @@ -59,6 +59,15 @@ func (a *App) changePassword(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, Prefix+"/account", http.StatusFound) } +// mfaSetupRequiredPage is the isolated, sidebar-free landing page requireAuth sends +// an admin to when enforce_admin_mfa applies and they have no second factor yet — the +// only page (besides the totp/passkey setup actions themselves) reachable until they +// set one up, so there's no visible navigation to anything else in the browser. +func (a *App) mfaSetupRequiredPage(w http.ResponseWriter, r *http.Request) { + user := userFromContext(r) + a.render(w, r, "mfa_setup_required.html", M{"username": user.Username, "flashes": popFlashes(w, r)}) +} + // totpSetupBegin generates a fresh (not-yet-enabled) TOTP secret and shows it as a // scannable QR code (rendered inline as a data: URI — simplest way to hand the // browser an image without a second round-trip route) plus the manual entry key. @@ -105,6 +114,7 @@ func (a *App) totpSetupConfirm(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, Prefix+"/account", http.StatusFound) return } + _ = a.DB.LogAuthAttempt("admin_mfa", user.Username, requestIP(r), true, "TOTP authenticator enabled") setFlash(w, "success", "Authenticator app MFA enabled") http.Redirect(w, r, Prefix+"/account", http.StatusFound) } @@ -114,6 +124,7 @@ func (a *App) totpDisable(w http.ResponseWriter, r *http.Request) { if err := a.DB.DisableAdminTOTP(user.ID); err != nil { setFlash(w, "error", "Something went wrong") } else { + _ = a.DB.LogAuthAttempt("admin_mfa", user.Username, requestIP(r), true, "TOTP authenticator disabled") setFlash(w, "success", "Authenticator app MFA disabled") } http.Redirect(w, r, Prefix+"/account", http.StatusFound) diff --git a/internal/webui/admins.go b/internal/webui/admins.go index c728acd..6487c3d 100644 --- a/internal/webui/admins.go +++ b/internal/webui/admins.go @@ -232,6 +232,7 @@ func (a *App) resetAdminMFA(w http.ResponseWriter, r *http.Request) { if err := a.DB.ResetAdminMFA(target.ID); err != nil { setFlash(w, "error", "Error resetting MFA") } else { + _ = a.DB.LogAuthAttempt("admin_mfa", target.Username, requestIP(r), true, "MFA reset by admin "+userFromContext(r).Username) setFlash(w, "success", "MFA reset for "+target.Username) } http.Redirect(w, r, Prefix+"/admins", http.StatusFound) diff --git a/internal/webui/auth.go b/internal/webui/auth.go index 50abe23..655abec 100644 --- a/internal/webui/auth.go +++ b/internal/webui/auth.go @@ -3,7 +3,6 @@ package webui import ( "context" "net/http" - "strings" "time" "mailgoserver/internal/db" @@ -54,6 +53,26 @@ func scopeFromContext(r *http.Request) accessScope { return s } +// adminMFASetupExempt is the strict allowlist for an admin with enforce_admin_mfa +// applying and no second factor yet: the isolated setup page itself, plus the actual +// form/API actions needed to complete TOTP or passkey enrollment. Everything else — +// including /account itself — redirects to /mfa-setup, so there's no visible +// navigation to any other route until MFA is actually configured. +func adminMFASetupExempt(method, path string) bool { + if method == http.MethodGet { + return path == Prefix+"/mfa-setup" + } + if method != http.MethodPost { + return false + } + switch path { + case Prefix + "/account/totp/setup", Prefix + "/account/totp/confirm", + Prefix + "/account/passkey/begin", Prefix + "/account/passkey/finish": + return true + } + return false +} + // 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 @@ -173,13 +192,15 @@ func (a *App) requireAuth(next http.Handler) http.Handler { return } - // enforce_admin_mfa applies to every admin, global or scoped — force setup at - // /account (which has the TOTP/passkey enrollment forms) before anything else - // is reachable, mirroring the must_change_password gate above. Checked after - // must_change_password so a brand-new admin sets a real password first. + // enforce_admin_mfa applies to every admin, global or scoped — an admin with no + // second factor yet is sent to the isolated /mfa-setup page (no sidebar, no + // other route reachable except the actual totp/passkey setup actions) instead + // of anywhere they'd otherwise have access, 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) + if !adminMFASetupExempt(r.Method, r.URL.Path) { + http.Redirect(w, r, Prefix+"/mfa-setup", http.StatusFound) return } } diff --git a/internal/webui/auth_audit_log_test.go b/internal/webui/auth_audit_log_test.go new file mode 100644 index 0000000..3224fa6 --- /dev/null +++ b/internal/webui/auth_audit_log_test.go @@ -0,0 +1,233 @@ +package webui + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + "time" + + "github.com/pquerna/otp/totp" + "mailgoserver/internal/db" +) + +// authLogsFor issues an authenticated GET /logs?type=auth and returns the raw body, +// used below to check which auth-log rows a given admin session can see. +func authLogsFor(t *testing.T, mux http.Handler, cookie *http.Cookie) string { + t.Helper() + req := httptest.NewRequest(http.MethodGet, Prefix+"/logs?type=auth", nil) + req.AddCookie(cookie) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("/logs?type=auth: status=%d", rec.Code) + } + return rec.Body.String() +} + +// TestAdminLoginLogsAuthAttempts confirms both a failed and a successful admin +// dashboard login are recorded to the audit log. +func TestAdminLoginLogsAuthAttempts(t *testing.T) { + app := newTestApp(t) + mux := app.Mux() + + hash, err := db.HashPassword("correct-horse-battery-1!") + if err != nil { + t.Fatal(err) + } + if _, err := app.DB.CreateAdminUser("audituser", hash, false); err != nil { + t.Fatal(err) + } + + // Wrong password. + form := url.Values{"username": {"audituser"}, "password": {"wrong-password"}} + req := httptest.NewRequest(http.MethodPost, Prefix+"/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + mux.ServeHTTP(httptest.NewRecorder(), req) + + // Correct password. + form = url.Values{"username": {"audituser"}, "password": {"correct-horse-battery-1!"}} + req = httptest.NewRequest(http.MethodPost, Prefix+"/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + mux.ServeHTTP(httptest.NewRecorder(), req) + + logs, err := app.DB.ListRecentAuthLogs(50) + if err != nil { + t.Fatal(err) + } + var sawFail, sawSuccess bool + for _, l := range logs { + if l.AuthType != "admin_login" || l.Identifier != "audituser" { + continue + } + if !l.Success { + sawFail = true + } else { + sawSuccess = true + } + } + if !sawFail { + t.Error("expected a failed admin_login entry for the wrong-password attempt") + } + if !sawSuccess { + t.Error("expected a successful admin_login entry for the correct-password attempt") + } +} + +// TestAdminMFAEventsLogged confirms enabling/disabling TOTP, and an admin resetting +// another admin's MFA, all produce admin_mfa audit entries. +func TestAdminMFAEventsLogged(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", false); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, Prefix+"/account/totp/confirm", strings.NewReader(url.Values{"code": {totpCodeFor(t, "JBSWY3DPEHPK3PXP")}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + mux.ServeHTTP(httptest.NewRecorder(), req) + + req = httptest.NewRequest(http.MethodPost, Prefix+"/account/totp/disable", nil) + req.AddCookie(cookie) + mux.ServeHTTP(httptest.NewRecorder(), req) + + targetID, err := app.DB.CreateAdminUser("reset-target", mustHash(t), false) + 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(cookie) + mux.ServeHTTP(httptest.NewRecorder(), req) + + logs, err := app.DB.ListRecentAuthLogs(50) + if err != nil { + t.Fatal(err) + } + var sawEnabled, sawDisabled, sawReset bool + for _, l := range logs { + if l.AuthType != "admin_mfa" { + continue + } + switch { + case strings.Contains(l.Message, "enabled") && l.Identifier == "test-admin": + sawEnabled = true + case strings.Contains(l.Message, "disabled") && l.Identifier == "test-admin": + sawDisabled = true + case strings.Contains(l.Message, "reset by admin") && l.Identifier == "reset-target": + sawReset = true + } + } + if !sawEnabled { + t.Error("expected an admin_mfa entry for TOTP enabled") + } + if !sawDisabled { + t.Error("expected an admin_mfa entry for TOTP disabled") + } + if !sawReset { + t.Error("expected an admin_mfa entry for the admin-initiated reset") + } +} + +// TestWebmailLoginLogsAuthAttempts mirrors TestAdminLoginLogsAuthAttempts for the +// self-service webmail portal. +func TestWebmailLoginLogsAuthAttempts(t *testing.T) { + app := newTestApp(t) + mux := app.Mux() + + mailboxes, err := app.DB.ListMailboxes() + if err != nil || len(mailboxes) == 0 { + t.Fatal("no seeded mailbox") + } + email := mailboxes[0].Email + + form := url.Values{"email": {email}, "password": {"wrong-password"}} + req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + mux.ServeHTTP(httptest.NewRecorder(), req) + + form = url.Values{"email": {email}, "password": {"testpass123"}} + req = httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + mux.ServeHTTP(httptest.NewRecorder(), req) + + logs, err := app.DB.ListRecentAuthLogs(50) + if err != nil { + t.Fatal(err) + } + var sawFail, sawSuccess bool + for _, l := range logs { + if l.AuthType != "webmail_login" || l.Identifier != email { + continue + } + if !l.Success { + sawFail = true + } else { + sawSuccess = true + } + } + if !sawFail { + t.Error("expected a failed webmail_login entry for the wrong-password attempt") + } + if !sawSuccess { + t.Error("expected a successful webmail_login entry for the correct-password attempt") + } +} + +// TestAdminAuditLogsHiddenFromScopedAdmins confirms admin_login/admin_mfa entries +// (identified by admin username, with no domain to attribute them to) are visible +// only to global admins, while webmail_login/mailbox_mfa entries (identified by +// mailbox email) remain visible to a scoped admin for their own domain. +func TestAdminAuditLogsHiddenFromScopedAdmins(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") + } + domainName := domains[0].DomainName + + if err := app.DB.LogAuthAttempt("admin_login", "some-admin-username", "127.0.0.1", true, "Login successful"); err != nil { + t.Fatal(err) + } + if err := app.DB.LogAuthAttempt("webmail_login", "owner@"+domainName, "127.0.0.1", true, "Login successful"); err != nil { + t.Fatal(err) + } + + scopedCookie := scopedLogin(t, app, "scoped-log-viewer", []int64{domains[0].ID}) + scopedBody := authLogsFor(t, mux, scopedCookie) + if strings.Contains(scopedBody, "some-admin-username") { + t.Error("a scoped admin should not see admin_login entries at all") + } + if !strings.Contains(scopedBody, "owner@"+domainName) { + t.Error("a scoped admin should see webmail_login entries for their own domain") + } + + globalCookie := loginSession(t, app) + globalBody := authLogsFor(t, mux, globalCookie) + if !strings.Contains(globalBody, "some-admin-username") { + t.Error("a global admin should see admin_login entries") + } +} + +// totpCodeFor generates a valid current TOTP code for a secret — used to drive +// account.go's totpSetupConfirm through a real form submission. +func totpCodeFor(t *testing.T, secret string) string { + t.Helper() + code, err := totp.GenerateCode(secret, time.Now()) + if err != nil { + t.Fatal(err) + } + return code +} diff --git a/internal/webui/login.go b/internal/webui/login.go index 33fcd2a..7efe0eb 100644 --- a/internal/webui/login.go +++ b/internal/webui/login.go @@ -58,6 +58,7 @@ func (a *App) loginSubmit(w http.ResponseWriter, r *http.Request) { return } if user == nil || !db.CheckPassword(password, user.PasswordHash) { + _ = a.DB.LogAuthAttempt("admin_login", username, requestIP(r), false, "Incorrect username or password") fail("Incorrect username or password.") return } @@ -75,6 +76,7 @@ func (a *App) loginSubmit(w http.ResponseWriter, r *http.Request) { fail("Something went wrong. Try again.") return } + _ = a.DB.LogAuthAttempt("admin_login", username, requestIP(r), true, "Login successful") setSessionCookie(w, token, r.TLS != nil) http.Redirect(w, r, redirectTarget(next), http.StatusFound) return @@ -127,6 +129,7 @@ func (a *App) mfaSubmit(w http.ResponseWriter, r *http.Request) { code := strings.TrimSpace(r.FormValue("code")) if !user.TOTPEnabled || !totp.Validate(code, user.TOTPSecret) { + _ = a.DB.LogAuthAttempt("admin_login", user.Username, requestIP(r), false, "Invalid MFA code") hasPasskeys, _ := a.DB.CountWebAuthnCredentials(userID) a.render(w, r, "login_mfa.html", M{ "next": next, "totp_enabled": user.TOTPEnabled, "has_passkeys": hasPasskeys > 0, "error": "Invalid code.", @@ -140,6 +143,7 @@ func (a *App) mfaSubmit(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, Prefix+"/login", http.StatusFound) return } + _ = a.DB.LogAuthAttempt("admin_login", user.Username, requestIP(r), true, "Login successful (authenticator app)") clearPendingMFACookie(w) setSessionCookie(w, token, r.TLS != nil) http.Redirect(w, r, redirectTarget(next), http.StatusFound) diff --git a/internal/webui/logs.go b/internal/webui/logs.go index d91f7b6..51cdcb9 100644 --- a/internal/webui/logs.go +++ b/internal/webui/logs.go @@ -22,7 +22,16 @@ func (a *App) logs(w http.ResponseWriter, r *http.Request) { setFlash(w, "error", "Error loading logs") } emailAllowed := func(e db.EmailLog) bool { return isGlobal || allowedNames[emailDomain(e.MailFrom)] } - authAllowed := func(au db.AuthLog) bool { return isGlobal || allowedNames[authLogDomain(au.Identifier)] } + // admin_login/admin_mfa entries are identified by admin username, not a mailbox + // email — there's no domain to attribute them to (a scoped admin's own username + // could otherwise coincidentally collide with a domain name they're allowed to + // see), so they're global-admin-only regardless of the identifier heuristic below. + authAllowed := func(au db.AuthLog) bool { + if au.AuthType == "admin_login" || au.AuthType == "admin_mfa" { + return isGlobal + } + return isGlobal || allowedNames[authLogDomain(au.Identifier)] + } filterType := r.URL.Query().Get("type") if filterType == "" { diff --git a/internal/webui/mailboxes.go b/internal/webui/mailboxes.go index 1de7af3..6ad9919 100644 --- a/internal/webui/mailboxes.go +++ b/internal/webui/mailboxes.go @@ -141,6 +141,7 @@ func (a *App) resetMailboxMFA(w http.ResponseWriter, r *http.Request) { if err := a.DB.ResetMailboxMFA(mailbox.ID); err != nil { setFlash(w, "error", "Error resetting MFA") } else { + _ = a.DB.LogAuthAttempt("mailbox_mfa", mailbox.Email, requestIP(r), true, "MFA reset by admin "+userFromContext(r).Username) setFlash(w, "success", "MFA reset for "+mailbox.Email) } http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound) diff --git a/internal/webui/mfa_enforcement_test.go b/internal/webui/mfa_enforcement_test.go index 42dac87..8ce4883 100644 --- a/internal/webui/mfa_enforcement_test.go +++ b/internal/webui/mfa_enforcement_test.go @@ -10,11 +10,11 @@ import ( "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) { +// TestAdminMFAEnforcementForcesIsolatedSetupThenReleases confirms enforce_admin_mfa +// redirects EVERY route — including /account itself — to the isolated /mfa-setup +// page for an admin with no second factor yet, until they actually set one up, after +// which normal access (including /account) resumes. +func TestAdminMFAEnforcementForcesIsolatedSetupThenReleases(t *testing.T) { app := newTestApp(t) app.Cfg.Section("Auth").Key("enforce_admin_mfa").SetValue("true") mux := app.Mux() @@ -33,37 +33,45 @@ func TestAdminMFAEnforcementForcesSetupThenReleases(t *testing.T) { } cookie := &http.Cookie{Name: sessionCookieName, Value: token} - // Blocked from an ordinary page, redirected to /account. - req := httptest.NewRequest(http.MethodGet, Prefix+"/domains", nil) + // Blocked from an ordinary page, AND from /account, both redirected to /mfa-setup. + for _, path := range []string{Prefix + "/domains", Prefix + "/account"} { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.AddCookie(cookie) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != Prefix+"/mfa-setup" { + t.Fatalf("%s: expected redirect to /mfa-setup, got %d Location=%q", path, rec.Code, rec.Header().Get("Location")) + } + } + + // The isolated setup page itself must be reachable and show no sidebar/nav. + req := httptest.NewRequest(http.MethodGet, Prefix+"/mfa-setup", 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()) + t.Fatalf("/mfa-setup: 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") + body := rec.Body.String() + if !strings.Contains(body, "Two-factor authentication required") { + t.Error("expected the MFA-required heading on /mfa-setup") + } + if strings.Contains(body, "sidebar") || strings.Contains(body, `href="/pymta-manager/domains"`) { + t.Error("expected no sidebar/navigation on the isolated setup page") } - // Once TOTP is enabled, other pages become reachable again. + // Once TOTP is enabled, both /domains and /account 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) + for _, path := range []string{Prefix + "/domains", Prefix + "/account"} { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.AddCookie(cookie) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s: expected reachable after enabling MFA, got %d", path, rec.Code) + } } } @@ -83,13 +91,13 @@ func TestAdminMFAEnforcementOffByDefault(t *testing.T) { } } -// 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) { +// TestMailboxMFAEnforcementLetsLoginThroughButBlocksPasswordChange confirms +// enforce_mailbox_mfa no longer blocks login itself for a mailbox with no MFA +// configured — it lands them on the dashboard (which has the TOTP/passkey setup +// cards) and only blocks changing the account password until MFA is set up. App +// passwords are deliberately untouched throughout; see mailboxNeedsMFASetup's doc +// comment. +func TestMailboxMFAEnforcementLetsLoginThroughButIsolatesEverythingElse(t *testing.T) { app := newTestApp(t) app.Cfg.Section("Auth").Key("enforce_mailbox_mfa").SetValue("true") mux := app.Mux() @@ -108,60 +116,115 @@ func TestMailboxMFAEnforcementBlocksLogin(t *testing.T) { 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")) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != MailboxPrefix+"/" { + t.Fatalf("expected login itself to succeed, got %d Location=%q", rec.Code, rec.Header().Get("Location")) + } + var cookie *http.Cookie + for _, c := range rec.Result().Cookies() { + if c.Name == mailboxSessionCookieName { + cookie = c + } + } + if cookie == nil { + t.Fatal("expected a session cookie despite no MFA configured") + } + + // The dashboard, password change, and app-password creation are ALL redirected + // to the isolated setup page — nothing else is reachable in the browser. + blockedGets := []string{MailboxPrefix + "/"} + for _, path := range blockedGets { + req = httptest.NewRequest(http.MethodGet, path, nil) + req.AddCookie(cookie) + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != MailboxPrefix+"/mfa-setup" { + t.Fatalf("GET %s: expected redirect to /mfa-setup, got %d Location=%q", path, rec.Code, rec.Header().Get("Location")) + } + } + + pwForm := url.Values{"current_password": {"mailbox-owner-password-1!"}, "new_password": {"NewPassw0rd!!"}, "new_password_confirm": {"NewPassw0rd!!"}} + req = httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/password", strings.NewReader(pwForm.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") != MailboxPrefix+"/mfa-setup" { + t.Fatalf("password change: expected redirect to /mfa-setup, got %d", rec.Code) + } + stillOld, err := app.DB.GetMailboxByID(mboxID) + if err != nil || stillOld == nil { + t.Fatal(err) + } + if !db.CheckPassword("mailbox-owner-password-1!", stillOld.PasswordHash) { + t.Fatal("password should not have changed") + } + + appForm := url.Values{"label": {"laptop"}} + req = httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/apppasswords/add", strings.NewReader(appForm.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") != MailboxPrefix+"/mfa-setup" { + t.Fatalf("app password creation: expected redirect to /mfa-setup, got %d", rec.Code) + } + if passwords, _ := app.DB.ListAppPasswordsForMailbox(mboxID); len(passwords) != 0 { + t.Fatalf("app password creation should have been blocked in the browser, got %d created", len(passwords)) + } + + // The isolated setup page itself is reachable and shows no other portal content. + req = httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mfa-setup", nil) + req.AddCookie(cookie) + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("/mfa-setup: status=%d body=%s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + if !strings.Contains(body, "Two-factor authentication required") { + t.Error("expected the MFA-required heading on /mfa-setup") + } + if strings.Contains(body, "App Passwords") || strings.Contains(body, "Change Password") { + t.Error("expected no other portal sections on the isolated setup page") + } + + // Once TOTP is configured, everything works normally again — dashboard, password + // change, and app passwords. + if err := app.DB.SetMailboxTOTPSecret(mboxID, "JBSWY3DPEHPK3PXP", true); err != nil { + t.Fatal(err) + } + req = httptest.NewRequest(http.MethodGet, MailboxPrefix+"/", nil) + req.AddCookie(cookie) + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("dashboard: expected reachable after enabling MFA, got %d", rec.Code) + } + + req = httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/password", strings.NewReader(pwForm.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, req) + updated, err := app.DB.GetMailboxByID(mboxID) + if err != nil || updated == nil { + t.Fatal(err) + } + if !db.CheckPassword("NewPassw0rd!!", updated.PasswordHash) { + t.Fatal("password should have changed once MFA is configured") + } + + req = httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/apppasswords/add", strings.NewReader(appForm.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if passwords, _ := app.DB.ListAppPasswordsForMailbox(mboxID); len(passwords) != 1 { + t.Fatalf("expected app password creation to succeed once MFA is configured, got %d", len(passwords)) } } diff --git a/internal/webui/render.go b/internal/webui/render.go index 91c41f6..4d4d48a 100644 --- a/internal/webui/render.go +++ b/internal/webui/render.go @@ -132,15 +132,18 @@ var pages = []string{ "ips.html", "add_ip.html", "edit_ip.html", "dkim.html", "edit_dkim.html", "settings.html", "letsencrypt.html", "logs.html", "view_message_content.html", "error.html", - "account.html", "first_login.html", "totp_setup.html", + "account.html", "first_login.html", "admins.html", "add_admin.html", "edit_admin.html", } -// standalonePages are pre-login screens — they intentionally don't use base.html's -// sidebar/dashboard chrome, since the visitor isn't authenticated yet. +// standalonePages don't use base.html's sidebar/dashboard chrome: pre-login screens +// (not authenticated yet) and the forced-MFA-setup flow (totp_setup.html included — +// deliberately isolated so an account with MFA enforced but not yet configured has +// no visible navigation to anything else, matching the enforcement gate in +// requireAuth/requireMailboxAuth that blocks every other route anyway). var standalonePages = []string{ - "login.html", "login_mfa.html", - "webmail_login.html", "webmail_login_mfa.html", "webmail_account.html", "webmail_totp_setup.html", + "login.html", "login_mfa.html", "mfa_setup_required.html", "totp_setup.html", + "webmail_login.html", "webmail_login_mfa.html", "webmail_account.html", "webmail_totp_setup.html", "webmail_mfa_setup_required.html", } // loadTemplates parses from the embedded assets FS (see embed.go), not the diff --git a/internal/webui/templates/account.html b/internal/webui/templates/account.html index c13c2d1..a656472 100644 --- a/internal/webui/templates/account.html +++ b/internal/webui/templates/account.html @@ -2,12 +2,6 @@ {{define "page_title"}}Account Settings{{end}} {{define "content"}} -{{if .mfa_required}} -
Your administrator requires MFA for every account (signed in as {{.username}}). Set up one of the options below to continue — nothing else is accessible until then.
+Use your device's built-in security (fingerprint, face, or a hardware security key).
+Use Google Authenticator, 1Password, or any TOTP app.
+ +Can't scan? Enter this key manually:
+{{.secret}}
-{{define "content"}}
-Can't scan? Enter this key manually:
-{{.secret}}
-
-
+ Your administrator requires MFA for this mailbox ({{.email}}). Set up one of the options below to continue — nothing else is accessible until then. Any app passwords you already have keep working for email as normal.
+Use your device's built-in security (fingerprint, face, or a hardware security key).
+Use Google Authenticator, 1Password, or any TOTP app.
+