154 lines
5.1 KiB
Go
154 lines
5.1 KiB
Go
package webui
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"time"
|
|
|
|
"mailgoserver/internal/db"
|
|
)
|
|
|
|
// MailboxPrefix is the self-service webmail portal's URL prefix — a mailbox owner's
|
|
// login/account area, entirely separate from the admin dashboard at Prefix.
|
|
const MailboxPrefix = "/webmail"
|
|
|
|
const mailboxSessionCookieName = "mailgoserver_mailbox_session"
|
|
|
|
// mailboxCtxKey is its own type (not webui's ctxKey) so a mailbox session can never
|
|
// collide with or be confused for an admin session in request context — the two
|
|
// actor types are deliberately kept fully separate, per the parallel-schema design.
|
|
type mailboxCtxKey int
|
|
|
|
const ctxMailboxKey mailboxCtxKey = iota
|
|
|
|
func setMailboxSessionCookie(w http.ResponseWriter, token string, secure bool) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: mailboxSessionCookieName,
|
|
Value: token,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: secure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: int(sessionTTL.Seconds()),
|
|
})
|
|
}
|
|
|
|
func clearMailboxSessionCookie(w http.ResponseWriter) {
|
|
http.SetCookie(w, &http.Cookie{Name: mailboxSessionCookieName, Value: "", Path: "/", MaxAge: -1})
|
|
}
|
|
|
|
// currentMailboxSession loads the session + mailbox for the request's cookie, if any
|
|
// and valid. A nil session/mailbox (no error) means "not logged in".
|
|
func (a *App) currentMailboxSession(r *http.Request) (*db.MailboxSession, *db.Mailbox, error) {
|
|
c, err := r.Cookie(mailboxSessionCookieName)
|
|
if err != nil || c.Value == "" {
|
|
return nil, nil, nil
|
|
}
|
|
sess, err := a.DB.GetMailboxSession(c.Value)
|
|
if err != nil || sess == nil {
|
|
return nil, nil, err
|
|
}
|
|
if time.Now().After(sess.ExpiresAt) {
|
|
_ = a.DB.DeleteMailboxSession(sess.Token)
|
|
return nil, nil, nil
|
|
}
|
|
mbox, err := a.DB.GetMailboxByID(sess.MailboxID)
|
|
if err != nil || mbox == nil {
|
|
return nil, nil, err
|
|
}
|
|
return sess, mbox, nil
|
|
}
|
|
|
|
func mailboxFromContext(r *http.Request) *db.Mailbox {
|
|
m, _ := r.Context().Value(ctxMailboxKey).(*db.Mailbox)
|
|
return m
|
|
}
|
|
|
|
// requireMailboxAuth gates every webmail route behind a valid, fully-authenticated
|
|
// mailbox session: logged in, and second factor satisfied if one is enabled.
|
|
func (a *App) requireMailboxAuth(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
sess, mbox, err := a.currentMailboxSession(r)
|
|
if err != nil {
|
|
a.Logger.Error("mailbox session lookup: %v", err)
|
|
}
|
|
if sess == nil || mbox == nil {
|
|
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
needsMFA := mbox.TOTPEnabled
|
|
if !needsMFA {
|
|
if n, _ := a.DB.CountMailboxWebAuthnCredentials(mbox.ID); n > 0 {
|
|
needsMFA = true
|
|
}
|
|
}
|
|
if needsMFA && !sess.MFAVerified {
|
|
http.Redirect(w, r, MailboxPrefix+"/login/mfa", http.StatusFound)
|
|
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.
|
|
// 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
|
|
}
|
|
hasMFA := mbox.TOTPEnabled
|
|
if !hasMFA {
|
|
if n, _ := a.DB.CountMailboxWebAuthnCredentials(mbox.ID); n > 0 {
|
|
hasMFA = true
|
|
}
|
|
}
|
|
if hasMFA || mbox.MFAExempt {
|
|
return false
|
|
}
|
|
if dom, err := a.DB.GetDomainByID(mbox.DomainID); err == nil && dom != nil && dom.MFAExempt {
|
|
return false
|
|
}
|
|
return true
|
|
}
|