121 lines
3.8 KiB
Go
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
|
|
}
|