From 45677f0655f3a02e9767b196122933dbfaabda4e Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Fri, 31 Jul 2026 20:53:28 -0400 Subject: [PATCH] Add option to delete your account --- internal/auth/delete_handlers.go | 105 +++++ internal/auth/reauth.go | 127 ++++++ internal/auth/templates/account.html | 16 + internal/auth/templates/delete_account.html | 194 ++++++++ internal/auth/web.go | 8 + internal/core/account_delete_test.go | 197 ++++++++ internal/e2e/account_delete_test.go | 126 ++++++ internal/store/delete_user.go | 260 +++++++++++ internal/store/delete_user_test.go | 470 ++++++++++++++++++++ internal/web/static/webauthn.js | 30 ++ 10 files changed, 1533 insertions(+) create mode 100644 internal/auth/delete_handlers.go create mode 100644 internal/auth/reauth.go create mode 100644 internal/auth/templates/delete_account.html create mode 100644 internal/core/account_delete_test.go create mode 100644 internal/e2e/account_delete_test.go create mode 100644 internal/store/delete_user.go create mode 100644 internal/store/delete_user_test.go diff --git a/internal/auth/delete_handlers.go b/internal/auth/delete_handlers.go new file mode 100644 index 0000000..0f419fb --- /dev/null +++ b/internal/auth/delete_handlers.go @@ -0,0 +1,105 @@ +package auth + +import ( + "errors" + "net/http" + + "github.com/jleight/meshtender/internal/store" + "github.com/jleight/meshtender/internal/web" +) + +// Account deletion. The page's job is to make the consequences legible before +// anyone clicks: exactly which repeaters would be destroyed (with a link to hand +// each one to a steward instead), which organizations go or stay, and anything +// that blocks the deletion outright. + +// deleteAccountErr bounces back to the confirm page with an error, so the user +// keeps the context rather than landing on the account page wondering. +func deleteAccountErr(w http.ResponseWriter, r *http.Request, msg string) { + web.RedirectErr(w, r, "/account/delete", msg) +} + +// pageDeleteAccount renders the deletion confirm page. +func (s *Handlers) pageDeleteAccount(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + uid := s.Auth.CurrentUserID(ctx) + u, err := s.Store.GetUserByID(ctx, uid) + if err != nil { + s.ServerError(w, r, "could not load account", err) + return + } + preview, err := s.Store.PreviewUserDeletion(ctx, uid) + if err != nil { + s.ServerError(w, r, "could not load account", err) + return + } + s.Render(w, r, "delete_account.html", map[string]any{ + "User": u, + "Preview": preview, + // Which proof of identity to ask for. An account can have both; the password + // field is shown when there is one, with the passkey button alongside. + "HasPassword": u.PasswordHash != nil, + "HasPasskeys": preview.Passkeys > 0, + // Repeater transfer lives on the app host, so links out need its origin. + "AppOrigin": s.Auth.AppOrigin(r), + "Error": r.URL.Query().Get("error"), + }) +} + +// handleDeleteAccount verifies the person is still present — a password, or a +// passkey assertion completed in the last ReauthWindow — and then deletes the +// account. The store re-checks every blocker inside its transaction, so this +// handler's job is the identity proof and the messaging. +func (s *Handlers) handleDeleteAccount(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + uid := s.Auth.CurrentUserID(ctx) + u, err := s.Store.GetUserByID(ctx, uid) + if err != nil { + s.ServerError(w, r, "could not load account", err) + return + } + + // A fresh passkey assertion satisfies this outright; otherwise fall back to the + // password. An account with neither can't reach here — every account has at + // least one sign-in method, enforced when removing one. + if !s.Auth.ReauthFresh(ctx) { + switch pw := r.FormValue("password"); { + case u.PasswordHash == nil: + deleteAccountErr(w, r, "Verify with your passkey to delete your account.") + return + case pw == "": + deleteAccountErr(w, r, "Enter your password, or verify with your passkey, to delete your account.") + return + case !s.Auth.PasswordMatches(u, pw): + deleteAccountErr(w, r, "That password is incorrect.") + return + } + } + + switch err := s.Store.DeleteUser(ctx, uid); { + case errors.Is(err, store.ErrSoleOrgAdmin): + deleteAccountErr(w, r, "You're the only admin of an organization that still has other members. "+ + "Make someone else an admin there first, then come back.") + case errors.Is(err, store.ErrLastSiteAdmin): + deleteAccountErr(w, r, "You're the last administrator of this MeshTender instance. "+ + "Give another account the manage-users capability first.") + case errors.Is(err, store.ErrNotFound): + // Already gone (a double submit, or deleted in another tab). Treat it as + // done rather than as an error: the outcome the user asked for holds. + s.finishDeletion(w, r) + case err != nil: + s.ServerError(w, r, "could not delete account", err) + default: + s.finishDeletion(w, r) + } +} + +// finishDeletion tears down the session and lands on the sign-in page with a +// confirmation. Every other host drops to anonymous on its next request anyway — +// the logins row cascaded away with the account, and a missing row reads as +// revoked — but clearing this host's session too means the browser isn't left +// holding a cookie for an account that no longer exists. +func (s *Handlers) finishDeletion(w http.ResponseWriter, r *http.Request) { + _ = s.Auth.Logout(r.Context()) + web.RedirectFlash(w, r, "/login", "ok", "Your account and everything in it have been deleted.") +} diff --git a/internal/auth/reauth.go b/internal/auth/reauth.go new file mode 100644 index 0000000..dff6c96 --- /dev/null +++ b/internal/auth/reauth.go @@ -0,0 +1,127 @@ +package auth + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/jleight/meshtender/internal/web" +) + +// Re-authentication ("sudo mode"): proving, right now, that the person driving +// an authenticated session is still the account holder. A live session says +// somebody signed in here at some point — not that the person about to destroy +// the account is the one who did. Deleting an account is irreversible and one +// click from an unattended browser, so it demands a fresh proof. +// +// Password holders re-enter their password (handled at the point of use, where +// the form already is); passkey-only accounts complete an assertion here, which +// stamps the session. Either way, the proof expires quickly. + +// sessKeyReauthAt holds the unix seconds of the last successful identity proof. +// Stored as an int64 rather than a time.Time: scs serializes session data with +// gob, and an integer needs no type registration to survive a round trip. +const sessKeyReauthAt = "reauth_at" + +// ReauthWindow is how long a proof of presence authorizes a sensitive action. +// Long enough to read a confirmation page and think, short enough that a walk- +// away between the ceremony and the click doesn't hand someone the account. +const ReauthWindow = 5 * time.Minute + +// MarkReauth records a successful identity proof on the current session. +func (s *Service) MarkReauth(ctx context.Context) { + s.Sessions.Put(ctx, sessKeyReauthAt, time.Now().Unix()) +} + +// ReauthFresh reports whether this session proved its identity within +// ReauthWindow. +func (s *Service) ReauthFresh(ctx context.Context) bool { + at := s.Sessions.GetInt64(ctx, sessKeyReauthAt) + return at != 0 && time.Since(time.Unix(at, 0)) < ReauthWindow +} + +// ReauthPasskeyBegin starts an assertion ceremony against the signed-in user's +// own credentials. Unlike LoginBegin it takes no username: the account is +// whoever the session says it is, so this can't be used to probe for accounts. +func (s *Service) ReauthPasskeyBegin(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + uid := s.CurrentUserID(ctx) + if uid == 0 { + httpError(w, r, http.StatusUnauthorized, "not signed in", nil) + return + } + u, err := s.store.GetUserByID(ctx, uid) + if err != nil { + httpError(w, r, http.StatusInternalServerError, "load user", err) + return + } + waUser, err := s.loadWebAuthnUser(ctx, u) + if err != nil { + httpError(w, r, http.StatusInternalServerError, "load credentials", err) + return + } + if len(waUser.creds) == 0 { + httpError(w, r, http.StatusBadRequest, "no passkey registered for this account", nil) + return + } + options, sessionData, err := s.wa.BeginLogin(waUser) + if err != nil { + httpError(w, r, http.StatusInternalServerError, "begin verification", err) + return + } + if err := s.stashCeremony(ctx, uid, sessionData); err != nil { + httpError(w, r, http.StatusInternalServerError, "save ceremony", err) + return + } + writeJSON(w, options) +} + +// ReauthPasskeyFinish completes the assertion and stamps the session as freshly +// verified. It grants no new access on its own — the sensitive handler decides +// what a fresh stamp is worth. +func (s *Service) ReauthPasskeyFinish(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + uid := s.CurrentUserID(ctx) + ceremonyUID, sessionData, ok := s.popCeremony(ctx) + if !ok { + httpError(w, r, http.StatusBadRequest, "no verification in progress", nil) + return + } + // The ceremony must belong to the session driving it. Begin only ever stashes + // the current user, so this is belt-and-braces against a ceremony stashed by + // some other flow (a half-finished sign-in) being spent as a re-auth here. + if uid == 0 || ceremonyUID != uid { + httpError(w, r, http.StatusUnauthorized, "verification failed", nil) + return + } + u, err := s.store.GetUserByID(ctx, uid) + if err != nil { + httpError(w, r, http.StatusInternalServerError, "load user", err) + return + } + waUser, err := s.loadWebAuthnUser(ctx, u) + if err != nil { + httpError(w, r, http.StatusInternalServerError, "load credentials", err) + return + } + cred, err := s.wa.FinishLogin(waUser, *sessionData, r) + if err != nil { + // Same treatment as LoginFinish: the go-webauthn detail can leak internals, + // so log it and return something generic. + web.LogError(r, "webauthn: finish reauth", err) + httpError(w, r, http.StatusUnauthorized, "verification failed", nil) + return + } + // Persist the updated sign counter / clone-warning state, as login does. + if blob, err := json.Marshal(cred); err == nil { + _ = s.store.UpdateCredential(ctx, cred.ID, blob) + } + s.MarkReauth(ctx) + writeJSON(w, authResult{OK: true}) +} + +// AppOrigin is the app host's origin for this request, so auth-host pages can +// link into the app (the delete page offers repeater transfers, which live +// there). +func (s *Service) AppOrigin(r *http.Request) string { return s.appOrigin(r) } diff --git a/internal/auth/templates/account.html b/internal/auth/templates/account.html index 20a5537..57e84e0 100644 --- a/internal/auth/templates/account.html +++ b/internal/auth/templates/account.html @@ -333,6 +333,22 @@ +{{/* Deletion is a page, not a button here: what it removes depends on what you + own, and that has to be shown before anyone commits to it. */}} +
+

Delete account

+
+

+ Permanently delete your account, your public profile, and the repeaters you own. This can't be + undone — the next page shows exactly what would go, and lets you hand any repeater to a steward + first. +

+ + {{template "icon-trash" "me-1"}}Delete my account… + +
+
+