feat(auth): add Owner authentication source settings
Manage Yggdrasil providers from the panel using durable platform settings, protected identity namespaces and atomic revisions. Apply changes to subsequent logins and profile lookups without restarting. Return operator-host logouts to the login method selection page.
This commit is contained in:
33 files changed
+1466
-18
No files matched your search
+5
-1
@@ -201,7 +201,8 @@ type API struct {
|
||||
// first for 正版优先). Nil makes the session verifier reject every login (204);
|
||||
// cmd/felis always wires at least the Mojang source through authSourcesFromConfig.
|
||||
// Consumed by handleHasJoined (handlers_hasjoined.go).
|
||||
AuthSources []AuthSource
|
||||
AuthSources []AuthSource
|
||||
AuthSourceSettings *AuthSourceSettings
|
||||
|
||||
// AuthDoorLimit bounds how often one client address may call the public
|
||||
// pre-session auth doors (ratelimit.go). MailLimit bounds all mail the API
|
||||
@@ -651,6 +652,9 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
||||
// 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/settings/auth-sources", Owner: true, Admin: true, h: a.handleGetAuthSources},
|
||||
{Method: "PUT", Pattern: "/api/v1/settings/auth-sources", Owner: true, Admin: true, h: a.handleSetAuthSources},
|
||||
{Method: "POST", Pattern: "/api/v1/settings/auth-sources/test", Owner: true, Admin: true, h: a.handleTestAuthSource},
|
||||
{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
|
||||
|
||||
@@ -1231,6 +1231,14 @@ func (f *fakeRepo) SetSetting(_ context.Context, key string, value []byte) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRepo) CompareAndSetSetting(ctx context.Context, key string, expected, value []byte) error {
|
||||
current, exists := f.settings[key]
|
||||
if (expected == nil && exists) || (expected != nil && (!exists || string(current) != string(expected))) {
|
||||
return ErrConflict
|
||||
}
|
||||
return f.SetSetting(ctx, key, value)
|
||||
}
|
||||
|
||||
// ---- user admin fakes ----
|
||||
|
||||
// seededUser is a test-only user row held in the fake repo.
|
||||
|
||||
@@ -37,8 +37,13 @@ func (a *API) handleLinkSources(w http.ResponseWriter, r *http.Request) {
|
||||
Tag string `json:"tag"`
|
||||
LookupAvailable bool `json:"lookup_available"`
|
||||
}
|
||||
sources := make([]sourceView, 0, len(a.AuthSources))
|
||||
for _, src := range a.AuthSources {
|
||||
configured, err := a.currentAuthSources(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
sources := make([]sourceView, 0, len(configured))
|
||||
for _, src := range configured {
|
||||
sources = append(sources, sourceView{src.Tag, src.Identity || profileAPIBase(src) != ""})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"sources": sources})
|
||||
@@ -105,7 +110,11 @@ func (a *API) lookupProfile(ctx context.Context, source, input string) (*linkedP
|
||||
}
|
||||
var src AuthSource
|
||||
found := false
|
||||
for _, candidate := range a.AuthSources {
|
||||
sources, err := a.currentAuthSources(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, candidate := range sources {
|
||||
if candidate.Tag == source {
|
||||
src, found = candidate, true
|
||||
break
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/config"
|
||||
)
|
||||
|
||||
const authSourcesKey = "auth_sources"
|
||||
|
||||
type authSourceEntry struct {
|
||||
config.AuthSourceConfig
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type authSourcesView struct {
|
||||
Sources []authSourceEntry `json:"sources"`
|
||||
Revision string `json:"revision"`
|
||||
Managed bool `json:"managed"`
|
||||
}
|
||||
|
||||
// AuthSourceSettings reuses platform_settings for the full control plane. Nano
|
||||
// keeps its TOML-only sources. A durable override is read for each login, so all
|
||||
// API replicas see the same list and a DB failure never revives a disabled root.
|
||||
type AuthSourceSettings struct {
|
||||
Repo Repo
|
||||
Defaults []AuthSource
|
||||
}
|
||||
|
||||
func validateAuthSourceEntries(entries []authSourceEntry) error {
|
||||
if entries == nil || len(entries) > 32 {
|
||||
return fmt.Errorf("provide a sources array with at most 32 entries")
|
||||
}
|
||||
sources := make([]config.AuthSourceConfig, len(entries))
|
||||
for i, entry := range entries {
|
||||
sources[i] = entry.AuthSourceConfig
|
||||
}
|
||||
return config.ValidateAuthSources(sources)
|
||||
}
|
||||
|
||||
func (s *AuthSourceSettings) read(ctx context.Context) (authSourcesView, []byte, error) {
|
||||
view := authSourcesView{Sources: []authSourceEntry{}}
|
||||
raw, err := s.Repo.GetSetting(ctx, authSourcesKey)
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
for _, source := range s.Defaults {
|
||||
if !source.Identity {
|
||||
view.Sources = append(view.Sources, authSourceEntry{AuthSourceConfig: config.AuthSourceConfig{
|
||||
Tag: source.Tag, Prefix: source.Prefix, URL: source.URL, APIURL: source.APIURL,
|
||||
}, Enabled: true})
|
||||
}
|
||||
}
|
||||
raw = nil
|
||||
case err != nil:
|
||||
return view, nil, err
|
||||
default:
|
||||
if err := json.Unmarshal(raw, &view.Sources); err != nil {
|
||||
return view, nil, fmt.Errorf("auth sources: invalid stored configuration: %w", err)
|
||||
}
|
||||
view.Managed = true
|
||||
}
|
||||
if err := validateAuthSourceEntries(view.Sources); err != nil {
|
||||
return view, nil, err
|
||||
}
|
||||
canonical, _ := json.Marshal(view.Sources)
|
||||
sum := sha256.Sum256(canonical)
|
||||
view.Revision = hex.EncodeToString(sum[:])
|
||||
return view, raw, nil
|
||||
}
|
||||
|
||||
func (a *API) currentAuthSources(ctx context.Context) ([]AuthSource, error) {
|
||||
if a.AuthSourceSettings == nil {
|
||||
return a.AuthSources, nil
|
||||
}
|
||||
view, _, err := a.AuthSourceSettings.read(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sources := make([]AuthSource, 0, len(view.Sources)+1)
|
||||
for _, source := range a.AuthSourceSettings.Defaults {
|
||||
if source.Identity {
|
||||
sources = append(sources, source)
|
||||
}
|
||||
}
|
||||
for _, entry := range view.Sources {
|
||||
if entry.Enabled {
|
||||
sources = append(sources, AuthSource{Tag: entry.Tag, Prefix: entry.Prefix, URL: entry.URL, APIURL: entry.APIURL})
|
||||
}
|
||||
}
|
||||
return sources, nil
|
||||
}
|
||||
|
||||
func (a *API) handleGetAuthSources(w http.ResponseWriter, r *http.Request) {
|
||||
if a.AuthSourceSettings == nil {
|
||||
writeError(w, r, newError(http.StatusServiceUnavailable, "auth_sources_unavailable", "authentication source settings are not configured"))
|
||||
return
|
||||
}
|
||||
view, _, err := a.AuthSourceSettings.read(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (a *API) handleSetAuthSources(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireReauth(w, r, principalFromContext(r.Context())) {
|
||||
return
|
||||
}
|
||||
if a.AuthSourceSettings == nil {
|
||||
writeError(w, r, newError(http.StatusServiceUnavailable, "auth_sources_unavailable", "authentication source settings are not configured"))
|
||||
return
|
||||
}
|
||||
if err := requireJSONContentType(r); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Sources []authSourceEntry `json:"sources"`
|
||||
Revision string `json:"revision"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &body); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := validateAuthSourceEntries(body.Sources); err != nil {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "%s", err))
|
||||
return
|
||||
}
|
||||
view, expected, err := a.AuthSourceSettings.read(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
if body.Revision != view.Revision {
|
||||
writeError(w, r, newError(http.StatusConflict, "auth_sources_changed", "authentication sources changed; reload before saving"))
|
||||
return
|
||||
}
|
||||
tags := make(map[string]bool, len(body.Sources))
|
||||
for _, source := range body.Sources {
|
||||
tags[source.Tag] = true
|
||||
}
|
||||
for _, existing := range view.Sources {
|
||||
if !tags[existing.Tag] {
|
||||
writeError(w, r, newError(http.StatusConflict, "auth_source_tag_locked", "saved source tags are permanent; disable the source instead of removing or renaming it"))
|
||||
return
|
||||
}
|
||||
}
|
||||
value, _ := json.Marshal(body.Sources)
|
||||
err = a.AuthSourceSettings.Repo.CompareAndSetSetting(r.Context(), authSourcesKey, expected, value)
|
||||
if errors.Is(err, ErrConflict) {
|
||||
writeError(w, r, newError(http.StatusConflict, "auth_sources_changed", "authentication sources changed; reload before saving"))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
view.Sources, view.Managed = body.Sources, true
|
||||
sum := sha256.Sum256(value)
|
||||
view.Revision = hex.EncodeToString(sum[:])
|
||||
a.audit(r, "auth_sources.updated", "")
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (a *API) handleTestAuthSource(w http.ResponseWriter, r *http.Request) {
|
||||
if err := requireJSONContentType(r); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
var source authSourceEntry
|
||||
if err := decodeJSON(w, r, &source); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := validateAuthSourceEntries([]authSourceEntry{source}); err != nil {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "%s", err))
|
||||
return
|
||||
}
|
||||
probeID, err := newPasskeyID()
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
// A fresh random serverId has never joined: a healthy hasJoined endpoint
|
||||
// answers 204. Reuse authentication's timeout, TLS and redirect policy.
|
||||
started := time.Now()
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet,
|
||||
source.URL+"?username=FelisProbe&serverId="+url.QueryEscape(probeID), nil)
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
resp, err := authHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
writeError(w, r, newError(http.StatusServiceUnavailable, "auth_source_unavailable", "the authentication endpoint could not be reached"))
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": resp.StatusCode == http.StatusNoContent, "status": resp.StatusCode, "elapsed_ms": time.Since(started).Milliseconds()})
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"felis.lolicon.best/internal/config"
|
||||
)
|
||||
|
||||
const authSourcesPath = "/api/v1/settings/auth-sources"
|
||||
|
||||
func sourceEntry(tag, prefix, endpoint string) authSourceEntry {
|
||||
return authSourceEntry{AuthSourceConfig: config.AuthSourceConfig{Tag: tag, Prefix: prefix, URL: endpoint}, Enabled: true}
|
||||
}
|
||||
|
||||
func seedAuthSourcesAPI() (*API, *fakeRepo) {
|
||||
repo := newFakeRepo()
|
||||
a := newTestAPI(repo, newFakeCluster())
|
||||
a.External = staticExternal{p: &Principal{UserID: "owner", Role: "owner", ViaAdminAccess: true}}
|
||||
a.AuthSources = []AuthSource{{Tag: "mojang", URL: "https://sessionserver.mojang.com/session/minecraft/hasJoined", Identity: true}, {Tag: "littleskin", Prefix: "LS", URL: "https://littleskin.cn/api/yggdrasil/sessionserver/session/minecraft/hasJoined"}}
|
||||
a.AuthSourceSettings = &AuthSourceSettings{Repo: repo, Defaults: a.AuthSources}
|
||||
return a, repo
|
||||
}
|
||||
|
||||
func readAuthSources(t *testing.T, a *API) authSourcesView {
|
||||
t.Helper()
|
||||
w := do(a.ExternalHandler(), "GET", authSourcesPath, "", nil)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("read = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var view authSourcesView
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &view); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func saveAuthSources(a *API, view authSourcesView) *httptest.ResponseRecorder {
|
||||
body, _ := json.Marshal(map[string]any{"sources": view.Sources, "revision": view.Revision})
|
||||
return do(a.ExternalHandler(), "PUT", authSourcesPath, string(body), jsonHeader)
|
||||
}
|
||||
|
||||
func TestAuthSourcesSaveAndRuntime(t *testing.T) {
|
||||
const native = "123456781234423482341234567890ab"
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/official" {
|
||||
w.WriteHeader(204)
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(r.URL.Path, "/profile/"+native) {
|
||||
_, _ = w.Write([]byte(`{"id":"` + native + `","name":"LemonMiaow"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"id":"` + native + `","name":"LemonMiaow"}`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
a, repo := seedAuthSourcesAPI()
|
||||
a.AuthSources[0].URL = upstream.URL + "/official"
|
||||
a.AuthSources[1].URL = upstream.URL + "/sessionserver/session/minecraft/hasJoined"
|
||||
before := readAuthSources(t, a)
|
||||
if before.Managed || len(before.Sources) != 1 || before.Sources[0].Tag != "littleskin" {
|
||||
t.Fatalf("defaults = %+v", before)
|
||||
}
|
||||
before.Sources = append(before.Sources, sourceEntry("other", "OT", upstream.URL+"/other-check"))
|
||||
before.Sources[1].APIURL = upstream.URL
|
||||
before.Sources[0], before.Sources[1] = before.Sources[1], before.Sources[0]
|
||||
w := saveAuthSources(a, before)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("save = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
saved := readAuthSources(t, a)
|
||||
if !saved.Managed || saved.Revision == before.Revision || saved.Sources[0].Tag != "other" {
|
||||
t.Fatalf("saved = %+v", saved)
|
||||
}
|
||||
// Another API replica sees the same order and identity policy without restart.
|
||||
replica := newTestAPI(repo, newFakeCluster())
|
||||
replica.AuthSourceSettings = &AuthSourceSettings{Repo: repo, Defaults: a.AuthSources}
|
||||
sources, err := replica.currentAuthSources(context.Background())
|
||||
if err != nil || len(sources) != 3 || !sources[0].Identity || sources[1].Identity || sources[1].Tag != "other" {
|
||||
t.Fatalf("active = %+v err = %v", sources, err)
|
||||
}
|
||||
profile, src, failed := replica.resolveHasJoined(context.Background(), "LemonMiaow", "joined-session", "")
|
||||
if profile == nil || failed || src.Tag != "other" {
|
||||
t.Fatalf("login = %+v src=%+v failed=%v", profile, src, failed)
|
||||
}
|
||||
preview, err := a.lookupProfile(context.Background(), "other", native)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonical, _ := canonicalProfileUUID(src, native)
|
||||
if preview.MCUUID != canonical.String() {
|
||||
t.Fatal("role lookup and game identity disagree")
|
||||
}
|
||||
// Disabled sources disappear from login priority and role lookup immediately.
|
||||
saved.Sources[0].Enabled = false
|
||||
if w = saveAuthSources(a, saved); w.Code != 200 {
|
||||
t.Fatalf("disable = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
_, src, _ = replica.resolveHasJoined(context.Background(), "LemonMiaow", "joined-session", "")
|
||||
if src.Tag != "littleskin" {
|
||||
t.Fatalf("disabled source still used: %+v", src)
|
||||
}
|
||||
if _, err = a.lookupProfile(context.Background(), "other", native); err == nil {
|
||||
t.Fatal("disabled source remains selectable")
|
||||
}
|
||||
list := do(a.ExternalHandler(), "GET", "/api/v1/account/link/sources", "", nil)
|
||||
if list.Code != 200 || strings.Contains(list.Body.String(), `"other"`) || strings.Contains(list.Body.String(), upstream.URL) {
|
||||
t.Fatalf("public source list = %d %s", list.Code, list.Body.String())
|
||||
}
|
||||
// Durable failure must never resurrect the installation defaults.
|
||||
repo.failGetSetting = errors.New("database unavailable")
|
||||
profile, _, failed = replica.resolveHasJoined(context.Background(), "LemonMiaow", "joined-session", "")
|
||||
if profile != nil || !failed {
|
||||
t.Fatal("DB outage fell back to obsolete configuration")
|
||||
}
|
||||
if _, err = replica.currentAuthSources(context.Background()); err == nil {
|
||||
t.Fatal("settings outage ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthSourcesProtectNamespacesAndRevision(t *testing.T) {
|
||||
a, repo := seedAuthSourcesAPI()
|
||||
initial := readAuthSources(t, a)
|
||||
for _, rename := range []bool{false, true} {
|
||||
v := readAuthSources(t, a)
|
||||
if rename {
|
||||
v.Sources[0].Tag = "renamed"
|
||||
} else {
|
||||
v.Sources = []authSourceEntry{}
|
||||
}
|
||||
w := saveAuthSources(a, v)
|
||||
if w.Code != 409 || decodeErr(t, w) != "auth_source_tag_locked" {
|
||||
t.Fatalf("namespace change = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
initial.Sources[0].Enabled = false
|
||||
if w := saveAuthSources(a, initial); w.Code != 200 {
|
||||
t.Fatalf("save = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
original := string(repo.settings[authSourcesKey])
|
||||
initial.Sources[0].Prefix = "XX"
|
||||
if w := saveAuthSources(a, initial); w.Code != 409 || decodeErr(t, w) != "auth_sources_changed" {
|
||||
t.Fatalf("stale save = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if string(repo.settings[authSourcesKey]) != original {
|
||||
t.Fatal("stale save overwrote durable config")
|
||||
}
|
||||
// Invalid stored data also fails closed.
|
||||
repo.settings[authSourcesKey] = []byte(`[{"tag":"mojang","prefix":"M","url":"https://example.test/check","enabled":true}]`)
|
||||
if _, err := a.currentAuthSources(context.Background()); err == nil {
|
||||
t.Fatal("corrupt DB config trusted as Mojang")
|
||||
}
|
||||
}
|
||||
|
||||
type conflictSettingsRepo struct{ *fakeRepo }
|
||||
|
||||
func (r conflictSettingsRepo) CompareAndSetSetting(context.Context, string, []byte, []byte) error {
|
||||
return ErrConflict
|
||||
}
|
||||
|
||||
func TestAuthSourcesConcurrentWrite(t *testing.T) {
|
||||
a, repo := seedAuthSourcesAPI()
|
||||
v := readAuthSources(t, a)
|
||||
a.AuthSourceSettings.Repo = conflictSettingsRepo{repo}
|
||||
v.Sources[0].Enabled = false
|
||||
w := saveAuthSources(a, v)
|
||||
if w.Code != 409 || decodeErr(t, w) != "auth_sources_changed" || len(repo.settings) != 0 {
|
||||
t.Fatalf("concurrent write = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthSourcesValidation(t *testing.T) {
|
||||
valid := sourceEntry("custom", "CS", "https://example.test/check")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
entries []authSourceEntry
|
||||
}{
|
||||
{"null", nil},
|
||||
{"Mojang", []authSourceEntry{sourceEntry("MOJANG", "M", valid.URL)}},
|
||||
{"empty tag", []authSourceEntry{sourceEntry("", "M", valid.URL)}},
|
||||
{"colon", []authSourceEntry{sourceEntry("x:y", "M", valid.URL)}},
|
||||
{"duplicate tag", []authSourceEntry{valid, valid}},
|
||||
{"duplicate prefix", []authSourceEntry{valid, sourceEntry("other", "cs", valid.URL)}},
|
||||
{"long prefix", []authSourceEntry{sourceEntry("x", "ABCDE", valid.URL)}},
|
||||
{"public HTTP", []authSourceEntry{sourceEntry("x", "X", "http://example.test/check")}},
|
||||
{"query", []authSourceEntry{sourceEntry("x", "X", valid.URL+"?secret=1")}},
|
||||
{"fragment", []authSourceEntry{sourceEntry("x", "X", valid.URL+"#fragment")}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
a, repo := seedAuthSourcesAPI()
|
||||
v := readAuthSources(t, a)
|
||||
v.Sources = tc.entries
|
||||
w := saveAuthSources(a, v)
|
||||
if w.Code != 400 || len(repo.settings) != 0 {
|
||||
t.Fatalf("validation = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
a, _ := seedAuthSourcesAPI()
|
||||
w := do(a.ExternalHandler(), "PUT", authSourcesPath, `{"sources":[],"revision":"x","identity":true}`, jsonHeader)
|
||||
if w.Code != 400 {
|
||||
t.Fatal("caller could set trust policy")
|
||||
}
|
||||
w = do(a.ExternalHandler(), "PUT", authSourcesPath, `{}`, map[string]string{"Content-Type": "application/x-www-form-urlencoded"})
|
||||
if w.Code != 415 {
|
||||
t.Fatal("cross-site form accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthSourcesOwnerGateAndReauth(t *testing.T) {
|
||||
for _, p := range []*Principal{nil, {UserID: "admin", Role: "admin", ViaAdminAccess: true}, {UserID: "user", Role: "user", ViaAdminAccess: true}, {UserID: "owner", Role: "owner"}} {
|
||||
a, repo := seedAuthSourcesAPI()
|
||||
a.External = staticExternal{p: p}
|
||||
for _, route := range []struct{ method, path string }{{"GET", authSourcesPath}, {"PUT", authSourcesPath}, {"POST", authSourcesPath + "/test"}} {
|
||||
w := do(a.ExternalHandler(), route.method, route.path, `{}`, jsonHeader)
|
||||
if w.Code != 401 && w.Code != 403 {
|
||||
t.Fatalf("%+v reached %s: %d", p, route.path, w.Code)
|
||||
}
|
||||
}
|
||||
if len(repo.settings) != 0 {
|
||||
t.Fatal("unauthorized write")
|
||||
}
|
||||
}
|
||||
a, repo := seedAuthSourcesAPI()
|
||||
p := &Principal{UserID: "owner", Role: "owner", ViaAdminAccess: true, ViaSession: true, EmailVerified: true}
|
||||
repo.passkeyCreds["owner-key"] = PasskeyCredential{ID: "owner-key", UserID: p.UserID, UserVerified: true}
|
||||
a.External = staticExternal{p: p}
|
||||
v := readAuthSources(t, a)
|
||||
v.Sources[0].Enabled = false
|
||||
w := saveAuthSources(a, v)
|
||||
if w.Code != 403 || decodeErr(t, w) != "reauth_required" || len(repo.settings) != 0 {
|
||||
t.Fatalf("reauth = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
p.ReauthAt = a.now()
|
||||
if w = saveAuthSources(a, v); w.Code != 200 {
|
||||
t.Fatalf("fresh reauth = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthSourceProbe(t *testing.T) {
|
||||
for _, status := range []int{204, 200, 302, 503} {
|
||||
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||
ids := []string{}
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("username") != "FelisProbe" {
|
||||
t.Error("missing probe user")
|
||||
}
|
||||
ids = append(ids, r.URL.Query().Get("serverId"))
|
||||
w.Header().Set("Location", "http://127.0.0.1:1/no-redirect")
|
||||
w.WriteHeader(status)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
a, repo := seedAuthSourcesAPI()
|
||||
body, _ := json.Marshal(sourceEntry("probe", "P", upstream.URL))
|
||||
for i := 0; i < 2; i++ {
|
||||
w := do(a.ExternalHandler(), "POST", authSourcesPath+"/test", string(body), jsonHeader)
|
||||
var result struct {
|
||||
OK bool `json:"ok"`
|
||||
Status int `json:"status"`
|
||||
Elapsed int64 `json:"elapsed_ms"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != 200 || result.OK != (status == 204) || result.Status != status || result.Elapsed < 0 {
|
||||
t.Fatalf("probe = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
if len(ids) != 2 || ids[0] == "" || ids[0] == ids[1] || !reflect.DeepEqual(repo.settings, map[string][]byte{}) {
|
||||
t.Fatal("probe reused identity or saved config")
|
||||
}
|
||||
})
|
||||
}
|
||||
a, _ := seedAuthSourcesAPI()
|
||||
body, _ := json.Marshal(sourceEntry("probe", "P", "http://127.0.0.1:1/check"))
|
||||
w := do(a.ExternalHandler(), "POST", authSourcesPath+"/test", string(body), jsonHeader)
|
||||
if w.Code != 503 || decodeErr(t, w) != "auth_source_unavailable" {
|
||||
t.Fatalf("unreachable = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -333,7 +333,12 @@ func lookupPremiumName(ctx context.Context, username string) (bool, error) {
|
||||
// as failed: otherwise a dead or mistyped source looks exactly like a player it does not
|
||||
// know, and nobody finds out.
|
||||
func (a *API) resolveHasJoined(ctx context.Context, username, serverID, ip string) (prof *sessionProfile, src AuthSource, failed bool) {
|
||||
for _, src := range a.AuthSources {
|
||||
sources, err := a.currentAuthSources(ctx)
|
||||
if err != nil {
|
||||
log.Printf("hasJoined: read authentication sources: %v", err)
|
||||
return nil, AuthSource{}, true
|
||||
}
|
||||
for _, src := range sources {
|
||||
u := src.URL + "?username=" + url.QueryEscape(username) + "&serverId=" + url.QueryEscape(serverID)
|
||||
if ip != "" {
|
||||
u += "&ip=" + url.QueryEscape(ip)
|
||||
|
||||
@@ -1494,6 +1494,24 @@ func (p *PGRepo) SetSetting(ctx context.Context, key string, value []byte) error
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *PGRepo) CompareAndSetSetting(ctx context.Context, key string, expected, value []byte) error {
|
||||
query := `UPDATE platform_settings SET value = $2::jsonb, updated_at = now() WHERE key = $1 AND value = $3::jsonb`
|
||||
args := []any{key, string(value), string(expected)}
|
||||
if expected == nil {
|
||||
query = `INSERT INTO platform_settings (key, value) VALUES ($1, $2::jsonb) ON CONFLICT (key) DO NOTHING`
|
||||
args = args[:2]
|
||||
}
|
||||
res, err := p.db.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err == nil && n == 0 {
|
||||
return ErrConflict
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- player passkey enrollment (spec §14 WebAuthn / Phase 6 bind, migration 0007) ----
|
||||
|
||||
// CreatePasskeyChallenge supersedes any prior challenge for (user, purpose) and inserts
|
||||
|
||||
@@ -750,6 +750,9 @@ type Repo interface {
|
||||
GetSetting(ctx context.Context, key string) ([]byte, error)
|
||||
// SetSetting upserts a runtime setting's raw jsonb value by key.
|
||||
SetSetting(ctx context.Context, key string, value []byte) error
|
||||
// CompareAndSetSetting refuses a concurrent edit with ErrConflict. A nil
|
||||
// expected value creates only when the setting has never been written.
|
||||
CompareAndSetSetting(ctx context.Context, key string, expected, value []byte) error
|
||||
|
||||
// ---- user admin (spec §7, admin-only) ----
|
||||
|
||||
|
||||
@@ -59,11 +59,11 @@ type Config struct {
|
||||
// the tag, which cannot know that "littleskin" is meant to read LS.
|
||||
// No trusted/identity field, by design — see Config.AuthSources.
|
||||
type AuthSourceConfig struct {
|
||||
Tag string `toml:"tag"`
|
||||
Prefix string `toml:"prefix"`
|
||||
URL string `toml:"url"`
|
||||
Tag string `toml:"tag" json:"tag"`
|
||||
Prefix string `toml:"prefix" json:"prefix"`
|
||||
URL string `toml:"url" json:"url"`
|
||||
// APIURL is optional for sources whose hasJoined URL does not use the standard path.
|
||||
APIURL string `toml:"api_url"`
|
||||
APIURL string `toml:"api_url" json:"api_url"`
|
||||
}
|
||||
|
||||
// SMTPConfig is the [smtp] table: the outbound mail relay felis-api delivers
|
||||
@@ -672,6 +672,12 @@ func (o OffsiteConfig) validate() error {
|
||||
// leave a legible name behind after truncation.
|
||||
var authSourcePrefixRe = regexp.MustCompile(`^[A-Za-z0-9]{1,4}$`)
|
||||
|
||||
// ValidateAuthSources applies the same identity and endpoint rules to panel and
|
||||
// TOML configuration. Mojang remains the code-owned first source.
|
||||
func ValidateAuthSources(sources []AuthSourceConfig) error {
|
||||
return (&Config{AuthSources: sources}).validateAuthSources()
|
||||
}
|
||||
|
||||
// validateAuthSources checks the [[auth_source]] block: each needs a namespace tag, a rename
|
||||
// prefix, and a scheme-qualified hasJoined URL, and both tag and prefix must be unique. A
|
||||
// blank, duplicate or colon-bearing tag collapses two sources into one UUID namespace
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//go:build pgint
|
||||
|
||||
package pgint
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"felis.lolicon.best/internal/api"
|
||||
)
|
||||
|
||||
func TestSettingsCompareAndSet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
key := "auth-sources-" + suffix(t)
|
||||
defer db.ExecContext(ctx, "DELETE FROM platform_settings WHERE key=$1", key)
|
||||
first := []byte(`[{"tag":"custom","enabled":true}]`)
|
||||
next := []byte(`[{"tag":"custom","enabled":false}]`)
|
||||
if err := repo.CompareAndSetSetting(ctx, key, first, next); !errors.Is(err, api.ErrConflict) {
|
||||
t.Fatalf("missing update = %v", err)
|
||||
}
|
||||
if err := repo.CompareAndSetSetting(ctx, key, nil, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.CompareAndSetSetting(ctx, key, nil, next); !errors.Is(err, api.ErrConflict) {
|
||||
t.Fatalf("concurrent insert = %v", err)
|
||||
}
|
||||
// jsonb equality must survive PostgreSQL's different spacing and key order.
|
||||
raw, err := repo.GetSetting(ctx, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan error, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); results <- repo.CompareAndSetSetting(ctx, key, raw, next) }()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
succeeded, conflicted := 0, 0
|
||||
for err := range results {
|
||||
switch {
|
||||
case err == nil:
|
||||
succeeded++
|
||||
case errors.Is(err, api.ErrConflict):
|
||||
conflicted++
|
||||
default:
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if succeeded != 1 || conflicted != 1 {
|
||||
t.Fatalf("concurrent update: success=%d conflict=%d", succeeded, conflicted)
|
||||
}
|
||||
if err := repo.CompareAndSetSetting(ctx, key, []byte(`[ { "enabled" : false, "tag" : "custom" } ]`), first); err != nil {
|
||||
t.Fatalf("JSON equality = %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user