feat: configure authenticated player entry routes

This commit is contained in:
Lemon-miaow committed 2026-10-07 17:19:20 +08:00
1 parent 07973a4bbd
commit 20994be996
24 files changed
+1044 -17

No files matched your search

+3
View File
@@ -437,6 +437,7 @@ func (a *API) internalAPIRoutes() []apiRoute {
// and keyed by the verified UUID (not the scanned code), so it consumes nothing
// and is safe to poll repeatedly.
{Method: "GET", Pattern: "/api/v1/internal/account/link/status/{mc_uuid}", Callers: gate, h: a.handleLinkStatus},
{Method: "GET", Pattern: "/api/v1/internal/settings/entry-policy", Callers: gate, h: a.handleGetEntryPolicy},
// Account migration (spec §B3 inherit), in-game side: /felis migrate puts the
// account linked to the running player's verified UUID into migrate mode. Internal
// only — the initiator is proven by online-mode auth, and the sensitive proof
@@ -655,6 +656,8 @@ func (a *API) externalAPIRoutes() []apiRoute {
{Method: "POST", Pattern: "/api/v1/settings/node-control/tasks", Owner: true, Admin: true, h: a.handleStartNodeTask},
{Method: "GET", Pattern: "/api/v1/settings/node-control/tasks/{id}", Owner: true, Admin: true, h: a.handleNodeTask},
{Method: "POST", Pattern: "/api/v1/settings/node-control/tasks/{id}/retry", Owner: true, Admin: true, h: a.handleRetryNodeTask},
{Method: "GET", Pattern: "/api/v1/settings/entry-policy", Owner: true, Admin: true, h: a.handleGetEntryPolicy},
{Method: "PUT", Pattern: "/api/v1/settings/entry-policy", Owner: true, Admin: true, h: a.handleSetEntryPolicy},
{Method: "GET", Pattern: "/api/v1/settings/wake-policy", Owner: true, Admin: true, h: a.handleGetWakePolicy},
{Method: "PUT", Pattern: "/api/v1/settings/wake-policy", Owner: true, Admin: true, h: a.handleSetWakePolicy},
{Method: "GET", Pattern: "/api/v1/settings/auth-sources", Owner: true, Admin: true, h: a.handleGetAuthSources},
+137
View File
@@ -0,0 +1,137 @@
package api
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"felis.lolicon.best/internal/naming"
)
const entryPolicyKey = "player_entry_policy"
type entryPolicy struct {
Mode string `json:"mode"`
DefaultServer string `json:"defaultServer"`
RequireAccountLink bool `json:"requireAccountLink"`
OfflineAction string `json:"offlineAction"`
WaitingSpace string `json:"waitingSpace"`
FallbackServer string `json:"fallbackServer"`
}
type entryPolicyView struct {
entryPolicy
Revision string `json:"revision"`
}
func defaultEntryPolicy() entryPolicy {
// Preserve existing host routing and web association until the Owner saves a policy.
return entryPolicy{Mode: "domain", RequireAccountLink: true, OfflineAction: "wake", WaitingSpace: "lobby"}
}
func (p entryPolicy) valid() bool {
if p.Mode != "lobby" && p.Mode != "direct" && p.Mode != "domain" {
return false
}
if p.OfflineAction != "wake" && p.OfflineAction != "fallback" && p.OfflineAction != "disconnect" {
return false
}
if p.WaitingSpace != "login" && p.WaitingSpace != "lobby" {
return false
}
if p.RequireAccountLink && p.WaitingSpace != "lobby" {
return false
}
if p.Mode == "direct" && p.DefaultServer == "" {
return false
}
if p.OfflineAction == "fallback" && (p.FallbackServer == "" || p.FallbackServer == p.DefaultServer) {
return false
}
for _, name := range []string{p.DefaultServer, p.FallbackServer} {
if name != "" && (naming.ValidateServerName(name) != nil || naming.IsSystemServer(name)) {
return false
}
}
return true
}
func (a *API) readEntryPolicy(ctx context.Context) (entryPolicyView, []byte, error) {
view := entryPolicyView{entryPolicy: defaultEntryPolicy()}
raw, err := a.Repo.GetSetting(ctx, entryPolicyKey)
if errors.Is(err, ErrNotFound) {
raw = nil
} else if err != nil {
return view, nil, err
} else if err = json.Unmarshal(raw, &view.entryPolicy); err != nil {
return view, nil, err
}
if !view.entryPolicy.valid() {
return view, nil, errors.New("invalid player entry policy")
}
canonical, _ := json.Marshal(view.entryPolicy)
sum := sha256.Sum256(canonical)
view.Revision = hex.EncodeToString(sum[:])
return view, raw, nil
}
func (a *API) handleGetEntryPolicy(w http.ResponseWriter, r *http.Request) {
view, _, err := a.readEntryPolicy(r.Context())
if err != nil {
writeError(w, r, err)
return
}
writeJSON(w, 200, view)
}
func (a *API) handleSetEntryPolicy(w http.ResponseWriter, r *http.Request) {
if !a.requireReauth(w, r, principalFromContext(r.Context())) {
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var body entryPolicyView
if err := decodeJSON(w, r, &body); err != nil {
writeError(w, r, err)
return
}
if !body.entryPolicy.valid() {
writeError(w, r, newError(400, "bad_request", "invalid entry mode, target or offline policy"))
return
}
for _, name := range []string{body.DefaultServer, body.FallbackServer} {
if name == "" {
continue
}
if _, err := a.Cluster.GetServer(r.Context(), name); err != nil {
a.writeLookupError(w, r, err)
return
}
}
current, expected, err := a.readEntryPolicy(r.Context())
if err != nil {
writeError(w, r, err)
return
}
if body.Revision != current.Revision {
writeError(w, r, newError(409, "conflict", "player entry policy changed; reload before saving"))
return
}
raw, _ := json.Marshal(body.entryPolicy)
if err = a.Repo.CompareAndSetSetting(r.Context(), entryPolicyKey, expected, raw); err != nil {
writeError(w, r, err)
return
}
a.audit(r, "platform.entry_policy", "platform")
view, _, err := a.readEntryPolicy(r.Context())
if err != nil {
writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, view)
}
@@ -0,0 +1,88 @@
package api
import (
"context"
"encoding/json"
"testing"
)
func TestEntryPolicyPersistenceAndAuthorization(t *testing.T) {
repo, cluster := newFakeRepo(), newFakeCluster()
cluster.byName["main"] = &ServerInfo{Name: "main"}
a := newTestAPI(repo, cluster)
path := "/api/v1/settings/entry-policy"
for _, p := range []*Principal{nil, {UserID: "admin", Role: "admin", ViaAdminAccess: true}, {UserID: "owner", Role: "owner"}} {
a.External = staticExternal{p: p}
for _, method := range []string{"GET", "PUT"} {
if w := do(a.ExternalHandler(), method, path, `{}`, jsonHeader); w.Code != 401 && w.Code != 403 {
t.Fatalf("unauthorized: %d", w.Code)
}
}
}
p := &Principal{UserID: "owner", Role: "owner", ViaAdminAccess: true}
a.External = staticExternal{p: p}
view, _, err := a.readEntryPolicy(context.Background())
if err != nil || !view.RequireAccountLink || view.Mode != "domain" {
t.Fatal(view, err)
}
save := func(v entryPolicyView) int {
body, _ := json.Marshal(v)
return do(a.ExternalHandler(), "PUT", path, string(body), jsonHeader).Code
}
view.Mode = "direct"
view.DefaultServer = "main"
view.RequireAccountLink = false
view.WaitingSpace = "login"
if code := save(view); code != 200 {
t.Fatal("save", code)
}
if code := save(view); code != 409 {
t.Fatal("stale save", code)
}
replica := newTestAPI(repo, cluster)
persisted, _, err := replica.readEntryPolicy(context.Background())
if err != nil || persisted.Mode != "direct" || persisted.DefaultServer != "main" || persisted.RequireAccountLink {
t.Fatal(persisted, err)
}
persisted.DefaultServer = "missing"
if code := save(persisted); code != 404 {
t.Fatal("missing target", code)
}
p.ViaSession = true
p.EmailVerified = true
repo.passkeyCreds["key"] = PasskeyCredential{ID: "key", UserID: "owner", UserVerified: true}
persisted.DefaultServer = "main"
if code := save(persisted); code != 403 {
t.Fatal("reauth", code)
}
repo.settings[entryPolicyKey] = []byte(`{"mode":"invalid"}`)
if _, _, err := a.readEntryPolicy(context.Background()); err == nil {
t.Fatal("invalid persisted policy accepted")
}
}
func TestEntryPolicyValidation(t *testing.T) {
good := entryPolicy{Mode: "direct", DefaultServer: "main", OfflineAction: "wake", WaitingSpace: "login"}
if !good.valid() {
t.Fatal("valid policy rejected")
}
for _, mutate := range []func(*entryPolicy){
func(p *entryPolicy) { p.Mode = "invalid" }, func(p *entryPolicy) { p.DefaultServer = "" }, func(p *entryPolicy) { p.DefaultServer = "login" }, func(p *entryPolicy) { p.OfflineAction = "fallback" }, func(p *entryPolicy) { p.OfflineAction = "fallback"; p.FallbackServer = "main" }, func(p *entryPolicy) { p.WaitingSpace = "invalid" }, func(p *entryPolicy) { p.RequireAccountLink = true },
} {
p := good
mutate(&p)
if p.valid() {
t.Fatal("invalid policy accepted", p)
}
}
}
func TestEntryPolicyInternalCallerBoundary(t *testing.T) {
a := newTestAPI(newFakeRepo(), newFakeCluster())
a.Internal = CallerTokens{CallerVelocity: "proxy", CallerLimbo: "login", CallerBuild: "build"}
for token, want := range map[string]int{"proxy": 200, "login": 200, "build": 403, "invalid": 401} {
if w := do(a.InternalHandler(), "GET", "/api/v1/internal/settings/entry-policy", "", map[string]string{"Authorization": "Bearer " + token}); w.Code != want {
t.Fatal(token, w.Code, w.Body.String())
}
}
}