Unverified Commit 11244138 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(api): setup 链接在同一事务里消费并建会话,存储故障回 500 且链接仍可重试

parent 26d997cb
Loading
Loading
Loading
Loading
+5 −1
Changes for docs/openapi.yaml: 5 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -3359,7 +3359,9 @@ paths:
        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.
        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-tier: public
      security: []
@@ -3407,6 +3409,8 @@ paths:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/auth/setup/status:
    get:
+12 −3
Changes for internal/api/api_test.go: 12 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -94,6 +94,7 @@ type fakeRepo struct {
	failRevokeOthers error
	failMarkReauth   error
	failGetSetting   error
	failRedeemSetup  error
	// 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.
	otps map[string]*fakeEmailOTP
@@ -1779,15 +1780,23 @@ func (f *fakeRepo) ApproveOpLogin(_ context.Context, id, approverUserID string,
	return nil
}

// ConsumeSetupToken atomically marks a one-time setup token consumed and returns
// its user_id, or ErrNotFound when absent, already consumed, or expired.
func (f *fakeRepo) ConsumeSetupToken(_ context.Context, tokenHash string, now time.Time) (string, error) {
// RedeemSetupToken spends a setup token and stores s for its user, or ErrNotFound
// when the token is absent, already spent, or expired. failRedeemSetup fails the
// 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]
	if !ok || !tok.ConsumedAt.IsZero() || !tok.ExpiresAt.After(now) {
		return "", ErrNotFound
	}
	tok.ConsumedAt = now
	f.setupTokens[tokenHash] = tok
	s.UserID = tok.UserID
	if err := f.CreateSession(ctx, s); err != nil {
		return "", err
	}
	return tok.UserID, nil
}

+15 −9
Changes for internal/api/handlers_setup.go: 15 added lines, 9 removed lines.
Original line number Diff line number Diff line
@@ -62,26 +62,32 @@ func (a *API) handleSetupRedeem(w http.ResponseWriter, r *http.Request) {
	sum := sha256.Sum256([]byte(token))
	tokenHash := hex.EncodeToString(sum[:])

	now := a.now()
	userID, err := a.Repo.ConsumeSetupToken(r.Context(), tokenHash, now)
	// A regular felis_session; the lockdown is a product-level restriction the
	// 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 {
		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
		// 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 {
	case err != nil:
		writeError(w, r, err)
		return
	}
	setSessionCookie(w, sessionToken, session.ExpiresAt)

	// 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, provenSignIn); err != nil {
	u, err := a.Repo.UserByID(r.Context(), userID)
	if err != nil {
		writeError(w, r, err)
		return
	}
+53 −0
Changes for internal/api/handlers_setup_test.go: 53 added lines, 0 removed lines.
Original line number Diff line number Diff line
package api

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"net/http"
	"testing"
	"time"
)

// 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
// non-failing decodeErr for cases where the response may be a success).
func errCode(body []byte) string {
+30 −7
Changes for internal/api/pgrepo.go: 30 added lines, 7 removed lines.
Original line number Diff line number Diff line
@@ -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
// (spec §B). Only the hash is stored, mirroring tokens.
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()}
	_, err := p.db.ExecContext(ctx,
	_, err := db.ExecContext(ctx,
		`INSERT INTO sessions (token_hash, user_id, expires_at, user_agent, client_ip, reauth_at)
		 VALUES ($1, $2, $3, $4, $5, $6)`,
		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) ----

// ConsumeSetupToken atomically marks a one-time setup token consumed and returns
// its user_id, or ErrNotFound when the token is absent, already consumed, or
// expired. The /setup?token=... web flow redeems it for a lockdown session.
func (p *PGRepo) ConsumeSetupToken(ctx context.Context, tokenHash string, now time.Time) (string, error) {
// RedeemSetupToken spends a one-time setup token and stores s as a session of the
// token's user in one transaction, so a failure leaves the token unspent. It
// returns the user id, or ErrNotFound when the token is absent, already spent, or
// 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
	switch err := p.db.QueryRowContext(ctx,
	switch err := tx.QueryRowContext(ctx,
		`UPDATE setup_tokens SET consumed_at = $2
		 WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > $2
		 RETURNING user_id`,
@@ -2971,6 +2987,13 @@ func (p *PGRepo) ConsumeSetupToken(ctx context.Context, tokenHash string, now ti
	case err != nil:
		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
}

@@ -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
// path uses CompleteOwnerSetup so identity binding, local auth, and this token
// 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 {
	_, err := p.db.ExecContext(ctx,
		`INSERT INTO setup_tokens (token_hash, user_id, expires_at) VALUES ($1, $2, $3)`,
Loading