Files
mailgoserver/internal/webui/webmail_auth.go
T

121 lines
3.8 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
}
ctx := context.WithValue(r.Context(), ctxMailboxKey, mbox)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// 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.
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
}