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.
139 lines
4.9 KiB
Go
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),
|
|
})
|
|
}
|