fix(api): 禁用用户与吊销会话合为一个事务,passkey 签名计数只增不减,引导检查遇到数据库故障返回 503

This commit is contained in:
Lemon-miaow committed 2026-09-25 13:29:52 +08:00
1 parent 88d3dd7121
commit 98295e630e
5 files changed
+164 -19

No files matched your search

+10 -1
View File
@@ -14,6 +14,7 @@ package api
import (
"context"
"log"
"log/slog"
"net/http"
"strings"
@@ -776,7 +777,15 @@ func (a *API) requireOnboarded(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
if p != nil && p.ViaSession && !p.EmailVerified {
creds, _ := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID)
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID)
if err != nil {
// A store outage reads as retry-later; setup_required would send
// the caller off to enroll a passkey they may already have.
log.Printf("api: %s %s: onboarding check (request_id=%s): %v",
r.Method, r.URL.Path, requestIDFromContext(r.Context()), err)
writeError(w, r, errAuthUnavailable)
return
}
if len(creds) == 0 {
writeError(w, r, newError(http.StatusForbidden, "setup_required",
"passkey enrollment is required before this action is available"))
+1 -1
View File
@@ -583,7 +583,7 @@ func (f *fakeRepo) DeleteAllPasskeyCredentialsForUser(_ context.Context, userID
func (f *fakeRepo) AdvanceCredentialSignCount(_ context.Context, credentialID string, newSignCount uint32, usedAt time.Time) error {
for id, c := range f.passkeyCreds {
if c.CredentialID == credentialID {
c.SignCount = newSignCount
c.SignCount = max(c.SignCount, newSignCount)
t := usedAt
c.LastUsedAt = &t
f.passkeyCreds[id] = c
@@ -2,6 +2,7 @@ package api
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
@@ -138,3 +139,33 @@ func TestAdvRequireOnboardedPredicate(t *testing.T) {
})
}
}
// credsDown is a store whose passkey lookup fails, as during a Postgres outage.
type credsDown struct{ *fakeRepo }
func (credsDown) PasskeyCredentialsForUser(context.Context, string) ([]PasskeyCredential, error) {
return nil, errors.New("connection refused")
}
// A failed passkey lookup is an outage: the caller gets 503 auth_unavailable to
// retry, never setup_required, which would send a player with a passkey off to
// enroll another.
func TestRequireOnboardedReportsAStoreOutage(t *testing.T) {
repo := newFakeRepo()
api := newTestAPI(repo, newFakeCluster())
api.Repo = credsDown{repo}
ran := false
h := api.requireOnboarded(func(http.ResponseWriter, *http.Request) { ran = true })
r := httptest.NewRequest("POST", "/api/v1/servers/demo2/wake", nil)
r = r.WithContext(context.WithValue(r.Context(), ctxKeyPrincipal,
&Principal{UserID: "has-pk", ViaSession: true, EmailVerified: false}))
w := httptest.NewRecorder()
h(w, r)
if ran {
t.Fatal("the handler ran although the onboarding check could not be made")
}
if w.Code != http.StatusServiceUnavailable || errCode(w.Body.Bytes()) != "auth_unavailable" {
t.Fatalf("got %d %s, want 503 auth_unavailable", w.Code, w.Body.String())
}
}
+26 -17
View File
@@ -1401,9 +1401,14 @@ func (p *PGRepo) PasskeyCredentialsForUser(ctx context.Context, userID string) (
// last_used_at. credential_id is UNIQUE so exactly one row is touched; a missing row (the
// credential was unbound mid-ceremony) affects zero rows and is a successful no-op, never an
// error — the assertion is already cryptographically complete by the time this runs.
//
// The counter only moves forward: two assertions verified at once against the same stored
// value land in either order, and the lower one must not overwrite the higher, or a clone
// replaying the count in between would pass the next check.
func (p *PGRepo) AdvanceCredentialSignCount(ctx context.Context, credentialID string, newSignCount uint32, usedAt time.Time) error {
_, err := p.db.ExecContext(ctx,
`UPDATE webauthn_credentials SET sign_count = $2, last_used_at = $3 WHERE credential_id = $1`,
`UPDATE webauthn_credentials SET sign_count = GREATEST(sign_count, $2), last_used_at = $3
WHERE credential_id = $1`,
credentialID, int64(newSignCount), usedAt)
return err
}
@@ -1738,31 +1743,35 @@ func (p *PGRepo) DeleteUser(ctx context.Context, userID, _ string) error {
}
// SetUserDisabled flips the disabled flag. Setting disabled→true additionally
// revokes every live session so the account is immediately locked out.
// revokes every live session so the account is immediately locked out. Both land
// in one transaction: a disable whose revocation failed is rolled back and
// reported, so the admin never reads "disabled" while a session still stands.
func (p *PGRepo) SetUserDisabled(ctx context.Context, userID string, disabled bool) error {
// Guard: the user must exist and not be deleted.
var ok bool
if err := p.db.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM users WHERE id = $1 AND deleted_at IS NULL)`,
userID).Scan(&ok); err != nil {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return err
}
if !ok {
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx,
`UPDATE users SET disabled = $2 WHERE id = $1 AND deleted_at IS NULL`, userID, disabled)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n == 0 {
return ErrNotFound
}
if _, err := p.db.ExecContext(ctx,
`UPDATE users SET disabled = $2 WHERE id = $1`, userID, disabled); err != nil {
return err
}
if disabled {
// Revoke every live session so the lockout is immediate.
_, _ = p.db.ExecContext(ctx,
if _, err := tx.ExecContext(ctx,
`UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL`,
userID)
userID); err != nil {
return err
}
}
return nil
return tx.Commit()
}
// ---- quota admin ----