feat(auth)!: go fully passwordless and fix cross-check review findings
Remove password authentication everywhere; the only session doors are passkey (WebAuthn), email OTP, in-game bind codes, QR scan-login, and op-login vouching. Remediates the 33-finding cross-check review across backend, CLI, panel, plugins, and docs. Backend/CLI: - Drop password routes and fields from account/user/onboard/auth handlers; align tests (new account subtests, naming reserves "console", op-login/onboard/qr-login test updates). - Add migrations 0016_op_login.sql and 0017_drop_password.sql. - Thread panel/admin hostnames from hostcfg through api.go, setup_panel.go, tui_root.go and tui_preflight.go instead of hardcoding; bootstrap.sh writes panel-hostname/admin-hostname into felis.toml. - Reword breakglass and TUI copy for passwordless flows. Panel: - Delete the ChangePassword page and all password UI; align login/auth/api/types with the passwordless contract; add the migration and op-login approval flows. - i18n: convert ImageBuildPage durations/status badges and ServerLuckPerms strings to translation keys; drop 72 orphan keys per locale; unify the title as "Felis - Console". Plugins (all six rebuilt): - Velocity waiting router returns 503 at_capacity during wake; MOTD/control-channel copy and config comments. - Paper zh menu title; Limbo bind-code TTL 600s with panel_url preference; unified /link lines in fabric/forge/neoforge; shared link-client javadoc contract fixes. Docs: openapi.yaml, sequence-diagrams.md, deploy/limbo/README.md and plugins/README.md aligned with the implementation. BREAKING CHANGE: migration 0017 irreversibly drops users.password_hash and users.must_change_password; password login cannot be restored after migrating.
This commit is contained in:
97 files changed
+1923
-1444
No files matched your search
+27
-5
@@ -100,6 +100,12 @@ type API struct {
|
||||
// console (console.<root_domain>) the gate is inert.
|
||||
AdminHostname string
|
||||
|
||||
// PanelHostname is the player console host (console.<root_domain>) from
|
||||
// config. Used to render user-facing panel URLs (the /link code's panel_url
|
||||
// hint); empty falls back to console.<RootDomain> (see panelURL), mirroring
|
||||
// AdminHostname's fallback.
|
||||
PanelHostname string
|
||||
|
||||
// WakeCooldown throttles repeated wakes per server (spec §9.1: cooldown hangs
|
||||
// on the wake lever). Zero disables throttling.
|
||||
WakeCooldown time.Duration
|
||||
@@ -143,6 +149,21 @@ type API struct {
|
||||
streamCap *streamLimiter
|
||||
}
|
||||
|
||||
// panelURL returns the public player-console origin ("https://console.<root>"),
|
||||
// preferring the configured PanelHostname and falling back to the conventional
|
||||
// console.<RootDomain> label — the same convention hostIsAdminConsole applies
|
||||
// to the operator host. Empty when neither is configured (a bare test API).
|
||||
func (a *API) panelURL() string {
|
||||
host := a.PanelHostname
|
||||
if host == "" && a.RootDomain != "" {
|
||||
host = "console." + a.RootDomain
|
||||
}
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://" + host
|
||||
}
|
||||
|
||||
// now returns the current time using the injected clock.
|
||||
func (a *API) now() time.Time {
|
||||
if a.Now != nil {
|
||||
@@ -237,7 +258,6 @@ func (a *API) internalAPIRoutes() []apiRoute {
|
||||
{Method: "GET", Pattern: "/readyz", Public: true, h: a.handleReadyz},
|
||||
|
||||
{Method: "GET", Pattern: "/api/v1/servers", h: a.handleListServers},
|
||||
{Method: "GET", Pattern: "/api/v1/servers/by-host/{host}", h: a.handleByHost},
|
||||
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/ready", h: a.handleReady},
|
||||
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/join-event", h: a.handleJoinEvent},
|
||||
// Domain-autostart (spec §9.1, §14): velocity drives the wake lever and polls
|
||||
@@ -282,10 +302,11 @@ func (a *API) internalAPIRoutes() []apiRoute {
|
||||
// (Mojang-first) and rewrites third-party UUIDs into a per-source namespace
|
||||
// before returning the canonical profile (handlers_hasjoined.go).
|
||||
{Method: "GET", Pattern: "/session/minecraft/hasJoined", Public: true, h: a.handleHasJoined},
|
||||
// Op-login (passwordless console login): an in-game op requests a login that
|
||||
// the web owner/admin approves, then redeems for a session. Internal face
|
||||
// carries the pending queue and the approve action (service-token auth, no
|
||||
// Principal); the external face carries the start/status/finish the op drives.
|
||||
// Op-login (passwordless op.console login): a staff member starts the login
|
||||
// on the web, and an ONLINE in-game admin vouches for it via velocity's
|
||||
// /felis web op approve. Internal face carries the pending queue and the
|
||||
// approve action (service-token auth, no Principal); the public face carries
|
||||
// the start/status/finish the staff member's browser drives.
|
||||
{Method: "GET", Pattern: "/api/v1/internal/op-login/pending", h: a.handleOpLoginPending},
|
||||
{Method: "POST", Pattern: "/api/v1/internal/op-login/{id}/approve", h: a.handleOpLoginApprove},
|
||||
|
||||
@@ -362,6 +383,7 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
||||
{Method: "GET", Pattern: "/api/v1/servers/{name}/access/ban", h: a.handleAccessBanList},
|
||||
{Method: "POST", Pattern: "/api/v1/servers/{name}/access/permission", h: a.handleAccessPermission},
|
||||
{Method: "POST", Pattern: "/api/v1/servers/{name}/access/group", h: a.handleAccessGroup},
|
||||
{Method: "GET", Pattern: "/api/v1/servers/{name}/access/luckperms/{player}", h: a.handleAccessLuckPermsInfo},
|
||||
{Method: "GET", Pattern: "/api/v1/servers/{name}/status", h: a.handleStatus},
|
||||
// Identity self-read (spec §14 tiering): the panel reads this once at boot to
|
||||
// learn its own tier and decide which navigation surfaces to render. App-tier —
|
||||
|
||||
@@ -52,7 +52,7 @@ type fakeRepo struct {
|
||||
linkAuthSource map[string]string
|
||||
// world backups (spec §7, §22). A nil slice lists empty.
|
||||
backups []fakeBackup
|
||||
// local-password auth (spec §B). staff is keyed by username (the login key);
|
||||
// session auth (spec §B, passwordless). staff is keyed by username (the login key);
|
||||
// sessions by token_hash; settings by key. They mirror the PG contract so the
|
||||
// hermetic tests exercise the same fail-closed semantics the integration impl
|
||||
// honors.
|
||||
@@ -1600,45 +1600,6 @@ func TestMeIdentity(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// ---- by-host ----
|
||||
|
||||
func TestByHost(t *testing.T) {
|
||||
cl := newFakeCluster()
|
||||
cl.bySub["survival"] = &ServerInfo{Name: "survival", Subdomain: "survival", Phase: "Running", Ready: true}
|
||||
api := newTestAPI(newFakeRepo(), cl)
|
||||
h := api.InternalHandler()
|
||||
tok := map[string]string{"Authorization": "Bearer "} // okInternal ignores it
|
||||
|
||||
t.Run("foreign domain rejected", func(t *testing.T) {
|
||||
w := do(h, "GET", "/api/v1/servers/by-host/survival.evil.example.org", "", tok)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("code = %d, want 400", w.Code)
|
||||
}
|
||||
})
|
||||
t.Run("multi-label rejected", func(t *testing.T) {
|
||||
w := do(h, "GET", "/api/v1/servers/by-host/a.b."+testRoot, "", tok)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("code = %d, want 400", w.Code)
|
||||
}
|
||||
})
|
||||
t.Run("unknown server 404", func(t *testing.T) {
|
||||
w := do(h, "GET", "/api/v1/servers/by-host/creative."+testRoot, "", tok)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("code = %d, want 404", w.Code)
|
||||
}
|
||||
})
|
||||
t.Run("found", func(t *testing.T) {
|
||||
w := do(h, "GET", "/api/v1/servers/by-host/survival."+testRoot, "", tok)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
var info ServerInfo
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil || info.Name != "survival" {
|
||||
t.Fatalf("unexpected body %s err %v", w.Body.String(), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- fleet (SysAdmin cockpit read) ----
|
||||
|
||||
// TestFleetAdminRead proves the SysAdmin cockpit's fleet read is admin-tier AND
|
||||
|
||||
@@ -403,6 +403,117 @@ func (a *API) handleAccessGroup(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// lpPermissionView is one parsed LuckPerms permission entry returned by the
|
||||
// luckperms read projector. World is surfaced only when the entry carries a
|
||||
// world= context (the one context the panel renders); Value comes from the
|
||||
// entry's color code (LuckPerms renders granted nodes green, negated red).
|
||||
type lpPermissionView struct {
|
||||
Node string `json:"node"`
|
||||
Value bool `json:"value"`
|
||||
World string `json:"world,omitempty"`
|
||||
}
|
||||
|
||||
// maxLPInfoPages bounds how many "permission info" pages the read projector
|
||||
// chases per request. LuckPerms paginates its reply, so one command shows only
|
||||
// the first page; we follow the header's page count up to this cap.
|
||||
// ponytail: 10 pages ≈ 150 entries — raise if a real user outgrows it.
|
||||
const maxLPInfoPages = 10
|
||||
|
||||
// handleAccessLuckPermsInfo is the read projector for a player's LuckPerms
|
||||
// state: it runs "lp user <player> permission info" over the same owner-gated
|
||||
// RCON spine as every access mutation and returns a best-effort parse — parent
|
||||
// groups split out from plain permission nodes — PLUS the raw reply, like the
|
||||
// whitelist/players/banlist reads. Page 1 goes through issueAccessCommand (the
|
||||
// gate); further pages are fetched best-effort directly, so a mid-fetch failure
|
||||
// keeps what was already read instead of erroring a half-served response.
|
||||
// No audit (a read).
|
||||
func (a *API) handleAccessLuckPermsInfo(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
player := r.PathValue("player")
|
||||
if !mcNameRe.MatchString(player) {
|
||||
writeError(w, r, errInvalidPlayer)
|
||||
return
|
||||
}
|
||||
|
||||
out, ok := a.issueAccessCommand(w, r, name, "lp user "+player+" permission info")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
raw := out
|
||||
entries, pages := parseLuckPermsInfo(out)
|
||||
for page := 2; page <= pages && page <= maxLPInfoPages; page++ {
|
||||
more, err := a.Console.RunCommand(r.Context(), name,
|
||||
fmt.Sprintf("lp user %s permission info %d", player, page))
|
||||
if err != nil {
|
||||
break // best-effort: keep the pages we have
|
||||
}
|
||||
raw += "\n" + more
|
||||
e, _ := parseLuckPermsInfo(more)
|
||||
entries = append(entries, e...)
|
||||
}
|
||||
|
||||
// Split parent groups ("group.<name>", granted, no context) from plain
|
||||
// permission nodes. A negated or world-scoped group.* entry stays in
|
||||
// permissions — folding it into groups would lose the negation/scope.
|
||||
groups := []string{}
|
||||
permissions := []lpPermissionView{}
|
||||
for _, e := range entries {
|
||||
if g, isGroup := strings.CutPrefix(e.Node, "group."); isGroup && e.Value && e.World == "" && lpCtxRe.MatchString(g) {
|
||||
groups = append(groups, g)
|
||||
continue
|
||||
}
|
||||
permissions = append(permissions, e)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"player": player, "groups": groups, "permissions": permissions, "output": raw,
|
||||
})
|
||||
}
|
||||
|
||||
var (
|
||||
// lpEntryRe matches one "permission info" entry: the "> " marker, then any
|
||||
// legacy color codes, then the node (lpNodeRe's charset). Anchoring on the
|
||||
// marker rather than lines follows banEntryRe's rationale: RCON concatenates
|
||||
// multi-message replies with a server-dependent separator, so a line split is
|
||||
// unreliable. Group 1 keeps the color codes so the entry's value survives the
|
||||
// later color strip (§a = granted, §c = negated).
|
||||
lpEntryRe = regexp.MustCompile(`>\s*((?:§[0-9a-fk-or])*)([A-Za-z0-9_.*-]{1,64})`)
|
||||
// lpPageRe reads the pagination header ("page 1 of 3") AFTER color stripping.
|
||||
lpPageRe = regexp.MustCompile(`page\s+(\d+)\s+of\s+(\d+)`)
|
||||
// lpColorRe strips legacy §-color codes.
|
||||
lpColorRe = regexp.MustCompile(`§[0-9a-fk-or]`)
|
||||
// lpWorldRe reads a world= context from an entry's color-stripped tail.
|
||||
lpWorldRe = regexp.MustCompile(`world=([A-Za-z0-9_-]{1,48})`)
|
||||
)
|
||||
|
||||
// parseLuckPermsInfo extracts permission entries and the total page count from
|
||||
// one "lp user <player> permission info" reply. Best-effort and
|
||||
// LuckPerms-specific (INTEGRATION-ONLY against a real server) — the caller
|
||||
// always returns the raw reply alongside, so an unrecognised format loses
|
||||
// nothing. An entry's value defaults to granted when no color code precedes the
|
||||
// node (a color-stripping RCON transport); pages is 0 when no header parses.
|
||||
func parseLuckPermsInfo(out string) (entries []lpPermissionView, pages int) {
|
||||
matches := lpEntryRe.FindAllStringSubmatchIndex(out, -1)
|
||||
for i, m := range matches {
|
||||
colors := out[m[2]:m[3]]
|
||||
node := out[m[4]:m[5]]
|
||||
// The entry's tail (up to the next marker) carries its contexts.
|
||||
tailEnd := len(out)
|
||||
if i+1 < len(matches) {
|
||||
tailEnd = matches[i+1][0]
|
||||
}
|
||||
tail := lpColorRe.ReplaceAllString(out[m[5]:tailEnd], "")
|
||||
e := lpPermissionView{Node: node, Value: !strings.Contains(colors, "§c")}
|
||||
if wm := lpWorldRe.FindStringSubmatch(tail); wm != nil {
|
||||
e.World = wm[1]
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
if pm := lpPageRe.FindStringSubmatch(lpColorRe.ReplaceAllString(out, "")); pm != nil {
|
||||
pages, _ = strconv.Atoi(pm[2])
|
||||
}
|
||||
return entries, pages
|
||||
}
|
||||
|
||||
// parseWhitelistOutput extracts player names from vanilla's "whitelist list"
|
||||
// reply, whose format is "There are N whitelisted player(s): a, b, c" (and "There
|
||||
// are no whitelisted players" / a trailing colon for the empty case). The parse
|
||||
|
||||
@@ -51,6 +51,26 @@ 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)
|
||||
@@ -88,12 +108,13 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) {
|
||||
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.
|
||||
// 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 = authSourceMojang
|
||||
authSource = deriveAuthSource(req.MCUUID)
|
||||
}
|
||||
if !validAuthSource(authSource) {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
|
||||
@@ -110,10 +131,17 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
// 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
|
||||
@@ -125,8 +153,9 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) {
|
||||
// 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.
|
||||
// → 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):
|
||||
@@ -147,7 +176,7 @@ func (a *API) handleLinkStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required"))
|
||||
return
|
||||
}
|
||||
userID, err := a.Repo.UserByMCUUID(r.Context(), mcUUID)
|
||||
_, 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
|
||||
@@ -159,7 +188,7 @@ func (a *API) handleLinkStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"linked": true, "user_id": userID})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"linked": true})
|
||||
}
|
||||
|
||||
// linkVerifyRequest is the panel verify-code body (spec §10): the logged-in user
|
||||
|
||||
@@ -120,6 +120,30 @@ func TestCreateLinkCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("panel_url points at the web console", func(t *testing.T) {
|
||||
// The mint response carries the redeem address so every plugin renders the
|
||||
// same hostname from one source of truth (derived console.<root> here).
|
||||
w := do(ih, "POST", "/api/v1/internal/account/link/code", `{"mc_uuid":"`+mcUUID+`"}`, nil)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("code = %d, want 201 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if got := acctBody(t, w)["panel_url"]; got != "https://console."+testRoot {
|
||||
t.Errorf("panel_url = %v, want https://console.%s", got, testRoot)
|
||||
}
|
||||
})
|
||||
t.Run("omitted auth_source with a v3 UUID derives thirdparty", func(t *testing.T) {
|
||||
// A felis-nano rewrite is a name-based UUIDv3; the version nibble alone must
|
||||
// classify it so no Java plugin has to learn the auth_source field.
|
||||
const v3UUID = "33333333-3333-3333-8333-333333333333"
|
||||
w := do(ih, "POST", "/api/v1/internal/account/link/code", `{"mc_uuid":"`+v3UUID+`"}`, nil)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("code = %d, want 201 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
code, _ := acctBody(t, w)["code"].(string)
|
||||
if rec := repo.linkCodes[code]; rec.authSource != authSourceThirdParty {
|
||||
t.Errorf("derived authSource = %q, want %q", rec.authSource, authSourceThirdParty)
|
||||
}
|
||||
})
|
||||
t.Run("explicit thirdparty is stored", func(t *testing.T) {
|
||||
body := `{"mc_uuid":"` + mcUUID + `","auth_source":"` + authSourceThirdParty + `"}`
|
||||
w := do(ih, "POST", "/api/v1/internal/account/link/code", body, nil)
|
||||
|
||||
@@ -7,11 +7,11 @@ import (
|
||||
)
|
||||
|
||||
// Pre-session Email-OTP LOGIN (spec §B, console.<root_domain> returning-player door).
|
||||
// This is the passwordless counterpart of handleLogin and the returning-player
|
||||
// counterpart of handleBindRedeem: an account that already proved control of an
|
||||
// email (email_verified, migration 0010) logs back in with a one-time code mailed
|
||||
// to that address — no password, no in-game Bind Code. The two halves are Public,
|
||||
// pre-session routes: the caller has no principal yet, so identity is resolved from
|
||||
// This is the returning-player counterpart of handleBindRedeem: an account that
|
||||
// already proved control of an email (email_verified, migration 0010) logs back in
|
||||
// with a one-time code mailed to that address — no password exists anywhere in the
|
||||
// product, and no in-game Bind Code is needed the second time. The two halves are
|
||||
// Public, pre-session routes: the caller has no principal yet, so identity is resolved from
|
||||
// the typed email via UserByEmail, exactly as handleBindRedeem resolves it from the
|
||||
// code.
|
||||
//
|
||||
@@ -55,9 +55,9 @@ type loginEmailStartRequest struct {
|
||||
}
|
||||
|
||||
// handleLoginEmailStart mints and mails a login code for a returning account (Public,
|
||||
// pre-session). It gates on local sessions being enabled — like handleLogin and
|
||||
// handleBindRedeem, minting a code toward a felis_session while SessionAuth would
|
||||
// reject that cookie is pointless — reserves the per-recipient cooldown, resolves the
|
||||
// pre-session). It gates on local sessions being enabled — like handleBindRedeem
|
||||
// and the op-login door, minting a code toward a felis_session while SessionAuth
|
||||
// would reject that cookie is pointless — reserves the per-recipient cooldown, resolves the
|
||||
// address to an account, and (only if one exists) mints a code under otpPurposeLogin.
|
||||
// An address with no verified account yields the SAME 202 as a successful send with
|
||||
// no code minted: the response never distinguishes the two, and the reservation is
|
||||
@@ -164,7 +164,7 @@ type loginEmailVerifyRequest struct {
|
||||
|
||||
// handleLoginEmailVerify redeems a login code into a session (Public, pre-session).
|
||||
// It resolves the address to an account, verifies the code under otpPurposeLogin, and
|
||||
// on success mints the same host-only felis_session as handleLogin. A missing account,
|
||||
// on success mints the same host-only felis_session as handleBindRedeem. A missing account,
|
||||
// a wrong code, AND an attempt-exhausted (locked) code all return the IDENTICAL 400
|
||||
// invalid_code, so a code-less caller cannot tell an unknown address from a bad guess
|
||||
// or farm a lockout into an is-this-a-real-account oracle. Staff are refused — but only
|
||||
|
||||
@@ -56,7 +56,7 @@ func errEnvelope(t *testing.T, w *httptest.ResponseRecorder) (code, msg string)
|
||||
// TestLoginEmailVertical walks the whole returning-player slice: a typed lowercase
|
||||
// address resolves the mixed-case stored account, the code is mailed to the account's
|
||||
// STORED casing (the address of record), and redeeming it mints the same host-only
|
||||
// felis_session as the password door — single-use, audited on both halves by the
|
||||
// felis_session as the other session doors — single-use, audited on both halves by the
|
||||
// account's username. The redeem never rewrites users.email (login re-proves an
|
||||
// already-verified address via ConsumeLoginEmailOTP), so the stored casing is
|
||||
// untouched by definition.
|
||||
@@ -113,7 +113,7 @@ func TestLoginEmailVertical(t *testing.T) {
|
||||
if vb["user_id"] != "u1" || vb["role"] != "user" {
|
||||
t.Fatalf("verify body = %v, want user_id:u1 role:user", vb)
|
||||
}
|
||||
// The HttpOnly cookie is the whole point — same contract as handleLogin.
|
||||
// The HttpOnly cookie is the whole point — same contract as every session door.
|
||||
cookies := w.Result().Cookies()
|
||||
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
|
||||
t.Fatalf("want one non-empty %s cookie, got %v", sessionCookieName, cookies)
|
||||
@@ -208,8 +208,8 @@ func TestLoginEmailStartNeutralOnUnknownAddress(t *testing.T) {
|
||||
|
||||
// TestLoginEmailGates covers the shared front doors of both halves: the fail-closed
|
||||
// local-auth toggle, the cross-site-forgery Content-Type guard (these are Public,
|
||||
// credential-minting routes — same rationale as handleLogin), and the input gates
|
||||
// that must reject before any mint or lookup.
|
||||
// credential-minting routes — same rationale as handleBindRedeem), and the input
|
||||
// gates that must reject before any mint or lookup.
|
||||
func TestLoginEmailGates(t *testing.T) {
|
||||
t.Run("local auth disabled -> 403 on both halves", func(t *testing.T) {
|
||||
api := newTestAPI(newFakeRepo(), newFakeCluster()) // no LocalAuthEnabledKey: fails closed
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||
"felis.lolicon.best/internal/naming"
|
||||
@@ -43,25 +42,6 @@ func (a *API) handleListServers(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"servers": servers})
|
||||
}
|
||||
|
||||
// handleByHost resolves host=subdomain.{root_domain} to its server (spec §7
|
||||
// GET /servers/by-host/{host}). The host is validated against the configured
|
||||
// root domain — the only place the deployment zone enters the lookup.
|
||||
func (a *API) handleByHost(w http.ResponseWriter, r *http.Request) {
|
||||
host := strings.ToLower(r.PathValue("host"))
|
||||
if err := naming.ValidateHostname(host, a.RootDomain); err != nil {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "bad_host", "invalid host: %v", err))
|
||||
return
|
||||
}
|
||||
subdomain := strings.TrimSuffix(host, "."+a.RootDomain)
|
||||
|
||||
info, err := a.Cluster.GetBySubdomain(r.Context(), subdomain)
|
||||
if err != nil {
|
||||
a.writeLookupError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, info)
|
||||
}
|
||||
|
||||
// handleReady accepts a backend's push that a server is up (spec §7
|
||||
// /internal/servers/{name}/ready). The RCON probe is the authoritative gate, so
|
||||
// this is advisory: it audits the signal and returns 204.
|
||||
@@ -159,8 +139,9 @@ func (a *API) handleInternalWake(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
// Global running-server cap (spec §9.1), shared with the external wake. velocity
|
||||
// treats 503 at_capacity as "cluster full, hold the player", distinct from the
|
||||
// 429 cooldown's "already waking, keep waiting".
|
||||
// treats 503 at_capacity as "cluster full, tell the player to try later" and does
|
||||
// NOT enqueue them (nothing is coming up, so waiting would only strand them),
|
||||
// distinct from the 429 cooldown's "already waking, keep waiting".
|
||||
ok, err := a.withinRunningCap(r.Context(), info)
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
@@ -278,7 +259,7 @@ func (a *API) handleInternalClaim(w http.ResponseWriter, r *http.Request) {
|
||||
// the lobby GUI needs to render one server tile, composed from the lifecycle view
|
||||
// (phase/ready/players from the CRD status) and the business ownership row
|
||||
// (claimable = nobody owns it yet). It is the only internal response carrying
|
||||
// claimable, so it has its own shape — the §11 list/by-host/status views never
|
||||
// claimable, so it has its own shape — the §11 list/status views never
|
||||
// expose ownership, and folding owner data into ServerInfo would force the
|
||||
// lifecycle layer to consult Postgres.
|
||||
//
|
||||
|
||||
@@ -32,7 +32,7 @@ import (
|
||||
// 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
|
||||
// for the other public session doors (email-OTP, op-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
|
||||
@@ -62,15 +62,16 @@ type bindRedeemRequest struct {
|
||||
|
||||
// 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.
|
||||
// no Zero Trust in front (unlike op.console). Like the email-OTP login door 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
|
||||
// cookie, so refuse loudly, consistently with the other session doors. 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).
|
||||
|
||||
@@ -227,7 +227,7 @@ func TestBindRedeemExpiredCode(t *testing.T) {
|
||||
|
||||
// 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.
|
||||
// cookie, mirroring the email-OTP login door.
|
||||
func TestBindRedeemLocalAuthDisabled(t *testing.T) {
|
||||
repo := newFakeRepo() // local_auth_enabled never set → fail closed
|
||||
api := newTestAPI(repo, newFakeCluster())
|
||||
|
||||
@@ -10,14 +10,15 @@ import (
|
||||
// op.console STAFF login (spec §B op-login): the two-factor door for the most
|
||||
// sensitive tier. Unlike the console.<root_domain> player doors (email OTP / bind
|
||||
// code), a staff web session is never minted from a single factor. The flow is a
|
||||
// three-call state machine over op_login_requests (migration 0012), all Public
|
||||
// three-call state machine over op_login_requests (migration 0016), all Public
|
||||
// pre-session routes (the caller has no principal yet), plus two internal-face routes
|
||||
// velocity drives on behalf of online admins:
|
||||
// for the in-game side (approve is driven by velocity's /felis command; pending has
|
||||
// no consumer yet — see handleOpLoginPending):
|
||||
//
|
||||
// POST /api/v1/auth/op-login/start (public) — mint a request + mail an OTP
|
||||
// GET /api/v1/auth/op-login/status/{id} (public) — poll until an admin approves
|
||||
// POST /api/v1/auth/op-login/finish (public) — redeem code+approval → session
|
||||
// GET /api/v1/internal/op-login/pending (internal) — the online-admin push list
|
||||
// GET /api/v1/internal/op-login/pending (internal) — list requests awaiting a vouch
|
||||
// POST /api/v1/internal/op-login/{id}/approve (internal) — an in-game admin vouches
|
||||
//
|
||||
// The two factors:
|
||||
@@ -26,9 +27,9 @@ import (
|
||||
// start and redeemed by finish, reusing the email_otps lifecycle (the purpose
|
||||
// column keeps it from ever colliding with a console login_email or onboard code).
|
||||
// - An in-game vouch — an already-trusted admin who is ONLINE approves the pending
|
||||
// request via velocity's /felis command (internal approve). Only a linked
|
||||
// role=admin account may approve; velocity additionally gates the command on
|
||||
// in-game op, so the API check is defence in depth over its own user table.
|
||||
// request via velocity's /felis command (internal approve). The API's own user
|
||||
// table is the sole authority: only a UUID linked to a role=admin account may
|
||||
// approve (velocity's command runs for any player and relies on this check).
|
||||
//
|
||||
// finish mints the session only when BOTH have landed. Neither factor alone — a mailed
|
||||
// code without an approval, or an approval without the code — yields a session.
|
||||
@@ -317,8 +318,10 @@ func (a *API) handleOpLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// handleOpLoginPending lists live pending staff login requests, oldest first (internal
|
||||
// face). Velocity polls it and pushes the waiting requests to online admins, who
|
||||
// approve one with /felis web op approve <id>. Internal-only: velocity holds a service
|
||||
// face). Today no plugin consumes it: the approver learns the request id out-of-band
|
||||
// (the op.console start screen shows it to the person logging in) and runs
|
||||
// /felis web op approve <id>. The route exists so velocity can later push the waiting
|
||||
// list to online admins without an API change. Internal-only: velocity holds a service
|
||||
// token and no pending request is secret to the operator crew.
|
||||
func (a *API) handleOpLoginPending(w http.ResponseWriter, r *http.Request) {
|
||||
reqs, err := a.Repo.ListPendingOpLogins(r.Context(), a.now())
|
||||
@@ -340,8 +343,8 @@ func (a *API) handleOpLoginPending(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// opLoginApproveRequest is the internal approve body: the online-mode UUID of the
|
||||
// in-game admin running /felis web op approve. The API resolves it to a linked account
|
||||
// and refuses unless that account is role=admin — defence in depth over velocity's own
|
||||
// in-game op gate, checked against the API's authoritative user table.
|
||||
// and refuses unless that account is role=admin — this check against the API's
|
||||
// authoritative user table is the only gate; velocity's command itself is unprivileged.
|
||||
type opLoginApproveRequest struct {
|
||||
ApproverUUID string `json:"approver_uuid"`
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ import (
|
||||
// all collapse to one op_login_invalid envelope; an early-but-correct code is
|
||||
// preserved (approval is read before the code is consumed), and a wrong code costs
|
||||
// an attempt without burning the approval.
|
||||
// - Admin-only approval. Only a linked role=admin UUID may vouch; the check is the
|
||||
// API's own user table, defence in depth over velocity's in-game op gate.
|
||||
// - Admin-only approval. Only a linked role=admin UUID may vouch; the API's own
|
||||
// user table is the sole gate (velocity's command itself is unprivileged).
|
||||
|
||||
const opUUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" // the seeded admin's linked in-game UUID
|
||||
|
||||
|
||||
@@ -12,11 +12,12 @@ func statusPath(mcUUID string) string {
|
||||
|
||||
// TestQRLoginCompletionPollVertical walks the QR scan-to-login flow end to end and
|
||||
// proves its load-bearing invariant: the internal completion poll reports the link
|
||||
// only after the WEB verify writes it, and reports it bound to the exact Principal
|
||||
// that verified — never to a UUID the poll itself could name. velocity mints and
|
||||
// polls on the internal face (it holds no web Principal); the durable bind is born
|
||||
// on the external face from a logged-in user. That split is the whole security
|
||||
// model of the scan, so the test drives both faces of one API.
|
||||
// only after the WEB verify writes it. velocity mints and polls on the internal
|
||||
// face (it holds no web Principal); the durable bind is born on the external face
|
||||
// from a logged-in user. That split is the whole security model of the scan, so
|
||||
// the test drives both faces of one API. The poll carries ONLY the boolean — the
|
||||
// plugin keys everything on the UUID it already holds, so no identity detail
|
||||
// (user_id) ever crosses back, in either state.
|
||||
func TestQRLoginCompletionPollVertical(t *testing.T) {
|
||||
const mcUUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
user := &Principal{UserID: "u-scan", Email: "[email protected]", Role: "user"}
|
||||
@@ -54,9 +55,8 @@ func TestQRLoginCompletionPollVertical(t *testing.T) {
|
||||
t.Fatalf("verify: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Now the poll flips: velocity sees linked:true and the user_id it must bind the
|
||||
// in-game session to — and that user_id is the verifier's, the only identity the
|
||||
// poll could ever return, since the poll cannot mint a link of its own.
|
||||
// Now the poll flips: velocity sees linked:true and admits the player. The
|
||||
// response stays identity-free — linked is the entire contract.
|
||||
w = do(ih, "GET", statusPath(mcUUID), "", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("post-verify poll: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
@@ -65,8 +65,8 @@ func TestQRLoginCompletionPollVertical(t *testing.T) {
|
||||
if b["linked"] != true {
|
||||
t.Fatalf("post-verify poll body = %v, want linked:true", b)
|
||||
}
|
||||
if got := b["user_id"]; got != user.UserID {
|
||||
t.Fatalf("post-verify poll user_id = %v, want %q (the verifier's id)", got, user.UserID)
|
||||
if _, ok := b["user_id"]; ok {
|
||||
t.Fatalf("post-verify poll leaked user_id: %v", b)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,8 +101,8 @@ func TestQRLoginStatusIdempotent(t *testing.T) {
|
||||
t.Fatalf("poll %d: code = %d, want 200 (%s)", i, w.Code, w.Body.String())
|
||||
}
|
||||
b := acctBody(t, w)
|
||||
if b["linked"] != true || b["user_id"] != "u-held" {
|
||||
t.Fatalf("poll %d body = %v, want linked:true user_id:u-held", i, b)
|
||||
if b["linked"] != true {
|
||||
t.Fatalf("poll %d body = %v, want linked:true", i, b)
|
||||
}
|
||||
}
|
||||
// The read must not have disturbed the durable link.
|
||||
@@ -112,8 +112,8 @@ func TestQRLoginStatusIdempotent(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestQRLoginStatusFaceSeparation enforces that the poll is internal-only. It
|
||||
// reads who a UUID is linked to — a fact the public web face must not be able to
|
||||
// fish out by UUID — so crossing onto the external face must 404, not answer.
|
||||
// reads whether a UUID is linked — a fact the public web face must not be able
|
||||
// to fish out by UUID — so crossing onto the external face must 404, not answer.
|
||||
func TestQRLoginStatusFaceSeparation(t *testing.T) {
|
||||
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
|
||||
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||
|
||||
@@ -203,6 +203,22 @@ func (a *API) handleMyServers(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
// Player counts are presentational and best-effort, mirroring handleFleet's
|
||||
// owner join: the list exists for ownership/claim state, so a cluster hiccup
|
||||
// must degrade to 0/0 counts, never 500 the whole list. The CRD status is the
|
||||
// only source of live counts (spec §1) — Postgres never stores them.
|
||||
if infos, err := a.Cluster.ListServers(r.Context()); err == nil {
|
||||
byName := make(map[string]ServerInfo, len(infos))
|
||||
for _, s := range infos {
|
||||
byName[s.Name] = s
|
||||
}
|
||||
for i := range servers {
|
||||
if info, ok := byName[servers[i].Name]; ok {
|
||||
servers[i].PlayersOnline = info.PlayersOnline
|
||||
servers[i].PlayersMax = info.PlayersMax
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"servers": servers})
|
||||
}
|
||||
|
||||
|
||||
@@ -437,7 +437,15 @@ func (a *API) handleLinkAccount(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if body.AuthSource == "" {
|
||||
body.AuthSource = "mojang"
|
||||
// Same version-nibble inference as the mint path (handlers_account.go):
|
||||
// defaulting to mojang here would leave a force-linked thirdparty UUID
|
||||
// outside the reclaim guard.
|
||||
body.AuthSource = deriveAuthSource(body.MCUUID)
|
||||
}
|
||||
if !validAuthSource(body.AuthSource) {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
|
||||
"auth_source must be %q or %q", authSourceMojang, authSourceThirdParty))
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.Repo.LinkAccount(r.Context(), userID, body.MCUUID, body.AuthSource); err != nil {
|
||||
|
||||
@@ -818,10 +818,11 @@ func (p *PGRepo) IsUsernameBlacklisted(ctx context.Context, mcUUID string) (bool
|
||||
// who authenticates through the third-party Yggdrasil — the admin-on-Yggdrasil reclaim
|
||||
// exception (spec §B3). The EXISTS joins account_links to users on exactly three
|
||||
// conjuncts: the UUID is linked, that link authenticated via 'thirdparty', and the
|
||||
// linked user is an admin. It intentionally does not test password_hash: an Operator
|
||||
// who signs in via SSO (Cloudflare Access, §14) carries role='admin' with a NULL hash
|
||||
// and must be protected just the same — the hash is orthogonal to "is staff" and "logs
|
||||
// in via the Login Server". Keyed by UUID, the only identity velocity holds.
|
||||
// linked user is an admin. It intentionally does not test HOW the account signs in:
|
||||
// an Operator may authenticate via SSO (Cloudflare Access, §14) or any local
|
||||
// passwordless door and must be protected just the same — the sign-in method is
|
||||
// orthogonal to "is staff" and "logs in via the Login Server". Keyed by UUID, the
|
||||
// only identity velocity holds.
|
||||
func (p *PGRepo) IsProtectedAdminLink(ctx context.Context, mcUUID string) (bool, error) {
|
||||
var ok bool
|
||||
err := p.db.QueryRowContext(ctx,
|
||||
|
||||
+16
-11
@@ -18,13 +18,18 @@ type ServerRecord struct {
|
||||
}
|
||||
|
||||
// MyServerView is a row of GET /api/v1/me/servers: a server the caller owns,
|
||||
// may auto-start, or may claim.
|
||||
// may auto-start, or may claim. PlayersOnline/PlayersMax are NOT stored in
|
||||
// Postgres — handleMyServers joins them best-effort from the CRD status
|
||||
// (Cluster.ListServers) at read time, so a cluster hiccup renders 0/0, never
|
||||
// a 500.
|
||||
type MyServerView struct {
|
||||
Name string `json:"name"`
|
||||
Subdomain string `json:"subdomain"`
|
||||
Owned bool `json:"owned"`
|
||||
Claimable bool `json:"claimable"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Subdomain string `json:"subdomain"`
|
||||
Owned bool `json:"owned"`
|
||||
Claimable bool `json:"claimable"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
PlayersOnline int32 `json:"playersOnline"`
|
||||
PlayersMax int32 `json:"playersMax"`
|
||||
}
|
||||
|
||||
// AuditEntry is one row written to audit_logs (spec §6). The actor is the Access
|
||||
@@ -197,8 +202,8 @@ type Repo interface {
|
||||
//
|
||||
// - 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
|
||||
// newUserID (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;
|
||||
@@ -456,9 +461,9 @@ type Repo interface {
|
||||
// Mojang-priority reclaim must never bar them. The predicate is exactly three
|
||||
// conjuncts: the UUID is linked (account_links), that link authenticated via
|
||||
// 'thirdparty' (auth_source), and the linked user is an admin (role='admin').
|
||||
// It deliberately does NOT require a local password hash: an Operator who signs
|
||||
// in through SSO (Cloudflare Access, IdP-agnostic per §14) carries role='admin'
|
||||
// with no password_hash, and must be protected all the same — a password hash is
|
||||
// It deliberately does NOT ask HOW the staff account signs in: an Operator may
|
||||
// authenticate via SSO (Cloudflare Access, IdP-agnostic per §14) or any local
|
||||
// passwordless door, and must be protected all the same — the sign-in method is
|
||||
// orthogonal to both "is staff" and "logs in via the Login Server". An unlinked
|
||||
// UUID, a Mojang-sourced link, or a non-admin link all yield false, so the
|
||||
// exception never broadens to ordinary thirdparty players (Mojang priority still
|
||||
|
||||
@@ -23,6 +23,7 @@ var reserved = map[string]struct{}{
|
||||
"lobby": {},
|
||||
"admin": {},
|
||||
"panel": {},
|
||||
"console": {}, // the player web console (console.<root>); op.console carries a dot and can never collide
|
||||
"api": {},
|
||||
"felis": {},
|
||||
"velocity": {},
|
||||
@@ -112,12 +113,6 @@ func ValidateSystemServerName(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsReserved reports whether label is on the reserved list.
|
||||
func IsReserved(label string) bool {
|
||||
_, ok := reserved[label]
|
||||
return ok
|
||||
}
|
||||
|
||||
// worldVolumeName mirrors operator.dataVolumeName: the per-server StatefulSet's
|
||||
// volumeClaimTemplate is named "world", so a single-replica server's world PVC
|
||||
// is "world-<name>-0". This is the one naming convention shared by the operator
|
||||
|
||||
@@ -26,6 +26,7 @@ func TestValidateServerName(t *testing.T) {
|
||||
{"lobby", false}, // reserved
|
||||
{"admin", false}, // reserved
|
||||
{"api", false}, // reserved
|
||||
{"console", false}, // reserved web console host
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := naming.ValidateServerName(c.name)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Felis Control Panel</title>
|
||||
<title>Felis Console</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">Felis panel assets were not built into this binary.</div>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
-- op.console staff sign-in (spec §B op-login): a staff account signs in at
|
||||
-- op.console with an email-OTP (minted under purpose 'op_login', stored in
|
||||
-- player_email_otp) PLUS an in-game admin vouching for the attempt via
|
||||
-- /felis web op approve <request_id>. A row here is the vouch half of that
|
||||
-- pair: it exists from the moment the OTP checks out until the approved
|
||||
-- request is exchanged for a session (consumed_at) or expires.
|
||||
--
|
||||
-- Lifecycle (derived, no state column): pending while approved_at IS NULL,
|
||||
-- approved once ApproveOpLogin stamps approved_at/approved_by, dead once
|
||||
-- consumed_at is set or expires_at passes. Both the approve and the consume
|
||||
-- UPDATE re-check the full liveness predicate, so a double approval or a
|
||||
-- replayed finish is a no-op.
|
||||
CREATE TABLE op_login_requests (
|
||||
id text PRIMARY KEY, -- opaque handle shown to the staff member and typed in-game
|
||||
user_id text NOT NULL REFERENCES users(id), -- the staff account signing in
|
||||
email text NOT NULL, -- snapshot for the audit trail (users.email may change later)
|
||||
expires_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
consumed_at timestamptz, -- set exactly once by the finish path
|
||||
approved_at timestamptz, -- set by the in-game admin's approval
|
||||
approved_by text REFERENCES users(id) -- the approving admin's web account
|
||||
);
|
||||
|
||||
-- ListPendingOpLogins serves the in-game admin's approval prompt: live rows
|
||||
-- only (pending, unconsumed, unexpired), oldest first.
|
||||
CREATE INDEX idx_op_login_requests_pending
|
||||
ON op_login_requests (created_at)
|
||||
WHERE consumed_at IS NULL AND approved_at IS NULL;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Global passwordless: retire the 0003 password columns.
|
||||
-- The product no longer has a password anywhere — web sessions are minted only
|
||||
-- by the passwordless doors (passkey, email-OTP, bind code, op-login vouch) and
|
||||
-- `felis breakGlass` hands the Owner a one-time setup URL instead of a
|
||||
-- credential. No code path reads or writes these columns any more, so keeping
|
||||
-- them would preserve stale bcrypt material for an auth model that cannot use
|
||||
-- it. Dropping the hashes is deliberate and irreversible: it guarantees no
|
||||
-- legacy password can ever authenticate again.
|
||||
ALTER TABLE users
|
||||
DROP COLUMN password_hash,
|
||||
DROP COLUMN must_change_password;
|
||||
Reference in new issue
Block a user