Unverified Commit 7860152f authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

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.
parent c96b36a4
Loading
Loading
Loading
Loading
+15 −7
Changes for cmd/felis/api.go: 15 added lines, 7 removed lines.
Original line number Diff line number Diff line
@@ -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
Changes for cmd/felis/breakglass.go: 8 added lines, 7 removed lines.
Original line number Diff line number Diff line
@@ -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
Changes for cmd/felis/setup.go: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -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
Changes for cmd/felis/setup_panel.go: 4 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -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
Changes for cmd/felis/tui_connect.go: 4 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -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."),
	)))
}

Loading