155 lines
6.8 KiB
Go
155 lines
6.8 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.
|
|
//
|
|
// This UUID-keyed, proxy-detected split matches the real multi-Yggdrasil reference
|
|
// (CaaMoe/MultiLogin binds identity in the plugin as serviceId+online-UUID, keyed by
|
|
// UUID, never by name). §B3's Mojang-priority reclaim goes beyond the common "protect
|
|
// the first-bound name" behavior: it evicts a squatter once the genuine Mojang owner
|
|
// appears and stashes the squatter's data for the code-only inherit path above.
|
|
|
|
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
|
|
}
|
|
// Admin-on-Yggdrasil exception (spec §B3). Before barring the holder, check
|
|
// whether the displaced UUID is a Linked Operator/SysAdmin authenticating through
|
|
// the third-party Yggdrasil. Such a holder is staff on the Login Server, not a
|
|
// Mojang squatter, so Mojang priority must NOT displace them: refuse the reclaim
|
|
// outright — no bar, no stash — so the protected admin never enters the blacklist
|
|
// and the login gate naturally passes them. The exception is scoped strictly to
|
|
// admins; an ordinary thirdparty player is still reclaimed (Mojang priority holds).
|
|
protected, err := a.Repo.IsProtectedAdminLink(r.Context(), req.SquatterUUID)
|
|
if err != nil {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
if protected {
|
|
// Distinct audit action so a refusal is never mistaken for a bar — the
|
|
// accountability record shows the reclaim was declined, and why.
|
|
payload, _ := json.Marshal(map[string]string{
|
|
"username": req.Username, "squatter_uuid": req.SquatterUUID, "reason": "protected_admin"})
|
|
a.auditEntry(r, AuditEntry{
|
|
Actor: "velocity", Source: internalSource(r), Action: "player.reclaim.refused", Payload: payload,
|
|
})
|
|
writeError(w, r, newError(http.StatusConflict, "protected_admin",
|
|
"that username belongs to a linked administrator on the login server and cannot be reclaimed"))
|
|
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.auditEntry(r, AuditEntry{
|
|
Actor: "velocity", Source: internalSource(r), Action: "player.reclaim", 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, err := parseMCUUID(r.PathValue("mc_uuid"))
|
|
if err != nil {
|
|
writeError(w, r, err)
|
|
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})
|
|
}
|