fix(api): setup 链接在同一事务里消费并建会话,存储故障回 500 且链接仍可重试
This commit is contained in:
13 files changed
+312
-47
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
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
@@ -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)`,
|
||||
|
||||
@@ -722,11 +722,13 @@ type Repo interface {
|
||||
// it ended.
|
||||
RevokeOtherUserSessions(ctx context.Context, userID, keepTokenHash string) (int, error)
|
||||
|
||||
// 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 that
|
||||
// can only complete passwordless login setup (verify email / enroll passkey).
|
||||
ConsumeSetupToken(ctx context.Context, tokenHash string, now time.Time) (userID string, err error)
|
||||
// RedeemSetupToken spends a one-time setup token and stores s as a session of
|
||||
// the token's user (s.UserID is ignored) in one transaction, so a failure
|
||||
// leaves the token unspent for another try. It returns the user id, or
|
||||
// ErrNotFound when the token is absent, already spent, or expired. The /setup?token=... web flow redeems it for a lockdown
|
||||
// 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) ----
|
||||
|
||||
|
||||
+19
-8
@@ -80,12 +80,27 @@ const (
|
||||
)
|
||||
|
||||
// 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 {
|
||||
token, err := newSessionToken()
|
||||
token, s, err := a.mintSession(r, userID, proof)
|
||||
if err != nil {
|
||||
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()
|
||||
expires := now.Add(sessionTTL)
|
||||
ip := ""
|
||||
@@ -96,18 +111,14 @@ func (a *API) startSession(w http.ResponseWriter, r *http.Request, userID string
|
||||
if proof == provenSignIn {
|
||||
reauth = now
|
||||
}
|
||||
if err := a.Repo.CreateSession(r.Context(), NewSession{
|
||||
return token, NewSession{
|
||||
TokenHash: hashCookie(token),
|
||||
UserID: userID,
|
||||
ExpiresAt: expires,
|
||||
UserAgent: truncateUTF8(r.UserAgent(), maxSessionUserAgent),
|
||||
ClientIP: ip,
|
||||
ReauthAt: reauth,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
setSessionCookie(w, token, expires)
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
// currentSessionHash is the storage key of the session cookie r carries, or ""
|
||||
|
||||
Reference in new issue
Block a user