package webui import ( "crypto/sha256" "encoding/base64" "encoding/json" "net/http" "strconv" "github.com/go-webauthn/webauthn/webauthn" "mailgoserver/internal/db" ) // webauthnSessionCookie carries the SessionData between a WebAuthn ceremony's Begin // and Finish steps — short-lived, httponly, holds no secret beyond the challenge // itself (which is meaningless without the matching authenticator response). const webauthnSessionCookie = "mailgoserver_webauthn_session" // webauthnUser adapts an AdminUser + their stored credentials to webauthn.User. type webauthnUser struct { user *db.AdminUser creds []db.WebAuthnCredential } func (u *webauthnUser) WebAuthnID() []byte { sum := sha256.Sum256([]byte("admin-" + strconv.FormatInt(u.user.ID, 10))) return sum[:] } func (u *webauthnUser) WebAuthnName() string { return u.user.Username } func (u *webauthnUser) WebAuthnDisplayName() string { return u.user.Username } func (u *webauthnUser) 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) webauthnUserFor(user *db.AdminUser) (*webauthnUser, error) { creds, err := a.DB.ListWebAuthnCredentials(user.ID) if err != nil { return nil, err } return &webauthnUser{user: user, creds: creds}, nil } func (a *App) buildWebAuthn() (*webauthn.WebAuthn, error) { sec := a.Cfg.Section("Auth") return webauthn.New(&webauthn.Config{ RPID: sec.Key("rp_id").MustString("localhost"), RPDisplayName: sec.Key("rp_display_name").MustString("mailgoserver"), RPOrigins: []string{sec.Key("rp_origin").MustString("http://localhost:5000")}, }) } func saveWebauthnSession(w http.ResponseWriter, s *webauthn.SessionData) error { b, err := json.Marshal(s) if err != nil { return err } http.SetCookie(w, &http.Cookie{ Name: webauthnSessionCookie, Value: base64.URLEncoding.EncodeToString(b), Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 5 * 60, }) return nil } func loadWebauthnSession(r *http.Request) (*webauthn.SessionData, error) { c, err := r.Cookie(webauthnSessionCookie) 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 clearWebauthnSession(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{Name: webauthnSessionCookie, Value: "", Path: "/", MaxAge: -1}) } // passkeyRegisterBegin starts enrolling a new passkey for the logged-in admin. func (a *App) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) { user := userFromContext(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.webauthnUserFor(user) 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 := saveWebauthnSession(w, session); err != nil { writeJSON(w, http.StatusInternalServerError, M{"error": "Could not start registration"}) return } writeJSON(w, http.StatusOK, creation) } // passkeyRegisterFinish completes enrollment and stores the new credential. func (a *App) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request) { user := userFromContext(r) wa, err := a.buildWebAuthn() if err != nil { writeJSON(w, http.StatusInternalServerError, M{"error": err.Error()}) return } session, err := loadWebauthnSession(r) if err != nil { writeJSON(w, http.StatusBadRequest, M{"error": "Registration session expired — try again"}) return } wu, err := a.webauthnUserFor(user) if err != nil { writeJSON(w, http.StatusInternalServerError, M{"error": "Could not load account"}) return } cred, err := wa.FinishRegistration(wu, *session, r) clearWebauthnSession(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.CreateWebAuthnCredential(user.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("admin_mfa", user.Username, a.requestIP(r), true, "Passkey added: "+name) writeJSON(w, http.StatusOK, M{"success": true}) } func (a *App) passkeyRemove(w http.ResponseWriter, r *http.Request) { user := userFromContext(r) if err := a.DB.DeleteWebAuthnCredential(pathID(r), user.ID); err != nil { setFlash(w, "error", "Could not remove passkey") } else { _ = a.DB.LogAuthAttempt("admin_mfa", user.Username, a.requestIP(r), true, "Passkey removed") setFlash(w, "success", "Passkey removed") } http.Redirect(w, r, Prefix+"/account", http.StatusFound) } // passkeyLoginBegin starts the passkey ceremony for the user who's already passed // their password and is now at the MFA step. func (a *App) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) { userID := pendingMFAUserID(r) if userID == 0 { writeJSON(w, http.StatusUnauthorized, M{"error": "No login in progress"}) return } user, err := a.DB.GetAdminUserByID(userID) if err != nil || user == 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.webauthnUserFor(user) 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 := saveWebauthnSession(w, session); err != nil { writeJSON(w, http.StatusInternalServerError, M{"error": "Could not start login"}) return } writeJSON(w, http.StatusOK, assertion) } // passkeyLoginFinish verifies the assertion and, on success, promotes the pending // login into a fully-verified session — the same outcome as a correct TOTP code. func (a *App) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) { userID := pendingMFAUserID(r) if userID == 0 { writeJSON(w, http.StatusUnauthorized, M{"error": "No login in progress"}) return } user, err := a.DB.GetAdminUserByID(userID) if err != nil || user == 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 := loadWebauthnSession(r) if err != nil { writeJSON(w, http.StatusBadRequest, M{"error": "Login session expired — try again"}) return } wu, err := a.webauthnUserFor(user) if err != nil { writeJSON(w, http.StatusInternalServerError, M{"error": "Could not load account"}) return } if _, err := wa.FinishLogin(wu, *session, r); err != nil { clearWebauthnSession(w) _ = a.DB.LogAuthAttempt("admin_login", user.Username, a.requestIP(r), false, "Passkey verification failed") writeJSON(w, http.StatusUnauthorized, M{"error": "Passkey verification failed"}) return } clearWebauthnSession(w) token, err := a.DB.CreateSession(user.ID, true, sessionTTL) if err != nil { writeJSON(w, http.StatusInternalServerError, M{"error": "Could not start session"}) return } _ = a.DB.LogAuthAttempt("admin_login", user.Username, a.requestIP(r), true, "Login successful (passkey)") clearPendingMFACookie(w) setSessionCookie(w, token, r.TLS != nil) writeJSON(w, http.StatusOK, M{"success": true}) }