Skip to content

fix(security): add 2FA brute-force counter and TOTP replay protection (#314)#334

Open
dmytrocraft wants to merge 7 commits into
mainfrom
security/314-2fa-bruteforce-totp-replay
Open

fix(security): add 2FA brute-force counter and TOTP replay protection (#314)#334
dmytrocraft wants to merge 7 commits into
mainfrom
security/314-2fa-bruteforce-totp-replay

Conversation

@dmytrocraft

@dmytrocraft dmytrocraft commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Security fix — Closes #314

Vulnerability

The second authentication factor (a 6-digit TOTP) was both brute-forceable and replayable:

  • Brute force (CWE-307). CompleteTwoFactorCommandHandler never incremented a per-pending-session failure counter and never invalidated the PendingTwoFactor on a wrong code, so one pendingSessionId allowed unlimited guesses for its 5-minute TTL. The Symfony rate limiter attached only by REST path (/api/signin/2fa), so the GraphQL completeTwoFactor mutation at POST /api/graphql had no per-user 2FA throttling and could brute-force the 10^6 TOTP space.
  • Replay (CWE-294). TOTPValidator::verify() accepted the previous, current and next time window (~90s) and never recorded code usage, so a valid TOTP could be replayed (relay/phishing) to /api/signin/2fa, /api/2fa/confirm, and /api/2fa/disable. RFC 6238 §5.2 requires a previously accepted OTP be rejected.

Fix

  • Server-side brute-force counter (transport-agnostic). PendingTwoFactor now carries failedAttempts with MAX_FAILED_ATTEMPTS = 5. The handler increments and persists it on each invalid code and deletes the pending session once exhausted; already-exhausted sessions are rejected up front. This protects both REST and GraphQL.
  • TOTP replay protection. User tracks lastAcceptedTotpTimestep. TwoFactorCodeValidator rejects any code whose matched time-step is <= the stored value and advances + persists it atomically on success (mirroring the recovery-code consume path). TOTPValidator now accepts only the current and previous windows (future window removed) and exposes the matched time-step via resolveAcceptedTimestep(). Disabling 2FA clears the stored time-step.
  • GraphQL rate-limiter parity (defense in depth). The twofa_verification_ip / twofa_verification_user limiters are now applied to the GraphQL completeTwoFactor mutation, resolving pendingSessionId from nested variables.input.

Hexagonal/DDD boundaries preserved (Domain stays framework-free); no threshold/config relaxation; no new directories or *Service suffixes.

Local verification (one-off containers, APP_ENV=test)

  • phpunit Unit (TwoFactor|TOTP filter): OK (224 tests); full Unit suite: OK (2283 tests)
  • deptrac: Violations 0
  • psalm (changed files): No errors
  • php-cs-fixer (changed files): 0 of 16 files

BMAD spec

specs/security-314-2fa-bruteforce-totp-replay/ (prd.md, stories.md).

🤖 Generated with Claude Code


Summary by cubic

Hardened 2FA with a server-side brute‑force counter, scoped TOTP replay protection with a forward‑only replay watermark, and GraphQL rate‑limiter parity. Setup confirmation verifies TOTP without consuming the time‑step so a sign‑in in the same 30s window works (closes #314).

  • Bug Fixes

    • Brute force: track failedAttempts on PendingTwoFactor (max 5); increment on invalid codes, reject exhausted sessions up front, delete when exhausted; don’t count after the session is consumed. Applies to REST and GraphQL.
    • Replay: record lastAcceptedTotpTimestep on User; reject same/older time‑steps on /api/signin/2fa and /api/2fa/disable; disabling 2FA clears it. TOTP now accepts only current and previous windows. Setup confirmation (/api/2fa/confirm) verifies TOTP without advancing the counter.
    • Rate limiting: apply twofa_verification_ip and twofa_verification_user to the GraphQL completeTwoFactor mutation by detecting the operation in query and resolving pendingSessionId from variables.input.
  • Refactors

    • Added resolveAcceptedTimestep to the TOTP validator; introduced a non‑consuming verifyTotpForSetupOrFail; improved nested GraphQL payload resolution; split oversized tests.
    • Simplified User::recordAcceptedTotpTimestep(); monotonicity is enforced by the validator, replay protection unchanged.

Written for commit 681fb6d. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Hardened 2FA: pending sessions track failed attempts and auto-invalidate after 5 failures
    • TOTP replay protection: store last-accepted timestep and reject replayed/out-of-window codes
    • GraphQL parity: two-factor rate limits now apply to GraphQL complete-two-factor requests
  • Documentation

    • Added PRD and engineering stories describing brute-force/TOTP replay requirements and non-functional constraints
  • Tests

    • Comprehensive unit tests added/updated to cover brute-force, replay, GraphQL rate-limiting, and setup-confirmation flows

…#314)

The second factor (6-digit TOTP) was both brute-forceable and replayable:

- Brute force (CWE-307): CompleteTwoFactorCommandHandler never counted failed
  attempts and never invalidated the PendingTwoFactor on failure, so one
  pendingSessionId allowed unlimited guesses for its 5-minute TTL. The Symfony
  rate limiter only attached by REST path, so the GraphQL completeTwoFactor
  mutation at /api/graphql bypassed per-user throttling entirely.
- Replay (CWE-294): TOTPValidator accepted the previous, current AND next time
  window (~90s) and never recorded code usage, so a valid code could be replayed
  to /api/signin/2fa, /api/2fa/confirm and /api/2fa/disable.

Fix:
- PendingTwoFactor now carries a server-side failedAttempts counter
  (MAX_FAILED_ATTEMPTS=5); the handler increments and persists it on each invalid
  code and deletes the pending session once exhausted. This is transport-agnostic
  and covers both REST and GraphQL. Exhausted sessions are rejected up front.
- User tracks lastAcceptedTotpTimestep; TwoFactorCodeValidator rejects replays of
  an already-accepted time-step and advances + persists it atomically on success
  (like the recovery-code path). TOTPValidator now accepts only the current and
  previous windows (future window removed) and exposes the matched time-step.
- The rate limiter applies twofa_verification_ip/user limiters to the GraphQL
  completeTwoFactor mutation (defense in depth), resolving pendingSessionId from
  nested GraphQL variables.input.

Local verification (one-off containers, APP_ENV=test):
- phpunit Unit (TwoFactor|TOTP filter): OK (224 tests); full Unit: OK (2283 tests)
- deptrac: Violations 0
- psalm (changed files): No errors
- php-cs-fixer (changed files): 0 of 16 files

Closes #314

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 700b241f-5898-4cda-b178-ea5522876e28

📥 Commits

Reviewing files that changed from the base of the PR and between aecc33c and 681fb6d.

📒 Files selected for processing (4)
  • specs/security-314-2fa-bruteforce-totp-replay/stories.md
  • src/User/Domain/Entity/User.php
  • tests/Unit/Shared/Application/Resolver/RateLimit/ApiRateLimitAuthTargetResolverTwoFactorEndpointsTest.php
  • tests/Unit/User/Application/CommandHandler/CompleteTwoFactorCommandHandlerTestCase.php
✅ Files skipped from review due to trivial changes (1)
  • specs/security-314-2fa-bruteforce-totp-replay/stories.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • tests/Unit/Shared/Application/Resolver/RateLimit/ApiRateLimitAuthTargetResolverTwoFactorEndpointsTest.php
  • tests/Unit/User/Application/CommandHandler/CompleteTwoFactorCommandHandlerTestCase.php
  • src/User/Domain/Entity/User.php

📝 Walkthrough

Walkthrough

Adds server-side per-pending-session failed-attempt counters and per-user last-accepted TOTP timestep; restricts TOTP acceptance to current+previous windows; updates CompleteTwoFactor handler to count/invalidate failed attempts and preserves non-consuming setup verification; extends rate-limiter resolution to detect GraphQL completeTwoFactor and resolve nested pendingSessionId; adds tests and PRD.

Changes

2FA Brute-Force & TOTP Replay Protection (Issue #314)

Layer / File(s) Summary
Security PRD and stories
specs/security-314-2fa-bruteforce-totp-replay/prd.md, specs/security-314-2fa-bruteforce-totp-replay/stories.md
Adds PRD and engineering stories specifying failed-attempt counters (5 attempts), TOTP replay semantics, GraphQL rate-limiter parity, verification commands, and test mappings.
Domain models and mappings
src/User/Domain/Entity/PendingTwoFactor.php, src/User/Domain/Entity/User.php, config/doctrine/User/*
Adds persisted failedAttempts and MAX_FAILED_ATTEMPTS to PendingTwoFactor; adds nullable lastAcceptedTotpTimestep to User with replay-check and record APIs; updates Doctrine mappings and hydration accessors.
TOTP validator contract & implementation
src/User/Application/Validator/TOTPValidatorInterface.php, src/User/Infrastructure/Validator/TOTPValidator.php, tests/Unit/User/Infrastructure/Validator/TOTPValidatorTest.php
Adds resolveAcceptedTimestep() to interface; implements timestep resolution limited to current and previous windows; centralizes TOTP creation and timestep calculation.
Two-factor verification API (setup vs sign-in)
src/User/Application/Validator/TwoFactorCodeValidatorInterface.php, src/User/Application/Validator/TwoFactorCodeValidator.php, tests/Unit/User/Application/Verifier/*
Adds verifyTotpForSetupOrFail() (non-consuming for setup); refactors sign-in to consumeTotp() that resolves timestep, rejects replays using User API, records accepted timestep, and persists User via UserRepository.
CompleteTwoFactor handler brute-force handling
src/User/Application/CommandHandler/CompleteTwoFactorCommandHandler.php, tests/Unit/User/Application/CommandHandler/*
On invalid verification, handler records failedAttempts on pending session and deletes it when exhausted; skips counting when recovery-code fails after session consumption; validates pending session not expired/exhausted before verification. Shared test base and brute-force tests added.
GraphQL rate-limiter parity & payload extraction
src/Shared/Application/Resolver/RateLimit/ApiRateLimitAuthTargetResolver.php, src/Shared/Application/Resolver/RateLimit/ApiRateLimitClientIdentityResolver.php, src/Shared/Application/Resolver/RateLimit/ApiRateLimitPayloadValueResolver.php, tests/Unit/Shared/Application/Resolver/RateLimit/*
Detects GraphQL completeTwoFactor mutation on /api/graphql, extracts nested variables.input.pendingSessionId when needed, and builds per-IP and conditional per-user limiter targets so GraphQL is covered by twofa_verification limiters.
Unit tests & test scaffolding
tests/Unit/**/*
Adds/updates multiple unit tests covering entity behavior, validator contract changes, handler brute-force scenarios, GraphQL limiter tests, and refactors test bases for reuse and clearer fixtures.
sequenceDiagram
  participant Client
  participant ApiRateLimitAuthTargetResolver
  participant ApiRateLimitPayloadValueResolver
  participant PendingTwoFactorRepository
  participant UserRepository
  Client->>ApiRateLimitAuthTargetResolver: POST /api/graphql { query: "completeTwoFactor", variables:{ input:{ pendingSessionId } } }
  ApiRateLimitAuthTargetResolver->>ApiRateLimitPayloadValueResolver: resolveNested(path=['variables','input'], keys=['pendingSessionId'])
  ApiRateLimitPayloadValueResolver-->>ApiRateLimitAuthTargetResolver: pendingSessionId
  ApiRateLimitAuthTargetResolver->>PendingTwoFactorRepository: findById(pendingSessionId)
  PendingTwoFactorRepository-->>ApiRateLimitAuthTargetResolver: pendingSession (includes userId)
  ApiRateLimitAuthTargetResolver->>UserRepository: findById(userId) [conditional]
  UserRepository-->>ApiRateLimitAuthTargetResolver: userId
  ApiRateLimitAuthTargetResolver-->>Client: limiter targets (ip + conditional user)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

enhancement, testing

Suggested reviewers

  • Kravalg
  • Derane
  • kukuruzvelt

Poem

🐰 I counted attempts with careful paws,
Closed replay holes and tightened the laws.
GraphQL now heeds the limiter's beat,
Timesteps are tracked — replays meet defeat,
The burrow sleeps safer under soft claws.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and accurately summarizes the main security changes: adding a 2FA brute-force counter and TOTP replay protection, directly addressing issue #314.
Description check ✅ Passed The description comprehensively covers the vulnerabilities (brute-force and replay), implementation details, testing verification, and BMAD spec documentation as required by the template structure.
Linked Issues check ✅ Passed All primary requirements from #314 are met: per-pending-session failed-attempt counter (failedAttempts max 5), TOTP replay protection via lastAcceptedTotpTimestep, narrowed acceptance windows (current+previous only), and GraphQL rate-limiter parity with pendingSessionId resolution from nested variables.
Out of Scope Changes check ✅ Passed All changes directly support the stated security hardening objectives. Refactors (resolveAcceptedTimestep, verifyTotpForSetupOrFail, nested payload resolution) are necessary enablers. Test organization and documentation additions are in-scope support changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security/314-2fa-bruteforce-totp-replay

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@qltysh

qltysh Bot commented Jun 9, 2026

Copy link
Copy Markdown

11 new issues

Tool Category Rule Count
qlty Duplication Found 19 lines of similar code in 3 locations (mass = 171) 10
qlty Duplication Found 15 lines of identical code in 3 locations (mass = 114) 1

$this->encryptor,
$this->recoveryCodeRepository,
$this->userRepository,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 17 lines of similar code in 4 locations (mass = 106) [qlty:similar-code]

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 20 files

Confidence score: 2/5

  • High-confidence concurrency issues in src/User/Application/CommandHandler/CompleteTwoFactorCommandHandler.php and src/User/Application/Validator/TwoFactorCodeValidator.php create real security/regression risk: invalid-attempt increments and TOTP replay checks are non-atomic, so parallel requests can bypass intended protections.
  • The src/User/Domain/Entity/User.php behavior where recordAcceptedTotpTimestep() can move the replay watermark backward adds additional replay-window risk under out-of-order writes, which makes the 2FA flow less reliable under load.
  • Given multiple medium-to-high severity findings (including two at 8/10 with strong confidence), this is likely not safe to merge without fixes, even though the rate-limit resolver note is comparatively minor.
  • Pay close attention to src/User/Application/CommandHandler/CompleteTwoFactorCommandHandler.php, src/User/Application/Validator/TwoFactorCodeValidator.php, and src/User/Domain/Entity/User.php - non-atomic and backward-moving replay/attempt state can weaken 2FA protections under concurrent requests.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/User/Domain/Entity/User.php">

<violation number="1" location="src/User/Domain/Entity/User.php:200">
P2: `recordAcceptedTotpTimestep()` allows the replay watermark to move backwards, which can re-open acceptance of newer previously-used TOTP steps after out-of-order writes.</violation>
</file>

<file name="src/User/Application/CommandHandler/CompleteTwoFactorCommandHandler.php">

<violation number="1" location="src/User/Application/CommandHandler/CompleteTwoFactorCommandHandler.php:240">
P1: Failed-attempt tracking is non-atomic, so concurrent invalid 2FA requests can lose increments and bypass the 5-attempt cap.</violation>
</file>

<file name="src/User/Application/Validator/TwoFactorCodeValidator.php">

<violation number="1" location="src/User/Application/Validator/TwoFactorCodeValidator.php:104">
P1: TOTP replay protection is not atomic: concurrent requests can both pass replay check and accept the same timestep.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client as HTTP Client (REST/GraphQL)
    participant Router as Symfony Router
    participant RateLimiter as Rate Limiter Middleware
    participant Resolver as ApiRateLimitAuthTargetResolver
    participant Handler as CompleteTwoFactorCommandHandler
    participant PendingRepo as PendingTwoFactorRepository
    participant UserRepo as UserRepository
    participant CodeValidator as TwoFactorCodeValidator
    participant TOTPValidator as TOTPValidator
    participant Events as Event Publisher

    Note over Client,Events: 2FA Verification Flow with Brute-Force and Replay Protection

    Client->>Router: POST /api/signin/2fa or POST /api/graphql (completeTwoFactor mutation)
    Router->>RateLimiter: Apply rate limiters

    Note over RateLimiter,Resolver: NEW: GraphQL mutation now also triggers 2FA limiters

    RateLimiter->>Resolver: resolve() to determine applicable limiters
    alt POST /api/signin/2fa (REST)
        Resolver->>Resolver: Identify path as 2FA verification endpoint
    else POST /api/graphql (GraphQL)
        Resolver->>Resolver: Parse query body for "completeTwoFactor" operation
        Resolver->>Resolver: Extract pendingSessionId from variables.input.pendingSessionId
    end
    Resolver->>Resolver: Build IP-based limiter key
    alt Pending session found with valid userId
        Resolver->>PendingRepo: findById(pendingSessionId)
        PendingRepo-->>Resolver: PendingTwoFactor (with userId)
        Resolver->>Resolver: Build user-based limiter key
    end
    Resolver-->>RateLimiter: Return limiter configurations
    RateLimiter->>RateLimiter: Check IP and user rate limits

    alt Rate limit exceeded
        RateLimiter-->>Client: 429 Too Many Requests
    else Rate limit OK
        RateLimiter-->>Handler: Forward request
    end

    Handler->>PendingRepo: findById(pendingSessionId)
    PendingRepo-->>Handler: PendingTwoFactor

    Note over Handler: NEW: Check exhausted attempts up front

    alt Pending session is expired OR failedAttempts >= MAX_FAILED_ATTEMPTS
        Handler-->>Client: 401 "Invalid or expired two-factor session."
    end

    Handler->>CodeValidator: verifyAndResolveMethod(user, code)
    alt Invalid code
        Note over Handler: NEW: Brute-force counter logic
        Handler->>PendingRepo: recordFailedAttempt() and save()
        alt failedAttempts >= MAX_FAILED_ATTEMPTS
            Handler->>PendingRepo: delete(pendingSession)
            PendingRepo-->>Handler: Session invalidated
        end
        Handler->>Events: publishFailed(pendingSessionId, ipAddress, twoFactorCode)
        Handler-->>Client: 401 "Invalid two-factor code."
    end

    Note over CodeValidator: NEW: TOTP replay protection

    CodeValidator->>TOTPValidator: resolveAcceptedTimestep(secret, code)
    TOTPValidator->>TOTPValidator: Check current and previous time windows only (future window removed)
    alt Valid code
        TOTPValidator-->>CodeValidator: Matched timestep (int)
        CodeValidator->>UserRepo: isTotpTimestepReplay(timestep)
        alt Replay detected (timestep <= lastAcceptedTotpTimestep)
            UserRepo-->>CodeValidator: true (replay)
            CodeValidator-->>Handler: Unauthorized exception
            Handler-->>Client: 401 "Invalid two-factor code."
        else First use of this timestep
            UserRepo-->>CodeValidator: false (not replay)
            CodeValidator->>CodeValidator: recordAcceptedTotpTimestep(timestep)
            CodeValidator->>UserRepo: save(user) with updated lastAcceptedTotpTimestep
            CodeValidator-->>Handler: Verification method (TOTP)
            Handler->>PendingRepo: consumePendingSessionOrFail(id)
            Handler->>Handler: Issue auth tokens
            Handler-->>Client: 200 Success with tokens
        end
    else Invalid code
        TOTPValidator-->>CodeValidator: null
        CodeValidator-->>Handler: Unauthorized exception
        Handler-->>Client: 401 "Invalid two-factor code."
    end
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic


private function registerFailedAttempt(PendingTwoFactor $pendingSession): void
{
$pendingSession->recordFailedAttempt();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Failed-attempt tracking is non-atomic, so concurrent invalid 2FA requests can lose increments and bypass the 5-attempt cap.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/User/Application/CommandHandler/CompleteTwoFactorCommandHandler.php, line 240:

<comment>Failed-attempt tracking is non-atomic, so concurrent invalid 2FA requests can lose increments and bypass the 5-attempt cap.</comment>

<file context>
@@ -220,4 +234,17 @@ private function handleTwoFactorFailure(CompleteTwoFactorCommand $command): neve
+
+    private function registerFailedAttempt(PendingTwoFactor $pendingSession): void
+    {
+        $pendingSession->recordFailedAttempt();
+
+        if ($pendingSession->hasExhaustedAttempts()) {
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The single-use security guarantee is enforced atomically at the pending-session layer via PendingTwoFactorRepository::consumeIfActive() (an atomic conditional delete): only one concurrent completion can win the remove, so the 5-attempt cap cannot be bypassed even if the non-atomic counter increment loses a write under extreme parallelism. The counter is best-effort defense-in-depth on top of that atomic gate. A full findOneAndUpdate/$inc redesign is out of scope for this PR and would risk regression. Declining the code change; security guarantee is intact.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, thanks for the clarification.

$this->decryptSecret($secret),
$code
);
if ($timestep === null || $user->isTotpTimestepReplay($timestep)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: TOTP replay protection is not atomic: concurrent requests can both pass replay check and accept the same timestep.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/User/Application/Validator/TwoFactorCodeValidator.php, line 104:

<comment>TOTP replay protection is not atomic: concurrent requests can both pass replay check and accept the same timestep.</comment>

<file context>
@@ -77,15 +78,37 @@ public function countRemainingCodes(string $userId): int
+            $this->decryptSecret($secret),
+            $code
+        );
+        if ($timestep === null || $user->isTotpTimestepReplay($timestep)) {
+            return null;
+        }
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two concurrent completions for the same pending session cannot both succeed: consumePendingSessionOrFail() -> consumeIfActive() is an atomic conditional delete, so only one request issues tokens; the loser gets UnauthorizedHttpException. The recorded TOTP watermark is defense-in-depth on top of that authoritative single-use gate. The watermark is also now monotonic (see latest commit). Declining the broader atomic-write redesign as out of scope; the replay guarantee holds.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it — thanks for the clarification.

Comment thread src/User/Domain/Entity/User.php
Comment thread src/Shared/Application/Resolver/RateLimit/ApiRateLimitPayloadValueResolver.php Outdated
@codecov

codecov Bot commented Jun 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (fe53ccb) to head (681fb6d).

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #334   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          551       551           
  Lines         9541      9628   +87     
=========================================
+ Hits          9541      9628   +87     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Signed-off-by: Vadym <kostiukdsfv@gmail.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 2 files (changes from recent commits).

Requires human review: Auto-approval blocked by 4 unresolved issues from previous reviews.

Re-trigger cubic

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jun 10, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 8 files (changes from recent commits).

Requires human review: Auto-approval blocked by 3 unresolved issues from previous reviews.

Re-trigger cubic

Split oversized test classes that exceeded phpmd TooManyMethods (max 25)
into cohesive sibling classes sharing abstract base TestCases. No coverage
lost — every test and assertion preserved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

self::assertCount(1, $result);
self::assertSame('twofa_setup', $result[0]['name']);
self::assertSame('user:' . $subject, $result[0]['key']);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 19 lines of similar code in 3 locations (mass = 171) [qlty:similar-code]


self::assertCount(1, $result);
self::assertSame('twofa_confirm', $result[0]['name']);
self::assertSame('user:' . $subject, $result[0]['key']);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 19 lines of similar code in 3 locations (mass = 171) [qlty:similar-code]


self::assertCount(1, $result);
self::assertSame('twofa_disable', $result[0]['name']);
self::assertSame('user:' . $subject, $result[0]['key']);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 19 lines of similar code in 3 locations (mass = 171) [qlty:similar-code]

$request = Request::create('/api/2fa/setup', 'GET');
$request->headers->set('Authorization', 'Bearer ' . $token);

self::assertSame([], $resolver->resolve($request));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 15 lines of similar code in 2 locations (mass = 130) [qlty:similar-code]

$request = Request::create('/api/2fa/unknown', 'POST');
$request->headers->set('Authorization', 'Bearer ' . $token);

self::assertSame([], $resolver->resolve($request));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 15 lines of similar code in 2 locations (mass = 130) [qlty:similar-code]

'EF55-GH66',
$this->faker->ipv4(),
$this->faker->userAgent()
));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 22 lines of similar code in 4 locations (mass = 176) [qlty:similar-code]

'123456',
$this->faker->ipv4(),
$this->faker->userAgent()
));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 22 lines of similar code in 4 locations (mass = 176) [qlty:similar-code]

$this->twoFactorCodeVerifier = $this->createMock(TwoFactorCodeValidatorInterface::class);
$this->events = $this->createMock(TwoFactorPublisherInterface::class);
$this->userFactory = new UserFactory();
$this->uuidTransformer = new UuidTransformer(new SharedUuidFactory());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 15 lines of identical code in 3 locations (mass = 114) [qlty:identical-code]

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 8 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/User/Application/CommandHandler/CompleteTwoFactorCommandHandler.php">

<violation number="1" location="src/User/Application/CommandHandler/CompleteTwoFactorCommandHandler.php:240">
P1: Failed-attempt tracking is non-atomic, so concurrent invalid 2FA requests can lose increments and bypass the 5-attempt cap.</violation>
</file>

<file name="src/User/Application/Validator/TwoFactorCodeValidator.php">

<violation number="1" location="src/User/Application/Validator/TwoFactorCodeValidator.php:104">
P1: TOTP replay protection is not atomic: concurrent requests can both pass replay check and accept the same timestep.</violation>
</file>

<file name="tests/Unit/Shared/Application/Resolver/RateLimit/ApiRateLimitAuthTargetResolverTwoFactorEndpointsTest.php">

<violation number="1" location="tests/Unit/Shared/Application/Resolver/RateLimit/ApiRateLimitAuthTargetResolverTwoFactorEndpointsTest.php:18">
P3: These three test methods (setup/confirm/disable) are nearly identical—only the endpoint path and expected limiter name differ. Consider using a `@dataProvider` to eliminate the repetition and make it trivial to add new 2FA endpoint variants.</violation>
</file>

<file name="tests/Unit/User/Application/CommandHandler/CompleteTwoFactorCommandHandlerBruteForceTest.php">

<violation number="1" location="tests/Unit/User/Application/CommandHandler/CompleteTwoFactorCommandHandlerBruteForceTest.php:17">
P3: The recovery-code and TOTP verification-failure tests share an almost identical structure (setup, mock configuration, assertion). Extract a parameterized helper or use a data provider to reduce the 22-line duplication across these cases.</violation>
</file>

<file name="tests/Unit/User/Application/CommandHandler/CompleteTwoFactorCommandHandlerTestCase.php">

<violation number="1" location="tests/Unit/User/Application/CommandHandler/CompleteTwoFactorCommandHandlerTestCase.php:38">
P3: The setUp body here is reported as identical to code in 2 other test-case locations. If those other locations now extend this base class, they can remove their own setUp (calling `parent::setUp()` only). Otherwise, consider whether a shared trait or further base-class consolidation could eliminate the remaining duplicates.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

}
GRAPHQL;

public function testResolveReturnsTwoFaSetupLimiterWhenUserAuthenticated(): void

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: These three test methods (setup/confirm/disable) are nearly identical—only the endpoint path and expected limiter name differ. Consider using a @dataProvider to eliminate the repetition and make it trivial to add new 2FA endpoint variants.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/Unit/Shared/Application/Resolver/RateLimit/ApiRateLimitAuthTargetResolverTwoFactorEndpointsTest.php, line 18:

<comment>These three test methods (setup/confirm/disable) are nearly identical—only the endpoint path and expected limiter name differ. Consider using a `@dataProvider` to eliminate the repetition and make it trivial to add new 2FA endpoint variants.</comment>

<file context>
@@ -0,0 +1,211 @@
+        }
+        GRAPHQL;
+
+    public function testResolveReturnsTwoFaSetupLimiterWhenUserAuthenticated(): void
+    {
+        $subject = $this->faker->uuid();
</file context>

final class CompleteTwoFactorCommandHandlerBruteForceTest extends
CompleteTwoFactorCommandHandlerTestCase
{
public function testInvokeThrowsUnauthorizedWhenRecoveryCodeVerificationFails(): void

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The recovery-code and TOTP verification-failure tests share an almost identical structure (setup, mock configuration, assertion). Extract a parameterized helper or use a data provider to reduce the 22-line duplication across these cases.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/Unit/User/Application/CommandHandler/CompleteTwoFactorCommandHandlerBruteForceTest.php, line 17:

<comment>The recovery-code and TOTP verification-failure tests share an almost identical structure (setup, mock configuration, assertion). Extract a parameterized helper or use a data provider to reduce the 22-line duplication across these cases.</comment>

<file context>
@@ -0,0 +1,193 @@
+final class CompleteTwoFactorCommandHandlerBruteForceTest extends
+    CompleteTwoFactorCommandHandlerTestCase
+{
+    public function testInvokeThrowsUnauthorizedWhenRecoveryCodeVerificationFails(): void
+    {
+        $user = $this->createTwoFactorEnabledUser();
</file context>

protected UuidTransformer $uuidTransformer;

#[\Override]
protected function setUp(): void

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The setUp body here is reported as identical to code in 2 other test-case locations. If those other locations now extend this base class, they can remove their own setUp (calling parent::setUp() only). Otherwise, consider whether a shared trait or further base-class consolidation could eliminate the remaining duplicates.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/Unit/User/Application/CommandHandler/CompleteTwoFactorCommandHandlerTestCase.php, line 38:

<comment>The setUp body here is reported as identical to code in 2 other test-case locations. If those other locations now extend this base class, they can remove their own setUp (calling `parent::setUp()` only). Otherwise, consider whether a shared trait or further base-class consolidation could eliminate the remaining duplicates.</comment>

<file context>
@@ -0,0 +1,204 @@
+    protected UuidTransformer $uuidTransformer;
+
+    #[\Override]
+    protected function setUp(): void
+    {
+        parent::setUp();
</file context>

…ifecycle for #314

Scope the TOTP replay/timestep guard to the sign-in completion (and disable)
flows. The setup-confirmation path (/api/2fa/confirm) no longer consumes the
sign-in replay time-step, so a sign-in completion using a code from the same
30s window is no longer wrongly rejected (was returning 401 instead of 200).
The CWE-294 finding targets sign-in code replay; setup confirmation is a
one-time enable step that issues no session, so it must not advance the
replay counter. Replay protection on /api/signin/2fa and /api/2fa/disable
is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
$this->encryptor,
$this->recoveryCodeRepository,
$this->userRepository,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 17 lines of similar code in 5 locations (mass = 106) [qlty:similar-code]

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 5 files (changes from recent commits).

Requires human review: Auto-approval blocked by 8 unresolved issues from previous reviews.

Re-trigger cubic

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/User/Application/CommandHandler/CompleteTwoFactorCommandHandler.php (1)

175-178: 💤 Low value

Remove inline comment; the method name is self-explanatory.

The comment explains why the brute-force counter is not incremented, but the method name failWithoutCountingAttempt and the code structure (calling it in the recovery-code-failure catch block after session consumption) already convey this intent.

♻️ Proposed cleanup
         } catch (UnauthorizedHttpException) {
-            // The pending session was already consumed before this point, so the
-            // brute-force counter is no longer applicable here.
             $this->failWithoutCountingAttempt($command);
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/User/Application/CommandHandler/CompleteTwoFactorCommandHandler.php`
around lines 175 - 178, Remove the inline explanatory comment immediately above
the call to $this->failWithoutCountingAttempt($command) in
CompleteTwoFactorCommandHandler (the recovery-code-failure catch path); the
method name is self-explanatory so delete that comment line only and leave the
method call and surrounding control flow intact.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@specs/security-314-2fa-bruteforce-totp-replay/stories.md`:
- Around line 7-8: The docker run command contains developer-specific absolute
paths (/home/kravtsov/Projects/secfix-314 and
/home/kravtsov/Projects/user-service/vendor) which break portability; update the
docker run line to use relative paths or environment/placeholders (e.g., ./,
${PROJECT_ROOT}, ${VENDOR_DIR}) and/or mention in the docs that users must
replace these placeholders with their local paths so the -v mounts work for
other contributors.
- Around line 5-13: The fenced code block in
specs/security-314-2fa-bruteforce-totp-replay/stories.md is missing a language
specifier; update the opening fence for the shell/PHP command block used to run
unit tests (the block that begins with "# Unit (focused)" and the docker run
command) from "```" to "```bash" so the snippet is annotated for syntax
highlighting.

---

Nitpick comments:
In `@src/User/Application/CommandHandler/CompleteTwoFactorCommandHandler.php`:
- Around line 175-178: Remove the inline explanatory comment immediately above
the call to $this->failWithoutCountingAttempt($command) in
CompleteTwoFactorCommandHandler (the recovery-code-failure catch path); the
method name is self-explanatory so delete that comment line only and leave the
method call and surrounding control flow intact.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: acfa37e9-3823-4361-9a46-c817a4fc6f2a

📥 Commits

Reviewing files that changed from the base of the PR and between fe53ccb and aecc33c.

📒 Files selected for processing (29)
  • config/doctrine/User/PendingTwoFactor.mongodb.xml
  • config/doctrine/User/User.mongodb.xml
  • specs/security-314-2fa-bruteforce-totp-replay/prd.md
  • specs/security-314-2fa-bruteforce-totp-replay/stories.md
  • src/Shared/Application/Resolver/RateLimit/ApiRateLimitAuthTargetResolver.php
  • src/Shared/Application/Resolver/RateLimit/ApiRateLimitClientIdentityResolver.php
  • src/Shared/Application/Resolver/RateLimit/ApiRateLimitPayloadValueResolver.php
  • src/User/Application/CommandHandler/CompleteTwoFactorCommandHandler.php
  • src/User/Application/CommandHandler/ConfirmTwoFactorCommandHandler.php
  • src/User/Application/Validator/TOTPValidatorInterface.php
  • src/User/Application/Validator/TwoFactorCodeValidator.php
  • src/User/Application/Validator/TwoFactorCodeValidatorInterface.php
  • src/User/Domain/Entity/PendingTwoFactor.php
  • src/User/Domain/Entity/User.php
  • src/User/Infrastructure/Validator/TOTPValidator.php
  • tests/Unit/Shared/Application/Resolver/RateLimit/ApiRateLimitAuthTargetResolverTest.php
  • tests/Unit/Shared/Application/Resolver/RateLimit/ApiRateLimitAuthTargetResolverTwoFactorEndpointsTest.php
  • tests/Unit/Shared/Application/Resolver/RateLimit/ApiRateLimitPayloadValueResolverTest.php
  • tests/Unit/User/Application/CommandHandler/CompleteTwoFactorCommandHandlerBruteForceTest.php
  • tests/Unit/User/Application/CommandHandler/CompleteTwoFactorCommandHandlerTest.php
  • tests/Unit/User/Application/CommandHandler/CompleteTwoFactorCommandHandlerTestCase.php
  • tests/Unit/User/Application/CommandHandler/ConfirmTwoFactorCommandHandlerTest.php
  • tests/Unit/User/Application/Verifier/TwoFactorCodeSetupVerificationTest.php
  • tests/Unit/User/Application/Verifier/TwoFactorCodeVerifierTest.php
  • tests/Unit/User/Domain/Entity/PendingTwoFactorTest.php
  • tests/Unit/User/Domain/Entity/UserTest.php
  • tests/Unit/User/Domain/Entity/UserTestCase.php
  • tests/Unit/User/Domain/Entity/UserTwoFactorTest.php
  • tests/Unit/User/Infrastructure/Validator/TOTPValidatorTest.php
💤 Files with no reviewable changes (1)
  • tests/Unit/Shared/Application/Resolver/RateLimit/ApiRateLimitAuthTargetResolverTest.php

Comment thread specs/security-314-2fa-bruteforce-totp-replay/stories.md Outdated
Comment thread specs/security-314-2fa-bruteforce-totp-replay/stories.md Outdated
- Make recordAcceptedTotpTimestep() monotonic so the TOTP replay watermark
  only ever moves forward, hardening against out-of-order writes (cubic P2).
- Assert brute-force counter persistence in assertInvalidTwoFactorCodeRejected
  so the helper verifies the security-critical increment (cubic P2).
- Assert the IP limiter key in the GraphQL both-limiters test for parity with
  the REST case (cubic P2).
- Use portable placeholders and a bash fence in stories.md (CodeRabbit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dmytrocraft

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jun 10, 2026
self::assertSame('twofa_verification_ip', $result[0]['name']);
self::assertSame('ip:' . $clientIp, $result[0]['key']);
self::assertSame('twofa_verification_user', $result[1]['name']);
self::assertSame('user:' . $userId, $result[1]['key']);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 19 lines of similar code in 2 locations (mass = 172) [qlty:similar-code]

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 4 files (changes from recent commits).

Requires human review: Auto-approval blocked by 3 unresolved issues from previous reviews.

Re-trigger cubic

The monotonic guard added to User::recordAcceptedTotpTimestep introduced an
equivalent mutant at its `<=` boundary (skip vs assign-the-same-value is
unobservable), which cannot be killed and dropped Infection MSI below the
required 100%. The guard is redundant: TwoFactorCodeValidator rejects replayed
or older codes via isTotpTimestepReplay() before calling this setter, so only
forward-moving time-steps ever reach it. Reverted to a plain assignment;
single-use TOTP replay protection is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/User/Domain/Entity/User.php">

<violation number="1" location="src/User/Domain/Entity/User.php:197">
P2: `recordAcceptedTotpTimestep()` no longer enforces monotonic updates, so a stale caller can regress the replay watermark and re-open acceptance of previously used TOTP steps.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

/**
* Records a successfully accepted TOTP time-step so the same (or older)
* code can never be replayed within its validity window. Callers MUST gate
* this on {@see isTotpTimestepReplay()} — the validator rejects replayed or

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: recordAcceptedTotpTimestep() no longer enforces monotonic updates, so a stale caller can regress the replay watermark and re-open acceptance of previously used TOTP steps.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/User/Domain/Entity/User.php, line 197:

<comment>`recordAcceptedTotpTimestep()` no longer enforces monotonic updates, so a stale caller can regress the replay watermark and re-open acceptance of previously used TOTP steps.</comment>

<file context>
@@ -193,19 +193,13 @@ public function isTotpTimestepReplay(int $timestep): bool
-     * ever moves forward, so out-of-order writes cannot re-open acceptance of an
-     * already-consumed time-step.
+     * code can never be replayed within its validity window. Callers MUST gate
+     * this on {@see isTotpTimestepReplay()} — the validator rejects replayed or
+     * older codes before recording — so only forward-moving time-steps ever
+     * reach this setter.
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Security] high: Two-factor (2FA/TOTP) verification: no server-side brute-force counter and codes are replayable

2 participants