feat(auth)!: go fully passwordless and fix cross-check review findings

Remove password authentication everywhere; the only session doors are
passkey (WebAuthn), email OTP, in-game bind codes, QR scan-login, and
op-login vouching. Remediates the 33-finding cross-check review across
backend, CLI, panel, plugins, and docs.

Backend/CLI:
- Drop password routes and fields from account/user/onboard/auth
  handlers; align tests (new account subtests, naming reserves
  "console", op-login/onboard/qr-login test updates).
- Add migrations 0016_op_login.sql and 0017_drop_password.sql.
- Thread panel/admin hostnames from hostcfg through api.go,
  setup_panel.go, tui_root.go and tui_preflight.go instead of
  hardcoding; bootstrap.sh writes panel-hostname/admin-hostname
  into felis.toml.
- Reword breakglass and TUI copy for passwordless flows.

Panel:
- Delete the ChangePassword page and all password UI; align
  login/auth/api/types with the passwordless contract; add the
  migration and op-login approval flows.
- i18n: convert ImageBuildPage durations/status badges and
  ServerLuckPerms strings to translation keys; drop 72 orphan keys
  per locale; unify the title as "Felis - Console".

Plugins (all six rebuilt):
- Velocity waiting router returns 503 at_capacity during wake;
  MOTD/control-channel copy and config comments.
- Paper zh menu title; Limbo bind-code TTL 600s with panel_url
  preference; unified /link lines in fabric/forge/neoforge; shared
  link-client javadoc contract fixes.

Docs: openapi.yaml, sequence-diagrams.md, deploy/limbo/README.md and
plugins/README.md aligned with the implementation.

BREAKING CHANGE: migration 0017 irreversibly drops
users.password_hash and users.must_change_password; password login
cannot be restored after migrating.
This commit is contained in:
flyemoji committed 2026-07-20 04:47:32 +09:00
1 parent c96b36a41f
commit 7860152f57
97 files changed
+1923 -1444

No files matched your search

+15 -7
View File
@@ -215,12 +215,13 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
Restorer: restorer,
Backuper: backuper,
Submissions: submissions,
// The external face is fronted by SessionAuth: it prefers a local-password
// session cookie and otherwise delegates to the Cloudflare-Access JWT verifier,
// so both auth models coexist on one face. The delegate's Keyfunc is
// intentionally nil — the JWT path fails closed until a JWKS-backed key function
// is wired (deployment integration point) — while the local-password path is
// live the moment `felis breakGlass` flips local_auth_enabled on.
// The external face is fronted by SessionAuth: it prefers a local session
// cookie (minted by the passwordless doors) and otherwise delegates to the
// Cloudflare-Access JWT verifier, so both auth models coexist on one face. The
// delegate's Keyfunc is intentionally nil — the JWT path fails closed until a
// JWKS-backed key function is wired (deployment integration point) — while the
// local session path is live the moment `felis breakGlass` flips
// local_auth_enabled on.
External: api.SessionAuth{
Repo: repo,
Delegate: api.AccessVerifier{Audience: cfg.Auth.AccessJWTAud},
@@ -229,6 +230,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
},
RootDomain: cfg.Server.RootDomain,
AdminHostname: cfg.Auth.AdminHostname,
PanelHostname: cfg.Auth.PanelHostname,
WakeCooldown: 30 * time.Second,
// Bound concurrent console/build-log SSE streams per principal. Generous enough
// for legitimate multi-tab / multi-server watching, while capping how many
@@ -271,7 +273,13 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "felis api: passkey verifier disabled (auth.panel_hostname unset) — passkey endpoints return 503")
}
externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain, cfg.Auth.PanelHostname, cfg.Auth.AdminHostname, resolvedVersion())
// 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
// serving and falls back to the player console on op.console.<root>.
externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain,
defaultPanelHostname(cfg.Server.RootDomain, cfg.Auth.PanelHostname),
defaultAdminHostname(cfg.Server.RootDomain, cfg.Auth.AdminHostname),
resolvedVersion())
internalSrv := newAPIServer(*internalAddr, a.InternalHandler())
externalSrv := newAPIServer(cfg.Server.Listen, externalHandler)
+8 -7
View File
@@ -26,7 +26,7 @@ import (
// authority is local root, so it legitimately BYPASSES the web Zero-Trust + Passkey
// path: critical recovery runs direct-to-Postgres. The thin-thread operation it
// ships here is the one that bootstraps everything else — provision (or reset) the
// single Owner account and turn local-password login on — so that even with the web
// single Owner account and turn local session sign-in on — so that even with the web
// auth path unconfigured an operator can get into op.console. It is a genuine
// interactive TUI, NOT a CLI: bare `felis` prints CLI usage, while `felis breakGlass`
// opens this full-screen console. It refuses to run unless euid is 0 (sudo/root).
@@ -44,7 +44,7 @@ import (
// When a staff account already exists the console opens on a thin top-level menu
// (menuModel) so that operations are peers, not tails of one wizard. Two account
// operations are wired today: (1) provision/reset the Owner — the thin thread above,
// which also re-enables local-password login — and (2) add an Operator: an
// which also re-enables local session sign-in — and (2) add an Operator: an
// insert-only mint of an additional staff admin (provisionOperator) that
// deliberately never touches the global local_auth toggle. On a fresh machine (no
// Owner yet) the menu is skipped: bootstrapping the first Owner is the only sensible
@@ -156,7 +156,7 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
// The TUI runs on the alternate screen, which is torn down on exit and takes its
// display with it. Re-print a durable summary to the normal screen so the
// outcome — and any generated one-time password — survives in scrollback long
// outcome — and the one-time setup URL — survives in scrollback long
// enough for the operator to log in.
if res.provisioned {
if res.isOperator {
@@ -164,7 +164,7 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
// so the summary must not claim it did — only the Owner thread enables login.
fmt.Fprintf(stdout, "\nfelis breakGlass: Operator account %q provisioned.\n", res.username)
} else {
fmt.Fprintf(stdout, "\nfelis breakGlass: Owner account %q provisioned; local-password login is ENABLED.\n", res.username)
fmt.Fprintf(stdout, "\nfelis breakGlass: Owner account %q provisioned; local session sign-in is ENABLED.\n", res.username)
}
fmt.Fprintf(stdout, "Recorded as %q (mode: %s, os user: %s).\n", res.accountable, res.mode, res.osUser)
if res.setupTokenURL != "" {
@@ -318,8 +318,9 @@ func provisionOperator(ctx context.Context, s ownerStore, username, email string
}
// enableLocalAuth flips the runtime local_auth_enabled toggle on
// direct-to-Postgres. It is a load-bearing write of break-glass: without it
// handleLogin returns 403 and the freshly provisioned Owner cannot log in, so a
// direct-to-Postgres. It is a load-bearing write of break-glass: without it every
// session-minting door (passkey / email-OTP / bind / op-login) returns 403
// local_auth_disabled and the freshly provisioned Owner cannot log in, so a
// successful provisionOwner with local auth off is not a usable thin thread.
func enableLocalAuth(ctx context.Context, s ownerStore) error {
// The setting is read back with json.Unmarshal into a bool, so the stored jsonb
@@ -349,7 +350,7 @@ type breakGlassOutcome struct {
}
// performBreakGlass executes a resolved break-glass operation: provision (or reset)
// the Owner, enable local-password login, then record a best-effort accountability
// the Owner, enable local session sign-in, then record a best-effort accountability
// audit row. The Owner is passwordless — the setup-token flow handles first-login
// setup. The audit write is best-effort: a logging failure is reported via auditErr
// but does NOT fail the recovery — break-glass must still work when the audit sink
+1 -1
View File
@@ -123,7 +123,7 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
}
panelURL := res.panelURL
if panelURL == "" {
panelURL = localPanelURL(setup.cfg.Server.RootDomain)
panelURL = localPanelURL(setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname)
}
if !res.provisioned && !res.connectConfigured {
+4 -4
View File
@@ -32,11 +32,11 @@ func setupPanelNodePort() int {
return port
}
func localPanelURL(rootDomain string) string {
func localPanelURL(rootDomain, adminHostname string) string {
if ip := rootDomainEmbeddedIP(rootDomain); ip != "" {
return fmt.Sprintf("https://%s:%d", ip, setupPanelNodePort())
}
host := defaultAdminHostname(rootDomain, "")
host := defaultAdminHostname(rootDomain, adminHostname)
if host == "" {
return ""
}
@@ -61,8 +61,8 @@ func localPanelOrigin() string {
return fmt.Sprintf("https://127.0.0.1:%d", setupPanelNodePort())
}
func checkPanelAccess(rootDomain string) panelAccessResult {
base := localPanelURL(rootDomain)
func checkPanelAccess(rootDomain, adminHostname string) panelAccessResult {
base := localPanelURL(rootDomain, adminHostname)
if base == "" {
return panelAccessResult{err: fmt.Errorf("root domain is empty")}
}
+4 -3
View File
@@ -15,7 +15,8 @@ import (
// None is privileged: "Local" installs nothing, "Cloudflare Tunnel" is a
// turnkey integration, and "Reverse proxy" just records hostnames and hands the
// operator a copy-paste guide. The admin console is gated by the Owner's
// local-password session regardless; Cloudflare Access is an *additional* layer.
// local session (passwordless sign-in) regardless; Cloudflare Access is an
// *additional* layer.
type connectChooserModel struct {
rootDomain string
adminHost string
@@ -46,8 +47,8 @@ func (m *connectChooserModel) build() *huh.Form {
// A dim, untitled footnote — deliberately subordinate to the picker above
// so the screen reads as a menu, not an info page.
huh.NewNote().Description(
"⚠ Local / reverse proxy gate the admin console on your Owner password alone. "+
"Cloudflare Access adds an edge check in front."),
"⚠ Local / reverse proxy gate the admin console on your Owner sign-in alone "+
"(passkey / email code). Cloudflare Access adds an edge check in front."),
)))
}
+1 -1
View File
@@ -72,7 +72,7 @@ func applyCloudflareEdge(ctx context.Context, result *cfsetup.Result, panelHost,
// applyReverseProxy records the operator's chosen public hostnames and rolls the
// API so the panel serves them. No Access audience is set: the admin console is
// gated by the Owner's local-password session, and the operator's own reverse
// gated by the Owner's local session (passwordless sign-in), and the operator's own reverse
// proxy (Caddy/nginx/Traefik/…) terminates TLS in front of the NodePort origin.
func applyReverseProxy(ctx context.Context, panelHost, adminHost string) error {
if adminHost == "" {
+3 -3
View File
@@ -56,10 +56,10 @@ func (m *menuModel) build() *huh.Form {
huh.NewOption("Back up a world now (Sync)", bgSyncBackup),
),
// A dim footnote spelling out the one behavioural difference that matters:
// Owner-reset re-enables local-password login, operator-add never touches the
// global auth toggle.
// Owner-reset re-enables local session sign-in, operator-add never touches
// the global auth toggle.
huh.NewNote().Description(
"Owner reset re-enables local-password login. Adding an Operator mints an "+
"Owner reset re-enables local session sign-in. Adding an Operator mints an "+
"additional staff admin and leaves the global auth toggle untouched."),
)))
}
+1 -1
View File
@@ -96,7 +96,7 @@ func TestProvisionCmdSelectsPathByOperation(t *testing.T) {
if msg.err != nil {
t.Fatalf("owner provision: %v", msg.err)
}
// performBreakGlass upserts the single Owner and enables local-password login.
// performBreakGlass upserts the single Owner and enables local session sign-in.
if len(f.upserts) != 1 || len(f.inserts) != 0 {
t.Fatalf("want 1 upsert and 0 inserts (performBreakGlass), got upserts=%d inserts=%d", len(f.upserts), len(f.inserts))
}
+2 -2
View File
@@ -103,7 +103,7 @@ func newOwnerModel(ctx context.Context, store ownerStore, osUser string, adminEx
// newOperatorModel builds the model for the Add-Operator break-glass operation. It
// always starts at admin authentication: adding an Operator presupposes an existing
// admin (that is why the menu only offers it when one exists), so there is no
// bootstrap branch and the password is always generated. The username is left empty
// bootstrap branch. The username is left empty
// on purpose — defaulting it to "owner" (as the Owner flow does) would make the
// happy path insert a duplicate and hit ErrConflict on every attempt.
func newOperatorModel(ctx context.Context, store ownerStore, osUser string) *ownerModel {
@@ -285,7 +285,7 @@ func (m *ownerModel) provisionCmd() tea.Cmd {
// performAddOperator and performBreakGlass share a signature; the operation
// discriminator selects which one runs. The operator path is insert-only and
// never flips local auth (see performAddOperator); the Owner path upserts and
// enables local-password login.
// enables local session sign-in.
perform := performBreakGlass
if m.operation == bgAddOperator {
perform = performAddOperator
+4 -3
View File
@@ -16,6 +16,7 @@ import (
type preflightModel struct {
dbURL string
rootDomain string
adminHost string
sp spinner.Model
state pfState
@@ -55,11 +56,11 @@ type pfMigApplyMsg struct {
type pfPanelMsg struct{ err error }
func newPreflightModel(dbURL, rootDomain string) *preflightModel {
func newPreflightModel(dbURL, rootDomain, adminHostname string) *preflightModel {
sp := spinner.New()
sp.Spinner = spinner.Dot
sp.Style = tuiLabel
return &preflightModel{dbURL: dbURL, rootDomain: rootDomain, sp: sp, state: pfCheckDB}
return &preflightModel{dbURL: dbURL, rootDomain: rootDomain, adminHost: adminHostname, sp: sp, state: pfCheckDB}
}
func (m *preflightModel) Init() tea.Cmd {
@@ -221,7 +222,7 @@ func (m *preflightModel) applyMigrations() tea.Cmd {
func (m *preflightModel) checkPanel() tea.Cmd {
return func() tea.Msg {
return pfPanelMsg{err: checkPanelAccess(m.rootDomain).err}
return pfPanelMsg{err: checkPanelAccess(m.rootDomain, m.adminHost).err}
}
}
+5 -5
View File
@@ -179,7 +179,7 @@ func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, admi
}
} else {
rm.stage = stagePreflight
rm.screen = newPreflightModel(dbURL, rootDomain)
rm.screen = newPreflightModel(dbURL, rootDomain, adminHostname)
}
return rm
}
@@ -524,7 +524,7 @@ func (m *rootModel) applyConnectResult(msg connectResultMsg) {
m.result.connectConfigured = true
m.result.reverseProxyGuide = msg.guide
}
m.result.panelURL = panelURLFor(msg.method, msg.panelHostname, m.rootDomain)
m.result.panelURL = panelURLFor(msg.method, msg.panelHostname, m.rootDomain, m.adminHost)
}
func (m *rootModel) showSummary() (tea.Model, tea.Cmd) {
@@ -555,7 +555,7 @@ func (m *rootModel) showStatus() (tea.Model, tea.Cmd) {
method = connectCloudflare
accessLabel = connectMethodLabel(connectCloudflare)
}
m.result.panelURL = panelURLFor(method, m.panelHost, m.rootDomain)
m.result.panelURL = panelURLFor(method, m.panelHost, m.rootDomain, m.adminHost)
return m.adopt(&summaryModel{
panelURL: m.result.panelURL,
accessLabel: accessLabel,
@@ -564,9 +564,9 @@ func (m *rootModel) showStatus() (tea.Model, tea.Cmd) {
})
}
func panelURLFor(method connectMethod, panelHostname, rootDomain string) string {
func panelURLFor(method connectMethod, panelHostname, rootDomain, adminHostname string) string {
if method != connectLocal && panelHostname != "" {
return "https://" + panelHostname
}
return localPanelURL(rootDomain)
return localPanelURL(rootDomain, adminHostname)
}