At bootstrap there is no SMTP, so the old /setup flow was unreachable: it requested an emailed OTP that could never arrive. Setup now records the Owner's email address unverified (no OTP round-trip) and requires a passkey, deferring SMTP configuration to a later Settings page. Setup completes on email-recorded + passkey-enrolled, and the lockdown lifts on the passkey, not on email_verified: a passkey is the Owner's only pre-SMTP login credential (email-OTP login refuses admin accounts). The record-email endpoint (POST /account/email) now clears email_verified in the same write. Only VerifyEmailOTP, which proves control of the address, may set that flag; recording a fresh unproven address must never leave a stale email_verified=true asserting a proof the user never gave. The change strictly tightens the invariant, so no existing reader breaks. Remove the dead ErrEmailTaken path and its documented 409: no migration puts a unique index on users.email and the codebase does not enforce email uniqueness, so the unique-violation branch was unreachable and the 409 an impossible response. The /setup route (Setup.tsx, setEmail helper, setup i18n copy) is rewritten to match: record-email, mandatory passkey, no skip-for-now. The SMTP settings page and post-setup configure-SMTP nudge are deferred.
145 lines
5.3 KiB
Go
145 lines
5.3 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 completes on email recorded + passkey enrolled. NOT email_verified:
|
|
// the bootstrap has no SMTP, so the Owner's address is stored unverified and a
|
|
// later Settings/SMTP flow verifies it. Passkey is the Owner's only pre-SMTP
|
|
// login credential, so it — not email verification — is the durable gate.
|
|
"setup_required": u.Email == "" || !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 completes on email recorded + passkey enrolled. NOT email_verified:
|
|
// the bootstrap has no SMTP, so the Owner's address is stored unverified and a
|
|
// later Settings/SMTP flow verifies it. Passkey is the Owner's only pre-SMTP
|
|
// login credential, so it — not email verification — is the durable gate.
|
|
"setup_required": u.Email == "" || !hasPasskey,
|
|
})
|
|
}
|