Files
Felis/internal/api/handlers_setup.go
T
flyemoji c4c964578e fix(setup): stop the forced-onboarding gate trapping players who have no email
setup_required is what the SPA polls to decide whether the onboarding wall is
still owed, and it disagreed with the middleware that actually enforces the
wall. requireOnboarded lifts on a verified email OR an enrolled passkey;
setup_required answered `u.Email == "" || !hasPasskey`. A console-tier player
joins through the bind-code door with no email at all — by design, there is no
SMTP at that point — so the email term never clears and the SPA keeps them on
the setup screen forever, even after they enroll the passkey that already
unlocked the API for them.

The predicate now lives in one place (setupRequired) and both endpoints call
it, so the next edit to the unlock condition cannot drift them apart again.
Keying it on EmailVerified rather than email presence is the deliberate part:
presence is exactly the term that trapped the no-email player, and it was also
wrong on its own terms — an unverified address is not an authentication
factor, so it was never what the lockdown could safely lift on.

Also lands the regression test for the mechanism behind the live claim-403
report: /me/servers answers 200 for a bind-onboarded player (which is why the
dashboard renders the 认领 button at all) while claim, wake and status all
answer 403 with code "setup_required" — i.e. the refusal comes from
requireOnboarded before the handler, not from isOwnerOrAdmin inside it, which
would have said "forbidden". Enrolling a passkey and changing nothing else
lifts all three, which isolates the gate as the sole cause. The backend authz
is correct; the button that leads a locked-down player into a 403 is the
frontend's to hide.
2026-07-22 14:40:28 +09:00

139 lines
4.9 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://op.console.<root>/setup?token=<raw>
//
// The Owner is staff, so onboarding lands on the operator console; the SPA there
// 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 MUST mirror requireOnboarded's unlock (api.go:615); see setupRequired.
"setup_required": setupRequired(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 MUST mirror requireOnboarded's unlock (api.go:615); see setupRequired.
"setup_required": setupRequired(u.EmailVerified, hasPasskey),
})
}