diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 5215562..0cb1ef7 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -668,6 +668,80 @@ paths: '401': $ref: '#/components/responses/Unauthorized' + /api/v1/internal/player/reclaim: + post: + tags: [account-internal] + operationId: reclaimUsername + summary: Record a Mojang-priority username reclaim — bar the squatter UUID and stash its data (spec §B3). + description: > + Internal-only. Velocity records a username-collision reclaim: the + non-genuine squatter UUID is barred and its world/player data stashed for + a 30-day window so a new account can inherit it. Idempotent — a repeat + reclaim of an already-barred UUID is a no-op. The bar is keyed by UUID, + never the contested name, so the genuine Mojang player always passes. + x-felis-face: [internal] + x-felis-tier: service + security: [{ serviceToken: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [squatter_uuid, username] + properties: + squatter_uuid: { type: string, format: uuid } + username: { type: string } + data_ref: + type: string + description: > + Optional opaque handle to the data already archived for the + hold (server-side only, never returned). Archival may be + deferred, in which case this is omitted. + responses: + '200': + description: Reclaim recorded. + content: + application/json: + schema: + type: object + required: [blacklisted, username, hold_expires_at] + properties: + blacklisted: { type: boolean, const: true } + username: { type: string } + hold_expires_at: { type: string, format: date-time } + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /api/v1/internal/player/blacklist/{mc_uuid}: + get: + tags: [account-internal] + operationId: checkUsernameBlacklist + summary: Report whether an in-game UUID was barred by a prior reclaim (spec §B3). + description: > + Internal-only. The velocity login gate calls it to reject a barred + squatter before letting them in; the genuine Mojang UUID — same username, + different UUID — is never on the list and always passes. + x-felis-face: [internal] + x-felis-tier: service + security: [{ serviceToken: [] }] + parameters: + - { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } } + responses: + '200': + description: Blacklist status. + content: + application/json: + schema: + type: object + required: [blacklisted] + properties: + blacklisted: { type: boolean } + '401': + $ref: '#/components/responses/Unauthorized' + # ----------------------------------------------------- external: servers --- /api/v1/servers/{name}/wake: post: diff --git a/internal/api/api.go b/internal/api/api.go index 0727aa7..76dc92f 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -172,6 +172,13 @@ func (a *API) internalAPIRoutes() []apiRoute { // verified UUID. Internal-only — the code is born from an online-mode UUID the // web never holds (account_link_codes has no user_id column). {Method: "POST", Pattern: "/api/v1/internal/account/link/code", h: a.handleCreateLinkCode}, + // Username-collision reclaim (spec §B3): velocity records a Mojang-priority + // reclaim (bar the squatter UUID + stash its data for 30 days) and gates the + // limbo login by checking whether a connecting UUID was barred. Internal-only — + // velocity holds a service token, and the bar is keyed by UUID so the genuine + // Mojang player (same name, different UUID) always passes. + {Method: "POST", Pattern: "/api/v1/internal/player/reclaim", h: a.handleReclaimUsername}, + {Method: "GET", Pattern: "/api/v1/internal/player/blacklist/{mc_uuid}", h: a.handleCheckBlacklist}, } } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index f05cf42..433e357 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -54,6 +54,24 @@ type fakeRepo struct { // player email OTPs (spec §B2). Keyed by row id; the verify path scans for the // newest live (user, purpose) just as the PG query does. otps map[string]*fakeEmailOTP + // username-collision reclaim (spec §B3). blacklist mirrors username_blacklist + // (mc_uuid -> barred), holds mirrors player_data_holds keyed by the held + // (squatter) mc_uuid — both keyed by UUID, matching the PG UNIQUE(mc_uuid) + // idempotency. They are written together by ReclaimUsername so the fake encodes + // the same all-or-nothing contract the PG transaction enforces. + blacklist map[string]bool + holds map[string]fakeDataHold +} + +// fakeDataHold mirrors a player_data_holds row at the granularity the verifiable +// (write-only) layer exercises: which name/data was stashed for the squatter UUID +// and when the 30-day window ends. reclaimed_by_user_id/reclaimed_at have no fake +// fields — the inherit flow that would set them is CODE-ONLY (deferred). +type fakeDataHold struct { + id string + username string + dataRef string + expiresAt time.Time } // fakeEmailOTP mirrors an email_otps row: only the code hash is held (never the @@ -107,6 +125,8 @@ func newFakeRepo() *fakeRepo { sessions: map[string]*fakeSession{}, settings: map[string][]byte{}, otps: map[string]*fakeEmailOTP{}, + blacklist: map[string]bool{}, + holds: map[string]fakeDataHold{}, } } @@ -210,6 +230,23 @@ func (f *fakeRepo) UserByMCUUID(_ context.Context, uuid string) (string, error) } return "", ErrNotFound } + +// ReclaimUsername mirrors PGRepo.ReclaimUsername: it bars the squatter UUID and +// stashes the data hold together (the all-or-nothing PG transaction), keyed by +// mc_uuid so a repeat reclaim of an already-barred UUID is an idempotent no-op +// (ON CONFLICT (mc_uuid) DO NOTHING on both tables) — the first reclaim wins and +// a duplicate neither errors nor overwrites the stored hold. +func (f *fakeRepo) ReclaimUsername(_ context.Context, id, squatterUUID, username, dataRef string, expiresAt time.Time) (time.Time, error) { + if h, ok := f.holds[squatterUUID]; ok { // already stashed — idempotent no-op; keep & report the first window + return h.expiresAt, nil + } + f.blacklist[squatterUUID] = true + f.holds[squatterUUID] = fakeDataHold{id: id, username: username, dataRef: dataRef, expiresAt: expiresAt} + return expiresAt, nil +} +func (f *fakeRepo) IsUsernameBlacklisted(_ context.Context, mcUUID string) (bool, error) { + return f.blacklist[mcUUID], nil +} func (f *fakeRepo) ClaimServer(_ context.Context, n, u string) (bool, error) { ok, present := f.claimOK[n] if !present { diff --git a/internal/api/handlers_player_reclaim.go b/internal/api/handlers_player_reclaim.go new file mode 100644 index 0000000..7bdd4e8 --- /dev/null +++ b/internal/api/handlers_player_reclaim.go @@ -0,0 +1,125 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "net/http" + "time" +) + +// Username-collision reclaim (spec §B3, internal face). The configured +// third-party Yggdrasil and the official Mojang service can mint the SAME +// username under DIFFERENT UUIDs. Velocity detects the collision in a limbo login +// server; when the connecting player is NOT the genuine Mojang owner, Mojang +// takes priority (正版优先): the squatter is rejected and barred, and its data is +// stashed for a 30-day window so a new account can inherit it. +// +// These two endpoints are the Go-verifiable data layer of that flow, both +// internal-face (velocity holds a service token, never a web Principal): +// +// POST /api/v1/internal/player/reclaim — record a reclaim (bar + stash) +// GET /api/v1/internal/player/blacklist/{uuid} — the login gate's bar check +// +// Velocity collision-routing, the limbo prompt, the authlib dual-backend, and the +// data-inherit flow are CODE-ONLY (Java + a QR-bound device session a Postgres +// row cannot express) and are not represented here. The block is keyed by UUID, +// never by the contested name, so the genuine Mojang player — same username, +// different UUID — is never caught. + +const ( + // reclaimHoldTTL is the 30-day window a reclaimed account's data is stashed for + // before it may be purged (spec §B3 "您的数据将会被暂存 30 天"). The hold's + // expires_at is the API clock + this, so one authoritative clock drives expiry. + reclaimHoldTTL = 30 * 24 * time.Hour +) + +// newHoldID returns an opaque random row id (128 bits, hex) for a +// player_data_holds row, mirroring newOTPID. +func newHoldID() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +} + +// reclaimRequest is the velocity reclaim callback body (spec §B3): the verified +// online-mode UUID of the squatter being displaced, the contested username (for +// display/audit), and an optional opaque handle to the data already archived for +// the hold. data_ref is optional — archival may be deferred — but a squatter_uuid +// and username are always required. +type reclaimRequest struct { + SquatterUUID string `json:"squatter_uuid"` + Username string `json:"username"` + DataRef string `json:"data_ref"` +} + +// handleReclaimUsername records a Mojang-priority username reclaim (spec §B3, +// internal face). In one transaction it bars the squatter UUID and stashes its +// data as a 30-day hold (Repo.ReclaimUsername). It is idempotent: a repeat +// reclaim of an already-barred UUID is a no-op that still answers 200, so a +// retried velocity callback is harmless. It returns the hold's expiry so velocity +// can tell the rejected player how long their data is kept. +func (a *API) handleReclaimUsername(w http.ResponseWriter, r *http.Request) { + var req reclaimRequest + if err := decodeJSON(w, r, &req); err != nil { + writeError(w, r, err) + return + } + if req.SquatterUUID == "" { + writeError(w, r, newError(http.StatusBadRequest, "bad_request", "squatter_uuid is required")) + return + } + if req.Username == "" { + writeError(w, r, newError(http.StatusBadRequest, "bad_request", "username is required")) + return + } + id, err := newHoldID() + if err != nil { + writeError(w, r, err) + return + } + proposedExpiry := a.now().Add(reclaimHoldTTL) + // ReclaimUsername returns the EFFECTIVE persisted expiry, which differs from the + // proposed one on an idempotent retry (the hold keeps its first window). We echo + // the persisted value so a re-firing velocity callback never tells the player a + // 30-day window that the stored hold does not actually have. + heldUntil, err := a.Repo.ReclaimUsername(r.Context(), id, req.SquatterUUID, req.Username, req.DataRef, proposedExpiry) + if err != nil { + writeError(w, r, err) + return + } + // A reclaim bars a player and stashes their world — a security-significant + // accountability event. The squatter UUID and contested name go in the audit + // payload (the flat columns model a server op, not this), keyed by Source + // internal since velocity, not a human, drives it. + payload, _ := json.Marshal(map[string]string{"username": req.Username, "squatter_uuid": req.SquatterUUID}) + _ = a.Repo.Audit(r.Context(), AuditEntry{ + Actor: "velocity", Source: "internal", Action: "player.reclaim", + RequestID: requestIDFromContext(r.Context()), Payload: payload, + }) + writeJSON(w, http.StatusOK, map[string]any{ + "blacklisted": true, + "username": req.Username, + "hold_expires_at": heldUntil.UTC(), + }) +} + +// handleCheckBlacklist reports whether an in-game UUID was barred by a prior +// reclaim (spec §B3, internal face). The velocity login gate calls it to reject a +// squatter before letting them in; the genuine Mojang UUID (same name, different +// UUID) is never on the list, so it always passes. +func (a *API) handleCheckBlacklist(w http.ResponseWriter, r *http.Request) { + mcUUID := r.PathValue("mc_uuid") + if mcUUID == "" { + writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required")) + return + } + blacklisted, err := a.Repo.IsUsernameBlacklisted(r.Context(), mcUUID) + if err != nil { + writeError(w, r, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"blacklisted": blacklisted}) +} diff --git a/internal/api/handlers_player_reclaim_test.go b/internal/api/handlers_player_reclaim_test.go new file mode 100644 index 0000000..38988fc --- /dev/null +++ b/internal/api/handlers_player_reclaim_test.go @@ -0,0 +1,239 @@ +package api + +import ( + "encoding/json" + "net/http" + "testing" + "time" +) + +// Username-collision reclaim (spec §B3) is Mojang-priority: when a non-genuine +// player squats a name the official service also issues, velocity bars the +// squatter's UUID and stashes its data for 30 days. These tests pin the +// Go-verifiable data layer of that flow — the bar, the stash, idempotency, and +// the one safety property that must never regress: the block is keyed by UUID, so +// the genuine Mojang player (same name, DIFFERENT UUID) is never caught. + +// TestReclaimBarsSquatterAndStashes is the happy path: a reclaim bars the +// squatter UUID, the subsequent gate check for that UUID reports barred, and the +// data hold lands with the API-clock 30-day expiry. +func TestReclaimBarsSquatterAndStashes(t *testing.T) { + const squatter = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + repo := newFakeRepo() + api := newTestAPI(repo, newFakeCluster()) + ih := api.InternalHandler() + + body := `{"squatter_uuid":"` + squatter + `","username":"Notch","data_ref":"s3://holds/notch"}` + w := do(ih, "POST", "/api/v1/internal/player/reclaim", body, nil) + if w.Code != http.StatusOK { + t.Fatalf("reclaim: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + b := acctBody(t, w) + if b["blacklisted"] != true || b["username"] != "Notch" { + t.Fatalf("reclaim body = %v, want blacklisted:true username:Notch", b) + } + + // The squatter UUID is now barred — what the velocity login gate checks. + if !repo.blacklist[squatter] { + t.Fatal("squatter UUID was not barred") + } + // The data is stashed for inherit with the authoritative-clock 30-day window. + hold, ok := repo.holds[squatter] + if !ok { + t.Fatal("no data hold was stashed for the squatter") + } + if hold.username != "Notch" || hold.dataRef != "s3://holds/notch" { + t.Errorf("hold = %+v, want username:Notch dataRef:s3://holds/notch", hold) + } + if want := api.now().Add(reclaimHoldTTL); !hold.expiresAt.Equal(want) { + t.Errorf("hold.expiresAt = %v, want %v", hold.expiresAt, want) + } + + // The gate check for the barred UUID reports it. + w = do(ih, "GET", "/api/v1/internal/player/blacklist/"+squatter, "", nil) + if w.Code != http.StatusOK { + t.Fatalf("blacklist check: code = %d (%s)", w.Code, w.Body.String()) + } + if acctBody(t, w)["blacklisted"] != true { + t.Fatalf("blacklist check body = %s, want blacklisted:true", w.Body.String()) + } +} + +// TestReclaimNeverCatchesGenuineMojangPlayer is the load-bearing safety property: +// the block is keyed by UUID, never by the contested name. A genuine Mojang +// player shares the username but carries a different UUID, so the gate must let +// them through even after the squatter is barred. If this ever regresses,正版优先 +// becomes正版连不上 — the exact outcome the design forbids. +func TestReclaimNeverCatchesGenuineMojangPlayer(t *testing.T) { + const ( + squatter = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + genuine = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" // same name, real owner + ) + repo := newFakeRepo() + api := newTestAPI(repo, newFakeCluster()) + ih := api.InternalHandler() + + body := `{"squatter_uuid":"` + squatter + `","username":"Notch"}` + if w := do(ih, "POST", "/api/v1/internal/player/reclaim", body, nil); w.Code != http.StatusOK { + t.Fatalf("reclaim: code = %d (%s)", w.Code, w.Body.String()) + } + + // The genuine owner — identical username, different UUID — is NOT barred. + w := do(ih, "GET", "/api/v1/internal/player/blacklist/"+genuine, "", nil) + if w.Code != http.StatusOK { + t.Fatalf("genuine check: code = %d (%s)", w.Code, w.Body.String()) + } + if got := acctBody(t, w)["blacklisted"]; got != false { + t.Fatalf("genuine Mojang player blacklisted = %v, want false", got) + } +} + +// TestReclaimIsIdempotent proves a retried velocity callback is harmless: a repeat +// reclaim of an already-barred UUID still answers 200 and does not disturb the +// original hold (matching the ON CONFLICT DO NOTHING in both inserts). +func TestReclaimIsIdempotent(t *testing.T) { + const squatter = "cccccccc-cccc-cccc-cccc-cccccccccccc" + repo := newFakeRepo() + api := newTestAPI(repo, newFakeCluster()) + ih := api.InternalHandler() + + first := `{"squatter_uuid":"` + squatter + `","username":"Herobrine","data_ref":"ref-1"}` + if w := do(ih, "POST", "/api/v1/internal/player/reclaim", first, nil); w.Code != http.StatusOK { + t.Fatalf("first reclaim: code = %d (%s)", w.Code, w.Body.String()) + } + original := repo.holds[squatter] + + // A retry with a different data_ref must not overwrite the original stash. + retry := `{"squatter_uuid":"` + squatter + `","username":"Herobrine","data_ref":"ref-2"}` + if w := do(ih, "POST", "/api/v1/internal/player/reclaim", retry, nil); w.Code != http.StatusOK { + t.Fatalf("retry reclaim: code = %d (%s)", w.Code, w.Body.String()) + } + if got := repo.holds[squatter]; got != original { + t.Errorf("idempotent retry altered the hold: got %+v, want %+v", got, original) + } +} + +// TestReclaimRetryReportsOriginalWindow is the regression for the response-honesty +// fix: the squatter reconnects days later, velocity re-fires the reclaim, and the +// hold keeps its FIRST 30-day window (ON CONFLICT preserves it). The response must +// report that persisted window, NOT a fresh now()+30d — otherwise velocity tells +// the player a kept-until date the stored hold does not actually have. A frozen +// clock cannot catch this, so the clock is advanced between the two reclaims. +func TestReclaimRetryReportsOriginalWindow(t *testing.T) { + const squatter = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee" + repo := newFakeRepo() + api := newTestAPI(repo, newFakeCluster()) + clock := time.Unix(1_700_000_000, 0) + api.Now = func() time.Time { return clock } + ih := api.InternalHandler() + + body := `{"squatter_uuid":"` + squatter + `","username":"Notch"}` + w := do(ih, "POST", "/api/v1/internal/player/reclaim", body, nil) + if w.Code != http.StatusOK { + t.Fatalf("first reclaim: code = %d (%s)", w.Code, w.Body.String()) + } + first := acctBody(t, w)["hold_expires_at"] + + // Three days pass; velocity re-fires the reclaim on the squatter's next attempt. + clock = clock.Add(72 * time.Hour) + w = do(ih, "POST", "/api/v1/internal/player/reclaim", body, nil) + if w.Code != http.StatusOK { + t.Fatalf("retry reclaim: code = %d (%s)", w.Code, w.Body.String()) + } + second := acctBody(t, w)["hold_expires_at"] + + if first != second { + t.Fatalf("retry hold_expires_at = %v, want the original %v — the response must reflect the persisted hold, not a fresh clock", second, first) + } + // And it must equal the first reclaim's window, not the retry-time clock. + if want := time.Unix(1_700_000_000, 0).Add(reclaimHoldTTL).UTC().Format(time.RFC3339Nano); second != want { + t.Errorf("hold_expires_at = %v, want %v (first reclaim's window)", second, want) + } +} + +// TestReclaimAudited proves a reclaim writes a security-significant accountability +// row: a player is barred and their world stashed, so the event lands in +// audit_logs as player.reclaim from velocity over the internal source, with the +// contested name and squatter UUID in the structured payload. +func TestReclaimAudited(t *testing.T) { + const squatter = "dddddddd-dddd-dddd-dddd-dddddddddddd" + repo := newFakeRepo() + api := newTestAPI(repo, newFakeCluster()) + ih := api.InternalHandler() + + body := `{"squatter_uuid":"` + squatter + `","username":"Steve"}` + if w := do(ih, "POST", "/api/v1/internal/player/reclaim", body, nil); w.Code != http.StatusOK { + t.Fatalf("reclaim: code = %d (%s)", w.Code, w.Body.String()) + } + if len(repo.audits) != 1 { + t.Fatalf("audits = %d, want 1", len(repo.audits)) + } + a := repo.audits[0] + if a.Action != "player.reclaim" || a.Actor != "velocity" || a.Source != "internal" { + t.Fatalf("audit = %+v, want player.reclaim/velocity/internal", a) + } + var p map[string]string + if err := json.Unmarshal(a.Payload, &p); err != nil { + t.Fatalf("audit payload not JSON: %v (%s)", err, a.Payload) + } + if p["username"] != "Steve" || p["squatter_uuid"] != squatter { + t.Errorf("audit payload = %v, want username:Steve squatter_uuid:%s", p, squatter) + } +} + +// TestReclaimValidation is the input matrix: a reclaim with no UUID or no username +// is a 400, strict decoding rejects unknown fields, and a rejected request neither +// bars anyone nor stashes anything. +func TestReclaimValidation(t *testing.T) { + cases := []struct { + name string + body string + }{ + {"missing squatter_uuid", `{"username":"Notch"}`}, + {"empty squatter_uuid", `{"squatter_uuid":"","username":"Notch"}`}, + {"missing username", `{"squatter_uuid":"aaaa"}`}, + {"empty username", `{"squatter_uuid":"aaaa","username":""}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo := newFakeRepo() + api := newTestAPI(repo, newFakeCluster()) + w := do(api.InternalHandler(), "POST", "/api/v1/internal/player/reclaim", tc.body, nil) + if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" { + t.Fatalf("code = %d body %s, want 400 bad_request", w.Code, w.Body.String()) + } + if len(repo.blacklist) != 0 || len(repo.holds) != 0 { + t.Fatal("a rejected reclaim must not bar or stash anything") + } + }) + } + + t.Run("unknown field rejected", func(t *testing.T) { + repo := newFakeRepo() + api := newTestAPI(repo, newFakeCluster()) + body := `{"squatter_uuid":"aaaa","username":"Notch","reason":"smuggled"}` + w := do(api.InternalHandler(), "POST", "/api/v1/internal/player/reclaim", body, nil) + if w.Code != http.StatusBadRequest { + t.Fatalf("strict decode must reject unknown field, code = %d (%s)", w.Code, w.Body.String()) + } + }) +} + +// TestReclaimFaceSeparation enforces that the reclaim and gate-check endpoints are +// internal-only: velocity holds a service token, a web Principal never reaches +// them. Crossing onto the external face must 404, not silently work — a logged-in +// user could otherwise bar an arbitrary UUID. +func TestReclaimFaceSeparation(t *testing.T) { + user := &Principal{UserID: "u1", Email: "u1@example.net", Role: "user"} + api := newTestAPI(newFakeRepo(), newFakeCluster()) + api.External = staticExternal{p: user} + eh := api.ExternalHandler() + + body := `{"squatter_uuid":"aaaa","username":"Notch"}` + if w := do(eh, "POST", "/api/v1/internal/player/reclaim", body, nil); w.Code != http.StatusNotFound { + t.Errorf("reclaim on external face: code = %d, want 404", w.Code) + } + if w := do(eh, "GET", "/api/v1/internal/player/blacklist/aaaa", "", nil); w.Code != http.StatusNotFound { + t.Errorf("blacklist check on external face: code = %d, want 404", w.Code) + } +} diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index ad0b4bf..cbe91f2 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -461,6 +461,60 @@ func (p *PGRepo) VerifyEmailOTP(ctx context.Context, userID, purpose, codeHash s return email, nil } +// ---- player game-login: username-collision reclaim (spec §B3) ---- + +// ReclaimUsername bars the squatter UUID and stashes its data hold in one +// transaction (spec §B3 正版优先), returning the hold's EFFECTIVE expiry — the +// value actually persisted, which the caller echoes so the rejected player is +// told the truth about how long their data is kept. The blacklist insert is +// ON CONFLICT (mc_uuid) DO NOTHING; the hold insert is a no-op DO UPDATE so a +// retried reclaim of an already-stashed UUID does not move the original window +// yet RETURNING still fires, handing back the FIRST reclaim's expires_at rather +// than a fresh now()+TTL (DO NOTHING would suppress RETURNING and lose it). The +// two writes share the tx so a failure on the second rolls back the first: the +// system is never left with a barred UUID whose data was never held (data loss) +// nor a hold for a UUID still able to connect (squatter not barred). dataRef "" +// lands as SQL NULL (the column is nullable — archival may be deferred), +// mirroring the NULLIF idiom used for optional text elsewhere. +func (p *PGRepo) ReclaimUsername(ctx context.Context, id, squatterUUID, username, dataRef string, expiresAt time.Time) (time.Time, error) { + tx, err := p.db.BeginTx(ctx, nil) + if err != nil { + return time.Time{}, err + } + defer tx.Rollback() //nolint:errcheck // no-op after commit + + if _, err := tx.ExecContext(ctx, + `INSERT INTO username_blacklist (mc_uuid, username) VALUES ($1, $2) + ON CONFLICT (mc_uuid) DO NOTHING`, + squatterUUID, username); err != nil { + return time.Time{}, fmt.Errorf("blacklist squatter uuid: %w", err) + } + var effective time.Time + if err := tx.QueryRowContext(ctx, + `INSERT INTO player_data_holds (id, mc_uuid, username, data_ref, expires_at) + VALUES ($1, $2, $3, NULLIF($4, ''), $5) + ON CONFLICT (mc_uuid) DO UPDATE SET mc_uuid = EXCLUDED.mc_uuid + RETURNING expires_at`, + id, squatterUUID, username, dataRef, expiresAt).Scan(&effective); err != nil { + return time.Time{}, fmt.Errorf("stash data hold: %w", err) + } + if err := tx.Commit(); err != nil { + return time.Time{}, err + } + return effective, nil +} + +// IsUsernameBlacklisted reports whether an in-game UUID is barred by a prior +// reclaim (spec §B3). It is a single EXISTS keyed by the UUID — the genuine +// Mojang player, who shares the contested name under a different UUID, never +// matches. +func (p *PGRepo) IsUsernameBlacklisted(ctx context.Context, mcUUID string) (bool, error) { + var ok bool + err := p.db.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM username_blacklist WHERE mc_uuid = $1)`, mcUUID).Scan(&ok) + return ok, err +} + // ---- local-password auth (spec §B) ---- // UserByUsername loads a staff login projection by username, or ErrNotFound. A diff --git a/internal/api/repo.go b/internal/api/repo.go index 2415cf5..776b656 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -196,6 +196,29 @@ type Repo interface { // proven email is returned. now is the API clock so expiry is testable. VerifyEmailOTP(ctx context.Context, userID, purpose, codeHash string, now time.Time) (email string, err error) + // ---- player game-login: username-collision reclaim (spec §B3) ---- + + // ReclaimUsername records a Mojang-priority username reclaim, atomically (spec + // §B3 正版优先): in one transaction it bars the non-genuine squatter UUID + // (username_blacklist) and stashes that account's data as a hold the velocity + // reclaim callback drives. The two writes are all-or-nothing — a half-applied + // reclaim (a barred UUID whose data was never held, or a hold for a UUID still + // able to connect) would either lose the player's data or let the squatter back + // in. id is the opaque hold row id; dataRef is the opaque archiver handle ("" + // stored as NULL when archival is deferred); expiresAt is the proposed held_at + + // the 30-day window on the API clock. Keyed by mc_uuid on both tables, so a + // repeat reclaim of an already-barred UUID is idempotent and never errors on a + // duplicate — the block stays on the squatting UUID, never the contested name. + // It returns the EFFECTIVE persisted expiry: on a fresh reclaim that is the + // passed expiresAt, but on an idempotent retry it is the FIRST reclaim's expiry, + // so the caller never reports a window the stored hold does not actually have. + ReclaimUsername(ctx context.Context, id, squatterUUID, username, dataRef string, expiresAt time.Time) (time.Time, error) + // IsUsernameBlacklisted reports whether an in-game UUID was barred by a prior + // reclaim (spec §B3). The velocity login gate calls it on the internal face to + // reject a squatter while letting the genuine Mojang UUID — same username, + // different UUID — through: the check is keyed by UUID, never by the name. + IsUsernameBlacklisted(ctx context.Context, mcUUID string) (bool, error) + // ---- local-password auth (spec §B) ---- // UserByUsername loads the login projection of a staff account by its unique diff --git a/internal/store/migrations/0006_player_reclaim.sql b/internal/store/migrations/0006_player_reclaim.sql new file mode 100644 index 0000000..dc4f96f --- /dev/null +++ b/internal/store/migrations/0006_player_reclaim.sql @@ -0,0 +1,52 @@ +-- Phase B3 player game-login: username-collision reclaim (Mojang-priority 正版优先). +-- +-- The configured third-party Yggdrasil and the official Mojang service can both +-- mint the SAME username under DIFFERENT UUIDs. Velocity detects the collision, +-- routes the player to a limbo login server, and asks whether they own both +-- accounts. When they do NOT — i.e. the connecting player is NOT the genuine +-- Mojang owner — the genuine Mojang account reclaims the name: the squatter is +-- rejected and barred, and their world/player data is stashed for 30 days so a +-- new account can inherit it. +-- +-- Velocity collision-routing, the limbo prompt, the authlib dual-backend, and the +-- data-inherit flow are CODE-ONLY (Java + a QR-bound device session a Postgres +-- row cannot express). THIS migration is the Go-verifiable data layer those +-- callbacks read and write: the reclaim event bars the squatter UUID and records +-- the hold; the login gate reads the blacklist to reject that UUID. + +-- username_blacklist bars a non-genuine UUID from connecting after a reclaim. It +-- is keyed by mc_uuid, NEVER by the contested username: the genuine Mojang player +-- shares that name under a DIFFERENT UUID and must always pass. The block is on +-- the squatting identity, not the name — this is the load-bearing safety property +-- (正版优先 must never harm the genuine Mojang player). +CREATE TABLE username_blacklist ( + mc_uuid uuid PRIMARY KEY, -- the barred (non-Mojang) UUID + username text NOT NULL, -- the contested name, for display/audit only + reason text NOT NULL DEFAULT 'mojang_priority_reclaim', + blacklisted_at timestamptz NOT NULL DEFAULT now() +); + +-- player_data_holds stashes the reclaimed (squatter) account's world/player data +-- so a new account can inherit it within the 30-day window (spec §B3 "数据将会被 +-- 暂存 30 天"). The verifiable layer is WRITE-ONLY here: the reclaim event inserts +-- a row (held_at..expires_at, the opaque data_ref handle); the inherit/claim flow +-- that stamps reclaimed_by_user_id/reclaimed_at lives in the CODE-ONLY device- +-- session layer — a new account proves entitlement to an OLD uuid's data via its +-- QR-bound session, a primitive the Postgres layer cannot express, so those two +-- columns stay NULL throughout the verifiable path. UNIQUE (mc_uuid) makes a +-- repeated reclaim of an already-barred UUID idempotent (it should never recur — +-- the login gate rejects that UUID before it can reach reclaim again). +CREATE TABLE player_data_holds ( + id text PRIMARY KEY, -- opaque row id (crypto-random hex) + mc_uuid uuid NOT NULL UNIQUE, -- the held (squatter) UUID + username text NOT NULL, -- the name at reclaim time + data_ref text, -- opaque archiver handle, server-side only (cf. world_backups.backup_ref); NULL until/unless archived + held_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, -- held_at + 30d, set by the API clock (one authoritative clock) + reclaimed_by_user_id text REFERENCES users(id), -- NULL until a new account inherits the data (CODE-ONLY inherit flow) + reclaimed_at timestamptz -- NULL until inherited +); + +-- The deferred inherit flow lists active holds by username (a reclaimed name's +-- new owner inheriting the old data), so index that lookup ahead of it. +CREATE INDEX player_data_holds_username_idx ON player_data_holds (username);