add MFA, user web mail portal

This commit is contained in:
2026-08-13 08:07:19 +01:00
parent 70c05cc777
commit bc4bbe6e56
38 changed files with 1072 additions and 59 deletions
+34 -4
View File
@@ -3,6 +3,7 @@ package webui
import (
"context"
"net/http"
"strings"
"time"
"mailgoserver/internal/db"
@@ -53,6 +54,21 @@ func scopeFromContext(r *http.Request) accessScope {
return s
}
// requireGlobalAdmin gates a handler behind the current admin's scope being global —
// used for server-wide settings (Server Settings, Let's Encrypt) that a domain-scoped
// admin has no business reading or changing, even if they can guess the URL. 404 (not
// 403) matches requireDomainAccess's reasoning: a scoped admin shouldn't be able to
// tell "doesn't exist" from "exists but isn't mine" by probing.
func (a *App) requireGlobalAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !scopeFromContext(r).Global {
http.NotFound(w, r)
return
}
next(w, r)
}
}
// requireDomainAccess checks the current admin's scope covers domainID; if not, it
// writes a 404 (not 403 — a scoped admin shouldn't be able to distinguish "doesn't
// exist" from "exists but isn't mine" by probing IDs) and returns false, matching the
@@ -138,13 +154,16 @@ func (a *App) requireAuth(next http.Handler) http.Handler {
return
}
needsMFA := user.TOTPEnabled
if !needsMFA {
// hasMFA: this account already has a second factor configured (TOTP or a
// passkey) — distinct from sess.MFAVerified, which is about *this session*
// having satisfied it.
hasMFA := user.TOTPEnabled
if !hasMFA {
if n, _ := a.DB.CountWebAuthnCredentials(user.ID); n > 0 {
needsMFA = true
hasMFA = true
}
}
if needsMFA && !sess.MFAVerified {
if hasMFA && !sess.MFAVerified {
http.Redirect(w, r, Prefix+"/login/mfa", http.StatusFound)
return
}
@@ -154,6 +173,17 @@ 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.
if !hasMFA && !user.MustChangePassword && a.Cfg.Section("Auth").Key("enforce_admin_mfa").MustBool(false) {
if r.URL.Path != Prefix+"/account" && !strings.HasPrefix(r.URL.Path, Prefix+"/account/") {
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
return
}
}
scope, err := a.buildAccessScope(user)
if err != nil {
a.Logger.Error("build access scope: %v", err)