Unverified Commit fe2ece08 authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

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.
parent c01f133c
Loading
Loading
Loading
Loading
+56 −0
Changes for docs/openapi.yaml: 56 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -1338,6 +1338,62 @@ paths:
        '403':
          $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:
    get:
      tags: [servers]
+8 −0
Changes for internal/api/api.go: 8 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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/logout", Public: true, h: a.handleLogout},
		{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).
		{Method: "POST", Pattern: "/api/v1/servers/{name}/wake", h: a.handleWake},
+35 −0
Changes for internal/api/api_test.go: 35 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -199,6 +199,41 @@ func (f *fakeRepo) VerifyLinkCode(_ context.Context, userID, code string, now ti
	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
// 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
+7 −0
Changes for internal/api/errors.go: 7 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -42,6 +42,13 @@ var (
	// finish endpoint exists; the ceremony state is gone (never begun, already
	// consumed, or expired) — so handlers map it to 400, not 404.
	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,
+131 −0
Changes for internal/api/handlers_onboard.go: 131 added lines, 0 removed lines.
Original line number Diff line number Diff line
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,
	})
}
Loading