Files
mailgoserver/internal/webui/webmail_login.go
T

152 lines
5.1 KiB
Go

package webui
import (
"net/http"
"strconv"
"strings"
"github.com/pquerna/otp/totp"
"mailgoserver/internal/db"
)
// mailboxPendingMFACookieName mirrors pendingMFACookieName for the mailbox portal —
// kept fully separate so an unfinished mailbox login can never be confused with (or
// promoted into) an admin session, and vice versa.
const mailboxPendingMFACookieName = "mailgoserver_mailbox_pending_mfa"
func setMailboxPendingMFACookie(w http.ResponseWriter, mailboxID string) {
http.SetCookie(w, &http.Cookie{
Name: mailboxPendingMFACookieName, Value: mailboxID, Path: "/", HttpOnly: true,
SameSite: http.SameSiteLaxMode, MaxAge: 10 * 60,
})
}
func clearMailboxPendingMFACookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{Name: mailboxPendingMFACookieName, Value: "", Path: "/", MaxAge: -1})
}
func pendingMailboxMFAID(r *http.Request) int64 {
c, err := r.Cookie(mailboxPendingMFACookieName)
if err != nil {
return 0
}
return int64(atoi(c.Value))
}
func (a *App) webmailLoginForm(w http.ResponseWriter, r *http.Request) {
if sess, mbox, _ := a.currentMailboxSession(r); sess != nil && mbox != nil {
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
return
}
a.render(w, r, "webmail_login.html", M{})
}
// webmailLoginSubmit checks email+password against the mailbox's own portal
// password (never an app password — that's for IMAP/SMTP clients only).
func (a *App) webmailLoginSubmit(w http.ResponseWriter, r *http.Request) {
email := strings.TrimSpace(r.FormValue("email"))
password := r.FormValue("password")
fail := func(msg string) {
a.render(w, r, "webmail_login.html", M{"error": msg, "email": email})
}
mbox, err := a.DB.GetMailboxByEmail(email)
if err != nil {
a.Logger.Error("webmail login lookup: %v", err)
fail("Something went wrong. Try again.")
return
}
if mbox == nil || !db.CheckPassword(password, mbox.PasswordHash) {
fail("Incorrect email or password.")
return
}
// enforce_mailbox_mfa blocks login entirely — not just app-password creation —
// for a mailbox with no MFA configured and no domain/mailbox-level exemption. This
// is a hard gate: since the mailbox owner can't reach any page (including a
// self-service TOTP/passkey setup form) without a session in the first place, an
// admin must either set up MFA on their behalf or grant a (typically temporary)
// exemption from the Edit Mailbox / Edit Domain pages to let them in and set it up
// themselves. This never applies to IMAP/SMTP app-password auth, which has no
// interactive step to enforce MFA on regardless.
if a.mailboxNeedsMFASetup(mbox) {
fail("Two-factor authentication is required for this mailbox but hasn't been set up yet. Contact your administrator.")
return
}
needsMFA := mbox.TOTPEnabled
if !needsMFA {
if n, _ := a.DB.CountMailboxWebAuthnCredentials(mbox.ID); n > 0 {
needsMFA = true
}
}
if !needsMFA {
token, err := a.DB.CreateMailboxSession(mbox.ID, true, sessionTTL)
if err != nil {
fail("Something went wrong. Try again.")
return
}
setMailboxSessionCookie(w, token, r.TLS != nil)
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
return
}
setMailboxPendingMFACookie(w, strconv.FormatInt(mbox.ID, 10))
http.Redirect(w, r, MailboxPrefix+"/login/mfa", http.StatusFound)
}
func (a *App) webmailMFAForm(w http.ResponseWriter, r *http.Request) {
mailboxID := pendingMailboxMFAID(r)
if mailboxID == 0 {
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
return
}
mbox, _ := a.DB.GetMailboxByID(mailboxID)
if mbox == nil {
clearMailboxPendingMFACookie(w)
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
return
}
hasPasskeys, _ := a.DB.CountMailboxWebAuthnCredentials(mailboxID)
a.render(w, r, "webmail_login_mfa.html", M{"totp_enabled": mbox.TOTPEnabled, "has_passkeys": hasPasskeys > 0})
}
func (a *App) webmailMFASubmit(w http.ResponseWriter, r *http.Request) {
mailboxID := pendingMailboxMFAID(r)
if mailboxID == 0 {
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
return
}
mbox, err := a.DB.GetMailboxByID(mailboxID)
if err != nil || mbox == nil {
clearMailboxPendingMFACookie(w)
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
return
}
code := strings.TrimSpace(r.FormValue("code"))
if !mbox.TOTPEnabled || !totp.Validate(code, mbox.TOTPSecret) {
hasPasskeys, _ := a.DB.CountMailboxWebAuthnCredentials(mailboxID)
a.render(w, r, "webmail_login_mfa.html", M{"totp_enabled": mbox.TOTPEnabled, "has_passkeys": hasPasskeys > 0, "error": "Invalid code."})
return
}
token, err := a.DB.CreateMailboxSession(mbox.ID, true, sessionTTL)
if err != nil {
a.Logger.Error("create mailbox session: %v", err)
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
return
}
clearMailboxPendingMFACookie(w)
setMailboxSessionCookie(w, token, r.TLS != nil)
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
}
func (a *App) webmailLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(mailboxSessionCookieName); err == nil {
_ = a.DB.DeleteMailboxSession(c.Value)
}
clearMailboxSessionCookie(w)
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
}