package webui import ( "crypto/sha256" "encoding/base64" "encoding/json" "net/http" "strconv" "github.com/go-webauthn/webauthn/webauthn" "mailgoserver/internal/db" ) // mailboxWebauthnSessionCookie mirrors webauthnSessionCookie but kept separate so an // in-progress admin passkey ceremony and an in-progress mailbox one (e.g. different // browser tabs) can never collide. const mailboxWebauthnSessionCookie = "mailgoserver_mailbox_webauthn_session" // mailboxWebauthnUser adapts a Mailbox + its stored credentials to webauthn.User, // mirroring webauthnUser. type mailboxWebauthnUser struct { mailbox *db.Mailbox creds []db.MailboxWebAuthnCredential } func (u *mailboxWebauthnUser) WebAuthnID() []byte { sum := sha256.Sum256([]byte("mailbox-" + strconv.FormatInt(u.mailbox.ID, 10))) return sum[:] } func (u *mailboxWebauthnUser) WebAuthnName() string { return u.mailbox.Email } func (u *mailboxWebauthnUser) WebAuthnDisplayName() string { return u.mailbox.Email } func (u *mailboxWebauthnUser) WebAuthnCredentials() []webauthn.Credential { out := make([]webauthn.Credential, 0, len(u.creds)) for _, c := range u.creds { var cred webauthn.Credential if err := json.Unmarshal([]byte(c.CredentialData), &cred); err == nil { out = append(out, cred) } } return out } func (a *App) mailboxWebauthnUserFor(mbox *db.Mailbox) (*mailboxWebauthnUser, error) { creds, err := a.DB.ListMailboxWebAuthnCredentials(mbox.ID) if err != nil { return nil, err } return &mailboxWebauthnUser{mailbox: mbox, creds: creds}, nil } func saveMailboxWebauthnSession(w http.ResponseWriter, s *webauthn.SessionData) error { b, err := json.Marshal(s) if err != nil { return err } http.SetCookie(w, &http.Cookie{ Name: mailboxWebauthnSessionCookie, Value: base64.URLEncoding.EncodeToString(b), Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 5 * 60, }) return nil } func loadMailboxWebauthnSession(r *http.Request) (*webauthn.SessionData, error) { c, err := r.Cookie(mailboxWebauthnSessionCookie) if err != nil { return nil, err } raw, err := base64.URLEncoding.DecodeString(c.Value) if err != nil { return nil, err } var s webauthn.SessionData if err := json.Unmarshal(raw, &s); err != nil { return nil, err } return &s, nil } func clearMailboxWebauthnSession(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{Name: mailboxWebauthnSessionCookie, Value: "", Path: "/", MaxAge: -1}) } func (a *App) webmailPasskeyRegisterBegin(w http.ResponseWriter, r *http.Request) { mbox := mailboxFromContext(r) wa, err := a.buildWebAuthn() if err != nil { writeJSON(w, http.StatusInternalServerError, M{"error": "WebAuthn is not configured correctly: " + err.Error()}) return } wu, err := a.mailboxWebauthnUserFor(mbox) if err != nil { writeJSON(w, http.StatusInternalServerError, M{"error": "Could not load account"}) return } creation, session, err := wa.BeginRegistration(wu) if err != nil { writeJSON(w, http.StatusInternalServerError, M{"error": err.Error()}) return } if err := saveMailboxWebauthnSession(w, session); err != nil { writeJSON(w, http.StatusInternalServerError, M{"error": "Could not start registration"}) return } writeJSON(w, http.StatusOK, creation) } func (a *App) webmailPasskeyRegisterFinish(w http.ResponseWriter, r *http.Request) { mbox := mailboxFromContext(r) wa, err := a.buildWebAuthn() if err != nil { writeJSON(w, http.StatusInternalServerError, M{"error": err.Error()}) return } session, err := loadMailboxWebauthnSession(r) if err != nil { writeJSON(w, http.StatusBadRequest, M{"error": "Registration session expired — try again"}) return } wu, err := a.mailboxWebauthnUserFor(mbox) if err != nil { writeJSON(w, http.StatusInternalServerError, M{"error": "Could not load account"}) return } cred, err := wa.FinishRegistration(wu, *session, r) clearMailboxWebauthnSession(w) if err != nil { writeJSON(w, http.StatusBadRequest, M{"error": err.Error()}) return } data, err := json.Marshal(cred) if err != nil { writeJSON(w, http.StatusInternalServerError, M{"error": "Could not save passkey"}) return } name := r.URL.Query().Get("name") if name == "" { name = "Passkey" } if err := a.DB.CreateMailboxWebAuthnCredential(mbox.ID, name, base64.URLEncoding.EncodeToString(cred.ID), string(data)); err != nil { writeJSON(w, http.StatusInternalServerError, M{"error": "Could not save passkey"}) return } _ = a.DB.LogAuthAttempt("mailbox_mfa", mbox.Email, a.requestIP(r), true, "Passkey added: "+name) writeJSON(w, http.StatusOK, M{"success": true}) } func (a *App) webmailPasskeyRemove(w http.ResponseWriter, r *http.Request) { mbox := mailboxFromContext(r) if err := a.DB.DeleteMailboxWebAuthnCredential(pathID(r), mbox.ID); err != nil { setFlash(w, "error", "Could not remove passkey") } else { _ = a.DB.LogAuthAttempt("mailbox_mfa", mbox.Email, a.requestIP(r), true, "Passkey removed") setFlash(w, "success", "Passkey removed") } http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound) } // webmailPasskeyLoginBegin starts the passkey ceremony for the mailbox that's already // passed its password and is now at the MFA step. func (a *App) webmailPasskeyLoginBegin(w http.ResponseWriter, r *http.Request) { mailboxID := pendingMailboxMFAID(r) if mailboxID == 0 { writeJSON(w, http.StatusUnauthorized, M{"error": "No login in progress"}) return } mbox, err := a.DB.GetMailboxByID(mailboxID) if err != nil || mbox == nil { writeJSON(w, http.StatusUnauthorized, M{"error": "No login in progress"}) return } wa, err := a.buildWebAuthn() if err != nil { writeJSON(w, http.StatusInternalServerError, M{"error": err.Error()}) return } wu, err := a.mailboxWebauthnUserFor(mbox) if err != nil || len(wu.creds) == 0 { writeJSON(w, http.StatusBadRequest, M{"error": "No passkeys registered"}) return } assertion, session, err := wa.BeginLogin(wu) if err != nil { writeJSON(w, http.StatusInternalServerError, M{"error": err.Error()}) return } if err := saveMailboxWebauthnSession(w, session); err != nil { writeJSON(w, http.StatusInternalServerError, M{"error": "Could not start login"}) return } writeJSON(w, http.StatusOK, assertion) } func (a *App) webmailPasskeyLoginFinish(w http.ResponseWriter, r *http.Request) { mailboxID := pendingMailboxMFAID(r) if mailboxID == 0 { writeJSON(w, http.StatusUnauthorized, M{"error": "No login in progress"}) return } mbox, err := a.DB.GetMailboxByID(mailboxID) if err != nil || mbox == nil { writeJSON(w, http.StatusUnauthorized, M{"error": "No login in progress"}) return } wa, err := a.buildWebAuthn() if err != nil { writeJSON(w, http.StatusInternalServerError, M{"error": err.Error()}) return } session, err := loadMailboxWebauthnSession(r) if err != nil { writeJSON(w, http.StatusBadRequest, M{"error": "Login session expired — try again"}) return } wu, err := a.mailboxWebauthnUserFor(mbox) if err != nil { writeJSON(w, http.StatusInternalServerError, M{"error": "Could not load account"}) return } if _, err := wa.FinishLogin(wu, *session, r); err != nil { clearMailboxWebauthnSession(w) _ = a.DB.LogAuthAttempt("webmail_login", mbox.Email, a.requestIP(r), false, "Passkey verification failed") writeJSON(w, http.StatusUnauthorized, M{"error": "Passkey verification failed"}) return } clearMailboxWebauthnSession(w) token, err := a.DB.CreateMailboxSession(mbox.ID, true, sessionTTL) if err != nil { writeJSON(w, http.StatusInternalServerError, M{"error": "Could not start session"}) return } _ = a.DB.LogAuthAttempt("webmail_login", mbox.Email, a.requestIP(r), true, "Login successful (passkey)") clearMailboxPendingMFACookie(w) setMailboxSessionCookie(w, token, r.TLS != nil) writeJSON(w, http.StatusOK, M{"success": true}) }