Files
Felis/internal/api/handlers_player_reclaim.go
T
flyemoji a29571de39 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.
2026-06-27 13:41:19 +09:00

126 lines
5.2 KiB
Go

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})
}