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.
116 lines
5.6 KiB
Go
116 lines
5.6 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
)
|
|
|
|
// Sentinel errors the repository and cluster layers return so handlers can map
|
|
// domain outcomes onto HTTP status codes without leaking driver details.
|
|
var (
|
|
// ErrNotFound means the requested server / record does not exist.
|
|
ErrNotFound = errors.New("not found")
|
|
// ErrConflict means an atomic precondition failed (e.g. claim lost the race).
|
|
ErrConflict = errors.New("conflict")
|
|
// ErrLinkCodeInvalid means an account-link code is unknown or expired (spec
|
|
// §10). It is a client error (the verify endpoint exists; the code is bad), so
|
|
// handlers map it to 400, not 404.
|
|
ErrLinkCodeInvalid = errors.New("link code invalid or expired")
|
|
// ErrConsoleUnavailable means the RCON write channel could not be reached —
|
|
// the dial timed out, was refused, or the password was rejected (spec §8).
|
|
// Because readiness IS an RCON probe (spec §141: phase=Running ⟺ RCON
|
|
// answers), a reachable failure here is a transient/racy "the server isn't
|
|
// actually up", not a server bug. Handlers map it to 503, not 500, so the
|
|
// caller is told to wake/retry rather than shown an opaque internal error.
|
|
ErrConsoleUnavailable = errors.New("server console is unavailable")
|
|
// ErrOTPInvalid means an email one-time code is unknown, expired, already
|
|
// consumed, or did not match (spec §B2 onboarding). Like ErrLinkCodeInvalid it
|
|
// is a client error — the verify endpoint exists; the code is bad — so handlers
|
|
// map it to 400, not 404. A wrong-but-not-yet-locked guess collapses to it too,
|
|
// so the response never distinguishes "no such code" from "wrong digits".
|
|
ErrOTPInvalid = errors.New("email code invalid or expired")
|
|
// ErrOTPLocked means the live email code has exhausted its attempt budget: too
|
|
// many wrong guesses (spec §B2). It is distinct from ErrOTPInvalid so handlers
|
|
// can answer 429 (back off / request a new code) rather than inviting another
|
|
// guess against a code that will never accept one.
|
|
ErrOTPLocked = errors.New("email code locked: too many attempts")
|
|
// ErrPasskeyChallengeInvalid means a passkey enrollment ceremony cannot be
|
|
// finished: there is no live (unconsumed, unexpired) challenge for the caller and
|
|
// purpose (Phase 6 WebAuthn bind). Like ErrOTPInvalid it is a client error — the
|
|
// 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,
|
|
// machine-readable code. The error envelope matches the platform convention:
|
|
//
|
|
// {"error": {"code": "...", "message": "...", "request_id": "..."}}
|
|
type apiError struct {
|
|
status int
|
|
code string
|
|
msg string
|
|
}
|
|
|
|
func (e *apiError) Error() string { return e.msg }
|
|
|
|
// newError builds an apiError with a formatted message.
|
|
func newError(status int, code, format string, a ...any) *apiError {
|
|
return &apiError{status: status, code: code, msg: fmt.Sprintf(format, a...)}
|
|
}
|
|
|
|
// Common errors reused across handlers.
|
|
var (
|
|
errUnauthorized = newError(http.StatusUnauthorized, "unauthorized", "authentication required")
|
|
errForbidden = newError(http.StatusForbidden, "forbidden", "not permitted")
|
|
errBadRequest = newError(http.StatusBadRequest, "bad_request", "invalid request")
|
|
// errInvalidCredentials is the single, deliberately vague answer to any failed
|
|
// local-password login (spec §B): unknown username, player row, or wrong
|
|
// password all collapse to it so the response never reveals which usernames
|
|
// carry a password. The anti-enumeration dummy-hash compare keeps the timing
|
|
// uniform alongside it (handlers_auth.go).
|
|
errInvalidCredentials = newError(http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
|
|
// errPasswordChangeRequired fences a staff principal that still owes a
|
|
// first-login password change to the change-password surface. The lockdown
|
|
// middleware returns it from every authenticated route except the opt-out set
|
|
// (change-password / logout / me), so a half-onboarded account cannot act until
|
|
// it sets its own password.
|
|
errPasswordChangeRequired = newError(http.StatusForbidden, "password_change_required",
|
|
"change your password before continuing")
|
|
)
|
|
|
|
// writeJSON writes v as an indented JSON body with the given status.
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
enc := json.NewEncoder(w)
|
|
enc.SetEscapeHTML(false)
|
|
_ = enc.Encode(v)
|
|
}
|
|
|
|
// writeError renders err as the standard error envelope. Non-apiError values
|
|
// collapse to a 500 so driver/internal details never reach the client.
|
|
func writeError(w http.ResponseWriter, r *http.Request, err error) {
|
|
var ae *apiError
|
|
if !errors.As(err, &ae) {
|
|
ae = newError(http.StatusInternalServerError, "internal", "internal error")
|
|
}
|
|
body := map[string]any{
|
|
"error": map[string]string{
|
|
"code": ae.code,
|
|
"message": ae.msg,
|
|
"request_id": requestIDFromContext(r.Context()),
|
|
},
|
|
}
|
|
writeJSON(w, ae.status, body)
|
|
}
|