fix(api): passkey 的 RP ID 按面板主机推导,只配 root_domain 的部署也能用 passkey

This commit is contained in:
Lemon-miaow committed 2026-09-27 14:18:33 +08:00
1 parent e9e9a7a622
commit 4839d52f88
2 files changed
+54 -13

No files matched your search

+22 -13
View File
@@ -49,6 +49,22 @@ import (
// the code marks Identity (UUIDs trusted verbatim); config can never add another. // the code marks Identity (UUIDs trusted verbatim); config can never add another.
const mojangSessionServer = "https://sessionserver.mojang.com/session/minecraft/hasJoined" const mojangSessionServer = "https://sessionserver.mojang.com/session/minecraft/hasJoined"
// passkeyRelyingParty is the one WebAuthn relying party both web faces share: its id
// is the player console host, derived from server.root_domain the way the panel
// handler derives it when auth.panel_hostname is unset, and its origins are that host
// plus the operator host. An empty id means the install names no panel host at all.
func passkeyRelyingParty(cfg *config.Config) (string, []string) {
rpID := defaultPanelHostname(cfg.Server.RootDomain, cfg.Auth.PanelHostname)
if rpID == "" {
return "", nil
}
origins := []string{"https://" + rpID}
if admin := defaultAdminHostname(cfg.Server.RootDomain, cfg.Auth.AdminHostname); admin != "" && admin != rpID {
origins = append(origins, "https://"+admin)
}
return rpID, origins
}
// authSourcesFromConfig builds the multiplexer's priority list from the configured // authSourcesFromConfig builds the multiplexer's priority list from the configured
// [[auth_source]] entries: Mojang leads as the code-owned identity anchor (正版优先, the ONLY // [[auth_source]] entries: Mojang leads as the code-owned identity anchor (正版优先, the ONLY
// Identity source — config can only append namespace-rewritten third-party sources, never a // Identity source — config can only append namespace-rewritten third-party sources, never a
@@ -393,23 +409,16 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
// enrolled once asserts on either face — one binding, usable on the player console // enrolled once asserts on either face — one binding, usable on the player console
// AND the operator console. Both hosts are therefore listed as permitted origins, // AND the operator console. Both hosts are therefore listed as permitted origins,
// while the RP id stays the panel host so the credential's scope is ONE relying // while the RP id stays the panel host so the credential's scope is ONE relying
// party, not two. Wired only when auth.panel_hostname is configured; otherwise // party, not two. Without a panel host (neither auth.panel_hostname nor
// a.Passkey stays nil and the passkey routes honestly return 503 (the authenticated // server.root_domain) a.Passkey stays nil and the passkey routes honestly return
// enrollment boundary is still enforced by the handlers). // 503 (the authenticated enrollment boundary is still enforced by the handlers).
if cfg.Auth.PanelHostname != "" { if rpID, origins := passkeyRelyingParty(cfg); rpID == "" {
origins := []string{"https://" + cfg.Auth.PanelHostname} fmt.Fprintln(stderr, "felis api: passkey verifier disabled (no panel host: set server.root_domain or auth.panel_hostname) — passkey endpoints return 503")
if admin := defaultAdminHostname(cfg.Server.RootDomain, cfg.Auth.AdminHostname); admin != "" && admin != cfg.Auth.PanelHostname { } else if pv, err := passkey.New(rpID, "Felis", origins); err != nil {
origins = append(origins, "https://"+admin)
}
pv, err := passkey.New(cfg.Auth.PanelHostname, "Felis", origins)
if err != nil {
fmt.Fprintf(stderr, "felis api: passkey verifier disabled: %v — passkey endpoints return 503\n", err) fmt.Fprintf(stderr, "felis api: passkey verifier disabled: %v — passkey endpoints return 503\n", err)
} else { } else {
a.Passkey = pv a.Passkey = pv
} }
} else {
fmt.Fprintln(stderr, "felis api: passkey verifier disabled (auth.panel_hostname unset) — passkey endpoints return 503")
}
// Derive the console hostnames when felis.toml leaves them unset, exactly as the // Derive the console hostnames when felis.toml leaves them unset, exactly as the
// setup/breakGlass paths do — otherwise the SPA cannot tell which face it is // setup/breakGlass paths do — otherwise the SPA cannot tell which face it is
+32
View File
@@ -7,6 +7,7 @@ import (
"io" "io"
"net" "net"
"net/http" "net/http"
"slices"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
@@ -16,6 +17,37 @@ import (
"felis.lolicon.best/internal/config" "felis.lolicon.best/internal/config"
) )
// The passkey relying party follows the panel host the SPA is served on: an install
// that names only its root domain still gets passkeys, on console.<root>, with the
// operator host as the second origin; only an install with no panel host goes without.
func TestPasskeyRelyingParty(t *testing.T) {
for _, tc := range []struct {
name string
root, panel, admin string
wantRP string
wantOrigins []string
}{
{"root domain only", "example.net", "", "", "console.example.net",
[]string{"https://console.example.net", "https://op.console.example.net"}},
{"configured hosts", "example.net", " play.example.net ", "ops.example.net", "play.example.net",
[]string{"https://play.example.net", "https://ops.example.net"}},
{"operator host equal to the panel host", "example.net", "console.example.net", "console.example.net",
"console.example.net", []string{"https://console.example.net"}},
{"panel host without a root domain", "", "console.example.org", "", "console.example.org",
[]string{"https://console.example.org"}},
{"no host at all", "", "", "", "", nil},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := &config.Config{}
cfg.Server.RootDomain, cfg.Auth.PanelHostname, cfg.Auth.AdminHostname = tc.root, tc.panel, tc.admin
rp, origins := passkeyRelyingParty(cfg)
if rp != tc.wantRP || !slices.Equal(origins, tc.wantOrigins) {
t.Fatalf("relying party = %q %q, want %q %q", rp, origins, tc.wantRP, tc.wantOrigins)
}
})
}
}
// TestAuthSourcesFromConfig pins the one place the hasJoined identity anchor is decided: // TestAuthSourcesFromConfig pins the one place the hasJoined identity anchor is decided:
// Mojang is prepended in code, first, and is the only source whose UUIDs are trusted as-is. // Mojang is prepended in code, first, and is the only source whose UUIDs are trusted as-is.
// The empty case matters on its own — both `felis api` and `felis nano` call this with a // The empty case matters on its own — both `felis api` and `felis nano` call this with a