Security & reliability hardening for 4.4.0 (Tier 1 remediation, MFA #313, session regen)#314
Security & reliability hardening for 4.4.0 (Tier 1 remediation, MFA #313, session regen)#314devondragon wants to merge 54 commits into
Conversation
…ng Security 7 Referer read
…lidation works (H1)
…ebAuthn login (H3)
…x migration guidance (H6)
…itionalOnMissingBean (H8)
…n, atomic consume, configurable lifetime
…h failure messages
…ccessService for logout audit
…private @transactional, AFTER_COMMIT delete event
…paths; support composite guards
…registration/disable/webauthn-auth events
…component scan; stabilize real-form-login integration tests
…o avoid component-scan leak
Addresses review finding MEDIUM-3.
…esetToken Asserts an expired token returns null AND is deleted as cleanup. Addresses review finding MEDIUM-4.
Two pre-existing issues on this branch broke './gradlew javadoc' (and thus publishLocal and the
Maven Central release, both of which build the Javadoc jar):
- @apiNote/@implSpec/@implNote were reported as 'unknown tag'. Registered them as standard doc tags
in the Javadoc options.
- {@link VerificationToken#getPlainToken()} could not resolve because getPlainToken() is Lombok-
generated (@DaTa) and invisible to the source-based Javadoc tool. Switched to {@code ...}.
…al consumer chains (HIGH-3) The library's SecurityFilterChain backed off via @ConditionalOnMissingBean(SecurityFilterChain.class) - a TYPE-based condition. That suppressed the ENTIRE library chain whenever a consumer defined any SecurityFilterChain, even a narrow one (e.g. an actuator- or test-API-only chain at a higher @order). The result: the library's form login, CSRF, session management, WebAuthn and OAuth2 config silently vanished and the library's URIs were left unprotected. This is a regression surfaced by running the demo app under its 'playwright-test' profile, which defines a narrow /api/test/** chain: the library chain was suppressed, /user/** became unprotected (500s on protected pages) and form login broke, failing ~70 Playwright tests. Switch to @ConditionalOnMissingBean(name = "securityFilterChain"). The library chain now coexists with additional, differently-named consumer chains (standard Spring Security multi-chain @order layering), while a consumer can still fully replace it by naming their bean 'securityFilterChain'. Rewrites WebSecurityConfigCompositionTest to assert coexistence (the regression guard) and the named-replacement back-off, replacing the prior test that wrongly encoded the type-based behavior. Validated end-to-end: demo app now builds both chains, protected pages redirect to login, and the Playwright suite goes from ~70 failures to 102/103 passing.
…hange (default) A self-service password change (and removing a password) previously invalidated ALL of the user's sessions including the current one, logging the user out of the very device they used to change it. This adds invalidateSessionsAfterPasswordChange(): by default it preserves the current session and regenerates its id (session-fixation protection per OWASP) while invalidating only the user's OTHER sessions, so the user stays logged in. Token-based password resets are unaffected (no authenticated current session to preserve -> all sessions invalidated as before). Controlled by user.session.invalidation.keep-current-session-on-password-change (default true); set false to restore the prior invalidate-everything behavior.
- Rewrite the SecurityFilterChain override-model section for the now name-based @ConditionalOnMissingBean (additional consumer chains coexist; full replacement requires a bean named securityFilterChain). - Document the UserEmailService constructor's new TokenHasher parameter (breaking for subclasses). - Document the Passay 1.x -> 2.0.0 upgrade and package relocations. - Document the new preserve-current-session-on-password-change default and its toggle.
…t redirect loop (#313) When MFA is enabled the framework already auto-unprotects /user/mfa/status, but not the configured passwordEntryPointUri / webauthnEntryPointUri. A partially-authenticated user (one factor satisfied) is redirected to a factor entry-point page to complete the remaining factor(s); if that page is itself protected, the redirect target is denied and the user loops between entry points (ERR_TOO_MANY_REDIRECTS). getUnprotectedURIsList() now also adds the configured entry-point URIs when MFA is enabled, so consuming apps no longer have to list them manually. This is the 'related observation' from #313. The issue's primary bug (second-factor login replacing rather than merging factor authorities) was already fixed on this branch by the MFA filter-merging post-processor (MfaFilterMergingConfiguration sets mfaEnabled=true on the auth processing filters).
Spring Boot 4.0 requires Java 21+, so the library compiles to Java 21 bytecode via the Gradle toolchain. Replace the testJdk17 task (which never worked against Java 21 bytecode) with testJdk25, and run runtime-compatibility tests on Java 21 (minimum) and Java 25 (current LTS). CI now installs JDK 21 (toolchain + Gradle JVM) and 25, runs './gradlew check' (compile + test on 21) and './gradlew testJdk25' (runtime on 25). The previous [17, 21] matrix's Java-17 leg could not resolve the required Java 21 toolchain and would have failed. Validated locally: testJdk25 is green.
Code Review — PR #314: Security & Reliability Hardening for 4.4.0This is a well-scoped, security-first release with thorough documentation. The architectural refactors (bean lifecycle, auto-configuration, multi-chain layering) are correct and the security fixes are substantive. Below is a focused review organized by topic. Security ✅ (Fixes are solid — a few notes)Token hashing ( Single-use tokens — Email leak in exception messages — throw new LockedException("Account is locked: " + user.getEmail());The
Architecture / Spring Boot Lifecycle ✅
Correctness Concerns
Code Quality / Cleanup
Test CoverageGiven the number of security-critical changes, a few specific coverage questions:
Minor Nits
SummaryThis is a high-quality, well-motivated PR. The security fixes are correct, the architectural moves to auto-configuration are sound, and the documentation (MIGRATION.md, CONFIG.md, REGISTRATION-GUARD.md) is thorough. The main actionable items are:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
Pull request overview
Hardens the framework for the 4.4.0 line by tightening security boundaries (token single-use + at-rest hashing, registration-guard enforcement, OAuth/OIDC safety), fixing a Spring Security 7 MFA factor-merging integration gap, and improving operational reliability (session regeneration policy, audit logging scalability, mail executor isolation, CI coverage).
Changes:
- Hash verification/password-reset tokens at rest (optional HMAC secret), enforce single-use/atomic consumption, and update related controller/service logic + tests.
- Fix MFA factor merging by enabling SS7 merging on authentication processing filters when
user.mfa.enabled=true, and auto-unprotect MFA factor entry-point URIs to prevent partial-auth redirect loops. - Improve reliability/ops: name-keyed
SecurityFilterChainbackoff, bounded/rotating audit log sink + bounded queries, dedicated mail executor, session invalidation behavior changes, and CI workflow (JDK 21 + 25 + CodeQL).
Reviewed changes
Copilot reviewed 115 out of 116 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/test/java/com/digitalsanctuary/spring/user/util/JpaAuditingConfigTest.java | New tests for user.jpa.auditing.enabled gating behavior. |
| src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java | Updates verification-token validation tests for hashing + single-use consumption. |
| src/test/java/com/digitalsanctuary/spring/user/service/UserEmailServiceTest.java | Updates email/token tests to assert raw-vs-hash behavior with TokenHasher. |
| src/test/java/com/digitalsanctuary/spring/user/service/TokenHasherTest.java | Adds unit coverage for token hashing behavior (SHA-256 vs keyed HMAC). |
| src/test/java/com/digitalsanctuary/spring/user/service/SessionInvalidationServiceTest.java | Adds tests for preserving/regenerating current session on password change. |
| src/test/java/com/digitalsanctuary/spring/user/service/PostgreSQLConcurrentRegistrationTest.java | Adds Postgres Testcontainers coverage for concurrent registration race. |
| src/test/java/com/digitalsanctuary/spring/user/service/MariaDBConcurrentRegistrationTest.java | Adds MariaDB Testcontainers coverage for concurrent registration race. |
| src/test/java/com/digitalsanctuary/spring/user/service/LogoutSuccessServiceTest.java | Adjusts header stubbing to avoid strict-mock failures. |
| src/test/java/com/digitalsanctuary/spring/user/service/DSUserDetailsTest.java | Adds tests for account locked status mapping. |
| src/test/java/com/digitalsanctuary/spring/user/service/DSUserDetailsSerializationTest.java | Adds serialization round-trip test for session principal graph. |
| src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceTest.java | Expands OIDC tests for registration event publishing and email_verified handling. |
| src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceRegistrationGuardTest.java | Updates guard enforcement tests to service-layer guard + exception translation. |
| src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceRegistrationGuardTest.java | Updates OAuth2 guard enforcement tests to service-layer guard + exception translation. |
| src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityCsrfTest.java | Adds CSRF enforcement tests against the real filter chain. |
| src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityAuthorizationFailClosedTest.java | Adds fail-closed tests for invalid user.security.defaultAction. |
| src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityAuthorizationDenyTest.java | Adds authorization-model tests for defaultAction=deny. |
| src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityAuthorizationAllowTest.java | Adds authorization-model tests for defaultAction=allow. |
| src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandlerTest.java | Updates tests for optional event publishing on WebAuthn success. |
| src/test/java/com/digitalsanctuary/spring/user/security/SessionInvalidationIntegrationTest.java | Adds integration test to assert SessionRegistry population on login. |
| src/test/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandlerTest.java | Adds tests ensuring OAuth2/OIDC failure messages are sanitized. |
| src/test/java/com/digitalsanctuary/spring/user/security/MfaFilterMergingConfigurationTest.java | Adds tests for MFA filter-merging post-processor gating and behavior. |
| src/test/java/com/digitalsanctuary/spring/user/security/LogoutAuditIntegrationTest.java | Adds integration test proving logout audit handler wiring and event publication. |
| src/test/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointConfigurationTest.java | Adjusts test config to avoid component-scan leakage into integration contexts. |
| src/test/java/com/digitalsanctuary/spring/user/registration/RegistrationGuardConfigurationTest.java | Adds wiring tests for default + composite guard behavior. |
| src/test/java/com/digitalsanctuary/spring/user/registration/CompositeRegistrationGuardTest.java | Adds unit tests for first-deny-wins ordering semantics. |
| src/test/java/com/digitalsanctuary/spring/user/profile/session/SessionScopedProfileTest.java | Adds tests proving session-scoping requirement and meta-annotation behavior. |
| src/test/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepositoryTest.java | Adds repository slice tests for atomic failed-attempt increments. |
| src/test/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordHistoryRepositoryTest.java | Adds repository slice tests for bounded pruning queries. |
| src/test/java/com/digitalsanctuary/spring/user/persistence/model/UserToStringTest.java | Adds test to ensure User#toString() does not leak password hashes. |
| src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceTest.java | Adds log-based tests for from-address warnings when mail is enabled. |
| src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceOverrideTest.java | Adds override-contract tests for consumer replaceability of MailService. |
| src/test/java/com/digitalsanctuary/spring/user/mail/MailExecutorConfigurationTest.java | Adds tests for dedicated bounded mail executor + @Async qualification. |
| src/test/java/com/digitalsanctuary/spring/user/listener/RegistrationListenerTest.java | Updates tests for skipping verification email for already-enabled social users. |
| src/test/java/com/digitalsanctuary/spring/user/jdbc/Jdbc.java | Removes legacy JDBC test helper (and embedded credentials). |
| src/test/java/com/digitalsanctuary/spring/user/jdbc/ConnectionManager.java | Removes legacy JDBC connection helper (and embedded credentials). |
| src/test/java/com/digitalsanctuary/spring/user/event/UserDisabledEventTest.java | Adds unit tests for new UserDisabledEvent. |
| src/test/java/com/digitalsanctuary/spring/user/controller/UserActionControllerTest.java | Updates controller test expectations for atomic token consumption behavior. |
| src/test/java/com/digitalsanctuary/spring/user/audit/FileAuditLogWriterTest.java | Adds rotation behavior tests for file-based audit writer. |
| src/test/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryServiceTest.java | Adds tests for bounded max-results query behavior. |
| src/test/java/com/digitalsanctuary/spring/user/audit/AuditEventListenerTest.java | Updates tests for lazy AuditLogWriter resolution via ObjectProvider. |
| src/test/java/com/digitalsanctuary/spring/user/architecture/ArchitectureTest.java | Adds ArchUnit rules for layering + logging restrictions. |
| src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java | Removes unused RegistrationGuard mocking after service-layer enforcement change. |
| src/test/java/com/digitalsanctuary/spring/user/api/UserAPIRegistrationGuardTest.java | Updates guard-denial tests to service-layer RegistrationDeniedException. |
| src/test/java/com/digitalsanctuary/spring/user/api/MfaFeatureEnabledIntegrationTest.java | Adds integration assertion for auto-unprotected MFA entry-point URI. |
| src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports | Registers new auto-configurations (audit/mail + security beans/filter chain). |
| src/main/resources/META-INF/additional-spring-configuration-metadata.json | Adds metadata for new/updated configuration properties (auditing, audit rotation, token hashing, validity minutes). |
| src/main/resources/config/dsspringuserconfig.properties | Updates defaults/docs for audit rotation, CSRF exemptions, token properties, mail fromAddress default. |
| src/main/java/com/digitalsanctuary/spring/user/util/JpaAuditingConfig.java | Gates JPA auditing behind user.jpa.auditing.enabled and reduces sensitive logging. |
| src/main/java/com/digitalsanctuary/spring/user/UserConfiguration.java | Adjusts component-scan exclude filters to respect TypeExclude/AutoConfiguration filters. |
| src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java | Adds TokenHasher, token validity minutes, and hashed single-token issuance logic. |
| src/main/java/com/digitalsanctuary/spring/user/service/TokenHasher.java | Introduces deterministic token hashing (SHA-256 / HMAC-SHA-256). |
| src/main/java/com/digitalsanctuary/spring/user/service/LogoutSuccessService.java | Reduces sensitive logout logging (logs user name instead of full auth). |
| src/main/java/com/digitalsanctuary/spring/user/service/LoginSuccessService.java | Reduces sensitive login logging (logs user name instead of full auth/principal). |
| src/main/java/com/digitalsanctuary/spring/user/service/LoginHelperService.java | Enforces locked/disabled status across auth paths (form/OAuth2/OIDC/WebAuthn). |
| src/main/java/com/digitalsanctuary/spring/user/service/LoginAttemptService.java | Uses atomic DB increment to avoid lockout-evasion lost-update race. |
| src/main/java/com/digitalsanctuary/spring/user/service/DSOidcUserService.java | Enforces centralized registration guard; publishes registration event; rejects explicit email_verified=false. |
| src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityFilterChainAutoConfiguration.java | Provides library SecurityFilterChain via name-keyed conditional backoff. |
| src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandler.java | Publishes InteractiveAuthenticationSuccessEvent for converted DSUserDetails-bearing auth. |
| src/main/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandler.java | Adds sanitized OAuth2/OIDC failure handler to avoid leaking exception messages to session/logs. |
| src/main/java/com/digitalsanctuary/spring/user/security/MfaFilterMergingConfiguration.java | Enables SS7 factor merging on auth-processing filters when MFA enabled. |
| src/main/java/com/digitalsanctuary/spring/user/security/MfaConfiguration.java | Documents/enforces split between MFA enforcement vs filter-side merging. |
| src/main/java/com/digitalsanctuary/spring/user/registration/RegistrationGuardConfiguration.java | Adds composite registration guard and ensures safe default fallback behavior. |
| src/main/java/com/digitalsanctuary/spring/user/registration/RegistrationDeniedException.java | Adds service-layer exception for guard denials. |
| src/main/java/com/digitalsanctuary/spring/user/registration/CompositeRegistrationGuard.java | Implements first-deny-wins guard composition. |
| src/main/java/com/digitalsanctuary/spring/user/profile/session/SessionScopedProfile.java | Adds meta-annotation to avoid session-scope inheritance trap. |
| src/main/java/com/digitalsanctuary/spring/user/profile/session/BaseSessionProfile.java | Documents non-inherited scope risk and points to meta-annotation. |
| src/main/java/com/digitalsanctuary/spring/user/persistence/repository/VerificationTokenRepository.java | Adds deleteByUser for single-active-token enforcement. |
| src/main/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepository.java | Adds atomic increment query for failed login attempts. |
| src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordResetTokenRepository.java | Adds deleteByUser for single-active-token enforcement. |
| src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordHistoryRepository.java | Adds bounded prune queries (cutoff id + set-based delete). |
| src/main/java/com/digitalsanctuary/spring/user/persistence/model/VerificationToken.java | Adds configurable lifetime + transient plain-token carrier field. |
| src/main/java/com/digitalsanctuary/spring/user/persistence/model/User.java | Implements Serializable and excludes password from toString(). |
| src/main/java/com/digitalsanctuary/spring/user/persistence/model/Role.java | Implements Serializable for session principal graph safety. |
| src/main/java/com/digitalsanctuary/spring/user/persistence/model/Privilege.java | Implements Serializable for session principal graph safety. |
| src/main/java/com/digitalsanctuary/spring/user/persistence/model/PasswordResetToken.java | Adds configurable token lifetime constructor. |
| src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java | Moves to auto-configured bean, warns on blank fromAddress, and qualifies @Async to dedicated executor. |
| src/main/java/com/digitalsanctuary/spring/user/mail/MailExecutorConfiguration.java | Adds bounded dedicated executor dsMailExecutor with backpressure. |
| src/main/java/com/digitalsanctuary/spring/user/listener/RegistrationListener.java | Skips verification email for already-enabled users (social registration). |
| src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionService.java | Defers post-delete event publication until after transaction commit. |
| src/main/java/com/digitalsanctuary/spring/user/event/UserDisabledEvent.java | Adds new post-commit observable event for soft-deletes. |
| src/main/java/com/digitalsanctuary/spring/user/event/UserDeletedEvent.java | Clarifies event is delivered after commit and how it’s deferred. |
| src/main/java/com/digitalsanctuary/spring/user/controller/UserActionController.java | Stops logging raw tokens; fingerprints tokens; resolves user before consuming token. |
| src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryService.java | Bounds query memory/CPU with a capped window of most recent matches. |
| src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogFlushScheduler.java | Moves scheduler to auto-config bean to back off when file writer is replaced/absent. |
| src/main/java/com/digitalsanctuary/spring/user/audit/AuditMailAutoConfiguration.java | Adds auto-config for default audit writer + flush scheduler + consumer-overridable MailService. |
| src/main/java/com/digitalsanctuary/spring/user/audit/AuditEventListener.java | Lazily resolves AuditLogWriter to allow audit to be fully disabled without missing-bean failures. |
| src/main/java/com/digitalsanctuary/spring/user/audit/AuditConfig.java | Adds audit rotation properties + clarifies flush durability behavior. |
| src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java | Moves registration guard enforcement to service layer; atomically consumes password-reset token. |
| REGISTRATION-GUARD.md | Updates SPI docs for composite guard behavior + service-layer enforcement. |
| PROFILE.md | Documents session-scope inheritance trap + @SessionScopedProfile usage. |
| CONFIG.md | Updates config docs for audit rotation/durability, JPA auditing gate, token hashing, and mail executor. |
| CLAUDE.md | Updates test dependency overview documentation. |
| build.gradle | Updates test deps, adds Javadoc tags, drops JDK17 test task, adds JDK25 testing. |
| .github/workflows/build.yml | Adds CI workflow for build/test on JDK 21 + 25 and CodeQL security-extended. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @Transactional | ||
| public void createPasswordResetTokenForUser(final User user, final String token) { | ||
| final PasswordResetToken myToken = new PasswordResetToken(token, user); | ||
| // Single active token per user: remove any previously issued token before creating a new one. | ||
| passwordTokenRepository.deleteByUser(user); | ||
| // Store only the hash of the token; the raw token is what was emailed to the user. | ||
| final PasswordResetToken myToken = | ||
| new PasswordResetToken(tokenHasher.hash(token), user, passwordResetTokenValidityMinutes); | ||
| passwordTokenRepository.save(myToken); |
There was a problem hiding this comment.
Good catch — confirmed the @Transactional was a no-op via self-invocation. Fixed in the latest commit: added a @Lazy @Autowired UserEmailService self proxy reference (mirroring the existing pattern in UserService) and routed both callers (sendForgotPasswordVerificationEmail and initiateAdminPasswordReset) through self.createPasswordResetTokenForUser(...) so the deleteByUser + save now commit atomically. Updated UserEmailServiceTest to wire self to the instance (no proxy in unit tests).
…rationGuard wiring tests BaseTestConfiguration set spring.profiles.active=test via System.setProperty, a JVM-global mutation that leaked across the parallel test suite. When it raced ahead of RegistrationGuardConfigurationTest — which runs an ApplicationContextRunner with no profile and gates its consumer-guard configs on @Profile("!test") — the test profile suppressed those guards, so DefaultRegistrationGuard was registered and two wiring assertions failed (intermittently; surfaced on the Java 25 run). The property was redundant: every test annotation and direct consumer already declares @activeprofiles("test"), and ddl-auto/datasource are set in the test properties files. Removed the TestPropertySourcesConfigurer entirely. Also pinned RegistrationGuardConfigurationTest's runner to a non-test profile via withPropertyValues so it stays deterministic against any future ambient leak.
|
You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool. What Enabling Code Scanning Means:
For more information about GitHub Code Scanning, check out the documentation. |
|
|
||
| return buildSuccessResponse("Registration Successful!", nextURL); | ||
| } catch (RegistrationDeniedException ex) { | ||
| log.info("Registration denied for email: {} source: FORM reason: {}", userDto.getEmail(), ex.getReason()); |
|
|
||
| return buildSuccessResponse("Registration Successful!", nextURL); | ||
| } catch (RegistrationDeniedException ex) { | ||
| log.info("Registration denied for email: {} source: PASSWORDLESS reason: {}", dto.getEmail(), ex.getReason()); |
| @Transactional(isolation = Isolation.SERIALIZABLE) | ||
| User persistNewUserAccount(final User user) { | ||
| if (emailExists(user.getEmail())) { | ||
| log.debug("UserService.persistNewUserAccount: email already exists: {}", user.getEmail()); |
| log.debug("UserService.deleteOrDisableUser: called for user: {}", user != null ? user.getEmail() : null); | ||
| if (actuallyDeleteAccount) { | ||
| log.debug("UserService.deleteOrDisableUser: actuallyDeleteAccount is true, deleting user: {}", user); | ||
| log.debug("UserService.deleteOrDisableUser: actuallyDeleteAccount is true, deleting user: {}", user.getEmail()); |
| publishEventAfterCommit(new UserDeletedEvent(this, userId, userEmail)); | ||
| } else { | ||
| log.debug("UserService.deleteOrDisableUser: actuallyDeleteAccount is false, disabling user: {}", user); | ||
| log.debug("UserService.deleteOrDisableUser: actuallyDeleteAccount is false, disabling user: {}", user.getEmail()); |
| public User registerPasswordlessAccount(final PasswordlessRegistrationDto dto) { | ||
| TimeLogger timeLogger = new TimeLogger(log, "UserService.registerPasswordlessAccount"); | ||
| log.debug("UserService.registerPasswordlessAccount: called with dto: {}", dto); | ||
| log.debug("UserService.registerPasswordlessAccount: called for email: {}", dto != null ? dto.getEmail() : null); |
| */ | ||
| public void authWithoutPassword(User user) { | ||
| log.debug("UserService.authWithoutPassword: authenticating user: {}", user); | ||
| log.debug("UserService.authWithoutPassword: authenticating user: {}", user != null ? user.getEmail() : null); |
Code Review — PR #314: Security & reliability hardening for 4.4.0Great PR overall. The security fixes are well-reasoned, the documentation is thorough, and the test suite additions are comprehensive. Here are my findings, organized by severity. Security✅ Token hashing at rest (TokenHasher) One note: ✅ Verification token atomic consumption
In throw new LockedException("Account is locked: " + user.getEmail());
throw new DisabledException("Account is disabled: " + user.getEmail());The ✅ All other security fixes are solid — RegistrationGuard bypass (package-private Architecture & Design
✅ ✅ Performance✅ bcrypt outside the transaction ✅ Dedicated mail executor ✅ Audit log ring-buffer query and log rotation TestsThe new test suite additions are comprehensive and cover the right things:
One gap: Minor / Nits
SummaryTwo items most worth addressing before merge:
Everything else ranges from minor style suggestions to optional follow-ups. This is a high-quality, well-tested PR with exemplary documentation. Reviewed by Claude Sonnet 4.6 |
…d hardening The prior commit removed BaseTestConfiguration's global spring.profiles.active=test, assuming it redundant. It was not: integration tests using a bare @SpringBootTest (e.g. UserUpdateIntegrationTest) rely on the ambient test profile to resolve the test datasource, and dropping it spun up default-profile contexts that contended on H2 locks — producing 11 failures (SQL grammar errors + H2 LockTimeoutException) on the Java 21 run. Restore the global property (with a comment explaining why it stays) and keep the real fix for the original flake: RegistrationGuardConfigurationTest pins its own non-test profile via a higher-precedence inlined property source, so it no longer depends on the ambient profile. Full test + testJdk25 suites: 855 tests, 0 failures.
Code Review — PR #314: Security & Reliability Hardening for 4.4.0Overall this is an excellent, well-scoped security hardening PR. The changes are coherent, the MIGRATION.md is present, and 854 tests + Playwright 103/103 is solid validation. Below is a detailed review organized by concern level. 🔴 Issues Worth Addressing1. Account email leaked in exception messages (
|
Review feedback addressedThanks for the thorough review. Pushed fixes for the actionable items:
Plus the must-fix Copilot caught: the Deferred / declined:
All affected test suites pass. 🤖 Generated with Claude Code |
- UserEmailService: route createPasswordResetTokenForUser through a @lazy self proxy so its @transactional applies (self-invocation was a no-op, reopening the single-active-token race between deleteByUser and save) - LoginHelperService: remove PII (email) from Locked/Disabled exception messages; email retained only in DEBUG logs - CompositeRegistrationGuard: throw IllegalStateException on a null delegate decision instead of failing open; add fail-fast tests - AuditMailAutoConfiguration: fix flushOnWrite SpEL default (:true -> :false) to match AuditConfig field default so the flush scheduler is created - TokenHasher: extract shared static fingerprint(); drop duplicated private copies in UserVerificationService and UserActionController - UserService: correct cleanUpPasswordHistory comment to reflect the real proxied call chain
| public ModelAndView showChangePasswordPage(final HttpServletRequest request, final ModelMap model, | ||
| @RequestParam("token") final String token) { | ||
| log.debug("UserAPI.showChangePasswordPage: called with token: {}", token); | ||
| log.debug("UserAPI.showChangePasswordPage: called with token: {}", TokenHasher.fingerprint(token)); |
| public ModelAndView confirmRegistration(final HttpServletRequest request, final ModelMap model, | ||
| @RequestParam("token") final String token) throws UnsupportedEncodingException { | ||
| log.debug("UserAPI.confirmRegistration: called with token: {}", token); | ||
| log.debug("UserAPI.confirmRegistration: called with token: {}", TokenHasher.fingerprint(token)); |
| public User getUserByVerificationToken(final String verificationToken) { | ||
| log.debug("UserVerificationService.getUserByVerificationToken: called with token: {}", verificationToken); | ||
| final VerificationToken token = tokenRepository.findByToken(verificationToken); | ||
| log.debug("UserVerificationService.getUserByVerificationToken: called with token: {}", TokenHasher.fingerprint(verificationToken)); |
Code Review — Security & Reliability Hardening for 4.4.0
This is a well-scoped, well-structured PR. The overall direction is correct and the security motivations are sound. The MFA factor-merging fix, the token-hashing approach, the 🔴 P0 — CriticalNo test for the plaintext-to-hashed token backward-compatibility path The dual-read strategy in 🟠 P1 — High Priority
log.info("Registration denied for source: {} provider: {} reason: {}", source, providerName, decision.reason());The
In unprotectedURIs.removeAll(Collections.emptyList());This does nothing. No integration test for session-ID regeneration after password change
🟡 P2 — Medium Priority
The new configurable-expiry overload ( Missing items from The following breaking changes are not documented:
MFA
The 🔵 P3 — Low Priority / Polish
The early-return for already-enabled users in
The handler's method signature accepts
Both Double-qualification on @Transient private transient String plainToken;Both ✅ What's Done Well
Summary Table
The P0 item (missing backward-compat token test) and the P1 PII log issue are the ones I'd want resolved before merge. Everything else is actionable but would be reasonable to file as follow-up issues if needed to avoid blocking the release. |
Integration tests shared a single named DB (jdbc:h2:mem:testdb). Under JUnit
parallel execution, concurrently-booting Spring contexts collided on schema DDL
and role seeding against that one DB — intermittently producing
'Table "ROLE" already exists' and H2 LockTimeoutException during context load.
Two tests already worked around this with dedicated DB names (lockouttest,
userapitest); this fixes it for all of them at the source.
Append ${random.uuid} to the H2 URL so each context resolves its own DB name once
at startup: connections within a context still share one DB, while distinct
contexts stay isolated. The TestContext cache key is unaffected (it keys on config,
not resolved properties), so identically-configured tests still reuse one context.
Verified: 3x testJdk25 + default test suite, 855 tests, 0 failures, no lock contention.
Code Review — PR #314: Security & reliability hardening for 4.4.0This is a large, well-motivated security hardening release. The changes are architecturally sound and the documentation (JavaDoc, migration guide) is thorough. Below are my findings organized by severity. 🔴 Issues to fix before merge1. request.getSession().setAttribute(ERROR_MESSAGE_SESSION_ATTRIBUTE, resolveUserFacingMessage(exception));
2. public VerificationToken generateNewVerificationToken(final String existingVerificationToken) {
VerificationToken vToken = resolveByRawToken(existingVerificationToken);
vToken.updateToken(tokenHasher.hash(rawToken), verificationTokenValidityMinutes); // NPE if vToken == null
🟡 Worth addressing3. CI actions are not pinned to commit hashes - uses: actions/checkout@v4
- uses: gradle/actions/setup-gradle@v4
- uses: github/codeql-action/init@v3For a security library, best practice is to pin to a specific commit SHA (e.g., 4. MFA filter-merging warning scope is underspecified in practice The class-level warning in log.warn("MFA factor merging is enabled. All AbstractAuthenticationProcessingFilter beans, including any custom ones, will have setMfaEnabled(true) applied. See MfaFilterMergingConfiguration for implications.");5. Indentation inconsistency in The new class uses tabs for indentation (visible in the 6.
🟢 Strengths worth calling outToken hashing design — The Atomic token consumption —
Connection pool optimization — Moving bcrypt hashing outside the transaction via
ℹ️ Observations / nits
Summary: The core security fixes (single-use tokens, atomic consume, Facebook email verification, sanitized failure handler, filter chain coexistence) are correct and well-executed. Fix the session-creation issue in the failure handler (#1) and the NPE in |
Clears the Node 20 deprecation warnings and the CodeQL Action v3 deprecation: - actions/checkout v4 -> v6 (all workflows) - actions/setup-java v4 -> v5 - gradle/actions/setup-gradle v4 -> v6 - actions/upload-artifact v4 -> v7 - github/codeql-action init+analyze v3 -> v4 anthropics/claude-code-action stays at v1 (current major).
…ction) - SanitizingOAuth2AuthenticationFailureHandler: use getSession(false) so a failed OAuth2 callback does not allocate a session for sessionless callers (prevents unauthenticated scanners from forcing session creation). A real OAuth2 flow already has a session, so the user-facing message is preserved. - UserVerificationService.generateNewVerificationToken: guard against a null resolved token (unknown/consumed/malformed) and throw IllegalArgumentException instead of NPEing on updateToken. - TokenHasher.fingerprint: strip control chars (CR/LF) from the logged prefix to close CodeQL log-injection on token-derived log lines. - MfaFilterMergingConfiguration: convert tab indentation to 4 spaces per CLAUDE.md style. - Tests: model the real (session-present) OAuth2 callback and add a test asserting no session is created for a sessionless request.
Second review pass addressed (commit
|
| Item | Fix |
|---|---|
| #1 Session created on every OAuth2 failure | SanitizingOAuth2AuthenticationFailureHandler now uses request.getSession(false) + null-guard, so a sessionless caller (unauthenticated scanner hitting the callback) no longer forces session allocation. A genuine OAuth2 flow already has a session from the authorization-request phase, so the user-facing message is preserved. Tests updated to model the real path; added a test asserting no session is created for a cold request. |
#2 NPE in generateNewVerificationToken |
Added a null guard on the resolved token; now throws IllegalArgumentException with a clear message instead of NPEing on updateToken(...). |
| CodeQL log-injection ×3 (advanced-security bot) | TokenHasher.fingerprint() now strips control chars (CR/LF) from the logged prefix, closing the log-forging vector centrally for all callers. |
🟡 Quick wins
- Redundant configmethod in WebSecurityConfig #5 Converted tab indentation → 4 spaces in
MfaFilterMergingConfiguration(CLAUDE.md style).
Already covered / declining
- Issue 5 redundant configmethod in web security config #6 (NOT_SUPPORTED migration note) — already documented in
MIGRATION.md§ Password hashing moved outside the transaction, including the bold "an outer rollback will not roll back the registration or password change" caveat. No change needed. - https://github.com/devondragon/SpringUserFramework/issues/2 #3 SHA-pin CI actions — valid hardening, but deferring to a follow-up: the actions were just bumped this PR, and pinning every third-party action to a SHA (with a maintenance story) is its own task.
- Docker compose #4 startup
log.warnfor MFA merging — low value vs. the existing class-level JavaDoc + per-filter DEBUG log; skipping.
Full test suite green locally.
🤖 Generated with Claude Code
|
Claude finished @devondragon's task in 4m 26s —— View job Third-Pass Review — PR #314 Security & Reliability HardeningTasks
Fix VerificationAll four stated fixes from commit ✅ Fix #1 — Session not allocated for cold OAuth2 callbacks
HttpSession session = request.getSession(false);
if (session != null) {
session.setAttribute(ERROR_MESSAGE_SESSION_ATTRIBUTE, resolveUserFacingMessage(exception));
}
✅ Fix #2 — NPE guard in
✅ Fix #3 —
final String prefix = token.substring(0, 6).replaceAll("\\p{Cntrl}", "");
✅ Fix #4 — File consistently uses 4-space indentation throughout. ✅ Previously Flagged Test Coverage Concerns — ResolvedTwo earlier reviews flagged these as P0/P1 gaps. Both tests exist: ✅ Plaintext backward-compat token test — ✅ Session regeneration after password change — Remaining Open ItemsThese are all carry-overs from prior reviews — noting their status for completeness.
First-scan CodeQL alerts IDs 3 & 6 in
The first-scan CodeQL alerts will continue showing in the Code Scanning UI until these lines are addressed or the alerts are dismissed. First-scan CodeQL alerts IDs 8-15 in
SummaryThe four stated fixes in commit |
…t integrity [P1] Token consume was replayable under concurrency: validateVerificationToken and validateAndConsumePasswordResetToken did read-check-delete, so two requests could both read a token before either delete committed. Make the conditional DELETE the atomic guard (deleteByToken returns a row count; apply the effect only when count==1). Added VerificationTokenRepository.deleteByToken. Added loser-path unit tests. [P2] GDPR deletion ran non-transactionally: deleteUser -> executeUserDeletion was a self-invocation that bypassed the @transactional proxy, so the whole delete was non-atomic and the after-commit event fired immediately. Route through a @lazy self proxy. [P2/P3] Audit records could be forged/corrupted via raw field text (CR/LF/pipe in User-Agent etc.). FileAuditLogWriter now sanitizes each field; updated the parser's stale comment. [P1/P2] Audit rotation hid retained history from query/export (reader only reads the active file). Default rotation OFF (maxFileSizeMb=0) so the regression cannot ship; multi-file archive reader is a planned follow-up. Refs review findings P1-P3.
… after-commit fix - AbstractConcurrentTokenConsumeTest (+ PostgreSQL/MariaDB Testcontainers subclasses): two threads race to consume the same password-reset and verification token; assert exactly one wins and the token is gone. Proves the conditional-DELETE single-use guard holds under real concurrency on production-grade databases (not just H2). - GdprDeletionAfterCommitIntegrationTest: proves executeUserDeletion now runs through the transactional proxy — the UserDeletedEvent is delivered from the afterCommit synchronization (synchronization active, user already deleted), and on a contributor failure the deletion rolls back atomically and NO event is published. These are the integration-level proofs the third-pass review requested for findings P1 (token replay) and P2 (GDPR atomicity).
Summary
Security and reliability hardening for the 4.4.0 release (Spring Boot 4.0 / Spring Security 7). This branch executes the Tier 1 remediation plan, fixes issues found by a follow-up code review, resolves the MFA factor-merging issue (#313), and was validated end-to-end against the demo app (Playwright 103/103).
46 commits. Library suite: 854 tests green, Javadoc builds, runtime-tested on Java 21 + 25.
Highlights
Security fixes
persist*methods are now package-private so consumers can't skip the centralized guard.validateVerificationTokennow enables the user and deletes the token atomically (was a non-atomic two-step that allowed replay).@EnableMultiFactorAuthenticationworkaround needed), and auto-unprotects the configured factor entry-point URIs to prevent the partial-auth redirect loop.Reliability / correctness
SecurityFilterChaincoexistence (was a regression) — the library chain backed off via a type-based@ConditionalOnMissingBean(SecurityFilterChain.class), which silently suppressed the entire library chain (form login, CSRF, sessions, WebAuthn, OAuth2) the moment a consumer defined any chain — e.g. a narrow actuator/test-API chain. Now keyed on the bean namesecurityFilterChain, so additional consumer chains coexist (standard multi-chain@Orderlayering) and a full replacement is an explicit opt-in. This is what broke ~70 demo Playwright tests until fixed.user.session.invalidation.keep-current-session-on-password-change(defaulttrue).methodSecurityExpressionHandler,httpSessionEventPublisher,authenticationEventPublisher) moved to an auto-config with@ConditionalOnMissingBeanso consumer overrides no longer conflict.@Configurationto avoid early-instantiation of the event-listeningMfaConfiguration.Infra
security-extended). Java 17 dropped (SB 4.0 requires 21+).@apiNote/@implSpec/@implNotetags; fixed a Lombok-getter@link).Breaking changes (documented in
MIGRATION.md)UserEmailServiceconstructor gained aTokenHasherparameter (affects subclasses).SecurityFilterChainmust now be namedsecurityFilterChain.Validation
testJdk25green.SecurityFilterChainregression and the session/MFA fixes were each caught or confirmed by running the demo app end-to-end.Closes #313.