Files
mailgoserver/internal/webui/mfa_enforcement_test.go
T
2026-08-13 10:40:27 +01:00

231 lines
8.9 KiB
Go

package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"mailgoserver/internal/db"
)
// 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()
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, 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.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, "sidebar") || strings.Contains(body, `href="/pymta-manager/domains"`) {
t.Error("expected no sidebar/navigation on the isolated setup page")
}
// Once TOTP is enabled, both /domains and /account become reachable again.
if err := app.DB.SetAdminTOTPSecret(userID, "JBSWY3DPEHPK3PXP", true); err != nil {
t.Fatal(err)
}
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)
}
}
}
// 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)
}
}
// 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()
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)
}
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+"/" {
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))
}
}