Files
Felis/internal/api/handlers_account.go
T

257 lines
11 KiB
Go

package api
import (
"crypto/rand"
"errors"
"net/http"
"strings"
"time"
)
// Account-linking endpoints (spec §10). The flow is forced by the
// account_link_codes schema, which carries mc_uuid but no user_id:
//
// 游戏内 /link → 生成一次性码 (internal: the in-game side has the verified UUID)
// → 玩家拿码 → 网页 verify 填码 (external: the web side has the logged-in user)
// → 写 account_links
//
// So code generation is internal-face and verification is external-face. A web
// endpoint cannot mint a code — it has no verified UUID to mint against — which
// is exactly what the schema (mc_uuid NOT NULL, no user_id) encodes. Verification
// is the load-bearing step: success there flips IsLinked true and unblocks every
// ownership operation (claim, §9.3), which otherwise dead-ends at a 412.
const (
// linkCodeTTL bounds how long a freshly minted code is accepted (spec §10:
// 短 TTL). Long enough to alt-tab from the game to the panel, short enough that
// a leaked code is useless minutes later.
linkCodeTTL = 10 * time.Minute
// linkCodeAlphabet is a 32-symbol set with the visually ambiguous characters
// I, O, 0 and 1 removed, so a player can read a code off chat and type it on the
// panel without confusion. 32 divides 256 evenly, so a uniform random byte
// reduced mod 32 is itself uniform — no modulo bias, no rejection sampling.
linkCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
// linkCodeLen is the symbol count: a 32^8 ≈ 1.1e12 keyspace, far beyond brute
// force inside the TTL.
linkCodeLen = 8
// authSource records which Yggdrasil established the in-game UUID when a code
// was minted (spec §10, dual-Yggdrasil): the official Mojang service, or a
// configured thirdparty. It is captured at mint (the only place that knows it)
// and copied onto the durable link at verify; the web side never sees the
// authentication. These mirror the link_auth_source enum (migration 0005).
authSourceMojang = "mojang"
authSourceThirdParty = "thirdparty"
)
// validAuthSource reports whether s is a recognised link_auth_source value. An
// empty string is NOT valid here — handleCreateLinkCode defaults it before this
// check, so a non-empty value reaching validation must be one we can store.
func validAuthSource(s string) bool {
return s == authSourceMojang || s == authSourceThirdParty
}
// deriveAuthSource infers the auth source from the UUID's version nibble when
// the minting backend omitted auth_source. Felis-nano rewrites every
// third-party profile to a name-based UUIDv3 under its namespace before it ever
// reaches the proxy, while Mojang profiles keep their random v4 — so on a
// nano-fronted deployment the version nibble alone identifies the source, and
// no Java plugin has to learn the field. Anything unparseable keeps the
// historical Mojang-priority default.
func deriveAuthSource(mcUUID string) string {
hex := strings.ReplaceAll(mcUUID, "-", "")
if len(hex) != 32 {
return authSourceMojang
}
switch hex[12] {
case '3':
return authSourceThirdParty
default:
return authSourceMojang
}
}
// newLinkCode returns a cryptographically random, unambiguous link code.
func newLinkCode() (string, error) {
buf := make([]byte, linkCodeLen)
if _, err := rand.Read(buf); err != nil {
return "", err
}
for i, b := range buf {
buf[i] = linkCodeAlphabet[int(b)%len(linkCodeAlphabet)]
}
return string(buf), nil
}
// createLinkCodeRequest is the in-game /link callback body (spec §10): the
// backend reports the verified UUID of the player who ran the command, plus how
// that UUID was authenticated (auth_source). auth_source is optional — an older
// backend that omits it falls back to the Mojang-priority default — but a value
// that IS sent must be one we can store.
type createLinkCodeRequest struct {
MCUUID string `json:"mc_uuid"`
AuthSource string `json:"auth_source"`
}
// handleCreateLinkCode mints a one-time link code for a verified in-game UUID
// (spec §10, internal face). It is the server side of the in-game /link command:
// the backend has already established the UUID via online-mode auth, so the code
// is born bound to a trustworthy identity. The player carries the returned code
// to the panel and submits it to the external verify endpoint.
func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) {
var req createLinkCodeRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
if req.MCUUID == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required"))
return
}
// Default an omitted source from the UUID's version nibble (v3 = felis-nano
// third-party rewrite, v4 = Mojang; see deriveAuthSource) but reject an
// unrecognised explicit one — a typo'd source must not silently land as a
// stored value the panel will later mislabel.
authSource := req.AuthSource
if authSource == "" {
authSource = deriveAuthSource(req.MCUUID)
}
if !validAuthSource(authSource) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"auth_source must be %q or %q", authSourceMojang, authSourceThirdParty))
return
}
code, err := newLinkCode()
if err != nil {
writeError(w, r, err)
return
}
expiresAt := a.now().Add(linkCodeTTL)
if err := a.Repo.CreateLinkCode(r.Context(), code, req.MCUUID, authSource, expiresAt); err != nil {
writeError(w, r, err)
return
}
// panel_url tells the in-game side where the player redeems the code, so
// every plugin renders the same address from one source of truth instead of
// each baking in its own hostname. Omitted when no hostname is configured.
resp := map[string]any{
"code": code,
"expires_at": expiresAt.UTC(),
}
if u := a.panelURL(); u != "" {
resp["panel_url"] = u
}
writeJSON(w, http.StatusCreated, resp)
}
// handleLinkStatus reports whether an in-game UUID has finished linking yet — the
// completion poll of the QR scan-to-login flow (spec §B3 player game-login; memory
// player-login-yggdrasil). It is internal-face and read-only, the device-code
// "poll for completion" step that turns the typed-code link into a scan:
//
// new player joins → velocity mints a code (handleCreateLinkCode) and renders it
// as a QR → player scans it on a phone already signed in to console.<root_domain>
// → that web session's verify (handleLinkVerify) writes the durable account_links
// row bound to THAT user → velocity polls HERE for the same UUID it minted against
// → on {linked:true} it admits the player with no reconnect — the whole point of
// scanning over typing. The response is deliberately just the boolean: the plugin
// keys everything on the UUID it already holds, so no identity detail crosses back.
//
// The poll is keyed by the verified mc_uuid velocity already holds, not by the
// scanned code, so it is a pure idempotent read of the durable link (UserByMCUUID):
// there is no transient device-session row, nothing is consumed, and a velocity
// restart re-polls safely. The secret is the short-TTL code the player scans, never
// this public UUID, so the read carries no guessing surface and needs no attempt
// cap — the internal face already gates it to service callers.
//
// CODE-ONLY (Java/Velocity, not represented here): rendering the code as a QR, the
// limbo collision routing, and admitting the polled player into the main server.
// KNOWN-LIMITATION: the reclaim disambiguation a scan can surface — "start fresh"
// vs "inherit the 30-day-held data" — is the data-inherit choice that
// handlers_player_reclaim.go keeps CODE-ONLY (reclaimed_by_user_id stays NULL on
// the verifiable path); this endpoint reports link completion only, not that choice.
func (a *API) handleLinkStatus(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
}
_, err := a.Repo.UserByMCUUID(r.Context(), mcUUID)
switch {
case errors.Is(err, ErrNotFound):
// Not linked yet. For the poller this is simply "keep waiting": velocity
// polls until its own code TTL lapses. A never-seen UUID is indistinguishable
// from a not-yet-scanned one, and deliberately so — both mean "do not admit".
writeJSON(w, http.StatusOK, map[string]any{"linked": false})
return
case err != nil:
writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"linked": true})
}
// linkVerifyRequest is the panel verify-code body (spec §10): the logged-in user
// submits the code they were shown in-game.
type linkVerifyRequest struct {
Code string `json:"code"`
}
// handleLinkVerify consumes a link code for the authenticated user and writes the
// account_links binding (spec §10, external app face). This is the load-bearing
// step of §10. The code is trimmed and uppercased so a player who typed it with
// stray spaces or in lowercase still matches the minted value. Outcomes:
// invalid/expired code → 400 invalid_code; the UUID already linked to a different
// user → 409 already_linked; otherwise the binding is written and IsLinked
// becomes true for this user.
func (a *API) handleLinkVerify(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
var req linkVerifyRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
code := strings.ToUpper(strings.TrimSpace(req.Code))
if code == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "code is required"))
return
}
mcUUID, authSource, err := a.Repo.VerifyLinkCode(r.Context(), p.UserID, code, a.now())
switch {
case errors.Is(err, ErrLinkCodeInvalid):
writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "link code is invalid or expired"))
return
case errors.Is(err, ErrConflict):
writeError(w, r, newError(http.StatusConflict, "already_linked",
"that Minecraft account is already linked to another user"))
return
case err != nil:
writeError(w, r, err)
return
}
a.audit(r, "account.link", "")
writeJSON(w, http.StatusOK, map[string]any{
"linked": true, "mc_uuid": mcUUID, "auth_source": authSource,
})
}
// handleLinkStart reports the caller's link status and how to link (spec §10,
// external app face). It is the endpoint handleClaim's 412 points at. It
// deliberately does NOT mint a code: a code is born in-game (account_link_codes
// has no user_id column), so the web can only report status and relay the in-game
// instruction — minting here would contradict the schema. This keeps the pointer
// in handleClaim honest without pretending the web can originate a binding.
func (a *API) handleLinkStart(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
linked, err := a.Repo.IsLinked(r.Context(), p.UserID)
if err != nil {
writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"linked": linked,
"instructions": "Run /link in-game to receive a one-time code, then submit it to " +
"POST /api/v1/account/link/verify.",
})
}