feat: initialize panel access before linking Minecraft accounts

Configure connection and storage before creating or resuming the one-time Owner login. Remove Minecraft prerequisites from setup and preserve established login credentials.

Let staff preview and confirm roles from configured authentication sources using the existing account-link storage and game UUID mapping. Retain in-game code proof for players, add client-version and lobby guidance, and support NodePort passkey origins.
This commit is contained in:
Lemon-miaow committed 2026-10-04 03:05:05 +08:00
1 parent 75845d8f58
commit efbbe27629
59 files changed
+1702 -1564

No files matched your search

+5
View File
@@ -643,6 +643,11 @@ func (a *API) externalAPIRoutes() []apiRoute {
// authenticated operation.
{Method: "POST", Pattern: "/api/v1/account/link/start", SetupAllowed: true, h: a.handleLinkStart},
{Method: "POST", Pattern: "/api/v1/account/link/verify", SetupAllowed: true, h: a.handleLinkVerify},
// Staff can designate their own game identity after panel setup. Players
// retain the in-game proof flow above.
{Method: "GET", Pattern: "/api/v1/account/link/sources", Admin: true, h: a.handleLinkSources},
{Method: "GET", Pattern: "/api/v1/account/link/profile", Admin: true, h: a.handleLookupProfile},
{Method: "POST", Pattern: "/api/v1/account/link/profile", Admin: true, h: a.handleLinkProfile},
// Email verification (spec §B2 onboarding), web side: /start mints+delivers a
// one-time code for the caller's chosen address, /verify redeems it and flips
// email_verified. App-tier like the link routes — proving control of your own
+191
View File
@@ -0,0 +1,191 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strings"
"github.com/google/uuid"
)
type linkedProfile struct {
Source string `json:"source"`
Name string `json:"name"`
ProfileUUID string `json:"profile_uuid"`
MCUUID string `json:"mc_uuid"`
AuthSource string `json:"auth_source"`
}
func profileAPIBase(src AuthSource) string {
if src.APIURL != "" {
return strings.TrimRight(src.APIURL, "/")
}
const suffix = "/sessionserver/session/minecraft/hasJoined"
if strings.HasSuffix(src.URL, suffix) {
return strings.TrimSuffix(src.URL, suffix)
}
return ""
}
func (a *API) handleLinkSources(w http.ResponseWriter, r *http.Request) {
type sourceView struct {
Tag string `json:"tag"`
LookupAvailable bool `json:"lookup_available"`
}
sources := make([]sourceView, 0, len(a.AuthSources))
for _, src := range a.AuthSources {
sources = append(sources, sourceView{src.Tag, src.Identity || profileAPIBase(src) != ""})
}
writeJSON(w, http.StatusOK, map[string]any{"sources": sources})
}
func (a *API) handleLookupProfile(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
profile, err := a.lookupProfile(r.Context(), q.Get("source"), q.Get("profile"))
if err != nil {
writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, profile)
}
// handleLinkProfile is a staff designation, not proof of game-account ownership.
// The user must already have panel authority and a fresh login factor. A client
// supplies only the selected source and native role UUID; mapping and target user
// are determined on the server.
func (a *API) handleLinkProfile(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
if !a.requireReauth(w, r, p) {
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var req struct {
Source string `json:"source"`
ProfileUUID string `json:"profile_uuid"`
}
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
if _, err := uuid.Parse(req.ProfileUUID); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "profile_uuid must be a role UUID"))
return
}
profile, err := a.lookupProfile(r.Context(), req.Source, req.ProfileUUID)
if err != nil {
writeError(w, r, err)
return
}
err = a.Repo.LinkAccount(r.Context(), p.UserID, profile.MCUUID, profile.AuthSource)
if errors.Is(err, ErrConflict) {
writeError(w, r, newError(http.StatusConflict, "already_linked", "that Minecraft role is linked to another user"))
return
}
if err != nil {
writeError(w, r, err)
return
}
a.audit(r, "account.link_profile", "")
writeJSON(w, http.StatusOK, map[string]any{"linked": true, "mc_uuid": profile.MCUUID, "auth_source": profile.AuthSource})
}
func (a *API) lookupProfile(ctx context.Context, source, input string) (*linkedProfile, error) {
input = strings.TrimSpace(input)
id, idErr := uuid.Parse(input)
if idErr != nil && !mcUsernameRe.MatchString(input) {
return nil, newError(http.StatusBadRequest, "bad_request", "provide a Minecraft role name or UUID")
}
var src AuthSource
found := false
for _, candidate := range a.AuthSources {
if candidate.Tag == source {
src, found = candidate, true
break
}
}
if !found {
return nil, newError(http.StatusBadRequest, "auth_source_unknown", "select a configured authentication source")
}
var target, method string
var body io.Reader
if src.Identity {
method = http.MethodGet
if idErr == nil {
target = strings.TrimSuffix(src.URL, "/hasJoined") + "/profile/" + strings.ReplaceAll(id.String(), "-", "")
} else {
target = mojangProfileAPI + url.PathEscape(input)
}
} else {
base := profileAPIBase(src)
if base == "" {
return nil, newError(http.StatusBadRequest, "auth_source_lookup_unsupported", "this source needs api_url for role lookup; game-code linking is still available")
}
if idErr == nil {
method, target = http.MethodGet, base+"/sessionserver/session/minecraft/profile/"+strings.ReplaceAll(id.String(), "-", "")
} else {
method, target = http.MethodPost, base+"/api/profiles/minecraft"
encoded, _ := json.Marshal([]string{input})
body = bytes.NewReader(encoded)
}
}
req, err := http.NewRequestWithContext(ctx, method, target, body)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
// Use the same bounded, redirect-free client as game authentication.
resp, err := authHTTPClient.Do(req)
if err != nil {
return nil, newError(http.StatusServiceUnavailable, "auth_source_unavailable", "the selected authentication source is unavailable")
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusNotFound {
return nil, newError(http.StatusNotFound, "minecraft_profile_not_found", "no role matched in the selected source")
}
if resp.StatusCode != http.StatusOK {
return nil, newError(http.StatusServiceUnavailable, "auth_source_unavailable", "the selected authentication source returned HTTP %d", resp.StatusCode)
}
decoder := json.NewDecoder(io.LimitReader(resp.Body, 1<<16))
var profile sessionProfile
if method == http.MethodPost {
var profiles []sessionProfile
if err := decoder.Decode(&profiles); err != nil {
return nil, newError(http.StatusBadGateway, "auth_source_unavailable", "invalid profile response")
}
for _, candidate := range profiles {
if strings.EqualFold(candidate.Name, input) {
profile = candidate
break
}
}
if profile.ID == "" {
return nil, newError(http.StatusNotFound, "minecraft_profile_not_found", "no role matched in the selected source")
}
} else if err := decoder.Decode(&profile); err != nil {
return nil, newError(http.StatusBadGateway, "auth_source_unavailable", "invalid profile response")
}
profileID, err := uuid.Parse(profile.ID)
if err != nil || !mcUsernameRe.MatchString(profile.Name) ||
(idErr == nil && profileID != id) || (idErr != nil && !strings.EqualFold(profile.Name, input)) {
return nil, newError(http.StatusBadGateway, "auth_source_unavailable", "the source returned a mismatched or invalid role")
}
canonical, err := canonicalProfileUUID(src, profile.ID)
if err != nil {
return nil, err
}
authSource := authSourceThirdParty
if src.Identity {
authSource = authSourceMojang
}
return &linkedProfile{Source: src.Tag, Name: profile.Name, ProfileUUID: profile.ID, MCUUID: canonical.String(), AuthSource: authSource}, nil
}
@@ -0,0 +1,168 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/google/uuid"
)
func TestStaffProfileDesignation(t *testing.T) {
const native = "123456781234423482341234567890ab"
stubMojangNames(t)
upstreamCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamCalls++
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/api/profiles/minecraft":
var names []string
if err := json.NewDecoder(r.Body).Decode(&names); err != nil || len(names) != 1 || names[0] != "LemonMiaow" {
t.Errorf("lookup names = %v err = %v", names, err)
}
_, _ = w.Write([]byte(`[{"id":"` + native + `","name":"LemonMiaow"}]`))
case strings.HasPrefix(r.URL.Path, "/sessionserver/session/minecraft/profile/"):
_, _ = w.Write([]byte(`{"id":"` + native + `","name":"LemonMiaow"}`))
case strings.HasSuffix(r.URL.Path, "/hasJoined"):
_, _ = w.Write([]byte(`{"id":"` + native + `","name":"LemonMiaow"}`))
default:
w.WriteHeader(http.StatusNoContent)
}
}))
defer server.Close()
repo := newFakeRepo()
repo.seedUser(UserView{ID: "owner", Username: "owner", Role: "owner"})
a := newTestAPI(repo, newFakeCluster())
p := &Principal{UserID: "owner", Role: "owner", ViaAdminAccess: true}
a.External = staticExternal{p: p}
src := AuthSource{Tag: "littleskin", Prefix: "LS", URL: server.URL + "/sessionserver/session/minecraft/hasJoined"}
a.AuthSources = []AuthSource{src, {Tag: "custom", URL: server.URL + "/custom-check"}}
h := a.ExternalHandler()
lookup := func(source, profile string) *httptest.ResponseRecorder {
return do(h, "GET", "/api/v1/account/link/profile?"+url.Values{"source": {source}, "profile": {profile}}.Encode(), "", nil)
}
w := do(h, "GET", "/api/v1/account/link/sources", "", nil)
if w.Code != http.StatusOK || strings.Contains(w.Body.String(), server.URL) {
t.Fatalf("sources = %d %s", w.Code, w.Body.String())
}
w = lookup("littleskin", "LemonMiaow")
if w.Code != http.StatusOK {
t.Fatalf("lookup = %d %s", w.Code, w.Body.String())
}
var profile linkedProfile
if err := json.Unmarshal(w.Body.Bytes(), &profile); err != nil {
t.Fatal(err)
}
canonical, _ := canonicalProfileUUID(src, native)
if profile.MCUUID != canonical.String() || profile.AuthSource != "thirdparty" || len(repo.links) != 0 {
t.Fatalf("preview = %+v links = %v", profile, repo.links)
}
game := httptest.NewRecorder()
a.handleHasJoined(game, httptest.NewRequest("GET", "/session/minecraft/hasJoined?username=LemonMiaow&serverId=abc123", nil))
var authenticated sessionProfile
if err := json.Unmarshal(game.Body.Bytes(), &authenticated); err != nil || game.Code != http.StatusOK || authenticated.ID != strings.ReplaceAll(profile.MCUUID, "-", "") {
t.Fatalf("preview differs from game identity: %d %s, err = %v", game.Code, game.Body.String(), err)
}
body := `{"source":"littleskin","profile_uuid":"` + native + `"}`
w = do(h, "POST", "/api/v1/account/link/profile", body, jsonHeader)
if w.Code != http.StatusOK || repo.links[canonical.String()] != "owner" {
t.Fatalf("bind = %d %s links = %v", w.Code, w.Body.String(), repo.links)
}
// Server-side designation is idempotent and never consumes game link codes.
w = do(h, "POST", "/api/v1/account/link/profile", body, jsonHeader)
if w.Code != http.StatusOK || len(repo.links) != 1 {
t.Fatalf("repeat = %d %s", w.Code, w.Body.String())
}
repo.links[canonical.String()] = "another-user"
w = do(h, "POST", "/api/v1/account/link/profile", body, jsonHeader)
if w.Code != http.StatusConflict || repo.links[canonical.String()] != "another-user" {
t.Fatal("designation overwrote another user")
}
w = lookup("custom", "LemonMiaow")
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "auth_source_lookup_unsupported" {
t.Fatalf("custom = %d %s", w.Code, w.Body.String())
}
before := upstreamCalls
w = lookup("unknown", "LemonMiaow")
if w.Code != http.StatusBadRequest || upstreamCalls != before {
t.Fatal("unknown source queried an upstream")
}
w = do(h, "POST", "/api/v1/account/link/profile", `{"source":"littleskin","profile_uuid":"`+native+`","user_id":"victim"}`, jsonHeader)
if w.Code != http.StatusBadRequest {
t.Fatal("caller can select another account")
}
// A local staff session must prove its factor before designating a role.
p.ViaSession = true
repo.passkeyCreds["owner-key"] = PasskeyCredential{ID: "owner-key", UserID: p.UserID, UserVerified: true}
before = upstreamCalls
w = do(h, "POST", "/api/v1/account/link/profile", body, jsonHeader)
if w.Code != http.StatusForbidden || decodeErr(t, w) != "reauth_required" || upstreamCalls != before {
t.Fatalf("stale staff session = %d %s", w.Code, w.Body.String())
}
p.ReauthAt = a.now()
repo.links[canonical.String()] = "owner"
w = do(h, "POST", "/api/v1/account/link/profile", body, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("proven staff session = %d %s", w.Code, w.Body.String())
}
// A player's session never reaches either lookup or designation.
p.Role = "user"
before = upstreamCalls
for _, w := range []*httptest.ResponseRecorder{lookup("littleskin", "LemonMiaow"), do(h, "POST", "/api/v1/account/link/profile", body, jsonHeader)} {
if w.Code != http.StatusForbidden {
t.Fatalf("player route = %d %s", w.Code, w.Body.String())
}
}
if upstreamCalls != before {
t.Fatal("player reached role lookup")
}
}
func TestProfileLookupRejectsInvalidResponses(t *testing.T) {
const native = "123456781234423482341234567890ab"
for _, tc := range []struct {
name, body string
status, want int
}{
{"missing", "", 204, 404},
{"unavailable", "", 503, 503},
{"invalid JSON", "broken", 200, 502},
{"different UUID", `{"id":"223456781234423482341234567890ab","name":"LemonMiaow"}`, 200, 502},
{"invalid name", `{"id":"` + native + `","name":"invalid name"}`, 200, 502},
{"redirect", "", 302, 503},
} {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(tc.body))
}))
defer server.Close()
a := newTestAPI(newFakeRepo(), newFakeCluster())
a.AuthSources = []AuthSource{{Tag: "test", APIURL: server.URL}}
_, err := a.lookupProfile(t.Context(), "test", native)
var apiErr *apiError
if !errors.As(err, &apiErr) || apiErr.status != tc.want {
t.Fatalf("lookup err = %v, want %d", err, tc.want)
}
})
}
}
func TestOfficialProfileUUIDIsPreserved(t *testing.T) {
const native = "123456781234423482341234567890ab"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"id":"` + native + `","name":"LemonMiaow"}`))
}))
defer server.Close()
a := newTestAPI(newFakeRepo(), newFakeCluster())
a.AuthSources = []AuthSource{{Tag: "mojang", Identity: true, URL: server.URL + "/session/minecraft/hasJoined"}}
profile, err := a.lookupProfile(t.Context(), "mojang", native)
if err != nil || profile.MCUUID != uuid.MustParse(native).String() || profile.AuthSource != "mojang" {
t.Fatalf("profile = %+v err = %v", profile, err)
}
}
+2 -3
View File
@@ -5,14 +5,13 @@ import (
"net/http"
)
// Pre-session install-state probe. Until `felis setup` binds an Owner, local sign-in is
// Pre-session install-state probe. Until `felis setup` creates an Owner, local sign-in is
// off and every login door answers 403 local_auth_disabled, so the sign-in page would
// offer four doors that all fail. This Public route lets the page say instead that no
// Owner exists yet and how to bind one.
//
// It discloses one bit: whether the install is still unclaimed. Claiming it needs root
// on the host (`felis setup` or the break-glass console) plus a Minecraft join whose
// link code is typed into that terminal; no web door works before then, so knowing the
// on the host (`felis setup` or the break-glass console); no web door works before then, so knowing the
// bit gives a remote caller nothing to act on. It must answer while local auth is off,
// so unlike its sibling doors it is not gated on local_auth_enabled.
//
+16 -10
View File
@@ -78,6 +78,7 @@ type AuthSource struct {
Tag string
Prefix string
URL string
APIURL string // optional Yggdrasil API root for role lookup
Identity bool
}
@@ -145,15 +146,12 @@ func (a *API) handleHasJoined(w http.ResponseWriter, r *http.Request) {
// nor onto another source's. resolveHasJoined has already screened both shapes and
// skipped unusable ones as failed; the two guards below are the last line before
// anything leaves, kept even though nothing reaches them.
var canonical uuid.UUID
if src.Identity {
id, err := uuid.Parse(prof.ID)
if err != nil {
w.WriteHeader(http.StatusNoContent)
return
}
canonical = id
} else {
canonical, err := canonicalProfileUUID(src, prof.ID)
if err != nil {
w.WriteHeader(http.StatusNoContent)
return
}
if !src.Identity {
// A third-party source is untrusted input, its name included: nothing stops a
// hostile or sloppy root from answering with "§4admin", an empty string, or 200
// characters, all of which must not be relayed straight into the proxy's player
@@ -162,7 +160,6 @@ func (a *API) handleHasJoined(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
return
}
canonical = uuid.NewMD5(felisAuthNS, []byte(src.Tag+":"+prof.ID))
// Give a Mojang player's name back to the Mojang player. The UUID rewrite above
// already keeps the two apart as identities, but the proxy's player registry is
@@ -224,6 +221,15 @@ func prefixedName(prefix, name string) string {
return p + name
}
// canonicalProfileUUID is shared by game login and staff-initiated role binding.
// Keep the source's native ID byte-for-byte: existing third-party identities use it.
func canonicalProfileUUID(src AuthSource, nativeID string) (uuid.UUID, error) {
if src.Identity {
return uuid.Parse(nativeID)
}
return uuid.NewMD5(felisAuthNS, []byte(src.Tag+":"+nativeID)), nil
}
// mojangProfileAPI answers the one question that decides a rename: is this username
// registered to a Mojang account? A var, not a const, so a test can point it at a stub
// instead of the real Mojang.
+2 -2
View File
@@ -8,7 +8,7 @@ import (
"strings"
)
// Setup-token redemption (spec §B setup bootstrap). The `felis setup` MC-bind
// Setup-token redemption (spec §B setup bootstrap). The `felis setup` host bootstrap
// flow mints a one-time token and prints a URL like:
//
// https://op.console.<root>/setup?token=<raw>
@@ -58,7 +58,7 @@ func (a *API) handleSetupRedeem(w http.ResponseWriter, r *http.Request) {
}
// Hash the raw token — only the hash is stored (mirroring session cookies and
// setup token creation in performSetupMCBind).
// setup token creation in performSetupOwner).
sum := sha256.Sum256([]byte(token))
tokenHash := hex.EncodeToString(sum[:])
+34 -59
View File
@@ -264,86 +264,61 @@ func (p *PGRepo) RedeemPlayerBindCode(ctx context.Context, newUserID, code strin
return userID, mcUUID, authSource, nil
}
// CompleteOwnerSetup consumes an in-game link code, creates-or-promotes the bound
// account to the passwordless Owner (role='owner'), enables local auth, and stores
// the one-time first-login token in one transaction. It is the `felis setup`
// MC-bind path: the operator enters limbo, runs /link, and types the code here.
// Unlike RedeemPlayerBindCode — which refuses an already-staff account so a game
// login can never self-elevate — this DELIBERATELY elevates: an unlinked UUID is
// born directly as staff, and an already-linked account (player OR staff) is
// promoted in place, preserving its id so any live sessions and its username
// survive. The elevation is gated by the caller's local-root break-glass
// authority, not by anything in-band. Returns the Owner's (userID, mcUUID,
// authSource); an absent or expired code is ErrLinkCodeInvalid and consumes
// nothing. Any failure in the auth-toggle or token writes rolls the elevation and
// code consumption back, leaving the operator able to retry setup.
func (p *PGRepo) CompleteOwnerSetup(ctx context.Context, newUserID, code string, now time.Time,
tokenHash string, tokenExpiresAt time.Time) (string, string, string, error) {
// CompleteOwnerSetup creates the first Owner and a one-time panel login under
// host-root authority. An unfinished setup renews the link without resetting the
// account; an Owner with a login factor is left untouched (ErrConflict).
func (p *PGRepo) CompleteOwnerSetup(ctx context.Context, newUserID string, now time.Time,
tokenHash string, tokenExpiresAt time.Time) (string, string, error) {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return "", "", "", err
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
// Serialize first-run creation as well as link renewal, including the case
// where there is no user row to lock yet.
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext('felis.owner_setup'))`); err != nil {
return "", "", err
}
// Create-or-promote keyed on the verified UUID. An unlinked UUID births a fresh
// staff row (role='owner') with a uuid-derived username; an already-linked
// account is promoted to role='owner' in place (idempotent when it already is),
// keeping its id and username. Setup elevates on purpose, so there is no staff
// refusal here — that guard belongs to the player path only.
userID := newUserID
switch err := tx.QueryRowContext(ctx,
`SELECT user_id FROM account_links WHERE mc_uuid = $1`, mcUUID).Scan(&userID); {
userID, username := newUserID, "owner"
var onboarded bool
err = tx.QueryRowContext(ctx,
`SELECT u.id, u.username, u.email_verified OR EXISTS (
SELECT 1 FROM webauthn_credentials c WHERE c.user_id = u.id)
FROM users u WHERE u.role IN ('owner', 'admin') AND u.deleted_at IS NULL
ORDER BY (u.role = 'owner') DESC, u.created_at, u.id LIMIT 1 FOR UPDATE`,
).Scan(&userID, &username, &onboarded)
switch {
case errors.Is(err, sql.ErrNoRows):
if _, err := tx.ExecContext(ctx,
`INSERT INTO users (id, username, role, created_at) VALUES ($1, $2, 'owner', $3)`,
newUserID, mcUUID, now); err != nil {
return "", "", "", fmt.Errorf("create owner: %w", err)
newUserID, username, now); err != nil {
return "", "", fmt.Errorf("create owner: %w", err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO account_links (user_id, mc_uuid, auth_source, verified_at) VALUES ($1, $2, $3, $4)`,
newUserID, mcUUID, authSource, now); err != nil {
return "", "", "", fmt.Errorf("write account link: %w", err)
}
userID = newUserID
case err != nil:
return "", "", "", err
default:
if _, err := tx.ExecContext(ctx,
`UPDATE users SET role = 'owner' WHERE id = $1`, userID); err != nil {
return "", "", "", fmt.Errorf("promote owner: %w", err)
}
return "", "", err
case onboarded:
return userID, username, ErrConflict
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO platform_settings (key, value, updated_at) VALUES ($1, $2::jsonb, $3)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at`,
LocalAuthEnabledKey, "true", now); err != nil {
return "", "", "", fmt.Errorf("enable local auth: %w", err)
return "", "", fmt.Errorf("enable local auth: %w", err)
}
// A renewed link replaces any unused links for this account.
if _, err := tx.ExecContext(ctx, `DELETE FROM setup_tokens WHERE user_id = $1`, userID); err != nil {
return "", "", err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO setup_tokens (token_hash, user_id, expires_at, created_at) VALUES ($1, $2, $3, $4)`,
tokenHash, userID, tokenExpiresAt, now); err != nil {
return "", "", "", fmt.Errorf("mint setup token: %w", err)
}
if _, err := tx.ExecContext(ctx,
`DELETE FROM account_link_codes WHERE code = $1`, code); err != nil {
return "", "", "", fmt.Errorf("consume link code: %w", err)
return "", "", fmt.Errorf("mint setup token: %w", err)
}
if err := tx.Commit(); err != nil {
return "", "", "", err
return "", "", err
}
return userID, mcUUID, authSource, nil
return userID, username, nil
}
// QuotaCheck reports whether accepting a server with resource spec `incoming`
@@ -3008,8 +2983,8 @@ func (p *PGRepo) RedeemSetupToken(ctx context.Context, tokenHash string, now tim
}
// CreateSetupToken persists a one-time first-web-login token, storing only its
// hash (the raw value rides in the /setup?token=... URL). The setup Owner-bind
// path uses CompleteOwnerSetup so identity binding, local auth, and this token
// hash (the raw value rides in the /setup?token=... URL). The setup Owner-creation
// path uses CompleteOwnerSetup so Owner creation, local auth, and this token
// commit atomically; this lower-level helper remains for callers that already
// established the user. The token is redeemed exactly once by RedeemSetupToken.
func (p *PGRepo) CreateSetupToken(ctx context.Context, tokenHash, userID string, expiresAt time.Time) error {
+10
View File
@@ -62,6 +62,8 @@ type AuthSourceConfig struct {
Tag string `toml:"tag"`
Prefix string `toml:"prefix"`
URL string `toml:"url"`
// APIURL is optional for sources whose hasJoined URL does not use the standard path.
APIURL string `toml:"api_url"`
}
// SMTPConfig is the [smtp] table: the outbound mail relay felis-api delivers
@@ -149,6 +151,9 @@ type VelocityConfig struct {
// FELIS_GAME_PORT). The panel adds it to the server addresses players copy
// when it is not Minecraft's default; 0 means that default, 25565.
GamePort int `toml:"game_port"`
// GameVersion is the login/lobby protocol built by bootstrap. Empty means
// unknown for custom images; the panel must not guess a client version.
GameVersion string `toml:"game_version"`
}
// AuthConfig is the [auth] table: the two privileged faces and the Cloudflare
@@ -721,6 +726,11 @@ func (c *Config) validateAuthSources() error {
if problem := hasJoinedURLProblem(s.URL); problem != "" {
return fmt.Errorf("config: [[auth_source]] %q url %q %s", s.Tag, s.URL, problem)
}
if s.APIURL != "" {
if problem := hasJoinedURLProblem(s.APIURL); problem != "" {
return fmt.Errorf("config: [[auth_source]] %q api_url %q %s", s.Tag, s.APIURL, problem)
}
}
}
return nil
}
+22
View File
@@ -501,6 +501,28 @@ func TestLoadRejectsUnqueryableAuthSourceURL(t *testing.T) {
}
}
func TestAuthSourceProfileAPIURL(t *testing.T) {
for _, tc := range []struct {
url string
valid bool
}{
{"https://ygg.example.net/api/yggdrasil", true},
{"http://127.0.0.1:8080/ygg", true},
{"ygg.example.net/api", false},
{"http://ygg.example.net/api", false},
{"https://ygg.example.net/api?token=x", false},
} {
cfg, err := config.LoadNano(writeTOML(t, "[[auth_source]]\ntag = \"a\"\nprefix = \"AA\"\nurl = \"https://ygg.example.net/custom-check\"\napi_url = \""+tc.url+"\"\n"))
if tc.valid {
if err != nil || cfg.AuthSources[0].APIURL != tc.url {
t.Fatalf("api_url %q = %v, %v", tc.url, cfg, err)
}
} else if err == nil || !strings.Contains(err.Error(), "api_url") {
t.Fatalf("invalid api_url %q: %v", tc.url, err)
}
}
}
// A source reached over plaintext can be answered by anyone on the path, who can then log in
// as any player of that source. Only a same-host or private-network root may skip TLS, and
// that is decided on the literal host, since nothing is resolved at load time.
+7 -3
View File
@@ -26,8 +26,9 @@ type runtimeConfig struct {
PanelHostname string `json:"panelHostname,omitempty"`
AdminHostname string `json:"adminHostname,omitempty"`
// GamePort is the public Minecraft port, absent when it is the default 25565.
GamePort int `json:"gamePort,omitempty"`
Build buildInfo `json:"build"`
GamePort int `json:"gamePort,omitempty"`
GameVersion string `json:"gameVersion,omitempty"`
Build buildInfo `json:"build"`
}
// buildInfo is the resolved build stamp the panel renders in its version badge.
@@ -113,7 +114,7 @@ func parseBuildVersion(raw string) buildInfo {
// right surface (player console vs SysAdmin console) without a rebuild. gamePort
// is the public Minecraft port ([velocity] game_port), which the SPA appends to
// the server addresses players copy; 0 or 25565 leaves them bare.
func Handler(api http.Handler, rootDomain, panelHost, adminHost string, gamePort int, version string, distributed ...bool) http.Handler {
func Handler(api http.Handler, rootDomain, panelHost, adminHost string, gamePort int, gameVersion, version string, distributed ...bool) http.Handler {
files, err := fs.Sub(static, "static")
if err != nil {
panic(err)
@@ -125,6 +126,7 @@ func Handler(api http.Handler, rootDomain, panelHost, adminHost string, gamePort
panelHostname: panelHost,
adminHostname: adminHost,
gamePort: publicGamePort(gamePort),
gameVersion: gameVersion,
build: parseBuildVersion(version),
files: files,
fileServer: http.FileServer(http.FS(files)),
@@ -151,6 +153,7 @@ type handler struct {
panelHostname string
adminHostname string
gamePort int
gameVersion string
build buildInfo
files fs.FS
fileServer http.Handler
@@ -231,6 +234,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
PanelHostname: h.panelHostname,
AdminHostname: h.adminHostname,
GamePort: h.gamePort,
GameVersion: h.gameVersion,
Build: h.build,
})
case h.hasStaticFile(r.URL.Path):
+4 -4
View File
@@ -18,7 +18,7 @@ func TestHandlerServesPanelAndConfig(t *testing.T) {
}
w.WriteHeader(http.StatusTeapot)
})
h := Handler(api, "example.test", "console.example.test", "op.console.example.test", 0, "v1.2.3")
h := Handler(api, "example.test", "console.example.test", "op.console.example.test", 0, "26.3", "v1.2.3")
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
@@ -41,7 +41,7 @@ func TestHandlerServesPanelAndConfig(t *testing.T) {
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
t.Fatalf("decode config: %v", err)
}
if cfg.APIBase != "/api/v1" || cfg.RootDomain != "example.test" {
if cfg.APIBase != "/api/v1" || cfg.RootDomain != "example.test" || cfg.GameVersion != "26.3" {
t.Fatalf("config = %+v", cfg)
}
// The tiering plumbing surfaces the two console hostnames so one bundle can
@@ -69,7 +69,7 @@ func TestHandlerServesPanelAndConfig(t *testing.T) {
// /config.json carries it, except when a bare hostname already reaches the proxy.
func TestHandlerPublishesNonDefaultGamePort(t *testing.T) {
for _, tc := range []struct{ in, want int }{{0, 0}, {25565, 0}, {25570, 25570}} {
h := Handler(http.NotFoundHandler(), "example.test", "", "", tc.in, "v1.2.3")
h := Handler(http.NotFoundHandler(), "example.test", "", "", tc.in, "", "v1.2.3")
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/config.json", nil))
var cfg map[string]any
@@ -115,7 +115,7 @@ func TestParseBuildVersionSplitsBothStampForms(t *testing.T) {
// its hash (and nothing else inline), cannot be framed, and cache by name:
// hashed assets forever, the page itself never without revalidation.
func TestHandlerSetsPageSecurityAndCacheHeaders(t *testing.T) {
h := Handler(http.NotFoundHandler(), "example.test", "", "", 0, "v1.2.3")
h := Handler(http.NotFoundHandler(), "example.test", "", "", 0, "26.3", "v1.2.3")
w := httptest.NewRecorder()
// Through the tunnel: TLS to the origin as well, the edge's scheme in XFP.
+1 -1
View File
@@ -40,7 +40,7 @@ func newPanelHandler(t *testing.T) http.Handler {
api := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
})
return Handler(api, "example.test", "", "", 0, "")
return Handler(api, "example.test", "", "", 0, "", "")
}
func TestGuardServesInterstitialForWeChatNavigation(t *testing.T) {
+14 -9
View File
@@ -126,18 +126,23 @@ func TestLinkWritesStampTheAPIClock(t *testing.T) {
})
t.Run("owner setup", func(t *testing.T) {
mc := testUUID(t)
r, setupDB := setupRepository(t)
id := "usr-clk-owner-" + suffix(t)
tok := "tok-clk-" + suffix(t)
if got, _, _, err := repo.CompleteOwnerSetup(ctx, id, code(mc), now, tok, now.Add(time.Hour)); err != nil || got != id {
t.Fatalf("CompleteOwnerSetup = %q, %v; want %s", got, err, id)
if got, _, err := r.CompleteOwnerSetup(ctx, id, now, tok, now.Add(time.Hour)); err != nil || got != id {
t.Fatalf("setup = %q, %v", got, err)
}
for _, query := range []string{
`SELECT created_at FROM users WHERE id = '` + id + `'`,
`SELECT created_at FROM setup_tokens WHERE token_hash = '` + tok + `'`,
`SELECT updated_at FROM platform_settings WHERE key = '` + api.LocalAuthEnabledKey + `'`,
} {
var stamp time.Time
if err := setupDB.QueryRow(query).Scan(&stamp); err != nil {
t.Fatal(err)
}
wantStamp(t, "owner setup clock", stamp, now)
}
wantStamp(t, "user created_at", createdAt(id), now)
wantStamp(t, "verified_at", linkedAt(mc), now)
wantStamp(t, "setup token created_at",
stampAt(t, `SELECT created_at FROM setup_tokens WHERE token_hash = $1`, tok), now)
wantStamp(t, "local auth updated_at",
stampAt(t, `SELECT updated_at FROM platform_settings WHERE key = $1`, api.LocalAuthEnabledKey), now)
})
}
+152 -15
View File
@@ -1138,25 +1138,162 @@ func TestOwnerProvisioningWritesOwnerRole(t *testing.T) {
}
}
// The setup wizard's MC-bind path establishes THE Owner, so it writes the same
// role as break-glass rather than a plain admin.
func TestCompleteOwnerSetupWritesOwnerRole(t *testing.T) {
// First-owner initialization needs an empty installation, rather than the
// accounts left by other contract tests. Migrate an isolated schema using the
// same database and real migration files.
func setupRepository(t *testing.T) (*api.PGRepo, *sql.DB) {
t.Helper()
schema := "setup_" + suffix(t)
mustExec(t, `CREATE SCHEMA `+schema)
u, err := url.Parse(os.Getenv("FELIS_TEST_PG_URL"))
if err != nil {
t.Fatal(err)
}
q := u.Query()
q.Set("search_path", schema)
u.RawQuery = q.Encode()
drv, err := store.Open(context.Background(), u.String())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { drv.Close(); mustExec(t, `DROP SCHEMA `+schema+` CASCADE`) })
ms, err := store.LoadMigrations()
if err != nil {
t.Fatal(err)
}
if _, err := store.Up(context.Background(), drv, ms); err != nil {
t.Fatal(err)
}
return api.NewPGRepo(drv.DB()), drv.DB()
}
func TestCompleteOwnerSetupContract(t *testing.T) {
ctx := context.Background()
r, setupDB := setupRepository(t)
now := mustNow().Truncate(time.Second)
id, username, err := r.CompleteOwnerSetup(ctx, "first-owner", now, "first-link", now.Add(time.Hour))
if err != nil || id != "first-owner" || username != "owner" {
t.Fatalf("setup = %q, %q, %v", id, username, err)
}
var role string
if err := setupDB.QueryRow(`SELECT role FROM users WHERE id = $1`, id).Scan(&role); err != nil || role != "owner" {
t.Fatalf("role = %q, %v", role, err)
}
linked, err := r.IsLinked(ctx, id)
if err != nil {
t.Fatal(err)
} else if linked {
t.Fatal("first panel login must not create a game binding")
}
// Deliberately fail token insertion. Auth/account state and the old link survive.
if err := r.CreateSetupToken(ctx, "collision", id, now.Add(time.Hour)); err != nil {
t.Fatal(err)
}
// Reusing an existing token hash for a different user forces the insert to fail.
_, err = setupDB.Exec(`INSERT INTO users (id, username, role) VALUES ('other-user', 'other', 'user')`)
if err != nil {
t.Fatal(err)
}
_, err = setupDB.Exec(`UPDATE setup_tokens SET user_id = 'other-user' WHERE token_hash = 'collision'`)
if err != nil {
t.Fatal(err)
}
if _, _, err := r.CompleteOwnerSetup(ctx, "unused-id", now, "collision", now.Add(time.Hour)); err == nil {
t.Fatal("token collision accepted")
}
var count int
if err := setupDB.QueryRow(`SELECT count(*) FROM setup_tokens WHERE token_hash = 'first-link'`).Scan(&count); err != nil || count != 1 {
t.Fatalf("old link lost after rollback: %d %v", count, err)
}
resumed, _, err := r.CompleteOwnerSetup(ctx, "unused-id", now, "renewed-link", now.Add(time.Hour))
if err != nil || resumed != id {
t.Fatalf("resume = %q %v", resumed, err)
}
if err := setupDB.QueryRow(`SELECT count(*) FROM users WHERE role = 'owner'`).Scan(&count); err != nil || count != 1 {
t.Fatalf("duplicate owners: %d %v", count, err)
}
if err := setupDB.QueryRow(`SELECT count(*) FROM setup_tokens WHERE user_id = $1`, id).Scan(&count); err != nil || count != 1 {
t.Fatalf("unused links not replaced: %d %v", count, err)
}
if _, err := setupDB.Exec(`UPDATE users SET email = '[email protected]', email_verified = true WHERE id = $1`, id); err != nil {
t.Fatal(err)
}
if _, _, err := r.CompleteOwnerSetup(ctx, "unused-id", now, "must-not-exist", now.Add(time.Hour)); !errors.Is(err, api.ErrConflict) {
t.Fatalf("settled account = %v", err)
}
if err := setupDB.QueryRow(`SELECT count(*) FROM setup_tokens WHERE token_hash = 'renewed-link'`).Scan(&count); err != nil || count != 1 {
t.Fatal("settled account was changed")
}
}
func TestCompleteOwnerSetupSerializesFirstOwner(t *testing.T) {
r, setupDB := setupRepository(t)
now := mustNow()
mc := testUUID(t)
code := "osc-" + suffix(t)
if err := repo.CreateLinkCode(ctx, code, mc, "mojang", now.Add(10*time.Minute)); err != nil {
t.Fatalf("CreateLinkCode: %v", err)
var wg sync.WaitGroup
errs := make(chan error, 8)
for i := 0; i < 8; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
_, _, err := r.CompleteOwnerSetup(context.Background(), fmt.Sprintf("racer-%d", i), now, fmt.Sprintf("token-%d", i), now.Add(time.Hour))
errs <- err
}(i)
}
newID := "usr-setup-" + suffix(t)
userID, gotUUID, src, err := repo.CompleteOwnerSetup(ctx, newID, code, now,
"tok-"+suffix(t), now.Add(time.Hour))
if err != nil || userID != newID || gotUUID != mc || src != "mojang" {
t.Fatalf("CompleteOwnerSetup = (%s, %s, %s, %v), want (%s, %s, mojang, nil)",
userID, gotUUID, src, err, newID, mc)
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
if role := userRole(t, newID); role != "owner" {
t.Fatalf("CompleteOwnerSetup role = %q, want owner", role)
var count int
if err := setupDB.QueryRow(`SELECT count(*) FROM users WHERE role = 'owner'`).Scan(&count); err != nil || count != 1 {
t.Fatalf("owners = %d %v", count, err)
}
if err := setupDB.QueryRow(`SELECT count(*) FROM setup_tokens`).Scan(&count); err != nil || count != 1 {
t.Fatalf("links = %d %v", count, err)
}
}
func TestCompleteOwnerSetupCreationRollsBack(t *testing.T) {
r, setupDB := setupRepository(t)
now := mustNow()
// Failing the final insert must undo the new Owner and
// local-auth setting as well, not strand an account without a usable link.
if _, err := setupDB.Exec(`ALTER TABLE setup_tokens ADD CONSTRAINT reject_setup_token CHECK (token_hash <> 'rejected')`); err != nil {
t.Fatal(err)
}
if _, _, err := r.CompleteOwnerSetup(context.Background(), "rolled-back", now, "rejected", now.Add(time.Hour)); err == nil {
t.Fatal("failed token insert accepted")
}
var count int
if err := setupDB.QueryRow(`SELECT count(*) FROM users`).Scan(&count); err != nil || count != 0 {
t.Fatalf("partial Owner remained: %d %v", count, err)
}
if err := setupDB.QueryRow(`SELECT count(*) FROM platform_settings WHERE key = $1`, api.LocalAuthEnabledKey).Scan(&count); err != nil || count != 0 {
t.Fatalf("partial local-auth setting remained: %d %v", count, err)
}
}
func TestCompleteOwnerSetupPreservesPasskey(t *testing.T) {
r, setupDB := setupRepository(t)
now := mustNow()
id, _, err := r.CompleteOwnerSetup(context.Background(), "owner-key", now, "first-link", now.Add(time.Hour))
if err != nil {
t.Fatal(err)
}
if _, err := setupDB.Exec(`INSERT INTO webauthn_credentials (id, user_id, credential_id, public_key, name) VALUES ('key', $1, 'credential', 'public-key', 'Laptop')`, id); err != nil {
t.Fatal(err)
}
if got, name, err := r.CompleteOwnerSetup(context.Background(), "unused", now, "new-link", now.Add(time.Hour)); !errors.Is(err, api.ErrConflict) || got != id || name != "owner" {
t.Fatalf("established passkey = %q %q %v", got, name, err)
}
var count int
if err := setupDB.QueryRow(`SELECT count(*) FROM webauthn_credentials WHERE user_id = $1`, id).Scan(&count); err != nil || count != 1 {
t.Fatalf("existing credential changed: %d %v", count, err)
}
if err := setupDB.QueryRow(`SELECT count(*) FROM setup_tokens WHERE token_hash = 'new-link'`).Scan(&count); err != nil || count != 0 {
t.Fatalf("setup created a reset link: %d %v", count, err)
}
}
+2
View File
@@ -371,6 +371,8 @@ func APIDeployment(p Params) *appsv1.Deployment {
}
env = append(env,
corev1.EnvVar{Name: "FELIS_IMAGE", Value: p.FelisImage},
// Passkey origins must include the same public port the Service exposes.
corev1.EnvVar{Name: "FELIS_PANEL_NODEPORT", Value: fmt.Sprint(p.PanelNodePort)},
// The api's own internal-face base URL, so it derives the submission
// context URLs that build Pods fetch through it. Same value the login gate
// is handed; one address for one face.
+9
View File
@@ -331,6 +331,15 @@ func TestAPIService_NodePort(t *testing.T) {
p.PanelNodePort = 30445
svc := apiService(p)
dep := APIDeployment(p)
foundPort := false
for _, env := range dep.Spec.Template.Spec.Containers[0].Env {
if env.Name == "FELIS_PANEL_NODEPORT" {
foundPort = env.Value == "30445"
}
}
if !foundPort {
t.Fatal("API passkey origins do not know the Service's public NodePort")
}
if svc.Name != SAAPI || svc.Namespace != p.ControlNamespace {
t.Errorf("api Service = %s/%s, want %s/%s", svc.Namespace, svc.Name, p.ControlNamespace, SAAPI)