diff --git a/README.md b/README.md index 120b0e0..44a9774 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,8 @@ curl -fsSL https://raw.githubusercontent.com/FelisMC/Felis/main/deploy/bootstrap * **设置向导**:先完成面板访问方式与存储配置,再由主机管理员创建首位 Owner,终端会给出一次性网页设置链接(30 分钟内有效)。用浏览器打开链接、登记邮箱并创建通行密钥,即可进入面板,无需启动 Minecraft。角色关联可稍后在“账户”页选择认证源、输入角色名或 UUID 并确认;普通玩家继续通过游戏绑定码验证身份。未完成网页登录或链接过期时,再次执行 `sudo felis setup` 会提供新链接;已设置登录凭证的账号不会被重置。登录服或大厅故障不会阻止面板初始化。“账户”页会解释世界树(Yggdrasil)认证、显示进服地址与登录服/大厅所需的 Java 版客户端版本;安装器会把实际构建版本写入 `[velocity].game_version`,自定义镜像需自行填写,未配置时不会猜测版本。标准世界树接口可直接查询角色;非标准 `hasJoined` 地址可通过 `[[auth_source]].api_url` 指定认证站 API 根地址,游戏绑定码仍可作为替代方式。安装器仅在交互式终端中自动启动向导;输出重定向至日志或经由 cloud-init 安装时,请在安装结束后执行 `sudo felis setup`。设置 `FELIS_NO_SETUP=1` 时,安装器在输出摘要后直接结束。 +* **认证源管理**:Owner 可在左侧“平台 → 认证源”添加、编辑、排序、停用第三方 Yggdrasil 认证站,并测试认证接口。保存后立即用于下一次登录和角色查询,无需重启;面板配置优先于安装配置。已保存的永久标识不能改名或删除,以保留玩家 UUID 与账号绑定;不再使用的源可停用。Mojang 始终优先验证。Nano 继续使用 TOML 配置。 + * **支持的系统**:CentOS Stream 9(aarch64)已在实机上验证;Ubuntu 24.04(x86_64)在每次推送时由 CI 执行全新安装、重复安装、升级及上述安装命令(参见 [运维手册 §1](docs/operations.md#1-supported-hosts))。 * **安装前检查**:安装器在修改主机之前检查内存、磁盘、端口、网段冲突、已有的 Kubernetes 及外网连通性。发现问题时一次性列出全部问题并退出,主机保持原状(检查项参见 [运维手册 §1](docs/operations.md#1-supported-hosts))。 diff --git a/cmd/felis/api.go b/cmd/felis/api.go index dcbfef2..76eeda8 100644 --- a/cmd/felis/api.go +++ b/cmd/felis/api.go @@ -475,7 +475,8 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { // [[auth_source]] is configured, so an empty list has to mean a Mojang-only relay, the // same as under `felis nano`. A nil list would 204 every login, premium ones included. a.AuthSources = authSourcesFromConfig(cfg.AuthSources) - fmt.Fprintf(stderr, "felis api: hasJoined multiplexer active — Mojang + %d third-party source(s)\n", len(cfg.AuthSources)) + a.AuthSourceSettings = &api.AuthSourceSettings{Repo: repo, Defaults: a.AuthSources} + fmt.Fprintf(stderr, "felis api: hasJoined multiplexer active — Mojang + %d default third-party source(s); panel settings take precedence\n", len(cfg.AuthSources)) // Passkey (WebAuthn) verifier (spec §14, Phase 6). One relying party spans BOTH // web faces: the RP id is the panel hostname (console.), and because that is diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 39c4015..d720a90 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -311,6 +311,42 @@ components: output: { type: string } schemas: + AuthSourceConfig: + type: object + additionalProperties: false + required: [tag, prefix, url, api_url, enabled] + properties: + tag: + type: string + description: Permanent UUID namespace; saved tags cannot be renamed or removed. mojang is reserved. + prefix: + type: string + pattern: '^[A-Za-z0-9]{1,4}$' + description: Unique case-insensitive display prefix. + url: + type: string + description: hasJoined endpoint; HTTPS required except for localhost or private literal IP addresses. No query or fragment. + api_url: + type: string + description: Optional Yggdrasil API base for role lookup; empty infers it from the standard hasJoined suffix. + enabled: { type: boolean } + + AuthSourcesSettings: + type: object + required: [sources, revision, managed] + properties: + sources: + type: array + maxItems: 32 + description: Third-party sources in priority order; built-in Mojang always precedes them and is immutable. + items: { $ref: '#/components/schemas/AuthSourceConfig' } + revision: + type: string + description: Opaque revision to send unchanged when saving; stale or concurrent writes return 409. + managed: + type: boolean + description: True when stored in platform_settings; false while using installation TOML defaults. + Error: type: object description: Uniform error envelope emitted by every handler (internal/api/errors.go). @@ -4229,6 +4265,101 @@ paths: '401': $ref: '#/components/responses/Unauthorized' + /api/v1/settings/auth-sources: + get: + tags: [account] + operationId: getAuthSources + summary: Read authentication sources (Owner). + x-felis-face: [external] + x-felis-tier: owner + security: [{ sessionCookie: [] }] + responses: + '200': + description: Current third-party sources and their revision. + content: + application/json: + schema: { $ref: '#/components/schemas/AuthSourcesSettings' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + put: + tags: [account] + operationId: setAuthSources + summary: Save authentication sources (Owner, fresh reauthentication). + description: >- + Atomically persists an override in platform_settings. It applies to the next + login and role lookup on every API replica without restarting; existing + players stay online. Tags identify permanent UUID namespaces; retain every + saved tag and disable unwanted sources. Mojang remains built-in and trusted, + while configured sources always remain third-party. Nano remains TOML-only. + x-felis-face: [external] + x-felis-tier: owner + security: [{ sessionCookie: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [sources, revision] + properties: + sources: + type: array + maxItems: 32 + items: { $ref: '#/components/schemas/AuthSourceConfig' } + revision: { type: string } + responses: + '200': + description: Saved configuration and new revision. + content: + application/json: + schema: { $ref: '#/components/schemas/AuthSourcesSettings' } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '409': + description: auth_sources_changed or auth_source_tag_locked; reload instead of overwriting another Owner's changes. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + + /api/v1/settings/auth-sources/test: + post: + tags: [account] + operationId: testAuthSource + summary: Probe a hasJoined endpoint (Owner). + description: >- + Checks an unsaved source with a fresh random serverId. A healthy endpoint + returns 204 for a session that never joined. Uses a five-second timeout, + verified TLS and no redirects. This tests connectivity and hasJoined + behavior, not launcher login or profile lookup. Does not save configuration. + x-felis-face: [external] + x-felis-tier: owner + security: [{ sessionCookie: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/AuthSourceConfig' } + responses: + '200': + description: Probe result; non-204 status has ok=false. + content: + application/json: + schema: + type: object + required: [ok, status, elapsed_ms] + properties: + ok: { type: boolean } + status: { type: integer } + elapsed_ms: { type: integer, format: int64 } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + /api/v1/updates/window: get: tags: [admin-updates] diff --git a/docs/operations.md b/docs/operations.md index 188a32a..0fe6c32 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -874,3 +874,32 @@ the moved domain and left `check` clean; moving back restored every surface on every run, so a host upgraded from one can show that line once with the file already on the names; `sudo systemctl restart felis-velocity` clears it. The installer now leaves the file alone when its content is the same. + +## 7. Authentication sources + +On the operator console, the Owner's **Platform → Authentication sources** page +(`/admin/auth-sources`) manages third-party Yggdrasil providers. It imports the +installation's `[[auth_source]]` list on first use. Save stores the ordered list +in `platform_settings.auth_sources`; that override then takes precedence over TOML +and is read by every full-API replica for the next game login and role lookup. +No restart is required, and existing players stay connected. Nano continues to +use its TOML list. A database read failure refuses new authentication rather than +falling back to an obsolete or disabled provider. + +Mojang remains enabled and first, retaining official UUIDs. Every third-party +provider uses a permanent tag as its UUID namespace; saved or imported tags cannot +be renamed or removed. Disable a provider to stop accepting its logins, or enable +it again to restore the same identities. Changing a provider's endpoint changes +who verifies identities in that namespace; keep it pointed at the same trusted +service. Prefixes are 1–4 letters/digits and must be unique regardless of case. + +Set the full `hasJoined` URL. Standard paths infer the profile-query API root; +nonstandard paths need an explicit API root for role-name/UUID lookup. HTTPS is +required, except for localhost or literal private IPs; query strings and fragments +are rejected. Launchers must authenticate with the same provider. **Test connection** +probes an unused session and expects HTTP 204; it does not save, verify launcher +configuration, or test the profile-query API. + +Saving requires Owner access on the operator host and recent reauthentication for +a local session. A revision conflict preserves the draft; discard it and reload +before editing the newer configuration. diff --git a/internal/api/api.go b/internal/api/api.go index d81cb64..a596739 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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 diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 1a749d2..714d2c4 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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. diff --git a/internal/api/handlers_account_profile.go b/internal/api/handlers_account_profile.go index 3c62c66..ed20dae 100644 --- a/internal/api/handlers_account_profile.go +++ b/internal/api/handlers_account_profile.go @@ -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 diff --git a/internal/api/handlers_auth_sources.go b/internal/api/handlers_auth_sources.go new file mode 100644 index 0000000..83a3112 --- /dev/null +++ b/internal/api/handlers_auth_sources.go @@ -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()}) +} diff --git a/internal/api/handlers_auth_sources_test.go b/internal/api/handlers_auth_sources_test.go new file mode 100644 index 0000000..b3cca5c --- /dev/null +++ b/internal/api/handlers_auth_sources_test.go @@ -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()) + } +} diff --git a/internal/api/handlers_hasjoined.go b/internal/api/handlers_hasjoined.go index 6cfe629..048d440 100644 --- a/internal/api/handlers_hasjoined.go +++ b/internal/api/handlers_hasjoined.go @@ -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) diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index fe7fe1d..ef5e96a 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -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 diff --git a/internal/api/repo.go b/internal/api/repo.go index 790feb2..282df56 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -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) ---- diff --git a/internal/config/config.go b/internal/config/config.go index 2ddc138..ff5fc1b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 diff --git a/internal/pgint/settings_test.go b/internal/pgint/settings_test.go new file mode 100644 index 0000000..0452dc8 --- /dev/null +++ b/internal/pgint/settings_test.go @@ -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) + } +} diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index 96e4907..c6f3b63 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -3,6 +3,8 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { gzipSync } from "node:zlib"; import type { Plugin } from "vite"; import type { + AuthSourceConfig, + AuthSourcesSettings, AutostartPolicy, BackupView, Build, @@ -89,6 +91,7 @@ interface MockState { passkeys: Record; submissions: Submission[]; updateWindow: { start: string | null; end: string | null }; + authSources: AuthSourcesSettings; // Each server's world volume, seeded on first visit. files: Record; } @@ -452,6 +455,11 @@ function initialState(): MockState { }, ], updateWindow: { start: null, end: null }, + authSources: { + sources: [{ tag: "littleskin", prefix: "LS", url: "https://littleskin.cn/api/yggdrasil/sessionserver/session/minecraft/hasJoined", api_url: "", enabled: true }], + revision: "installation-defaults", + managed: false, + }, files: {}, }; } @@ -1008,6 +1016,38 @@ async function handlePublic(ctx: RequestContext): Promise { async function handleSession(ctx: SessionContext): Promise { switch (route(ctx)) { + case "GET settings/auth-sources": + case "PUT settings/auth-sources": + case "POST settings/auth-sources/test": { + if (!isOwner(ctx.account.role)) { + sendError(ctx.res, 403, "forbidden", "Owner account required"); + return true; + } + if (is("GET", ctx)) { + sendJSON(ctx.res, 200, ctx.state.authSources); + } else if (is("PUT", ctx)) { + const body = await readJSON<{ sources: AuthSourceConfig[]; revision: string }>(ctx.req); + if (body.revision !== ctx.state.authSources.revision) { + sendError(ctx.res, 409, "auth_sources_changed", "authentication sources changed; reload before saving"); + } else if (ctx.state.authSources.sources.some((source) => !body.sources.some((s) => s.tag === source.tag))) { + sendError(ctx.res, 409, "auth_source_tag_locked", "disable saved sources instead of removing or renaming them"); + } else { + ctx.state.authSources = { sources: body.sources, revision: createHash("sha256").update(JSON.stringify(body.sources)).digest("hex"), managed: true }; + sendJSON(ctx.res, 200, ctx.state.authSources); + } + } else { + await readJSON(ctx.req); + sendJSON(ctx.res, 200, { ok: true, status: 204, elapsed_ms: 20 }); + } + return true; + } + case "GET account/link/sources": + if (!isAdmin(ctx.account.role)) { + sendError(ctx.res, 403, "forbidden", "admin account required"); + return true; + } + sendJSON(ctx.res, 200, { sources: [{ tag: "mojang", prefix: "", lookup_available: true }, ...ctx.state.authSources.sources.filter((source) => source.enabled).map((source) => ({ tag: source.tag, prefix: source.prefix, lookup_available: !!source.api_url || source.url.endsWith("/sessionserver/session/minecraft/hasJoined") }))] }); + return true; case "GET platform/db-backup": { if (!isAdmin(ctx.account.role)) { sendError(ctx.res, 403, "forbidden", "admin account required"); diff --git a/panel/e2e/auth-sources.smoke.spec.ts b/panel/e2e/auth-sources.smoke.spec.ts new file mode 100644 index 0000000..5d05097 --- /dev/null +++ b/panel/e2e/auth-sources.smoke.spec.ts @@ -0,0 +1,84 @@ +import { test, expect, t, expectFitsScreen } from "./fixtures"; + +test("Owner manages durable authentication sources from the sidebar", async ({ page, signIn }) => { + await signIn("owner"); + await page.goto("/"); + await page.getByRole("link", { name: t("navigation:auth_sources"), exact: true }).click(); + await expect(page).toHaveURL(/\/admin\/auth-sources$/); + await expect(page.getByLabel(t("authSources:tag"))).toHaveValue("littleskin"); + await expect(page.getByLabel(t("authSources:tag"))).toHaveAttribute("readonly", ""); + await page.getByRole("button", { name: t("authSources:add"), exact: true }).click(); + await page.getByLabel(t("authSources:tag")).nth(1).fill("custom"); + await page.getByLabel(t("authSources:prefix")).nth(1).fill("CS"); + await page.getByLabel(t("authSources:url"), { exact: true }).nth(1).fill("https://custom.example/sessionserver/session/minecraft/hasJoined"); + await page.getByRole("button", { name: t("authSources:test"), exact: true }).nth(1).click(); + await expect(page.getByText(t("authSources:test_ok", { ms: 20 }), { exact: true })).toBeVisible(); + await page.getByRole("button", { name: t("authSources:move_up"), exact: true }).nth(1).click(); + await page.getByRole("checkbox", { name: t("authSources:enabled"), exact: true }).nth(1).uncheck(); + await page.getByRole("button", { name: t("authSources:save"), exact: true }).click(); + await expect(page.getByText(t("authSources:saved"), { exact: true })).toBeVisible(); + await page.reload(); + await expect(page.getByLabel(t("authSources:tag")).nth(0)).toHaveValue("custom"); + await expect(page.getByRole("checkbox", { name: t("authSources:enabled"), exact: true }).nth(1)).not.toBeChecked(); + await expect(page.getByRole("button", { name: t("authSources:remove"), exact: true })).toHaveCount(0); + const available = await page.request.get("/api/v1/account/link/sources"); + expect((await available.json()).sources.map((source: { tag: string }) => source.tag)).toEqual(["mojang", "custom"]); + await expectFitsScreen(page); + await page.screenshot({ path: "/tmp/felis-auth-sources.png", fullPage: true }); +}); + +test("source fields stay visible while initial configuration is loading", async ({ page, signIn }) => { + await signIn("owner"); + let release!: () => void; + const pending = new Promise((resolve) => { release = resolve; }); + await page.route("**/api/v1/settings/auth-sources", async (route) => { await pending; await route.continue(); }); + await page.goto("/admin/auth-sources"); + await expect(page.getByLabel(t("authSources:tag"))).toBeVisible(); + await expect(page.getByLabel(t("authSources:tag"))).toBeDisabled(); + await expect(page.getByText(t("authSources:loading"), { exact: true }).first()).toBeVisible(); + release(); + await expect(page.getByLabel(t("authSources:tag"))).toBeEnabled(); +}); + +test("a player has neither an authentication source entry nor access to its API", async ({ page, signIn }) => { + await signIn("linked"); + await page.goto("/admin/auth-sources"); + await expect(page.getByText(t("common:not_authorized_title"))).toBeVisible(); + await expect(page.getByRole("link", { name: t("navigation:auth_sources"), exact: true })).toHaveCount(0); + const response = await page.request.get("/api/v1/settings/auth-sources"); + expect(response.status()).toBe(403); +}); + +for (const path of ["/servers", "/account"]) { + test(`logout from ${path} on the operator host returns to login methods`, async ({ page, signIn }) => { + await page.route("**/config.json", async (route) => { + const response = await route.fetch(); + await route.fulfill({ response, json: { ...await response.json(), adminHostname: "localhost" } }); + }); + await signIn("owner"); + await page.goto(path); + const logout = page.getByRole("button", { name: t("account:sign_out"), exact: true }); + await (path === "/account" ? logout.last() : logout.first()).click(); + await expect(page).toHaveURL(/\/login\?/); + await expect(page.getByRole("button", { name: t("auth:passkey_btn"), exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: t("auth:tab_op_btn"), exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: t("auth:op_start_btn"), exact: true })).toHaveCount(0); + await page.getByRole("button", { name: t("auth:tab_op_btn"), exact: true }).click(); + await expect(page.getByRole("button", { name: t("auth:op_start_btn"), exact: true })).toBeVisible(); + }); +} + +test("Chinese authentication settings render in both themes", async ({ page, signIn }) => { + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.addInitScript(() => localStorage.setItem("felis-lang", "zh-CN")); + await signIn("owner"); + await page.emulateMedia({ colorScheme: "dark" }); + await page.goto("/admin/auth-sources"); + await expect(page.getByRole("heading", { name: "认证源", exact: true })).toBeVisible(); + await expect(page.getByLabel("永久标识")).toHaveValue("littleskin"); + await expectFitsScreen(page); + await page.screenshot({ path: "/tmp/felis-auth-sources-zh-dark.png", fullPage: true }); + await page.getByRole("button", { name: "切换主题", exact: true }).click(); + await expectFitsScreen(page); + await page.screenshot({ path: "/tmp/felis-auth-sources-zh-light.png", fullPage: true }); +}); diff --git a/panel/e2e/mobile.spec.ts b/panel/e2e/mobile.spec.ts index 5024fec..9b3d318 100644 --- a/panel/e2e/mobile.spec.ts +++ b/panel/e2e/mobile.spec.ts @@ -20,6 +20,7 @@ for (const [account, path] of [ ["linked", "/account"], ["owner", "/servers"], ["owner", "/admin/users"], + ["owner", "/admin/auth-sources"], ["owner", "/servers/survival/luckperms"], ] as const) { test(`${path} fits a 375px screen for ${account}`, async ({ page, signIn }) => { @@ -61,3 +62,12 @@ test("login customization fits a phone and retains a saved title", async ({ page await page.reload(); await expect(page.getByLabel(t("lobby:bookTitle"))).toHaveValue("Our Network"); }); + +test("authentication source drafts with long IDs fit a phone", async ({ page, signIn }) => { + await signIn("owner"); + await page.goto("/admin/auth-sources"); + await page.getByRole("button", { name: t("authSources:add"), exact: true }).click(); + await page.getByLabel(t("authSources:tag")).nth(1).fill("a".repeat(128)); + await expectFitsScreen(page); + await expect(page.getByRole("button", { name: t("authSources:remove"), exact: true })).toBeVisible(); +}); diff --git a/panel/src/App.tsx b/panel/src/App.tsx index 4460e2b..579018e 100644 --- a/panel/src/App.tsx +++ b/panel/src/App.tsx @@ -65,6 +65,7 @@ const LobbyPage = lazyWithReload(() => const UpdatesPage = lazyWithReload(() => import("@/pages/admin/UpdatesPage").then((m) => ({ default: m.UpdatesPage })), ); +const AuthSourcesPage = lazyWithReload(() => import("@/pages/admin/AuthSourcesPage").then((m) => ({ default: m.AuthSourcesPage }))); // Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES): // / User-Side — app-tier, every authenticated principal @@ -129,8 +130,9 @@ export default function App() { } /> } /> } /> - {/* Owner-gated: user management (one level above admin). */} + {/* Owner-gated platform settings and user management. */} }> + } /> } /> }> } /> diff --git a/panel/src/i18n/index.ts b/panel/src/i18n/index.ts index 698f91d..1d3ea99 100644 --- a/panel/src/i18n/index.ts +++ b/panel/src/i18n/index.ts @@ -13,6 +13,8 @@ import enNavigation from "./resources/en-US/navigation.json"; import enBackups from "./resources/en-US/backups.json"; import enSubmissions from "./resources/en-US/submissions.json"; import enFiles from "./resources/en-US/files.json"; +import enAuthSources from "./resources/en-US/authSources.json"; +import zhAuthSources from "./resources/zh-CN/authSources.json"; import enLobby from "./resources/en-US/lobby.json"; import zhLobby from "./resources/zh-CN/lobby.json"; import enSchedules from "./resources/en-US/schedules.json"; @@ -49,6 +51,7 @@ export const i18nOptions: InitOptions = { files: enFiles, schedules: enSchedules, lobby: enLobby, + authSources: enAuthSources, }, "zh-CN": { common: zhCommon, @@ -65,6 +68,7 @@ export const i18nOptions: InitOptions = { files: zhFiles, schedules: zhSchedules, lobby: zhLobby, + authSources: zhAuthSources, }, }, supportedLngs: [...SUPPORTED_LANGUAGES], diff --git a/panel/src/i18n/resources/en-US/authSources.json b/panel/src/i18n/resources/en-US/authSources.json new file mode 100644 index 0000000..cec19da --- /dev/null +++ b/panel/src/i18n/resources/en-US/authSources.json @@ -0,0 +1,40 @@ +{ + "title": "Authentication sources", + "subtitle": "Manage Minecraft identity providers and their verification priority.", + "builtin_title": "Mojang · built in, checked first", + "builtin_hint": "Official accounts keep their original UUIDs. This source is always enabled and cannot be replaced or reordered.", + "custom_title": "Third-party sources", + "priority_hint": "Enabled sources are checked from top to bottom after Mojang.", + "reload": "Reload", + "add": "Add source", + "new_source": "New source", + "loading": "Loading authentication sources…", + "enabled": "Enabled", + "move_up": "Move source up", + "move_down": "Move source down", + "remove": "Remove unsaved source", + "tag": "Permanent source ID", + "tag_hint": "A unique ID without colons or surrounding spaces. It becomes permanent when saved.", + "tag_locked": "This ID is fixed to preserve player UUIDs and account links.", + "prefix": "Name collision prefix", + "prefix_hint": "1–4 letters or digits, used when a player has an official account’s name.", + "url": "Authentication endpoint (hasJoined)", + "url_hint": "Use HTTPS. HTTP is allowed only for localhost or private IPs. Do not include a query or fragment.", + "api_url": "Role lookup API root (optional)", + "api_hint": "Leave empty for a standard hasJoined URL; otherwise provide the Yggdrasil API root for role lookup.", + "test": "Test connection", + "testing": "Testing…", + "test_hint": "Checks whether the login endpoint rejects an unused session correctly.", + "test_ok": "Authentication endpoint passed ({{ms}} ms).", + "test_status": "Endpoint returned HTTP {{status}}; expected 204 for an unused session.", + "empty": "Only official accounts are enabled. Add a source to accept another provider.", + "disable_hint": "Disable a saved source to stop accepting its logins. Its ID stays reserved so identities can be restored later. Launchers must use the same provider.", + "invalid": "Check source IDs, unique prefixes and complete HTTP(S) addresses without queries or fragments.", + "unsaved": "Unsaved changes · saving applies to the next login and role lookup", + "active": "Saved sources are active; no restart is required.", + "defaults": "Using installation defaults. Saved panel configuration takes precedence.", + "discard": "Discard changes", + "save": "Save & apply", + "saving": "Saving…", + "saved": "Saved and active. New logins and role lookups use this configuration; online players stay connected." +} diff --git a/panel/src/i18n/resources/en-US/errors.json b/panel/src/i18n/resources/en-US/errors.json index 7472dd4..fc3a579 100644 --- a/panel/src/i18n/resources/en-US/errors.json +++ b/panel/src/i18n/resources/en-US/errors.json @@ -141,5 +141,8 @@ "auth_source_unknown": "Select a configured authentication source.", "auth_source_unavailable": "The authentication source is unavailable or returned an invalid role. Try again later.", "auth_source_lookup_unsupported": "Role lookup is not configured for this authentication source.", - "minecraft_profile_not_found": "No role matched in this source. Check the role name or UUID." + "minecraft_profile_not_found": "No role matched in this source. Check the role name or UUID.", + "auth_sources_unavailable": "Authentication source management is unavailable.", + "auth_sources_changed": "Authentication sources changed. Discard your edits and reload before saving.", + "auth_source_tag_locked": "Saved source IDs cannot be renamed or removed. Disable the source instead." } diff --git a/panel/src/i18n/resources/en-US/navigation.json b/panel/src/i18n/resources/en-US/navigation.json index 8d96bb3..afc22dc 100644 --- a/panel/src/i18n/resources/en-US/navigation.json +++ b/panel/src/i18n/resources/en-US/navigation.json @@ -10,5 +10,6 @@ "admin_builds": "Build Pipeline", "admin_submissions": "Submissions", "admin_updates": "Maintenance & Backups", - "admin_lobby": "Login & lobby" + "admin_lobby": "Login & lobby", + "auth_sources": "Authentication sources" } diff --git a/panel/src/i18n/resources/zh-CN/authSources.json b/panel/src/i18n/resources/zh-CN/authSources.json new file mode 100644 index 0000000..d205cb0 --- /dev/null +++ b/panel/src/i18n/resources/zh-CN/authSources.json @@ -0,0 +1,40 @@ +{ + "title": "认证源", + "subtitle": "管理 Minecraft 身份认证服务及验证顺序。", + "builtin_title": "Mojang · 内置,优先验证", + "builtin_hint": "正版账号保留原有 UUID。此认证源始终启用,不能替换或调整顺序。", + "custom_title": "第三方认证源", + "priority_hint": "正版验证之后,按下方顺序查询已启用的认证源。", + "reload": "重新读取", + "add": "添加认证源", + "new_source": "新认证源", + "loading": "正在加载认证源…", + "enabled": "启用", + "move_up": "上移认证源", + "move_down": "下移认证源", + "remove": "移除未保存的认证源", + "tag": "永久标识", + "tag_hint": "标识须唯一,不能包含冒号或首尾空格。保存后将固定。", + "tag_locked": "此标识已固定,用于保留玩家 UUID 和账号绑定。", + "prefix": "重名处理前缀", + "prefix_hint": "1–4 位字母或数字。第三方角色与正版角色重名时使用。", + "url": "认证接口(hasJoined)", + "url_hint": "使用 HTTPS;仅本机或私有 IP 允许 HTTP。地址不能带查询参数或片段。", + "api_url": "角色查询 API 根地址(选填)", + "api_hint": "标准 hasJoined 地址可留空;使用自定义认证路径时,填写 Yggdrasil API 根地址,以便按角色名或 UUID 查询。", + "test": "测试连接", + "testing": "正在测试…", + "test_hint": "检查认证接口能否正确拒绝一个未登录的会话。", + "test_ok": "认证接口检查通过({{ms}} 毫秒)。", + "test_status": "接口返回 HTTP {{status}};未登录会话应返回 204,检查未通过。", + "empty": "当前仅接受正版 Minecraft 账号。添加认证源后可接入其他认证服务。", + "disable_hint": "停用已保存的认证源后,将不再接受该源的登录;标识仍保留,方便之后恢复玩家身份。玩家启动器也须配置同一个认证站。", + "invalid": "请检查永久标识、不能重复的重名前缀,以及不含查询参数或片段的完整 HTTP(S) 地址。", + "unsaved": "有未保存更改 · 保存后用于下一次登录和角色查询", + "active": "已保存的认证源正在生效,无需重启。", + "defaults": "当前使用安装配置。保存后将持久保存在面板中,并优先于安装配置。", + "discard": "放弃更改", + "save": "保存并生效", + "saving": "正在保存…", + "saved": "已保存并生效。下一次登录和角色查询使用新配置;在线玩家不会断开。" +} diff --git a/panel/src/i18n/resources/zh-CN/errors.json b/panel/src/i18n/resources/zh-CN/errors.json index 50f42df..b755bd3 100644 --- a/panel/src/i18n/resources/zh-CN/errors.json +++ b/panel/src/i18n/resources/zh-CN/errors.json @@ -141,5 +141,8 @@ "auth_source_unknown": "请选择已配置的认证源。", "auth_source_unavailable": "认证源暂时不可用或返回了无效角色,请稍后重试。", "auth_source_lookup_unsupported": "此认证源尚未配置角色查询地址。", - "minecraft_profile_not_found": "在所选认证源中没有找到这个角色,请检查角色名或 UUID。" + "minecraft_profile_not_found": "在所选认证源中没有找到这个角色,请检查角色名或 UUID。", + "auth_sources_unavailable": "认证源管理暂不可用。", + "auth_sources_changed": "认证源已被其他操作修改。请放弃当前更改并重新读取后再保存。", + "auth_source_tag_locked": "已保存的认证源标识不能改名或移除,请使用停用。" } diff --git a/panel/src/i18n/resources/zh-CN/navigation.json b/panel/src/i18n/resources/zh-CN/navigation.json index c9b3e12..67b82d1 100644 --- a/panel/src/i18n/resources/zh-CN/navigation.json +++ b/panel/src/i18n/resources/zh-CN/navigation.json @@ -10,5 +10,6 @@ "admin_builds": "构建流水线", "admin_submissions": "审核提交", "admin_updates": "维护与备份", - "admin_lobby": "登录与大厅" + "admin_lobby": "登录与大厅", + "auth_sources": "认证源" } diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index 9dff7fe..6a966c3 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -21,6 +21,8 @@ import type { KickResult, LinkResult, MinecraftAuthSource, + AuthSourceConfig, + AuthSourcesSettings, MinecraftProfile, LinkStatus, BindResult, @@ -950,6 +952,10 @@ export const api = rejectingSync({ linkSources: () => request<{ sources: MinecraftAuthSource[] }>("GET", "/account/link/sources"), + getAuthSources: () => request("GET", "/settings/auth-sources"), + setAuthSources: (sources: AuthSourceConfig[], revision: string) => request("PUT", "/settings/auth-sources", { sources, revision }), + testAuthSource: (source: AuthSourceConfig) => request<{ ok: boolean; status: number; elapsed_ms: number }>("POST", "/settings/auth-sources/test", source), + lookupProfile: (source: string, profile: string) => request("GET", `/account/link/profile?${new URLSearchParams({ source, profile })}`), diff --git a/panel/src/lib/nav.ts b/panel/src/lib/nav.ts index b43df23..9779890 100644 --- a/panel/src/lib/nav.ts +++ b/panel/src/lib/nav.ts @@ -9,6 +9,7 @@ import { ClipboardCheck, Upload, Clock, + ShieldCheck, type LucideIcon, } from "lucide-react"; @@ -76,6 +77,7 @@ export const NAV_SECTIONS: NavSection[] = [ ownerOnly: true, items: [ { to: "/admin/users", key: "admin_users", icon: Users }, + { to: "/admin/auth-sources", key: "auth_sources", icon: ShieldCheck }, ], }, ]; diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts index c4f1de3..b626824 100644 --- a/panel/src/lib/openapi.gen.ts +++ b/panel/src/lib/openapi.gen.ts @@ -1243,6 +1243,47 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/settings/auth-sources": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read authentication sources (Owner). */ + get: operations["getAuthSources"]; + /** + * Save authentication sources (Owner, fresh reauthentication). + * @description Atomically persists an override in platform_settings. It applies to the next login and role lookup on every API replica without restarting; existing players stay online. Tags identify permanent UUID namespaces; retain every saved tag and disable unwanted sources. Mojang remains built-in and trusted, while configured sources always remain third-party. Nano remains TOML-only. + */ + put: operations["setAuthSources"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/settings/auth-sources/test": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Probe a hasJoined endpoint (Owner). + * @description Checks an unsaved source with a fresh random serverId. A healthy endpoint returns 204 for a session that never joined. Uses a five-second timeout, verified TLS and no redirects. This tests connectivity and hasJoined behavior, not launcher login or profile lookup. Does not save configuration. + */ + post: operations["testAuthSource"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/updates/window": { parameters: { query?: never; @@ -2863,6 +2904,25 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + AuthSourceConfig: { + /** @description Permanent UUID namespace; saved tags cannot be renamed or removed. mojang is reserved. */ + tag: string; + /** @description Unique case-insensitive display prefix. */ + prefix: string; + /** @description hasJoined endpoint; HTTPS required except for localhost or private literal IP addresses. No query or fragment. */ + url: string; + /** @description Optional Yggdrasil API base for role lookup; empty infers it from the standard hasJoined suffix. */ + api_url: string; + enabled: boolean; + }; + AuthSourcesSettings: { + /** @description Third-party sources in priority order; built-in Mojang always precedes them and is immutable. */ + sources: components["schemas"]["AuthSourceConfig"][]; + /** @description Opaque revision to send unchanged when saving; stale or concurrent writes return 409. */ + revision: string; + /** @description True when stored in platform_settings; false while using installation TOML defaults. */ + managed: boolean; + }; /** @description Uniform error envelope emitted by every handler (internal/api/errors.go). */ Error: { error: { @@ -6669,6 +6729,102 @@ export interface operations { 401: components["responses"]["Unauthorized"]; }; }; + getAuthSources: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Current third-party sources and their revision. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthSourcesSettings"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; + setAuthSources: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + sources: components["schemas"]["AuthSourceConfig"][]; + revision: string; + }; + }; + }; + responses: { + /** @description Saved configuration and new revision. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthSourcesSettings"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + /** @description auth_sources_changed or auth_source_tag_locked; reload instead of overwriting another Owner's changes. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; + testAuthSource: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AuthSourceConfig"]; + }; + }; + responses: { + /** @description Probe result; non-204 status has ok=false. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ok: boolean; + status: number; + /** Format: int64 */ + elapsed_ms: number; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; getUpdateWindow: { parameters: { query?: never; diff --git a/panel/src/lib/types.ts b/panel/src/lib/types.ts index 0ff0d8b..607b8a6 100644 --- a/panel/src/lib/types.ts +++ b/panel/src/lib/types.ts @@ -362,6 +362,20 @@ export interface MinecraftAuthSource { lookup_available: boolean; } +export interface AuthSourceConfig { + tag: string; + prefix: string; + url: string; + api_url: string; + enabled: boolean; +} + +export interface AuthSourcesSettings { + sources: AuthSourceConfig[]; + revision: string; + managed: boolean; +} + export interface MinecraftProfile { source: string; name: string; diff --git a/panel/src/pages/Login.test.tsx b/panel/src/pages/Login.test.tsx index a9bc6f5..3ccda54 100644 --- a/panel/src/pages/Login.test.tsx +++ b/panel/src/pages/Login.test.tsx @@ -71,6 +71,14 @@ beforeEach(() => { }); describe("Login", () => { + it("starts with login methods on the operator host after signing out", async () => { + config.value.adminHostname = window.location.hostname; + await renderLogin(); + expect(screen.getByRole("button", { name: t("auth:passkey_btn") })).toBeTruthy(); + expect(screen.getByRole("button", { name: t("auth:tab_op_btn") })).toBeTruthy(); + expect(screen.queryByRole("button", { name: t("auth:op_start_btn") })).toBeNull(); + }); + it("reads out why the code could not be sent", async () => { calls.authEmailStart.mockRejectedValue({ status: 429, code: "otp_resend_cooldown", message: "" }); await renderLogin(); diff --git a/panel/src/pages/Login.tsx b/panel/src/pages/Login.tsx index 22c6d50..0e62308 100644 --- a/panel/src/pages/Login.tsx +++ b/panel/src/pages/Login.tsx @@ -68,14 +68,13 @@ export function Login() { return () => clearTimeout(timer); }, [countdown]); - // Tier-aware copy: on the op.console hostname the staff door is the default tab - // (the player doors refuse staff accounts anyway). + // The operator hostname changes the copy, while every visit starts with the + // login methods (including passkeys). Staff explicitly choose vouched login. useEffect(() => { void loadConfig().then((cfg) => { setJoinAddr(entryAddress(cfg)); if (cfg.adminHostname && window.location.hostname === cfg.adminHostname) { setIsOpHost(true); - setActiveTab("op"); } }); }, []); diff --git a/panel/src/pages/admin/AuthSourcesPage.test.tsx b/panel/src/pages/admin/AuthSourcesPage.test.tsx new file mode 100644 index 0000000..68c34ae --- /dev/null +++ b/panel/src/pages/admin/AuthSourcesPage.test.tsx @@ -0,0 +1,114 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { AuthSourcesPage } from "./AuthSourcesPage"; +import type { AuthSourceConfig, AuthSourcesSettings } from "@/lib/types"; + +const calls = vi.hoisted(() => ({ getAuthSources: vi.fn(), setAuthSources: vi.fn(), testAuthSource: vi.fn() })); +vi.mock("@/lib/api", async (original) => ({ ...await original(), api: calls })); +const source = (tag = "littleskin", prefix = "LS"): AuthSourceConfig => ({ tag, prefix, url: "https://example.org/sessionserver/session/minecraft/hasJoined", api_url: "", enabled: true }); +const settings = (sources = [source()]): AuthSourcesSettings => ({ sources, revision: "original", managed: false }); +function page() { render(); } +const button = (name: string) => screen.getByRole("button", { name }); +const field = (label: string, index = 0) => screen.getAllByLabelText(label)[index] as HTMLInputElement; +async function ready() { await waitFor(() => expect(field("Permanent source ID").disabled).toBe(false)); } + +beforeEach(() => { + vi.clearAllMocks(); + calls.getAuthSources.mockResolvedValue(settings()); + calls.setAuthSources.mockImplementation(async (sources) => ({ sources, revision: "saved", managed: true })); + calls.testAuthSource.mockResolvedValue({ ok: true, status: 204, elapsed_ms: 18 }); +}); + +describe("AuthSourcesPage", () => { + it("shows disabled fields while loading, then protects saved IDs", async () => { + let resolve!: (settings: AuthSourcesSettings) => void; + calls.getAuthSources.mockReturnValue(new Promise((r) => { resolve = r; })); + page(); + expect(field("Permanent source ID").disabled).toBe(true); + expect(field("Authentication endpoint (hasJoined)").value).toBe(""); + expect((button("Save & apply") as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByRole("status").textContent).toContain("Loading authentication sources"); + await act(async () => resolve(settings())); + await ready(); + expect(field("Permanent source ID").readOnly).toBe(true); + expect(screen.queryByRole("button", { name: "Remove unsaved source" })).toBeNull(); + }); + it("adds a source, fixes a duplicate ID, saves and locks it without sending UI state", async () => { + page(); await ready(); + await userEvent.click(button("Add source")); + fireEvent.change(field("Permanent source ID", 1), { target: { value: "littleskin" } }); + expect(field("Permanent source ID", 1).readOnly).toBe(false); + expect(button("Remove unsaved source")).toBeTruthy(); + fireEvent.change(field("Permanent source ID", 1), { target: { value: "custom" } }); + fireEvent.change(field("Name collision prefix", 1), { target: { value: "CS" } }); + fireEvent.change(field("Authentication endpoint (hasJoined)", 1), { target: { value: "https://custom.example/check" } }); + fireEvent.change(field("Role lookup API root (optional)", 1), { target: { value: "https://custom.example" } }); + await userEvent.click(button("Save & apply")); + await screen.findByText("Saved and active. New logins and role lookups use this configuration; online players stay connected."); + expect(calls.setAuthSources).toHaveBeenCalledWith([source(), { tag: "custom", prefix: "CS", url: "https://custom.example/check", api_url: "https://custom.example", enabled: true }], "original"); + expect(field("Permanent source ID", 1).readOnly).toBe(true); + expect(screen.queryByRole("button", { name: "Remove unsaved source" })).toBeNull(); + expect((button("Save & apply") as HTMLButtonElement).disabled).toBe(true); + }); + it("reorders and disables a saved source, while discard restores its original fields and lock", async () => { + calls.getAuthSources.mockResolvedValue(settings([source(), source("custom", "CS")])); + page(); await ready(); + await userEvent.click(screen.getAllByRole("button", { name: "Move source up" })[1]); + expect(field("Permanent source ID").value).toBe("custom"); + await userEvent.click(screen.getAllByRole("checkbox", { name: "Enabled" })[0]); + await userEvent.click(button("Save & apply")); + await waitFor(() => expect(calls.setAuthSources).toHaveBeenCalledWith([{ ...source("custom", "CS"), enabled: false }, source()], "original")); + await waitFor(() => expect((button("Save & apply") as HTMLButtonElement).disabled).toBe(true)); + fireEvent.change(field("Name collision prefix"), { target: { value: "NEW" } }); + await userEvent.click(button("Discard changes")); + expect(field("Name collision prefix").value).toBe("CS"); + expect(field("Permanent source ID").readOnly).toBe(true); + }); + it("preserves a draft on conflict and lets the Owner discard then reload", async () => { + calls.setAuthSources.mockRejectedValue({ status: 409, code: "auth_sources_changed" }); + page(); await ready(); + fireEvent.change(field("Name collision prefix"), { target: { value: "NEW" } }); + await userEvent.click(button("Save & apply")); + await screen.findByRole("alert"); + expect(field("Name collision prefix").value).toBe("NEW"); + expect((button("Reload") as HTMLButtonElement).disabled).toBe(true); + calls.getAuthSources.mockResolvedValue({ ...settings(), sources: [source("littleskin", "UP")], revision: "newest", managed: true }); + await userEvent.click(button("Discard changes")); + await userEvent.click(button("Reload")); + await waitFor(() => expect(field("Name collision prefix").value).toBe("UP")); + }); + it("tests unsaved endpoints without saving, reports unexpected status and clears stale results on edit", async () => { + page(); await ready(); + fireEvent.change(field("Authentication endpoint (hasJoined)"), { target: { value: "https://new.example/check" } }); + calls.testAuthSource.mockResolvedValueOnce({ ok: false, status: 200, elapsed_ms: 10 }); + await userEvent.click(button("Test connection")); + await screen.findByText("Endpoint returned HTTP 200; expected 204 for an unused session."); + expect(calls.testAuthSource).toHaveBeenLastCalledWith({ ...source(), url: "https://new.example/check" }); + expect(calls.setAuthSources).not.toHaveBeenCalled(); + fireEvent.change(field("Authentication endpoint (hasJoined)"), { target: { value: "https://valid.example/check" } }); + expect(screen.queryByRole("alert")).toBeNull(); + await userEvent.click(button("Test connection")); + await screen.findByText("Authentication endpoint passed (18 ms)."); + }); + it("retries a failed initial load with the fields still visible and disabled", async () => { + calls.getAuthSources.mockRejectedValueOnce({ status: 503, code: "auth_sources_unavailable" }); + page(); await screen.findByRole("alert"); + expect(field("Permanent source ID").disabled).toBe(true); + await userEvent.click(button("Try again")); + await ready(); + expect(field("Permanent source ID").value).toBe("littleskin"); + }); + it("supports installations with only the built-in source", async () => { + calls.getAuthSources.mockResolvedValue(settings([])); + page(); await screen.findByText("Only official accounts are enabled. Add a source to accept another provider."); + expect(screen.queryByLabelText("Permanent source ID")).toBeNull(); + await userEvent.click(button("Add source")); + expect(field("Permanent source ID").readOnly).toBe(false); + expect((button("Save & apply") as HTMLButtonElement).disabled).toBe(true); + await userEvent.click(button("Remove unsaved source")); + expect((button("Save & apply") as HTMLButtonElement).disabled).toBe(true); + }); +}); diff --git a/panel/src/pages/admin/AuthSourcesPage.tsx b/panel/src/pages/admin/AuthSourcesPage.tsx new file mode 100644 index 0000000..6edc669 --- /dev/null +++ b/panel/src/pages/admin/AuthSourcesPage.tsx @@ -0,0 +1,151 @@ +import { useEffect, useState } from "react"; +import { ArrowDown, ArrowUp, Check, Loader2, Plus, RefreshCw, Save, ShieldCheck, Trash2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { PageHeader } from "@/components/PageHeader"; +import { InlineError, MessageLine } from "@/components/MessageLine"; +import { isReauthCancelled, useReauth } from "@/components/ReauthDialog"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { api, humanizeError } from "@/lib/api"; +import { useAsync, useUnsavedGuard } from "@/lib/hooks"; +import type { AuthSourceConfig, AuthSourcesSettings } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +const emptySource = (): AuthSourceConfig => ({ tag: "", prefix: "", url: "", api_url: "", enabled: true }); + +function validSource(source: AuthSourceConfig): boolean { + const endpoint = (value: string) => { + try { + const url = new URL(value); + return value.trim() === value && ["http:", "https:"].includes(url.protocol) && !!url.hostname && !/[?#]/.test(value); + } catch { return false; } + }; + return !!source.tag && source.tag.trim() === source.tag && !source.tag.includes(":") && source.tag.toLowerCase() !== "mojang" && + /^[a-z0-9]{1,4}$/i.test(source.prefix) && endpoint(source.url) && (!source.api_url || endpoint(source.api_url)); +} + +type SourceDraft = AuthSourceConfig & { locked?: boolean }; +type Draft = Omit & { sources: SourceDraft[]; original: string }; +const wireSources = (sources: SourceDraft[]): AuthSourceConfig[] => sources.map(({ locked: _locked, ...source }) => source); +const loadedDraft = (settings: AuthSourcesSettings): Draft => ({ ...settings, sources: settings.sources.map((source) => ({ ...source, locked: true })), original: JSON.stringify(settings.sources) }); + +export function AuthSourcesPage() { + const { t } = useTranslation("authSources"); + const query = useAsync(api.getAuthSources, []); + const reauth = useReauth(); + const [draft, setDraft] = useState(null); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(null); + const [checks, setChecks] = useState>({}); + const [message, setMessage] = useState<{ kind: "success" | "error"; text: string } | null>(null); + useEffect(() => { + if (query.data) setDraft((current) => current && JSON.stringify(wireSources(current.sources)) !== current.original ? current : loadedDraft(query.data!)); + }, [query.data]); + const dirty = draft !== null && JSON.stringify(wireSources(draft.sources)) !== draft.original; + useUnsavedGuard(dirty); + const busy = saving || testing !== null || query.loading; + const valid = draft !== null && draft.sources.every(validSource) && + new Set(draft.sources.map((s) => s.tag)).size === draft.sources.length && + new Set(draft.sources.map((s) => s.prefix.toLowerCase())).size === draft.sources.length; + + function edit(sources: SourceDraft[]) { + setDraft((current) => current && { ...current, sources }); + setChecks({}); + setMessage(null); + } + + function discard() { + if (!draft) return; + setDraft(loadedDraft({ ...draft, sources: JSON.parse(draft.original) as AuthSourceConfig[] })); + setChecks({}); + setMessage(null); + } + + async function save() { + if (!draft || !dirty || !valid || busy) return; + setSaving(true); + setMessage(null); + try { + const saved = await reauth.guard(() => api.setAuthSources(wireSources(draft.sources), draft.revision)); + setDraft(loadedDraft(saved)); + setMessage({ kind: "success", text: t("saved") }); + } catch (error) { + if (!isReauthCancelled(error)) setMessage({ kind: "error", text: humanizeError(error) }); + } finally { setSaving(false); } + } + + async function testSource(index: number) { + if (!draft || busy || !validSource(draft.sources[index])) return; + setTesting(index); + try { + const result = await api.testAuthSource(wireSources(draft.sources)[index]); + setChecks((current) => ({ ...current, [index]: { kind: result.ok ? "success" : "error", text: result.ok ? t("test_ok", { ms: result.elapsed_ms }) : t("test_status", { status: result.status }) } })); + } catch (error) { + setChecks((current) => ({ ...current, [index]: { kind: "error", text: humanizeError(error) } })); + } finally { setTesting(null); } + } + + return
+ + + +

{t("builtin_title")}

{t("builtin_hint")}

+
+
+

{t("custom_title")}

{t("priority_hint")}

+
+ + +
+
+ {query.loading &&

{t("loading")}

} + {query.error != null &&
} +
+ {(draft?.sources ?? [emptySource()]).map((source, index) => { + const locked = "locked" in source && source.locked === true; + const set = (patch: Partial) => draft && edit(draft.sources.map((s, i) => i === index ? { ...s, ...patch } : s)); + const move = (offset: number) => { + if (!draft) return; + const sources = [...draft.sources]; + [sources[index], sources[index + offset]] = [sources[index + offset], sources[index]]; + edit(sources); + }; + return + +
{index + 2}{source.tag || t(draft ? "new_source" : "loading")}
+
+ + + + {!locked && } +
+
+ +
+
set({ tag: e.target.value })} />

{t(locked ? "tag_locked" : "tag_hint")}

+
set({ prefix: e.target.value })} />

{t("prefix_hint")}

+
+
set({ url: e.target.value })} />

{t("url_hint")}

+
set({ api_url: e.target.value })} />

{t("api_hint")}

+

{t("test_hint")}

+ {checks[index] && } +
+
; + })} + {draft?.sources.length === 0 && {t("empty")}} +
+

{t("disable_hint")}

+ {message && } + {draft && !valid && } +
+

{draft ? t(dirty ? "unsaved" : draft.managed ? "active" : "defaults") : query.loading ? t("loading") : ""}

+
+ {dirty && } + +
+
+ {reauth.dialog} +
; +}