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.
126 lines
5.2 KiB
Go
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})
|
|
}
|