Files
Felis/internal/api/handlers_auth_options.go
T
flyemoji 0c1cc598c1 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 ./...).
2026-07-04 21:47:12 +09:00

96 lines
3.9 KiB
Go

package api
import (
"errors"
"net/http"
"strings"
)
// Pre-session identifier-first discovery (spec §B, #71). Given a typed email, this
// Public door reports which console login methods the account can use, so the SPA's
// identifier-first form can prompt for the right authenticator (a passkey assertion,
// or "we'll email you a code") instead of guessing.
//
// It is the deliberate counter-slice to the anti-enumeration login doors
// (handlers_auth_email.go, handlers_passkey.go): those refuse to disclose whether an
// address has an account precisely because THIS endpoint is the one sanctioned place
// existence is revealed. An empty methods array means "no (verified) account". That
// makes it a mass-enumeration surface by design — an accepted product decision, the
// same one the email door's header records. It is bounded only at the edge: the
// handler sends no mail and mutates nothing, so a per-recipient cooldown would merely
// block a legitimate retry, and per-source (client-IP) limiting is the edge's job
// (behind Cloudflare RemoteAddr is the proxy, and CGNAT would false-positive) — see
// the handlers_auth_email.go header for the same reasoning.
//
// It never reveals STAFFNESS. Methods are computed by the SAME rule for every resolved
// account — no role branch, no operator hint — so a staff email and a player email in
// the same credential state return byte-identical bodies. The console doors' own
// post-redemption staff refusal is not previewed here: a staff caller is told
// email_otp is available and is turned away only later, at op.console's Zero-Trust
// gate. Staffness is thus invisible by construction, with no side channel to regress.
type authOptionsRequest struct {
Email string `json:"email"`
}
// handleAuthOptions resolves the typed email and returns the console login methods it
// can use. Public, pre-session, gated on local_auth_enabled like its sibling doors.
func (a *API) handleAuthOptions(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"session login is disabled"))
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var req authOptionsRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
email := strings.TrimSpace(req.Email)
if !looksLikeEmail(email) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
return
}
// methods is initialised non-nil so the no-account branch marshals as [] (not null).
methods := []string{}
u, err := a.Repo.UserByEmail(r.Context(), email)
switch {
case errors.Is(err, ErrNotFound):
// The sanctioned existence oracle: an unknown (or not-yet-verified) address is
// not disguised — it honestly reports no methods.
writeJSON(w, http.StatusOK, map[string]any{"methods": methods})
return
case err != nil:
writeError(w, r, err)
return
}
// Compute methods identically for EVERY resolved account. There is deliberately no
// branch on u.Role: a staff address must be indistinguishable from a player address
// in the same credential state, so the response carries nothing account-identifying.
//
// Advertise passkey only when a verifier is actually wired: both login halves 503
// passkey_unavailable when a.Passkey is nil regardless of enrolled credentials, so
// options must not offer a method the finish door would immediately reject.
if a.Passkey != nil {
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), u.ID)
if err != nil {
writeError(w, r, err)
return
}
if len(creds) > 0 {
methods = append(methods, "passkey")
}
}
// Email-OTP login works for any resolved verified account (UserByEmail resolves only
// email_verified rows), so it is always on offer.
methods = append(methods, "email_otp")
writeJSON(w, http.StatusOK, map[string]any{"methods": methods})
}