feat(auth): migrate console login to passwordless

Replace console password auth with a passwordless surface — the pre-session
login doors plus an identifier-first discovery endpoint — and remove the
password paths.

- Login doors (Public, pre-session): email-OTP, passkey assertion, op.console
  login with in-game approval, and setup-token redeem.
- /api/v1/auth/options: identifier-first discovery reporting which console
  methods an email can use. The single sanctioned existence oracle; methods
  are computed with no role branch, so staff and player accounts in the same
  credential state return byte-identical bodies (staffness invisible by
  construction).
- Remove password auth: drop StaffUser.PasswordHash and the /auth/login,
  /auth/change-password and /users/{id}/reset-password endpoints (and test).
- Data layer: UserByEmail, verified-email uniqueness, setup-token store
  (migration 0012).
- Reconcile docs/openapi.yaml with the served surface; the method/path/face/
  tier parity gate (TestOpenAPIMatchesServedRoutes) passes.
- felis TUI: in-game MC bind, owner/break-glass OP provisioning, version.
- Velocity /felis command suite.

Consolidates the accumulated backend migration work; the frontend (panel/)
is left untouched. Full Go tree green on WSL (go build ./... && go test ./...).
This commit is contained in:
flyemoji committed 2026-07-04 21:47:12 +09:00
1 parent 627883e89a
commit 0c1cc598c1
46 files changed
+5554 -1651

No files matched your search

+21 -6
View File
@@ -24,6 +24,15 @@ const hostBootstrapKubeconfigPath = "/etc/rancher/k3s/k3s.yaml"
var errHostBootstrapCancelled = errors.New("host bootstrap cancelled")
// channelName maps the --dev flag to the release channel deploy/bootstrap.sh
// understands. Release is the default so a bare `felis setup` is production.
func channelName(dev bool) string {
if dev {
return "dev"
}
return "release"
}
// cmdSetup is the normal first-run operator console. It is intentionally separate
// from breakGlass: setup creates the initial Owner and optional web edge; breakGlass
// is reserved for emergency local recovery/reset.
@@ -31,12 +40,20 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("setup", flag.ContinueOnError)
fs.SetOutput(stderr)
cfgPath := fs.String("config", defaultSetupConfigPath, "path to felis.toml")
dev := fs.Bool("dev", false, "install the dev channel (felis:dev, main HEAD) instead of the default release channel (felis:release, newest tag)")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
// The channel governs which image tag/source ref the host bootstrap builds.
// runBootstrap forwards the whole environment, so exporting it here is enough
// to reach deploy/bootstrap.sh without threading a parameter through the TUI.
if err := os.Setenv("FELIS_CHANNEL", channelName(*dev)); err != nil {
fmt.Fprintf(stderr, "felis setup: %v\n", err)
return 1
}
configFlagSet := false
fs.Visit(func(f *flag.Flag) {
if f.Name == "config" {
@@ -112,18 +129,16 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
}
if res.provisioned {
fmt.Fprintf(stdout, "\nfelis setup: Owner account %q provisioned; local-password login is ENABLED.\n", res.username)
fmt.Fprintf(stdout, "\nfelis setup: Owner account %q provisioned (passwordless).\n", res.username)
fmt.Fprintf(stdout, "Recorded as %q (mode: %s, os user: %s).\n", res.accountable, res.mode, res.osUser)
if res.displayPassword != "" {
fmt.Fprintf(stdout, "One-time password (you MUST change it on first login):\n\n %s\n\n", res.displayPassword)
} else {
fmt.Fprintln(stdout, "Log in with the password you just entered (you MUST change it on first login).")
if res.setupTokenURL != "" {
fmt.Fprintf(stdout, "Open this URL to complete passwordless login setup (verify email / enroll passkey):\n\n %s\n\n", res.setupTokenURL)
}
if res.auditWarning != "" {
fmt.Fprintf(stdout, "WARNING: the accountability audit row was NOT written: %s\n", res.auditWarning)
}
if panelURL != "" {
fmt.Fprintf(stdout, "Log in at %s with that username and password.\n", panelURL)
fmt.Fprintf(stdout, "Admin console: %s\n", panelURL)
fmt.Fprintln(stdout, "The local HTTPS certificate is self-signed; your browser may ask for confirmation on first visit.")
}
}