QR scan-to-login is a device-code grant where the QR encodes the existing
short-lived account-link code (spec §B3 player game-login). velocity mints a
code in-game, renders it as a QR, the player scans it on a phone already signed
in to the panel, and that web session's verify writes the durable account_links
row bound to that user. The only new verifiable surface that flow needs is the
completion poll velocity calls to learn the link landed and admit the player.
Add GET /api/v1/internal/account/link/status/{mc_uuid}: a read-only, internal
handleLinkStatus keyed by the verified mc_uuid velocity already holds. It reuses
the existing UserByMCUUID, so it adds no migration and no mutation to the
load-bearing VerifyLinkCode; ErrNotFound maps to {linked:false} (pending /
not-yet-scanned), a hit to {linked:true, user_id}. Keying on the public UUID and
not the scanned code means the read carries no guessing surface and needs no
attempt cap — the internal face already gates it to service callers, and the poll
consumes nothing so a velocity restart re-polls safely.
QR render, limbo collision routing, in-game admit, and the reclaim
inherit-disambiguation stay CODE-ONLY (Java/Velocity) and are labeled as such;
this endpoint reports link completion only.
Document the route in openapi.yaml (x-felis-face internal, x-felis-tier service)
so the parity gate holds, and cover it with a hermetic vertical that proves the
poll reflects the durable link only after the external verify and binds the
verifier's id, plus unknown-uuid, idempotency, and internal-only face separation.
228 lines
9.7 KiB
Go
228 lines
9.7 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
|
|
}
|
|
|
|
// 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 to Mojang (spec §10 priority) but reject an
|
|
// unrecognised one — a typo'd source must not silently land as a stored value
|
|
// the panel will later mislabel.
|
|
authSource := req.AuthSource
|
|
if authSource == "" {
|
|
authSource = authSourceMojang
|
|
}
|
|
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
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
"code": code,
|
|
"expires_at": expiresAt.UTC(),
|
|
})
|
|
}
|
|
|
|
// 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, binding the in-game session to user_id
|
|
// with no reconnect — the whole point of scanning over typing.
|
|
//
|
|
// 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
|
|
}
|
|
userID, 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, "user_id": userID})
|
|
}
|
|
|
|
// 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, p.Email, "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.",
|
|
})
|
|
}
|