feat: configure authenticated player entry routes
This commit is contained in:
24 files changed
+1044
-17
No files matched your search
@@ -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},
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user