fix(api): setup 链接在同一事务里消费并建会话,存储故障回 500 且链接仍可重试
This commit is contained in:
13 files changed
+312
-47
No files matched your search
+5
-1
@@ -3359,7 +3359,9 @@ paths:
|
|||||||
the felis TUI (stored and looked up by SHA-256 hash, like session cookies),
|
the felis TUI (stored and looked up by SHA-256 hash, like session cookies),
|
||||||
mints a host-only felis_session, and returns the remaining setup steps so the
|
mints a host-only felis_session, and returns the remaining setup steps so the
|
||||||
SPA can drive the wizard. An unknown, consumed, or expired token returns a
|
SPA can drive the wizard. An unknown, consumed, or expired token returns a
|
||||||
uniform 400 setup_token_invalid. Gated on local_auth_enabled.
|
uniform 400 setup_token_invalid. Gated on local_auth_enabled. The token is
|
||||||
|
spent in the same transaction that stores the session, so a redemption that
|
||||||
|
fails with 500 leaves the link working for another try.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: public
|
x-felis-tier: public
|
||||||
security: []
|
security: []
|
||||||
@@ -3407,6 +3409,8 @@ paths:
|
|||||||
schema: { $ref: '#/components/schemas/Error' }
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
'429':
|
'429':
|
||||||
$ref: '#/components/responses/RateLimited'
|
$ref: '#/components/responses/RateLimited'
|
||||||
|
'500':
|
||||||
|
$ref: '#/components/responses/InternalError'
|
||||||
|
|
||||||
/api/v1/auth/setup/status:
|
/api/v1/auth/setup/status:
|
||||||
get:
|
get:
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ type fakeRepo struct {
|
|||||||
failRevokeOthers error
|
failRevokeOthers error
|
||||||
failMarkReauth error
|
failMarkReauth error
|
||||||
failGetSetting error
|
failGetSetting error
|
||||||
|
failRedeemSetup error
|
||||||
// player email OTPs (spec §B2). Keyed by row id; the verify path scans for the
|
// player email OTPs (spec §B2). Keyed by row id; the verify path scans for the
|
||||||
// newest live (user, purpose) just as the PG query does.
|
// newest live (user, purpose) just as the PG query does.
|
||||||
otps map[string]*fakeEmailOTP
|
otps map[string]*fakeEmailOTP
|
||||||
@@ -1779,15 +1780,23 @@ func (f *fakeRepo) ApproveOpLogin(_ context.Context, id, approverUserID string,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConsumeSetupToken atomically marks a one-time setup token consumed and returns
|
// RedeemSetupToken spends a setup token and stores s for its user, or ErrNotFound
|
||||||
// its user_id, or ErrNotFound when absent, already consumed, or expired.
|
// when the token is absent, already spent, or expired. failRedeemSetup fails the
|
||||||
func (f *fakeRepo) ConsumeSetupToken(_ context.Context, tokenHash string, now time.Time) (string, error) {
|
// whole redemption.
|
||||||
|
func (f *fakeRepo) RedeemSetupToken(ctx context.Context, tokenHash string, now time.Time, s NewSession) (string, error) {
|
||||||
|
if f.failRedeemSetup != nil {
|
||||||
|
return "", f.failRedeemSetup
|
||||||
|
}
|
||||||
tok, ok := f.setupTokens[tokenHash]
|
tok, ok := f.setupTokens[tokenHash]
|
||||||
if !ok || !tok.ConsumedAt.IsZero() || !tok.ExpiresAt.After(now) {
|
if !ok || !tok.ConsumedAt.IsZero() || !tok.ExpiresAt.After(now) {
|
||||||
return "", ErrNotFound
|
return "", ErrNotFound
|
||||||
}
|
}
|
||||||
tok.ConsumedAt = now
|
tok.ConsumedAt = now
|
||||||
f.setupTokens[tokenHash] = tok
|
f.setupTokens[tokenHash] = tok
|
||||||
|
s.UserID = tok.UserID
|
||||||
|
if err := f.CreateSession(ctx, s); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
return tok.UserID, nil
|
return tok.UserID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,26 +62,32 @@ func (a *API) handleSetupRedeem(w http.ResponseWriter, r *http.Request) {
|
|||||||
sum := sha256.Sum256([]byte(token))
|
sum := sha256.Sum256([]byte(token))
|
||||||
tokenHash := hex.EncodeToString(sum[:])
|
tokenHash := hex.EncodeToString(sum[:])
|
||||||
|
|
||||||
now := a.now()
|
// A regular felis_session; the lockdown is a product-level restriction the
|
||||||
userID, err := a.Repo.ConsumeSetupToken(r.Context(), tokenHash, now)
|
// frontend enforces until email is verified / a passkey is bound. The token and
|
||||||
|
// the session are written together: a new setup link takes `felis setup` on
|
||||||
|
// the node, so a failure here must leave this one working.
|
||||||
|
sessionToken, session, err := a.mintSession(r, "", provenSignIn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, err := a.Repo.RedeemSetupToken(r.Context(), tokenHash, a.now(), session)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
// Unknown, already-consumed, or expired — uniform 400 so the token cannot
|
// Unknown, already-consumed, or expired — uniform 400 so the token cannot
|
||||||
// be used as an oracle.
|
// be used as an oracle.
|
||||||
a.authFailure(r, "setup_redeem", "bad_token", nil)
|
a.authFailure(r, "setup_redeem", "bad_token", nil)
|
||||||
writeError(w, r, newError(http.StatusBadRequest, "setup_token_invalid",
|
writeError(w, r, newError(http.StatusBadRequest, "setup_token_invalid",
|
||||||
"this setup link is invalid or has already been used"))
|
"this setup link is invalid or has already been used"))
|
||||||
return
|
return
|
||||||
}
|
case err != nil:
|
||||||
|
|
||||||
u, err := a.Repo.UserByID(r.Context(), userID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, r, err)
|
writeError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setSessionCookie(w, sessionToken, session.ExpiresAt)
|
||||||
|
|
||||||
// Mint the session — a regular felis_session; the lockdown is a product-level
|
u, err := a.Repo.UserByID(r.Context(), userID)
|
||||||
// restriction the frontend enforces until email is verified / a passkey is bound.
|
if err != nil {
|
||||||
if err := a.startSession(w, r, u.ID, provenSignIn); err != nil {
|
|
||||||
writeError(w, r, err)
|
writeError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestSetupNoSMTPFlow pins the no-SMTP onboarding contract: setup completes on email
|
// TestSetupNoSMTPFlow pins the no-SMTP onboarding contract: setup completes on email
|
||||||
@@ -137,6 +141,55 @@ func TestSetupCompletesForNoEmailPlayer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redeeming a setup link signs the owner in once. A link the store does not know
|
||||||
|
// (unknown, spent, expired) is the uniform 400; a store that fails is an outage,
|
||||||
|
// answered as one, so the page does not tell the owner a link that still works
|
||||||
|
// was used up.
|
||||||
|
func TestSetupRedeem(t *testing.T) {
|
||||||
|
const raw = "setup-token-raw"
|
||||||
|
sum := sha256.Sum256([]byte(raw))
|
||||||
|
hash := hex.EncodeToString(sum[:])
|
||||||
|
body := `{"token":"` + raw + `"}`
|
||||||
|
setup := func() (*fakeRepo, http.Handler) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
repo.settings[LocalAuthEnabledKey] = []byte("true")
|
||||||
|
repo.staff["owner"] = &StaffUser{ID: "o1", Username: "owner", Role: "admin"}
|
||||||
|
repo.setupTokens[hash] = fakeSetupToken{TokenHash: hash, UserID: "o1",
|
||||||
|
ExpiresAt: time.Unix(1_700_000_000, 0).Add(10 * time.Minute)}
|
||||||
|
return repo, newTestAPI(repo, newFakeCluster()).ExternalHandler()
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("redeems once", func(t *testing.T) {
|
||||||
|
repo, h := setup()
|
||||||
|
w := do(h, "POST", "/api/v1/auth/setup/redeem", body, jsonHeader)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("redeem: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
cookies := w.Result().Cookies()
|
||||||
|
if len(cookies) != 1 || cookies[0].Name != sessionCookieName {
|
||||||
|
t.Fatalf("cookies = %v, want one %s", cookies, sessionCookieName)
|
||||||
|
}
|
||||||
|
if s, ok := repo.sessions[hashCookie(cookies[0].Value)]; !ok || s.userID != "o1" {
|
||||||
|
t.Fatalf("session for the cookie = %+v (found %v), want one of o1", s, ok)
|
||||||
|
}
|
||||||
|
if w := do(h, "POST", "/api/v1/auth/setup/redeem", body, jsonHeader); w.Code != http.StatusBadRequest ||
|
||||||
|
errCode(w.Body.Bytes()) != "setup_token_invalid" || len(w.Result().Cookies()) != 0 {
|
||||||
|
t.Fatalf("replay: code = %d err = %q cookies = %v, want 400 setup_token_invalid and none",
|
||||||
|
w.Code, errCode(w.Body.Bytes()), w.Result().Cookies())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("store outage", func(t *testing.T) {
|
||||||
|
repo, h := setup()
|
||||||
|
repo.failRedeemSetup = errors.New("connection refused")
|
||||||
|
w := do(h, "POST", "/api/v1/auth/setup/redeem", body, jsonHeader)
|
||||||
|
if w.Code != http.StatusInternalServerError || errCode(w.Body.Bytes()) != "internal" || len(w.Result().Cookies()) != 0 {
|
||||||
|
t.Fatalf("outage: code = %d err = %q cookies = %v, want 500 internal and no cookie",
|
||||||
|
w.Code, errCode(w.Body.Bytes()), w.Result().Cookies())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// errCode returns the error.code of a JSON error body, or "" if body is not one (a
|
// errCode returns the error.code of a JSON error body, or "" if body is not one (a
|
||||||
// non-failing decodeErr for cases where the response may be a success).
|
// non-failing decodeErr for cases where the response may be a success).
|
||||||
func errCode(body []byte) string {
|
func errCode(body []byte) string {
|
||||||
|
|||||||
+30
-7
@@ -1416,8 +1416,17 @@ func (p *PGRepo) InsertOperator(ctx context.Context, id, username, email string)
|
|||||||
// CreateSession records a minted session by the sha-256 of its cookie value
|
// CreateSession records a minted session by the sha-256 of its cookie value
|
||||||
// (spec §B). Only the hash is stored, mirroring tokens.
|
// (spec §B). Only the hash is stored, mirroring tokens.
|
||||||
func (p *PGRepo) CreateSession(ctx context.Context, s NewSession) error {
|
func (p *PGRepo) CreateSession(ctx context.Context, s NewSession) error {
|
||||||
|
return insertSession(ctx, p.db, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sqlExecer is the write half shared by *sql.DB and *sql.Tx.
|
||||||
|
type sqlExecer interface {
|
||||||
|
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertSession(ctx context.Context, db sqlExecer, s NewSession) error {
|
||||||
reauth := sql.NullTime{Time: s.ReauthAt, Valid: !s.ReauthAt.IsZero()}
|
reauth := sql.NullTime{Time: s.ReauthAt, Valid: !s.ReauthAt.IsZero()}
|
||||||
_, err := p.db.ExecContext(ctx,
|
_, err := db.ExecContext(ctx,
|
||||||
`INSERT INTO sessions (token_hash, user_id, expires_at, user_agent, client_ip, reauth_at)
|
`INSERT INTO sessions (token_hash, user_id, expires_at, user_agent, client_ip, reauth_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
s.TokenHash, s.UserID, s.ExpiresAt, s.UserAgent, s.ClientIP, reauth)
|
s.TokenHash, s.UserID, s.ExpiresAt, s.UserAgent, s.ClientIP, reauth)
|
||||||
@@ -2956,12 +2965,19 @@ func (p *PGRepo) ConsumeOpLoginRequest(ctx context.Context, id string, now time.
|
|||||||
|
|
||||||
// ---- setup token redemption (spec §B) ----
|
// ---- setup token redemption (spec §B) ----
|
||||||
|
|
||||||
// ConsumeSetupToken atomically marks a one-time setup token consumed and returns
|
// RedeemSetupToken spends a one-time setup token and stores s as a session of the
|
||||||
// its user_id, or ErrNotFound when the token is absent, already consumed, or
|
// token's user in one transaction, so a failure leaves the token unspent. It
|
||||||
// expired. The /setup?token=... web flow redeems it for a lockdown session.
|
// returns the user id, or ErrNotFound when the token is absent, already spent, or
|
||||||
func (p *PGRepo) ConsumeSetupToken(ctx context.Context, tokenHash string, now time.Time) (string, error) {
|
// expired.
|
||||||
|
func (p *PGRepo) RedeemSetupToken(ctx context.Context, tokenHash string, now time.Time, s NewSession) (string, error) {
|
||||||
|
tx, err := p.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck // no-op after commit
|
||||||
|
|
||||||
var userID string
|
var userID string
|
||||||
switch err := p.db.QueryRowContext(ctx,
|
switch err := tx.QueryRowContext(ctx,
|
||||||
`UPDATE setup_tokens SET consumed_at = $2
|
`UPDATE setup_tokens SET consumed_at = $2
|
||||||
WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > $2
|
WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > $2
|
||||||
RETURNING user_id`,
|
RETURNING user_id`,
|
||||||
@@ -2971,6 +2987,13 @@ func (p *PGRepo) ConsumeSetupToken(ctx context.Context, tokenHash string, now ti
|
|||||||
case err != nil:
|
case err != nil:
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
s.UserID = userID
|
||||||
|
if err := insertSession(ctx, tx, s); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
return userID, nil
|
return userID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2978,7 +3001,7 @@ func (p *PGRepo) ConsumeSetupToken(ctx context.Context, tokenHash string, now ti
|
|||||||
// hash (the raw value rides in the /setup?token=... URL). The setup Owner-bind
|
// hash (the raw value rides in the /setup?token=... URL). The setup Owner-bind
|
||||||
// path uses CompleteOwnerSetup so identity binding, local auth, and this token
|
// path uses CompleteOwnerSetup so identity binding, local auth, and this token
|
||||||
// commit atomically; this lower-level helper remains for callers that already
|
// commit atomically; this lower-level helper remains for callers that already
|
||||||
// established the user. The token is redeemed exactly once by ConsumeSetupToken.
|
// established the user. The token is redeemed exactly once by RedeemSetupToken.
|
||||||
func (p *PGRepo) CreateSetupToken(ctx context.Context, tokenHash, userID string, expiresAt time.Time) error {
|
func (p *PGRepo) CreateSetupToken(ctx context.Context, tokenHash, userID string, expiresAt time.Time) error {
|
||||||
_, err := p.db.ExecContext(ctx,
|
_, err := p.db.ExecContext(ctx,
|
||||||
`INSERT INTO setup_tokens (token_hash, user_id, expires_at) VALUES ($1, $2, $3)`,
|
`INSERT INTO setup_tokens (token_hash, user_id, expires_at) VALUES ($1, $2, $3)`,
|
||||||
|
|||||||
@@ -722,11 +722,13 @@ type Repo interface {
|
|||||||
// it ended.
|
// it ended.
|
||||||
RevokeOtherUserSessions(ctx context.Context, userID, keepTokenHash string) (int, error)
|
RevokeOtherUserSessions(ctx context.Context, userID, keepTokenHash string) (int, error)
|
||||||
|
|
||||||
// ConsumeSetupToken atomically marks a one-time setup token consumed and returns
|
// RedeemSetupToken spends a one-time setup token and stores s as a session of
|
||||||
// its user_id, or ErrNotFound when the token is absent, already consumed, or
|
// the token's user (s.UserID is ignored) in one transaction, so a failure
|
||||||
// expired. The /setup?token=... web flow redeems it for a lockdown session that
|
// leaves the token unspent for another try. It returns the user id, or
|
||||||
// can only complete passwordless login setup (verify email / enroll passkey).
|
// ErrNotFound when the token is absent, already spent, or expired. The /setup?token=... web flow redeems it for a lockdown
|
||||||
ConsumeSetupToken(ctx context.Context, tokenHash string, now time.Time) (userID string, err error)
|
// session that can only complete passwordless login setup (verify email /
|
||||||
|
// enroll passkey).
|
||||||
|
RedeemSetupToken(ctx context.Context, tokenHash string, now time.Time, s NewSession) (userID string, err error)
|
||||||
|
|
||||||
// ---- runtime platform settings (spec §B platform_settings) ----
|
// ---- runtime platform settings (spec §B platform_settings) ----
|
||||||
|
|
||||||
|
|||||||
+19
-8
@@ -80,12 +80,27 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// startSession mints a session for userID and sets its cookie. Every sign-in door
|
// startSession mints a session for userID and sets its cookie. Every sign-in door
|
||||||
// ends here, so every session records the device it was minted for.
|
// ends here (or at mintSession), so every session records the device it was
|
||||||
|
// minted for.
|
||||||
func (a *API) startSession(w http.ResponseWriter, r *http.Request, userID string, proof signInProof) error {
|
func (a *API) startSession(w http.ResponseWriter, r *http.Request, userID string, proof signInProof) error {
|
||||||
token, err := newSessionToken()
|
token, s, err := a.mintSession(r, userID, proof)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := a.Repo.CreateSession(r.Context(), s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
setSessionCookie(w, token, s.ExpiresAt)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mintSession makes a session cookie value and the row that stores it, for a door
|
||||||
|
// that writes the row itself.
|
||||||
|
func (a *API) mintSession(r *http.Request, userID string, proof signInProof) (string, NewSession, error) {
|
||||||
|
token, err := newSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", NewSession{}, err
|
||||||
|
}
|
||||||
now := a.now()
|
now := a.now()
|
||||||
expires := now.Add(sessionTTL)
|
expires := now.Add(sessionTTL)
|
||||||
ip := ""
|
ip := ""
|
||||||
@@ -96,18 +111,14 @@ func (a *API) startSession(w http.ResponseWriter, r *http.Request, userID string
|
|||||||
if proof == provenSignIn {
|
if proof == provenSignIn {
|
||||||
reauth = now
|
reauth = now
|
||||||
}
|
}
|
||||||
if err := a.Repo.CreateSession(r.Context(), NewSession{
|
return token, NewSession{
|
||||||
TokenHash: hashCookie(token),
|
TokenHash: hashCookie(token),
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ExpiresAt: expires,
|
ExpiresAt: expires,
|
||||||
UserAgent: truncateUTF8(r.UserAgent(), maxSessionUserAgent),
|
UserAgent: truncateUTF8(r.UserAgent(), maxSessionUserAgent),
|
||||||
ClientIP: ip,
|
ClientIP: ip,
|
||||||
ReauthAt: reauth,
|
ReauthAt: reauth,
|
||||||
}); err != nil {
|
}, nil
|
||||||
return err
|
|
||||||
}
|
|
||||||
setSessionCookie(w, token, expires)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// currentSessionHash is the storage key of the session cookie r carries, or ""
|
// currentSessionHash is the storage key of the session cookie r carries, or ""
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -595,8 +596,10 @@ func TestCrossingMigrationsDoNotDeadlock(t *testing.T) {
|
|||||||
// ---- setup tokens (migration 0012) --------------------------------------------------
|
// ---- setup tokens (migration 0012) --------------------------------------------------
|
||||||
|
|
||||||
// A setup token redeems once, never at or after expiry, and racing redeems of one
|
// A setup token redeems once, never at or after expiry, and racing redeems of one
|
||||||
// token yield one session.
|
// token yield one session. The session is written with the spend: the token's
|
||||||
func TestConsumeSetupTokenContract(t *testing.T) {
|
// user owns it, and a redemption whose session cannot be stored leaves the token
|
||||||
|
// for the next try.
|
||||||
|
func TestRedeemSetupTokenContract(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
u := newUser(t, "user", "setup")
|
u := newUser(t, "user", "setup")
|
||||||
t0 := mustNow().Truncate(time.Second)
|
t0 := mustNow().Truncate(time.Second)
|
||||||
@@ -606,27 +609,64 @@ func TestConsumeSetupTokenContract(t *testing.T) {
|
|||||||
t.Fatalf("CreateSetupToken: %v", err)
|
t.Fatalf("CreateSetupToken: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var mu sync.Mutex
|
||||||
|
minted := 0
|
||||||
|
redeem := func(hash string, at time.Time) (string, string, error) {
|
||||||
|
mu.Lock()
|
||||||
|
minted++
|
||||||
|
s := api.NewSession{TokenHash: "st-sess-" + strconv.Itoa(minted) + "-" + suffix(t), UserID: "ignored", ExpiresAt: t0.Add(time.Hour)}
|
||||||
|
mu.Unlock()
|
||||||
|
id, err := repo.RedeemSetupToken(ctx, hash, at, s)
|
||||||
|
return id, s.TokenHash, err
|
||||||
|
}
|
||||||
|
sessionOf := func(hash string) string {
|
||||||
|
t.Helper()
|
||||||
|
su, err := repo.SessionUser(ctx, hash, t0)
|
||||||
|
if errors.Is(err, api.ErrNotFound) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SessionUser: %v", err)
|
||||||
|
}
|
||||||
|
return su.ID
|
||||||
|
}
|
||||||
|
|
||||||
once := "st-once-" + suffix(t)
|
once := "st-once-" + suffix(t)
|
||||||
create(once)
|
create(once)
|
||||||
if got, err := repo.ConsumeSetupToken(ctx, once, t0); err != nil || got != u.ID {
|
got, sess, err := redeem(once, t0)
|
||||||
|
if err != nil || got != u.ID {
|
||||||
t.Fatalf("redeem = %q, %v; want %s", got, err, u.ID)
|
t.Fatalf("redeem = %q, %v; want %s", got, err, u.ID)
|
||||||
}
|
}
|
||||||
if _, err := repo.ConsumeSetupToken(ctx, once, t0); !errors.Is(err, api.ErrNotFound) {
|
if owner := sessionOf(sess); owner != u.ID {
|
||||||
t.Fatalf("replay = %v, want ErrNotFound", err)
|
t.Fatalf("redeemed session belongs to %q, want %s", owner, u.ID)
|
||||||
}
|
}
|
||||||
if _, err := repo.ConsumeSetupToken(ctx, "st-never-"+suffix(t), t0); !errors.Is(err, api.ErrNotFound) {
|
if _, sess, err := redeem(once, t0); !errors.Is(err, api.ErrNotFound) || sessionOf(sess) != "" {
|
||||||
|
t.Fatalf("replay = %v (session stored: %v), want ErrNotFound and no session", err, sessionOf(sess) != "")
|
||||||
|
}
|
||||||
|
if _, _, err := redeem("st-never-"+suffix(t), t0); !errors.Is(err, api.ErrNotFound) {
|
||||||
t.Fatalf("unknown token = %v, want ErrNotFound", err)
|
t.Fatalf("unknown token = %v, want ErrNotFound", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
late := "st-late-" + suffix(t)
|
late := "st-late-" + suffix(t)
|
||||||
create(late)
|
create(late)
|
||||||
if _, err := repo.ConsumeSetupToken(ctx, late, t0.Add(10*time.Minute)); !errors.Is(err, api.ErrNotFound) {
|
if _, _, err := redeem(late, t0.Add(10*time.Minute)); !errors.Is(err, api.ErrNotFound) {
|
||||||
t.Fatalf("redeem at expiry = %v, want ErrNotFound", err)
|
t.Fatalf("redeem at expiry = %v, want ErrNotFound", err)
|
||||||
}
|
}
|
||||||
if got, err := repo.ConsumeSetupToken(ctx, late, t0.Add(10*time.Minute-time.Second)); err != nil || got != u.ID {
|
if got, _, err := redeem(late, t0.Add(10*time.Minute-time.Second)); err != nil || got != u.ID {
|
||||||
t.Fatalf("redeem a second before expiry = %q, %v; want %s (the refused try must not spend it)", got, err, u.ID)
|
t.Fatalf("redeem a second before expiry = %q, %v; want %s (the refused try must not spend it)", got, err, u.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The session's hash is taken, so storing it fails: the token stays unspent.
|
||||||
|
kept := "st-kept-" + suffix(t)
|
||||||
|
create(kept)
|
||||||
|
taken := newSession(t, u.ID, "st-taken", t0.Add(time.Hour))
|
||||||
|
if _, err := repo.RedeemSetupToken(ctx, kept, t0, api.NewSession{TokenHash: taken, ExpiresAt: t0.Add(time.Hour)}); err == nil || errors.Is(err, api.ErrNotFound) {
|
||||||
|
t.Fatalf("redeem into a taken session hash = %v, want the insert failure", err)
|
||||||
|
}
|
||||||
|
if got, _, err := redeem(kept, t0); err != nil || got != u.ID {
|
||||||
|
t.Fatalf("retry after the failed session = %q, %v; want %s (the token must survive)", got, err, u.ID)
|
||||||
|
}
|
||||||
|
|
||||||
raced := "st-race-" + suffix(t)
|
raced := "st-race-" + suffix(t)
|
||||||
create(raced)
|
create(raced)
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
@@ -635,7 +675,7 @@ func TestConsumeSetupTokenContract(t *testing.T) {
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(i int) {
|
go func(i int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
_, errs[i] = repo.ConsumeSetupToken(ctx, raced, t0)
|
_, _, errs[i] = redeem(raced, t0)
|
||||||
}(i)
|
}(i)
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|||||||
@@ -42,6 +42,9 @@
|
|||||||
"setup_invalid_subtitle": "This setup link is invalid or has already been used",
|
"setup_invalid_subtitle": "This setup link is invalid or has already been used",
|
||||||
"setup_invalid_hint_prefix": "Re-run ",
|
"setup_invalid_hint_prefix": "Re-run ",
|
||||||
"setup_invalid_hint_suffix": " on the server to get a fresh setup link, or head to the sign-in page.",
|
"setup_invalid_hint_suffix": " on the server to get a fresh setup link, or head to the sign-in page.",
|
||||||
|
"setup_failed_title": "Setup didn't start",
|
||||||
|
"setup_failed_subtitle": "The server couldn't sign you in just now. The setup link is kept, so you can try again.",
|
||||||
|
"setup_retry": "Try again",
|
||||||
"setup_goto_login": "Go to sign in",
|
"setup_goto_login": "Go to sign in",
|
||||||
"setup_email_step": "Step 1 · Add your email",
|
"setup_email_step": "Step 1 · Add your email",
|
||||||
"setup_email_desc": "We'll save this address for your account. You can configure email delivery (SMTP) and verify it later from Settings — it isn't required to finish setup.",
|
"setup_email_desc": "We'll save this address for your account. You can configure email delivery (SMTP) and verify it later from Settings — it isn't required to finish setup.",
|
||||||
|
|||||||
@@ -42,6 +42,9 @@
|
|||||||
"setup_invalid_subtitle": "这个设置链接无效或已被使用",
|
"setup_invalid_subtitle": "这个设置链接无效或已被使用",
|
||||||
"setup_invalid_hint_prefix": "请在服务器上重新运行 ",
|
"setup_invalid_hint_prefix": "请在服务器上重新运行 ",
|
||||||
"setup_invalid_hint_suffix": " 获取新的设置链接,或直接前往登录页。",
|
"setup_invalid_hint_suffix": " 获取新的设置链接,或直接前往登录页。",
|
||||||
|
"setup_failed_title": "设置没能开始",
|
||||||
|
"setup_failed_subtitle": "服务器暂时没能让你登录。设置链接仍然有效,可以再试一次。",
|
||||||
|
"setup_retry": "重试",
|
||||||
"setup_goto_login": "前往登录",
|
"setup_goto_login": "前往登录",
|
||||||
"setup_email_step": "第一步 · 填写邮箱",
|
"setup_email_step": "第一步 · 填写邮箱",
|
||||||
"setup_email_desc": "我们会为你的账户保存这个邮箱地址。发信服务(SMTP)和邮箱验证可稍后在设置中配置——完成初始化并不需要它。",
|
"setup_email_desc": "我们会为你的账户保存这个邮箱地址。发信服务(SMTP)和邮箱验证可稍后在设置中配置——完成初始化并不需要它。",
|
||||||
|
|||||||
@@ -964,7 +964,7 @@ export interface paths {
|
|||||||
put?: never;
|
put?: never;
|
||||||
/**
|
/**
|
||||||
* Redeem a one-time setup token into a lockdown session (spec §B).
|
* Redeem a one-time setup token into a lockdown session (spec §B).
|
||||||
* @description Public, pre-session first-run door: consumes the one-time setup token minted by the felis TUI (stored and looked up by SHA-256 hash, like session cookies), mints a host-only felis_session, and returns the remaining setup steps so the SPA can drive the wizard. An unknown, consumed, or expired token returns a uniform 400 setup_token_invalid. Gated on local_auth_enabled.
|
* @description Public, pre-session first-run door: consumes the one-time setup token minted by the felis TUI (stored and looked up by SHA-256 hash, like session cookies), mints a host-only felis_session, and returns the remaining setup steps so the SPA can drive the wizard. An unknown, consumed, or expired token returns a uniform 400 setup_token_invalid. Gated on local_auth_enabled. The token is spent in the same transaction that stores the session, so a redemption that fails with 500 leaves the link working for another try.
|
||||||
*/
|
*/
|
||||||
post: operations["setupRedeem"];
|
post: operations["setupRedeem"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
@@ -5345,6 +5345,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
429: components["responses"]["RateLimited"];
|
429: components["responses"]["RateLimited"];
|
||||||
|
500: components["responses"]["InternalError"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
setupStatus: {
|
setupStatus: {
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import i18next from "i18next";
|
||||||
|
import { Setup } from "./Setup";
|
||||||
|
|
||||||
|
const calls = vi.hoisted(() => ({
|
||||||
|
setupRedeem: vi.fn(),
|
||||||
|
setupStatus: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/tier", () => ({
|
||||||
|
useTier: () => ({ loading: false, identity: null, refresh: calls.refresh }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@/lib/api")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: { ...actual.api, setupRedeem: calls.setupRedeem, setupStatus: calls.setupStatus },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const t = (key: string, opts?: Record<string, unknown>) => i18next.t(key, opts);
|
||||||
|
|
||||||
|
const fresh = {
|
||||||
|
user_id: "o1",
|
||||||
|
username: "owner",
|
||||||
|
role: "owner",
|
||||||
|
email: "",
|
||||||
|
email_verified: false,
|
||||||
|
has_passkey: false,
|
||||||
|
setup_required: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderSetup() {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={["/setup?token=raw-token"]}>
|
||||||
|
<Setup />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const fn of Object.values(calls)) fn.mockReset();
|
||||||
|
// No session survives a failed redeem.
|
||||||
|
calls.setupStatus.mockRejectedValue({ status: 401, code: "unauthenticated", message: "" });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Setup", () => {
|
||||||
|
it.each([
|
||||||
|
[500, "internal"],
|
||||||
|
[503, "service_unavailable"],
|
||||||
|
[429, "rate_limited"],
|
||||||
|
[0, "network_error"],
|
||||||
|
])("offers another try with the same link after a %i %s", async (status, code) => {
|
||||||
|
calls.setupRedeem.mockRejectedValueOnce({ status, code, message: "" });
|
||||||
|
calls.setupRedeem.mockResolvedValueOnce(fresh);
|
||||||
|
renderSetup();
|
||||||
|
|
||||||
|
expect(await screen.findByText(t("auth:setup_failed_title"))).toBeTruthy();
|
||||||
|
expect(screen.queryByText(t("auth:setup_invalid_subtitle"))).toBeNull();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: t("auth:setup_retry") }));
|
||||||
|
|
||||||
|
expect(await screen.findByText(t("auth:setup_welcome", { name: "owner" }))).toBeTruthy();
|
||||||
|
expect(calls.setupRedeem.mock.calls).toEqual([["raw-token"], ["raw-token"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a spent link back to felis setup, with nothing to retry", async () => {
|
||||||
|
calls.setupRedeem.mockRejectedValue({ status: 400, code: "setup_token_invalid", message: "" });
|
||||||
|
renderSetup();
|
||||||
|
|
||||||
|
expect(await screen.findByText(t("auth:setup_invalid_subtitle"))).toBeTruthy();
|
||||||
|
expect(await screen.findByText(t("errors:setup_token_invalid"))).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("button", { name: t("auth:setup_retry") })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { api, clientError, humanizeError, type SetupState } from "@/lib/api";
|
import { api, clientError, humanizeError, type SetupState } from "@/lib/api";
|
||||||
|
import type { ApiError } from "@/lib/types";
|
||||||
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
|
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
|
||||||
import { useTier } from "@/lib/tier";
|
import { useTier } from "@/lib/tier";
|
||||||
import { InlineError } from "@/components/MessageLine";
|
import { InlineError } from "@/components/MessageLine";
|
||||||
@@ -27,6 +28,15 @@ import { InlineError } from "@/components/MessageLine";
|
|||||||
// a spent token. The step endpoints and /me are all SetupAllowed, so the lockdown
|
// a spent token. The step endpoints and /me are all SetupAllowed, so the lockdown
|
||||||
// session can complete the wizard; the backend lifts the lockdown once a passkey is
|
// session can complete the wizard; the backend lifts the lockdown once a passkey is
|
||||||
// enrolled, and we hand off to / once nothing remains.
|
// enrolled, and we hand off to / once nothing remains.
|
||||||
|
// retryable tells a failure worth another try with the same link (the server or
|
||||||
|
// the network failed, or asked to slow down) from a link that cannot work. The
|
||||||
|
// redeem spends the token only together with the session it mints, so a failed
|
||||||
|
// try leaves the link as it was.
|
||||||
|
function retryable(e: unknown): boolean {
|
||||||
|
const status = (e as Partial<ApiError> | undefined)?.status;
|
||||||
|
return status === 0 || status === 429 || (typeof status === "number" && status >= 500);
|
||||||
|
}
|
||||||
|
|
||||||
export function Setup() {
|
export function Setup() {
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -35,7 +45,8 @@ export function Setup() {
|
|||||||
|
|
||||||
const [state, setState] = useState<SetupState | null>(null);
|
const [state, setState] = useState<SetupState | null>(null);
|
||||||
const [booting, setBooting] = useState(true);
|
const [booting, setBooting] = useState(true);
|
||||||
const [fatal, setFatal] = useState<string | null>(null);
|
const [fatal, setFatal] = useState<{ message: string; retryable: boolean } | null>(null);
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
const finishing = useRef(false);
|
const finishing = useRef(false);
|
||||||
|
|
||||||
// Boot: redeem the URL token, or resume from the session if the token is already
|
// Boot: redeem the URL token, or resume from the session if the token is already
|
||||||
@@ -63,7 +74,7 @@ export function Setup() {
|
|||||||
}
|
}
|
||||||
if (alive) setState(st);
|
if (alive) setState(st);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (alive) setFatal(humanizeError(e));
|
if (alive) setFatal({ message: humanizeError(e), retryable: retryable(e) });
|
||||||
} finally {
|
} finally {
|
||||||
if (alive) setBooting(false);
|
if (alive) setBooting(false);
|
||||||
}
|
}
|
||||||
@@ -71,7 +82,13 @@ export function Setup() {
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [params]);
|
}, [params, attempt]);
|
||||||
|
|
||||||
|
const retry = () => {
|
||||||
|
setFatal(null);
|
||||||
|
setBooting(true);
|
||||||
|
setAttempt((n) => n + 1);
|
||||||
|
};
|
||||||
|
|
||||||
// reload re-reads progress after a wizard step so the view advances to the next.
|
// reload re-reads progress after a wizard step so the view advances to the next.
|
||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async () => {
|
||||||
@@ -103,12 +120,27 @@ export function Setup() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fatal?.retryable) {
|
||||||
|
return (
|
||||||
|
<AuthLayout title={t("setup_failed_title")} subtitle={t("setup_failed_subtitle")}>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-4 pt-6 text-sm">
|
||||||
|
<p role="alert" className="text-muted-foreground">{fatal.message}</p>
|
||||||
|
<Button className="w-full" onClick={retry}>
|
||||||
|
{t("setup_retry")}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (fatal) {
|
if (fatal) {
|
||||||
return (
|
return (
|
||||||
<AuthLayout title={t("setup_invalid_title")} subtitle={t("setup_invalid_subtitle")}>
|
<AuthLayout title={t("setup_invalid_title")} subtitle={t("setup_invalid_subtitle")}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="space-y-4 pt-6 text-sm">
|
<CardContent className="space-y-4 pt-6 text-sm">
|
||||||
<p role="alert" className="text-muted-foreground">{fatal}</p>
|
<p role="alert" className="text-muted-foreground">{fatal.message}</p>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{t("setup_invalid_hint_prefix")}
|
{t("setup_invalid_hint_prefix")}
|
||||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
|
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
|
||||||
|
|||||||
Reference in new issue
Block a user