feat(account): deletion orchestration + step-up + gateway edge
Step-up: email accounts confirm with a mailed code (purpose=delete, no deeplink —
ConfirmByToken refuses a delete token so a stray click can't delete); platform-only
accounts type a fixed phrase (anti-impulse). Endpoints /user/delete/{request,confirm};
the confirm orchestration resigns active games, drops all-robot games, tombstones +
anonymizes the account (freeing its creds), and revokes its sessions — the tombstone is
the point of no return, the rest best-effort. Gateway account.delete.{request,confirm}
ops + fbs AccountDeleteConfirm/AccountDeleteRequestResult + branded ru/en delete email.
Integration tests cover the step-up (code + no-email) and the orchestration pieces.
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/accountdelete"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// Account deletion is legal retention, not erasure (docs/ARCHITECTURE.md §9.1): the account
|
||||
// row survives as a tombstone while its credentials are journalled + freed, its live
|
||||
// surfaces anonymised, and its own social/ephemeral data dropped. Messages are kept. The
|
||||
// step-up is a mailed code for an account with a confirmed email, or a typed phrase for a
|
||||
// platform-only account (possession-proof is unattainable there, so the phrase is
|
||||
// anti-impulse only).
|
||||
|
||||
// deletePhrase is the fixed confirmation phrase a no-email account types to delete.
|
||||
// Compared case-insensitively; the client localises only the surrounding instruction.
|
||||
const deletePhrase = "DELETE"
|
||||
|
||||
// deleteRequestResponse tells the client which step-up the account uses.
|
||||
type deleteRequestResponse struct {
|
||||
Method string `json:"method"` // "email" | "phrase"
|
||||
}
|
||||
|
||||
// handleRequestDelete starts account deletion: it mails a delete code to an account with a
|
||||
// confirmed email, or reports the typed-phrase path otherwise.
|
||||
func (s *Server) handleRequestDelete(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
hasEmail, err := s.emails.HasEmail(ctx, uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
if !hasEmail {
|
||||
c.JSON(http.StatusOK, deleteRequestResponse{Method: "phrase"})
|
||||
return
|
||||
}
|
||||
if err := s.emails.RequestDeleteCode(ctx, uid); err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, deleteRequestResponse{Method: "email"})
|
||||
}
|
||||
|
||||
// deleteConfirmBody carries the step-up proof: a mailed code (email account) or the typed
|
||||
// phrase (platform-only account).
|
||||
type deleteConfirmBody struct {
|
||||
Code string `json:"code"`
|
||||
Phrase string `json:"phrase"`
|
||||
}
|
||||
|
||||
// handleConfirmDelete verifies the step-up and deletes the account.
|
||||
func (s *Server) handleConfirmDelete(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
var req deleteConfirmBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
hasEmail, err := s.emails.HasEmail(ctx, uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
if hasEmail {
|
||||
if err := s.emails.VerifyDeleteCode(ctx, uid, req.Code); err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
} else if !strings.EqualFold(strings.TrimSpace(req.Phrase), deletePhrase) {
|
||||
abortBadRequest(c, "confirmation phrase does not match")
|
||||
return
|
||||
}
|
||||
if err := s.deleteAccount(ctx, uid); err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
// deleteAccount runs the deletion orchestration after the step-up passed: it resigns the
|
||||
// account's active games (so opponents are not stranded and robot games end cleanly),
|
||||
// drops its all-robot games, tombstones + anonymises the account (journalling and freeing
|
||||
// its credentials), and revokes its sessions. The tombstone is the point of no return —
|
||||
// its failure aborts; the game cleanup and session revocation around it are best-effort.
|
||||
func (s *Server) deleteAccount(ctx context.Context, uid uuid.UUID) error {
|
||||
deleter := accountdelete.NewDeleter(s.db)
|
||||
if s.games != nil {
|
||||
if games, err := s.games.ListForAccount(ctx, uid); err == nil {
|
||||
for _, g := range games {
|
||||
if g.Status != game.StatusActive {
|
||||
continue
|
||||
}
|
||||
if _, err := s.games.Resign(ctx, g.ID, uid); err != nil {
|
||||
s.log.Warn("delete: resign game failed", zap.String("game", g.ID.String()), zap.Error(err))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
s.log.Warn("delete: list games failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
if _, err := deleter.DropAllRobotGames(ctx, uid); err != nil {
|
||||
s.log.Warn("delete: drop all-robot games failed", zap.Error(err))
|
||||
}
|
||||
if err := deleter.AnonymizeAndTombstone(ctx, uid); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.sessions != nil {
|
||||
if err := s.sessions.RevokeAllForAccount(ctx, uid); err != nil {
|
||||
s.log.Warn("delete: revoke sessions failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user