package webui import ( "context" "net/http" "time" "mailgoserver/internal/db" ) const ( sessionCookieName = "mailgoserver_session" sessionTTL = 7 * 24 * time.Hour ) type ctxKey int const ( ctxUserKey ctxKey = iota ctxScopeKey ) // accessScope is which domains the current admin can see/manage. A global admin // bypasses the domain-ID check entirely; a scoped admin is restricted to exactly the // domains in DomainIDs — computed once per request in requireAuth and reused by every // handler via scopeFromContext, rather than re-querying esrv_admin_domain_access // repeatedly within the same request. type accessScope struct { Global bool DomainIDs map[int64]bool } func (s accessScope) Allowed(domainID int64) bool { return s.Global || s.DomainIDs[domainID] } // IDs returns the accessible domain IDs as a slice — nil (not empty) for a global // admin, since "nil" is the signal callers should treat as "no filter" rather than // "empty set" when building an IN (...) clause or similar. func (s accessScope) IDs() []int64 { if s.Global { return nil } ids := make([]int64, 0, len(s.DomainIDs)) for id := range s.DomainIDs { ids = append(ids, id) } return ids } func scopeFromContext(r *http.Request) accessScope { s, _ := r.Context().Value(ctxScopeKey).(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 // 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 // existing "not found" handling every route already does for a missing resource. func requireDomainAccess(w http.ResponseWriter, r *http.Request, domainID int64) bool { if scopeFromContext(r).Allowed(domainID) { return true } http.NotFound(w, r) return false } func (a *App) buildAccessScope(user *db.AdminUser) (accessScope, error) { if user.IsGlobalAdmin { return accessScope{Global: true}, nil } ids, err := a.DB.AccessibleDomainIDs(user.ID) if err != nil { return accessScope{}, err } m := make(map[int64]bool, len(ids)) for _, id := range ids { m[id] = true } return accessScope{DomainIDs: m}, nil } func setSessionCookie(w http.ResponseWriter, token string, secure bool) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: token, Path: "/", HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: int(sessionTTL.Seconds()), }) } func clearSessionCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1}) } // currentSession loads the session + user for the request's cookie, if any and valid // (exists, not expired). A nil session/user (no error) means "not logged in". func (a *App) currentSession(r *http.Request) (*db.AdminSession, *db.AdminUser, error) { c, err := r.Cookie(sessionCookieName) if err != nil || c.Value == "" { return nil, nil, nil } sess, err := a.DB.GetSession(c.Value) if err != nil || sess == nil { return nil, nil, err } if time.Now().After(sess.ExpiresAt) { _ = a.DB.DeleteSession(sess.Token) return nil, nil, nil } user, err := a.DB.GetAdminUserByID(sess.UserID) if err != nil || user == nil { return nil, nil, err } return sess, user, nil } func userFromContext(r *http.Request) *db.AdminUser { u, _ := r.Context().Value(ctxUserKey).(*db.AdminUser) return u } // requireAuth gates every admin route behind a valid, fully-authenticated session: // logged in, second factor satisfied if one is enabled, and not stuck in the forced // first-login credential change. Unauthenticated/incomplete requests are redirected // to the right step of the login flow rather than shown an error. func (a *App) requireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sess, user, err := a.currentSession(r) if err != nil { a.Logger.Error("session lookup: %v", err) } if sess == nil || user == nil { http.Redirect(w, r, Prefix+"/login?next="+r.URL.Path, http.StatusFound) return } // 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 { hasMFA = true } } if hasMFA && !sess.MFAVerified { http.Redirect(w, r, Prefix+"/login/mfa", http.StatusFound) return } if user.MustChangePassword && r.URL.Path != Prefix+"/first-login" { http.Redirect(w, r, Prefix+"/first-login", http.StatusFound) return } // 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 !adminMFASetupExempt(r.Method, r.URL.Path) { http.Redirect(w, r, Prefix+"/mfa-setup", http.StatusFound) return } } scope, err := a.buildAccessScope(user) if err != nil { a.Logger.Error("build access scope: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } ctx := context.WithValue(r.Context(), ctxUserKey, user) ctx = context.WithValue(ctx, ctxScopeKey, scope) next.ServeHTTP(w, r.WithContext(ctx)) }) }