Skip to content

fix: TOTP clock-drift tolerance with replay prevention#3601

Open
Anty0 wants to merge 6 commits intomainfrom
jirikuchynka/totp-window-tolerance
Open

fix: TOTP clock-drift tolerance with replay prevention#3601
Anty0 wants to merge 6 commits intomainfrom
jirikuchynka/totp-window-tolerance

Conversation

@Anty0
Copy link
Copy Markdown
Collaborator

@Anty0 Anty0 commented Apr 10, 2026

This PR was made in preparation for implementing support for WebAuthn (HW MFA tokens).

Summary

  • Accept TOTP codes from the previous and next 30-second time steps in addition to the current one so a few seconds of clock drift between the user's device and the server no longer cause confusing login failures (±1 time-step tolerance is the RFC 6238 recommendation and matches Google / GitHub / Microsoft).
  • Prevent replay of the now-widened window per RFC 6238 §5.2 by tracking the last accepted absolute time step counter on UserAccount and rejecting any authentication whose matched counter is not strictly greater.
  • Use a conditional atomic UPDATE (updateTotpLastUsedTimeStepIfGreater) so concurrent requests with the same code are handled without row locks: the first commit wins and the loser is rejected as a replay.
  • Seed the counter from the matched step inside enableMfaTotp so the same code used to enable MFA cannot be replayed against the login endpoint immediately afterwards.
  • Burn the current + next TOTP windows inside consumeMfaRecoveryCode so a previously captured TOTP cannot be replayed after a recovery-code authentication.
  • Reset the counter inside disableMfaTotp so re-enabling starts from a clean slate.
  • Covered by 11 new integration tests exercising enable-time replay, same-window replay, past/future-drift behaviour, forward progress, disable-and-re-enable, recovery-code burn, null-key defence, malformed codes, and end-to-end replay on both /api/public/generatetoken and /v2/user/generate-super-token.

Summary by CodeRabbit

  • Bug Fixes
    • Improved MFA/TOTP behavior: accepts adjacent time windows, prevents replayed codes, advances/locks time windows on consumption, rejects malformed or out-of-window TOTPs, and ensures recovery-code use invalidates related TOTP windows. Added race-resistant last-used-step checks to prevent replay under concurrency.
  • Chores
    • Removed a duplicate build configuration entry and added a database migration to store the last-used TOTP time step.
  • Tests
    • Added comprehensive MFA/TOTP tests covering replay, windowing, burn behavior, and edge cases.

Expand 2FA validation to accept codes from the previous and next
30-second time windows in addition to the current one. This handles
clock drift between the user's device and the server, which can
otherwise cause confusing validation failures when the user's time
is off by a few seconds or when entering a code near a time-step
boundary. ±1 tolerance is the RFC 6238 recommendation and matches
the behavior of all major 2FA providers.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 10, 2026

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: 753d5c35-a080-4094-80bb-8e945dd341d5

📥 Commits

Reviewing files that changed from the base of the PR and between 6f6f01f and 77443a6.

📒 Files selected for processing (2)
  • backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SensitiveOperationProtectionTestData.kt
  • backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/SensitiveOperationProtectionE2eDataController.kt

📝 Walkthrough

Walkthrough

Adds time-step-aware TOTP handling with replay protection and atomic last-used tracking, persists a new totp_last_used_time_step column, updates MFA service and account service flows/signatures, extends controller tests for many TOTP scenarios, and removes a duplicate build allOpen annotation.

Changes

Cohort / File(s) Summary
Build Configuration
backend/app/build.gradle
Removed duplicate allOpen annotation mapping for org.springframework.stereotype.Service.
MFA/TOTP Core
backend/data/src/main/kotlin/io/tolgee/service/security/MfaService.kt
Reworked TOTP utilities to be time-step-aware: added currentTimeStep, findMatchingTimeStep, time-slot offsets for generateCode/generateStringCode, switched validation to accept raw key, injected currentDateProvider, and centralized matching/validation logic.
User Account model
backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt
Added nullable totpLastUsedTimeStep: Long? mapped to totp_last_used_time_step.
Repository
backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt
Added updateTotpLastUsedTimeStepIfGreater(id, newTimeStep) JPQL update to atomically set last-used step only if null or smaller (returns affected rows).
Account Service
backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt
Injected mfaService; enableMfaTotp now accepts initialOtp and sets totpLastUsedTimeStep; added consumeTotpCode with atomic update and race detection; consumeMfaRecoveryCode burns current TOTP window; notifications centralized.
Migrations
backend/data/src/main/resources/db/changelog/schema.xml
Added Liquibase changeSet to add user_account.totp_last_used_time_step BIGINT.
E2E Dev Endpoint
backend/development/src/main/kotlin/.../SensitiveOperationProtectionE2eDataController.kt
Injected UserAccountRepository; /get-totp endpoint clears totpLastUsedTimeStep for test user before generating and returning a TOTP.
Tests
backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/UserMfaControllerTest.kt
Expanded MFA/TOTP tests (replay, windowing, recovery interactions), added fixed-time helpers and teardown, centralized persisted-user helper, switched some assertions, and corrected a DTO usage in a disable-password test.

Sequence Diagram(s)

sequenceDiagram
  participant Client as Client
  participant Controller as UserMfaController
  participant Service as UserAccountService
  participant Mfa as MfaService
  participant Repo as UserAccountRepository
  participant DB as Database

  Client->>Controller: POST /mfa/enable (initialOtp, key)
  Controller->>Service: enableMfaTotp(user, key, initialOtp)
  Service->>Mfa: findMatchingTimeStep(key, initialOtp)
  Mfa-->>Service: matchedTimeStep or null
  alt matched
    Service->>Repo: save totpKey + totpLastUsedTimeStep = matchedTimeStep
    Repo->>DB: INSERT/UPDATE row
    DB-->>Repo: OK
    Repo-->>Service: persisted UserAccount
    Service-->>Controller: success
    Controller-->>Client: 200 OK
  else not matched
    Service-->>Controller: ValidationException
    Controller-->>Client: 400 Invalid OTP
  end

  Client->>Controller: POST /mfa/consume (code)
  Controller->>Service: consumeTotpCode(user, code)
  Service->>Mfa: findMatchingTimeStep(key, code, minExclusive = user.totpLastUsedTimeStep)
  Mfa-->>Service: matchedStep or null
  alt matchedStep and > lastUsed
    Service->>Repo: updateTotpLastUsedTimeStepIfGreater(id, matchedStep)
    Repo->>DB: atomic UPDATE WHERE totp_last_used_time_step IS NULL OR < matchedStep
    DB-->>Repo: rowsAffected (1 or 0)
    alt rowsAffected == 1
      Repo-->>Service: success -> allowed
      Service-->>Controller: consumed OK
      Controller-->>Client: 200 OK
    else rowsAffected == 0
      Repo-->>Service: conflict -> AuthenticationException (replay/race)
      Service-->>Controller: reject
      Controller-->>Client: 401 Replay detected
    end
  else not matched
    Service-->>Controller: AuthenticationException
    Controller-->>Client: 401 Invalid OTP
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • bdshadow

Poem

🐇 I hopped through time-steps, windows three,
I guard each code so none replay on me.
A tiny column keeps the last-used spot,
Atomic hops ensure a code is not.
Secure little jumps — now celebrate with a carrot! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.87% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main changes: adding TOTP clock-drift tolerance (±1 time-step window per RFC 6238) while implementing replay prevention via time-step tracking.

✏️ 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 jirikuchynka/totp-window-tolerance

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

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
backend/data/src/main/kotlin/io/tolgee/service/security/MfaService.kt (1)

145-159: Optional: flip the two TOTP helpers.

generateStringCode() is the caller but currently sits below generateCode(), so the file reads bottom-up at the end. Swapping them would match the Stepdown Rule used for Kotlin files here.

As per coding guidelines, "Functions should be ordered so that a caller appears before the functions it calls."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/data/src/main/kotlin/io/tolgee/service/security/MfaService.kt` around
lines 145 - 159, Move the TOTP helper functions so the caller appears before the
callee: place generateStringCode(...) above generateCode(...). Update their
order within the MfaService class so generateStringCode calls generateCode and
then generates/pads the string, followed by generateCode which computes the Int
TOTP using totpGenerator and SecretKeySpec.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/data/src/main/kotlin/io/tolgee/service/security/MfaService.kt`:
- Around line 137-150: The TOTP validation currently calls generateCode(key,
offset) three times which each calls Instant.now(), causing flakiness near
time-step boundaries; update validateTotpCode to read Instant.now() once into a
local Instant and pass that fixed timestamp into generateCode (add an overload
or parameter like timestamp: Instant to generateCode) so all three comparisons
use the same reference time; update generateCode to call
totpGenerator.generateOneTimePassword(SecretKeySpec(key,
SecurityConfiguration.OTP_ALGORITHM), timestamp) with the passed Instant and
adjust tests to use the injectable timestamp path.

---

Nitpick comments:
In `@backend/data/src/main/kotlin/io/tolgee/service/security/MfaService.kt`:
- Around line 145-159: Move the TOTP helper functions so the caller appears
before the callee: place generateStringCode(...) above generateCode(...). Update
their order within the MfaService class so generateStringCode calls generateCode
and then generates/pads the string, followed by generateCode which computes the
Int TOTP using totpGenerator and SecretKeySpec.
🪄 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: b33d0458-35d8-4f26-bf0f-b21c83e60c1b

📥 Commits

Reviewing files that changed from the base of the PR and between 8fef429 and 6becc9d.

📒 Files selected for processing (3)
  • backend/app/build.gradle
  • backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/UserMfaControllerTest.kt
  • backend/data/src/main/kotlin/io/tolgee/service/security/MfaService.kt
💤 Files with no reviewable changes (1)
  • backend/app/build.gradle

Comment thread backend/data/src/main/kotlin/io/tolgee/service/security/MfaService.kt Outdated
The previous commit widened TOTP validation to the ±1 time step window to
tolerate clock drift, which also widened the theoretical replay window of a
captured code. Track the last accepted absolute time step counter per user
and reject any authentication whose matched counter is not strictly greater,
using a conditional atomic UPDATE to stay safe under concurrent requests.

The enable flow seeds the counter from the matched step so the enable-time
code cannot be replayed against the login endpoint, and the recovery-code
path burns the current TOTP window so a captured code cannot be replayed
after a recovery-code authentication.
@Anty0 Anty0 changed the title fix: accept TOTP codes from adjacent time steps fix: TOTP clock-drift tolerance with replay prevention Apr 10, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt (1)

341-363: Consider moving notifySelf to follow the Stepdown Rule.

The private helper notifySelf is placed between public methods (disableMfaTotp and consumeTotpCode). Per the Stepdown Rule, private helpers should appear after the public methods that call them.

Suggested reorder

Move notifySelf to after updatePassword (around line 564) or group it with other private methods like resetTokensValidNotBefore. This keeps high-level public API at the top with implementation details below.

As per coding guidelines: "Functions should be ordered so that a caller appears before the functions it calls."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt`
around lines 341 - 363, The private helper notifySelf is currently interleaved
between public methods (it sits between disableMfaTotp and consumeTotpCode);
move notifySelf so it follows the Stepdown Rule by placing it after the public
API methods that call it (for example after updatePassword or grouped with other
private helpers like resetTokensValidNotBefore), ensuring all public methods
(disableMfaTotp, consumeTotpCode, updatePassword, etc.) appear before notifySelf
and other private helper functions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt`:
- Around line 341-363: The private helper notifySelf is currently interleaved
between public methods (it sits between disableMfaTotp and consumeTotpCode);
move notifySelf so it follows the Stepdown Rule by placing it after the public
API methods that call it (for example after updatePassword or grouped with other
private helpers like resetTokensValidNotBefore), ensuring all public methods
(disableMfaTotp, consumeTotpCode, updatePassword, etc.) appear before notifySelf
and other private helper functions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: db63eb80-1d02-4e44-b587-b3fe3338eba9

📥 Commits

Reviewing files that changed from the base of the PR and between 6becc9d and acf6668.

📒 Files selected for processing (6)
  • backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/UserMfaControllerTest.kt
  • backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt
  • backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt
  • backend/data/src/main/kotlin/io/tolgee/service/security/MfaService.kt
  • backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt
  • backend/data/src/main/resources/db/changelog/schema.xml
🚧 Files skipped from review as they are similar to previous changes (1)
  • backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/UserMfaControllerTest.kt

Two tests started failing in CI after replay prevention landed:

- `UserMfaControllerTest.it regenerates MFA recovery codes` hit a
  pre-existing JWT/`tokens_valid_not_before` race — the test helper
  `enableMfa()` bumps `tokens_valid_not_before` via `resetTokensValidNotBefore`,
  and when the wall clock crossed a second boundary between the earlier
  `loginAsAdmin` and that bump, the JWT's `iat` became strictly less than
  the new `tokens_valid_not_before` and the next `performAuth*` call 401-ed.
  Refresh the JWT defensively inside the helper.

- The `Asks for OTP before operation` Cypress spec called the internal
  `get-totp` helper twice per run — once for pepa's login and once for the
  sensitive-operation dialog — and within a single 30-second window both
  calls returned the same 6-digit code. Replay prevention now correctly
  rejects the second use. Reset pepa's `totpLastUsedTimeStep` inside the
  test-only helper so each `get-totp` call yields a code that will be
  accepted regardless of what the previous call burned.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

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 (2)
backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/UserMfaControllerTest.kt (2)

398-399: Minor: Comment references enableMfa but enableMfaAtFixedTime was called.

The comment is technically correct since enableMfaAtFixedTime() delegates to enableMfa(), but for clarity:

📝 Suggested clarification
-    // `enableMfa` cleared the JWT-valid-from marker, so refresh the test's auth token.
+    // `enableMfaAtFixedTime` (via `enableMfa`) cleared the JWT-valid-from marker, so refresh the test's auth token.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/UserMfaControllerTest.kt`
around lines 398 - 399, Update the inline comment to reference
enableMfaAtFixedTime instead of enableMfa for clarity: change the comment above
the loginAsUser(persistedUser()) call to mention that enableMfaAtFixedTime
cleared the JWT-valid-from marker, so the test's auth token is refreshed;
reference enableMfaAtFixedTime and loginAsUser/persistedUser in the comment.

336-336: Consider clarifying or removing the sanity-check assertion.

The assertion assertThat(advancedStep).isLessThan(postReEnableStep + 1L) is mathematically equivalent to advancedStep <= postReEnableStep. Given that postReEnableStep is exactly one time step after advancedStep due to the 30-second advancement, this will always trivially pass.

If the intent is to verify the counter reset actually allowed forward progress past the old high-water mark, consider a clearer assertion:

♻️ Suggested clarification
-    assertThat(advancedStep).isLessThan(postReEnableStep + 1L) // sanity: values are comparable
+    // Verify that after disable/re-enable, the counter can advance past where it was before
+    assertThat(postReEnableStep).isGreaterThanOrEqualTo(advancedStep)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/UserMfaControllerTest.kt`
at line 336, The current sanity-check assertion using
assertThat(advancedStep).isLessThan(postReEnableStep + 1L) is effectively
tautological; replace it with a clearer intent check in UserMfaControllerTest.kt
that uses the existing variables (advancedStep, postReEnableStep). Either assert
forward progress explicitly (e.g., assert that postReEnableStep is greater than
advancedStep) to prove the counter moved forward, or assert the exact expected
increment (e.g., postReEnableStep equals advancedStep + 1) if the step advance
is deterministic; remove the original tautological assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/SensitiveOperationProtectionE2eDataController.kt`:
- Line 45: Typo in the inline comment inside
SensitiveOperationProtectionE2eDataController: replace "cadency then" with
"cadence than" (or better "cadence than once every 30s") so the comment reads
clearly; locate the comment mentioning "Reset OTP replay protection" and update
the wording to "cadence than" to fix the spelling and comparative usage.
- Line 46: The current safe-call on userAccountRepository.findActive("pepa")
makes the totpLastUsedTimeStep reset a silent no-op if the "pepa" account is
missing; change this to fail fast by asserting presence and throwing when null
(e.g., use requireNotNull(userAccountRepository.findActive("pepa")) or check
result and throw IllegalStateException) before setting totpLastUsedTimeStep =
null so tests error immediately and not later with replay failures.

---

Nitpick comments:
In
`@backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/UserMfaControllerTest.kt`:
- Around line 398-399: Update the inline comment to reference
enableMfaAtFixedTime instead of enableMfa for clarity: change the comment above
the loginAsUser(persistedUser()) call to mention that enableMfaAtFixedTime
cleared the JWT-valid-from marker, so the test's auth token is refreshed;
reference enableMfaAtFixedTime and loginAsUser/persistedUser in the comment.
- Line 336: The current sanity-check assertion using
assertThat(advancedStep).isLessThan(postReEnableStep + 1L) is effectively
tautological; replace it with a clearer intent check in UserMfaControllerTest.kt
that uses the existing variables (advancedStep, postReEnableStep). Either assert
forward progress explicitly (e.g., assert that postReEnableStep is greater than
advancedStep) to prove the counter moved forward, or assert the exact expected
increment (e.g., postReEnableStep equals advancedStep + 1) if the step advance
is deterministic; remove the original tautological assertion.
🪄 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: b37df762-0054-4ba5-9954-d7bd1f116563

📥 Commits

Reviewing files that changed from the base of the PR and between dad873b and 6f6f01f.

📒 Files selected for processing (2)
  • backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/UserMfaControllerTest.kt
  • backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/SensitiveOperationProtectionE2eDataController.kt

Anty0 and others added 2 commits April 10, 2026 18:23
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
@Anty0 Anty0 requested review from JanCizmar, bdshadow and dkrizan April 10, 2026 17:05
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.

1 participant