From 6063f95504d714cadb0ff234509cd14f3585092a Mon Sep 17 00:00:00 2001 From: nahakubuilder Date: Thu, 13 Aug 2026 10:40:27 +0100 Subject: [PATCH] mfa fixing --- internal/webui/account.go | 19 +- internal/webui/admins.go | 1 + internal/webui/auth.go | 35 ++- internal/webui/auth_audit_log_test.go | 233 ++++++++++++++++++ internal/webui/login.go | 4 + internal/webui/logs.go | 11 +- internal/webui/mailboxes.go | 1 + internal/webui/mfa_enforcement_test.go | 229 ++++++++++------- internal/webui/render.go | 13 +- internal/webui/templates/account.html | 6 - internal/webui/templates/login_mfa.html | 24 +- .../webui/templates/mfa_setup_required.html | 111 +++++++++ internal/webui/templates/totp_setup.html | 63 +++-- .../webui/templates/webmail_login_mfa.html | 24 +- .../templates/webmail_mfa_setup_required.html | 111 +++++++++ internal/webui/utils.go | 21 ++ internal/webui/webauthn.go | 4 + internal/webui/webmail_account.go | 18 +- internal/webui/webmail_auth.go | 41 ++- internal/webui/webmail_login.go | 17 +- internal/webui/webmail_webauthn.go | 4 + internal/webui/webui.go | 2 + 22 files changed, 834 insertions(+), 158 deletions(-) create mode 100644 internal/webui/auth_audit_log_test.go create mode 100644 internal/webui/templates/mfa_setup_required.html create mode 100644 internal/webui/templates/webmail_mfa_setup_required.html 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 two-factor authentication for all admin accounts. Set up an authenticator app or a passkey below to continue using the dashboard. -
-{{end}}
diff --git a/internal/webui/templates/login_mfa.html b/internal/webui/templates/login_mfa.html index 415e464..1125cdc 100644 --- a/internal/webui/templates/login_mfa.html +++ b/internal/webui/templates/login_mfa.html @@ -27,22 +27,26 @@ {{if .has_passkeys}}
-
- {{if .totp_enabled}}
or
{{end}} + {{if .totp_enabled}} + + {{end}} {{end}} {{if .totp_enabled}} -
+
- +
- +
{{end}} @@ -66,6 +70,16 @@ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } + const showTotpLink = document.getElementById('show-totp-link'); + if (showTotpLink) { + showTotpLink.addEventListener('click', function(e) { + e.preventDefault(); + document.getElementById('totp-form').classList.remove('d-none'); + document.getElementById('code').focus(); + this.parentElement.classList.add('d-none'); + }); + } + const passkeyBtn = document.getElementById('passkey-btn'); if (passkeyBtn) { passkeyBtn.addEventListener('click', async function() { diff --git a/internal/webui/templates/mfa_setup_required.html b/internal/webui/templates/mfa_setup_required.html new file mode 100644 index 0000000..832fd4d --- /dev/null +++ b/internal/webui/templates/mfa_setup_required.html @@ -0,0 +1,111 @@ +{{define "mfa_setup_required.html"}} + + + + + + Set up two-factor authentication - mailgoserver + + + + + +
+ {{range .flashes}} + + {{end}} +
+ +
+
+ +

Two-factor authentication 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.

+
+ +
+
Passkey Recommended
+
+

Use your device's built-in security (fingerprint, face, or a hardware security key).

+ +
+
+
+ +
+
Authenticator App
+
+

Use Google Authenticator, 1Password, or any TOTP app.

+
+ +
+
+
+
+ + + + + +{{end}} diff --git a/internal/webui/templates/totp_setup.html b/internal/webui/templates/totp_setup.html index d538979..59933e6 100644 --- a/internal/webui/templates/totp_setup.html +++ b/internal/webui/templates/totp_setup.html @@ -1,30 +1,45 @@ -{{define "title"}}Set up authenticator app{{end}} -{{define "page_title"}}Set up authenticator app{{end}} +{{define "totp_setup.html"}} + + + + + + Set up authenticator app - mailgoserver + + + + + +
+
+
+
+
Scan with your authenticator app
+
+ {{if .qr_data_uri}} + TOTP QR code + {{end}} +

Can't scan? Enter this key manually:

+ {{.secret}} -{{define "content"}} -
-
-
-
Scan with your authenticator app
-
- {{if .qr_data_uri}} - TOTP QR code - {{end}} -

Can't scan? Enter this key manually:

- {{.secret}} - -
-
- - + +
+ + +
+
+ Cancel + +
+
-
- Cancel - -
- +
-
+ + {{end}} diff --git a/internal/webui/templates/webmail_login_mfa.html b/internal/webui/templates/webmail_login_mfa.html index 5442fdd..64a8bed 100644 --- a/internal/webui/templates/webmail_login_mfa.html +++ b/internal/webui/templates/webmail_login_mfa.html @@ -27,21 +27,25 @@ {{if .has_passkeys}}
-
- {{if .totp_enabled}}
or
{{end}} + {{if .totp_enabled}} + + {{end}} {{end}} {{if .totp_enabled}} -
+
- +
- +
{{end}} @@ -65,6 +69,16 @@ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } + const showTotpLink = document.getElementById('show-totp-link'); + if (showTotpLink) { + showTotpLink.addEventListener('click', function(e) { + e.preventDefault(); + document.getElementById('totp-form').classList.remove('d-none'); + document.getElementById('code').focus(); + this.parentElement.classList.add('d-none'); + }); + } + const passkeyBtn = document.getElementById('passkey-btn'); if (passkeyBtn) { passkeyBtn.addEventListener('click', async function() { diff --git a/internal/webui/templates/webmail_mfa_setup_required.html b/internal/webui/templates/webmail_mfa_setup_required.html new file mode 100644 index 0000000..2819835 --- /dev/null +++ b/internal/webui/templates/webmail_mfa_setup_required.html @@ -0,0 +1,111 @@ +{{define "webmail_mfa_setup_required.html"}} + + + + + + Set up two-factor authentication - Webmail + + + + + +
+ {{range .flashes}} + + {{end}} +
+ +
+
+ +

Two-factor authentication required

+

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.

+
+ +
+
Passkey Recommended
+
+

Use your device's built-in security (fingerprint, face, or a hardware security key).

+ +
+
+
+ +
+
Authenticator App
+
+

Use Google Authenticator, 1Password, or any TOTP app.

+
+ +
+
+
+
+ + + + + +{{end}} diff --git a/internal/webui/utils.go b/internal/webui/utils.go index 49e6c02..a6d5ab7 100644 --- a/internal/webui/utils.go +++ b/internal/webui/utils.go @@ -45,6 +45,27 @@ func fetchBody(client *http.Client, url string) string { return string(b) } +// requestIP returns the best-effort client IP for an HTTP request — the first hop of +// X-Forwarded-For if present (this app is documented to run behind a reverse proxy), +// falling back to the direct connection's address with its port stripped. +func requestIP(r *http.Request) string { + if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { + if i := strings.Index(fwd, ","); i >= 0 { + fwd = fwd[:i] + } + if ip := strings.TrimSpace(fwd); ip != "" { + return ip + } + } + if realIP := r.Header.Get("X-Real-IP"); realIP != "" { + return realIP + } + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + return r.RemoteAddr +} + // resolverAt builds a resolver pinned to a specific DNS server, mirroring // utils.check_dns_record's hardcoded Cloudflare resolver (1.1.1.1), 5s timeout. func resolverAt(serverIP string) *net.Resolver { diff --git a/internal/webui/webauthn.go b/internal/webui/webauthn.go index 23fda2c..5f9f7d1 100644 --- a/internal/webui/webauthn.go +++ b/internal/webui/webauthn.go @@ -150,6 +150,7 @@ func (a *App) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusInternalServerError, M{"error": "Could not save passkey"}) return } + _ = a.DB.LogAuthAttempt("admin_mfa", user.Username, requestIP(r), true, "Passkey added: "+name) writeJSON(w, http.StatusOK, M{"success": true}) } @@ -158,6 +159,7 @@ func (a *App) passkeyRemove(w http.ResponseWriter, r *http.Request) { if err := a.DB.DeleteWebAuthnCredential(pathID(r), user.ID); err != nil { setFlash(w, "error", "Could not remove passkey") } else { + _ = a.DB.LogAuthAttempt("admin_mfa", user.Username, requestIP(r), true, "Passkey removed") setFlash(w, "success", "Passkey removed") } http.Redirect(w, r, Prefix+"/account", http.StatusFound) @@ -228,6 +230,7 @@ func (a *App) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) { } if _, err := wa.FinishLogin(wu, *session, r); err != nil { clearWebauthnSession(w) + _ = a.DB.LogAuthAttempt("admin_login", user.Username, requestIP(r), false, "Passkey verification failed") writeJSON(w, http.StatusUnauthorized, M{"error": "Passkey verification failed"}) return } @@ -238,6 +241,7 @@ func (a *App) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusInternalServerError, M{"error": "Could not start session"}) return } + _ = a.DB.LogAuthAttempt("admin_login", user.Username, requestIP(r), true, "Login successful (passkey)") clearPendingMFACookie(w) setSessionCookie(w, token, r.TLS != nil) writeJSON(w, http.StatusOK, M{"success": true}) diff --git a/internal/webui/webmail_account.go b/internal/webui/webmail_account.go index 51c348e..858377b 100644 --- a/internal/webui/webmail_account.go +++ b/internal/webui/webmail_account.go @@ -15,7 +15,10 @@ import ( // usage, password change, TOTP MFA enable/disable, registered passkeys, and app // passwords for IMAP/SMTP clients — everything scoped to reusing the app-password // CRUD already built for the admin-managed mailbox pages (db.ListAppPasswordsForMailbox -// etc.), just presented for self-service instead of admin management. +// etc.), just presented for self-service instead of admin management. Only ever +// reached with MFA already satisfying enforce_mailbox_mfa (or enforcement off) — +// requireMailboxAuth redirects everywhere else, including here, to the isolated +// /mfa-setup page otherwise (see webmailMFASetupRequiredPage). func (a *App) webmailDashboard(w http.ResponseWriter, r *http.Request) { mbox := mailboxFromContext(r) passkeys, _ := a.DB.ListMailboxWebAuthnCredentials(mbox.ID) @@ -33,6 +36,17 @@ func (a *App) webmailDashboard(w http.ResponseWriter, r *http.Request) { }) } +// webmailMFASetupRequiredPage is the isolated, no-navigation landing page +// requireMailboxAuth sends a mailbox owner to when enforce_mailbox_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. Existing app passwords keep +// authenticating IMAP/SMTP clients throughout — that's a separate, non-interactive +// protocol path this gate has no bearing on. +func (a *App) webmailMFASetupRequiredPage(w http.ResponseWriter, r *http.Request) { + mbox := mailboxFromContext(r) + a.render(w, r, "webmail_mfa_setup_required.html", M{"email": mbox.Email, "flashes": popFlashes(w, r)}) +} + func (a *App) webmailChangePassword(w http.ResponseWriter, r *http.Request) { mbox := mailboxFromContext(r) current := r.FormValue("current_password") @@ -106,6 +120,7 @@ func (a *App) webmailTOTPSetupConfirm(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound) return } + _ = a.DB.LogAuthAttempt("mailbox_mfa", mbox.Email, requestIP(r), true, "TOTP authenticator enabled") setFlash(w, "success", "Authenticator app MFA enabled") http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound) } @@ -115,6 +130,7 @@ func (a *App) webmailTOTPDisable(w http.ResponseWriter, r *http.Request) { if err := a.DB.DisableMailboxTOTP(mbox.ID); err != nil { setFlash(w, "error", "Something went wrong") } else { + _ = a.DB.LogAuthAttempt("mailbox_mfa", mbox.Email, requestIP(r), true, "TOTP authenticator disabled") setFlash(w, "success", "Authenticator app MFA disabled") } http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound) diff --git a/internal/webui/webmail_auth.go b/internal/webui/webmail_auth.go index 85728bf..8182428 100644 --- a/internal/webui/webmail_auth.go +++ b/internal/webui/webmail_auth.go @@ -88,18 +88,51 @@ func (a *App) requireMailboxAuth(next http.Handler) http.Handler { return } + // enforce_mailbox_mfa: login itself is never blocked (see webmailLoginSubmit) — + // instead, a mailbox with no MFA configured and no domain/mailbox exemption is + // sent to the isolated /mfa-setup page (no other route reachable except the + // actual totp/passkey setup actions) until they configure one. This never + // touches IMAP/SMTP app-password auth — a completely separate, non-interactive + // protocol path this gate has no bearing on; see mailboxNeedsMFASetup's doc + // comment. + if a.mailboxNeedsMFASetup(mbox) { + if !mailboxMFASetupExempt(r.Method, r.URL.Path) { + http.Redirect(w, r, MailboxPrefix+"/mfa-setup", http.StatusFound) + return + } + } + ctx := context.WithValue(r.Context(), ctxMailboxKey, mbox) next.ServeHTTP(w, r.WithContext(ctx)) }) } +// mailboxMFASetupExempt mirrors adminMFASetupExempt for the webmail portal: the +// isolated setup page itself, plus the actual form/API actions needed to complete +// TOTP or passkey enrollment. Everything else — including the dashboard itself — +// redirects to /mfa-setup. +func mailboxMFASetupExempt(method, path string) bool { + if method == http.MethodGet { + return path == MailboxPrefix+"/mfa-setup" + } + if method != http.MethodPost { + return false + } + switch path { + case MailboxPrefix + "/account/totp/setup", MailboxPrefix + "/account/totp/confirm", + MailboxPrefix + "/account/passkey/begin", MailboxPrefix + "/account/passkey/finish": + return true + } + return false +} + // 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. +// Login is never blocked by this (see webmailLoginSubmit) — it gates every other +// webmail route (see requireMailboxAuth/mailboxMFASetupExempt). Never applies to +// IMAP/SMTP app-password auth, which has no interactive step to enforce MFA on +// regardless. func (a *App) mailboxNeedsMFASetup(mbox *db.Mailbox) bool { if !a.Cfg.Section("Auth").Key("enforce_mailbox_mfa").MustBool(false) { return false diff --git a/internal/webui/webmail_login.go b/internal/webui/webmail_login.go index c137c49..d906e11 100644 --- a/internal/webui/webmail_login.go +++ b/internal/webui/webmail_login.go @@ -56,23 +56,11 @@ func (a *App) webmailLoginSubmit(w http.ResponseWriter, r *http.Request) { return } if mbox == nil || !db.CheckPassword(password, mbox.PasswordHash) { + _ = a.DB.LogAuthAttempt("webmail_login", email, requestIP(r), false, "Incorrect email or password") fail("Incorrect email or password.") return } - // enforce_mailbox_mfa blocks login entirely — not just app-password creation — - // for a mailbox with no MFA configured and no domain/mailbox-level exemption. This - // is a hard gate: since the mailbox owner can't reach any page (including a - // self-service TOTP/passkey setup form) without a session in the first place, an - // admin must either set up MFA on their behalf or grant a (typically temporary) - // exemption from the Edit Mailbox / Edit Domain pages to let them in and set it up - // themselves. This never applies to IMAP/SMTP app-password auth, which has no - // interactive step to enforce MFA on regardless. - if a.mailboxNeedsMFASetup(mbox) { - fail("Two-factor authentication is required for this mailbox but hasn't been set up yet. Contact your administrator.") - return - } - needsMFA := mbox.TOTPEnabled if !needsMFA { if n, _ := a.DB.CountMailboxWebAuthnCredentials(mbox.ID); n > 0 { @@ -86,6 +74,7 @@ func (a *App) webmailLoginSubmit(w http.ResponseWriter, r *http.Request) { fail("Something went wrong. Try again.") return } + _ = a.DB.LogAuthAttempt("webmail_login", email, requestIP(r), true, "Login successful") setMailboxSessionCookie(w, token, r.TLS != nil) http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound) return @@ -126,6 +115,7 @@ func (a *App) webmailMFASubmit(w http.ResponseWriter, r *http.Request) { code := strings.TrimSpace(r.FormValue("code")) if !mbox.TOTPEnabled || !totp.Validate(code, mbox.TOTPSecret) { + _ = a.DB.LogAuthAttempt("webmail_login", mbox.Email, requestIP(r), false, "Invalid MFA code") hasPasskeys, _ := a.DB.CountMailboxWebAuthnCredentials(mailboxID) a.render(w, r, "webmail_login_mfa.html", M{"totp_enabled": mbox.TOTPEnabled, "has_passkeys": hasPasskeys > 0, "error": "Invalid code."}) return @@ -137,6 +127,7 @@ func (a *App) webmailMFASubmit(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound) return } + _ = a.DB.LogAuthAttempt("webmail_login", mbox.Email, requestIP(r), true, "Login successful (authenticator app)") clearMailboxPendingMFACookie(w) setMailboxSessionCookie(w, token, r.TLS != nil) http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound) diff --git a/internal/webui/webmail_webauthn.go b/internal/webui/webmail_webauthn.go index a2c5552..1b22f54 100644 --- a/internal/webui/webmail_webauthn.go +++ b/internal/webui/webmail_webauthn.go @@ -140,6 +140,7 @@ func (a *App) webmailPasskeyRegisterFinish(w http.ResponseWriter, r *http.Reques writeJSON(w, http.StatusInternalServerError, M{"error": "Could not save passkey"}) return } + _ = a.DB.LogAuthAttempt("mailbox_mfa", mbox.Email, requestIP(r), true, "Passkey added: "+name) writeJSON(w, http.StatusOK, M{"success": true}) } @@ -148,6 +149,7 @@ func (a *App) webmailPasskeyRemove(w http.ResponseWriter, r *http.Request) { if err := a.DB.DeleteMailboxWebAuthnCredential(pathID(r), mbox.ID); err != nil { setFlash(w, "error", "Could not remove passkey") } else { + _ = a.DB.LogAuthAttempt("mailbox_mfa", mbox.Email, requestIP(r), true, "Passkey removed") setFlash(w, "success", "Passkey removed") } http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound) @@ -216,6 +218,7 @@ func (a *App) webmailPasskeyLoginFinish(w http.ResponseWriter, r *http.Request) } if _, err := wa.FinishLogin(wu, *session, r); err != nil { clearMailboxWebauthnSession(w) + _ = a.DB.LogAuthAttempt("webmail_login", mbox.Email, requestIP(r), false, "Passkey verification failed") writeJSON(w, http.StatusUnauthorized, M{"error": "Passkey verification failed"}) return } @@ -226,6 +229,7 @@ func (a *App) webmailPasskeyLoginFinish(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusInternalServerError, M{"error": "Could not start session"}) return } + _ = a.DB.LogAuthAttempt("webmail_login", mbox.Email, requestIP(r), true, "Login successful (passkey)") clearMailboxPendingMFACookie(w) setMailboxSessionCookie(w, token, r.TLS != nil) writeJSON(w, http.StatusOK, M{"success": true}) diff --git a/internal/webui/webui.go b/internal/webui/webui.go index d7f63b7..5476df1 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -103,6 +103,7 @@ func (a *App) Mux() *http.ServeMux { webmailMux := http.NewServeMux() webmailMux.HandleFunc("GET "+MailboxPrefix+"/", a.webmailDashboard) + webmailMux.HandleFunc("GET "+MailboxPrefix+"/mfa-setup", a.webmailMFASetupRequiredPage) webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/password", a.webmailChangePassword) webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/setup", a.webmailTOTPSetupBegin) webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/confirm", a.webmailTOTPSetupConfirm) @@ -118,6 +119,7 @@ func (a *App) Mux() *http.ServeMux { mux.HandleFunc("GET "+Prefix+"/", a.dashboard) mux.HandleFunc("GET "+Prefix+"/account", a.accountPage) + mux.HandleFunc("GET "+Prefix+"/mfa-setup", a.mfaSetupRequiredPage) mux.HandleFunc("POST "+Prefix+"/account/password", a.changePassword) mux.HandleFunc("POST "+Prefix+"/account/totp/setup", a.totpSetupBegin) mux.HandleFunc("POST "+Prefix+"/account/totp/confirm", a.totpSetupConfirm)