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:
97 files changed
+1923
-1444
No files matched your search
+15
-7
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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")}
|
||||
}
|
||||
|
||||
@@ -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."),
|
||||
)))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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."),
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user