Files
Felis/internal/api/handlers_setup.go
T

133 lines
4.8 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.
a.authFailure(r, "setup_redeem", "bad_token", nil)
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.
if err := a.startSession(w, r, u.ID); err != nil {
writeError(w, r, err)
return
}
// 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.auditAccount(r, u, "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),
})
}