fix: retry concurrent passwordless registration deadlocks (#368); document step-up session id behavior (#369) - #372
Conversation
…ument step-up session id behavior (#369) Passwordless registration had the same SERIALIZABLE-isolation deadlock as password registration but none of the hardening: registerPasswordlessAccount ran a single @transactional(SERIALIZABLE) save with no retry and no DataIntegrityViolationException translation, so a concurrent-insert deadlock (including between DIFFERENT emails contending on index gap locks) surfaced as a generic 500 "System Error" on POST /user/registration/passwordless. Mirror the password path: generalize persistWithSerializationRetry to take the proxied persist call, add persistNewPasswordlessAccount (@transactional(SERIALIZABLE), DataIntegrityViolationException -> 409, no password history), and make registerPasswordlessAccount NOT_SUPPORTED so each retry runs in its own fresh short transaction. Exhausted retries still propagate honestly rather than being misreported as an existing account. Tests: 4 mock-based UserServiceTest cases (retry-then-succeed, DIV->409, exhausted->propagate, unrelated-exception-not-swallowed) and passwordless same-email / different-email cases in AbstractConcurrentRegistrationTest, which run against real MariaDB and PostgreSQL via Testcontainers. Full suite: 1280 tests, 0 failures. #369: document in CONFIG.md that step-up re-assertion preserves the session id (intended: principal unchanged, no fixation vector) while the CSRF token rotates, and that a client retrying after step-up must refresh its CSRF token. Claude-Session: https://claude.ai/code/session_01KL5qvKVGQLLQDjHToy34vj
ReviewThis is a clean, well-executed mirror of the existing password-registration hardening onto the passwordless path. A few notes, none blocking. Correctness — looks solid
One thing worth double-checking
Minor style nit
Test coverage — thorough
Docs
Overall: solid, low-risk fix that follows the codebase's established pattern faithfully. No security or performance concerns beyond the note above about |
| @Transactional(isolation = Isolation.SERIALIZABLE) | ||
| protected User persistNewPasswordlessAccount(final User user) { | ||
| if (emailExists(user.getEmail())) { | ||
| log.debug("UserService.persistNewPasswordlessAccount: email already exists: {}", user.getEmail()); |
| // registration of a DIFFERENT email, so it propagates to the retry in | ||
| // persistWithSerializationRetry, whose fresh-transaction pre-check distinguishes the two cases. | ||
| log.debug("UserService.persistNewPasswordlessAccount: concurrent duplicate registration detected for email {}: {}", | ||
| user.getEmail(), e.getClass().getSimpleName()); |
There was a problem hiding this comment.
🟢 Approval recommended
The concurrency/transaction changes are consistent with the existing password registration hardening and are backed by targeted unit tests plus real concurrent DB tests.
Pull request overview
This PR hardens passwordless registration to handle SERIALIZABLE deadlocks the same way as password-based registration, and documents intended step-up session/CSRF behavior for clients consuming the library.
Changes:
- Generalizes the registration serialization retry loop to accept a proxied persister function and applies it to passwordless registration.
- Adds
persistNewPasswordlessAccount(@Transactional(SERIALIZABLE))to translate unique-constraint races intoUserAlreadyExistExceptionwhile allowing serialization failures to trigger retries. - Adds unit + concurrent Testcontainers coverage for passwordless registration deadlock/duplicate scenarios and documents step-up session id vs CSRF rotation behavior.
File summaries
| File | Description |
|---|---|
| src/main/java/com/digitalsanctuary/spring/user/service/UserService.java | Refactors retry helper to take a proxied persister and applies it to passwordless registration with a new SERIALIZABLE persist method and exception translation. |
| src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java | Adds mock-based tests validating passwordless retry behavior, exception translation, and propagation on exhausted retries. |
| src/test/java/com/digitalsanctuary/spring/user/service/AbstractConcurrentRegistrationTest.java | Adds real DB concurrent tests for passwordless same-email and different-email contention scenarios. |
| CONFIG.md | Documents intended step-up re-assertion behavior: session id unchanged while CSRF rotates, and client implications. |
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Summary
Closes #368 and #369.
#368 — Concurrent passwordless registrations no longer 500 on deadlock
registerPasswordlessAccounthad the same SERIALIZABLE-isolation deadlock exposure as password registration but none of the hardening that path already got: it ran a single@Transactional(SERIALIZABLE)save with no retry and noDataIntegrityViolationExceptiontranslation. A concurrent-insert deadlock (including between different emails contending on InnoDB index gap locks) surfaced as a generic 500 "System Error" onPOST /user/registration/passwordless.The fix mirrors the password path exactly:
persistWithSerializationRetryis generalized to take the proxied persist call (Function<User, User>), so both registration paths share the retry-with-jittered-backoff loop.persistNewPasswordlessAccount(@Transactional(SERIALIZABLE)) translatesDataIntegrityViolationExceptiontoUserAlreadyExistException(409) and records no password history (the account has no password).ConcurrencyFailureExceptiondeliberately propagates to the retry, whose fresh-transaction pre-check distinguishes a same-email race (409) from a different-email deadlock (succeeds on retry).registerPasswordlessAccountis nowPropagation.NOT_SUPPORTEDso each retry runs in its own fresh short transaction. Exhausted retries propagate honestly (500 the caller can retry) rather than being misreported as an existing account.#369 — Step-up session id behavior documented
Added a note to the CONFIG.md step-up section: re-assertion preserves the servlet session id (
JSESSIONID) while rotating the CSRF token, because factor merging merges the new factor onto the already-authenticated principal instead of running a fresh authentication. This is intended (principal unchanged, no pre-auth session id to upgrade, so no fixation vector). Documents the client consequence: a client retrying a gated operation after step-up must refresh its CSRF token first.Testing
UserServiceTestcases for the passwordless path: retry-then-succeed,DataIntegrityViolationException→409, exhausted-retries→propagateConcurrencyFailureException, and unrelated-exception-not-swallowed.AbstractConcurrentRegistrationTest, which runs against real MariaDB and PostgreSQL via Testcontainers. The MariaDB run reproduces the issue-368 deadlock (1213-40001) and confirms it is now caught and retried rather than surfaced.https://claude.ai/code/session_01KL5qvKVGQLLQDjHToy34vj