mfa fixing
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+28
-7
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+10
-1
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,12 +2,6 @@
|
||||
{{define "page_title"}}Account Settings{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{if .mfa_required}}
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-shield-exclamation me-2"></i>
|
||||
Your administrator requires two-factor authentication for all admin accounts. Set up an authenticator app or a passkey below to continue using the dashboard.
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="row">
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card">
|
||||
|
||||
@@ -27,22 +27,26 @@
|
||||
|
||||
{{if .has_passkeys}}
|
||||
<div class="d-grid mb-3">
|
||||
<button type="button" class="btn btn-outline-primary" id="passkey-btn">
|
||||
<button type="button" class="btn btn-primary" id="passkey-btn">
|
||||
<i class="bi bi-fingerprint me-1"></i>Use a passkey / security key
|
||||
</button>
|
||||
</div>
|
||||
{{if .totp_enabled}}<div class="text-center text-muted mb-3">or</div>{{end}}
|
||||
{{if .totp_enabled}}
|
||||
<div class="text-center text-muted mb-3">
|
||||
or <a href="#" id="show-totp-link">use an authenticator app code instead</a>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if .totp_enabled}}
|
||||
<form method="POST" action="/pymta-manager/login/mfa">
|
||||
<form method="POST" action="/pymta-manager/login/mfa" id="totp-form" {{if .has_passkeys}}class="d-none"{{end}}>
|
||||
<input type="hidden" name="next" value="{{.next}}">
|
||||
<div class="mb-3">
|
||||
<label for="code" class="form-label">6-digit authenticator code</label>
|
||||
<input type="text" class="form-control" id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required autofocus>
|
||||
<input type="text" class="form-control" id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required {{if not .has_passkeys}}autofocus{{end}}>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-shield-check me-1"></i>Verify</button>
|
||||
<button type="submit" class="btn {{if .has_passkeys}}btn-outline-primary{{else}}btn-primary{{end}}"><i class="bi bi-shield-check me-1"></i>Verify</button>
|
||||
</div>
|
||||
</form>
|
||||
{{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() {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
{{define "mfa_setup_required.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Set up two-factor authentication - mailgoserver</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background-color: #1a1a1a; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; }
|
||||
.setup-card { max-width: 480px; margin: 0 auto; width: 100%; }
|
||||
.card { background-color: #2d2d2d; border: 1px solid #404040; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
|
||||
{{range .flashes}}
|
||||
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
|
||||
{{.Message}}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="container setup-card">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-shield-lock-fill" style="font-size: 2.5rem;"></i>
|
||||
<h4 class="mt-2">Two-factor authentication required</h4>
|
||||
<p class="text-muted">Your administrator requires MFA for every account (signed in as <strong>{{.username}}</strong>). Set up one of the options below to continue — nothing else is accessible until then.</p>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-fingerprint me-2"></i>Passkey <span class="badge bg-primary ms-1">Recommended</span></h5></div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">Use your device's built-in security (fingerprint, face, or a hardware security key).</p>
|
||||
<button type="button" class="btn btn-primary" id="passkey-add-btn"><i class="bi bi-fingerprint me-1"></i>Set up a Passkey</button>
|
||||
<div id="passkey-error" class="alert alert-danger d-none mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-qr-code me-2"></i>Authenticator App</h5></div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">Use Google Authenticator, 1Password, or any TOTP app.</p>
|
||||
<form method="post" action="/pymta-manager/account/totp/setup">
|
||||
<button type="submit" class="btn btn-outline-primary"><i class="bi bi-qr-code me-1"></i>Set up Authenticator App</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 6000}).show(); });
|
||||
});
|
||||
function b64urlToBuf(s) {
|
||||
s = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (s.length % 4) s += '=';
|
||||
const bin = atob(s);
|
||||
const buf = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
|
||||
return buf.buffer;
|
||||
}
|
||||
function bufToB64url(buf) {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let bin = '';
|
||||
bytes.forEach(b => bin += String.fromCharCode(b));
|
||||
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
document.getElementById('passkey-add-btn').addEventListener('click', async function() {
|
||||
const errEl = document.getElementById('passkey-error');
|
||||
errEl.classList.add('d-none');
|
||||
try {
|
||||
const beginResp = await fetch('/pymta-manager/account/passkey/begin', { method: 'POST' });
|
||||
if (!beginResp.ok) throw new Error((await beginResp.json()).error || 'Could not start passkey registration');
|
||||
const options = await beginResp.json();
|
||||
const publicKey = options.publicKey;
|
||||
publicKey.challenge = b64urlToBuf(publicKey.challenge);
|
||||
publicKey.user.id = b64urlToBuf(publicKey.user.id);
|
||||
if (publicKey.excludeCredentials) {
|
||||
publicKey.excludeCredentials = publicKey.excludeCredentials.map(c => ({ ...c, id: b64urlToBuf(c.id) }));
|
||||
}
|
||||
const cred = await navigator.credentials.create({ publicKey });
|
||||
const body = {
|
||||
id: cred.id, rawId: bufToB64url(cred.rawId), type: cred.type,
|
||||
response: {
|
||||
attestationObject: bufToB64url(cred.response.attestationObject),
|
||||
clientDataJSON: bufToB64url(cred.response.clientDataJSON),
|
||||
},
|
||||
};
|
||||
const finishResp = await fetch('/pymta-manager/account/passkey/finish', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
});
|
||||
if (!finishResp.ok) throw new Error((await finishResp.json()).error || 'Could not save passkey');
|
||||
window.location.href = '/pymta-manager/';
|
||||
} catch (e) {
|
||||
errEl.textContent = e.message || 'Passkey registration failed';
|
||||
errEl.classList.remove('d-none');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -1,30 +1,45 @@
|
||||
{{define "title"}}Set up authenticator app{{end}}
|
||||
{{define "page_title"}}Set up authenticator app{{end}}
|
||||
{{define "totp_setup.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Set up authenticator app - mailgoserver</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background-color: #1a1a1a; color: #e0e0e0; }
|
||||
.card { background-color: #2d2d2d; border: 1px solid #404040; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-qr-code me-2"></i>Scan with your authenticator app</h5></div>
|
||||
<div class="card-body text-center">
|
||||
{{if .qr_data_uri}}
|
||||
<img src="{{.qr_data_uri}}" alt="TOTP QR code" class="img-fluid mb-3" style="max-width: 256px; background: white; padding: 8px; border-radius: 8px;">
|
||||
{{end}}
|
||||
<p class="text-muted">Can't scan? Enter this key manually:</p>
|
||||
<code class="d-block mb-4" style="word-break: break-all;">{{.secret}}</code>
|
||||
|
||||
{{define "content"}}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-qr-code me-2"></i>Scan with your authenticator app</h5></div>
|
||||
<div class="card-body text-center">
|
||||
{{if .qr_data_uri}}
|
||||
<img src="{{.qr_data_uri}}" alt="TOTP QR code" class="img-fluid mb-3" style="max-width: 256px; background: white; padding: 8px; border-radius: 8px;">
|
||||
{{end}}
|
||||
<p class="text-muted">Can't scan? Enter this key manually:</p>
|
||||
<code class="d-block mb-4" style="word-break: break-all;">{{.secret}}</code>
|
||||
|
||||
<form method="POST" action="/pymta-manager/account/totp/confirm" class="text-start">
|
||||
<div class="mb-3">
|
||||
<label for="code" class="form-label">Enter the 6-digit code from your app to confirm</label>
|
||||
<input type="text" class="form-control" id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required autofocus>
|
||||
<form method="POST" action="/pymta-manager/account/totp/confirm" class="text-start">
|
||||
<div class="mb-3">
|
||||
<label for="code" class="form-label">Enter the 6-digit code from your app to confirm</label>
|
||||
<input type="text" class="form-control" id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required autofocus>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="/pymta-manager/account" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Confirm and enable</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="/pymta-manager/account" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Confirm and enable</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -27,21 +27,25 @@
|
||||
|
||||
{{if .has_passkeys}}
|
||||
<div class="d-grid mb-3">
|
||||
<button type="button" class="btn btn-outline-primary" id="passkey-btn">
|
||||
<button type="button" class="btn btn-primary" id="passkey-btn">
|
||||
<i class="bi bi-fingerprint me-1"></i>Use a passkey / security key
|
||||
</button>
|
||||
</div>
|
||||
{{if .totp_enabled}}<div class="text-center text-muted mb-3">or</div>{{end}}
|
||||
{{if .totp_enabled}}
|
||||
<div class="text-center text-muted mb-3">
|
||||
or <a href="#" id="show-totp-link">use an authenticator app code instead</a>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if .totp_enabled}}
|
||||
<form method="POST" action="/webmail/login/mfa">
|
||||
<form method="POST" action="/webmail/login/mfa" id="totp-form" {{if .has_passkeys}}class="d-none"{{end}}>
|
||||
<div class="mb-3">
|
||||
<label for="code" class="form-label">6-digit authenticator code</label>
|
||||
<input type="text" class="form-control" id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required autofocus>
|
||||
<input type="text" class="form-control" id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required {{if not .has_passkeys}}autofocus{{end}}>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-shield-check me-1"></i>Verify</button>
|
||||
<button type="submit" class="btn {{if .has_passkeys}}btn-outline-primary{{else}}btn-primary{{end}}"><i class="bi bi-shield-check me-1"></i>Verify</button>
|
||||
</div>
|
||||
</form>
|
||||
{{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() {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
{{define "webmail_mfa_setup_required.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Set up two-factor authentication - Webmail</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background-color: #1a1a1a; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; }
|
||||
.setup-card { max-width: 480px; margin: 0 auto; width: 100%; }
|
||||
.card { background-color: #2d2d2d; border: 1px solid #404040; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
|
||||
{{range .flashes}}
|
||||
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
|
||||
{{.Message}}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="container setup-card">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-shield-lock-fill" style="font-size: 2.5rem;"></i>
|
||||
<h4 class="mt-2">Two-factor authentication required</h4>
|
||||
<p class="text-muted">Your administrator requires MFA for this mailbox (<strong>{{.email}}</strong>). 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.</p>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-fingerprint me-2"></i>Passkey <span class="badge bg-primary ms-1">Recommended</span></h5></div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">Use your device's built-in security (fingerprint, face, or a hardware security key).</p>
|
||||
<button type="button" class="btn btn-primary" id="passkey-add-btn"><i class="bi bi-fingerprint me-1"></i>Set up a Passkey</button>
|
||||
<div id="passkey-error" class="alert alert-danger d-none mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-qr-code me-2"></i>Authenticator App</h5></div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">Use Google Authenticator, 1Password, or any TOTP app.</p>
|
||||
<form method="post" action="/webmail/account/totp/setup">
|
||||
<button type="submit" class="btn btn-outline-primary"><i class="bi bi-qr-code me-1"></i>Set up Authenticator App</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 6000}).show(); });
|
||||
});
|
||||
function b64urlToBuf(s) {
|
||||
s = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (s.length % 4) s += '=';
|
||||
const bin = atob(s);
|
||||
const buf = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
|
||||
return buf.buffer;
|
||||
}
|
||||
function bufToB64url(buf) {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let bin = '';
|
||||
bytes.forEach(b => bin += String.fromCharCode(b));
|
||||
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
document.getElementById('passkey-add-btn').addEventListener('click', async function() {
|
||||
const errEl = document.getElementById('passkey-error');
|
||||
errEl.classList.add('d-none');
|
||||
try {
|
||||
const beginResp = await fetch('/webmail/account/passkey/begin', { method: 'POST' });
|
||||
if (!beginResp.ok) throw new Error((await beginResp.json()).error || 'Could not start passkey registration');
|
||||
const options = await beginResp.json();
|
||||
const publicKey = options.publicKey;
|
||||
publicKey.challenge = b64urlToBuf(publicKey.challenge);
|
||||
publicKey.user.id = b64urlToBuf(publicKey.user.id);
|
||||
if (publicKey.excludeCredentials) {
|
||||
publicKey.excludeCredentials = publicKey.excludeCredentials.map(c => ({ ...c, id: b64urlToBuf(c.id) }));
|
||||
}
|
||||
const cred = await navigator.credentials.create({ publicKey });
|
||||
const body = {
|
||||
id: cred.id, rawId: bufToB64url(cred.rawId), type: cred.type,
|
||||
response: {
|
||||
attestationObject: bufToB64url(cred.response.attestationObject),
|
||||
clientDataJSON: bufToB64url(cred.response.clientDataJSON),
|
||||
},
|
||||
};
|
||||
const finishResp = await fetch('/webmail/account/passkey/finish', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
});
|
||||
if (!finishResp.ok) throw new Error((await finishResp.json()).error || 'Could not save passkey');
|
||||
window.location.href = '/webmail/';
|
||||
} catch (e) {
|
||||
errEl.textContent = e.message || 'Passkey registration failed';
|
||||
errEl.classList.remove('d-none');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user