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:
8 files changed
+611
No files matched your search
@@ -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:
|
||||||
|
|||||||
@@ -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},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
Reference in new issue
Block a user