Files
Felis/internal/api/handlers_setup.go
T
flyemoji 9ea35304e3 fix(setup): source the console host from the panel hostname, not op.console
The owner setup URL and the limbo login link were built from the admin host
(op.console.<root>, with an op.console.localhost fallback) and a hardcoded
console.<root>, so an operator who set a custom panel_hostname got an unreachable setup
link and a wrong login target. Thread the resolved panel host (defaultPanelHostname)
through performSetupMCBind, the MC-bind TUI, and the login system-server env
(new FELIS_PANEL_HOSTNAME); the limbo plugin prefers it and keeps console.<root> only as
the fallback for an older operator whose env predates it. This also matters for security:
the only wired WebAuthn verifier is scoped to the panel host, so passkey enrollment must
land on the panel face, never op.console.

While here, the limbo login handler checks link status before minting a bind code: an
already-linked player is sent straight to the lobby instead of being shown a useless code.
2026-07-16 13:27:02 +09:00

136 lines
4.7 KiB
Go

package api
import (
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"strings"
)
// Setup-token redemption (spec §B setup bootstrap). The `felis setup` MC-bind
// flow mints a one-time token and prints a URL like:
//
// https://console.<root>/setup?token=<raw>
//
// The Owner opens that URL in a browser; the SPA reads the token from the query
// string and POSTs it here. This handler consumes the token (single-use, hashed
// at rest like session cookies), mints a felis_session, and returns the caller's
// setup state so the frontend can guide email verification + passkey enrollment
// before unlocking the admin console.
//
// The minted session is a "lockdown" session in product terms: the Owner has not
// yet proven control of an email or enrolled a passkey, so the frontend restricts
// it to the setup wizard. Backend enforcement of the lockdown is a separate
// middleware concern (checking email_verified on the principal); this handler's
// job is the one-time token→session swap and reporting what setup remains.
// setupRedeemRequest is the redeem body: the raw one-time token from the setup URL.
type setupRedeemRequest struct {
Token string `json:"token"`
}
// handleSetupRedeem consumes a one-time setup token and mints a lockdown session
// (Public, pre-session). The token is hashed (sha-256) before lookup — only the
// hash is persisted, mirroring session-cookie storage. On success the caller
// receives a felis_session cookie and a JSON body describing the remaining setup
// steps (email set? verified? passkey enrolled?) so the SPA can drive the wizard.
func (a *API) handleSetupRedeem(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 setupRedeemRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
token := strings.TrimSpace(req.Token)
if token == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "token is required"))
return
}
// Hash the raw token — only the hash is stored (mirroring session cookies and
// setup token creation in performSetupMCBind).
sum := sha256.Sum256([]byte(token))
tokenHash := hex.EncodeToString(sum[:])
now := a.now()
userID, err := a.Repo.ConsumeSetupToken(r.Context(), tokenHash, now)
if err != nil {
// Unknown, already-consumed, or expired — uniform 400 so the token cannot
// be used as an oracle.
writeError(w, r, newError(http.StatusBadRequest, "setup_token_invalid",
"this setup link is invalid or has already been used"))
return
}
u, err := a.Repo.UserByID(r.Context(), userID)
if err != nil {
writeError(w, r, err)
return
}
// Mint the session — a regular felis_session; the lockdown is a product-level
// restriction the frontend enforces until email is verified / a passkey is bound.
sessionToken, err := newSessionToken()
if err != nil {
writeError(w, r, err)
return
}
expires := now.Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(sessionToken), u.ID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, sessionToken, expires)
// Report the setup state so the SPA knows which wizard steps remain.
creds, _ := a.Repo.PasskeyCredentialsForUser(r.Context(), u.ID)
hasPasskey := len(creds) > 0
a.audit(r, u.Username, "auth.setup_redeem", "")
writeJSON(w, http.StatusOK, map[string]any{
"user_id": u.ID,
"username": u.Username,
"role": u.Role,
"email": u.Email,
"email_verified": u.EmailVerified,
"has_passkey": hasPasskey,
"setup_required": !u.EmailVerified || !hasPasskey,
})
}
// handleSetupStatus reports the caller's setup progress (app-tier). The SPA polls
// it after each wizard step (email verify, passkey enroll) to decide whether the
// lockdown can lift. It reads only the principal's own state.
func (a *API) handleSetupStatus(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
u, err := a.Repo.UserByID(r.Context(), p.UserID)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, r, newError(http.StatusNotFound, "not_found", "user not found"))
return
}
writeError(w, r, err)
return
}
creds, _ := a.Repo.PasskeyCredentialsForUser(r.Context(), u.ID)
hasPasskey := len(creds) > 0
writeJSON(w, http.StatusOK, map[string]any{
"user_id": u.ID,
"username": u.Username,
"role": u.Role,
"email": u.Email,
"email_verified": u.EmailVerified,
"has_passkey": hasPasskey,
"setup_required": !u.EmailVerified || !hasPasskey,
})
}