feat(api): add public Bind-Code onboarding for the player console

Adds POST /api/v1/auth/bind, the one public pre-account entrypoint of the
player console (console.<root_domain>). An account-less player redeems the
one-time Bind Code minted in the in-game Login Lobby; in a single step the
platform creates a role=user player, links it to the verified in-game UUID,
and mints a host-only felis_session. Login is thus not forced at the edge
while operations stay app-authenticated.

The operator console (op.console.<root_domain>) is unaffected and stays
behind Zero Trust: a code whose UUID resolves to a staff (role=admin)
account is refused with 403 (ErrPlayerBindForbidden) without consuming the
code, so the public door provably never yields an admin principal — the
session it mints carries ViaAdminAccess=false and is host-only to console,
never sent to op.console.

Repo layer: new RedeemPlayerBindCode on the Repo interface, implemented on
PGRepo (single tx: resolve code, create-or-fetch the player, consume) and
the test fake. The returning-player branch is idempotent and is a deliberate
standing "log in via the game" door, not just first-time onboarding.

Honest labeling:
- ORACLE-VERIFIED (Go): account/session logic — role=user, refuse-staff,
  idempotent create-or-fetch, single-use code, and the op.console redline
  (player session rejected on admin routes). Covered by handlers_onboard_test
  and the OpenAPI parity gate.
- INTEGRATION-dependent: the endpoint's security rests on the Bind Code having
  been minted against an online-mode-Yggdrasil-authenticated UUID, a
  precondition that lives in velocity/Java and is not verifiable from this
  repo (CODE-ONLY). The Go layer proves the logic, not that identity guarantee.
- No app-level attempt cap: rate-limiting is deferred to the edge as for the
  public /auth/login; the ~1e12 keyspace, single use and short TTL make a
  blind app-level cap non-critical.
This commit is contained in:
flyemoji committed 2026-07-01 18:00:13 +09:00
1 parent c01f133cd8
commit fe2ece08cc
8 files changed
+585

No files matched your search

+56
View File
@@ -1338,6 +1338,62 @@ paths:
'403': '403':
$ref: '#/components/responses/Forbidden' $ref: '#/components/responses/Forbidden'
/api/v1/auth/bind:
post:
tags: [auth]
operationId: bindRedeem
summary: Redeem a Bind Code into a player account + session (public console bootstrap, spec §10/§B).
description: >-
The one public, pre-account entrypoint of the player console
(console.<root_domain>): an account-less player redeems the one-time Bind
Code they generated in the in-game Login Lobby, and the platform creates their
player account (role=user), binds it to the verified in-game UUID, and mints a
host-only session cookie. Safe to expose unauthenticated because the code is
minted internal-face only, against an online-mode-verified UUID, with a short
TTL and single use — possession already proves control of a Minecraft identity.
An already-linked player UUID logs that player back in (idempotent); a UUID
that belongs to staff is refused (403) — operators authenticate at op.console
behind Zero Trust, so this never mints a session for an admin identity. Requires
local sessions to be enabled (same toggle as login).
x-felis-face: [external]
x-felis-tier: public
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [code]
properties:
code: { type: string }
responses:
'200':
description: Player account bootstrapped; the session cookie is set on the response.
content:
application/json:
schema:
type: object
required: [user_id, linked, mc_uuid, auth_source]
properties:
user_id: { type: string }
linked: { type: boolean, const: true }
mc_uuid: { type: string }
auth_source:
type: string
enum: [mojang, thirdparty]
description: The source captured at mint, copied onto the durable link.
'400':
description: Invalid or expired bind code.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'403':
description: Local sessions are disabled, or the code's UUID belongs to a staff account.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/me: /api/v1/me:
get: get:
tags: [servers] tags: [servers]
+8
View File
@@ -227,6 +227,14 @@ func (a *API) externalAPIRoutes() []apiRoute {
{Method: "POST", Pattern: "/api/v1/auth/login", Public: true, h: a.handleLogin}, {Method: "POST", Pattern: "/api/v1/auth/login", Public: true, h: a.handleLogin},
{Method: "POST", Pattern: "/api/v1/auth/logout", Public: true, h: a.handleLogout}, {Method: "POST", Pattern: "/api/v1/auth/logout", Public: true, h: a.handleLogout},
{Method: "POST", Pattern: "/api/v1/auth/change-password", AllowDuringPasswordChange: true, h: a.handleChangePassword}, {Method: "POST", Pattern: "/api/v1/auth/change-password", AllowDuringPasswordChange: true, h: a.handleChangePassword},
// Player-console bootstrap (console-tier access model): the account-less
// player's door into console.<root_domain>. Public — like login there is no prior
// principal — and session-minting, but the artifact it consumes is a one-time
// Bind Code minted internal-face against an online-mode-verified UUID, so
// possession already proves a Minecraft identity. A code whose UUID belongs to
// staff is refused (403) so this never yields an admin session; op.console stays
// behind Zero Trust (handlers_onboard.go).
{Method: "POST", Pattern: "/api/v1/auth/bind", Public: true, h: a.handleBindRedeem},
// App-auth tier: operations on your own servers (spec §14). // App-auth tier: operations on your own servers (spec §14).
{Method: "POST", Pattern: "/api/v1/servers/{name}/wake", h: a.handleWake}, {Method: "POST", Pattern: "/api/v1/servers/{name}/wake", h: a.handleWake},
+35
View File
@@ -199,6 +199,41 @@ func (f *fakeRepo) VerifyLinkCode(_ context.Context, userID, code string, now ti
return rec.mcUUID, rec.authSource, nil return rec.mcUUID, rec.authSource, nil
} }
// RedeemPlayerBindCode mirrors PGRepo.RedeemPlayerBindCode: it create-or-fetches a
// player keyed on the code's verified mc_uuid. The staff map stands in for the single
// users table, so a newly created role='user' player is stored there (uuid-derived
// username) and resolves through SessionUser/UserByID just like the PG JOIN. An
// already-linked admin UUID is refused without consuming the code; an already-linked
// player is fetched idempotently.
//
// Contract gap vs PG (benign): on an orphan link (mc_uuid linked but its users row
// gone) the fake resolves no role and falls through to the idempotent return, minting
// a session for a ghost id, whereas PG's account_links⋈users JOIN would find no row,
// take the insert branch and 500 on the UNIQUE(mc_uuid) clash. The account_links.user_id
// FK makes an orphan link unreachable in production, so this divergence is untestable
// rather than a real behavioral difference.
func (f *fakeRepo) RedeemPlayerBindCode(_ context.Context, newUserID, code string, now time.Time) (string, string, string, error) {
rec, ok := f.linkCodes[code]
if !ok || !rec.expiresAt.After(now) {
return "", "", "", ErrLinkCodeInvalid
}
if existing, ok := f.links[rec.mcUUID]; ok {
for _, u := range f.staff { // resolve the linked identity to check its role
if u.ID == existing && u.Role != "user" {
return "", "", "", ErrPlayerBindForbidden // staff must use op.console; do not consume
}
}
delete(f.linkCodes, code)
return existing, rec.mcUUID, rec.authSource, nil
}
f.staff[rec.mcUUID] = &StaffUser{ID: newUserID, Username: rec.mcUUID, Role: "user"}
f.links[rec.mcUUID] = newUserID
f.linkAuthSource[rec.mcUUID] = rec.authSource
f.linked[newUserID] = true
delete(f.linkCodes, code)
return newUserID, rec.mcUUID, rec.authSource, nil
}
// CreateEmailOTP / VerifyEmailOTP mirror PGRepo's contract so the hermetic tests // CreateEmailOTP / VerifyEmailOTP mirror PGRepo's contract so the hermetic tests
// exercise the same semantics the integration impl honors: a fresh code supersedes // exercise the same semantics the integration impl honors: a fresh code supersedes
// the prior live one for (user, purpose), expiry and the attempt cap are checked // the prior live one for (user, purpose), expiry and the attempt cap are checked
+7
View File
@@ -42,6 +42,13 @@ var (
// finish endpoint exists; the ceremony state is gone (never begun, already // finish endpoint exists; the ceremony state is gone (never begun, already
// consumed, or expired) — so handlers map it to 400, not 404. // consumed, or expired) — so handlers map it to 400, not 404.
ErrPasskeyChallengeInvalid = errors.New("passkey challenge invalid or expired") ErrPasskeyChallengeInvalid = errors.New("passkey challenge invalid or expired")
// ErrPlayerBindForbidden means a public Bind-Code redemption resolved to a STAFF
// account (role=admin), which the player-console bootstrap refuses (console-tier
// access model). Operators authenticate at op.console behind Zero Trust, never via
// the account-less console.<root_domain> door, so the public bootstrap provably
// never mints a session for an admin identity. It is distinct from ErrConflict so
// the handler answers 403 (wrong door) rather than 409 (already linked).
ErrPlayerBindForbidden = errors.New("bind code belongs to a staff account")
) )
// apiError is a handler-level error carrying an HTTP status and a stable, // apiError is a handler-level error carrying an HTTP status and a stable,
+131
View File
@@ -0,0 +1,131 @@
package api
import (
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"strings"
)
// Player-console onboarding bootstrap (console-tier access model; spec §10, §B). This
// is the ONE public, pre-account entrypoint of the player console (console.<root_domain>):
// an account-less player redeems the one-time Bind Code they generated in the in-game
// Login Lobby, and in a single step the platform creates their player account
// (role=user), binds it to their verified in-game UUID, and mints a player session.
// From there the ordinary app-tier onboarding endpoints (email-OTP, passkey) work off
// the resulting principal like any other — the session model is role-agnostic, so a
// player session is just an opaque felis_session over a role=user row.
//
// Why this may be Public while /account/link/verify may not: a Bind Code is minted
// INTERNAL-face only (handleCreateLinkCode), with a short TTL, single use, and a 32^8
// keyspace, so the web can never originate one (account_link_codes has no user_id) —
// no code, no account. This endpoint MATERIALLY elevates the code's authority: where
// /account/link/verify bound a UUID to an already-authenticated principal, this makes
// the code alone create an account and mint a session. That is only safe if the code
// was minted against a UUID an online-mode Yggdrasil actually authenticated — a
// precondition that lives in velocity/Java (CODE-ONLY, not verifiable from this repo).
// So the safety here is INTEGRATION-dependent on that upstream online-mode guarantee;
// the Go layer proves only the account/session logic (role, refuse-staff, idempotent),
// never the identity guarantee itself.
//
// No app-level attempt cap is enforced here (unlike the email-OTP flow, whose 1e6
// keyspace demanded one): the code's ~1e12 keyspace, single use and short TTL make
// blind brute force non-viable, and rate-limiting is deferred to the edge exactly as
// for the public /auth/login. The idempotent returning-player branch (a UUID already
// linked to a role=user player is fetched, not re-created) is a DELIBERATE standing
// "log in via the game" door, not merely first-time onboarding: control of the
// in-game identity is the root of trust, so re-minting a code always re-grants a
// session even after email/passkey are bound. "登录并非强制,但没登录什么都干不了".
//
// op.console stays behind Zero Trust. A code whose UUID belongs to STAFF (role=admin)
// is refused here (ErrPlayerBindForbidden → 403), so the public bootstrap provably
// never mints a session for an admin identity — the sole tier it yields is a role=user
// player session, host-only to console.<root_domain> (never sent to op.console) and
// carrying ViaAdminAccess=false. "op.console 必须得 Auth".
// newUserID returns an opaque random user id (128 bits, hex), matching the shape of
// the ids break-glass mints for staff rows.
func newUserID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return hex.EncodeToString(b[:]), nil
}
// bindRedeemRequest is the console bootstrap body: the Bind Code the player was shown
// in the Login Lobby.
type bindRedeemRequest struct {
Code string `json:"code"`
}
// handleBindRedeem redeems a Bind Code into a player account + session (Public). It is
// the account-less player's only door into console.<root_domain>: no prior principal,
// no Zero Trust in front (unlike op.console). Like handleLogin it is a cookie-minting
// public route, so it requires local sessions to be enabled and a JSON content type
// (the cross-site-forgery guard) and mints the same host-only felis_session cookie.
// The code is trimmed and uppercased so a player who typed it with stray spaces or in
// lowercase still matches, mirroring handleLinkVerify.
func (a *API) handleBindRedeem(w http.ResponseWriter, r *http.Request) {
// The minted session is a felis_session cookie, honored only when local sessions
// are enabled (SessionAuth). Minting one while they are off would hand back a dead
// cookie, so refuse loudly and consistently with handleLogin. This couples the
// player bootstrap to the same toggle that gates op.console local login; a future
// deployment wanting player cookies without local admin login would decouple them
// in SessionAuth — out of scope here (KNOWN coupling).
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"session login is disabled"))
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var body bindRedeemRequest
if err := decodeJSON(w, r, &body); err != nil {
writeError(w, r, err)
return
}
code := strings.ToUpper(strings.TrimSpace(body.Code))
if code == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "code is required"))
return
}
uid, err := newUserID()
if err != nil {
writeError(w, r, err)
return
}
userID, mcUUID, authSource, err := a.Repo.RedeemPlayerBindCode(r.Context(), uid, code, a.now())
switch {
case errors.Is(err, ErrLinkCodeInvalid):
writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "bind code is invalid or expired"))
return
case errors.Is(err, ErrPlayerBindForbidden):
writeError(w, r, newError(http.StatusForbidden, "staff_account",
"that Minecraft account belongs to staff; sign in at the operator console"))
return
case err != nil:
writeError(w, r, err)
return
}
token, err := newSessionToken()
if err != nil {
writeError(w, r, err)
return
}
expires := a.now().Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), userID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, token, expires)
a.audit(r, userID, "account.bind_redeem", "")
writeJSON(w, http.StatusOK, map[string]any{
"user_id": userID, "linked": true, "mc_uuid": mcUUID, "auth_source": authSource,
})
}
+261
View File
@@ -0,0 +1,261 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// Player-console onboarding bootstrap tests (console-tier access model). The load-
// bearing cases: a Bind Code alone bootstraps a role=user player + session on the
// public console door (no prior principal, no Zero Trust); a code whose UUID belongs
// to staff is refused so the public door never mints an admin session; and — the
// invariant that keeps "op.console 必须得 Auth" true after making console.<root> public —
// a redeemed player session is rejected on every Admin route and carries
// ViaAdminAccess=false.
const bindTestUUID = "11111111-1111-1111-1111-111111111111"
// seedBindAPI returns an API with local sessions enabled and its external face wired
// to the real SessionAuth, so a cookie minted by /auth/bind is actually honored on
// the follow-up authenticated requests (the whole point of the bootstrap).
func seedBindAPI(t *testing.T) (*API, *fakeRepo) {
t.Helper()
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
api := newTestAPI(repo, newFakeCluster())
api.External = SessionAuth{Repo: repo, RootDomain: testRoot, Now: api.now}
return api, repo
}
// mintBindCode stores a live Bind Code for uuid/authSource, mirroring the internal
// mint (handleCreateLinkCode → CreateLinkCode).
func mintBindCode(t *testing.T, api *API, repo *fakeRepo, code, uuid, authSource string) {
t.Helper()
if err := repo.CreateLinkCode(t.Context(), code, uuid, authSource, api.now().Add(linkCodeTTL)); err != nil {
t.Fatalf("mint bind code: %v", err)
}
}
// sessionCookieValue returns the raw felis_session cookie value set on w, or fails.
func sessionCookieValue(t *testing.T, w *httptest.ResponseRecorder) string {
t.Helper()
for _, c := range w.Result().Cookies() {
if c.Name == sessionCookieName && c.Value != "" {
return c.Value
}
}
t.Fatalf("no %s cookie set (%s)", sessionCookieName, w.Body.String())
return ""
}
// TestBindRedeemBootstrapsPlayer is the happy path: an account-less player redeems a
// Bind Code and, in one step, gets a role=user account, an account_links binding, and
// a live session — which then resolves through the external face on /me.
func TestBindRedeemBootstrapsPlayer(t *testing.T) {
api, repo := seedBindAPI(t)
mintBindCode(t, api, repo, "ABCD2345", bindTestUUID, authSourceMojang)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"ABCD2345"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
var got map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("body not JSON: %v (%s)", err, w.Body.String())
}
userID, _ := got["user_id"].(string)
if userID == "" || got["linked"] != true || got["mc_uuid"] != bindTestUUID || got["auth_source"] != authSourceMojang {
t.Fatalf("body = %v, want a user_id, linked=true, mc_uuid+auth_source echoed", got)
}
// A fresh role=user player row was created and bound; the code was consumed.
if u := repo.staff[bindTestUUID]; u == nil || u.Role != "user" || u.PasswordHash != "" || u.ID != userID {
t.Fatalf("created row = %+v, want role=user, NULL hash, id=%s", u, userID)
}
if repo.links[bindTestUUID] != userID {
t.Fatalf("account_links[%s] = %q, want %q", bindTestUUID, repo.links[bindTestUUID], userID)
}
if _, live := repo.linkCodes["ABCD2345"]; live {
t.Fatal("the bind code must be consumed on success")
}
// The minted session must resolve through the external face: /me returns the
// player identity, role=user, is_admin=false.
token := sessionCookieValue(t, w)
me := do(api.ExternalHandler(), "GET", "/api/v1/me", "",
map[string]string{"Cookie": sessionCookieName + "=" + token})
if me.Code != http.StatusOK {
t.Fatalf("/me code = %d, want 200 (%s)", me.Code, me.Body.String())
}
var meBody map[string]any
if err := json.Unmarshal(me.Body.Bytes(), &meBody); err != nil {
t.Fatalf("/me body not JSON: %v", err)
}
if meBody["role"] != "user" || meBody["is_admin"] != false || meBody["user_id"] != userID {
t.Fatalf("/me = %v, want role=user is_admin=false user_id=%s", meBody, userID)
}
}
// TestBindRedeemIdempotentReturningPlayer proves a second redemption of the SAME UUID
// logs the same player back in (idempotent "log in via the game") rather than forking
// a duplicate account.
func TestBindRedeemIdempotentReturningPlayer(t *testing.T) {
api, repo := seedBindAPI(t)
mintBindCode(t, api, repo, "FIRST234", bindTestUUID, authSourceMojang)
w1 := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"FIRST234"}`, jsonHeader)
if w1.Code != http.StatusOK {
t.Fatalf("first redeem code = %d, want 200 (%s)", w1.Code, w1.Body.String())
}
var b1 map[string]any
_ = json.Unmarshal(w1.Body.Bytes(), &b1)
mintBindCode(t, api, repo, "SECOND34", bindTestUUID, authSourceThirdParty)
w2 := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"SECOND34"}`, jsonHeader)
if w2.Code != http.StatusOK {
t.Fatalf("second redeem code = %d, want 200 (%s)", w2.Code, w2.Body.String())
}
var b2 map[string]any
_ = json.Unmarshal(w2.Body.Bytes(), &b2)
if b1["user_id"] != b2["user_id"] {
t.Fatalf("returning player got a new account: %v then %v", b1["user_id"], b2["user_id"])
}
}
// TestBindRedeemRefusesStaffAccount is the op.console red line at the data layer: a
// Bind Code whose UUID belongs to a STAFF account (role=admin) is refused 403, and the
// code is NOT consumed and no session is minted. The public console door therefore can
// never yield a session for an admin identity.
func TestBindRedeemRefusesStaffAccount(t *testing.T) {
api, repo := seedBindAPI(t)
// An admin already linked to this UUID (e.g. an Operator on the thirdparty Yggdrasil).
repo.staff["op"] = &StaffUser{ID: "admin1", Username: "op", Role: "admin"}
repo.links[bindTestUUID] = "admin1"
mintBindCode(t, api, repo, "STAFF234", bindTestUUID, authSourceThirdParty)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"STAFF234"}`, jsonHeader)
if w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403 (%s)", w.Code, w.Body.String())
}
if code := decodeErr(t, w); code != "staff_account" {
t.Fatalf("error code = %q, want staff_account", code)
}
if len(w.Result().Cookies()) != 0 {
t.Fatal("no session cookie may be set when a staff UUID is refused")
}
if _, live := repo.linkCodes["STAFF234"]; !live {
t.Fatal("a refused staff redemption must NOT consume the code")
}
}
// TestPlayerSessionRejectedOnAdminRoutes is the invariant that lets console.<root> be
// public without weakening op.console: a session minted by the public bootstrap is a
// role=user session, so every Admin route rejects it (403) and it carries
// ViaAdminAccess=false even on the operator host.
func TestPlayerSessionRejectedOnAdminRoutes(t *testing.T) {
api, repo := seedBindAPI(t)
mintBindCode(t, api, repo, "PLAYER34", bindTestUUID, authSourceMojang)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"PLAYER34"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("redeem code = %d, want 200 (%s)", w.Code, w.Body.String())
}
token := sessionCookieValue(t, w)
// An Admin route (POST /api/v1/servers), requested on the OPERATOR host with the
// player cookie, is rejected before the handler: the player is role=user, so
// IsAdmin() is false regardless of host.
adm := do(api.ExternalHandler(), "POST", "https://op.console.mc.example.net/api/v1/servers", `{}`,
map[string]string{"Cookie": sessionCookieName + "=" + token})
if adm.Code != http.StatusForbidden {
t.Fatalf("player on Admin route: code = %d, want 403 (%s)", adm.Code, adm.Body.String())
}
// Directly: SessionAuth resolves the player on the operator host WITHOUT admin
// access. A role=user can never satisfy ViaAdminAccess (it is admin-AND-host), so
// the public bootstrap provably yields no admin principal.
auth := SessionAuth{Repo: repo, RootDomain: testRoot, Now: api.now}
r := httptest.NewRequest("GET", "https://op.console.mc.example.net/api/v1/me", nil)
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
p, err := auth.Authenticate(r)
if err != nil {
t.Fatalf("Authenticate: %v", err)
}
if p.Role != "user" || p.ViaAdminAccess || p.IsAdmin() {
t.Fatalf("player principal = %+v, want role=user ViaAdminAccess=false IsAdmin=false", p)
}
}
func TestBindRedeemInvalidCode(t *testing.T) {
api, _ := seedBindAPI(t)
for _, tc := range []struct{ name, body string }{
{"unknown code", `{"code":"NOPE2345"}`},
{"empty code", `{"code":""}`},
} {
t.Run(tc.name, func(t *testing.T) {
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", tc.body, jsonHeader)
if w.Code != http.StatusBadRequest {
t.Fatalf("code = %d, want 400 (%s)", w.Code, w.Body.String())
}
if len(w.Result().Cookies()) != 0 {
t.Fatal("no session cookie may be set on an invalid redeem")
}
})
}
}
// TestBindRedeemExpiredCode proves expiry is enforced against the API clock: a code
// past its TTL is invalid_code, never a silent bootstrap.
func TestBindRedeemExpiredCode(t *testing.T) {
api, repo := seedBindAPI(t)
repo.linkCodes["OLD23456"] = fakeLinkCode{
mcUUID: bindTestUUID, authSource: authSourceMojang,
expiresAt: api.now().Add(-time.Minute), // already expired
}
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"OLD23456"}`, jsonHeader)
if w.Code != http.StatusBadRequest {
t.Fatalf("code = %d, want 400 (%s)", w.Code, w.Body.String())
}
if code := decodeErr(t, w); code != "invalid_code" {
t.Fatalf("error code = %q, want invalid_code", code)
}
}
// TestBindRedeemLocalAuthDisabled proves the bootstrap refuses to mint a session that
// SessionAuth would not honor: with local sessions off it returns 403, never a dead
// cookie, mirroring handleLogin.
func TestBindRedeemLocalAuthDisabled(t *testing.T) {
repo := newFakeRepo() // local_auth_enabled never set → fail closed
api := newTestAPI(repo, newFakeCluster())
mintBindCode(t, api, repo, "DEAD2345", bindTestUUID, authSourceMojang)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"DEAD2345"}`, jsonHeader)
if w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403 (%s)", w.Code, w.Body.String())
}
if code := decodeErr(t, w); code != "local_auth_disabled" {
t.Fatalf("error code = %q, want local_auth_disabled", code)
}
if len(w.Result().Cookies()) != 0 {
t.Fatal("no session cookie may be minted while local auth is disabled")
}
}
// TestBindRedeemContentTypeGuard pins the cross-site-forgery guard: a body whose
// Content-Type an HTML form could emit is rejected 415 before any redemption, so a
// forged off-origin POST cannot bootstrap an account.
func TestBindRedeemContentTypeGuard(t *testing.T) {
api, repo := seedBindAPI(t)
mintBindCode(t, api, repo, "FORM2345", bindTestUUID, authSourceMojang)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"FORM2345"}`,
map[string]string{"Content-Type": "application/x-www-form-urlencoded"})
if w.Code != http.StatusUnsupportedMediaType {
t.Fatalf("code = %d, want 415 (%s)", w.Code, w.Body.String())
}
if _, live := repo.linkCodes["FORM2345"]; !live {
t.Fatal("a content-type-rejected redeem must not consume the code")
}
}
+64
View File
@@ -125,6 +125,70 @@ func (p *PGRepo) VerifyLinkCode(ctx context.Context, userID, code string, now ti
return mcUUID, authSource, nil return mcUUID, authSource, nil
} }
// RedeemPlayerBindCode redeems a Bind Code into a player account + link in one
// transaction (console-tier access model). It mirrors VerifyLinkCode's structure —
// strict expiry against the passed clock, the durable-link write, and the DELETE
// that consumes the code — but creates or fetches the user instead of requiring one.
// See the Repo interface for the full contract. Like VerifyLinkCode a concurrent
// racer that passed the SELECT loses to the UNIQUE(mc_uuid)/UNIQUE(username) guard
// (a 500), acceptable for this integration-only path; the primary idempotency is the
// mc_uuid-keyed fetch below.
func (p *PGRepo) RedeemPlayerBindCode(ctx context.Context, newUserID, code string, now time.Time) (string, string, string, error) {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return "", "", "", err
}
defer tx.Rollback() //nolint:errcheck // no-op after commit
var mcUUID, authSource string
switch err := tx.QueryRowContext(ctx,
`SELECT mc_uuid, auth_source FROM account_link_codes WHERE code = $1 AND expires_at > $2`,
code, now).Scan(&mcUUID, &authSource); {
case errors.Is(err, sql.ErrNoRows):
return "", "", "", ErrLinkCodeInvalid
case err != nil:
return "", "", "", err
}
// Create-or-fetch keyed on the verified UUID. An already-linked role='user' player
// is fetched (idempotent "log in via the game"); a role='admin' STAFF account is
// refused (op.console only) BEFORE any consume, so the code survives; an unlinked
// UUID births a fresh role='user' player with a uuid-derived unique username.
userID := newUserID
var existingRole string
switch err := tx.QueryRowContext(ctx,
`SELECT u.id, u.role::text FROM account_links al JOIN users u ON u.id = al.user_id WHERE al.mc_uuid = $1`,
mcUUID).Scan(&userID, &existingRole); {
case errors.Is(err, sql.ErrNoRows):
if _, err := tx.ExecContext(ctx,
`INSERT INTO users (id, username, role) VALUES ($1, $2, 'user')`,
newUserID, mcUUID); err != nil {
return "", "", "", fmt.Errorf("create player: %w", err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO account_links (user_id, mc_uuid, auth_source) VALUES ($1, $2, $3)`,
newUserID, mcUUID, authSource); err != nil {
return "", "", "", fmt.Errorf("write account link: %w", err)
}
userID = newUserID
case err != nil:
return "", "", "", err
default:
if existingRole != "user" {
return "", "", "", ErrPlayerBindForbidden // staff must use op.console
}
}
if _, err := tx.ExecContext(ctx,
`DELETE FROM account_link_codes WHERE code = $1`, code); err != nil {
return "", "", "", fmt.Errorf("consume link code: %w", err)
}
if err := tx.Commit(); err != nil {
return "", "", "", err
}
return userID, mcUUID, authSource, nil
}
// QuotaAvailable treats a missing quota row or a NULL max_servers as unlimited; // QuotaAvailable treats a missing quota row or a NULL max_servers as unlimited;
// otherwise it compares the live owned-server count against the cap (spec §9.3). // otherwise it compares the live owned-server count against the cap (spec §9.3).
func (p *PGRepo) QuotaAvailable(ctx context.Context, userID string) (bool, error) { func (p *PGRepo) QuotaAvailable(ctx context.Context, userID string) (bool, error) {
+23
View File
@@ -149,6 +149,29 @@ type Repo interface {
// re-verifying the same (user, uuid) pair is idempotent and refreshes the stored // re-verifying the same (user, uuid) pair is idempotent and refreshes the stored
// authSource. now is the API clock so expiry is testable. // authSource. now is the API clock so expiry is testable.
VerifyLinkCode(ctx context.Context, userID, code string, now time.Time) (mcUUID, authSource string, err error) VerifyLinkCode(ctx context.Context, userID, code string, now time.Time) (mcUUID, authSource string, err error)
// RedeemPlayerBindCode is the account-less player-console bootstrap (console-tier
// access model): it redeems a one-time Bind Code into a PLAYER account + link in
// one atomic step, so a first-time player with no Felis account can create one
// from the console.<root_domain> door. Unlike VerifyLinkCode it takes NO prior
// user — it creates or fetches one, keyed on the verified mc_uuid the code carries:
//
// - code missing/expired → ErrLinkCodeInvalid (does not consume it);
// - the uuid is not yet linked → create a role='user' player row with id
// newUserID (NULL password_hash, username derived from the uuid so it is unique
// and deterministic), write the account_links binding, consume the code, and
// return newUserID;
// - the uuid is already linked to a role='user' player → return THAT user
// (idempotent "log in via the game"), consuming the code;
// - the uuid is linked to a role='admin' STAFF account → ErrPlayerBindForbidden
// WITHOUT consuming the code (operators use op.console behind Zero Trust; the
// public bootstrap never mints a session for an admin identity).
//
// Safe as an unauthenticated entrypoint because a Bind Code is minted internal-face
// only (CreateLinkCode), against an online-mode-verified UUID, short-TTL and
// single-use — possession already proves control of a Minecraft identity. now is
// the API clock so expiry is testable. It returns the effective userID plus the
// bound mc_uuid and authSource (for the response + audit).
RedeemPlayerBindCode(ctx context.Context, newUserID, code string, now time.Time) (userID, mcUUID, authSource string, err error)
// QuotaAvailable reports whether the user is under their max_servers quota // QuotaAvailable reports whether the user is under their max_servers quota
// (spec §9.3 step ②, evaluated before provisioning). // (spec §9.3 step ②, evaluated before provisioning).
QuotaAvailable(ctx context.Context, userID string) (bool, error) QuotaAvailable(ctx context.Context, userID string) (bool, error)