feat(api): reclaim squatted usernames for Mojang-priority players (spec §B3)

When the configured third-party Yggdrasil and the official Mojang service
issue the same username under different UUIDs, the non-genuine squatter is
displaced in favour of the real Mojang owner (正版优先). This adds the
Go-verifiable data layer of that flow on the internal (velocity) face.

- migration 0006: username_blacklist (barred squatter UUIDs) and
  player_data_holds (the displaced account's 30-day data stash), both keyed
  by mc_uuid so the genuine Mojang player — identical username, different
  UUID — is never caught by the bar.
- POST /api/v1/internal/player/reclaim bars the squatter UUID and stashes
  its data in one transaction (all-or-nothing). It is idempotent on a
  retried callback and returns the hold's effective expiry — the first
  reclaim's window, never a fresh now()+30d — so the rejected player is told
  the truth about how long their data is kept.
- GET /api/v1/internal/player/blacklist/{mc_uuid} is the login-gate check
  velocity calls to reject a barred squatter before admitting them.

Scope: velocity collision-routing, the limbo prompt, the authlib
dual-backend and the data-inherit flow are code-only (Java plus a QR-bound
device session a row cannot express) and are not part of this slice. Unit
tests cover the handlers and the in-memory repo contract; the Postgres SQL
path is exercised by integration only.
This commit is contained in:
flyemoji committed 2026-06-27 13:41:19 +09:00
1 parent 1f8b9bb5d0
commit a29571de39
8 files changed
+611

No files matched your search

+74
View File
@@ -668,6 +668,80 @@ paths:
'401': '401':
$ref: '#/components/responses/Unauthorized' $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 --- # ----------------------------------------------------- external: servers ---
/api/v1/servers/{name}/wake: /api/v1/servers/{name}/wake:
post: post:
+7
View File
@@ -172,6 +172,13 @@ func (a *API) internalAPIRoutes() []apiRoute {
// verified UUID. Internal-only — the code is born from an online-mode UUID the // 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). // web never holds (account_link_codes has no user_id column).
{Method: "POST", Pattern: "/api/v1/internal/account/link/code", h: a.handleCreateLinkCode}, {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},
} }
} }
+37
View File
@@ -54,6 +54,24 @@ type fakeRepo struct {
// player email OTPs (spec §B2). Keyed by row id; the verify path scans for the // 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. // newest live (user, purpose) just as the PG query does.
otps map[string]*fakeEmailOTP 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 // 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{}, sessions: map[string]*fakeSession{},
settings: map[string][]byte{}, settings: map[string][]byte{},
otps: map[string]*fakeEmailOTP{}, 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 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) { func (f *fakeRepo) ClaimServer(_ context.Context, n, u string) (bool, error) {
ok, present := f.claimOK[n] ok, present := f.claimOK[n]
if !present { if !present {
+125
View File
@@ -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})
}
@@ -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: "[email protected]", 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)
}
}
+54
View File
@@ -461,6 +461,60 @@ func (p *PGRepo) VerifyEmailOTP(ctx context.Context, userID, purpose, codeHash s
return email, nil 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) ---- // ---- local-password auth (spec §B) ----
// UserByUsername loads a staff login projection by username, or ErrNotFound. A // UserByUsername loads a staff login projection by username, or ErrNotFound. A
+23
View File
@@ -196,6 +196,29 @@ type Repo interface {
// proven email is returned. now is the API clock so expiry is testable. // 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) 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) ---- // ---- local-password auth (spec §B) ----
// UserByUsername loads the login projection of a staff account by its unique // UserByUsername loads the login projection of a staff account by its unique
@@ -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);