diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..699c44a4 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,57 @@ +name: Build & Test + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + # Spring Boot 4.x requires Java 21+. The library compiles to Java 21 bytecode via the Gradle + # toolchain; we additionally verify runtime compatibility on Java 25 (current LTS). Both JDKs are + # installed; listing 21 last makes it the default JAVA_HOME, so Gradle runs on (and compiles with) + # Java 21 and discovers Java 25 for the runtime-only testJdk25 task. + - name: Set up JDK 21 (toolchain) and 25 (runtime) + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: | + 25 + 21 + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + - name: Build and test (compile + test on Java 21) + run: ./gradlew check --no-daemon + - name: Test on Java 25 (runtime compatibility) + run: ./gradlew testJdk25 --no-daemon + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-reports + path: build/reports/tests/ + + codeql: + runs-on: ubuntu-latest + permissions: + security-events: write + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21' + - uses: github/codeql-action/init@v4 + with: + languages: java-kotlin + queries: security-extended + - name: Build + run: ./gradlew compileJava --no-daemon + - uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 8452b0f2..fd65da18 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 1 diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index d300267f..94dcce55 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -26,7 +26,7 @@ jobs: actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 1 diff --git a/CLAUDE.md b/CLAUDE.md index 572c8617..a61fc5ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -164,7 +164,7 @@ All configuration uses `user.*` prefix in application.yml. Key property groups: ## Testing -Tests use H2 in-memory database with JUnit 5 parallel execution. Key dependencies: Testcontainers, WireMock, GreenMail, AssertJ, REST Assured. +Tests use H2 in-memory database with JUnit 5 parallel execution. Key dependencies: AssertJ, spring-security-test, Testcontainers (MariaDB/PostgreSQL), Awaitility, ArchUnit, and H2. ### Custom Test Annotations (use these instead of raw Spring annotations) diff --git a/CONFIG.md b/CONFIG.md index 08bfe4ab..961d5820 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -36,9 +36,26 @@ Welcome to the User Framework SpringBoot Configuration Guide! This document outl ## Audit Logging -- **Log File Path (`user.audit.logFilePath`)**: The path to the audit log file. -- **Flush on Write (`user.audit.flushOnWrite`)**: Set to `true` for immediate log flushing. Defaults to `false` for performance. -- **Max Query Results (`user.audit.maxQueryResults`)**: Maximum number of audit events returned from queries. Prevents memory issues with large logs. Defaults to `10000`. +- **Log File Path (`user.audit.logFilePath`)**: The path to the audit log file. If this path is not writable, the system falls back to the system temp directory. +- **Flush on Write (`user.audit.flushOnWrite`)**: Set to `true` for immediate log flushing on every write. Defaults to `false` for performance. See **Durability** below. +- **Flush Rate (`user.audit.flushRate`)**: The interval, in milliseconds, at which the buffered audit log is flushed to disk when `flushOnWrite=false`. Defaults to `30000` (30 seconds). +- **Max Query Results (`user.audit.maxQueryResults`)**: Maximum number of audit events returned from queries. The query service streams the active log file and retains only the most-recent `maxQueryResults` matching events in a bounded ring buffer, so query memory stays bounded regardless of file size. Defaults to `10000`. +- **Max File Size (`user.audit.maxFileSizeMb`)**: Maximum size, in megabytes, of the active audit log file before it is rotated. When exceeded, the active file is renamed to `.1` (shifting existing archives up to `maxFiles`) and a fresh active file is opened. Set to `0` or a negative value to **disable rotation** (logs grow unbounded). Defaults to `10`. **Rotation is enabled by default** to prevent unbounded disk growth. +- **Max Files (`user.audit.maxFiles`)**: Maximum number of rotated archive files to retain (e.g. `user-audit.log.1` .. `user-audit.log.5`). The oldest archive beyond this count is deleted on rotation. Defaults to `5`. + +### Durability + +The file audit sink uses a buffered writer. With the default `flushOnWrite=false`, audit events are written to an in-memory buffer and flushed to disk periodically on the `flushRate` schedule. On a hard crash, JVM kill (SIGKILL), or power loss, **up to one `flushRate` interval of buffered audit events (plus any un-flushed buffer contents) can be lost**. + +For compliance or security-critical deployments where no audit event may be lost, set `user.audit.flushOnWrite=true`. This flushes to disk after every event, eliminating the durability window at a per-write performance cost (under heavy load). Alternatively, lowering `flushRate` narrows the window without paying the full per-write cost. + +### Query Scope + +Audit queries (used by GDPR export and consent history) read only the **active** log file. Rotated archive files (`.1`, `.2`, ...) are not included in query results. If long-range historical queries are required, use a larger `maxFileSizeMb`/`maxFiles` window or a database-backed `AuditLogWriter`/`AuditLogQueryService`. + +## JPA Auditing + +- **Enable JPA Auditing (`user.jpa.auditing.enabled`)**: Controls whether the library enables Spring Data JPA auditing (`@EnableJpaAuditing`) and registers an `AuditorAware` that captures the current user from the Spring Security context for `@CreatedBy`/`@LastModifiedBy` fields. Defaults to `true`. Set to `false` if your application runs its own JPA auditing or supplies its own `AuditorAware` bean, so the library does not hijack it. This property is the primary opt-out, because the library's `@EnableJpaAuditing` resolves the auditor bean by name (`auditorProvider`). ## GDPR Compliance @@ -65,6 +82,16 @@ user: - **Account Lockout Duration (`spring.security.accountLockoutDuration`)**: Duration (in minutes) for account lockout. - **BCrypt Strength (`spring.security.bcryptStrength`)**: Adjust the bcrypt strength for password hashing. Default is `12`. +### Token Security + +Verification and password-reset tokens are **hashed at rest**. The raw token is only ever sent to the user in the emailed link; the database stores its hash. Lookups hash the incoming token and match by hash, with a transparent fallback to plaintext lookup so that any links issued before upgrading keep working until they expire. This requires no schema migration and no action from consuming applications. + +- **Token Hash Secret (`user.security.tokenHashSecret`)**: Optional secret used to key the at-rest hashing (HMAC-SHA-256) of verification and password-reset tokens. If left unset, plain SHA-256 is used, which is adequate because tokens are high-entropy random values. Setting a secret (kept outside the database) adds defense-in-depth against a database-only compromise. Default: unset. +- **Password Reset Token Lifetime (`user.security.passwordResetTokenValidityMinutes`)**: Lifetime in minutes of a password reset token before it expires. Default is `1440` (24 hours). +- **Verification Token Lifetime (`user.registration.verificationTokenValidityMinutes`)**: Lifetime in minutes of a registration verification token before it expires. Default is `1440` (24 hours). + +Only one active token per user is kept for each token type: requesting a new password reset or verification email invalidates the previous one. + ## WebAuthn / Passkey Settings Provides passwordless login using biometrics, security keys, or device authentication. **HTTPS is required** for WebAuthn to function. @@ -138,6 +165,13 @@ user: - **From Address (`spring.mail.fromAddress`)**: The email address used as the sender in outgoing emails. +### Mail Executor + +Email is sent asynchronously (`@Async`) with retry/backoff. To prevent an SMTP outage from starving the shared application task executor that other +async features rely on, mail runs on its own dedicated, bounded executor bean named `dsMailExecutor` (core pool 2, max pool 4, queue capacity 50, with +a `CallerRunsPolicy` rejection handler that applies backpressure to the calling thread when the pool and queue are saturated). To change the sizing, +supply your own `dsMailExecutor` bean (a `ThreadPoolTaskExecutor`); the library's default backs off via `@ConditionalOnMissingBean(name = "dsMailExecutor")`. + ## Role and Privileges diff --git a/MIGRATION.md b/MIGRATION.md index e8cc4ed1..2d39dec0 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -91,6 +91,21 @@ plugins { } ``` +#### Passay upgraded to 2.0.0 + +The framework's transitive Passay dependency was upgraded from 1.x to **2.0.0**, which **relocated +several packages** (e.g. `org.passay.CharacterData` → `org.passay.data.CharacterData`, +`org.passay.CharacterRule` → `org.passay.rule.CharacterRule`). This only affects you if your +application **uses Passay directly** (e.g. for custom password rules): + +- If you declared your own `org.passay:passay` dependency at a 1.x version, **remove the explicit + pin** (let it inherit 2.0.0 transitively) or bump it to `2.0.0`. Pinning an older version forces a + conflicting downgrade that breaks the framework's `PasswordPolicyService` at runtime + (`ClassNotFoundException: org.passay.data.CharacterData`). +- Update your own Passay imports to the new 2.0.0 package names. + +Applications that do not use Passay directly need no changes. + ### Step 3: Spring Security 7 Changes Spring Boot 4.0 includes Spring Security 7, which has breaking changes from Spring Security 6.x. @@ -266,30 +281,67 @@ If you have a custom `WebSecurityConfig` or extend the framework's security conf 2. **Update to lambda DSL style** (required in Spring Security 7) 3. **Review method security annotations** - `@PreAuthorize`, `@PostAuthorize` unchanged -**Example custom security configuration:** +#### SecurityFilterChain override model (4.x) + +The library contributes its `SecurityFilterChain` through a dedicated auto-configuration with two important properties: + +- **Ordered at low precedence.** The library's chain is registered with `@Order(Ordered.LOWEST_PRECEDENCE - 5)` — the same low precedence Spring Boot uses for its own default servlet security chain. This value is sourced from `SecurityFilterProperties.BASIC_AUTH_ORDER`; the constant was `SecurityProperties.BASIC_AUTH_ORDER` in Spring Boot 3.x and was relocated to `SecurityFilterProperties.BASIC_AUTH_ORDER` in Spring Boot 4.0 (still `Ordered.LOWEST_PRECEDENCE - 5`). The library's chain has **no `securityMatcher`**, so it is the catch-all: any consumer-supplied chain with a `securityMatcher` and a lower (higher-precedence) `@Order` is consulted first by Spring Security's `FilterChainProxy`, and unmatched requests fall through to the library's chain. +- **Backs off only on a same-named replacement.** The library's chain bean is named `securityFilterChain` and is annotated `@ConditionalOnMissingBean(name = "securityFilterChain")`. It backs off **only** when you define a `SecurityFilterChain` bean **named `securityFilterChain`** (an explicit full replacement). Defining additional, differently-named chains does **not** suppress it. + +> **Behavior change vs. earlier 4.x pre-releases:** an earlier iteration used `@ConditionalOnMissingBean(SecurityFilterChain.class)` (type-based), which suppressed the entire library chain as soon as you defined *any* `SecurityFilterChain` — even a narrow one (e.g. a test-API or actuator chain). That silently left the library's URIs unprotected. The conditional is now **name-based** so the standard Spring Security multi-chain `@Order` layering pattern works as expected. + +This gives you two ways to customize security: + +**Option A — Add additional, narrower chains alongside the library's (recommended for most layering).** + +Define your own `SecurityFilterChain` with a `securityMatcher` scoping it to a subset of requests and a higher-precedence (lower) `@Order`. Give it **any name other than `securityFilterChain`**. Both chains coexist: your chain handles its matched requests, and the library's catch-all chain keeps protecting everything else (login, registration, password reset, profile, etc.). + ```java @Configuration @EnableWebSecurity -public class CustomSecurityConfig { +public class ApiSecurityConfig { @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + @Order(1) // higher precedence than the library's catch-all chain + public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/api/**") // scopes this chain to /api/** + .authorizeHttpRequests(authz -> authz + .requestMatchers("/api/public/**").permitAll() + .anyRequest().hasRole("ADMIN")) + .csrf(csrf -> csrf.disable()); + return http.build(); // bean name is "apiSecurityFilterChain" -> library chain stays active + } +} +``` + +**Option B — Fully replace the library's chain (you own all the rules).** + +Define your own `SecurityFilterChain` bean **named `securityFilterChain`**. The library's chain backs off entirely and does **not** apply any of its rules; you are now responsible for protecting *all* URIs, including the framework's endpoints. + +```java +@Configuration +@EnableWebSecurity +public class CustomSecurityConfig { + + @Bean // bean name MUST be "securityFilterChain" to replace the library's chain + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authz -> authz - // All patterns must start with / .requestMatchers("/api/public/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") - .anyRequest().authenticated() - ) - .formLogin(form -> form - .loginPage("/user/login.html") - .permitAll() - ); + // You must also permit/secure the framework's own URIs here, + // since the library chain no longer applies: + .requestMatchers("/user/registration", "/user/login", "/user/resetPassword").permitAll() + .anyRequest().authenticated()) + .formLogin(form -> form.loginPage("/user/login.html").permitAll()); return http.build(); } } ``` +For most applications that only need to *add* a few rules, the simplest path is to rely on the library's chain and the `user.security.*` properties (`protectedURIs`, `unprotectedURIs`, `defaultAction`, etc.) rather than defining your own `SecurityFilterChain` at all. + ### Custom User Services If you extend `UserService` or implement custom user management: @@ -298,6 +350,65 @@ If you extend `UserService` or implement custom user management: 2. **Password encoding** - Still uses BCrypt, no changes required 3. **User entity** - No schema changes required +#### Password hashing moved outside the transaction (perf) + +To avoid holding a pooled DB connection during the deliberately slow bcrypt hash, password +hashing now runs *outside* the database transaction. As a result `registerNewUserAccount`, +`changeUserPassword`, and `setInitialPassword` are annotated `Propagation.NOT_SUPPORTED` and +delegate the actual DB write to short, separate transactions of their own. + +**Consumer-facing behavior change:** these three methods no longer participate in a caller's +transaction. If you previously called one of them from inside your own `@Transactional`, that +outer transaction is now suspended for the call and the registration / password change commits +independently — **an outer rollback will not roll back the registration or password change.** + +Most consumers call these methods from controllers (which are not transactional) and are +unaffected. If you depend on enlisting these operations in a surrounding transaction, you will +need to restructure that flow. + +#### `UserEmailService` constructor gained a `TokenHasher` parameter (breaking for subclasses) + +Verification and password-reset tokens are now hashed at rest. `UserEmailService` therefore takes +an additional `TokenHasher` constructor parameter. **If you subclass `UserEmailService`**, your +subclass constructor must accept and pass through the new parameter: + +```java +public CustomUserEmailService( + MailService mailService, + UserVerificationService userVerificationService, + PasswordResetTokenRepository passwordTokenRepository, + ApplicationEventPublisher eventPublisher, + SessionInvalidationService sessionInvalidationService, + TokenHasher tokenHasher) { // <-- new parameter + super(mailService, userVerificationService, passwordTokenRepository, + eventPublisher, sessionInvalidationService, tokenHasher); +} +``` + +`TokenHasher` is a framework `@Component`, so it is available for injection. Consumers that do not +subclass `UserEmailService` are unaffected. The hashing is backward compatible at runtime: tokens +issued before the upgrade (stored in plaintext) are still resolved via a dual-read lookup and remain +usable until they expire. + +#### Sessions on password change: current session is now preserved (OWASP) + +A self-service password change (and removing a password to go passwordless) invalidates the user's +**other** sessions but, by default, now **preserves and regenerates the current session** rather than +logging the user out of the device they just used. This follows OWASP guidance (regenerate the +current session id, invalidate the rest) and is a friendlier default. + +To restore the previous "invalidate every session, including the current one" behavior, set: + +```yaml +user: + session: + invalidation: + keep-current-session-on-password-change: false +``` + +Token-based password **resets** (the forgot-password flow) are unaffected: there is no authenticated +current session to preserve, so all of the user's sessions are invalidated as before. + ### Custom Controllers If you have controllers that extend or work alongside framework controllers: diff --git a/PROFILE.md b/PROFILE.md index 1ffd3762..379bc2da 100644 --- a/PROFILE.md +++ b/PROFILE.md @@ -165,13 +165,26 @@ public class AppUserProfileService implements UserProfileService ### Step 4: Create a Session Profile Manager -Create a session-scoped component to access the current user's profile: +Create a session-scoped component to access the current user's profile. + +> **⚠️ IMPORTANT — `@Scope` is NOT inherited.** Spring does not propagate the `@Scope` declared on +> `BaseSessionProfile` to your subclass. If you annotate your subclass with only `@Component` (and omit +> `@Scope`), it becomes a **singleton shared across all HTTP sessions**, leaking one user's profile to every +> other user — a serious security bug. Always declare session scoping on the subclass itself, either with the +> explicit `@Scope` shown below, or with the convenience meta-annotation `@SessionScopedProfile` +> (`com.digitalsanctuary.spring.user.profile.session.SessionScopedProfile`), which carries `@Component` and the +> correct `@Scope` in a single annotation. ```java +// Option A — explicit @Scope: @Component @Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.TARGET_CLASS) public class AppSessionProfile extends BaseSessionProfile { +// Option B (equivalent) — the convenience meta-annotation: +// @SessionScopedProfile +// public class AppSessionProfile extends BaseSessionProfile { + // Add custom accessor methods for your application public String getDisplayName() { return getUserProfile() != null ? getUserProfile().getDisplayName() : null; diff --git a/REGISTRATION-GUARD.md b/REGISTRATION-GUARD.md index 057f4ae2..0fac5b25 100644 --- a/REGISTRATION-GUARD.md +++ b/REGISTRATION-GUARD.md @@ -14,6 +14,7 @@ This guide explains how to use the Registration Guard SPI in Spring User Framewo - [Invite-Only with OAuth2 Bypass](#invite-only-with-oauth2-bypass) - [Beta Access / Waitlist](#beta-access--waitlist) - [Denial Behavior](#denial-behavior) + - [Composing Multiple Guards](#composing-multiple-guards) - [Key Constraints](#key-constraints) - [Troubleshooting](#troubleshooting) @@ -54,11 +55,19 @@ The Registration Guard SPI consists of these types in the `com.digitalsanctuary. 4. **`RegistrationSource`** — Enum identifying the registration path: `FORM`, `PASSWORDLESS`, `OAUTH2`, `OIDC` -5. **`DefaultRegistrationGuard`** — The built-in permit-all fallback. Automatically registered via `@ConditionalOnMissingBean` when no custom guard bean exists. +5. **`DefaultRegistrationGuard`** — The built-in permit-all fallback. Automatically registered via `@ConditionalOnMissingBean` when no custom guard bean exists, so the composite always has at least one delegate. + +6. **`CompositeRegistrationGuard`** — The primary (`@Primary`) guard the framework injects everywhere. It wraps **all** `RegistrationGuard` beans and evaluates them in order with **first-deny-wins** semantics (see [Composing Multiple Guards](#composing-multiple-guards)). You normally never reference it directly. + +7. **`RegistrationDeniedException`** — A `RuntimeException` carrying the denial `reason`. The framework throws this from the service layer when a guard denies, and translates it into the appropriate response per path (see [Denial Behavior](#denial-behavior)). Consumers rarely need to catch it. + +### Where the guard runs + +The guard is enforced **inside `UserService`** (and, for first-time social sign-ups, via `UserService.enforceRegistrationGuard(...)` called by the OAuth2/OIDC user services). Because enforcement lives in the service rather than the controller, **every** registration path — REST API, OAuth2, OIDC, and any direct call to `UserService.registerNewUserAccount(...)` / `registerPasswordlessAccount(...)` — is guarded exactly once and cannot be bypassed. The guard runs only for **new** registrations; existing users logging in are never evaluated. ## Implementation Guide -Create a `@Component` that implements `RegistrationGuard`. That's it — the default guard is automatically replaced. +Create a `@Component` that implements `RegistrationGuard`. That's it — the default guard is automatically replaced, and your guard is wrapped by the composite. ```java @Component @@ -158,11 +167,34 @@ The JSON error code `6` identifies a registration guard denial specifically, dis For OAuth2/OIDC denials, customize the user experience by configuring Spring Security's OAuth2 login failure handler to inspect the error code and display an appropriate message. -All denied registrations are logged at INFO level with the email, source, and denial reason. +All denied registrations are logged at INFO level with the source and denial reason. + +Internally, a denial surfaces from the service layer as a `RegistrationDeniedException` carrying the reason. The REST API catches it and returns the form/passwordless JSON above; the OAuth2/OIDC user services catch it and re-throw the `OAuth2AuthenticationException` shown above. The HTTP contract is identical regardless of how registration was triggered. + +## Composing Multiple Guards + +You may define **more than one** `RegistrationGuard` bean. The framework wraps them all in a `CompositeRegistrationGuard` (registered as `@Primary`) that evaluates them **in order, first-deny-wins**: + +- Guards are consulted in `@Order` / `org.springframework.core.Ordered` order (lowest value first; unordered beans come last). +- The **first** guard to return `deny(...)` short-circuits — later guards are not consulted — and its reason is propagated. +- If every guard allows (or you define no guards at all, leaving only the permit-all default), registration proceeds. + +This lets you layer independent policies — for example an invite-only guard **and** a domain-allowlist guard — where any single denial blocks the registration. Each guard stays small and single-purpose: + +```java +@Component +@Order(1) +public class InviteOnlyGuard implements RegistrationGuard { /* ... */ } + +@Component +@Order(2) +public class DomainAllowlistGuard implements RegistrationGuard { /* ... */ } +``` ## Key Constraints -- **Single-bean SPI** — Only one `RegistrationGuard` bean may be active at a time. This is not a chain or filter pattern; define exactly one guard. +- **Composable SPI** — One or more `RegistrationGuard` beans may be active; they are composed with first-deny-wins ordering. (You can still define exactly one guard — that is just a composite of size one.) +- **Enforced in the service** — The guard runs inside `UserService`, so direct callers of the service registration methods are guarded too; the SPI cannot be bypassed by skipping the controller. - **Thread safety required** — The guard may be invoked concurrently from multiple request threads. Ensure your implementation is thread-safe. - **No configuration properties** — The guard is activated entirely by bean presence. There are no `user.*` properties involved. - **Existing users unaffected** — The guard only runs for new registrations. Existing users logging in via OAuth2/OIDC are not evaluated. @@ -176,8 +208,8 @@ All denied registrations are logged at INFO level with the email, source, and de - You can also check the active guard via `/actuator/beans` (if enabled) or your IDE's Spring tooling. **Multiple Guards Defined** -- Only one `RegistrationGuard` bean is allowed. If multiple beans are defined, Spring will throw a `NoUniqueBeanDefinitionException` at startup. -- If you need to compose multiple rules, implement a single guard that delegates internally. +- Multiple `RegistrationGuard` beans are fully supported — they are composed automatically with first-deny-wins ordering (see [Composing Multiple Guards](#composing-multiple-guards)). Use `@Order` to control evaluation order. +- The framework injects the `@Primary` `CompositeRegistrationGuard` everywhere, so defining several guards does **not** cause a `NoUniqueBeanDefinitionException`. **OAuth2/OIDC Denial UX** - By default, OAuth2/OIDC denials redirect to Spring Security's default failure URL with a generic error. @@ -197,6 +229,6 @@ All denied registrations are logged at INFO level with the email, source, and de --- -This SPI provides a clean extension point for controlling registration without modifying framework internals. Implement a single bean, return allow or deny, and the framework handles the rest across all registration paths. +This SPI provides a clean extension point for controlling registration without modifying framework internals. Implement one or more beans, return allow or deny, and the framework composes them and handles the rest across all registration paths. For a complete working example, refer to the [Spring User Framework Demo Application](https://github.com/devondragon/SpringUserFrameworkDemoApp). diff --git a/build.gradle b/build.gradle index c9b5680e..8c6a9bbf 100644 --- a/build.gradle +++ b/build.gradle @@ -46,7 +46,6 @@ dependencies { // Other dependencies (moved to test scope for library) implementation 'org.passay:passay:2.0.0' - implementation 'com.google.guava:guava:33.6.0-jre' implementation 'org.apache.commons:commons-text:1.15.0' compileOnly 'jakarta.validation:jakarta.validation-api:3.1.1' compileOnly 'org.springframework.retry:spring-retry:2.0.12' @@ -93,12 +92,12 @@ dependencies { testImplementation 'org.testcontainers:testcontainers-junit-jupiter:2.0.5' testImplementation 'org.testcontainers:testcontainers-mariadb:2.0.5' testImplementation 'org.testcontainers:testcontainers-postgresql:2.0.5' - testImplementation 'com.github.tomakehurst:wiremock:3.0.1' testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2' testImplementation 'org.assertj:assertj-core:3.27.7' - testImplementation 'io.rest-assured:rest-assured:6.0.0' - testImplementation 'com.icegreen:greenmail:2.1.8' - testImplementation 'org.awaitility:awaitility:4.3.0' + // Legacy Jackson 2 (com.fasterxml.jackson) for test JSON utilities. Spring Boot 4 ships Jackson 3 + // (tools.jackson), so the com.fasterxml.jackson APIs these tests use must be declared explicitly + // rather than relied upon transitively. Version is managed by the Spring Boot BOM. + testImplementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' } tasks.named('bootJar') { @@ -140,6 +139,13 @@ tasks.named('jar') { // This is necessary because Lombok generates constructors that cannot be documented tasks.withType(Javadoc).configureEach { options.addStringOption('Xdoclint:all,-missing', '-quiet') + // Register the standard JDK documentation tags so @apiNote/@implSpec/@implNote are recognized + // by the doclet (otherwise they are reported as "unknown tag" errors and fail the Javadoc jar). + options.tags = [ + 'apiNote:a:API Note:', + 'implSpec:a:Implementation Requirements:', + 'implNote:a:Implementation Note:' + ] } def registerJdkTestTask(name, jdkVersion) { @@ -176,15 +182,17 @@ def registerJdkTestTask(name, jdkVersion) { } } -registerJdkTestTask('testJdk17', 17) +// Spring Boot 4.x requires Java 21+, so the library compiles to Java 21 bytecode (see the toolchain +// above) and is tested for runtime compatibility on Java 21 (the minimum) and Java 25 (the current LTS). registerJdkTestTask('testJdk21', 21) +registerJdkTestTask('testJdk25', 25) // Optional task that runs tests with multiple JDKs tasks.register('testAll') { - dependsOn(tasks.named('testJdk17'), tasks.named('testJdk21')) + dependsOn(tasks.named('testJdk21'), tasks.named('testJdk25')) doFirst { - println("Running tests with both JDK 17 and JDK 21") + println("Running tests with JDK 21 and JDK 25") } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/UserConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/UserConfiguration.java index fd55bc00..ef65a8b7 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/UserConfiguration.java +++ b/src/main/java/com/digitalsanctuary/spring/user/UserConfiguration.java @@ -1,7 +1,10 @@ package com.digitalsanctuary.spring.user; +import org.springframework.boot.autoconfigure.AutoConfigurationExcludeFilter; +import org.springframework.boot.context.TypeExcludeFilter; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; import org.springframework.context.annotation.Import; import org.springframework.retry.annotation.EnableRetry; import org.springframework.scheduling.annotation.EnableAsync; @@ -23,7 +26,9 @@ @EnableRetry @EnableScheduling @EnableMethodSecurity -@ComponentScan(basePackages = "com.digitalsanctuary.spring.user") +@ComponentScan(basePackages = "com.digitalsanctuary.spring.user", + excludeFilters = {@ComponentScan.Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class), + @ComponentScan.Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class)}) @Import(UserAutoConfigurationRegistrar.class) public class UserConfiguration { diff --git a/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java b/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java index ff631d70..1dd2dfb4 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java +++ b/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java @@ -31,10 +31,8 @@ import com.digitalsanctuary.spring.user.exceptions.InvalidOldPasswordException; import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException; import com.digitalsanctuary.spring.user.persistence.model.User; -import com.digitalsanctuary.spring.user.registration.RegistrationContext; -import com.digitalsanctuary.spring.user.registration.RegistrationDecision; +import com.digitalsanctuary.spring.user.registration.RegistrationDeniedException; import com.digitalsanctuary.spring.user.registration.RegistrationGuard; -import com.digitalsanctuary.spring.user.registration.RegistrationSource; import com.digitalsanctuary.spring.user.service.DSUserDetails; import com.digitalsanctuary.spring.user.service.PasswordPolicyService; import com.digitalsanctuary.spring.user.service.UserEmailService; @@ -74,7 +72,6 @@ public class UserAPI { private final ApplicationEventPublisher eventPublisher; private final PasswordPolicyService passwordPolicyService; private final ObjectProvider webAuthnCredentialManagementServiceProvider; - private final RegistrationGuard registrationGuard; @Value("${user.security.registrationPendingURI}") private String registrationPendingURI; @@ -112,13 +109,10 @@ public ResponseEntity registerUserAccount(@Valid @RequestBody User return buildErrorResponse(String.join(" ", errors), 1, HttpStatus.BAD_REQUEST); } - RegistrationDecision decision = registrationGuard.evaluate( - new RegistrationContext(userDto.getEmail(), RegistrationSource.FORM, null)); - if (!decision.allowed()) { - log.info("Registration denied for email: {} source: FORM reason: {}", userDto.getEmail(), decision.reason()); - return buildErrorResponse(decision.reason(), ERROR_CODE_REGISTRATION_DENIED, HttpStatus.FORBIDDEN); - } - + // The RegistrationGuard is now enforced inside UserService.registerNewUserAccount so that every + // registration path is guarded exactly once and direct service callers cannot bypass it. A + // denial surfaces as RegistrationDeniedException, translated below into the same + // REGISTRATION_DENIED response this endpoint returned previously. User registeredUser = userService.registerNewUserAccount(userDto); publishRegistrationEvent(registeredUser, request); logAuditEvent("Registration", "Success", "Registration Successful", registeredUser, request); @@ -126,6 +120,9 @@ public ResponseEntity registerUserAccount(@Valid @RequestBody User String nextURL = registeredUser.isEnabled() ? handleAutoLogin(registeredUser) : registrationPendingURI; return buildSuccessResponse("Registration Successful!", nextURL); + } catch (RegistrationDeniedException ex) { + log.info("Registration denied for email: {} source: FORM reason: {}", userDto.getEmail(), ex.getReason()); + return buildErrorResponse(ex.getReason(), ERROR_CODE_REGISTRATION_DENIED, HttpStatus.FORBIDDEN); } catch (UserAlreadyExistException ex) { log.warn("User already exists with email: {}", userDto.getEmail()); logAuditEvent("Registration", "Failure", "User Already Exists", null, request); @@ -265,13 +262,20 @@ public ResponseEntity savePassword(@Valid @RequestBody SavePasswor return buildErrorResponse(String.join(" ", errors), 4, HttpStatus.BAD_REQUEST); } - // Save the new password (this also saves to history) - userService.changeUserPassword(user, savePasswordDto.getNewPassword()); + // Atomically consume the reset token: this validates the token is still present and + // deletes it in a single transaction so it cannot be double-consumed by a concurrent + // request. If it returns null, the token was already used or expired between validation + // above and now. + User consumedUser = userService.validateAndConsumePasswordResetToken(savePasswordDto.getToken()); + if (consumedUser == null) { + return buildErrorResponse(messages.getMessage("auth.message.invalid", null, "Invalid token", locale), 3, + HttpStatus.BAD_REQUEST); + } - // Delete the reset token (it's been used) - userService.deletePasswordResetToken(savePasswordDto.getToken()); + // Save the new password (this also saves to history) + userService.changeUserPassword(consumedUser, savePasswordDto.getNewPassword()); - logAuditEvent("PasswordReset", "Success", "Password reset completed", user, request); + logAuditEvent("PasswordReset", "Success", "Password reset completed", consumedUser, request); return buildSuccessResponse(messages.getMessage("message.reset-password.success", null, "Password has been reset successfully", locale), "/user/login.html"); @@ -410,13 +414,10 @@ public ResponseEntity registerPasswordlessAccount(@Valid @RequestB return buildErrorResponse("Passwordless registration is not available", 1, HttpStatus.BAD_REQUEST); } try { - RegistrationDecision decision = registrationGuard.evaluate( - new RegistrationContext(dto.getEmail(), RegistrationSource.PASSWORDLESS, null)); - if (!decision.allowed()) { - log.info("Registration denied for email: {} source: PASSWORDLESS reason: {}", dto.getEmail(), decision.reason()); - return buildErrorResponse(decision.reason(), ERROR_CODE_REGISTRATION_DENIED, HttpStatus.FORBIDDEN); - } - + // The RegistrationGuard is now enforced inside UserService.registerPasswordlessAccount so that + // every registration path is guarded exactly once and direct service callers cannot bypass it. + // A denial surfaces as RegistrationDeniedException, translated below into the same + // REGISTRATION_DENIED response this endpoint returned previously. User registeredUser = userService.registerPasswordlessAccount(dto); publishRegistrationEvent(registeredUser, request); logAuditEvent("PasswordlessRegistration", "Success", "Passwordless registration successful", registeredUser, request); @@ -424,6 +425,9 @@ public ResponseEntity registerPasswordlessAccount(@Valid @RequestB String nextURL = registeredUser.isEnabled() ? handleAutoLogin(registeredUser) : registrationPendingURI; return buildSuccessResponse("Registration Successful!", nextURL); + } catch (RegistrationDeniedException ex) { + log.info("Registration denied for email: {} source: PASSWORDLESS reason: {}", dto.getEmail(), ex.getReason()); + return buildErrorResponse(ex.getReason(), ERROR_CODE_REGISTRATION_DENIED, HttpStatus.FORBIDDEN); } catch (UserAlreadyExistException ex) { log.warn("User already exists with email: {}", dto.getEmail()); logAuditEvent("PasswordlessRegistration", "Failure", "User Already Exists", null, request); diff --git a/src/main/java/com/digitalsanctuary/spring/user/audit/AuditConfig.java b/src/main/java/com/digitalsanctuary/spring/user/audit/AuditConfig.java index 01b15646..17805603 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/audit/AuditConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/audit/AuditConfig.java @@ -42,8 +42,11 @@ public class AuditConfig { private boolean flushOnWrite; /** - * The flush rate. This is the rate at which the audit log buffer is flushed to the log file. The value is in milliseconds and can be set to any - * positive integer. The default value is 1000 (1 second). + * The flush rate, in milliseconds, at which the audit log buffer is flushed to the log file when + * {@link #flushOnWrite} is {@code false}. May be set to any positive integer. The library default + * (from {@code dsspringuserconfig.properties}) is {@code 30000} (30 seconds). Smaller values reduce + * the durability window (the amount of buffered audit data that can be lost on a hard crash) at a + * small performance cost. */ private int flushRate; @@ -55,4 +58,26 @@ public class AuditConfig { */ private int maxQueryResults = 10000; + /** + * Maximum size of the active audit log file, in megabytes, before it is rotated. + * When the active log file exceeds this size, it is rotated: the current file is renamed to + * {@code .1} (shifting any existing {@code .1} to {@code .2}, and so on, up to + * {@link #maxFiles}) and a fresh active file is opened. Set to a positive value to enable rotation. + *

+ * Default is {@code 0} (rotation disabled). Rotation is opt-in because the audit + * query/export reader currently reads only the active log file; once rotation moves older events into + * {@code .1}, {@code .2}, ... they are no longer visible to GDPR exports or investigations. + * Enable rotation only when you have external log shipping/retention, or wait for the query reader to + * read rotated archives (planned follow-up). With the default, logs grow unbounded. + *

+ */ + private int maxFileSizeMb = 0; + + /** + * Maximum number of rotated audit log files to keep (e.g. {@code user-audit.log.1} .. + * {@code user-audit.log.5}). When rotation produces more than this many archived files, the oldest + * is deleted. Must be at least {@code 1} for rotation to retain any history. Default is {@code 5}. + */ + private int maxFiles = 5; + } diff --git a/src/main/java/com/digitalsanctuary/spring/user/audit/AuditEventListener.java b/src/main/java/com/digitalsanctuary/spring/user/audit/AuditEventListener.java index b6208551..f7ef052f 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/audit/AuditEventListener.java +++ b/src/main/java/com/digitalsanctuary/spring/user/audit/AuditEventListener.java @@ -1,5 +1,6 @@ package com.digitalsanctuary.spring.user.audit; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @@ -17,6 +18,17 @@ * {@code AuditConfig.isLogEvents()}. All exceptions are caught and logged to ensure * audit failures never impact application flow. * + *

The {@link AuditLogWriter} dependency is resolved lazily through an + * {@link ObjectProvider}. This is deliberate: the library's default writer bean + * ({@link FileAuditLogWriter}) is gated by {@code user.audit.logEvents} in + * {@link AuditMailAutoConfiguration}, so when audit logging is disabled (and no consumer + * supplies their own writer) no {@link AuditLogWriter} bean exists. Injecting the writer + * directly would then fail the application context startup with an + * {@code UnsatisfiedDependencyException}. By holding an {@link ObjectProvider} and + * resolving the writer only when an event is actually logged, this listener always starts + * cleanly and simply short-circuits on the {@code logEvents} flag (with a null guard as a + * belt-and-suspenders safety) when no writer is available. + * * @see AuditEvent * @see AuditLogWriter * @see AuditConfig @@ -29,7 +41,7 @@ public class AuditEventListener { private final AuditConfig auditConfig; - private final AuditLogWriter auditLogWriter; + private final ObjectProvider auditLogWriterProvider; /** * Handle the AuditEvents. @@ -43,8 +55,13 @@ public void onApplicationEvent(AuditEvent event) { try { log.debug("AuditEventListener.onApplicationEvent: called with event: {}", event); if (auditConfig.isLogEvents() && event != null) { - log.debug("AuditEventListener.onApplicationEvent: logging event..."); - auditLogWriter.writeLog(event); + AuditLogWriter auditLogWriter = auditLogWriterProvider.getIfAvailable(); + if (auditLogWriter != null) { + log.debug("AuditEventListener.onApplicationEvent: logging event..."); + auditLogWriter.writeLog(event); + } else { + log.debug("AuditEventListener.onApplicationEvent: no AuditLogWriter available; skipping event."); + } } } catch (Exception e) { // Never let audit failures impact application flow diff --git a/src/main/java/com/digitalsanctuary/spring/user/audit/AuditMailAutoConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/audit/AuditMailAutoConfiguration.java new file mode 100644 index 00000000..a8002c48 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/audit/AuditMailAutoConfiguration.java @@ -0,0 +1,95 @@ +package com.digitalsanctuary.spring.user.audit; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.mail.javamail.JavaMailSender; +import com.digitalsanctuary.spring.user.UserConfiguration; +import com.digitalsanctuary.spring.user.mail.MailContentBuilder; +import com.digitalsanctuary.spring.user.mail.MailService; + +/** + * Auto-configuration that contributes the library's two consumer-overridable extension-point beans for audit logging and email delivery: the default + * {@link AuditLogWriter} (a {@link FileAuditLogWriter}) and the default {@link MailService}. + * + *

+ * Both beans are guarded by {@link ConditionalOnMissingBean}, so a consuming application can fully replace either of them simply by defining their own + * bean of the same type. A consumer can route audit events to a database, REST endpoint, or SIEM by supplying their own {@link AuditLogWriter}, and can + * replace mail delivery by supplying their own {@link MailService} (typically a subclass). When the consumer defines no such bean, the library's default + * applies and behavior is unchanged. + *

+ * + *

+ * These beans live on an {@code @AutoConfiguration} class — rather than as a component-scanned {@code @Component}/{@code @Service} — precisely + * because {@code @ConditionalOnMissingBean} is only reliable on auto-configuration classes, which are guaranteed to load AFTER user-defined bean + * definitions. Placing the conditional on a component-scanned stereotype would evaluate it too early and could suppress the consumer's override or cause a + * bean-definition conflict (the H8 finding). This mirrors {@link com.digitalsanctuary.spring.user.security.UserSecurityBeansAutoConfiguration}. + *

+ * + *

+ * Bean-method-produced instances retain full Spring lifecycle support: {@link FileAuditLogWriter}'s {@code @PostConstruct setup()} / + * {@code @PreDestroy cleanup()} and {@link MailService}'s {@code @PostConstruct init()}, {@code @Value} {@code fromAddress} field injection, and + * {@code @Async}/{@code @Retryable} AOP proxying all still apply to {@code @Bean}-produced objects. + *

+ */ +@AutoConfiguration(after = UserConfiguration.class) +public class AuditMailAutoConfiguration { + + /** + * Creates the library's default {@link AuditLogWriter}, a {@link FileAuditLogWriter} that writes pipe-delimited audit events to a log file. Backs + * off entirely if the consuming application defines its own {@link AuditLogWriter}. + * + *

+ * Gated by {@code user.audit.logEvents} (default {@code true}): when audit logging is disabled the writer bean is not created at all, which in turn + * lets {@link FileAuditLogFlushScheduler} back off too. Runtime write paths ({@link AuditEventListener}) already short-circuit on the same flag, so + * this gate simply avoids creating an unused file-handle-owning bean. + *

+ * + * @param auditConfig the audit configuration properties + * @return the default {@link FileAuditLogWriter} + */ + @Bean + @ConditionalOnMissingBean(AuditLogWriter.class) + @ConditionalOnProperty(name = "user.audit.logEvents", havingValue = "true", matchIfMissing = true) + public FileAuditLogWriter fileAuditLogWriter(AuditConfig auditConfig) { + return new FileAuditLogWriter(auditConfig); + } + + /** + * Creates the {@link FileAuditLogFlushScheduler} that periodically flushes the {@link FileAuditLogWriter} buffer to disk. + * + *

+ * Only created when the library's {@link FileAuditLogWriter} is present ({@link ConditionalOnBean}) — so it backs off cleanly when a consumer + * replaces the writer with their own {@link AuditLogWriter} — and only when audit logging is enabled and flush-on-write is disabled, because + * immediate flushing makes the scheduler unnecessary. Declared after {@link #fileAuditLogWriter(AuditConfig)} so {@code @ConditionalOnBean} reliably + * observes the writer. + *

+ * + * @param fileAuditLogWriter the library's file audit log writer + * @return the flush scheduler + */ + @Bean + @ConditionalOnBean(FileAuditLogWriter.class) + @ConditionalOnExpression("${user.audit.logEvents:true} && !${user.audit.flushOnWrite:false}") + public FileAuditLogFlushScheduler fileAuditLogFlushScheduler(FileAuditLogWriter fileAuditLogWriter) { + return new FileAuditLogFlushScheduler(fileAuditLogWriter); + } + + /** + * Creates the library's default {@link MailService}. Backs off entirely if the consuming application defines its own {@link MailService} (or a + * subclass), keeping the concrete type so existing injectors are unaffected. + * + * @param mailSenderProvider provider for the mail sender; may resolve to null when mail is not configured + * @param mailContentBuilder the mail content builder + * @return the default {@link MailService} + */ + @Bean + @ConditionalOnMissingBean(MailService.class) + public MailService mailService(ObjectProvider mailSenderProvider, MailContentBuilder mailContentBuilder) { + return new MailService(mailSenderProvider, mailContentBuilder); + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogFlushScheduler.java b/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogFlushScheduler.java index 0e109575..f2d079d0 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogFlushScheduler.java +++ b/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogFlushScheduler.java @@ -1,8 +1,6 @@ package com.digitalsanctuary.spring.user.audit; -import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -12,22 +10,24 @@ *

This component ensures buffered audit data is written to the file at regular intervals, * balancing write performance with data integrity. * - *

Conditional Activation: This scheduler is only active when both conditions are met: + *

Conditional Activation: This scheduler is contributed as a {@code @Bean} by + * {@link AuditMailAutoConfiguration} only when all of these hold: *

    *
  • {@code user.audit.logEvents=true} - audit logging is enabled
  • *
  • {@code user.audit.flushOnWrite=false} - immediate flush is disabled
  • + *
  • a {@link FileAuditLogWriter} bean is present (i.e. a consumer has not replaced the writer)
  • *
* *

When flush-on-write is enabled, logs are flushed immediately after each write, * making this scheduler unnecessary. The flush frequency is controlled by - * {@code user.audit.flushRate} (in milliseconds). + * {@code user.audit.flushRate} (in milliseconds). It is not component-scanned because it depends on the + * auto-configured {@link FileAuditLogWriter} and must back off when that writer is absent. * * @see FileAuditLogWriter * @see AuditConfig + * @see AuditMailAutoConfiguration */ @Slf4j -@Component -@ConditionalOnExpression("${user.audit.logEvents:true} && !${user.audit.flushOnWrite:true}") @RequiredArgsConstructor public class FileAuditLogFlushScheduler { diff --git a/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryService.java b/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryService.java index b04554f3..8831baa0 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryService.java @@ -10,8 +10,10 @@ import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; +import java.util.ArrayDeque; import java.util.Collections; import java.util.Comparator; +import java.util.Deque; import java.util.List; import java.util.Locale; import java.util.Objects; @@ -26,16 +28,18 @@ * File-based implementation of {@link AuditLogQueryService} that parses the * pipe-delimited audit log file created by {@link FileAuditLogWriter}. * - *

This implementation reads and parses the entire log file for each query, - * filtering results by user email or ID. While suitable for small to medium - * audit volumes (<50MB, <100K events), applications with high audit volumes - * or frequent export requests should consider implementing a database-backed - * query service for better performance. + *

This implementation streams the active log file once per query, filtering + * results by user email or ID. To bound memory and CPU on large files, it retains + * only the most recent {@code user.audit.maxQueryResults} matching events in a + * bounded ring buffer rather than loading and sorting the whole file. While + * suitable for small to medium audit volumes (<50MB, <100K events), + * applications with high audit volumes or frequent export requests should consider + * implementing a database-backed query service for better performance. * *

Performance Note: GDPR export operations call this service - * multiple times (findByUser, findByUserAndAction) which results in reading - * and parsing the entire log file for each call. For production deployments - * with large audit logs, consider: + * multiple times (findByUser, findByUserAndAction); each call streams the active + * log file once. Memory per call is bounded to {@code maxQueryResults} events. For + * production deployments with large audit logs, consider: *

    *
  • Implementing a database-backed {@link AuditLogQueryService}
  • *
  • Adding log rotation to keep file sizes manageable
  • @@ -85,12 +89,26 @@ public List findByUserAndAction(User user, String action) { /** * Internal method to find audit events with optional filtering. - * Uses Java Streams for efficient memory handling with large log files. + * + *

    Bounded memory/CPU: The log file is written in append order (oldest first, + * newest last). Rather than parsing and sorting the entire file in memory, this method streams the + * file once and retains only the last {@code maxQueryResults} matching raw lines in a bounded + * {@link ArrayDeque} ring buffer. Only that bounded window is then parsed and sorted by timestamp + * descending. Memory is therefore {@code O(maxQueryResults)} regardless of file size, and the sort + * cost is bounded to the result window rather than the whole file. + * + *

    For result sets {@code <= maxQueryResults} the observable output (filters + newest-first + * ordering) is identical to the previous full-file implementation. For larger sets, the most-recent + * {@code maxQueryResults} matches (by file/append order) are returned, then ordered newest-first. + * + *

    Scope: Only the active log file is queried; rotated archive files + * ({@code .1}, {@code .2}, ...) are not included. This preserves the prior behavior; query + * results reflect only the currently-active audit log. * * @param user the user to filter by * @param since optional timestamp filter * @param action optional action filter - * @return filtered list of audit events + * @return filtered list of audit events, newest first, capped at {@code maxQueryResults} */ private List findByUser(User user, Instant since, String action) { if (user == null) { @@ -108,28 +126,34 @@ private List findByUser(User user, Instant since, String action) int maxResults = auditConfig.getMaxQueryResults(); + // Bounded ring buffer of the most recent matching parsed events in file (append) order. + // When maxResults <= 0 the limit is disabled and all matching events are retained. + Deque window = new ArrayDeque<>(); + try (Stream lines = Files.lines(logPath)) { - Stream stream = lines - .skip(1) // Skip header line + lines.skip(1) // Skip header line .map(this::parseLine) .filter(Objects::nonNull) .filter(event -> matchesUser(event, userEmail, userId)) .filter(event -> since == null || event.getTimestamp() == null || !event.getTimestamp().isBefore(since)) .filter(event -> action == null || action.equals(event.getAction())) - .sorted(Comparator.comparing(AuditEventDTO::getTimestamp, - Comparator.nullsLast(Comparator.reverseOrder()))); - - // Apply limit if configured to prevent unbounded memory usage - if (maxResults > 0) { - stream = stream.limit(maxResults); - } - - return stream.collect(Collectors.toList()); + .forEach(event -> { + window.addLast(event); + if (maxResults > 0 && window.size() > maxResults) { + window.removeFirst(); // evict oldest to keep only the most recent N + } + }); } catch (IOException e) { log.error("FileAuditLogQueryService.findByUser: Error reading audit log file", e); return Collections.emptyList(); } + + // Sort only the bounded window by timestamp descending (newest first). + return window.stream() + .sorted(Comparator.comparing(AuditEventDTO::getTimestamp, + Comparator.nullsLast(Comparator.reverseOrder()))) + .collect(Collectors.toList()); } /** @@ -159,10 +183,11 @@ private Path getLogFilePath() { /** * Parses a single line from the audit log file. * - *

    Note: This parser assumes the audit log writer properly escapes - * pipe characters in message content. If audit messages contain unescaped pipes, - * parsing may be corrupted. Consider migrating to a structured format (JSON lines) - * for production deployments with untrusted input. + *

    Note: {@code FileAuditLogWriter} sanitizes each field (stripping CR/LF and the {@code |} + * delimiter) before writing, so records produced by this library always have exactly ten fields on a + * single line. The defensive rejoin below remains only to tolerate pre-existing log files written before + * that sanitization, or files produced by other tooling. A structured format (JSON lines) is still the + * better long-term choice for deployments that ingest fully untrusted audit input. * * @param line the line to parse * @return the parsed AuditEventDTO, or null if parsing fails diff --git a/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogWriter.java b/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogWriter.java index 43c59a85..9f7c6913 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogWriter.java +++ b/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogWriter.java @@ -6,13 +6,12 @@ import java.nio.file.Files; import java.nio.file.OpenOption; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.text.MessageFormat; -import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** @@ -29,18 +28,63 @@ *

    If the configured log path is not writable, the writer falls back to a temporary * directory location. * + *

    This class is not component-scanned; it is contributed as a consumer-overridable {@code @Bean} by + * {@link AuditMailAutoConfiguration} so a consumer-supplied {@link AuditLogWriter} can replace it via + * {@code @ConditionalOnMissingBean}. + * * @see AuditLogWriter * @see AuditConfig * @see FileAuditLogFlushScheduler + * @see AuditMailAutoConfiguration */ @Slf4j -@Component -@RequiredArgsConstructor public class FileAuditLogWriter implements AuditLogWriter { private final AuditConfig auditConfig; private BufferedWriter bufferedWriter; + /** Absolute/relative path of the currently-open active log file (set by {@link #tryOpenLogFile}). */ + private String activeFilePath; + + /** + * Approximate number of bytes written to the active log file since it was opened. Tracked + * incrementally (rather than calling {@code Files.size} on every write) to keep the hot write path + * cheap. The estimate uses {@link String#length()} as a byte approximation; this is sufficient for a + * size-based rotation trigger and intentionally avoids per-write {@code stat} syscalls. Resets on open + * and rotation. + */ + private long currentFileBytes = 0L; + + /** + * Effective rotation threshold in bytes, derived from {@link AuditConfig#getMaxFileSizeMb()} at open + * time. A value {@code <= 0} disables rotation. Package-private so tests can set a tiny threshold + * without writing megabytes of data. + */ + long maxFileSizeBytes = 0L; + + /** When true, {@link #maxFileSizeBytes} was set via the test hook and must not be overwritten on (re)open. */ + private boolean maxFileSizeBytesOverridden = false; + + /** + * Constructs the writer with the audit configuration it depends on. + * + * @param auditConfig the audit configuration properties + */ + public FileAuditLogWriter(AuditConfig auditConfig) { + this.auditConfig = auditConfig; + } + + /** + * Test-only hook to override the effective rotation threshold (in bytes) so rotation can be exercised + * without writing the full configured {@code maxFileSizeMb} of data. Not part of the public API. + * + * @param bytes the effective byte threshold; {@code <= 0} disables rotation + */ + void setMaxFileSizeBytesForTesting(long bytes) { + this.maxFileSizeBytes = bytes; + this.maxFileSizeBytesOverridden = true; + } + /** * Initializes the log file writer. This method is called after the bean is constructed. It validates the configuration and opens the log file for * writing. @@ -98,14 +142,22 @@ public synchronized void writeLog(AuditEvent event) { } } - String output = MessageFormat.format("{0}|{1}|{2}|{3}|{4}|{5}|{6}|{7}|{8}|{9}", event.getDate(), event.getAction(), - event.getActionStatus(), userId, userEmail, event.getIpAddress(), event.getSessionId(), event.getMessage(), event.getUserAgent(), - event.getExtraData()); + // Sanitize every text field before writing it into the pipe-delimited, line-oriented record. + // Fields such as user-agent, message, email and extra data can be attacker-influenced; an embedded + // newline would forge a fake record and an embedded pipe would shift columns. Stripping CR/LF and the + // delimiter guarantees each record stays on one line with exactly ten fields. The date is rendered by + // MessageFormat (no user content) so the query-service timestamp parser remains compatible. + String output = MessageFormat.format("{0}|{1}|{2}|{3}|{4}|{5}|{6}|{7}|{8}|{9}", event.getDate(), + sanitizeField(event.getAction()), sanitizeField(event.getActionStatus()), sanitizeField(userId), + sanitizeField(userEmail), sanitizeField(event.getIpAddress()), sanitizeField(event.getSessionId()), + sanitizeField(event.getMessage()), sanitizeField(event.getUserAgent()), sanitizeField(event.getExtraData())); bufferedWriter.write(output); bufferedWriter.newLine(); + currentFileBytes += output.length() + 1L; // +1 approximates the newline if (auditConfig.isFlushOnWrite()) { bufferedWriter.flush(); } + rotateIfNeeded(); } catch (IOException e) { log.error("FileAuditLogWriter.writeLog: IOException writing to log file: {}", auditConfig.getLogFilePath(), e); } catch (Exception e) { @@ -115,6 +167,22 @@ public synchronized void writeLog(AuditEvent event) { } + /** + * Sanitizes a single field for the pipe-delimited, line-oriented audit format by removing CR, LF and the + * {@code |} delimiter (each replaced with a single space). This prevents log forging (an injected newline + * starting a fake record) and field corruption (an injected delimiter shifting columns) from + * attacker-influenced values such as the user agent, message, email, or extra data. + * + * @param value the raw field value (may be {@code null}) + * @return the value with CR/LF/{@code |} replaced by spaces, or an empty string when {@code value} is null + */ + private static String sanitizeField(final Object value) { + if (value == null) { + return ""; + } + return value.toString().replaceAll("[\\r\\n|]", " "); + } + /** * Flushes the buffered writer to ensure all data is written to the log file. This method is called by the {@link FileAuditLogFlushScheduler} to * ensure that the buffer is flushed periodically to balance performance with data integrity. @@ -196,11 +264,20 @@ private boolean tryOpenLogFile(String filePath) { OpenOption[] fileOptions = {StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE}; boolean newFile = Files.notExists(path); bufferedWriter = Files.newBufferedWriter(path, fileOptions); - + this.activeFilePath = filePath; + + // Initialize the rotation threshold (derived from MB config) and the byte counter. For an + // existing (appended) file, seed the counter from its current size so rotation accounts for + // pre-existing content. + if (!maxFileSizeBytesOverridden) { + this.maxFileSizeBytes = (long) auditConfig.getMaxFileSizeMb() * 1024L * 1024L; + } + this.currentFileBytes = newFile ? 0L : sizeQuietly(path); + if (newFile) { writeHeader(); } - + log.info("FileAuditLogWriter.setup: Log file opened successfully: {}", filePath); return true; @@ -210,6 +287,89 @@ private boolean tryOpenLogFile(String filePath) { } } + /** + * Checks the tracked size of the active log file and rotates it if it has exceeded the configured + * threshold. Called from within the synchronized {@link #writeLog(AuditEvent)} after each write. + * + *

    Rotation is disabled when {@link #maxFileSizeBytes} is {@code <= 0} (i.e. + * {@link AuditConfig#getMaxFileSizeMb()} is {@code <= 0}). Rotation failures are caught and logged so + * that audit writing is never interrupted by a rotation problem. + */ + private void rotateIfNeeded() { + if (maxFileSizeBytes <= 0 || activeFilePath == null) { + return; // rotation disabled or no active file + } + if (currentFileBytes < maxFileSizeBytes) { + return; + } + try { + rotateLogFiles(); + } catch (Exception e) { + // Rotation must never break audit writing; log and continue with the current file. + log.error("FileAuditLogWriter.rotateIfNeeded: Failed to rotate audit log file '{}' (continuing without rotation): {}", + activeFilePath, e.getMessage(), e); + } + } + + /** + * Performs size-based rotation of the active log file: flushes and closes the current writer, shifts + * existing archives ({@code name.(N-1) -> name.N}, deleting the oldest beyond {@code maxFiles}), + * renames the active file to {@code name.1}, then reopens a fresh active file (writing the header + * again via {@link #openLogFile()} semantics). + * + * @throws IOException if the file shuffling fails irrecoverably + */ + private void rotateLogFiles() throws IOException { + String basePath = activeFilePath; + int maxFiles = Math.max(1, auditConfig.getMaxFiles()); + + // Flush and close the current writer before moving the file. + closeLogFile(); + bufferedWriter = null; + + // Delete the oldest archive that would be pushed out of the retention window. + Path oldest = Path.of(basePath + "." + maxFiles); + Files.deleteIfExists(oldest); + + // Shift archives upward: name.(N-1) -> name.N for N from maxFiles down to 2. + for (int i = maxFiles - 1; i >= 1; i--) { + Path src = Path.of(basePath + "." + i); + Path dst = Path.of(basePath + "." + (i + 1)); + if (Files.exists(src)) { + Files.move(src, dst, StandardCopyOption.REPLACE_EXISTING); + } + } + + // Move the current active file to name.1. + Path active = Path.of(basePath); + if (Files.exists(active)) { + Files.move(active, Path.of(basePath + ".1"), StandardCopyOption.REPLACE_EXISTING); + } + + // Reopen a fresh active file at the same configured path (rewrites the header for the new file). + currentFileBytes = 0L; + if (!tryOpenLogFile(basePath)) { + log.error("FileAuditLogWriter.rotateLogFiles: Unable to reopen audit log file after rotation: {}", basePath); + } else { + log.info("FileAuditLogWriter.rotateLogFiles: Rotated audit log file: {}", basePath); + } + } + + /** + * Returns the size of the given file, or {@code 0} if it cannot be determined. Used only to seed the + * byte counter when appending to a pre-existing file. + * + * @param path the file to measure + * @return the file size in bytes, or {@code 0} on error + */ + private long sizeQuietly(Path path) { + try { + return Files.exists(path) ? Files.size(path) : 0L; + } catch (IOException e) { + return 0L; + } + } + /** * Closes the log file to ensure all data is flushed and resources are released. */ @@ -235,6 +395,7 @@ private void writeHeader() { bufferedWriter.write(output); bufferedWriter.newLine(); bufferedWriter.flush(); + currentFileBytes += output.length() + 1L; // count header toward rotation threshold } catch (IOException e) { log.error("FileAuditLogWriter.writeHeader: IOException writing header: {}", output, e); } diff --git a/src/main/java/com/digitalsanctuary/spring/user/controller/UserActionController.java b/src/main/java/com/digitalsanctuary/spring/user/controller/UserActionController.java index c0bb63f5..bf69bf56 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/controller/UserActionController.java +++ b/src/main/java/com/digitalsanctuary/spring/user/controller/UserActionController.java @@ -12,6 +12,7 @@ import org.springframework.web.servlet.ModelAndView; import com.digitalsanctuary.spring.user.audit.AuditEvent; import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.service.TokenHasher; import com.digitalsanctuary.spring.user.service.UserService; import com.digitalsanctuary.spring.user.service.UserService.TokenValidationResult; import com.digitalsanctuary.spring.user.service.UserVerificationService; @@ -78,7 +79,7 @@ public class UserActionController { @GetMapping("${user.security.changePasswordURI:/user/changePassword}") 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)); final TokenValidationResult result = userService.validatePasswordResetToken(token); log.debug("UserAPI.showChangePasswordPage: result: {}", result); AuditEvent changePasswordAuditEvent = AuditEvent.builder().source(this).sessionId(request.getSession().getId()) @@ -111,16 +112,18 @@ public ModelAndView showChangePasswordPage(final HttpServletRequest request, fin @GetMapping("${user.security.registrationConfirmURI:/user/registrationConfirm}") 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)); Locale locale = request.getLocale(); model.addAttribute("lang", locale.getLanguage()); + // Resolve the user BEFORE validating: validateVerificationToken atomically consumes (deletes) the token, + // so a lookup by the same raw token afterward would no longer resolve. + final User user = userVerificationService.getUserByVerificationToken(token); final TokenValidationResult result = userVerificationService.validateVerificationToken(token); if (result == TokenValidationResult.VALID) { - final User user = userVerificationService.getUserByVerificationToken(token); if (user != null) { + // The token was already consumed (deleted) atomically inside validateVerificationToken. userService.authWithoutPassword(user); - userVerificationService.deleteVerificationToken(token); AuditEvent registrationAuditEvent = AuditEvent.builder().source(this).user(user) .sessionId(request.getSession().getId()) diff --git a/src/main/java/com/digitalsanctuary/spring/user/event/UserDeletedEvent.java b/src/main/java/com/digitalsanctuary/spring/user/event/UserDeletedEvent.java index 3b0a4f9b..87ac7930 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/event/UserDeletedEvent.java +++ b/src/main/java/com/digitalsanctuary/spring/user/event/UserDeletedEvent.java @@ -6,9 +6,21 @@ * Event published after a user entity has been successfully deleted. * *

    Unlike {@link UserPreDeleteEvent} which is published before deletion and allows - * cleanup operations within the transaction, this event is published after the - * deletion has been committed. Use this event for post-deletion notifications, - * external system updates, or logging that should only occur after successful deletion. + * cleanup operations within the transaction, this event is delivered after the + * deletion transaction has committed. Listeners (including {@code @Async} ones) + * are therefore guaranteed to observe a committed deletion and will never act on a + * not-yet-committed change. Use this event for post-deletion notifications, external + * system updates, or logging that should only occur after successful deletion. + * + *

    Delivery-after-commit is achieved by the publisher itself, not by the listener: + * the event is published from a registered {@code TransactionSynchronization.afterCommit} + * callback, so it is only fired once the surrounding transaction has committed. Because + * publication is already deferred, consumers do not need + * {@code @TransactionalEventListener} — a plain {@code @EventListener} (or an + * {@code @Async @EventListener}) will already receive the event post-commit. When no + * transaction synchronization is active (e.g. a non-transactional caller), the event is + * published immediately as a fallback. {@code GdprDeletionService.executeUserDeletion} + * uses this same deferred-publication mechanism. * *

    Note: Since the user entity has been deleted by the time this event is published, * only the user's ID and email are retained in this event. diff --git a/src/main/java/com/digitalsanctuary/spring/user/event/UserDisabledEvent.java b/src/main/java/com/digitalsanctuary/spring/user/event/UserDisabledEvent.java new file mode 100644 index 00000000..127c0c2f --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/event/UserDisabledEvent.java @@ -0,0 +1,74 @@ +package com.digitalsanctuary.spring.user.event; + +import org.springframework.context.ApplicationEvent; + +/** + * Event published after a user account has been disabled (soft-deleted). + * + *

    When {@code user.actuallyDeleteAccount} is {@code false} (the default), an account + * "deletion" request disables the user rather than removing the row. This event makes that + * default soft-delete path observable so consuming applications can react (e.g. revoke + * external access, notify downstream systems, or update analytics). + * + *

    Like {@link UserDeletedEvent}, this event is delivered after the disabling + * transaction has committed. Listeners (including {@code @Async} ones) are therefore + * guaranteed to observe a committed change and will never act on a not-yet-committed update. + * Delivery-after-commit is achieved by the publisher itself: the event is published from a + * registered {@code TransactionSynchronization.afterCommit} callback. Because publication is + * already deferred, consumers do not need {@code @TransactionalEventListener} + * — a plain {@code @EventListener} (or an {@code @Async @EventListener}) will already + * receive the event post-commit. When no transaction synchronization is active (e.g. a + * non-transactional caller), the event is published immediately as a fallback. + * + *

    Note: To mirror {@link UserDeletedEvent} and avoid handing listeners a live, detached, or + * mutated entity, only the user's ID and email are retained in this event. + * + * @see UserDeletedEvent + * @see UserPreDeleteEvent + */ +public class UserDisabledEvent extends ApplicationEvent { + + private static final long serialVersionUID = 1L; + + /** + * The ID of the disabled user. + */ + private final Long userId; + + /** + * The email of the disabled user. + */ + private final String userEmail; + + /** + * Creates a new UserDisabledEvent. + * + * @param source the object on which the event initially occurred + * @param userId the ID of the disabled user + * @param userEmail the email of the disabled user + */ + public UserDisabledEvent(Object source, Long userId, String userEmail) { + super(source); + this.userId = userId; + this.userEmail = userEmail; + } + + /** + * Gets the ID of the disabled user. + * + * @return the user ID + */ + public Long getUserId() { + return userId; + } + + /** + * Gets the email of the disabled user. + * + * @return the user email + */ + public String getUserEmail() { + return userEmail; + } + +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionService.java b/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionService.java index f6b94997..2ac4b701 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionService.java @@ -1,8 +1,12 @@ package com.digitalsanctuary.spring.user.gdpr; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import com.digitalsanctuary.spring.user.dto.GdprExportDTO; import com.digitalsanctuary.spring.user.event.UserDeletedEvent; import com.digitalsanctuary.spring.user.event.UserPreDeleteEvent; @@ -44,6 +48,17 @@ public class GdprDeletionService { private final List dataContributors; private final ApplicationEventPublisher eventPublisher; + /** + * Self-reference resolved through the Spring proxy, used to invoke {@link #executeUserDeletion} so its + * {@code @Transactional} boundary actually applies. Calling {@code executeUserDeletion(...)} directly + * ({@code this.}) would be a self-invocation that bypasses the proxy, leaving the deletion non-transactional + * (partial deletes possible) and causing the after-commit event to fire immediately. Injected {@link Lazy} + * to break the construction-time circular dependency on itself. + */ + @Lazy + @Autowired + private GdprDeletionService self; + /** * Result of a GDPR deletion operation. */ @@ -129,8 +144,9 @@ public DeletionResult deleteUser(User user, boolean exportBeforeDeletion) { exportedData = gdprExportService.exportUserData(user); } - // Step 2: Perform deletion in transaction - return executeUserDeletion(user, exportedData, exportBeforeDeletion); + // Step 2: Perform deletion in transaction. Invoke through the proxy (self) so the @Transactional + // boundary on executeUserDeletion applies — a direct this-call would bypass it. + return self.executeUserDeletion(user, exportedData, exportBeforeDeletion); } catch (Exception e) { log.error("GdprDeletionService.deleteUser: Failed to delete user {}: {}", @@ -168,14 +184,39 @@ protected DeletionResult executeUserDeletion(User user, GdprExportDTO exportedDa log.info("GdprDeletionService.deleteUser: Successfully deleted user {}", userId); - // Step 6: Publish UserDeletedEvent after successful deletion - eventPublisher.publishEvent(new UserDeletedEvent(this, userId, userEmail, wasExported)); + // Step 6: Publish UserDeletedEvent AFTER the deletion transaction commits, so listeners + // (especially @Async ones) never observe a not-yet-committed deletion. If no transaction + // is active, publish immediately. + publishUserDeletedEventAfterCommit(userId, userEmail, wasExported); return wasExported ? DeletionResult.successWithExport(exportedData) : DeletionResult.success(null); } + /** + * Publishes a {@link UserDeletedEvent} after the current transaction commits. + * + *

    If a transaction is active, the event is published from + * {@link TransactionSynchronization#afterCommit()}; otherwise it is published immediately. + * + * @param userId the id of the deleted user + * @param userEmail the email of the deleted user + * @param wasExported whether data was exported before deletion + */ + private void publishUserDeletedEventAfterCommit(Long userId, String userEmail, boolean wasExported) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + eventPublisher.publishEvent(new UserDeletedEvent(GdprDeletionService.this, userId, userEmail, wasExported)); + } + }); + } else { + eventPublisher.publishEvent(new UserDeletedEvent(this, userId, userEmail, wasExported)); + } + } + /** * Notifies all GdprDataContributors to prepare for deletion. *

    diff --git a/src/main/java/com/digitalsanctuary/spring/user/listener/RegistrationListener.java b/src/main/java/com/digitalsanctuary/spring/user/listener/RegistrationListener.java index 86a2f347..4123941c 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/listener/RegistrationListener.java +++ b/src/main/java/com/digitalsanctuary/spring/user/listener/RegistrationListener.java @@ -42,6 +42,16 @@ public class RegistrationListener { @EventListener public void onApplicationEvent(final OnRegistrationCompleteEvent event) { log.debug("RegistrationListener.onApplicationEvent: called with event: {}", event.toString()); + // Skip sending a verification email to users who are already enabled (e.g. first-time OAuth2/OIDC + // registrations, where the provider has already verified the email and the account is created + // ENABLED). Form registrations that require verification are created DISABLED, so they still receive + // the email. This lets OAuth/OIDC services publish OnRegistrationCompleteEvent so consumers can + // observe social registrations uniformly, without sending those users a pointless verification email. + if (event.getUser() != null && event.getUser().isEnabled()) { + log.debug("RegistrationListener.onApplicationEvent: user {} is already enabled; skipping verification email", + event.getUser().getEmail()); + return; + } if (sendRegistrationVerificationEmail) { this.sendRegistrationVerificationEmail(event); } diff --git a/src/main/java/com/digitalsanctuary/spring/user/mail/MailExecutorConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/mail/MailExecutorConfiguration.java new file mode 100644 index 00000000..262d40be --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/mail/MailExecutorConfiguration.java @@ -0,0 +1,47 @@ +package com.digitalsanctuary.spring.user.mail; + +import java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +/** + * Provides the dedicated, bounded {@code dsMailExecutor} used to run the library's mail-sending {@code @Async} work. + * + *

    + * {@link MailService}'s send methods combine {@code @Async} with {@code @Retryable} (3 attempts with exponential backoff). If those methods shared + * Spring Boot's default application task executor, an SMTP outage could occupy that shared pool for seconds at a time per message and starve every + * other {@code @Async} feature in the library (event publishing, listeners, etc.). To prevent that, mail runs on its own small, bounded pool with a + * bounded queue and a {@link CallerRunsPolicy} rejection handler — so when the mail pool and queue are both saturated, the calling thread runs + * the task itself, applying backpressure rather than dropping mail or letting the queue grow without bound. + *

    + * + *

    + * The pool uses fixed, conservative defaults (core 2, max 4, queue 50). A consuming application that needs different sizing can supply its own + * {@code dsMailExecutor} bean; the library's default then backs off via {@link ConditionalOnMissingBean}. + *

    + */ +@Configuration +public class MailExecutorConfiguration { + + /** + * Creates the dedicated, bounded executor for mail-sending {@code @Async} work. Bounded core/max pool sizes plus a bounded queue and a + * {@link CallerRunsPolicy} rejection handler ensure an SMTP stall applies backpressure to the caller instead of starving the shared default + * async executor or queueing mail without limit. + * + * @return the bounded {@link ThreadPoolTaskExecutor} for mail + */ + @Bean("dsMailExecutor") + @ConditionalOnMissingBean(name = "dsMailExecutor") + public ThreadPoolTaskExecutor dsMailExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(50); + executor.setThreadNamePrefix("ds-mail-"); + executor.setRejectedExecutionHandler(new CallerRunsPolicy()); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java b/src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java index 37a6f50e..6da12ff1 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java @@ -12,7 +12,6 @@ import org.springframework.retry.annotation.Recover; import org.springframework.retry.annotation.Retryable; import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; import org.thymeleaf.context.Context; import lombok.extern.slf4j.Slf4j; @@ -23,9 +22,12 @@ *

    Email is treated as optional: if no {@link JavaMailSender} bean is available (typically because {@code spring.mail.host} is not configured), * a single warning is logged at startup and all send operations silently no-op, so the application starts and runs normally with email-dependent * features degraded.

    + * + *

    This class is not component-scanned; it is contributed as a consumer-overridable {@code @Bean} by + * {@code AuditMailAutoConfiguration} so a consumer can replace mail delivery by supplying their own {@link MailService} + * (or subclass) bean, which the library's default then backs off from via {@code @ConditionalOnMissingBean}.

    */ @Slf4j -@Service public class MailService { private final ObjectProvider mailSenderProvider; @@ -59,6 +61,9 @@ void init() { resolvedSender = mailSenderProvider.getIfAvailable(); if (resolvedSender == null) { log.warn("JavaMailSender is not configured — email sending is disabled. Set 'spring.mail.host' to enable."); + } else if (fromAddress == null || fromAddress.isBlank()) { + log.warn("JavaMailSender is configured but 'user.mail.fromAddress' is not set — outbound emails will have no valid sender address. " + + "Set 'user.mail.fromAddress' to a valid from address."); } } @@ -69,7 +74,7 @@ void init() { * @param subject the subject of the email * @param text the text to include as the email message body */ - @Async + @Async("dsMailExecutor") @Retryable(retryFor = {MailException.class}, maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2)) public void sendSimpleMessage(String to, String subject, String text) { @@ -98,7 +103,7 @@ public void sendSimpleMessage(String to, String subject, String text) { * @param variables a map of variables (key->value) to use in building the dynamic content via the template * @param templatePath the file name, or path and name, for the Thymeleaf template to use to build the dynamic email */ - @Async + @Async("dsMailExecutor") @Retryable(retryFor = {MailException.class}, maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2)) public void sendTemplateMessage(String to, String subject, Map variables, String templatePath) { diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/PasswordResetToken.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/PasswordResetToken.java index 061f1f80..e74b158a 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/PasswordResetToken.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/PasswordResetToken.java @@ -18,7 +18,7 @@ @Entity public class PasswordResetToken { - /** The Constant EXPIRATION. */ + /** The Constant EXPIRATION. Default token lifetime in minutes (24h) used by the legacy constructors. */ private static final int EXPIRATION = 60 * 24; /** The id. */ @@ -68,6 +68,20 @@ public PasswordResetToken(final String token, final User user) { this.expiryDate = calculateExpiryDate(EXPIRATION); } + /** + * Instantiates a new password reset token with a configurable lifetime. + * + * @param token the token (already hashed for storage by the calling service) + * @param user the user + * @param expiryTimeInMinutes the token lifetime in minutes + */ + public PasswordResetToken(final String token, final User user, final int expiryTimeInMinutes) { + super(); + this.token = token; + this.user = user; + this.expiryDate = calculateExpiryDate(expiryTimeInMinutes); + } + /** * Calculate expiry date. * diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Privilege.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Privilege.java index 691697f8..9c99ab7e 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Privilege.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Privilege.java @@ -1,5 +1,6 @@ package com.digitalsanctuary.spring.user.persistence.model; +import java.io.Serializable; import java.util.Collection; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -19,7 +20,10 @@ @EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString @Entity -public class Privilege { +public class Privilege implements Serializable { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; /** The id. */ @Id diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Role.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Role.java index c18a5726..516414e0 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Role.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Role.java @@ -1,5 +1,6 @@ package com.digitalsanctuary.spring.user.persistence.model; +import java.io.Serializable; import java.util.HashSet; import java.util.Set; import jakarta.persistence.CascadeType; @@ -24,7 +25,11 @@ @EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString @Entity -public class Role { +public class Role implements Serializable { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + /** The id. */ @Id @GeneratedValue(strategy = GenerationType.AUTO) diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/User.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/User.java index 4b710104..2f9360c9 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/User.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/User.java @@ -1,5 +1,6 @@ package com.digitalsanctuary.spring.user.persistence.model; +import java.io.Serializable; import java.util.ArrayList; import java.util.Date; import java.util.HashSet; @@ -16,12 +17,21 @@ /** * The User Entity. Part of the basic User ->> Role ->> Privilege structure. This is the primary user data object. You can add to this, or add * referenced types as needed. Leverages the Spring JPA Auditing framework to automatically manage the registrationDate and lastActivityDate fields. + * + *

    + * This entity implements {@link Serializable} so it can be stored in the HTTP session as part of the authenticated principal + * ({@code DSUserDetails}) and serialized by distributed/persistent session stores such as Spring Session JDBC or Redis. Consumers using + * distributed sessions must ensure any custom profile or data reachable from the session-stored principal is also {@link Serializable}. + *

    */ @Data @Entity @EntityListeners(AuditingEntityListener.class) @Table(name = "user_account") -public class User { +public class User implements Serializable { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; /** * Enum representing the available login providers. @@ -73,6 +83,7 @@ public enum Provider { private Provider provider = Provider.LOCAL; /** The password. */ + @ToString.Exclude @Column(length = 60) private String password; diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/VerificationToken.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/VerificationToken.java index 615969e0..0aa5f589 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/VerificationToken.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/VerificationToken.java @@ -10,6 +10,7 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.OneToOne; +import jakarta.persistence.Transient; import lombok.Data; /** @@ -19,7 +20,7 @@ @Entity public class VerificationToken { - /** The Constant EXPIRATION. */ + /** The Constant EXPIRATION. Default token lifetime in minutes (24h) used by the legacy constructors. */ private static final int EXPIRATION = 60 * 24; /** The id. */ @@ -38,6 +39,15 @@ public class VerificationToken { /** The expiry date. */ private Date expiryDate; + /** + * The raw (unhashed) token value. This is transient and never persisted: the {@link #token} + * column holds only the hash. It carries the raw token back to a caller (e.g. so a verification + * email link can be built) when a service regenerates a token. It is {@code null} on entities + * loaded from the database. + */ + @Transient + private transient String plainToken; + /** * Instantiates a new verification token. */ @@ -69,6 +79,20 @@ public VerificationToken(final String token, final User user) { this.expiryDate = calculateExpiryDate(EXPIRATION); } + /** + * Instantiates a new verification token with a configurable lifetime. + * + * @param token the token (already hashed for storage by the calling service) + * @param user the user + * @param expiryTimeInMinutes the token lifetime in minutes + */ + public VerificationToken(final String token, final User user, final int expiryTimeInMinutes) { + super(); + this.token = token; + this.user = user; + this.expiryDate = calculateExpiryDate(expiryTimeInMinutes); + } + /** * Calculate expiry date. * @@ -83,7 +107,7 @@ private Date calculateExpiryDate(final int expiryTimeInMinutes) { } /** - * Update token. + * Update token, resetting the expiry to the default (24h) lifetime. * * @param token the token */ @@ -92,4 +116,15 @@ public void updateToken(final String token) { this.expiryDate = calculateExpiryDate(EXPIRATION); } + /** + * Update token with a configurable lifetime. + * + * @param token the token (already hashed for storage by the calling service) + * @param expiryTimeInMinutes the token lifetime in minutes + */ + public void updateToken(final String token, final int expiryTimeInMinutes) { + this.token = token; + this.expiryDate = calculateExpiryDate(expiryTimeInMinutes); + } + } diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordHistoryRepository.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordHistoryRepository.java index 3d3e2f55..a8c59b84 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordHistoryRepository.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordHistoryRepository.java @@ -4,7 +4,9 @@ import com.digitalsanctuary.spring.user.persistence.model.User; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; @@ -34,6 +36,36 @@ public interface PasswordHistoryRepository extends JpaRepository findByUserOrderByEntryDateDesc(User user); + /** + * Fetch the ids of a user's password history entries, newest first. + * + *

    Used to locate the cutoff id when pruning old entries. Ordering is by primary + * key descending: the id column is generated with {@code GenerationType.IDENTITY}, + * so it is monotonically increasing and therefore a reliable recency ordering even + * when multiple entries share the same {@code entryDate} timestamp. + * + * @param user the user + * @param pageable the pageable defining the offset/limit (used to fetch only the cutoff row) + * @return list of entry ids, newest first + */ + @Query("SELECT p.id FROM PasswordHistoryEntry p WHERE p.user = :user ORDER BY p.id DESC") + List findIdsByUserOrderByIdDesc(@Param("user") User user, Pageable pageable); + + /** + * Delete a user's password history entries whose id is below the given cutoff. + * + *

    This is a single set-based delete that prunes everything older than the cutoff + * id in one statement, avoiding the load-then-deleteAll read/delete window. It is + * portable across H2, MariaDB, and PostgreSQL (no subquery {@code LIMIT}). + * + * @param user the user whose old entries should be removed + * @param cutoffId entries with an id strictly less than this are deleted + * @return the number of rows deleted + */ + @Modifying + @Query("DELETE FROM PasswordHistoryEntry p WHERE p.user = :user AND p.id < :cutoffId") + int deleteByUserAndIdLessThan(@Param("user") User user, @Param("cutoffId") Long cutoffId); + /** * Delete all password history entries for a user. * Used when removing a user's password for passwordless accounts. diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordResetTokenRepository.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordResetTokenRepository.java index 7287caa8..394565b8 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordResetTokenRepository.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordResetTokenRepository.java @@ -29,6 +29,13 @@ public interface PasswordResetTokenRepository extends JpaRepository { */ User findByEmail(String email); + /** + * Atomically increments the failed login attempt counter for the user with the given email. + * + *

    This is a single bulk UPDATE statement executed directly against the database, which avoids the lost-update race that a read-modify-write + * (read counter, increment in memory, save) would suffer under concurrent failed logins. Each concurrent increment is serialized by the database + * so no increment is lost, ensuring an attacker cannot evade lockout by hammering an account in parallel.

    + * + *

    {@code flushAutomatically = true} flushes any pending persistence-context changes before the bulk update so they are not lost. + * {@code clearAutomatically = true} clears the persistence context afterward; the bulk UPDATE bypasses the first-level cache, so clearing ensures a + * subsequent {@code findByEmail} reads the fresh, incremented value from the database rather than a stale cached entity.

    + * + * @param email the email of the user whose counter should be incremented + * @return the number of rows affected (1 if the user exists, 0 otherwise) + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update User u set u.failedLoginAttempts = u.failedLoginAttempts + 1 where u.email = :email") + int incrementFailedAttempts(@Param("email") String email); + /** * Find all enabled users. * diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/VerificationTokenRepository.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/VerificationTokenRepository.java index 42c078bc..4750baba 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/VerificationTokenRepository.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/VerificationTokenRepository.java @@ -29,6 +29,13 @@ public interface VerificationTokenRepository extends JpaRepository * *

    - * Example usage: + * IMPORTANT: Spring's {@link Scope @Scope} annotation is NOT inherited by + * subclasses. The {@code @Scope} declared on this base class does not propagate to your concrete + * subclass. If your subclass is annotated only with {@code @Component} (and no {@code @Scope}), it becomes a + * singleton shared across every HTTP session — one user's profile data will leak to all + * other users, which is a serious security vulnerability. Every concrete subclass MUST declare + * session scoping on itself, either by repeating the {@code @Scope} annotation explicitly or by using the + * convenience meta-annotation {@link SessionScopedProfile @SessionScopedProfile}. + *

    + * + *

    + * Example usage — Option A, the convenience meta-annotation (recommended): + *

    + * + *
    {@code
    + * @SessionScopedProfile
    + * public class CustomSessionProfile extends BaseSessionProfile {
    + *     public boolean hasSpecificPermission() {
    + *         return getUserProfile().getPermissions().contains("SPECIFIC_PERMISSION");
    + *     }
    + * }
    + * }
    + * + *

    + * Example usage — Option B, an explicit {@code @Scope} on the subclass (equivalent to Option A): *

    * *
    {@code
      * @Component
    + * @Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.TARGET_CLASS)
      * public class CustomSessionProfile extends BaseSessionProfile {
      *     public boolean hasSpecificPermission() {
      *         return getUserProfile().getPermissions().contains("SPECIFIC_PERMISSION");
    @@ -37,6 +61,7 @@
      * @param  the type of user profile, must extend BaseUserProfile
      *
      * @see BaseUserProfile
    + * @see SessionScopedProfile
      * @see WebApplicationContext#SCOPE_SESSION
      */
     @Data
    diff --git a/src/main/java/com/digitalsanctuary/spring/user/profile/session/SessionScopedProfile.java b/src/main/java/com/digitalsanctuary/spring/user/profile/session/SessionScopedProfile.java
    new file mode 100644
    index 00000000..214f336f
    --- /dev/null
    +++ b/src/main/java/com/digitalsanctuary/spring/user/profile/session/SessionScopedProfile.java
    @@ -0,0 +1,62 @@
    +package com.digitalsanctuary.spring.user.profile.session;
    +
    +import java.lang.annotation.Documented;
    +import java.lang.annotation.ElementType;
    +import java.lang.annotation.Retention;
    +import java.lang.annotation.RetentionPolicy;
    +import java.lang.annotation.Target;
    +import org.springframework.context.annotation.Scope;
    +import org.springframework.context.annotation.ScopedProxyMode;
    +import org.springframework.core.annotation.AliasFor;
    +import org.springframework.stereotype.Component;
    +import org.springframework.web.context.WebApplicationContext;
    +
    +/**
    + * Convenience meta-annotation that registers a Spring bean as a correctly session-scoped component.
    + *
    + * 

    + * Spring's {@link Scope @Scope} annotation is not inherited by subclasses. A concrete subclass of + * {@link BaseSessionProfile} that is annotated only with {@code @Component} (and no {@code @Scope}) becomes a + * singleton shared across every HTTP session — one user's profile data leaks to all other + * users. To avoid this trap, every concrete session profile must declare session scoping on itself. + *

    + * + *

    + * Annotating a subclass with {@code @SessionScopedProfile} is the single-annotation equivalent of writing both: + *

    + * + *
    {@code
    + * @Component
    + * @Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.TARGET_CLASS)
    + * }
    + * + *

    + * Example usage: + *

    + * + *
    {@code
    + * @SessionScopedProfile
    + * public class CustomSessionProfile extends BaseSessionProfile {
    + *     // ...
    + * }
    + * }
    + * + * @see BaseSessionProfile + * @see Scope + * @see WebApplicationContext#SCOPE_SESSION + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Component +@Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.TARGET_CLASS) +public @interface SessionScopedProfile { + + /** + * Alias for {@link Component#value()} to allow the bean name to be supplied directly on the meta-annotation. + * + * @return the suggested component name, if any + */ + @AliasFor(annotation = Component.class) + String value() default ""; +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/registration/CompositeRegistrationGuard.java b/src/main/java/com/digitalsanctuary/spring/user/registration/CompositeRegistrationGuard.java new file mode 100644 index 00000000..a7be9609 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/registration/CompositeRegistrationGuard.java @@ -0,0 +1,78 @@ +package com.digitalsanctuary.spring.user.registration; + +import java.util.ArrayList; +import java.util.List; + +import lombok.extern.slf4j.Slf4j; + +/** + * A {@link RegistrationGuard} that composes multiple delegate guards and evaluates them in order, + * applying first-deny-wins semantics. + * + *

    The delegates are consulted sequentially. The first delegate that returns a + * {@link RegistrationDecision#deny(String) deny} decision short-circuits evaluation — subsequent + * delegates are not consulted — and its reason is propagated. If every delegate allows (or there are + * no delegates at all), the composite allows the registration.

    + * + *

    This enables layered policies, e.g. an invite-only guard and a domain-allowlist guard, + * where any single denial blocks the registration. The order of evaluation follows the order of the + * injected list; consumers can control it with Spring's {@code @Order}/{@link org.springframework.core.Ordered}.

    + * + *

    Thread Safety: This class holds an immutable snapshot of its delegates and is + * therefore thread-safe provided the delegates themselves are thread-safe (as required by the SPI).

    + * + * @see RegistrationGuard + * @see RegistrationGuardConfiguration + */ +@Slf4j +public class CompositeRegistrationGuard implements RegistrationGuard { + + /** The ordered, immutable list of delegate guards. Never {@code null}; may be empty. */ + private final List delegates; + + /** + * Instantiates a new composite registration guard. + * + * @param delegates the ordered delegate guards to consult; a {@code null} list is treated as empty + */ + public CompositeRegistrationGuard(final List delegates) { + this.delegates = delegates == null ? List.of() : List.copyOf(delegates); + log.debug("CompositeRegistrationGuard initialized with {} delegate guard(s)", this.delegates.size()); + } + + /** + * Evaluates each delegate in order, returning the first denial encountered (first-deny-wins). If no + * delegate denies, the registration is allowed. + * + * @param context the registration context describing the attempt + * @return the first denying {@link RegistrationDecision}, or {@link RegistrationDecision#allow()} if + * all delegates allow + * @throws IllegalStateException if any delegate returns {@code null}; the SPI contract requires a + * non-null decision, and silently treating {@code null} as "allow" would let a buggy guard + * fail open. Failing fast surfaces the bug at test/runtime instead. + */ + @Override + public RegistrationDecision evaluate(final RegistrationContext context) { + for (RegistrationGuard delegate : delegates) { + RegistrationDecision decision = delegate.evaluate(context); + if (decision == null) { + throw new IllegalStateException( + "RegistrationGuard " + delegate.getClass().getName() + " returned a null decision; " + + "guards must return a non-null RegistrationDecision (allow or deny)."); + } + if (!decision.allowed()) { + return decision; + } + } + return RegistrationDecision.allow(); + } + + /** + * Returns the delegate guards composed by this guard, in evaluation order. + * + * @return an immutable list of delegate guards + */ + public List getDelegates() { + return new ArrayList<>(delegates); + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/registration/RegistrationDeniedException.java b/src/main/java/com/digitalsanctuary/spring/user/registration/RegistrationDeniedException.java new file mode 100644 index 00000000..0bb4adac --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/registration/RegistrationDeniedException.java @@ -0,0 +1,43 @@ +package com.digitalsanctuary.spring.user.registration; + +/** + * Thrown when a {@link RegistrationGuard} denies a registration attempt. + * + *

    This exception is raised from the service layer (e.g. {@code UserService}) so that the guard is + * enforced exactly once for every registration path — form, passwordless, OAuth2, and OIDC — and can + * never be bypassed by direct callers of the service registration methods. Callers (such as the REST + * controller or the OAuth/OIDC user services) catch this exception and translate it into the + * appropriate denial response for their transport.

    + * + *

    The {@link #getReason() reason} carries the human-readable denial message produced by the guard + * via {@link RegistrationDecision#deny(String)}.

    + * + * @see RegistrationGuard + * @see RegistrationDecision + */ +public class RegistrationDeniedException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** The human-readable reason the registration was denied. */ + private final String reason; + + /** + * Instantiates a new registration denied exception. + * + * @param reason the human-readable denial reason from the guard + */ + public RegistrationDeniedException(final String reason) { + super(reason); + this.reason = reason; + } + + /** + * Gets the human-readable reason the registration was denied. + * + * @return the denial reason + */ + public String getReason() { + return reason; + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/registration/RegistrationGuardConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/registration/RegistrationGuardConfiguration.java index 403c3332..d9d09e0a 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/registration/RegistrationGuardConfiguration.java +++ b/src/main/java/com/digitalsanctuary/spring/user/registration/RegistrationGuardConfiguration.java @@ -1,25 +1,70 @@ package com.digitalsanctuary.spring.user.registration; +import java.util.List; + import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import lombok.extern.slf4j.Slf4j; /** * Auto-configuration for the {@link RegistrationGuard} SPI. * - *

    Registers a {@link DefaultRegistrationGuard} (permit-all) when no custom - * {@link RegistrationGuard} bean is defined by the consuming application.

    + *

    Two beans are registered:

    + *
      + *
    • A {@link DefaultRegistrationGuard} (permit-all) named {@code defaultRegistrationGuard}, created + * only when the consuming application defines no other "custom" {@link RegistrationGuard} bean. + * This guarantees the composite always has at least one delegate so registration is never + * silently un-guarded.
    • + *
    • A {@link Primary} {@link CompositeRegistrationGuard} that wraps every {@link RegistrationGuard} + * bean (the default, or one-or-more consumer guards) and evaluates them in order with + * first-deny-wins semantics. Because it is {@code @Primary}, all single-valued + * {@code RegistrationGuard} injection points (e.g. {@code UserService}) resolve to it.
    • + *
    + * + *

    The {@code List} injected into the composite factory method is populated by + * Spring with all {@link RegistrationGuard} beans except the composite itself (which is still under + * construction), ordered by {@code @Order}/{@link org.springframework.core.Ordered} where present.

    */ @Slf4j @Configuration public class RegistrationGuardConfiguration { + /** + * Registers the permit-all {@link DefaultRegistrationGuard} when the consuming application supplies + * no other {@link RegistrationGuard} bean. + * + *

    The {@code @ConditionalOnMissingBean} explicitly {@code ignored}s the + * {@link CompositeRegistrationGuard} declared in this same configuration. Without that, the + * composite (which itself implements {@link RegistrationGuard}) could satisfy the condition and + * suppress the default — leaving the composite with an empty delegate list when the consumer + * provides no guards. By ignoring the composite, the default is created when (and only when) the + * consumer provides no {@link RegistrationGuard}, guaranteeing the composite always has at + * least one delegate.

    + * + * @return a permit-all registration guard + */ @Bean - @ConditionalOnMissingBean(RegistrationGuard.class) - public RegistrationGuard registrationGuard() { + @ConditionalOnMissingBean(value = RegistrationGuard.class, ignored = CompositeRegistrationGuard.class) + public DefaultRegistrationGuard defaultRegistrationGuard() { log.info("No custom RegistrationGuard bean found — using DefaultRegistrationGuard (permit-all)"); return new DefaultRegistrationGuard(); } + + /** + * Registers the primary {@link CompositeRegistrationGuard} that composes all available + * {@link RegistrationGuard} delegates with first-deny-wins ordering. + * + * @param guards all {@link RegistrationGuard} beans (the default permit-all, or one-or-more consumer + * guards), ordered by {@code @Order}/{@link org.springframework.core.Ordered} + * @return the primary composite registration guard + */ + @Bean + @Primary + public CompositeRegistrationGuard compositeRegistrationGuard(final List guards) { + log.info("Registering CompositeRegistrationGuard with {} delegate guard(s)", guards.size()); + return new CompositeRegistrationGuard(guards); + } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/MfaConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/security/MfaConfiguration.java index bbf18f9a..94487b5c 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/MfaConfiguration.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/MfaConfiguration.java @@ -14,6 +14,7 @@ import org.springframework.security.authorization.DefaultAuthorizationManagerFactory; import org.springframework.security.authorization.RequiredFactor; import org.springframework.security.core.authority.FactorGrantedAuthority; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,16 +22,49 @@ * Configuration that registers {@link MfaConfigProperties} and provides MFA-related beans. *

    * This configuration is always active because {@code WebSecurityConfig} requires {@link MfaConfigProperties} regardless - * of whether MFA is enabled. The {@code DefaultAuthorizationManagerFactory} bean is only created when MFA is enabled. + * of whether MFA is enabled. The MFA beans are only created when MFA is enabled. + *

    + * + *

    Spike conclusion (Spring Security 7.0.5 factor merging, H4)

    + *

    + * Multi-factor login has two distinct sides in SS7 and this framework was only wiring one of them: + *

    + *
      + *
    1. Enforcement — the {@link DefaultAuthorizationManagerFactory} produced by + * {@link #mfaAuthorizationManagerFactory()} sets an {@link AllRequiredFactorsAuthorizationManager} (AND semantics) so + * {@code .authenticated()} additionally requires every configured factor authority. This was already present + * and correct, scoped to the property-driven subset (so a {@code PASSWORD}-only deployment is not locked out).
    2. + *
    3. Merging — the missing half. SS7 merges factor authorities across login steps inside + * {@code org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter#doFilter}: when its + * {@code mfaEnabled} flag is {@code true} and an already-authenticated context exists for the same principal, the new + * authentication result is rebuilt via {@code authenticationResult.toBuilder().authorities(...)} to additively carry the + * authorities of the existing authentication. That merged result is then passed through {@code successfulAuthentication} + * to the success handler. Without {@code mfaEnabled=true}, completing a second factor REPLACES the authentication + * (dropping the first factor) and the user can never satisfy "all required factors" — the H4 lockout.
    4. + *
    + *

    + * The {@code mfaEnabled} flag is normally flipped by {@code @EnableMultiFactorAuthentication}, whose + * {@code MultiFactorAuthenticationSelector} imports {@code AuthorizationManagerFactoryConfiguration} (a second, static + * {@link DefaultAuthorizationManagerFactory} bean built from the annotation's STATIC {@code authorities()} superset) and + * {@code EnableMfaFiltersConfiguration} (an {@code EnableMfaFiltersPostProcessor} that calls + * {@code setMfaEnabled(true)} on every authentication processing filter). We deliberately do NOT use the annotation here + * for two reasons: (a) its {@code authorities()} is a static superset, but AllRequiredFactors is AND-enforcement, so a + * superset would lock out subset deployments (e.g. {@code PASSWORD}-only); and (b) its factory bean is registered + * by-type with no {@code @ConditionalOnMissingBean}, which would collide with our property-driven factory — + * {@code AuthorizeHttpRequestsConfigurer} resolves the factory via {@code getBeanProvider(...).getIfAvailable()}, which + * returns {@code null} on ambiguity and silently falls back to a non-enforcing default, disabling factor enforcement. *

    *

    - * When enabled, the {@code DefaultAuthorizationManagerFactory} is configured with an - * {@link AllRequiredFactorsAuthorizationManager} that makes {@code .authenticated()} in - * {@code authorizeHttpRequests} additionally require all configured factor authorities. Spring Security 7's built-in - * infrastructure handles enforcement and session management automatically. + * Therefore we keep our single property-driven enforcement factory and activate ONLY the merging side using public + * SS7 API. The merging post-processor lives in {@link MfaFilterMergingConfiguration} (a separate, dependency-free + * configuration) so that declaring a {@code BeanPostProcessor} does not force this dependency-rich, event-listening + * configuration to be instantiated early. It replicates {@code EnableMfaFiltersPostProcessor} by invoking the public + * {@link AbstractAuthenticationProcessingFilter#setMfaEnabled(boolean)} on the authentication processing filters, gated + * on {@code user.mfa.enabled=true}, leaving the default (no-MFA) path untouched. *

    * * @see MfaConfigProperties + * @see MfaFilterMergingConfiguration * @see WebSecurityConfig */ @Slf4j diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/MfaFilterMergingConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/security/MfaFilterMergingConfiguration.java new file mode 100644 index 00000000..1bdb8ed3 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/security/MfaFilterMergingConfiguration.java @@ -0,0 +1,74 @@ +package com.digitalsanctuary.spring.user.security; + +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; +import lombok.extern.slf4j.Slf4j; + +/** + * Activates Spring Security 7 factor merging by enabling MFA mode on authentication processing filters. This is the + * "merging" half of multi-factor login described in {@link MfaConfiguration}; it is isolated here, in a small + * configuration with no constructor-injected dependencies and no {@code @EventListener} methods, on purpose. + * + *

    + * The merging behaviour is provided by a {@code static} {@link BeanPostProcessor} {@code @Bean}. A {@code BeanPostProcessor}- + * declaring class is instantiated very early in the context lifecycle (before the regular bean-instantiation phase). Keeping + * that declaration on the dependency-rich {@link MfaConfiguration} would force that class to be instantiated early + * too — which can emit the "not eligible for getting processed by all BeanPostProcessors" warning and interfere with + * its {@code @EventListener} registration. Housing the post-processor in this dependency-free class avoids that entirely. + *

    + * + *

    + * WARNING — scope of {@code setMfaEnabled(true)}. When {@code user.mfa.enabled=true}, the post-processor flips + * MFA mode on every {@link AbstractAuthenticationProcessingFilter} bean in the application context. That includes + * any filter a consuming application defines that extends this base class (e.g. a custom JWT or API-key + * authentication filter). Such a filter will then also perform SS7 factor merging: on a subsequent authentication for an + * already-authenticated principal it rebuilds the result via {@code authenticationResult.toBuilder()...}. If that filter's + * {@link org.springframework.security.core.Authentication} implementation does not support {@code toBuilder()}, the merge can + * throw at runtime. Consumers enabling MFA who also register custom processing filters should be aware their filters are + * affected. (This mirrors the framework default: it is only active when MFA is explicitly enabled.) + *

    + * + * @see MfaConfiguration + * @see AbstractAuthenticationProcessingFilter#setMfaEnabled(boolean) + */ +@Slf4j +@Configuration +@ConditionalOnProperty(name = "user.mfa.enabled", havingValue = "true", matchIfMissing = false) +public class MfaFilterMergingConfiguration { + + /** + * Replicates the behaviour of {@code @EnableMultiFactorAuthentication}'s internal {@code EnableMfaFiltersPostProcessor} + * using only public API, by invoking the public {@link AbstractAuthenticationProcessingFilter#setMfaEnabled(boolean)} on + * every authentication processing filter. Without this, completing a second factor would REPLACE the first factor's + * authentication (dropping its authority) and the user could never satisfy all required factors (the H4 lockout). + * + *

    + * Declared {@code static} so the post-processor can be registered without eagerly instantiating this configuration + * class. The bean exists only when {@code user.mfa.enabled=true}, so the default (no-MFA) login path is unaffected. + *

    + * + * @return a {@link BeanPostProcessor} that calls {@code setMfaEnabled(true)} on authentication processing filters + */ + @Bean + public static BeanPostProcessor mfaFilterMergingPostProcessor() { + return new BeanPostProcessor() { + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) { + // Intentionally scoped to AbstractAuthenticationProcessingFilter, which covers every authentication + // mechanism this framework configures: formLogin, webAuthn, and oauth2Login all extend it. SS's internal + // EnableMfaFiltersPostProcessor additionally flips the flag on AuthenticationFilter, + // BasicAuthenticationFilter, and pre-authentication filters; this framework does not configure those + // mechanisms, so they are deliberately not targeted here. See the class-level WARNING regarding + // consumer-defined filters that extend this base class. + if (bean instanceof AbstractAuthenticationProcessingFilter filter) { + filter.setMfaEnabled(true); + log.debug("MFA factor merging enabled on filter: {}", bean.getClass().getName()); + } + return bean; + } + }; + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandler.java b/src/main/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandler.java new file mode 100644 index 00000000..1d9d2923 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandler.java @@ -0,0 +1,128 @@ +package com.digitalsanctuary.spring.user.security; + +import java.io.IOException; +import org.springframework.security.authentication.AccountStatusException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import lombok.extern.slf4j.Slf4j; + +/** + * OAuth2/OIDC login failure handler that stores a GENERIC, user-safe message in the session for the UI to + * display, while logging the real exception detail server-side. + * + *

    + * Raw {@link AuthenticationException} messages can leak sensitive detail to the browser. In particular, the + * {@code LockedException}/{@code DisabledException} messages thrown during OAuth2/OIDC login (see Task 1.4) + * embed the account email, and provider-conflict messages reveal which provider an account is registered with. + * This handler ensures none of that raw detail reaches the user-facing {@code error.message} session attribute. + *

    + * + *

    + * The full exception (including its message and stack trace) is logged at {@code error}/{@code debug} level so + * operators retain the diagnostic detail server-side, where it is an acceptable place for it. + *

    + */ +@Slf4j +public class SanitizingOAuth2AuthenticationFailureHandler implements AuthenticationFailureHandler { + + /** Session attribute key the login page reads to display an error message. */ + public static final String ERROR_MESSAGE_SESSION_ATTRIBUTE = "error.message"; + + /** OAuth2 error code raised when a provider explicitly reports the email is not verified. */ + static final String EMAIL_NOT_VERIFIED_ERROR_CODE = "email_not_verified"; + + /** Generic message shown to the user for any unspecified authentication failure. */ + public static final String GENERIC_FAILURE_MESSAGE = "Authentication failed. Please try again."; + + /** Slightly more specific (but still non-sensitive) message for unverified-email failures. */ + public static final String EMAIL_NOT_VERIFIED_MESSAGE = + "Your email address is not verified with your login provider. Please verify it and try again."; + + /** The login page URI to redirect to after a failure. */ + private final String loginPageURI; + + /** + * Creates a new handler. + * + * @param loginPageURI the URI to redirect the user back to after a failed login + */ + public SanitizingOAuth2AuthenticationFailureHandler(String loginPageURI) { + this.loginPageURI = loginPageURI; + } + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) + throws IOException, ServletException { + // Avoid placing potentially-sensitive raw exception messages into the ERROR/WARN log streams that + // commonly feed SIEM/centralized logging. LockedException/DisabledException messages can embed the + // account email, and a registration_denied error can carry a guard-specific reason. So we log only a + // NON-sensitive summary (exception type plus, for OAuth2 failures, the error code) at WARN for the + // expected failure categories, reserving ERROR for genuinely unexpected failures. The full exception + // (including its message and stack trace) remains available at DEBUG for operators who opt in. + if (isExpectedFailure(exception)) { + log.warn("OAuth2 login failure ({}): {}", exception.getClass().getSimpleName(), nonSensitiveDetail(exception)); + } else { + log.error("Unexpected OAuth2 login failure ({})", exception.getClass().getSimpleName()); + } + log.debug("OAuth2 login failure detail", exception); + + // Store ONLY a generic, non-sensitive message for the UI. Never the raw exception message. + // Use getSession(false) so we do not allocate a session for callers that do not already have one: + // a legitimate OAuth2 attempt establishes a session during the authorization-request phase, while an + // unauthenticated scanner hitting the callback cold has none and should not be able to force session + // creation. When there is no session the login page simply shows its default message. + HttpSession session = request.getSession(false); + if (session != null) { + session.setAttribute(ERROR_MESSAGE_SESSION_ATTRIBUTE, resolveUserFacingMessage(exception)); + } + response.sendRedirect(loginPageURI); + } + + /** + * Identifies authentication failures that are expected during normal operation (a locked/disabled account, a + * provider-conflict, an unverified email, or a denied registration) and therefore warrant only a WARN-level, + * non-sensitive log line rather than an ERROR. + * + * @param exception the authentication failure + * @return {@code true} if this is an expected failure category + */ + private boolean isExpectedFailure(AuthenticationException exception) { + return exception instanceof AccountStatusException || exception instanceof OAuth2AuthenticationException; + } + + /** + * Produces a non-sensitive description of the failure for logging. For OAuth2 failures this is the error code + * (a fixed identifier such as {@code email_not_verified} or {@code registration_denied}); the error description + * and the raw exception message are intentionally excluded because they can contain PII (e.g. an account email). + * + * @param exception the authentication failure + * @return a non-sensitive, log-safe description + */ + private String nonSensitiveDetail(AuthenticationException exception) { + if (exception instanceof OAuth2AuthenticationException oauth2Exception && oauth2Exception.getError() != null) { + return "error=" + oauth2Exception.getError().getErrorCode(); + } + return "see DEBUG log for detail"; + } + + /** + * Maps an authentication failure to a safe, user-facing message. A small number of failure categories map to + * a slightly more helpful (but still non-sensitive) message; everything else falls back to a fixed generic + * message. + * + * @param exception the authentication failure + * @return a generic, non-sensitive message safe to display to the user + */ + private String resolveUserFacingMessage(AuthenticationException exception) { + if (exception instanceof OAuth2AuthenticationException oauth2Exception && oauth2Exception.getError() != null + && EMAIL_NOT_VERIFIED_ERROR_CODE.equals(oauth2Exception.getError().getErrorCode())) { + return EMAIL_NOT_VERIFIED_MESSAGE; + } + return GENERIC_FAILURE_MESSAGE; + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java new file mode 100644 index 00000000..34c506a3 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java @@ -0,0 +1,169 @@ +package com.digitalsanctuary.spring.user.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; +import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; +import org.springframework.security.access.hierarchicalroles.RoleHierarchy; +import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; +import org.springframework.security.authentication.AuthenticationEventPublisher; +import org.springframework.security.authentication.DefaultAuthenticationEventPublisher; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.session.SessionRegistryImpl; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.session.HttpSessionEventPublisher; +import com.digitalsanctuary.spring.user.UserConfiguration; +import com.digitalsanctuary.spring.user.roles.RolesAndPrivilegesConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Auto-configuration that contributes the library's core, consumer-overridable security beans: + * {@link PasswordEncoder}, {@link SessionRegistry}, {@link RoleHierarchy}, and {@link DaoAuthenticationProvider}. + * + *

    + * Each bean is guarded by {@link ConditionalOnMissingBean}, so a consuming application can fully replace any of them simply by defining their own bean + * of the same type — for example, to swap {@code BCryptPasswordEncoder} for an Argon2 or delegating encoder, supply a different + * {@link SessionRegistry} implementation, or provide a custom {@link RoleHierarchy}. When the consumer defines no such bean, the library's default + * applies and behavior is unchanged. + *

    + * + *

    + * These beans live on an {@code @AutoConfiguration} class — rather than directly as {@code @Bean} methods on the component-scanned + * {@link WebSecurityConfig} — precisely because {@code @ConditionalOnMissingBean} is only reliable on auto-configuration classes, which are + * guaranteed to load AFTER user-defined bean definitions. Placing the conditional on a component-scanned {@code @Configuration} would evaluate it too + * early and could suppress the consumer's override or cause a bean-definition conflict (the H8 finding). This mirrors the pattern established for the + * library's {@link org.springframework.security.web.SecurityFilterChain} in {@link WebSecurityFilterChainAutoConfiguration}. + *

    + * + *

    + * {@code authProvider()} intentionally receives the effective {@link PasswordEncoder} as a method parameter rather than calling + * {@code encoder()} directly. Because Spring proxies {@code @Configuration}/{@code @AutoConfiguration} classes, a self-call to {@code encoder()} would + * always return the library's bean even when a consumer overrode the {@link PasswordEncoder}. Injecting the parameter lets Spring supply the consumer's + * encoder when present, so the authentication provider honors the override. + *

    + */ +@Slf4j +@AutoConfiguration(after = UserConfiguration.class) +@RequiredArgsConstructor +public class UserSecurityBeansAutoConfiguration { + + private final UserDetailsService userDetailsService; + private final RolesAndPrivilegesConfig rolesAndPrivilegesConfig; + + @Value("${user.security.bcryptStrength:10}") + private int bcryptStrength; + + /** + * Creates the library's default {@link PasswordEncoder}, a {@link BCryptPasswordEncoder} using the configured {@code user.security.bcryptStrength}. + * Backs off entirely if the consuming application defines its own {@link PasswordEncoder}. + * + * @return the default {@link BCryptPasswordEncoder} + */ + @Bean + @ConditionalOnMissingBean(PasswordEncoder.class) + public PasswordEncoder encoder() { + return new BCryptPasswordEncoder(bcryptStrength); + } + + /** + * Creates the library's default {@link SessionRegistry}, a {@link SessionRegistryImpl}. Backs off entirely if the consuming application defines its + * own {@link SessionRegistry}. + * + * @return the default {@link SessionRegistryImpl} + */ + @Bean + @ConditionalOnMissingBean(SessionRegistry.class) + public SessionRegistry sessionRegistry() { + return new SessionRegistryImpl(); + } + + /** + * Creates the library's default {@link RoleHierarchy} from the {@code roleHierarchyString} in {@link RolesAndPrivilegesConfig}. Returns + * {@code null} (no role hierarchy) when the configuration is absent or empty — preserving the historical behavior. Backs off entirely if the + * consuming application defines its own {@link RoleHierarchy}. + * + * @return the configured {@link RoleHierarchyImpl}, or {@code null} when no hierarchy is configured + */ + @Bean + @ConditionalOnMissingBean(RoleHierarchy.class) + public RoleHierarchy roleHierarchy() { + if (rolesAndPrivilegesConfig == null) { + log.error("UserSecurityBeansAutoConfiguration.roleHierarchy: rolesAndPrivilegesConfig is null!"); + return null; + } + if (rolesAndPrivilegesConfig.getRoleHierarchyString() == null) { + log.error("UserSecurityBeansAutoConfiguration.roleHierarchy: rolesAndPrivilegesConfig.getRoleHierarchyString() is null!"); + return null; + } + RoleHierarchyImpl roleHierarchy = RoleHierarchyImpl.fromHierarchy(rolesAndPrivilegesConfig.getRoleHierarchyString()); + log.debug("UserSecurityBeansAutoConfiguration.roleHierarchy: roleHierarchy: {}", roleHierarchy.toString()); + return roleHierarchy; + } + + /** + * Creates the library's default {@link DaoAuthenticationProvider}, wiring in the {@link UserDetailsService} and the effective + * {@link PasswordEncoder}. The encoder is received as a method parameter (not via a self-call to {@code encoder()}) so a consumer-supplied + * {@link PasswordEncoder} is honored. Backs off entirely if the consuming application defines its own {@link DaoAuthenticationProvider}. + * + * @param passwordEncoder the effective {@link PasswordEncoder} (the consumer's bean if present, otherwise the library default) + * @return the default {@link DaoAuthenticationProvider} + */ + @Bean + @ConditionalOnMissingBean(DaoAuthenticationProvider.class) + public DaoAuthenticationProvider authProvider(PasswordEncoder passwordEncoder) { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(userDetailsService); + authProvider.setPasswordEncoder(passwordEncoder); + return authProvider; + } + + /** + * Creates a {@link MethodSecurityExpressionHandler} wired with the configured {@link RoleHierarchy} so method + * security annotations (e.g. {@code @PreAuthorize}) honor the role hierarchy. Declared {@code static} so it is + * available to the method-security infrastructure during early initialization. Backs off entirely if the + * consuming application defines its own {@link MethodSecurityExpressionHandler}. + * + * @param roleHierarchy the effective {@link RoleHierarchy} (may be {@code null} when none is configured) + * @return the configured {@link MethodSecurityExpressionHandler} + */ + @Bean + @ConditionalOnMissingBean(MethodSecurityExpressionHandler.class) + static MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) { + DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler(); + expressionHandler.setRoleHierarchy(roleHierarchy); + return expressionHandler; + } + + /** + * Creates the {@link HttpSessionEventPublisher} that bridges servlet {@code HttpSession} lifecycle events into + * the Spring event system (required for {@link SessionRegistry}-based concurrent-session tracking). Backs off + * entirely if the consuming application defines its own {@link HttpSessionEventPublisher}. + * + * @return the {@link HttpSessionEventPublisher} + */ + @Bean + @ConditionalOnMissingBean(HttpSessionEventPublisher.class) + public HttpSessionEventPublisher httpSessionEventPublisher() { + return new HttpSessionEventPublisher(); + } + + /** + * Publishes Spring Security authentication events to the application event system so listeners can react to + * successful/failed authentication. Backs off entirely if the consuming application defines its own + * {@link AuthenticationEventPublisher}. + * + * @param applicationEventPublisher the Spring {@link ApplicationEventPublisher} + * @return the default {@link AuthenticationEventPublisher} + */ + @Bean + @ConditionalOnMissingBean(AuthenticationEventPublisher.class) + public AuthenticationEventPublisher authenticationEventPublisher(ApplicationEventPublisher applicationEventPublisher) { + return new DefaultAuthenticationEventPublisher(applicationEventPublisher); + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandler.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandler.java index fc1642cd..ca9c7a0d 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandler.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandler.java @@ -1,6 +1,8 @@ package com.digitalsanctuary.spring.user.security; import java.io.IOException; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.authentication.event.InteractiveAuthenticationSuccessEvent; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; @@ -31,6 +33,22 @@ * The handler delegates to {@link HttpMessageConverterAuthenticationSuccessHandler} to write the JSON response expected by the WebAuthn JavaScript * client ({@code {"authenticated": true, "redirectUrl": "..."}}). *

    + * + *

    + * After converting the principal, this handler publishes an additional {@link InteractiveAuthenticationSuccessEvent} carrying the converted + * authentication. Spring Security's {@code AbstractAuthenticationProcessingFilter} (the superclass of {@code WebAuthnAuthenticationFilter}) already + * publishes an {@code InteractiveAuthenticationSuccessEvent} for passkey logins, but it carries the raw {@code WebAuthnAuthentication} whose principal + * is a {@code PublicKeyCredentialUserEntity}. The framework's {@code BaseAuthenticationListener} (which loads the session-scoped user profile) ignores + * that event because it requires a {@code DSUserDetails} principal. This handler therefore publishes a second event carrying the converted + * {@code DSUserDetails} so that {@code BaseAuthenticationListener} fires for passkey logins exactly as it does for form and OAuth2 logins. + *

    + * + *

    + * Consequence: two {@code InteractiveAuthenticationSuccessEvent}s are emitted per passkey login (the framework's, with the raw principal, and this + * handler's, with {@code DSUserDetails}). The framework's own listeners are principal-type-guarded and unaffected, but a principal-agnostic consumer + * {@code @EventListener(InteractiveAuthenticationSuccessEvent.class)} would observe both. Note this event does not reset brute-force counters; + * those are driven by {@code AuthenticationSuccessEvent} (a sibling event), not by {@code InteractiveAuthenticationSuccessEvent}. + *

    */ @Slf4j public class WebAuthnAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @@ -39,6 +57,7 @@ public class WebAuthnAuthenticationSuccessHandler implements AuthenticationSucce private final AuthenticationSuccessHandler delegate; private final SecurityContextRepository securityContextRepository; private final SecurityContextHolderStrategy securityContextHolderStrategy; + private final ApplicationEventPublisher eventPublisher; /** * Creates a new handler with the given {@code UserDetailsService} and a default {@link HttpMessageConverterAuthenticationSuccessHandler} delegate. @@ -46,7 +65,18 @@ public class WebAuthnAuthenticationSuccessHandler implements AuthenticationSucce * @param userDetailsService the service to load the full user details */ public WebAuthnAuthenticationSuccessHandler(UserDetailsService userDetailsService) { - this(userDetailsService, new HttpMessageConverterAuthenticationSuccessHandler()); + this(userDetailsService, new HttpMessageConverterAuthenticationSuccessHandler(), null); + } + + /** + * Creates a new handler with the given {@code UserDetailsService} and event publisher, using a default + * {@link HttpMessageConverterAuthenticationSuccessHandler} delegate. + * + * @param userDetailsService the service to load the full user details + * @param eventPublisher the publisher used to fire an {@link InteractiveAuthenticationSuccessEvent} on successful WebAuthn login (may be null) + */ + public WebAuthnAuthenticationSuccessHandler(UserDetailsService userDetailsService, ApplicationEventPublisher eventPublisher) { + this(userDetailsService, new HttpMessageConverterAuthenticationSuccessHandler(), eventPublisher); } /** @@ -56,10 +86,23 @@ public WebAuthnAuthenticationSuccessHandler(UserDetailsService userDetailsServic * @param delegate the handler to delegate to after principal conversion */ public WebAuthnAuthenticationSuccessHandler(UserDetailsService userDetailsService, AuthenticationSuccessHandler delegate) { + this(userDetailsService, delegate, null); + } + + /** + * Creates a new handler with the given {@code UserDetailsService}, delegate handler, and event publisher. + * + * @param userDetailsService the service to load the full user details + * @param delegate the handler to delegate to after principal conversion + * @param eventPublisher the publisher used to fire an {@link InteractiveAuthenticationSuccessEvent} on successful WebAuthn login (may be null) + */ + public WebAuthnAuthenticationSuccessHandler(UserDetailsService userDetailsService, AuthenticationSuccessHandler delegate, + ApplicationEventPublisher eventPublisher) { this.userDetailsService = userDetailsService; this.delegate = delegate; this.securityContextRepository = new HttpSessionSecurityContextRepository(); this.securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy(); + this.eventPublisher = eventPublisher; } @Override @@ -84,6 +127,19 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo securityContextRepository.saveContext(context, request, response); log.info("WebAuthn authentication principal converted to DSUserDetails for user: {}", username); + + // AbstractAuthenticationProcessingFilter (WebAuthnAuthenticationFilter's superclass) already publishes an + // InteractiveAuthenticationSuccessEvent for this login, but it carries the raw WebAuthnAuthentication whose + // principal is a PublicKeyCredentialUserEntity, which BaseAuthenticationListener ignores (it requires a + // DSUserDetails principal). Publish an additional event here with the converted DSUserDetails-bearing + // authentication so BaseAuthenticationListener loads the session profile for passkey logins, just as it does + // for form and OAuth2 logins. This emits two InteractiveAuthenticationSuccessEvents per passkey login; the + // framework's listeners are principal-type-guarded, but principal-agnostic consumer listeners would see both. + // This event does not reset brute-force counters (those react to AuthenticationSuccessEvent, a sibling event). + if (eventPublisher != null) { + eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(convertedAuth, this.getClass())); + } + delegate.onAuthenticationSuccess(request, response, convertedAuth); } else { delegate.onAuthenticationSuccess(request, response, authentication); diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java index 5f5b52ae..3fcc7902 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java @@ -12,28 +12,15 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; -import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; -import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; -import org.springframework.security.access.hierarchicalroles.RoleHierarchy; -import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; -import org.springframework.security.authentication.AuthenticationEventPublisher; -import org.springframework.security.authentication.DefaultAuthenticationEventPublisher; -import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.ObjectPostProcessor; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.session.SessionRegistry; -import org.springframework.security.core.session.SessionRegistryImpl; import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.DelegatingMissingAuthorityAccessDeniedHandler; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; -import org.springframework.security.web.session.HttpSessionEventPublisher; import org.springframework.security.web.webauthn.authentication.WebAuthnAuthenticationFilter; -import com.digitalsanctuary.spring.user.roles.RolesAndPrivilegesConfig; import com.digitalsanctuary.spring.user.service.DSOAuth2UserService; import com.digitalsanctuary.spring.user.service.DSOidcUserService; import com.digitalsanctuary.spring.user.service.LoginSuccessService; @@ -53,7 +40,6 @@ @EqualsAndHashCode(callSuper = false) @Configuration @RequiredArgsConstructor -@EnableWebSecurity public class WebSecurityConfig { @@ -111,9 +97,6 @@ public class WebSecurityConfig { @Value("${spring.security.oauth2.enabled:false}") private boolean oauth2Enabled; - @Value("${user.security.bcryptStrength}") - private int bcryptStrength = 10; - @Value("${user.security.rememberMe.enabled:false}") private boolean rememberMeEnabled; @@ -127,23 +110,28 @@ public class WebSecurityConfig { private final UserDetailsService userDetailsService; private final LoginSuccessService loginSuccessService; private final LogoutSuccessService logoutSuccessService; - private final RolesAndPrivilegesConfig rolesAndPrivilegesConfig; private final DSOAuth2UserService dsOAuth2UserService; private final DSOidcUserService dsOidcUserService; private final WebAuthnConfigProperties webAuthnConfigProperties; private final MfaConfigProperties mfaConfigProperties; private final Environment environment; + private final ApplicationEventPublisher applicationEventPublisher; /** - * - * The securityFilterChain method builds the security filter chain for Spring Security. + * Builds the library's security filter chain for Spring Security. + *

    + * This method is invoked by {@link WebSecurityFilterChainAutoConfiguration}, which exposes the result as a {@link SecurityFilterChain} bean at a + * low precedence and backs off entirely when the consuming application defines its own {@link SecurityFilterChain}. It is intentionally NOT a + * {@code @Bean} method here: {@code @ConditionalOnMissingBean} is only reliable on auto-configuration classes (which load after user-defined + * beans), so the conditional/ordering lives on the auto-configuration class rather than on this component-scanned {@code @Configuration}. + *

    * * @param http the HttpSecurity object + * @param sessionRegistry the SessionRegistry used to track active sessions * @return the SecurityFilterChain object * @throws Exception if there is an issue creating the SecurityFilterChain */ - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain buildSecurityFilterChain(HttpSecurity http, SessionRegistry sessionRegistry) throws Exception { log.debug("WebSecurityConfig.configure: user.security.defaultAction: {}", getDefaultAction()); log.debug("WebSecurityConfig.configure: unprotectedURIs: {}", Arrays.toString(getUnprotectedURIsArray())); List unprotectedURIs = getUnprotectedURIsList(); @@ -160,9 +148,17 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti http.rememberMe(rememberMe -> rememberMe.key(rememberMeKey).userDetailsService(userDetailsService)); } - http.logout(logout -> logout.logoutUrl(logoutActionURI).logoutSuccessUrl(logoutSuccessURI).invalidateHttpSession(true) + // Use the LogoutSuccessService handler (instead of logoutSuccessUrl) so logout publishes an audit event. + // The handler still redirects to logoutSuccessURI (see LogoutSuccessService.onLogoutSuccess). + http.logout(logout -> logout.logoutUrl(logoutActionURI).logoutSuccessHandler(logoutSuccessService).invalidateHttpSession(true) .deleteCookies("JSESSIONID")); + // Register sessions in the SessionRegistry so SessionInvalidationService and concurrent-session + // features actually work. maximumSessions(-1) = unlimited concurrent sessions, but still tracked + // in the registry. The SessionRegistry is injected (rather than calling the local bean method) so + // consumers and tests can override it via a @Primary / @ConditionalOnMissingBean bean. + http.sessionManagement(session -> session.maximumSessions(-1).sessionRegistry(sessionRegistry)); + // If we have URIs to disable CSRF validation on, do so here String[] baseDisableCSRFURIs = getDisableCSRFURIsArray(); List csrfIgnoreList = new ArrayList<>(Arrays.asList(baseDisableCSRFURIs)); @@ -216,13 +212,12 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti * @throws Exception the exception */ private void setupOAuth2(HttpSecurity http) throws Exception { - // Entry point is handled globally in securityFilterChain via the injected authenticationEntryPoint bean - http.oauth2Login(o -> o.loginPage(loginPageURI).successHandler(loginSuccessService).failureHandler((request, response, exception) -> { - log.error("WebSecurityConfig.configure: OAuth2 login failure: {}", exception.getMessage()); - request.getSession().setAttribute("error.message", exception.getMessage()); - response.sendRedirect(loginPageURI); - // handler.onAuthenticationFailure(request, response, exception); - }).userInfoEndpoint(userInfo -> { + // Entry point is handled globally in securityFilterChain via the injected authenticationEntryPoint bean. + // The failure handler stores only a GENERIC message in the session for the UI (raw exception messages can + // leak account emails from Locked/Disabled exceptions and the registered provider from conflict errors); + // the real detail is logged server-side by the handler itself. + http.oauth2Login(o -> o.loginPage(loginPageURI).successHandler(loginSuccessService) + .failureHandler(new SanitizingOAuth2AuthenticationFailureHandler(loginPageURI)).userInfoEndpoint(userInfo -> { userInfo.userService(dsOAuth2UserService); userInfo.oidcUserService(dsOidcUserService); })); @@ -290,7 +285,7 @@ private ObjectPostProcessor webAuthnSuccessHandler return new ObjectPostProcessor() { @Override public O postProcess(O filter) { - filter.setAuthenticationSuccessHandler(new WebAuthnAuthenticationSuccessHandler(userDetailsService)); + filter.setAuthenticationSuccessHandler(new WebAuthnAuthenticationSuccessHandler(userDetailsService, applicationEventPublisher)); return filter; } }; @@ -323,96 +318,28 @@ private List getUnprotectedURIsList() { } if (mfaConfigProperties.isEnabled()) { unprotectedURIs.add("/user/mfa/status"); + // A partially-authenticated user (one factor satisfied) is redirected to the configured factor + // entry-point page(s) to complete the remaining factor(s). Those pages MUST be reachable without + // full authentication; otherwise the redirect target is itself denied and the user loops between + // entry points (ERR_TOO_MANY_REDIRECTS). Auto-unprotect the configured entry-point URIs so a + // consuming app does not have to remember to list them manually. + addIfHasText(unprotectedURIs, mfaConfigProperties.getPasswordEntryPointUri()); + addIfHasText(unprotectedURIs, mfaConfigProperties.getWebauthnEntryPointUri()); } unprotectedURIs.removeAll(Collections.emptyList()); return unprotectedURIs; } /** - * The authProvider method creates a DaoAuthenticationProvider and sets the UserDetailsService and PasswordEncoder for the provider. - * - * @return the DaoAuthenticationProvider object - */ - @Bean - public DaoAuthenticationProvider authProvider() { - DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(userDetailsService); - authProvider.setPasswordEncoder(encoder()); - return authProvider; - } - - /** - * The encoder method creates a BCryptPasswordEncoder with the bcryptStrength value. - * - * @return the BCryptPasswordEncoder object - */ - @Bean - public PasswordEncoder encoder() { - return new BCryptPasswordEncoder(bcryptStrength); - } - - /** - * The sessionRegistry method creates a SessionRegistryImpl object. - * - * @return the SessionRegistryImpl object - */ - @Bean - public SessionRegistry sessionRegistry() { - return new SessionRegistryImpl(); - } - - /** - * The roleHierarchy method creates a RoleHierarchyImpl object from the roleHierarchyString in the rolesAndPrivilegesConfig object. + * Adds the given URI to the list only when it is non-null and not blank. * - * @return the RoleHierarchyImpl object + * @param uris the list to add to + * @param uri the candidate URI (may be {@code null} or blank) */ - @Bean - public RoleHierarchy roleHierarchy() { - if (rolesAndPrivilegesConfig == null) { - log.error("WebSecurityConfig.roleHierarchy: rolesAndPrivilegesConfig is null!"); - return null; - } - if (rolesAndPrivilegesConfig.getRoleHierarchyString() == null) { - log.error("WebSecurityConfig.roleHierarchy: rolesAndPrivilegesConfig.getRoleHierarchyString() is null!"); - return null; + private void addIfHasText(List uris, String uri) { + if (uri != null && !uri.isBlank()) { + uris.add(uri); } - RoleHierarchyImpl roleHierarchy = RoleHierarchyImpl.fromHierarchy(rolesAndPrivilegesConfig.getRoleHierarchyString()); - log.debug("WebSecurityConfig.roleHierarchy: roleHierarchy: {}", roleHierarchy.toString()); - return roleHierarchy; - } - - /** - * The methodSecurityExpressionHandler method creates a MethodSecurityExpressionHandler object and sets the roleHierarchy for the handler. This - * ensures that method security annotations like @PreAuthorize use the configured role hierarchy. - * - * @return the MethodSecurityExpressionHandler object - */ - @Bean - static MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) { - DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler(); - expressionHandler.setRoleHierarchy(roleHierarchy); - return expressionHandler; - } - - /** - * The httpSessionEventPublisher method creates an HttpSessionEventPublisher object. - * - * @return the HttpSessionEventPublisher object - */ - @Bean - public HttpSessionEventPublisher httpSessionEventPublisher() { - return new HttpSessionEventPublisher(); - } - - /** - * This is required to publish authentication events to the Spring event system. This allows us to listen for authentication events and perform - * actions based on successful or failed authentication. - * - * @param applicationEventPublisher the Spring ApplicationEventPublisher - * @return the Spring Security default AuthenticationEventPublisher - */ - @Bean - public AuthenticationEventPublisher authenticationEventPublisher(ApplicationEventPublisher applicationEventPublisher) { - return new DefaultAuthenticationEventPublisher(applicationEventPublisher); } /** diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityFilterChainAutoConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityFilterChainAutoConfiguration.java new file mode 100644 index 00000000..de6e62fa --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityFilterChainAutoConfiguration.java @@ -0,0 +1,80 @@ +package com.digitalsanctuary.spring.user.security; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.web.SecurityFilterChain; +import com.digitalsanctuary.spring.user.UserConfiguration; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Auto-configuration that contributes the library's {@link SecurityFilterChain}. + * + *

    + * The chain is contributed at a low precedence ({@link #SECURITY_FILTER_CHAIN_ORDER}) so it acts as the catch-all chain. The back-off is keyed on the + * bean name {@code securityFilterChain} (via {@link ConditionalOnMissingBean}), which supports two distinct consumer scenarios: + *

    + *
      + *
    • Add additional, narrower chains alongside the library's (the common case). A consumer can define one or more extra + * {@link SecurityFilterChain} beans with their own {@code @Order} and {@code securityMatcher} (e.g. an actuator-only or API-only chain). Because the + * conditional is name-based, those differently-named chains do not suppress the library chain — both coexist, and Spring Security's + * {@code FilterChainProxy} consults them in {@code @Order}. The narrower chain (higher precedence / lower order) handles its matched requests; the + * library chain remains the catch-all for everything else (form login, logout, CSRF, session management, WebAuthn, OAuth2).
    • + *
    • Fully replace the library's chain by defining a {@link SecurityFilterChain} bean named exactly {@code securityFilterChain}. That single + * named bean suppresses the library's chain, and the consumer then owns all security rules, including the library's protected URIs.
    • + *
    + * + *

    + * Why name-based rather than type-based: a type-based {@code @ConditionalOnMissingBean(SecurityFilterChain.class)} would back off as soon as the + * consumer defined any chain — even a narrow one — silently suppressing the entire library chain and leaving the library's URIs + * unprotected. Keying on the {@code securityFilterChain} bean name preserves the standard Spring Security multi-chain {@code @Order} layering pattern, + * while still giving consumers a clear, explicit way to opt into a full replacement (name your replacement bean {@code securityFilterChain}). + *

    + * + *

    + * The actual chain-building logic lives in {@link WebSecurityConfig#buildSecurityFilterChain(HttpSecurity, SessionRegistry)}. It is exposed via this + * auto-configuration (rather than directly as a {@code @Bean} on {@link WebSecurityConfig}) because {@code @ConditionalOnMissingBean} is only reliable + * on auto-configuration classes, which are guaranteed to load after any user-defined bean definitions. Placing the conditional on a + * component-scanned {@code @Configuration} bean method would evaluate it too early and could suppress the library chain incorrectly. + *

    + */ +@Slf4j +@EnableWebSecurity +@AutoConfiguration(after = UserConfiguration.class) +@RequiredArgsConstructor +public class WebSecurityFilterChainAutoConfiguration { + + /** + * Order of the library's {@link SecurityFilterChain}. This is a low precedence (high numeric value) so that any consumer-supplied chain with a + * lower {@code @Order} takes precedence. The value is sourced from {@link SecurityFilterProperties#BASIC_AUTH_ORDER} + * ({@code Ordered.LOWEST_PRECEDENCE - 5}), so the library's chain sits at the same low precedence as Spring Boot's own default servlet security + * chain and always loses to consumer chains. This constant was historically exposed as {@code SecurityProperties.BASIC_AUTH_ORDER} in Spring Boot + * 3.x; in Spring Boot 4.0 it was relocated to {@link SecurityFilterProperties#BASIC_AUTH_ORDER} (still {@code Ordered.LOWEST_PRECEDENCE - 5}). + */ + public static final int SECURITY_FILTER_CHAIN_ORDER = SecurityFilterProperties.BASIC_AUTH_ORDER; + + private final WebSecurityConfig webSecurityConfig; + + /** + * Exposes the library's {@link SecurityFilterChain} bean, delegating construction to + * {@link WebSecurityConfig#buildSecurityFilterChain(HttpSecurity, SessionRegistry)}. + * + * @param http the shared {@link HttpSecurity} builder + * @param sessionRegistry the {@link SessionRegistry} used to track active sessions + * @return the library's configured {@link SecurityFilterChain} + * @throws Exception if there is an issue creating the {@link SecurityFilterChain} + */ + @Bean + @Order(SECURITY_FILTER_CHAIN_ORDER) + @ConditionalOnMissingBean(name = "securityFilterChain") + public SecurityFilterChain securityFilterChain(HttpSecurity http, SessionRegistry sessionRegistry) throws Exception { + log.debug("WebSecurityFilterChainAutoConfiguration: contributing library SecurityFilterChain at order {}", SECURITY_FILTER_CHAIN_ORDER); + return webSecurityConfig.buildSecurityFilterChain(http, sessionRegistry); + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java index 6831f367..3cd10f5a 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java @@ -1,6 +1,7 @@ package com.digitalsanctuary.spring.user.service; import java.util.Arrays; +import java.util.Locale; import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; @@ -11,12 +12,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.digitalsanctuary.spring.user.audit.AuditEvent; +import com.digitalsanctuary.spring.user.event.OnRegistrationCompleteEvent; import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.persistence.repository.RoleRepository; import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; -import com.digitalsanctuary.spring.user.registration.RegistrationContext; -import com.digitalsanctuary.spring.user.registration.RegistrationDecision; -import com.digitalsanctuary.spring.user.registration.RegistrationGuard; +import com.digitalsanctuary.spring.user.registration.RegistrationDeniedException; import com.digitalsanctuary.spring.user.registration.RegistrationSource; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -47,7 +47,8 @@ public class DSOAuth2UserService implements OAuth2UserService + * Google's userinfo serializes {@code email_verified} as either a {@link Boolean} or a {@link String} + * ({@code "true"}/{@code "false"}) depending on the response format, so both are handled here. The value is + * treated as explicitly unverified only when it is {@link Boolean#FALSE} or the case-insensitive String + * {@code "false"}. An absent ({@code null}) claim is trusted and is NOT treated as unverified. + *

    + * + * @param emailVerified the raw {@code email_verified} attribute value (may be {@code Boolean}, {@code String}, or {@code null}) + * @return {@code true} only when the provider explicitly reports the email as not verified + */ + private boolean isExplicitlyUnverified(Object emailVerified) { + if (emailVerified == null) { + return false; + } + if (emailVerified instanceof Boolean booleanValue) { + return Boolean.FALSE.equals(booleanValue); + } + if (emailVerified instanceof String stringValue) { + return "false".equalsIgnoreCase(stringValue.trim()); + } + // Unknown type: trust (do not reject) rather than risk locking out legitimate users. + return false; + } + /** * Retrieves user information from a Facebook OAuth2User object. * @@ -182,11 +226,20 @@ public User getUserFromGoogleOAuth2User(OAuth2User principal) { * @return A User object representing the authenticated user. */ public User getUserFromFacebookOAuth2User(OAuth2User principal) { - log.debug("Getting user info from Facebook OAuth2 provider with principal: {}", principal); + log.debug("Getting user info from Facebook OAuth2 provider with principal: {}", principal != null ? principal.getName() : null); if (principal == null) { return null; } - log.debug("Principal attributes: {}", principal.getAttributes()); + log.debug("Principal attribute keys: {}", principal.getAttributes().keySet()); + // Reject the login if Facebook explicitly reports the email as NOT verified. Facebook exposes this + // under "email_verified" on newer Graph API versions and "verified" on older ones; either explicit + // false rejects. Providers that do not expose the claim at all are trusted (only an explicit false + // is rejected), matching the Google path's policy in getUserFromGoogleOAuth2User. + if (isExplicitlyUnverified(principal.getAttribute("email_verified")) || isExplicitlyUnverified(principal.getAttribute("verified"))) { + log.warn("getUserFromFacebookOAuth2User: rejecting login because Facebook reports the email as not verified"); + throw new OAuth2AuthenticationException(new OAuth2Error("email_not_verified"), + "Your email address is not verified with your login provider."); + } User user = new User(); String email = principal.getAttribute("email"); user.setEmail(email != null ? email.toLowerCase() : null); diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/DSOidcUserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/DSOidcUserService.java index 2c7b0d6e..676c2e25 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/DSOidcUserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/DSOidcUserService.java @@ -14,12 +14,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.digitalsanctuary.spring.user.audit.AuditEvent; +import com.digitalsanctuary.spring.user.event.OnRegistrationCompleteEvent; import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.persistence.repository.RoleRepository; import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; -import com.digitalsanctuary.spring.user.registration.RegistrationContext; -import com.digitalsanctuary.spring.user.registration.RegistrationDecision; -import com.digitalsanctuary.spring.user.registration.RegistrationGuard; +import com.digitalsanctuary.spring.user.registration.RegistrationDeniedException; import com.digitalsanctuary.spring.user.registration.RegistrationSource; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -53,7 +52,8 @@ public class DSOidcUserService implements OAuth2UserService 0) { - User user = userRepository.findByEmail(email); - if (user != null) { - incrementFailedAttempts(user); - } else { + // Atomically increment the counter via a single DB UPDATE to avoid the lost-update race that a read-modify-write would suffer under + // concurrent failed logins (which could let an attacker evade lockout). + int updated = userRepository.incrementFailedAttempts(email); + if (updated == 0) { log.warn("User not found for email: {}", email); + return; + } + // Re-read the fresh user; thanks to clearAutomatically on the bulk update, this reflects the true incremented count from the database. + User user = userRepository.findByEmail(email); + if (user != null && user.getFailedLoginAttempts() >= maxFailedLoginAttempts && !user.isLocked()) { + // Setting locked is idempotent if two threads both observe the threshold; the COUNTER is what must not lose updates. + user.setLocked(true); + user.setLockedDate(new Date()); + userRepository.save(user); } } } - /** - * Increment failed attempts. - * - * @param user the user - */ - private void incrementFailedAttempts(User user) { - int currentAttempts = user.getFailedLoginAttempts(); - user.setFailedLoginAttempts(++currentAttempts); - if (currentAttempts >= maxFailedLoginAttempts) { - user.setLocked(true); - user.setLockedDate(new Date()); - } - userRepository.save(user); - } - /** * Checks if the user account is locked. * diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/LoginHelperService.java b/src/main/java/com/digitalsanctuary/spring/user/service/LoginHelperService.java index c85c8094..823c086e 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/LoginHelperService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/LoginHelperService.java @@ -3,6 +3,8 @@ import java.util.Collection; import java.util.Date; import java.util.Map; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.LockedException; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.security.oauth2.core.oidc.OidcUserInfo; @@ -60,6 +62,9 @@ public DSUserDetails userLoginHelper(User dbUser, Map attributes // Check if the user account is locked, but should be unlocked now, and unlock it dbUser = loginAttemptService.checkIfUserShouldBeUnlocked(dbUser); + // Enforce account status for all authentication paths (form, OAuth2, OIDC, WebAuthn) + assertAccountUsable(dbUser); + Collection authorities = authorityService.getAuthoritiesFromUser(dbUser); return new DSUserDetails(dbUser, authorities, attributes); } @@ -96,7 +101,33 @@ public DSUserDetails userLoginHelper(User dbUser, OidcUserInfo oidcUserInfo, Oid // Check if the user account is locked, but should be unlocked now, and unlock it dbUser = loginAttemptService.checkIfUserShouldBeUnlocked(dbUser); + // Enforce account status for all authentication paths (form, OAuth2, OIDC, WebAuthn) + assertAccountUsable(dbUser); + Collection authorities = authorityService.getAuthoritiesFromUser(dbUser); return new DSUserDetails(dbUser, oidcUserInfo, oidcIdToken, authorities, attributes); } + + /** + * Verifies that the given user account is in a usable state for authentication. This enforces account status + * ({@code locked}/{@code enabled}) for every authentication path that flows through this helper, including + * OAuth2, OIDC, and WebAuthn (which load the user via {@link DSUserDetailsService}). Locked status is checked + * before disabled status so a locked account surfaces a {@link LockedException} even if it is also disabled. + * + * @param user the user to validate (after any auto-unlock has been applied) + * @throws LockedException if the account is locked + * @throws DisabledException if the account is disabled + */ + private void assertAccountUsable(User user) { + // Exception messages are intentionally generic (no PII): they can surface to WARN/ERROR logs and + // user-facing error flows via handlers we do not control. The email is captured only in DEBUG logs. + if (user.isLocked()) { + log.debug("Rejecting authentication for locked account: {}", user.getEmail()); + throw new LockedException("Account is locked"); + } + if (!user.isEnabled()) { + log.debug("Rejecting authentication for disabled account: {}", user.getEmail()); + throw new DisabledException("Account is disabled"); + } + } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/LoginSuccessService.java b/src/main/java/com/digitalsanctuary/spring/user/service/LoginSuccessService.java index 905ba76d..b5ee404f 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/LoginSuccessService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/LoginSuccessService.java @@ -57,7 +57,8 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo ServletException { log.debug("LoginSuccessService.onAuthenticationSuccess()"); log.debug("LoginSuccessService.onAuthenticationSuccess: called with request: {}", request); - log.debug("LoginSuccessService.onAuthenticationSuccess: called with authentication: {}", authentication); + log.debug("LoginSuccessService.onAuthenticationSuccess: called for user: {}", + authentication != null ? authentication.getName() : null); // Enhanced logging to check request attributes log.debug("Request URI: {}", request.getRequestURI()); @@ -72,12 +73,12 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo User user = null; if (authentication != null && authentication.getPrincipal() != null) { - log.debug("LoginSuccessService.onAuthenticationSuccess() authentication.getPrincipal(): " + authentication.getPrincipal()); + log.debug("LoginSuccessService.onAuthenticationSuccess() user: {}", authentication.getName()); log.debug("LoginSuccessService.onAuthenticatonSuccess() authentication.getClass(): " + authentication.getClass()); log.debug("LoginSuccessService.onAuthenticationSuccess() authentication.getPrincipal().getClass(): " + authentication.getPrincipal().getClass()); if (authentication.getPrincipal() instanceof DSUserDetails) { - log.debug("LoginSuccessService.onAuthenticationSuccess: DSUserDetails: {}", authentication.getPrincipal()); + log.debug("LoginSuccessService.onAuthenticationSuccess: DSUserDetails for user: {}", authentication.getName()); user = ((DSUserDetails) authentication.getPrincipal()).getUser(); } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/LogoutSuccessService.java b/src/main/java/com/digitalsanctuary/spring/user/service/LogoutSuccessService.java index 4e81131a..9644d80e 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/LogoutSuccessService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/LogoutSuccessService.java @@ -51,7 +51,8 @@ public class LogoutSuccessService extends SimpleUrlLogoutSuccessHandler { public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { log.debug("LogoutSuccessService.onLogoutSuccess: called."); - log.debug("LogoutSuccessService.onAuthenticationSuccess: called with authentiation: {}", authentication); + log.debug("LogoutSuccessService.onLogoutSuccess: called for user: {}", + authentication != null ? authentication.getName() : null); log.debug("LogoutSuccessService.onAuthenticationSuccess: targetUrl: {}", super.determineTargetUrl(request, response)); User user = null; diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/SessionInvalidationService.java b/src/main/java/com/digitalsanctuary/spring/user/service/SessionInvalidationService.java index a1ff7942..a3ae6c32 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/SessionInvalidationService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/SessionInvalidationService.java @@ -5,16 +5,23 @@ import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.session.SessionRegistry; import org.springframework.stereotype.Service; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; import com.digitalsanctuary.spring.user.persistence.model.User; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** * Service for invalidating user sessions. * - *

    Provides functionality to invalidate all active sessions for a given user, useful for + *

    Provides functionality to invalidate active sessions for a given user, useful for * admin-initiated password resets and other security operations that require forcing users - * to re-authenticate.

    + * to re-authenticate. {@link #invalidateUserSessions(User)} terminates every session for the user; + * {@link #invalidateSessionsAfterPasswordChange(User)} applies the self-service password-change policy, which by + * default preserves and regenerates the user's current session while invalidating their other sessions.

    * *

    Race Condition Note: This service uses Spring's SessionRegistry to track * and invalidate sessions. Due to the nature of the SessionRegistry API, there is an inherent @@ -37,6 +44,15 @@ public class SessionInvalidationService { @Value("${user.session.invalidation.warn-threshold:1000}") private int warnThreshold; + /** + * When {@code true} (the default), a self-service password change preserves the user's current session + * (regenerating its id to mitigate session fixation) and invalidates only the user's other sessions, so + * the user stays logged in after changing their own password. When {@code false}, every session for the user is + * invalidated, including the current one (the pre-4.x behavior), forcing an immediate re-login. + */ + @Value("${user.session.invalidation.keep-current-session-on-password-change:true}") + private boolean keepCurrentSessionOnPasswordChange; + /** * Invalidates all active sessions for the given user. * This forces the user to re-authenticate on their next request. @@ -76,11 +92,8 @@ public int invalidateUserSessions(User user) { for (SessionInformation session : sessions) { session.expireNow(); invalidatedCount++; - // Log truncated session ID to avoid exposing full session identifiers - String sessionId = session.getSessionId(); - String safeSessionId = sessionId.length() > 8 ? sessionId.substring(0, 8) + "..." : sessionId; log.debug("SessionInvalidationService.invalidateUserSessions: expired session {} for user {}", - safeSessionId, user.getEmail()); + truncateSessionId(session.getSessionId()), user.getEmail()); } } } @@ -90,6 +103,138 @@ public int invalidateUserSessions(User user) { return invalidatedCount; } + /** + * Invalidates sessions after a self-service password change, applying the configured policy + * ({@code user.session.invalidation.keep-current-session-on-password-change}, default {@code true}). + * + *

    + * With the default policy, the user's current session is preserved and its id is regenerated (mitigating + * session fixation), while every other session for the user is invalidated — so the user remains + * logged in on the device they just used to change their password, but any other active sessions are terminated. + * This follows the OWASP guidance to regenerate the current session and invalidate the rest on a credential change. + *

    + * + *

    + * When the policy is disabled, this delegates to {@link #invalidateUserSessions(User)} and terminates all + * sessions including the current one. If there is no current servlet request/session (e.g. the password is being + * changed through a flow where the user is not authenticated in a session, such as a token-based password reset), + * there is no current session to preserve, so all of the user's registered sessions are invalidated. + *

    + * + * @param user the user whose sessions should be invalidated + * @return the number of other sessions that were invalidated (the preserved current session is not counted) + */ + public int invalidateSessionsAfterPasswordChange(User user) { + if (!keepCurrentSessionOnPasswordChange) { + return invalidateUserSessions(user); + } + if (user == null) { + log.warn("SessionInvalidationService.invalidateSessionsAfterPasswordChange: user is null"); + return 0; + } + + final HttpServletRequest request = currentRequest(); + final String currentSessionId = currentSessionId(request); + + int invalidatedCount = 0; + Object currentPrincipal = null; + final List principals = sessionRegistry.getAllPrincipals(); + if (principals.size() > warnThreshold) { + log.warn("SessionInvalidationService.invalidateSessionsAfterPasswordChange: high principal count ({}) may impact performance", + principals.size()); + } + + for (Object principal : principals) { + User principalUser = extractUser(principal); + if (principalUser != null && principalUser.getId().equals(user.getId())) { + for (SessionInformation session : sessionRegistry.getAllSessions(principal, false)) { + if (currentSessionId != null && currentSessionId.equals(session.getSessionId())) { + // Preserve the current session; it is regenerated below rather than expired. + currentPrincipal = principal; + continue; + } + session.expireNow(); + invalidatedCount++; + log.debug("SessionInvalidationService.invalidateSessionsAfterPasswordChange: expired other session {} for user {}", + truncateSessionId(session.getSessionId()), user.getEmail()); + } + } + } + + if (currentPrincipal != null) { + regenerateCurrentSession(request, currentSessionId, currentPrincipal, user); + } + + log.info("SessionInvalidationService.invalidateSessionsAfterPasswordChange: invalidated {} other session(s) for user {}; " + + "current session preserved and regenerated: {}", invalidatedCount, user.getEmail(), currentPrincipal != null); + return invalidatedCount; + } + + /** + * Regenerates the current HTTP session id (preserving the session and its {@code SecurityContext}) and keeps the + * {@link SessionRegistry} consistent so the concurrent-session machinery recognizes the new id on the next request. + * Best-effort: if there is no active session to regenerate (e.g. it was already invalidated or the response is + * committed), the user simply keeps their existing session id. + * + * @param request the current servlet request (non-null) + * @param oldSessionId the current session id prior to regeneration + * @param principal the security principal the session is registered under + * @param user the user (for logging) + */ + private void regenerateCurrentSession(HttpServletRequest request, String oldSessionId, Object principal, User user) { + try { + final String newSessionId = request.changeSessionId(); + if (!newSessionId.equals(oldSessionId)) { + sessionRegistry.removeSessionInformation(oldSessionId); + sessionRegistry.registerNewSession(newSessionId, principal); + log.debug("SessionInvalidationService.regenerateCurrentSession: regenerated current session {} -> {} for user {}", + truncateSessionId(oldSessionId), truncateSessionId(newSessionId), user.getEmail()); + } + } catch (IllegalStateException ex) { + log.debug("SessionInvalidationService.regenerateCurrentSession: could not regenerate current session for user {}: {}", + user.getEmail(), ex.getMessage()); + } + } + + /** + * Returns the current servlet request bound to this thread, or {@code null} if the call is not happening on a + * request-bound thread (e.g. a background job). + * + * @return the current {@link HttpServletRequest}, or {@code null} + */ + private HttpServletRequest currentRequest() { + RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); + if (attributes instanceof ServletRequestAttributes servletRequestAttributes) { + return servletRequestAttributes.getRequest(); + } + return null; + } + + /** + * Returns the id of the existing session on the given request, or {@code null} if the request is {@code null} or + * has no session. + * + * @param request the current request (may be {@code null}) + * @return the current session id, or {@code null} + */ + private String currentSessionId(HttpServletRequest request) { + if (request == null) { + return null; + } + HttpSession session = request.getSession(false); + return session != null ? session.getId() : null; + } + + /** + * Truncates a session id for safe logging, never exposing the full identifier. + * + * @param sessionId the full session id + * @return the first 8 characters followed by an ellipsis, or the id unchanged if it is short + */ + private String truncateSessionId(String sessionId) { + return sessionId != null && sessionId.length() > 8 ? sessionId.substring(0, 8) + "..." : sessionId; + } + /** * Extracts the User object from a principal. * Handles both User and DSUserDetails principal types. diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/TokenHasher.java b/src/main/java/com/digitalsanctuary/spring/user/service/TokenHasher.java new file mode 100644 index 00000000..c88e5f45 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/service/TokenHasher.java @@ -0,0 +1,137 @@ +package com.digitalsanctuary.spring.user.service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import lombok.extern.slf4j.Slf4j; + +/** + * Deterministically hashes verification and password-reset tokens before they are stored at rest. + * + *

    + * The raw token (a high-entropy, URL-safe value) is what gets emailed to the user. Only the + * hash of that token is persisted in the database. On lookup, the service hashes the + * incoming raw token and queries by the hash. Because the hash is deterministic, the same raw token + * always maps to the same stored value, which is what makes lookup-by-hash possible. + *

    + * + *

    + * Keyed vs. plain. When {@code user.security.tokenHashSecret} is configured, this + * class uses HMAC-SHA-256 keyed by that secret. Otherwise it falls back to a plain SHA-256 digest. + *

    + *
      + *
    • Plain SHA-256 is adequate here because the tokens are themselves high-entropy + * random values (256 bits of entropy). An attacker who steals the database cannot feasibly reverse + * the hash or guess the pre-image, so even unkeyed hashing prevents the stored value from being used + * directly as a token.
    • + *
    • HMAC with a secret adds defense-in-depth against a database-only compromise: + * without the application secret (stored outside the DB), an attacker cannot pre-compute or verify + * candidate hashes offline at all. Configure a secret if you want the stored hashes to be useless to + * anyone who only has the database.
    • + *
    + * + *

    + * The output is a lowercase hexadecimal string (64 characters), which fits comfortably in the + * existing {@code String token} column — so enabling hashing requires no schema migration. + *

    + */ +@Slf4j +@Component +public class TokenHasher { + + private static final String HMAC_ALGORITHM = "HmacSHA256"; + private static final String DIGEST_ALGORITHM = "SHA-256"; + + /** Optional secret. When present, HMAC-SHA-256 is used; otherwise plain SHA-256. */ + private final String tokenHashSecret; + + /** + * Instantiates a new token hasher. + * + * @param tokenHashSecret the optional secret used to key the HMAC; may be {@code null} or blank, + * in which case plain SHA-256 is used + */ + public TokenHasher(@Value("${user.security.tokenHashSecret:#{null}}") final String tokenHashSecret) { + this.tokenHashSecret = tokenHashSecret; + if (StringUtils.hasText(tokenHashSecret)) { + log.debug("TokenHasher initialized with a configured secret (HMAC-SHA-256)."); + } else { + log.debug("TokenHasher initialized without a secret (plain SHA-256). " + + "Set user.security.tokenHashSecret for keyed hashing."); + } + } + + /** + * Hashes the given raw token deterministically. + * + * @param rawToken the raw token value (the value emailed to the user) + * @return the lowercase hex-encoded hash, or {@code null} if {@code rawToken} is {@code null} + */ + public String hash(final String rawToken) { + if (rawToken == null) { + return null; + } + try { + final byte[] digest; + if (StringUtils.hasText(tokenHashSecret)) { + final Mac mac = Mac.getInstance(HMAC_ALGORITHM); + mac.init(new SecretKeySpec(tokenHashSecret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM)); + digest = mac.doFinal(rawToken.getBytes(StandardCharsets.UTF_8)); + } else { + final MessageDigest md = MessageDigest.getInstance(DIGEST_ALGORITHM); + digest = md.digest(rawToken.getBytes(StandardCharsets.UTF_8)); + } + return toHex(digest); + } catch (final NoSuchAlgorithmException | java.security.InvalidKeyException e) { + // SHA-256 / HmacSHA256 are guaranteed by the JCA spec; this should never happen. + throw new IllegalStateException("Failed to hash token", e); + } + } + + /** + * Produces a short, non-reversible fingerprint of a raw token for safe logging. Never logs the full + * token: returns a fixed placeholder for {@code null}/short values and only the first 6 characters + * (followed by an ellipsis) for longer tokens. Intended purely for correlating log lines, not for + * any security decision. + * + *

    + * The returned prefix is stripped of CR/LF and other control characters so that an attacker-supplied + * token (these values arrive as request parameters) cannot forge or split log lines (log injection). + *

    + * + * @param token the raw token (may be {@code null}) + * @return {@code "null"} if the token is {@code null}, {@code "****"} if it is 8 characters or fewer, + * otherwise the first 6 (control-character-stripped) characters followed by an ellipsis + */ + public static String fingerprint(final String token) { + if (token == null) { + return "null"; + } + if (token.length() <= 8) { + return "****"; + } + // Strip control characters (incl. CR/LF) from the logged prefix to prevent log injection/forging. + final String prefix = token.substring(0, 6).replaceAll("\\p{Cntrl}", ""); + return prefix + "…"; + } + + /** + * Converts a byte array to a lowercase hex string. + * + * @param bytes the bytes + * @return the hex string + */ + private static String toHex(final byte[] bytes) { + final StringBuilder sb = new StringBuilder(bytes.length * 2); + for (final byte b : bytes) { + sb.append(Character.forDigit((b >> 4) & 0xF, 16)); + sb.append(Character.forDigit(b & 0xF, 16)); + } + return sb.toString(); + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java index 93030743..cfe4e488 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java @@ -8,12 +8,15 @@ import java.util.Map; import tools.jackson.core.JacksonException; import tools.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Lazy; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.digitalsanctuary.spring.user.audit.AuditEvent; import com.digitalsanctuary.spring.user.mail.MailService; import com.digitalsanctuary.spring.user.persistence.model.PasswordResetToken; @@ -57,10 +60,34 @@ public class UserEmailService { /** The session invalidation service. */ private final SessionInvalidationService sessionInvalidationService; + /** Hashes tokens before they are stored at rest. */ + private final TokenHasher tokenHasher; + + /** + * Self-reference, resolved through the Spring proxy, used to invoke {@link #createPasswordResetTokenForUser} + * so its {@code @Transactional} boundary actually applies. + * + *

    + * Calling {@code createPasswordResetTokenForUser(...)} directly from another method in this class + * ({@code this.createPasswordResetTokenForUser(...)}) is a self-invocation that bypasses the Spring proxy, + * so the {@code @Transactional} would never start and the {@code deleteByUser} + {@code save} could run in + * separate transactions — reopening the single-active-token race. Invoking through this proxied reference + * ensures the delete and save commit atomically. It is injected {@link Lazy} to break the construction-time + * circular dependency on itself. + *

    + */ + @Lazy + @Autowired + private UserEmailService self; + /** The configured app URL for admin-initiated password resets. */ @Value("${user.admin.appUrl:#{null}}") private String configuredAppUrl; + /** Password reset token lifetime in minutes. Defaults to 24h. */ + @Value("${user.security.passwordResetTokenValidityMinutes:1440}") + private int passwordResetTokenValidityMinutes; + /** ObjectMapper for JSON serialization in audit events. */ private final ObjectMapper objectMapper = new ObjectMapper(); @@ -75,9 +102,10 @@ public class UserEmailService { * @throws IllegalArgumentException if appUrl is null, blank, or uses a dangerous scheme */ public void sendForgotPasswordVerificationEmail(final User user, final String appUrl) { - log.debug("UserEmailService.sendForgotPasswordVerificationEmail: called with user: {}", user); + log.debug("UserEmailService.sendForgotPasswordVerificationEmail: called for user: {}", user != null ? user.getEmail() : null); final String token = generateToken(); - createPasswordResetTokenForUser(user, token); + // Invoke through the proxy so the @Transactional boundary on createPasswordResetTokenForUser applies. + self.createPasswordResetTokenForUser(user, token); AuditEvent sendForgotPasswordEmailAuditEvent = AuditEvent.builder().source(this).user(user).action("sendForgotPasswordVerificationEmail") .actionStatus("Success").message("Forgot password email to be sent.").build(); @@ -200,11 +228,21 @@ private String generateToken() { /** * Creates the password reset token for user. * + *

    + * The token is hashed before storage (the raw value goes into the emailed link). Any existing + * token for the user is deleted first so that only one active reset token exists per user. + *

    + * * @param user the user - * @param token the token + * @param token the raw token (emailed to the user) */ + @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); } @@ -238,7 +276,8 @@ public int initiateAdminPasswordReset(final User user, final String appUrl, fina // Step 1: Generate token and create password reset token (must succeed before invalidating sessions) final String token = generateToken(); - createPasswordResetTokenForUser(user, token); + // Invoke through the proxy so the @Transactional boundary on createPasswordResetTokenForUser applies. + self.createPasswordResetTokenForUser(user, token); // Step 2: Send password reset email (must succeed before invalidating sessions) sendPasswordResetEmail(user, appUrl, token); diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java index 18867ddb..f20b8f79 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java @@ -7,8 +7,15 @@ import java.util.List; import java.util.Optional; import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEvent; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Lazy; +import org.springframework.dao.CannotAcquireLockException; +import org.springframework.dao.ConcurrencyFailureException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.PageRequest; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.event.InteractiveAuthenticationSuccessEvent; import org.springframework.security.core.Authentication; @@ -19,13 +26,17 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.util.StringUtils; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import com.digitalsanctuary.spring.user.dto.PasswordlessRegistrationDto; import com.digitalsanctuary.spring.user.dto.UserDto; import com.digitalsanctuary.spring.user.event.UserDeletedEvent; +import com.digitalsanctuary.spring.user.event.UserDisabledEvent; import com.digitalsanctuary.spring.user.event.UserPreDeleteEvent; import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException; import com.digitalsanctuary.spring.user.persistence.model.PasswordHistoryEntry; @@ -37,6 +48,11 @@ import com.digitalsanctuary.spring.user.persistence.repository.RoleRepository; import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; import com.digitalsanctuary.spring.user.persistence.repository.VerificationTokenRepository; +import com.digitalsanctuary.spring.user.registration.RegistrationContext; +import com.digitalsanctuary.spring.user.registration.RegistrationDecision; +import com.digitalsanctuary.spring.user.registration.RegistrationDeniedException; +import com.digitalsanctuary.spring.user.registration.RegistrationGuard; +import com.digitalsanctuary.spring.user.registration.RegistrationSource; import com.digitalsanctuary.spring.user.util.TimeLogger; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; @@ -226,6 +242,36 @@ public String getValue() { private final SessionInvalidationService sessionInvalidationService; + /** Hashes tokens before they are stored / looked up at rest. */ + private final TokenHasher tokenHasher; + + /** + * The registration guard, enforced on every registration path (form, passwordless) in this service + * so that direct callers cannot bypass it. Resolves to the primary + * {@link com.digitalsanctuary.spring.user.registration.CompositeRegistrationGuard composite guard}, + * which applies first-deny-wins across all configured guards. + */ + private final RegistrationGuard registrationGuard; + + /** + * Self-reference, resolved through the Spring proxy, used to invoke the transactional persistence + * methods from the non-transactional public entry points. + * + *

    + * bcrypt hashing is deliberately slow (~100ms+). Running it inside an open transaction holds a + * pooled DB connection for the full hash and starves the pool under load. The public entry methods + * are therefore annotated {@link Propagation#NOT_SUPPORTED} so they run with no transaction (no + * connection held) while the encode happens, then delegate the actual DB write to a short + * {@code @Transactional} persist method invoked through this proxy reference. Calling the + * persist method directly ({@code this.persistX(...)}) would be a self-invocation that bypasses the + * proxy, so the transaction would never start — hence the proxied self-reference. It is injected + * {@link Lazy} to break the construction-time circular dependency on itself. + *

    + */ + @Lazy + @Autowired + private UserService self; + /** The send registration verification email flag. */ @Value("${user.registration.sendVerificationEmail:false}") private boolean sendRegistrationVerificationEmail; @@ -243,10 +289,28 @@ public String getValue() { * * @param newUserDto the data transfer object containing the user registration * information + *

    + * Runs with {@link Isolation#SERIALIZABLE} isolation to close the duplicate-registration + * race when two requests register the same email concurrently. The {@link #emailExists} + * pre-check handles the common case, but a concurrent insert can still fail at commit; in + * that case the resulting {@link DataIntegrityViolationException} (unique-constraint + * violation) or serialization failure ({@link CannotAcquireLockException} / + * {@link ConcurrencyFailureException}) is translated into a {@link UserAlreadyExistException} + * (HTTP 409) rather than surfacing as a 500. Unrelated failures are never swallowed. + *

    + * + * @implNote This method is {@link Propagation#NOT_SUPPORTED}: the slow bcrypt hash runs with no + * transaction (and no pooled connection) held, and the DB write is delegated to a + * short, separate transaction. As a result this method does not enlist in a + * caller's transaction — if a consumer calls it from inside their own + * {@code @Transactional}, that outer transaction is suspended and the registration + * commits independently, so an outer rollback will not undo the persisted user. + * * @return the newly created user entity * @throws UserAlreadyExistException if an account with the same email already * exists */ + @Transactional(propagation = Propagation.NOT_SUPPORTED) public User registerNewUserAccount(final UserDto newUserDto) { TimeLogger timeLogger = new TimeLogger(log, "UserService.registerNewUserAccount"); log.debug("UserService.registerNewUserAccount: called with userDto: {}", newUserDto); @@ -262,19 +326,19 @@ public User registerNewUserAccount(final UserDto newUserDto) { throw new IllegalArgumentException("Passwords do not match"); } - if (emailExists(newUserDto.getEmail())) { - log.debug("UserService.registerNewUserAccount: email already exists: {}", newUserDto.getEmail()); - throw new UserAlreadyExistException( - "There is an account with that email address: " + newUserDto.getEmail()); - } + // Enforce the RegistrationGuard for every form registration — including direct callers of this + // service method — before doing any (slow) work. A denial throws RegistrationDeniedException, + // which the UserAPI translates into the REGISTRATION_DENIED response. + evaluateRegistrationGuard(newUserDto.getEmail(), RegistrationSource.FORM, null); - // Create a new User entity + // Create a new User entity. The (deliberately slow) bcrypt encode runs HERE, with NO + // transaction active (this method is Propagation.NOT_SUPPORTED), so it never holds a pooled + // DB connection. The DB write happens afterward in the short, proxied persistNewUserAccount. User user = new User(); user.setFirstName(newUserDto.getFirstName()); user.setLastName(newUserDto.getLastName()); user.setPassword(passwordEncoder.encode(newUserDto.getPassword())); user.setEmail(newUserDto.getEmail().toLowerCase()); - user.setRoles(Arrays.asList(roleRepository.findByName(USER_ROLE_NAME))); // If we are not sending a verification email if (!sendRegistrationVerificationEmail) { @@ -282,11 +346,70 @@ public User registerNewUserAccount(final UserDto newUserDto) { user.setEnabled(true); } - user = userRepository.save(user); - savePasswordHistory(user, user.getPassword()); - // authWithoutPassword(user); + // Persist through the proxy so the SERIALIZABLE transaction actually applies (a direct + // this.persistNewUserAccount(...) self-invocation would bypass the proxy and run no transaction). + User saved = self.persistNewUserAccount(user); + // authWithoutPassword(saved); timeLogger.end(); - return user; + return saved; + } + + /** + * Persists a new user account inside a short, serializable transaction. + * + *

    + * This is the DB-only half of {@link #registerNewUserAccount(UserDto)}: the password has already + * been encoded by the (non-transactional) caller, so no slow bcrypt work happens while this + * connection-holding transaction is open. It runs with {@link Isolation#SERIALIZABLE} to close the + * duplicate-registration race when two requests register the same email concurrently. The + * {@link #emailExists} pre-check handles the common case, but a concurrent insert can still fail at + * commit; in that case the resulting {@link DataIntegrityViolationException} (unique-constraint + * violation) or serialization failure ({@link CannotAcquireLockException} / + * {@link ConcurrencyFailureException}) is translated into a {@link UserAlreadyExistException} + * (HTTP 409) rather than surfacing as a 500. Unrelated failures are never swallowed. + *

    + * + *

    + * Internal seam: this method exists only to split the DB write away from the bcrypt hash. It MUST + * be invoked through the Spring proxy (via {@link #self}) so the transaction applies. It is + * deliberately protected so consumers cannot call it directly and bypass the centralized + * RegistrationGuard enforced by {@link #registerNewUserAccount(UserDto)}. It must be + * {@code protected} rather than package-private: the CGLIB proxy subclass is generated in a + * different package, so it can only override (and therefore advise/route) {@code public} or + * {@code protected} methods. A package-private method is not overridden, so the {@code self} + * invocation would execute on the proxy instance — whose {@code @Autowired} fields are never + * populated — and both the transaction and the dependencies would be missing. + *

    + * + * @param user the fully built user entity (password already encoded) + * @return the saved user entity + * @throws UserAlreadyExistException if an account with the same email already exists + */ + @Transactional(isolation = Isolation.SERIALIZABLE) + protected User persistNewUserAccount(final User user) { + if (emailExists(user.getEmail())) { + log.debug("UserService.persistNewUserAccount: email already exists: {}", user.getEmail()); + throw new UserAlreadyExistException( + "There is an account with that email address: " + user.getEmail()); + } + + user.setRoles(Arrays.asList(roleRepository.findByName(USER_ROLE_NAME))); + + try { + User saved = userRepository.save(user); + savePasswordHistory(saved, saved.getPassword()); + return saved; + } catch (DataIntegrityViolationException | ConcurrencyFailureException e) { + // A concurrent registration won the race: the unique-email constraint was violated + // (DataIntegrityViolationException) or the SERIALIZABLE transaction could not be + // serialized (ConcurrencyFailureException, e.g. CannotAcquireLockException). Translate + // to a 409 instead of letting it surface as a 500. Only these duplicate/serialization + // cases are translated; unrelated exceptions propagate unchanged. + log.debug("UserService.persistNewUserAccount: concurrent registration detected for email {}: {}", + user.getEmail(), e.getClass().getSimpleName()); + throw new UserAlreadyExistException( + "There is an account with that email address: " + user.getEmail()); + } } /** @@ -315,25 +438,52 @@ private void savePasswordHistory(User user, String encodedPassword) { /** * Cleans up old password history entries for a user, keeping only the most recent entries. - * Uses SERIALIZABLE isolation to prevent race conditions when the same user changes - * their password concurrently from multiple sessions. + * + *

    + * This is a private helper reached via {@code savePasswordHistory} on the call chain + * {@code changeUserPassword} → {@code self.persistChangedPassword} (proxied, {@code @Transactional}) + * → {@code savePasswordHistory} → {@code cleanUpPasswordHistory}. It therefore runs inside the + * transaction opened at {@code persistChangedPassword}; it carries no {@code @Transactional} of its + * own (one on a private/self-invoked method would be ignored by the proxy anyway). Rather than load + * every history row and {@code deleteAll} the overflow (a read-then-delete window that races with + * concurrent inserts), it issues a single set-based, bounded delete: + *

    + *
      + *
    1. Locate the id of the oldest entry to keep (the {@code maxEntries}-th most recent entry, + * ordered by primary key descending).
    2. + *
    3. Delete all of the user's entries with an id strictly less than that cutoff.
    4. + *
    + * + *

    + * Ordering by id is reliable because the id is generated with {@code GenerationType.IDENTITY} + * and is therefore monotonically increasing. The approach is portable across H2, MariaDB, and + * PostgreSQL (no subquery {@code LIMIT}) and is tolerant of being called repeatedly. + *

    * * @param user the user whose password history should be cleaned up */ - @Transactional(isolation = Isolation.SERIALIZABLE) private void cleanUpPasswordHistory(User user) { if (user == null || historyCount <= 0) { return; } - List entries = passwordHistoryRepository.findByUserOrderByEntryDateDesc(user); - // Keep historyCount + 1 entries: the current password plus historyCount previous passwords - // This ensures we actually prevent reuse of the last historyCount passwords + // Keep historyCount + 1 entries: the current password plus historyCount previous passwords. + // This ensures we actually prevent reuse of the last historyCount passwords. int maxEntries = historyCount + 1; - if (entries.size() > maxEntries) { - List toDelete = entries.subList(maxEntries, entries.size()); - passwordHistoryRepository.deleteAll(toDelete); - log.debug("Cleaned up {} old password history entries for user: {}", toDelete.size(), user.getEmail()); + + // Fetch only the cutoff row: the oldest entry we want to keep (0-based index maxEntries - 1, + // newest first). Everything older than this is pruned. + List cutoffIds = + passwordHistoryRepository.findIdsByUserOrderByIdDesc(user, PageRequest.of(maxEntries - 1, 1)); + if (cutoffIds.isEmpty()) { + // Fewer than maxEntries rows exist; nothing to prune. + return; + } + + Long cutoffId = cutoffIds.get(0); + int deleted = passwordHistoryRepository.deleteByUserAndIdLessThan(user, cutoffId); + if (deleted > 0) { + log.debug("Cleaned up {} old password history entries for user: {}", deleted, user.getEmail()); } } @@ -351,9 +501,9 @@ private void cleanUpPasswordHistory(User user) { */ @Transactional public void deleteOrDisableUser(final User user) { - log.debug("UserService.deleteOrDisableUser: called with user: {}", user); + 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()); // Capture user details before deletion for the post-delete event Long userId = user.getId(); String userEmail = user.getEmail(); @@ -376,14 +526,56 @@ public void deleteOrDisableUser(final User user) { // Delete the user userRepository.delete(user); - // Publish UserDeletedEvent after successful deletion - log.debug("Publishing UserDeletedEvent"); - eventPublisher.publishEvent(new UserDeletedEvent(this, userId, userEmail)); + // Publish UserDeletedEvent AFTER the surrounding transaction commits. The event is + // primarily consumed by external applications (often via @Async listeners) that must + // not observe a not-yet-committed deletion. There is no framework-internal listener + // for this event, so rather than annotate a listener we defer publication itself via a + // transaction synchronization. If no transaction is active (e.g. called outside a + // transactional context), fall back to publishing immediately. + 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()); + // Capture user details before the save for the post-commit event, mirroring the delete path. + Long userId = user.getId(); + String userEmail = user.getEmail(); + user.setEnabled(false); userRepository.save(user); log.debug("UserService.deleteOrDisableUser: user {} has been disabled", user.getEmail()); + + // Publish UserDisabledEvent AFTER the surrounding transaction commits so listeners (often + // @Async, in consuming apps) never observe a not-yet-committed change. This makes the default + // soft-delete path observable, matching the hard-delete path's UserDeletedEvent. + publishEventAfterCommit(new UserDisabledEvent(this, userId, userEmail)); + } + } + + /** + * Publishes the given application event after the current transaction commits. + * + *

    + * If a transaction is active, the event is published from + * {@link TransactionSynchronization#afterCommit()} so listeners (especially {@code @Async} + * ones) never act on a change that has not yet been committed. If no transaction is active, + * the event is published immediately so the behavior is still correct in non-transactional + * callers. Used for both {@link UserDeletedEvent} (hard delete) and {@link UserDisabledEvent} + * (soft delete). + *

    + * + * @param event the event to publish after commit + */ + private void publishEventAfterCommit(final ApplicationEvent event) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + log.debug("Publishing {} after commit", event.getClass().getSimpleName()); + eventPublisher.publishEvent(event); + } + }); + } else { + log.debug("Publishing {} (no active transaction)", event.getClass().getSimpleName()); + eventPublisher.publishEvent(event); } } @@ -400,27 +592,50 @@ public User findUserByEmail(final String email) { return userRepository.findByEmail(email.toLowerCase()); } + /** + * Resolves a password reset token by its raw value using a dual-read strategy. + * + *

    + * Tokens are stored hashed, so we first look up by {@code hash(rawToken)}. For backward + * compatibility we fall back to looking up by the raw value, which resolves any pre-upgrade + * tokens that were stored in plaintext before token hashing was introduced. This fallback is + * permanently safe and needs no operator action to retire: every token carries an + * {@code expiryDate} bounded by the configured lifetime, and the validate path rejects expired + * tokens, so any lingering plaintext token becomes unusable within its lifetime window. + *

    + * + * @param rawToken the raw token value + * @return the resolved token entity, or {@code null} if not found + */ + private PasswordResetToken resolvePasswordResetToken(final String rawToken) { + if (rawToken == null) { + return null; + } + PasswordResetToken token = passwordTokenRepository.findByToken(tokenHasher.hash(rawToken)); + if (token == null) { + token = passwordTokenRepository.findByToken(rawToken); + } + return token; + } + /** * Gets the password reset token. * - * @param token the token + * @param token the raw token * @return the password reset token */ public PasswordResetToken getPasswordResetToken(final String token) { - return passwordTokenRepository.findByToken(token); + return resolvePasswordResetToken(token); } /** * Gets the user by password reset token. * - * @param token the token + * @param token the raw token * @return the user by password reset token */ public Optional getUserByPasswordResetToken(final String token) { - if (token == null) { - return Optional.empty(); - } - PasswordResetToken passwordResetToken = passwordTokenRepository.findByToken(token); + PasswordResetToken passwordResetToken = resolvePasswordResetToken(token); if (passwordResetToken == null) { return Optional.empty(); } @@ -429,20 +644,74 @@ public Optional getUserByPasswordResetToken(final String token) { /** * Deletes a password reset token after it has been used. - * Uses a direct DELETE query for efficiency (no SELECT required). * - * @param token the token string to delete + *

    + * Uses dual-delete to match the dual-read lookup: deletes the hashed value first, then falls back + * to the raw value to clean up any pre-upgrade plaintext token. + *

    + * + * @param token the raw token string to delete */ public void deletePasswordResetToken(final String token) { if (token == null) { return; } - int deletedCount = passwordTokenRepository.deleteByToken(token); + int deletedCount = passwordTokenRepository.deleteByToken(tokenHasher.hash(token)); + if (deletedCount == 0) { + deletedCount = passwordTokenRepository.deleteByToken(token); + } if (deletedCount > 0) { - log.debug("Deleted password reset token: {}", token); + log.debug("Deleted used password reset token."); } } + /** + * Atomically validates and consumes a password reset token in a single transaction. + * + *

    + * This prevents a token from being double-consumed: validation and deletion happen together, so + * two concurrent reset attempts cannot both succeed with the same token. Returns the associated + * user when the token is valid (and deletes it), or {@code null} when the token is missing or + * expired (expired tokens are also deleted as a cleanup). Uses dual-read so both hashed + * (post-upgrade) and plaintext (pre-upgrade) tokens resolve. + *

    + * + *

    + * Concurrency: the conditional {@code DELETE} is the atomicity guard, not the + * surrounding transaction. A plain read-check-delete would let two concurrent requests both read the + * row (under READ_COMMITTED) and both return the user before either delete commits. Instead we delete + * by token value and only return the user when the delete actually removed the row ({@code count == 1}); + * the row lock serializes concurrent deletes so exactly one caller wins. + *

    + * + * @param token the raw token to validate and consume + * @return the user associated with the token if it was valid, otherwise {@code null} + */ + @Transactional + public User validateAndConsumePasswordResetToken(final String token) { + final PasswordResetToken passToken = resolvePasswordResetToken(token); + if (passToken == null) { + return null; + } + final User user = passToken.getUser(); + final boolean expired = passToken.getExpiryDate().before(Calendar.getInstance().getTime()); + + // Atomic single-use guard: consume by deleting the row and act only if THIS call removed it. + // Dual-delete mirrors the dual-read (hashed first, then pre-upgrade plaintext fallback). + int consumed = passwordTokenRepository.deleteByToken(tokenHasher.hash(token)); + if (consumed == 0) { + consumed = passwordTokenRepository.deleteByToken(token); + } + if (consumed == 0) { + // Another concurrent reset attempt consumed the token first. + return null; + } + if (expired) { + return null; + } + return user; + } + /** * Gets the user by ID. * @@ -458,12 +727,56 @@ public Optional findUserByID(final long id) { * * @param user the user * @param password the password + * + * @implNote This method is {@link Propagation#NOT_SUPPORTED}: the slow bcrypt hash runs with no + * transaction (and no pooled connection) held, and the DB write is delegated to a + * short, separate transaction. As a result this method does not enlist in a + * caller's transaction — if a consumer calls it from inside their own + * {@code @Transactional}, that outer transaction is suspended and the password change + * commits independently, so an outer rollback will not undo it. */ + @Transactional(propagation = Propagation.NOT_SUPPORTED) public void changeUserPassword(final User user, final String password) { + // Encode the new password with NO transaction active (this method is + // Propagation.NOT_SUPPORTED) so the slow bcrypt hash never holds a pooled DB connection. String encodedPassword = passwordEncoder.encode(password); user.setPassword(encodedPassword); + // Persist through the proxy so the short transaction applies. + self.persistChangedPassword(user, encodedPassword); + } + + /** + * Persists a changed password inside a short transaction. + * + *

    + * The DB-only half of {@link #changeUserPassword(User, String)}: the password has already been + * encoded by the (non-transactional) caller, so no bcrypt work happens while this transaction holds + * a connection. Saves the user, records password history, and invalidates all existing sessions so + * a reset/change forces re-auth everywhere (OWASP). + *

    + * + *

    + * Internal seam: this method exists only to split the DB write away from the bcrypt hash. It MUST + * be invoked through the Spring proxy (via {@link #self}) so the transaction applies. It is + * protected so it stays out of the public API yet remains overridable by the CGLIB proxy + * subclass — which is generated in a different package and therefore cannot override a + * package-private method. A package-private method would not be advised and the {@code self} + * invocation would run on the proxy instance (whose {@code @Autowired} fields are null), so it + * must be {@code protected} (or public). + *

    + * + * @param user the user whose password changed (password field already set/encoded) + * @param encodedPassword the already-encoded password to record in history + */ + @Transactional + protected void persistChangedPassword(final User user, final String encodedPassword) { userRepository.save(user); savePasswordHistory(user, encodedPassword); + // Force re-auth on a password change (OWASP). By default the current session is preserved and + // regenerated and only the user's OTHER sessions are invalidated, so the user is not logged out + // of the device they just used; set user.session.invalidation.keep-current-session-on-password-change=false + // to terminate every session including the current one. + sessionInvalidationService.invalidateSessionsAfterPasswordChange(user); } /** @@ -502,7 +815,9 @@ public void removeUserPassword(User user) { user.setPassword(null); userRepository.save(user); passwordHistoryRepository.deleteByUser(user); - sessionInvalidationService.invalidateUserSessions(user); + // Same policy as a password change: by default preserve+regenerate the current session and invalidate + // only the user's other sessions (see user.session.invalidation.keep-current-session-on-password-change). + sessionInvalidationService.invalidateSessionsAfterPasswordChange(user); log.info("Password removed for user: {}", user.getEmail()); } @@ -513,17 +828,54 @@ public void removeUserPassword(User user) { * @param user the user to set the password for * @param rawPassword the raw password to encode and save * @throws IllegalStateException if the user already has a password + * + * @implNote This method is {@link Propagation#NOT_SUPPORTED}: the slow bcrypt hash runs with no + * transaction (and no pooled connection) held, and the DB write is delegated to a + * short, separate transaction. As a result this method does not enlist in a + * caller's transaction — if a consumer calls it from inside their own + * {@code @Transactional}, that outer transaction is suspended and the password change + * commits independently, so an outer rollback will not undo it. */ - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) public void setInitialPassword(User user, String rawPassword) { if (hasPassword(user)) { throw new IllegalStateException("User already has a password"); } + // Encode with NO transaction active (this method is Propagation.NOT_SUPPORTED) so the slow + // bcrypt hash never holds a pooled DB connection. The DB write runs in the proxied persist. String encodedPassword = passwordEncoder.encode(rawPassword); user.setPassword(encodedPassword); + // Persist through the proxy so the short transaction applies. + self.persistInitialPassword(user, encodedPassword); + log.info("Initial password set for user: {}", user.getEmail()); + } + + /** + * Persists an initial password inside a short transaction. + * + *

    + * The DB-only half of {@link #setInitialPassword(User, String)}: the password has already been + * encoded by the (non-transactional) caller, so no bcrypt work happens while this transaction holds + * a connection. Saves the user and records password history. + *

    + * + *

    + * Internal seam: this method exists only to split the DB write away from the bcrypt hash. It MUST + * be invoked through the Spring proxy (via {@link #self}) so the transaction applies. It is + * protected so it stays out of the public API yet remains overridable by the CGLIB proxy + * subclass — which is generated in a different package and therefore cannot override a + * package-private method. A package-private method would not be advised and the {@code self} + * invocation would run on the proxy instance (whose {@code @Autowired} fields are null), so it + * must be {@code protected} (or public). + *

    + * + * @param user the user whose initial password is being set (password field already set) + * @param encodedPassword the already-encoded password to record in history + */ + @Transactional + protected void persistInitialPassword(final User user, final String encodedPassword) { userRepository.save(user); savePasswordHistory(user, encodedPassword); - log.info("Initial password set for user: {}", user.getEmail()); } /** @@ -537,7 +889,12 @@ public void setInitialPassword(User user, String rawPassword) { @Transactional(isolation = Isolation.SERIALIZABLE) 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); + + // Enforce the RegistrationGuard for every passwordless registration — including direct callers of + // this service method. A denial throws RegistrationDeniedException, which the UserAPI translates + // into the REGISTRATION_DENIED response. + evaluateRegistrationGuard(dto.getEmail(), RegistrationSource.PASSWORDLESS, null); if (emailExists(dto.getEmail())) { log.debug("UserService.registerPasswordlessAccount: email already exists: {}", dto.getEmail()); @@ -571,6 +928,50 @@ private boolean emailExists(final String email) { return userRepository.findByEmail(email.toLowerCase()) != null; } + /** + * Evaluates the configured {@link RegistrationGuard} for a registration attempt and throws a + * {@link RegistrationDeniedException} if it is denied. + * + *

    This centralizes guard enforcement in the service so that every registration path — + * including direct callers of the public registration methods — is guarded exactly once with the + * correct {@link RegistrationSource}. The injected guard is the primary + * {@link com.digitalsanctuary.spring.user.registration.CompositeRegistrationGuard composite}, so all + * configured guards are applied with first-deny-wins semantics.

    + * + * @param email the email address of the registration attempt (may be {@code null}) + * @param source the registration source; never {@code null} + * @param providerName the OAuth2/OIDC provider registration id, or {@code null} for form/passwordless + * @throws RegistrationDeniedException if the guard denies the registration + */ + private void evaluateRegistrationGuard(final String email, final RegistrationSource source, final String providerName) { + RegistrationDecision decision = registrationGuard.evaluate(new RegistrationContext(email, source, providerName)); + if (!decision.allowed()) { + log.info("Registration denied for source: {} provider: {} reason: {}", source, providerName, decision.reason()); + throw new RegistrationDeniedException(decision.reason()); + } + } + + /** + * Enforces the configured {@link RegistrationGuard} for a first-time OAuth2/OIDC social registration. + * + *

    The OAuth2 and OIDC user services build and persist new social users themselves (with + * provider-specific role assignment and audit events). To keep guard enforcement centralized in this + * service — so the guard SPI lives in exactly one place and direct callers of the registration paths + * cannot bypass it — those services delegate the guard check here at the point a NEW social user is + * about to be created (never on login of an existing OAuth/OIDC user). On denial this throws + * {@link RegistrationDeniedException}, which the OAuth/OIDC services translate into the appropriate + * {@code OAuth2AuthenticationException}.

    + * + * @param email the email address from the OAuth2/OIDC provider + * @param source the registration source ({@link RegistrationSource#OAUTH2} or + * {@link RegistrationSource#OIDC}) + * @param providerName the OAuth2/OIDC provider registration id (e.g. {@code "google"}, {@code "keycloak"}) + * @throws RegistrationDeniedException if the guard denies the registration + */ + public void enforceRegistrationGuard(final String email, final RegistrationSource source, final String providerName) { + evaluateRegistrationGuard(email, source, providerName); + } + /** * Validate password reset token. * @@ -578,7 +979,7 @@ private boolean emailExists(final String email) { * @return the password reset token validation result enum */ public TokenValidationResult validatePasswordResetToken(String token) { - final PasswordResetToken passToken = passwordTokenRepository.findByToken(token); + final PasswordResetToken passToken = resolvePasswordResetToken(token); if (passToken == null) { return TokenValidationResult.INVALID_TOKEN; } @@ -621,7 +1022,7 @@ public List getUsersFromSessionRegistry() { * @param user The user to authenticate without password verification */ public void authWithoutPassword(User user) { - log.debug("UserService.authWithoutPassword: authenticating user: {}", user); + log.debug("UserService.authWithoutPassword: authenticating user: {}", user != null ? user.getEmail() : null); if (user == null || user.getEmail() == null) { log.error("Invalid user or user email"); return; @@ -678,9 +1079,20 @@ private void storeSecurityContextInSession() { } HttpServletRequest request = servletRequestAttributes.getRequest(); - HttpSession session = request.getSession(true); + // Ensure a session exists before attempting to rotate its id. + request.getSession(true); - // Store the security context in the session + // Defend against session fixation on this programmatic-login path: issue a new session id + // (existing attributes are preserved) so a pre-auth fixed id cannot be reused post-authentication (OWASP). + try { + request.changeSessionId(); + } catch (IllegalStateException e) { + // No active session to rotate (shouldn't happen after getSession(true)); fall back to a fresh session below. + log.warn("UserService.storeSecurityContextInSession: could not rotate session id: {}", e.getMessage()); + } + + // Store the security context on the (now rotated) session. + HttpSession session = request.getSession(true); session.setAttribute("SPRING_SECURITY_CONTEXT", SecurityContextHolder.getContext()); } diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java index c413b3ca..ce939501 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java @@ -2,7 +2,9 @@ import java.util.Calendar; import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.persistence.model.VerificationToken; import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; @@ -32,6 +34,39 @@ public class UserVerificationService { /** The token repository. */ private final VerificationTokenRepository tokenRepository; + /** Hashes tokens before they are stored at rest. */ + private final TokenHasher tokenHasher; + + /** Verification token lifetime in minutes. Defaults to 24h. */ + @Value("${user.registration.verificationTokenValidityMinutes:1440}") + private int verificationTokenValidityMinutes; + + /** + * Resolves a verification token by its raw value using a dual-read strategy. + * + *

    + * Tokens are stored hashed, so we first look up by {@code hash(rawToken)}. For backward + * compatibility we fall back to looking up by the raw value, which resolves any pre-upgrade + * tokens that were stored in plaintext before token hashing was introduced. This fallback is + * permanently safe and needs no operator action to retire: every token carries an + * {@code expiryDate} bounded by the configured lifetime, and the validate path rejects expired + * tokens, so any lingering plaintext token becomes unusable within its lifetime window. + *

    + * + * @param rawToken the raw token value + * @return the resolved token entity, or {@code null} if not found + */ + private VerificationToken resolveByRawToken(final String rawToken) { + if (rawToken == null) { + return null; + } + VerificationToken token = tokenRepository.findByToken(tokenHasher.hash(rawToken)); + if (token == null) { + token = tokenRepository.findByToken(rawToken); + } + return token; + } + /** * Gets the user by verification token. * @@ -39,10 +74,11 @@ public class UserVerificationService { * @return the user by verification 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)); + final VerificationToken token = resolveByRawToken(verificationToken); if (token != null) { - log.debug("UserVerificationService.getUserByVerificationToken: user found: {}", token.getUser()); + log.debug("UserVerificationService.getUserByVerificationToken: user found: {}", + token.getUser() != null ? token.getUser().getEmail() : null); return token.getUser(); } log.debug("UserVerificationService.getUserByVerificationToken: no user found!"); @@ -56,19 +92,40 @@ public User getUserByVerificationToken(final String verificationToken) { * @return the verification token entity */ public VerificationToken getVerificationToken(final String verificationToken) { - return tokenRepository.findByToken(verificationToken); + return resolveByRawToken(verificationToken); } /** - * Generates a new verification token to replace an existing one. - * Useful for extending verification periods or re-sending verification emails. + * Generates a new verification token to replace an existing one. Useful for extending + * verification periods or re-sending verification emails. + * + *

    + * A fresh high-entropy raw token is generated. Only its hash is persisted in the + * {@code token} column (consistent with {@link #createVerificationTokenForUser}); the raw value + * is returned to the caller via {@code VerificationToken.getPlainToken()} so a verification email + * link can be built. The expiry is set from the configurable + * {@code user.registration.verificationTokenValidityMinutes} (not a hardcoded 24h). The existing + * row is updated in place, preserving the single-active-token invariant. + *

    * * @param existingVerificationToken the existing verification token string to replace - * @return the updated verification token entity with a new token value + * @return the updated verification token entity. Its persisted {@code token} is the hash; the raw + * value is available via {@code VerificationToken.getPlainToken()}. */ + @Transactional public VerificationToken generateNewVerificationToken(final String existingVerificationToken) { - VerificationToken vToken = tokenRepository.findByToken(existingVerificationToken); - vToken.updateToken(UUID.randomUUID().toString()); + VerificationToken vToken = resolveByRawToken(existingVerificationToken); + if (vToken == null) { + // The supplied token does not resolve to a stored row (unknown, already consumed, or malformed). + // Fail explicitly rather than NPE on the update below. + log.warn("UserVerificationService.generateNewVerificationToken: no token found for {}", + TokenHasher.fingerprint(existingVerificationToken)); + throw new IllegalArgumentException("No verification token found for the supplied value"); + } + final String rawToken = UUID.randomUUID().toString(); + // Store the hash of the new raw token; the raw value is what gets emailed to the user. + vToken.updateToken(tokenHasher.hash(rawToken), verificationTokenValidityMinutes); + vToken.setPlainToken(rawToken); vToken = tokenRepository.save(vToken); return vToken; } @@ -76,30 +133,68 @@ public VerificationToken generateNewVerificationToken(final String existingVerif /** * Creates the verification token for user. * + *

    + * The token is hashed before storage (the raw value goes into the emailed link). Any existing + * token for the user is deleted first so that only one active verification token exists per user. + *

    + * * @param user the user - * @param token the token + * @param token the raw token (emailed to the user) */ + @Transactional public void createVerificationTokenForUser(final User user, final String token) { - final VerificationToken myToken = new VerificationToken(token, user); + // Single active token per user: remove any previously issued token before creating a new one. + tokenRepository.deleteByUser(user); + // Store only the hash of the token; the raw token is what was emailed to the user. + final VerificationToken myToken = + new VerificationToken(tokenHasher.hash(token), user, verificationTokenValidityMinutes); tokenRepository.save(myToken); } /** - * Validates a user verification token. + * Validates a user verification token and, when valid, atomically consumes it: the user is enabled and the + * token is deleted within a single transaction so the token is strictly single-use and cannot be replayed. + * Expired tokens are likewise deleted (as cleanup) and rejected. Uses dual-read so both hashed (post-upgrade) + * and plaintext (pre-upgrade) tokens resolve. + * + *

    + * Concurrency: the conditional {@code DELETE} is the atomicity guard, not the surrounding + * transaction. A plain read-check-delete would let two concurrent requests both read the row (under + * READ_COMMITTED) and both proceed before either delete commits. Instead we delete by token value and only + * apply the effect when the delete actually removed the row ({@code count == 1}); the row lock serializes + * concurrent deletes so exactly one caller wins and the rest are rejected. + *

    + * + *

    + * Because this method consumes the token, callers must obtain any needed {@link User} reference (e.g. via + * {@link #getUserByVerificationToken(String)}) before invoking it; a subsequent lookup by the same + * raw token will no longer resolve. + *

    * - * @param token the token to validate + * @param token the raw token to validate and consume * @return the token validation result (VALID, INVALID_TOKEN, or EXPIRED) */ + @Transactional public UserService.TokenValidationResult validateVerificationToken(String token) { - final VerificationToken verificationToken = tokenRepository.findByToken(token); + final VerificationToken verificationToken = resolveByRawToken(token); if (verificationToken == null) { return UserService.TokenValidationResult.INVALID_TOKEN; } final User user = verificationToken.getUser(); - final Calendar cal = Calendar.getInstance(); - if (verificationToken.getExpiryDate().before(cal.getTime())) { - tokenRepository.delete(verificationToken); + final boolean expired = verificationToken.getExpiryDate().before(Calendar.getInstance().getTime()); + + // Atomic single-use guard: consume by deleting the row and acting only if THIS call removed it. + // Dual-delete mirrors the dual-read (hashed first, then pre-upgrade plaintext fallback). + int consumed = tokenRepository.deleteByToken(tokenHasher.hash(token)); + if (consumed == 0) { + consumed = tokenRepository.deleteByToken(token); + } + if (consumed == 0) { + // Another concurrent request consumed the token first; treat as already-used. + return UserService.TokenValidationResult.INVALID_TOKEN; + } + if (expired) { return UserService.TokenValidationResult.EXPIRED; } @@ -111,11 +206,11 @@ public UserService.TokenValidationResult validateVerificationToken(String token) /** * Delete verification token. * - * @param token the token + * @param token the raw token */ public void deleteVerificationToken(final String token) { - log.debug("UserVerificationService.deleteVerificationToken: called with token: {}", token); - final VerificationToken verificationToken = tokenRepository.findByToken(token); + log.debug("UserVerificationService.deleteVerificationToken: called with token: {}", TokenHasher.fingerprint(token)); + final VerificationToken verificationToken = resolveByRawToken(token); if (verificationToken != null) { tokenRepository.delete(verificationToken); log.debug("UserVerificationService.deleteVerificationToken: token deleted."); diff --git a/src/main/java/com/digitalsanctuary/spring/user/util/JpaAuditingConfig.java b/src/main/java/com/digitalsanctuary/spring/user/util/JpaAuditingConfig.java index af0663fb..0d32b921 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/util/JpaAuditingConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/util/JpaAuditingConfig.java @@ -1,6 +1,7 @@ package com.digitalsanctuary.spring.user.util; import java.util.Optional; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.domain.AuditorAware; @@ -19,9 +20,25 @@ * {@code @CreatedBy} and {@code @LastModifiedBy} annotations to automatically track * which user created or modified them. *

    + *

    + * Consumer opt-out (H5): This configuration is gated by the {@code user.jpa.auditing.enabled} property + * (default {@code true}). A consuming application that runs its own JPA auditing, or that supplies its own + * {@link AuditorAware}, can disable the library's auditing entirely by setting {@code user.jpa.auditing.enabled=false}. + * Disabling the whole configuration is the single, reliable opt-out: because + * {@code @EnableJpaAuditing(auditorAwareRef = "auditorProvider")} resolves the auditor bean strictly by name, + * a consumer with their own auditing must disable this configuration via the property so the library's name-bound + * {@code @EnableJpaAuditing} (and its {@code "auditorProvider"} bean) are not registered at all. + *

    + *

    + * A {@code @ConditionalOnMissingBean} on {@code auditorProvider} was intentionally not used. In this + * component-scanned (non-auto-configuration) context it does not reliably defer to a consumer bean, and combined with + * the {@code auditorAwareRef = "auditorProvider"} name binding it can leave {@code @EnableJpaAuditing} searching for a + * suppressed bean. The class-level property gate is the supported mechanism. + *

    */ @Slf4j @Configuration +@ConditionalOnProperty(name = "user.jpa.auditing.enabled", havingValue = "true", matchIfMissing = true) @EnableJpaAuditing(auditorAwareRef = "auditorProvider") public class JpaAuditingConfig { @@ -48,16 +65,17 @@ private class AuditorAwareImpl implements AuditorAware { @Override public Optional getCurrentAuditor() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - log.debug("AuditorAwareImpl.getCurrentAuditor: Authentication: {}", authentication); + log.debug("AuditorAwareImpl.getCurrentAuditor: Authentication for user: {}", + authentication != null ? authentication.getName() : null); if (authentication == null || !authentication.isAuthenticated()) { return Optional.empty(); } - log.debug("AuditorAwareImpl.getCurrentAuditor: Principal: {}", authentication.getPrincipal()); + log.debug("AuditorAwareImpl.getCurrentAuditor: Principal for user: {}", authentication.getName()); if (authentication.getPrincipal() instanceof String) { - log.info("AuditorAwareImpl.getCurrentAuditor: principal is String: {}. Returning empty.", authentication.getPrincipal()); + log.info("AuditorAwareImpl.getCurrentAuditor: principal is String for user: {}. Returning empty.", authentication.getName()); return Optional.empty(); } diff --git a/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 2fe5c1b1..09914cfa 100644 --- a/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -1,5 +1,11 @@ { "properties": [ + { + "name": "user.jpa.auditing.enabled", + "type": "java.lang.Boolean", + "description": "Enable the library's JPA auditing (@EnableJpaAuditing) and its AuditorAware. Set to false if the consuming application runs its own JPA auditing or supplies its own AuditorAware.", + "defaultValue": true + }, { "name": "user.registration.facebookEnabled", "type": "java.lang.Boolean", @@ -205,11 +211,52 @@ "type": "java.lang.String", "description": "Path for audit log file" }, + { + "name": "user.audit.flushRate", + "type": "java.lang.Integer", + "description": "Interval in milliseconds at which the buffered audit log is flushed to disk when flushOnWrite=false. Smaller values reduce the durability window at a small performance cost.", + "defaultValue": 30000 + }, + { + "name": "user.audit.maxQueryResults", + "type": "java.lang.Integer", + "description": "Maximum number of audit events returned from a single query. The query service retains only the most-recent matching events in a bounded ring buffer, bounding memory regardless of file size. Set to 0 or negative to disable the limit (not recommended).", + "defaultValue": 10000 + }, + { + "name": "user.audit.maxFileSizeMb", + "type": "java.lang.Integer", + "description": "Maximum size in megabytes of the active audit log file before it is rotated. When exceeded, the active file is renamed to .1 (shifting archives up to maxFiles) and a fresh file is opened. Default is 0 (rotation disabled, unbounded growth): rotation is opt-in because the audit query/export reader currently reads only the active file, so rotated archives are not visible to GDPR exports/investigations. Set a positive value only with external log retention.", + "defaultValue": 0 + }, + { + "name": "user.audit.maxFiles", + "type": "java.lang.Integer", + "description": "Maximum number of rotated audit log archive files to retain (e.g. user-audit.log.1 .. user-audit.log.5). The oldest archive beyond this count is deleted on rotation.", + "defaultValue": 5 + }, { "name": "user.security.bcryptStrength", "type": "java.lang.Integer", "description": "BCrypt hash strength (4-31, default 10)" }, + { + "name": "user.security.tokenHashSecret", + "type": "java.lang.String", + "description": "Optional secret used to key the at-rest hashing (HMAC-SHA-256) of verification and password-reset tokens. If unset, plain SHA-256 is used. Setting a secret adds defense-in-depth against a database-only compromise." + }, + { + "name": "user.security.passwordResetTokenValidityMinutes", + "type": "java.lang.Integer", + "description": "Lifetime in minutes of a password reset token before it expires. Default 1440 (24 hours).", + "defaultValue": 1440 + }, + { + "name": "user.registration.verificationTokenValidityMinutes", + "type": "java.lang.Integer", + "description": "Lifetime in minutes of a registration verification token before it expires. Default 1440 (24 hours).", + "defaultValue": 1440 + }, { "name": "user.security.testHashTime", "type": "java.lang.Boolean", diff --git a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 7963c408..f5da61f8 100644 --- a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1 +1,4 @@ com.digitalsanctuary.spring.user.UserConfiguration +com.digitalsanctuary.spring.user.audit.AuditMailAutoConfiguration +com.digitalsanctuary.spring.user.security.UserSecurityBeansAutoConfiguration +com.digitalsanctuary.spring.user.security.WebSecurityFilterChainAutoConfiguration diff --git a/src/main/resources/config/dsspringuserconfig.properties b/src/main/resources/config/dsspringuserconfig.properties index d033767a..7582f512 100644 --- a/src/main/resources/config/dsspringuserconfig.properties +++ b/src/main/resources/config/dsspringuserconfig.properties @@ -18,12 +18,26 @@ spring.messages.basename=messages/messages,messages/dsspringusermessages # If this path is not writable, the system will automatically fall back to using the system temp directory. user.audit.logFilePath=./logs/user-audit.log -# If true, the audit log will be flushed to disk after every write (less performant). If false, the audit log will be flushed to disk every 10 seconds (more performant). +# If true, the audit log buffer is flushed to disk after every write (less performant, but durable: no +# events are lost on a crash). If false, the buffer is flushed periodically on the user.audit.flushRate +# schedule (more performant, but up to flushRate worth of buffered events can be lost on a hard crash). user.audit.flushOnWrite=false -# The rate at which the audit log will be flushed to disk in milliseconds. +# The rate, in milliseconds, at which the audit log buffer is flushed to disk when flushOnWrite=false. +# Smaller values reduce the durability window (events at risk on a crash) at a small performance cost. user.audit.flushRate=30000 +# Maximum size, in megabytes, of the active audit log file before it is rotated. When exceeded, the +# current file is rotated to .1 (shifting older archives up to maxFiles) and a fresh file is opened. +# Default is 0 (rotation DISABLED, logs grow unbounded). Rotation is opt-in because the audit query/export +# reader currently reads only the active file: once events are rotated into .1, .2, ... they are +# no longer visible to GDPR exports/investigations. Set a positive value only with external log retention. +user.audit.maxFileSizeMb=0 + +# Maximum number of rotated audit log files to retain (e.g. user-audit.log.1 .. user-audit.log.5). The +# oldest archive beyond this count is deleted on rotation. Default is 5. +user.audit.maxFiles=5 + # If true, all events will be logged. user.audit.logEvents=true @@ -32,12 +46,19 @@ user.audit.logEvents=true # Set to 0 or negative to disable the limit (not recommended for production). user.audit.maxQueryResults=10000 +# If true, the library enables JPA auditing (@EnableJpaAuditing) and registers an AuditorAware that captures the +# current user from the Spring Security context for @CreatedBy/@LastModifiedBy fields. Set this to false if your +# application runs its own JPA auditing or supplies its own AuditorAware, so the library does not hijack it. Default true. +user.jpa.auditing.enabled=true + # If true, users can delete their own accounts. If false, accounts are disabled instead of deleted. user.actuallyDeleteAccount=false # If true, a verification email will be sent to the user after registration. If false, the user will be automatically verified. user.registration.sendVerificationEmail=true +# The lifetime, in minutes, of a registration verification token before it expires. Default is 1440 (24 hours). +user.registration.verificationTokenValidityMinutes=1440 # If true, Google OAuth2 will be enabled for registration. user.registration.googleEnabled=false @@ -54,6 +75,12 @@ user.security.failedLoginAttempts=10 user.security.accountLockoutDuration=30 # The bcrypt strength to use for password hashing. The higher the number, the longer it takes to hash the password. The default is 12. The minimum is 4. The maximum is 31. user.security.bcryptStrength=12 +# Optional secret used to key the at-rest hashing of verification and password-reset tokens (HMAC-SHA-256). +# If left unset, tokens are hashed with plain SHA-256, which is adequate because tokens are high-entropy. +# Setting a secret adds defense-in-depth against a database-only compromise. Default: unset. +# user.security.tokenHashSecret= +# The lifetime, in minutes, of a password reset token before it expires. Default is 1440 (24 hours). +user.security.passwordResetTokenValidityMinutes=1440 # If true, the test hash time will be logged to the console on startup. This is useful for determining the optimal bcryptStrength value. user.security.testHashTime=true # The default action for all requests. This can be either deny or allow. @@ -63,7 +90,8 @@ user.security.unprotectedURIs=/,/index.html,/favicon.ico,/css/*,/js/*,/img/*,/us # A comma delimited list of URIs that should be protected by Spring Security if the defaultAction is allow. user.security.protectedURIs=/protected.html # A comma delimited list of URIs that should not be protected by CSRF protection. This may include API endpoints that need to be called without a CSRF token. -user.security.disableCSRFURIs=/no-csrf-test +# Empty by default — no URIs are CSRF-exempt unless the consuming application explicitly lists them here. +user.security.disableCSRFURIs= # The URI for the login page. user.security.loginPageURI=/user/login.html @@ -147,7 +175,8 @@ user.mfa.passwordEntryPointUri=/user/login.html user.mfa.webauthnEntryPointUri=/user/webauthn/login.html # The from address for all emails sent by the application. -user.mail.fromAddress=test@test.com +# Empty by default — the consuming application must set this to a valid address when mail is enabled, otherwise outbound emails will have no valid sender. +user.mail.fromAddress= # The cron expression for the token purge job. This defaults to 3 am every day. user.purgetokens.cron.expression=0 0 3 * * ? # The first year of the copyright. This is used for dispaly of the page footer. diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/MfaFeatureEnabledIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/MfaFeatureEnabledIntegrationTest.java index 4961e4e6..d3e91058 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/MfaFeatureEnabledIntegrationTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/MfaFeatureEnabledIntegrationTest.java @@ -95,6 +95,19 @@ void shouldRedirectToPasswordEntryPointWhenMissingFactorAuthority() throws Excep .andExpect(redirectedUrlPattern(mfaConfigProperties.getPasswordEntryPointUri() + "**")); } + @Test + @DisplayName("auto-unprotects the configured WebAuthn factor entry-point URI (prevents the partial-auth redirect loop)") + void shouldUnprotectConfiguredWebauthnEntryPointUri() throws Exception { + // A partially-authenticated user (missing the WEBAUTHN factor) is redirected to the WebAuthn entry-point + // page to complete it. If that page is itself protected, the redirect target is denied and the user loops + // between entry points (ERR_TOO_MANY_REDIRECTS). The framework auto-unprotects the configured entry-point + // URIs, so an unauthenticated request must NOT be redirected to the login page (a protected path would be). + String webauthnEntryPoint = mfaConfigProperties.getWebauthnEntryPointUri(); + mockMvc.perform(get(webauthnEntryPoint)).andExpect(result -> assertThat(result.getResponse().getStatus()) + .as("MFA WebAuthn entry point %s must be unprotected (not redirected to login)", webauthnEntryPoint) + .isNotEqualTo(org.springframework.http.HttpStatus.FOUND.value())); + } + @Test @DisplayName("should report fully authenticated when user has all required factor authorities") void shouldReportFullyAuthenticatedWhenUserHasAllRequiredFactorAuthorities() throws Exception { diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIRegistrationGuardTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIRegistrationGuardTest.java index 73e78405..e26c8938 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIRegistrationGuardTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIRegistrationGuardTest.java @@ -28,11 +28,8 @@ import com.digitalsanctuary.spring.user.dto.PasswordlessRegistrationDto; import com.digitalsanctuary.spring.user.dto.UserDto; -import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException; import com.digitalsanctuary.spring.user.persistence.model.User; -import com.digitalsanctuary.spring.user.registration.RegistrationContext; -import com.digitalsanctuary.spring.user.registration.RegistrationDecision; -import com.digitalsanctuary.spring.user.registration.RegistrationGuard; +import com.digitalsanctuary.spring.user.registration.RegistrationDeniedException; import com.digitalsanctuary.spring.user.service.PasswordPolicyService; import com.digitalsanctuary.spring.user.service.UserEmailService; import com.digitalsanctuary.spring.user.service.UserService; @@ -67,9 +64,6 @@ class UserAPIRegistrationGuardTest { @Mock private ObjectProvider webAuthnCredentialManagementServiceProvider; - @Mock - private RegistrationGuard registrationGuard; - @Mock private WebAuthnCredentialManagementService webAuthnService; @@ -102,8 +96,9 @@ void shouldRejectFormRegistrationWhenGuardDenies() throws Exception { when(passwordPolicyService.validate(any(), anyString(), anyString(), any(Locale.class))) .thenReturn(Collections.emptyList()); - when(registrationGuard.evaluate(any(RegistrationContext.class))) - .thenReturn(RegistrationDecision.deny("Registration is by invitation only")); + // The guard now fires inside the service: a denial surfaces as RegistrationDeniedException. + when(userService.registerNewUserAccount(any(UserDto.class))) + .thenThrow(new RegistrationDeniedException("Registration is by invitation only")); mockMvc.perform(post("/user/registration") .contentType(MediaType.APPLICATION_JSON) @@ -133,8 +128,6 @@ void shouldAllowFormRegistrationWhenGuardAllows() throws Exception { when(passwordPolicyService.validate(any(), anyString(), anyString(), any(Locale.class))) .thenReturn(Collections.emptyList()); - when(registrationGuard.evaluate(any(RegistrationContext.class))) - .thenReturn(RegistrationDecision.allow()); when(userService.registerNewUserAccount(any(UserDto.class))).thenReturn(registeredUser); mockMvc.perform(post("/user/registration") @@ -159,8 +152,9 @@ void shouldRejectPasswordlessRegistrationWhenGuardDenies() throws Exception { dto.setLastName("User"); when(webAuthnCredentialManagementServiceProvider.getIfAvailable()).thenReturn(webAuthnService); - when(registrationGuard.evaluate(any(RegistrationContext.class))) - .thenReturn(RegistrationDecision.deny("Beta access required")); + // The guard now fires inside the service: a denial surfaces as RegistrationDeniedException. + when(userService.registerPasswordlessAccount(any(PasswordlessRegistrationDto.class))) + .thenThrow(new RegistrationDeniedException("Beta access required")); mockMvc.perform(post("/user/registration/passwordless") .contentType(MediaType.APPLICATION_JSON) @@ -186,8 +180,6 @@ void shouldAllowPasswordlessRegistrationWhenGuardAllows() throws Exception { .build(); when(webAuthnCredentialManagementServiceProvider.getIfAvailable()).thenReturn(webAuthnService); - when(registrationGuard.evaluate(any(RegistrationContext.class))) - .thenReturn(RegistrationDecision.allow()); when(userService.registerPasswordlessAccount(any(PasswordlessRegistrationDto.class))) .thenReturn(registeredUser); diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java index e4630263..62be1f0d 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java @@ -17,8 +17,6 @@ import com.digitalsanctuary.spring.user.exceptions.InvalidOldPasswordException; import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException; import com.digitalsanctuary.spring.user.persistence.model.User; -import com.digitalsanctuary.spring.user.registration.RegistrationDecision; -import com.digitalsanctuary.spring.user.registration.RegistrationGuard; import com.digitalsanctuary.spring.user.service.DSUserDetails; import com.digitalsanctuary.spring.user.service.PasswordPolicyService; import com.digitalsanctuary.spring.user.service.UserEmailService; @@ -93,9 +91,6 @@ public JSONResponse handleSecurityException(SecurityException e) { @Mock private PasswordPolicyService passwordPolicyService; - @Mock - private RegistrationGuard registrationGuard; - @InjectMocks private UserAPI userAPI; @@ -123,9 +118,6 @@ void setUp() { testUserDto.setRole(1); testUserDetails = new DSUserDetails(testUser); - - // Default guard allows all registrations - lenient().when(registrationGuard.evaluate(any())).thenReturn(RegistrationDecision.allow()); // Set field values using reflection ReflectionTestUtils.setField(userAPI, "registrationPendingURI", "/user/registration-pending.html"); diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/UserApiTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/UserApiTest.java index dace41e1..f269140b 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/UserApiTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/UserApiTest.java @@ -1,155 +1,432 @@ package com.digitalsanctuary.spring.user.api; -import static com.digitalsanctuary.spring.user.api.helper.ApiTestHelper.buildUrlEncodedFormEntity; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.Order; +import java.util.Calendar; +import java.util.Date; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ArgumentsSource; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; -import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.test.web.servlet.ResultActions; -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; -import com.digitalsanctuary.spring.user.api.data.ApiTestData; -import com.digitalsanctuary.spring.user.api.data.DataStatus; -import com.digitalsanctuary.spring.user.api.data.Response; -import com.digitalsanctuary.spring.user.api.helper.AssertionsHelper; -import com.digitalsanctuary.spring.user.api.provider.ApiTestRegistrationArgumentsProvider; -import com.digitalsanctuary.spring.user.api.provider.holder.ApiTestArgumentsHolder; -import com.digitalsanctuary.spring.user.dto.PasswordDto; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + import com.digitalsanctuary.spring.user.dto.UserDto; -import com.digitalsanctuary.spring.user.jdbc.Jdbc; +import com.digitalsanctuary.spring.user.persistence.model.PasswordResetToken; import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.repository.PasswordResetTokenRepository; +import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; +import com.digitalsanctuary.spring.user.persistence.repository.VerificationTokenRepository; +import com.digitalsanctuary.spring.user.service.DSUserDetails; +import com.digitalsanctuary.spring.user.service.TokenHasher; +import com.digitalsanctuary.spring.user.service.UserEmailService; import com.digitalsanctuary.spring.user.service.UserService; -import com.digitalsanctuary.spring.user.test.annotations.IntegrationTest; -import com.digitalsanctuary.spring.user.test.fixtures.TestFixtures; -import com.digitalsanctuary.spring.user.api.provider.ApiTestUpdatePasswordArgumentsProvider; +import com.digitalsanctuary.spring.user.test.app.TestApplication; +import com.digitalsanctuary.spring.user.test.config.BaseTestConfiguration; +import com.digitalsanctuary.spring.user.test.config.DatabaseTestConfiguration; +import com.digitalsanctuary.spring.user.test.config.MockMailConfiguration; +import com.digitalsanctuary.spring.user.test.config.OAuth2TestConfiguration; +import com.digitalsanctuary.spring.user.test.config.SecurityTestConfiguration; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.core.user.DefaultOAuth2User; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.ResultActions; -import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; -import org.springframework.beans.factory.annotation.Autowired; -import org.junit.jupiter.api.Disabled; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; -import java.util.HashMap; -import java.util.Map; -import java.util.Collections; +/** + * Integration tests for {@link UserAPI}. + * + *

    Why this test was previously disabled

    + * + *

    + * The original version relied on a custom {@code jdbc.Jdbc} helper whose {@code ConnectionManager} + * opens a hardcoded MariaDB connection ({@code jdbc:mariadb://127.0.0.1:3306/springuser}). + * That fought the test infrastructure, which runs against an in-memory H2 database: the Jdbc-based + * {@code @AfterAll} cleanup could not connect to (or see) the test slice's H2 data, so the class was + * disabled. It also POSTed {@code application/x-www-form-urlencoded} bodies, but the API now consumes + * JSON ({@code @RequestBody}). + *

    + * + *

    How it was ported to the standard infrastructure

    + * + *

    + * This test boots the same full context the {@code @IntegrationTest} composite annotation provides + * (the five standard test configurations against H2 + {@link MockMvc}), but intentionally + * does NOT use {@code @Transactional}. The password-management service methods + * ({@link UserService#registerNewUserAccount} and {@link UserService#changeUserPassword}) are + * declared {@code @Transactional(propagation = NOT_SUPPORTED)} and commit their work in short, + * independent transactions. Under an ambient test transaction those independent commits interleave + * with the test's suspended persistence context and the password change is masked — a test-only + * artifact, not a production bug (verified: the same flow persists correctly with no ambient + * transaction). Running without {@code @Transactional} mirrors production exactly. + *

    + * + *

    + * Because nothing rolls back automatically, the test data is cleaned up explicitly via + * repository-based deletes (replacing the old {@code Jdbc} helper). Each test uses a unique email and + * cleanup runs both before and after every test for isolation. + *

    + * + *

    Why this class uses an isolated in-memory database

    + * + *

    + * The standard {@code test} profile points every test at the shared in-memory database + * {@code jdbc:h2:mem:testdb}. Because this class is intentionally non-{@code @Transactional} (see + * above), its registration / password rows are committed into that shared database. + * JUnit runs test classes in parallel, and other integration tests (e.g. + * {@code WebAuthnFeatureEnabledIntegrationTest}) call {@code userRepository.deleteAll()}; that delete + * races with this class's committed-but-not-yet-cleaned rows, producing intermittent FK / + * optimistic-lock failures. To remove the race entirely, {@link TestPropertySource} below overrides + * {@code spring.datasource.url} to a dedicated in-memory database + * ({@code jdbc:h2:mem:userapitest}). The URL options are copied verbatim from the shared + * {@code application-test.properties} URL ({@code DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE}); only the + * database name differs. Distinct datasource properties also give this class its own Spring context, + * so its committed rows live in a database no other test's {@code deleteAll()} can see. The schema is + * created automatically because the {@code test} profile sets {@code ddl-auto=create-drop}, which + * applies to whatever datasource URL the context boots. + *

    + */ +@SpringBootTest(classes = TestApplication.class) +@AutoConfigureMockMvc +@ActiveProfiles("test") +@TestPropertySource(properties = { + // Isolated in-memory DB so this non-@Transactional class's COMMITTED rows are invisible to the + // shared-DB integration tests' deleteAll(). Options copied verbatim from the shared testdb URL + // (application-test.properties) — only the database name (userapitest) differs. + "spring.datasource.url=jdbc:h2:mem:userapitest;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" +}) +@Import({ + BaseTestConfiguration.class, + DatabaseTestConfiguration.class, + SecurityTestConfiguration.class, + OAuth2TestConfiguration.class, + MockMailConfiguration.class +}) +@DisplayName("UserAPI Integration Tests") +class UserApiTest { -@Disabled("Temporarily disabled - requires specific database setup that conflicts with current test infrastructure") -@IntegrationTest -public class UserApiTest { private static final String URL = "/user"; + /** + * Passwords that satisfy the default password policy (upper, lower, digit, special, min length + * 8) so registration and password changes succeed. + */ + private static final String VALID_PASSWORD = "ValidPass1!"; + private static final String NEW_VALID_PASSWORD = "NewValidPass2!"; + + @Autowired + private MockMvc mockMvc; + @Autowired private UserService userService; @Autowired - private MockMvc mockMvc; + private UserEmailService userEmailService; - private static final UserDto baseTestUser = ApiTestData.BASE_TEST_USER; + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordResetTokenRepository passwordResetTokenRepository; - @AfterAll - public static void afterAll() { - Jdbc.deleteTestUser(baseTestUser); + @Autowired + private VerificationTokenRepository verificationTokenRepository; + + @Autowired + private TokenHasher tokenHasher; + + @Autowired + private PlatformTransactionManager transactionManager; + + private final ObjectMapper objectMapper = JsonMapper.builder().build(); + + private TransactionTemplate txTemplate; + private String testEmail; + private UserDto baseTestUser; + + @BeforeEach + void setUp() { + txTemplate = new TransactionTemplate(transactionManager); + // Unique email per test method so a committed registration in one test never collides with + // another. The nanoTime suffix is sufficient for sequential per-method isolation. + testEmail = "api.tester+" + System.nanoTime() + "@example.com"; + + baseTestUser = new UserDto(); + baseTestUser.setFirstName("Api"); + baseTestUser.setLastName("Tester"); + baseTestUser.setEmail(testEmail); + baseTestUser.setPassword(VALID_PASSWORD); + baseTestUser.setMatchingPassword(VALID_PASSWORD); + + deleteTestUser(testEmail); } - protected ResultActions perform(MockHttpServletRequestBuilder builder) throws Exception { - return mockMvc.perform(builder); + @AfterEach + void tearDown() { + deleteTestUser(testEmail); } /** - * - * @param argumentsHolder - * @throws Exception testing with three params: new user data, exist user data and invalid user data + * Hard-deletes the test user and any associated tokens. Tokens are deleted before the user to + * satisfy the FK from the token tables to {@code user_account}. This replaces the custom + * {@code Jdbc} helper the disabled version used. */ - @ParameterizedTest - @ArgumentsSource(ApiTestRegistrationArgumentsProvider.class) - @Order(1) - // correctly run separately - public void registerUserAccount(ApiTestArgumentsHolder argumentsHolder) throws Exception { - ResultActions action = perform(MockMvcRequestBuilders.post(URL + "/registration").contentType(MediaType.APPLICATION_FORM_URLENCODED) - .content(buildUrlEncodedFormEntity(argumentsHolder.getUserDto()))); - - if (argumentsHolder.getStatus() == DataStatus.NEW) { - action.andExpect(status().isOk()); + private void deleteTestUser(String email) { + // This test is not @Transactional, so cleanup must run in its own committed transaction. + txTemplate.executeWithoutResult(status -> { + User user = userRepository.findByEmail(email); + if (user != null) { + passwordResetTokenRepository.deleteByUser(user); + verificationTokenRepository.deleteByUser(user); + userRepository.delete(user); + } + }); + } + + private String json(Object value) { + return objectMapper.writeValueAsString(value); + } + + @Nested + @DisplayName("Registration") + class Registration { + + @Test + @DisplayName("Should register a brand new user account") + void shouldRegisterNewUser() throws Exception { + mockMvc.perform(post(URL + "/registration") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(json(baseTestUser))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.messages[0]").value("Registration Successful!")); + + assertThat(userService.findUserByEmail(testEmail)).isNotNull(); } - if (argumentsHolder.getStatus() == DataStatus.EXIST) { - action.andExpect(status().isConflict()); + + @Test + @DisplayName("Should return 409 Conflict when the user already exists") + void shouldRejectExistingUser() throws Exception { + // Register once. + userService.registerNewUserAccount(baseTestUser); + + // Register again with the same email. + mockMvc.perform(post(URL + "/registration") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(json(baseTestUser))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.code").value(2)) + .andExpect(jsonPath("$.messages[0]").value("An account already exists for the email address")); } - if (argumentsHolder.getStatus() == DataStatus.INVALID) { - action.andExpect(status().is5xxServerError()); + + @Test + @DisplayName("Should return 400 Bad Request for an invalid (empty) registration") + void shouldRejectInvalidUser() throws Exception { + // An empty DTO fails bean validation (@NotBlank firstName/lastName/email/password) before + // reaching the controller body, so the framework returns a 400. + mockMvc.perform(post(URL + "/registration") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(json(new UserDto()))) + .andExpect(status().isBadRequest()); } + } + + @Nested + @DisplayName("Reset Password Request") + class ResetPasswordRequest { + + @Test + @DisplayName("Should accept a reset-password request and return the pending page") + void shouldAcceptResetPasswordRequest() throws Exception { + userService.registerNewUserAccount(baseTestUser); + + Map body = Map.of("email", testEmail); - MockHttpServletResponse actual = action.andReturn().getResponse(); - Response excepted = argumentsHolder.getResponse(); - AssertionsHelper.compareResponses(actual, excepted); + mockMvc.perform(post(URL + "/resetPassword") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(json(body))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.redirectUrl").value("/user/forgot-password-pending-verification.html")) + .andExpect(jsonPath("$.messages[0]").value("If account exists, password reset email has been sent!")); + } } - @Test - @Order(2) - public void resetPassword() throws Exception { - ResultActions action = perform(MockMvcRequestBuilders.post(URL + "/resetPassword").contentType(MediaType.APPLICATION_FORM_URLENCODED) - .content(buildUrlEncodedFormEntity(baseTestUser))).andExpect(status().isOk()); + @Nested + @DisplayName("Update Password (authenticated)") + class UpdatePassword { + + @Test + @DisplayName("Should update the password with a valid old password") + void shouldUpdatePasswordWhenOldPasswordValid() throws Exception { + User user = userService.registerNewUserAccount(baseTestUser); + + Map body = Map.of( + "oldPassword", VALID_PASSWORD, + "newPassword", NEW_VALID_PASSWORD); + + mockMvc.perform(post(URL + "/updatePassword") + .with(user(new DSUserDetails(user))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(json(body))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); - MockHttpServletResponse actual = action.andReturn().getResponse(); - Response excepted = ApiTestData.resetPassword(); - AssertionsHelper.compareResponses(actual, excepted); + // The stored hash must now verify against the new password. + User reloaded = userService.findUserByEmail(testEmail); + assertThat(userService.checkIfValidOldPassword(reloaded, NEW_VALID_PASSWORD)).isTrue(); + } + + @Test + @DisplayName("Should return 400 when the old password is incorrect") + void shouldRejectUpdateWhenOldPasswordInvalid() throws Exception { + User user = userService.registerNewUserAccount(baseTestUser); + + Map body = Map.of( + "oldPassword", "WrongOldPass9!", + "newPassword", NEW_VALID_PASSWORD); + + mockMvc.perform(post(URL + "/updatePassword") + .with(user(new DSUserDetails(user))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(json(body))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + } } - /** - * Tests the update password functionality with valid and invalid password combinations. - * - * @param argumentsHolder Contains test data for password updates (valid/invalid scenarios) - * @throws Exception if any error occurs during test execution - */ - @ParameterizedTest - @ArgumentsSource(ApiTestUpdatePasswordArgumentsProvider.class) - @Order(3) - public void updatePassword(ApiTestArgumentsHolder argumentsHolder) throws Exception { - // Register and login test user first - login(baseTestUser); - - PasswordDto passwordDto = argumentsHolder.getPasswordDto(); - - ResultActions action = perform(MockMvcRequestBuilders.post(URL + "/updatePassword") - .with(oauth2Login().oauth2User(createTestOAuth2User())) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .content(buildUrlEncodedFormEntity(passwordDto))); - - if (argumentsHolder.getStatus() == DataStatus.VALID) { - action.andExpect(status().isOk()); + @Nested + @DisplayName("Save Password (password-reset completion)") + class SavePassword { + + /** + * Creates a password reset token for the user the way production code does (hashed at rest via + * {@link UserEmailService#createPasswordResetTokenForUser}) and returns the raw token, which is + * what the dual-read lookup supports. + */ + private String createResetTokenForUser(User user) { + String rawToken = "raw-reset-token-" + System.nanoTime(); + userEmailService.createPasswordResetTokenForUser(user, rawToken); + return rawToken; } - if (argumentsHolder.getStatus() == DataStatus.INVALID) { - action.andExpect(status().isBadRequest()); + + private Map savePasswordBody(String token, String newPassword, String confirmPassword) { + return Map.of( + "token", token, + "newPassword", newPassword, + "confirmPassword", confirmPassword); } - MockHttpServletResponse actual = action.andReturn().getResponse(); - Response expected = argumentsHolder.getResponse(); - AssertionsHelper.compareResponses(actual, expected); - } + @Test + @DisplayName("Should change the password and consume the token for a valid reset token") + void shouldChangePasswordWithValidToken() throws Exception { + User user = userService.registerNewUserAccount(baseTestUser); + String rawToken = createResetTokenForUser(user); + + // Sanity: the token is stored hashed, not raw. + assertThat(passwordResetTokenRepository.findByToken(tokenHasher.hash(rawToken))).isNotNull(); + + mockMvc.perform(post(URL + "/savePassword") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(json(savePasswordBody(rawToken, NEW_VALID_PASSWORD, NEW_VALID_PASSWORD)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + // Password was actually changed (the new password now verifies / the stored hash changed). + User reloaded = userService.findUserByEmail(testEmail); + assertThat(userService.checkIfValidOldPassword(reloaded, NEW_VALID_PASSWORD)).isTrue(); - protected void login(UserDto userDto) { - User user; - if ((user = userService.findUserByEmail(userDto.getEmail())) == null) { - user = userService.registerNewUserAccount(userDto); + // Token was consumed/deleted (neither hashed nor raw form remains). + assertThat(passwordResetTokenRepository.findByToken(tokenHasher.hash(rawToken))).isNull(); + assertThat(passwordResetTokenRepository.findByToken(rawToken)).isNull(); } - userService.authWithoutPassword(user); - } - /** - * Creates a test OAuth2 user for authentication in tests. - */ - private OAuth2User createTestOAuth2User() { - return TestFixtures.OAuth2.customUser(baseTestUser.getEmail(), "Test User", "test-user-123"); - } + @Test + @DisplayName("Should reject a reused token on the second attempt") + void shouldRejectReusedToken() throws Exception { + User user = userService.registerNewUserAccount(baseTestUser); + String rawToken = createResetTokenForUser(user); + // First use succeeds. + mockMvc.perform(post(URL + "/savePassword") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(json(savePasswordBody(rawToken, NEW_VALID_PASSWORD, NEW_VALID_PASSWORD)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + + // Second use of the same (now consumed) token is rejected. + mockMvc.perform(post(URL + "/savePassword") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(json(savePasswordBody(rawToken, "AnotherPass3!", "AnotherPass3!")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + } + @Test + @DisplayName("Should reject an expired token") + void shouldRejectExpiredToken() throws Exception { + User user = userService.registerNewUserAccount(baseTestUser); + String rawToken = "raw-expired-token-" + System.nanoTime(); + + // Persist a token whose stored value matches the dual-read hash lookup but is already + // expired. Wrapped in its own transaction since this test is not @Transactional. + txTemplate.executeWithoutResult(status -> { + PasswordResetToken expired = new PasswordResetToken(tokenHasher.hash(rawToken), user); + Calendar cal = Calendar.getInstance(); + cal.add(Calendar.DATE, -1); + expired.setExpiryDate(new Date(cal.getTimeInMillis())); + passwordResetTokenRepository.save(expired); + }); + + mockMvc.perform(post(URL + "/savePassword") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(json(savePasswordBody(rawToken, NEW_VALID_PASSWORD, NEW_VALID_PASSWORD)))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + } + + @Test + @DisplayName("Should return 400 when the new passwords do not match") + void shouldRejectMismatchedPasswords() throws Exception { + User user = userService.registerNewUserAccount(baseTestUser); + String rawToken = createResetTokenForUser(user); + + mockMvc.perform(post(URL + "/savePassword") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(json(savePasswordBody(rawToken, NEW_VALID_PASSWORD, "DifferentPass4!")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.code").value(1)); + + // The token must NOT have been consumed by a failed (mismatched) attempt. + assertThat(passwordResetTokenRepository.findByToken(tokenHasher.hash(rawToken))).isNotNull(); + } + } } diff --git a/src/test/java/com/digitalsanctuary/spring/user/architecture/ArchitectureTest.java b/src/test/java/com/digitalsanctuary/spring/user/architecture/ArchitectureTest.java new file mode 100644 index 00000000..be0fe476 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/architecture/ArchitectureTest.java @@ -0,0 +1,58 @@ +package com.digitalsanctuary.spring.user.architecture; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.library.GeneralCodingRules; + +/** + * Architectural invariants enforced via ArchUnit. These rules document and protect the layering and conventions of the + * library. Every rule here reflects a currently-true property of the codebase; do not add aspirational rules that fail. + * + *

    + * Analysis is scoped to the production package only ({@link ImportOption.DoNotIncludeTests}), so test fixtures and + * scaffolding never affect the results. + */ +@AnalyzeClasses(packages = "com.digitalsanctuary.spring.user", importOptions = ImportOption.DoNotIncludeTests.class) +public class ArchitectureTest { + + /** + * Persistence (JPA entities and repositories) is the lowest layer and must not reach upward into web, API, + * controller, or service code. + */ + @ArchTest + static final ArchRule persistenceDoesNotDependOnUpperLayers = noClasses().that() + .resideInAPackage("com.digitalsanctuary.spring.user.persistence..") + .should() + .dependOnClassesThat() + .resideInAnyPackage("com.digitalsanctuary.spring.user.api..", "com.digitalsanctuary.spring.user.controller..", + "com.digitalsanctuary.spring.user.service..") + .because("persistence is the lowest layer and must not depend on web/API/service layers"); + + /** + * The service layer holds business logic and must not depend on the web-facing API or MVC controller layers. + * Dependencies flow inward: api/controller may use service, never the reverse. + */ + @ArchTest + static final ArchRule serviceDoesNotDependOnWebLayers = noClasses().that() + .resideInAPackage("com.digitalsanctuary.spring.user.service..") + .should() + .dependOnClassesThat() + .resideInAnyPackage("com.digitalsanctuary.spring.user.api..", "com.digitalsanctuary.spring.user.controller..") + .because("services contain business logic and must not depend on web-facing layers"); + + /** + * The library uses SLF4J (typically via Lombok's {@code @Slf4j}) for all logging and must not print to the + * standard streams. + */ + @ArchTest + static final ArchRule noAccessToStandardStreams = GeneralCodingRules.NO_CLASSES_SHOULD_ACCESS_STANDARD_STREAMS; + + /** + * The library standardizes on SLF4J and must not use {@code java.util.logging} directly. + */ + @ArchTest + static final ArchRule noJavaUtilLogging = GeneralCodingRules.NO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGING; +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/architecture/SelfProxiedMethodVisibilityTest.java b/src/test/java/com/digitalsanctuary/spring/user/architecture/SelfProxiedMethodVisibilityTest.java new file mode 100644 index 00000000..14478290 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/architecture/SelfProxiedMethodVisibilityTest.java @@ -0,0 +1,78 @@ +package com.digitalsanctuary.spring.user.architecture; + +import static org.assertj.core.api.Assertions.assertThat; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import com.digitalsanctuary.spring.user.gdpr.GdprDeletionService; +import com.digitalsanctuary.spring.user.service.UserEmailService; +import com.digitalsanctuary.spring.user.service.UserService; + +/** + * Structural guard for the Spring self-invocation proxy pattern used across the service layer. + * + *

    + * Several services split a slow, non-transactional step (e.g. bcrypt hashing) from the short DB write by routing the + * write back through their own Spring proxy: {@code self.persistX(...)} where {@code self} is an + * {@code @Lazy @Autowired} reference to the same bean. For that to work the target method must be + * {@code public} or {@code protected}: Spring generates the CGLIB proxy subclass in a different + * package, so it can only override (and therefore advise + route) {@code public}/{@code protected} methods. A + * package-private target is not overridden, so {@code self.persistX(...)} executes the inherited body on the + * proxy instance — whose {@code @Autowired} fields were never populated — yielding a {@link NullPointerException} (e.g. + * {@code "this.userRepository" is null}) and silently dropping the transaction. + *

    + * + *

    + * This bug is version-dependent at runtime: it reproduces on some Spring Framework patch releases and + * is masked on others, so a {@code @SpringBootTest} on the CI's pinned Spring version cannot be relied on to catch a + * regression. This bytecode-level visibility check is version-independent and fails fast if any self-proxied method is + * ever made package-private (or private) again. + *

    + * + *

    + * Note: this rule is intentionally scoped to the specific methods invoked via {@code self}. Other package-private + * {@code @Transactional} helpers (e.g. {@code RolePrivilegeSetupService.getOrCreateRole}) are called via {@code this} + * from within an already-transactional method and run in the caller's transaction, so they do not need to be proxied. + *

    + */ +@DisplayName("Self-proxied (@Lazy self) transactional methods must be proxyable (public/protected)") +class SelfProxiedMethodVisibilityTest { + + /** + * Every method that is invoked through a {@code self} proxy reference in the production code. Keep this list in + * sync with the {@code self.(...)} call sites in the service/gdpr packages. + */ + static List selfProxiedMethods() { + return List.of(Arguments.of(UserService.class, "persistNewUserAccount"), + Arguments.of(UserService.class, "persistChangedPassword"), + Arguments.of(UserService.class, "persistInitialPassword"), + Arguments.of(GdprDeletionService.class, "executeUserDeletion"), + Arguments.of(UserEmailService.class, "createPasswordResetTokenForUser")); + } + + @ParameterizedTest(name = "{0}#{1} is public or protected") + @MethodSource("selfProxiedMethods") + void selfInvokedMethodMustBeProxyable(final Class declaringClass, final String methodName) { + final List matches = Arrays.stream(declaringClass.getDeclaredMethods()) + .filter(m -> m.getName().equals(methodName)).toList(); + + assertThat(matches).as("expected to find method %s#%s — has it been renamed or removed? Update this guard.", + declaringClass.getSimpleName(), methodName).isNotEmpty(); + + for (final Method method : matches) { + final int modifiers = method.getModifiers(); + assertThat(Modifier.isPublic(modifiers) || Modifier.isProtected(modifiers)) + .as("%s#%s is invoked through the Spring self-proxy and MUST be public or protected; a " + + "package-private/private method is not overridden by the CGLIB proxy subclass (generated " + + "in a different package), so the self-invocation runs on the un-injected proxy instance " + + "and throws NPE while silently losing the transaction.", + declaringClass.getSimpleName(), methodName) + .isTrue(); + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/audit/AuditEventListenerTest.java b/src/test/java/com/digitalsanctuary/spring/user/audit/AuditEventListenerTest.java index fc09ac8c..64fb8541 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/audit/AuditEventListenerTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/audit/AuditEventListenerTest.java @@ -11,9 +11,9 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.ObjectProvider; @ExtendWith(MockitoExtension.class) @DisplayName("AuditEventListener Tests") @@ -25,7 +25,9 @@ class AuditEventListenerTest { @Mock private AuditLogWriter auditLogWriter; - @InjectMocks + @Mock + private ObjectProvider auditLogWriterProvider; + private AuditEventListener auditEventListener; private User testUser; @@ -33,6 +35,11 @@ class AuditEventListenerTest { @BeforeEach void setUp() { + // The provider resolves to the mock writer by default; tests that exercise the + // disabled path never reach getIfAvailable() because isLogEvents() short-circuits. + lenient().when(auditLogWriterProvider.getIfAvailable()).thenReturn(auditLogWriter); + auditEventListener = new AuditEventListener(auditConfig, auditLogWriterProvider); + testUser = UserTestDataBuilder.aUser() .withId(1L) .withEmail("test@example.com") diff --git a/src/test/java/com/digitalsanctuary/spring/user/audit/AuditLogWriterOverrideTest.java b/src/test/java/com/digitalsanctuary/spring/user/audit/AuditLogWriterOverrideTest.java new file mode 100644 index 00000000..2840284f --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/audit/AuditLogWriterOverrideTest.java @@ -0,0 +1,164 @@ +package com.digitalsanctuary.spring.user.audit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import java.lang.reflect.Method; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.mail.javamail.JavaMailSender; +import com.digitalsanctuary.spring.user.mail.MailContentBuilder; + +/** + * Proves that the library's {@link AuditLogWriter} is genuinely replaceable by a consuming application. + * + *

    + * The library historically defined {@link FileAuditLogWriter} as an unconditional component-scanned {@code @Component}. A consumer that supplied their + * own {@link AuditLogWriter} bean got a collision at startup. Following the H8/Task 3.3 lesson, the library's default writer now lives on an + * {@code @AutoConfiguration} class guarded by {@code @ConditionalOnMissingBean(AuditLogWriter.class)}, which loads AFTER user-defined beans so the + * consumer's override reliably wins. + *

    + * + *

    + * The test is deliberately isolated: it drives {@link AuditMailAutoConfiguration} directly through an {@link ApplicationContextRunner} with mock + * collaborators, so it never boots the full JPA/security context (avoiding JPA-metamodel pollution across parallel integration contexts). + *

    + */ +@DisplayName("AuditLogWriter Override Tests") +class AuditLogWriterOverrideTest { + + /** + * Drives the real {@link AuditMailAutoConfiguration}. An {@link AuditConfig} is supplied as a collaborator because {@link FileAuditLogWriter} + * depends on it. Registered as an auto-configuration so {@code @ConditionalOnMissingBean} evaluates AFTER any consumer-supplied beans. + */ + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("user.mail.fromAddress=test@example.com") + .withBean(AuditConfig.class, AuditLogWriterOverrideTest::auditConfig) + .withBean(MailContentBuilder.class, () -> mock(MailContentBuilder.class)) + .withBean("javaMailSender", JavaMailSender.class, () -> mock(JavaMailSender.class)) + .withConfiguration(AutoConfigurations.of(AuditMailAutoConfiguration.class)); + + private static AuditConfig auditConfig() { + AuditConfig config = new AuditConfig(); + // logEvents=true so the default writer bean is created under the @ConditionalOnProperty gate. + config.setLogEvents(true); + config.setLogFilePath(System.getProperty("java.io.tmpdir") + "/audit-override-test.log"); + config.setFlushOnWrite(true); + config.setFlushRate(1000); + return config; + } + + @Nested + @DisplayName("Default behavior: library FileAuditLogWriter present when consumer supplies none") + class Defaults { + + @Test + @DisplayName("Library provides a FileAuditLogWriter by default") + void libraryWriterPresentByDefault() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(AuditLogWriter.class); + assertThat(context.getBean(AuditLogWriter.class)).isInstanceOf(FileAuditLogWriter.class); + }); + } + } + + @Nested + @DisplayName("Override behavior: consumer AuditLogWriter wins") + class Overrides { + + @Test + @DisplayName("Consumer AuditLogWriter replaces the library's FileAuditLogWriter, which backs off") + void consumerWriterWins() { + contextRunner.withUserConfiguration(ConsumerAuditLogWriterConfig.class).run(context -> { + assertThat(context).hasSingleBean(AuditLogWriter.class); + AuditLogWriter active = context.getBean(AuditLogWriter.class); + assertThat(active).as("consumer's writer must win").isSameAs(ConsumerAuditLogWriterConfig.CONSUMER_WRITER); + assertThat(active).as("library FileAuditLogWriter must NOT be the active writer").isNotInstanceOf(FileAuditLogWriter.class); + assertThat(context).as("library FileAuditLogWriter must back off entirely").doesNotHaveBean(FileAuditLogWriter.class); + }); + } + } + + @Nested + @DisplayName("Disabled audit: context starts even though no writer bean exists (regression: Task 3.5)") + class DisabledAudit { + + /** + * Regression guard for the Task 3.5 bug. When {@code user.audit.logEvents=false} the library's + * {@link FileAuditLogWriter} bean is suppressed by {@code @ConditionalOnProperty}. The unconditional + * {@link AuditEventListener} must still start, because its {@link AuditLogWriter} dependency is now resolved + * through an {@link org.springframework.beans.factory.ObjectProvider} rather than a hard constructor injection. + * Before the fix this configuration threw {@code UnsatisfiedDependencyException} ("No qualifying bean of type + * 'AuditLogWriter' available") at context startup. + */ + @Test + @DisplayName("Context starts with AuditEventListener present and logEvents=false (no UnsatisfiedDependencyException)") + void contextStartsWhenAuditDisabledAndListenerPresent() { + contextRunner.withPropertyValues("user.audit.logEvents=false") + .withUserConfiguration(AuditEventListenerConfig.class) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(AuditEventListener.class); + assertThat(context).as("library FileAuditLogWriter must not be created when logEvents=false") + .doesNotHaveBean(FileAuditLogWriter.class); + assertThat(context).as("no AuditLogWriter bean of any kind should exist") + .doesNotHaveBean(AuditLogWriter.class); + }); + } + } + + @Nested + @DisplayName("Annotation contract on the auto-configuration bean method") + class AnnotationContract { + + @Test + @DisplayName("fileAuditLogWriter() is @ConditionalOnMissingBean") + void writerIsConditional() throws Exception { + Method method = AuditMailAutoConfiguration.class.getMethod("fileAuditLogWriter", AuditConfig.class); + assertThat(method.getAnnotation(ConditionalOnMissingBean.class)).isNotNull(); + } + } + + // ---- Consumer-supplied stand-in configuration. Not @Configuration so the integration tests' component scan does not pick it up. ---- + + static class ConsumerAuditLogWriterConfig { + static final AuditLogWriter CONSUMER_WRITER = new CustomAuditLogWriter(); + + @Bean + AuditLogWriter consumerAuditLogWriter() { + return CONSUMER_WRITER; + } + } + + /** + * Registers the real {@link AuditEventListener} so the disabled-audit regression test can prove the context starts + * even when no {@link AuditLogWriter} bean exists. Spring supplies the {@code ObjectProvider} + * automatically; {@link AuditConfig} is provided by the shared context runner. + */ + static class AuditEventListenerConfig { + @Bean + AuditEventListener auditEventListener(AuditConfig auditConfig, + org.springframework.beans.factory.ObjectProvider auditLogWriterProvider) { + return new AuditEventListener(auditConfig, auditLogWriterProvider); + } + } + + /** + * A trivial custom {@link AuditLogWriter} that is NOT a {@link FileAuditLogWriter}, so the test can assert the consumer's instance wins. + */ + static class CustomAuditLogWriter implements AuditLogWriter { + @Override + public void writeLog(AuditEvent event) {} + + @Override + public void setup() {} + + @Override + public void cleanup() {} + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryServiceTest.java index a9bd4618..a70a990a 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryServiceTest.java @@ -135,6 +135,60 @@ void returnsEvents_sortedByTimestampDescending() throws IOException { } } + @Nested + @DisplayName("Bounded query") + class BoundedQuery { + + @Test + @DisplayName("caps results at maxQueryResults and returns the most recent window") + void capsResults_andReturnsMostRecent() throws IOException { + // Given - small cap, and more matching lines than the cap + setupLogFilePath(); + when(auditConfig.getMaxQueryResults()).thenReturn(5); + + StringBuilder sb = new StringBuilder(); + sb.append("Date|Action|Action Status|User ID|Email|IP Address|SessionId|Message|User Agent|Extra Data\n"); + // 20 events, increasing timestamps; the newest 5 are minutes 15..19 + for (int i = 0; i < 20; i++) { + String ts = String.format("2025-01-15T10:%02d:00Z", i); + sb.append(ts).append("|Action").append(i) + .append("|Success|1|test@example.com|127.0.0.1|sess").append(i) + .append("|msg|Mozilla/5.0|null\n"); + } + Files.writeString(logFile, sb.toString()); + + // When + List result = queryService.findByUser(testUser); + + // Then - exactly maxQueryResults, newest first (Action19 .. Action15) + assertThat(result).hasSize(5); + assertThat(result).extracting(AuditEventDTO::getAction) + .containsExactly("Action19", "Action18", "Action17", "Action16", "Action15"); + } + + @Test + @DisplayName("returns all matches when fewer than maxQueryResults, newest first") + void returnsAll_whenUnderCap() throws IOException { + // Given - cap larger than the number of matching lines + setupLogFilePath(); + when(auditConfig.getMaxQueryResults()).thenReturn(100); + String logContent = """ + Date|Action|Action Status|User ID|Email|IP Address|SessionId|Message|User Agent|Extra Data + 2025-01-15T08:00:00Z|Login|Success|1|test@example.com|127.0.0.1|sess1|First|Mozilla/5.0|null + 2025-01-15T12:00:00Z|Logout|Success|1|test@example.com|127.0.0.1|sess2|Third|Mozilla/5.0|null + 2025-01-15T10:00:00Z|PasswordUpdate|Success|1|test@example.com|127.0.0.1|sess3|Second|Mozilla/5.0|null + """; + Files.writeString(logFile, logContent); + + // When + List result = queryService.findByUser(testUser); + + // Then - preserves newest-first ordering of the full result set + assertThat(result).extracting(AuditEventDTO::getAction) + .containsExactly("Logout", "PasswordUpdate", "Login"); + } + } + @Nested @DisplayName("findByUserAndAction") class FindByUserAndAction { diff --git a/src/test/java/com/digitalsanctuary/spring/user/audit/FileAuditLogWriterTest.java b/src/test/java/com/digitalsanctuary/spring/user/audit/FileAuditLogWriterTest.java index a5bd07a5..0f049c88 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/audit/FileAuditLogWriterTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/audit/FileAuditLogWriterTest.java @@ -334,6 +334,92 @@ void writeLog_flushesWhenConfigured() throws IOException { } } + @Nested + @DisplayName("Rotation Tests") + class RotationTests { + + private AuditEvent event(String action) { + return AuditEvent.builder() + .source(this) + .action(action) + .actionStatus("Success") + .message("rotation test message padding padding padding") + .build(); + } + + @Test + @DisplayName("rotates the active file when the size threshold is exceeded") + void rotatesActiveFile_whenThresholdExceeded() throws IOException { + // Given - a tiny effective byte threshold so a few writes trigger rotation + when(auditConfig.isFlushOnWrite()).thenReturn(true); + when(auditConfig.getMaxFileSizeMb()).thenReturn(10); // positive => rotation enabled + when(auditConfig.getMaxFiles()).thenReturn(5); + fileAuditLogWriter.setup(); + fileAuditLogWriter.setMaxFileSizeBytesForTesting(120L); // exceeded quickly + + Path active = Path.of(logFilePath); + Path rotated1 = Path.of(logFilePath + ".1"); + + // When - write enough to exceed the threshold + for (int i = 0; i < 10; i++) { + fileAuditLogWriter.writeLog(event("Action" + i)); + } + + // Then - a rotated file exists and the active file is fresh (header + recent writes only) + assertTrue(Files.exists(rotated1), "rotated file .1 should exist after rotation"); + assertTrue(Files.exists(active), "active log file should be reopened after rotation"); + String activeContent = Files.readString(active); + assertTrue(activeContent.contains("Date|Action|Action Status"), + "freshly reopened active file should contain the header"); + assertTrue(Files.size(active) < Files.size(rotated1) + 4096, + "active file should be smaller than total rotated content"); + } + + @Test + @DisplayName("respects maxFiles by deleting the oldest rotated file") + void respectsMaxFiles_deletingOldest() throws IOException { + // Given - keep only 2 rotated files + when(auditConfig.isFlushOnWrite()).thenReturn(true); + when(auditConfig.getMaxFileSizeMb()).thenReturn(10); + when(auditConfig.getMaxFiles()).thenReturn(2); + fileAuditLogWriter.setup(); + fileAuditLogWriter.setMaxFileSizeBytesForTesting(80L); + + Path rotated1 = Path.of(logFilePath + ".1"); + Path rotated2 = Path.of(logFilePath + ".2"); + Path rotated3 = Path.of(logFilePath + ".3"); + + // When - force several rotations + for (int i = 0; i < 40; i++) { + fileAuditLogWriter.writeLog(event("Action" + i)); + } + + // Then - at most maxFiles rotated files are kept; .3 must never exist + assertTrue(Files.exists(rotated1), ".1 should exist"); + assertTrue(Files.exists(rotated2), ".2 should exist"); + assertTrue(!Files.exists(rotated3), ".3 should have been deleted (exceeds maxFiles)"); + } + + @Test + @DisplayName("does not rotate when rotation is disabled (maxFileSizeMb <= 0)") + void doesNotRotate_whenDisabled() throws IOException { + // Given - rotation disabled + when(auditConfig.isFlushOnWrite()).thenReturn(true); + when(auditConfig.getMaxFileSizeMb()).thenReturn(0); + fileAuditLogWriter.setup(); + + Path rotated1 = Path.of(logFilePath + ".1"); + + // When - write a lot + for (int i = 0; i < 50; i++) { + fileAuditLogWriter.writeLog(event("Action" + i)); + } + + // Then - no rotation occurred + assertTrue(!Files.exists(rotated1), "no rotated file should exist when rotation is disabled"); + } + } + @Nested @DisplayName("Complete Event Data Tests") class CompleteEventDataTests { diff --git a/src/test/java/com/digitalsanctuary/spring/user/controller/UserActionControllerTest.java b/src/test/java/com/digitalsanctuary/spring/user/controller/UserActionControllerTest.java index 5bc680de..091a6fc8 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/controller/UserActionControllerTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/controller/UserActionControllerTest.java @@ -163,10 +163,11 @@ void confirmRegistration_validToken_confirmsAndAuthenticatesUser() throws Except .andExpect(redirectedUrl("/user/registration-complete.html?lang=en&message=Account+verified+successfully")) .andExpect(model().attribute("message", "Account verified successfully")); - // Verify interactions + // Verify interactions. The token is consumed atomically inside validateVerificationToken, so the + // controller no longer issues a separate deleteVerificationToken call. verify(userService).authWithoutPassword(testUser); - verify(userVerificationService).deleteVerificationToken(token); - + verify(userVerificationService, never()).deleteVerificationToken(anyString()); + // Verify audit event ArgumentCaptor auditCaptor = ArgumentCaptor.forClass(AuditEvent.class); verify(eventPublisher).publishEvent(auditCaptor.capture()); diff --git a/src/test/java/com/digitalsanctuary/spring/user/event/UserDisabledEventTest.java b/src/test/java/com/digitalsanctuary/spring/user/event/UserDisabledEventTest.java new file mode 100644 index 00000000..de3cc049 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/event/UserDisabledEventTest.java @@ -0,0 +1,62 @@ +package com.digitalsanctuary.spring.user.event; + +import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("UserDisabledEvent Tests") +class UserDisabledEventTest { + + private Object eventSource; + + @BeforeEach + void setUp() { + eventSource = this; + } + + @Test + @DisplayName("Event creation stores user ID and email") + void eventCreation_storesUserIdAndEmail() { + // When + UserDisabledEvent event = new UserDisabledEvent(eventSource, 1L, "test@example.com"); + + // Then + assertThat(event.getUserId()).isEqualTo(1L); + assertThat(event.getUserEmail()).isEqualTo("test@example.com"); + assertThat(event.getSource()).isEqualTo(eventSource); + } + + @Test + @DisplayName("Event with different sources") + void event_withDifferentSources() { + // Given + Object source1 = new Object(); + Object source2 = "Different Source"; + + // When + UserDisabledEvent event1 = new UserDisabledEvent(source1, 1L, "user1@example.com"); + UserDisabledEvent event2 = new UserDisabledEvent(source2, 2L, "user2@example.com"); + + // Then + assertThat(event1.getSource()).isEqualTo(source1); + assertThat(event2.getSource()).isEqualTo(source2); + assertThat(event1.getUserId()).isNotEqualTo(event2.getUserId()); + } + + @Test + @DisplayName("Event timestamp is set on creation") + void event_timestampIsSet() { + // Given + long beforeCreation = System.currentTimeMillis(); + + // When + UserDisabledEvent event = new UserDisabledEvent(eventSource, 1L, "test@example.com"); + + // Then + long afterCreation = System.currentTimeMillis(); + assertThat(event.getTimestamp()).isGreaterThanOrEqualTo(beforeCreation); + assertThat(event.getTimestamp()).isLessThanOrEqualTo(afterCreation); + } + +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionAfterCommitIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionAfterCommitIntegrationTest.java new file mode 100644 index 00000000..4d97fe39 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionAfterCommitIntegrationTest.java @@ -0,0 +1,195 @@ +package com.digitalsanctuary.spring.user.gdpr; + +import static org.assertj.core.api.Assertions.assertThat; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.event.EventListener; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import com.digitalsanctuary.spring.user.event.UserDeletedEvent; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; +import com.digitalsanctuary.spring.user.test.app.TestApplication; +import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder; + +/** + * Integration test proving that {@link GdprDeletionService} actually runs the deletion inside a transaction and + * publishes {@link UserDeletedEvent} only after that transaction commits. This is the integration-level + * proof for the self-invocation fix: {@code deleteUser} now invokes {@code executeUserDeletion} through the Spring + * proxy ({@code self}), so the {@code @Transactional} boundary is honored. A unit test cannot verify this — the + * transactional proxy only exists in a real context. + * + *

    + * This test is deliberately not {@code @Transactional}: the service must run and commit (or roll + * back) its own transaction so the after-commit synchronization fires for real. Two behaviors are asserted: + *

    + *
      + *
    • Happy path: the user row is committed (gone) by the time the event listener runs, and no transaction is + * active during event delivery — i.e. the event is genuinely after-commit, not mid-transaction.
    • + *
    • Rollback path: when a data contributor throws, the whole deletion rolls back (the user still exists) and + * no {@link UserDeletedEvent} is published — proving the event is not emitted on a failed/partial delete.
    • + *
    + */ +@SpringBootTest(classes = TestApplication.class) +@ActiveProfiles("test") +@Import(GdprDeletionAfterCommitIntegrationTest.TestBeans.class) +@DisplayName("GdprDeletionService after-commit / rollback integration") +class GdprDeletionAfterCommitIntegrationTest { + + @Autowired + private GdprDeletionService gdprDeletionService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private DeletedEventRecorder recorder; + + @Autowired + private TogglableFailingContributor failingContributor; + + @AfterEach + void cleanUp() { + // No test-managed transaction here, so clean up committed rows and reset shared test state. + userRepository.deleteAll(); + recorder.reset(); + failingContributor.disarm(); + } + + @Test + @DisplayName("publishes UserDeletedEvent AFTER the deletion transaction commits") + void publishesEventAfterCommit() { + User user = userRepository.save(UserTestDataBuilder.aUser() + .withId(null) // let the DB assign the id so save() performs an INSERT, not a merge + .withEmail("after-commit-" + System.nanoTime() + "@test.com") + .withFirstName("After").withLastName("Commit").enabled().build()); + Long userId = user.getId(); + + GdprDeletionService.DeletionResult result = gdprDeletionService.deleteUser(user, false); + + assertThat(result.isSuccess()).as("deletion should succeed").isTrue(); + assertThat(recorder.isReceived()).as("UserDeletedEvent should be published").isTrue(); + // The whole point of the fix: by the time the event fires, the delete has COMMITTED. + assertThat(recorder.wasUserAbsentAtEventTime()) + .as("user row must already be deleted when the event is delivered").isTrue(); + // Distinguishes the fix from the bug: the event is delivered from within the transaction's afterCommit + // synchronization (synchronization active). In the broken self-invocation version there was no transaction, + // so the event published immediately with NO synchronization active and this would be false. + assertThat(recorder.wasSynchronizationActiveAtEventTime()) + .as("event must be delivered inside the transaction's afterCommit synchronization").isTrue(); + assertThat(userRepository.findById(userId)).as("user is deleted").isEmpty(); + } + + @Test + @DisplayName("rolls back the deletion and publishes NO event when a contributor fails") + void rollsBackAndPublishesNoEventOnFailure() { + failingContributor.arm(); + User user = userRepository.save(UserTestDataBuilder.aUser() + .withId(null) // let the DB assign the id so save() performs an INSERT, not a merge + .withEmail("rollback-" + System.nanoTime() + "@test.com") + .withFirstName("Roll").withLastName("Back").enabled().build()); + Long userId = user.getId(); + + GdprDeletionService.DeletionResult result = gdprDeletionService.deleteUser(user, false); + + assertThat(result.isSuccess()).as("deletion should fail when a contributor throws").isFalse(); + assertThat(recorder.isReceived()) + .as("no UserDeletedEvent may be published when the transaction rolled back").isFalse(); + assertThat(userRepository.findById(userId)) + .as("the user must still exist — the deletion transaction rolled back atomically").isPresent(); + } + + @TestConfiguration + static class TestBeans { + @Bean + DeletedEventRecorder deletedEventRecorder(UserRepository userRepository) { + return new DeletedEventRecorder(userRepository); + } + + @Bean + TogglableFailingContributor togglableFailingContributor() { + return new TogglableFailingContributor(); + } + } + + /** + * Records receipt of {@link UserDeletedEvent} and captures, at event-delivery time, whether the user row is + * already gone (committed) and whether a transaction is still active. Used to prove after-commit semantics. + */ + static class DeletedEventRecorder { + private final UserRepository userRepository; + private volatile boolean received; + private volatile boolean userAbsentAtEventTime; + private volatile boolean synchronizationActiveAtEventTime; + + DeletedEventRecorder(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @EventListener + void onUserDeleted(UserDeletedEvent event) { + received = true; + synchronizationActiveAtEventTime = TransactionSynchronizationManager.isSynchronizationActive(); + userAbsentAtEventTime = userRepository.findById(event.getUserId()).isEmpty(); + } + + boolean isReceived() { + return received; + } + + boolean wasUserAbsentAtEventTime() { + return userAbsentAtEventTime; + } + + boolean wasSynchronizationActiveAtEventTime() { + return synchronizationActiveAtEventTime; + } + + void reset() { + received = false; + userAbsentAtEventTime = false; + synchronizationActiveAtEventTime = false; + } + } + + /** + * A {@link GdprDataContributor} that throws during {@code prepareForDeletion} only when armed, used to force a + * transaction rollback in the middle of the deletion. + */ + static class TogglableFailingContributor implements GdprDataContributor { + private final AtomicBoolean armed = new AtomicBoolean(false); + + void arm() { + armed.set(true); + } + + void disarm() { + armed.set(false); + } + + @Override + public String getDataKey() { + return "test-failing-contributor"; + } + + @Override + public Map exportUserData(User user) { + return Map.of(); + } + + @Override + public void prepareForDeletion(User user) { + if (armed.get()) { + throw new RuntimeException("Simulated contributor failure to force rollback"); + } + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionServiceTest.java index acfb157a..344c6a47 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionServiceTest.java @@ -16,6 +16,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; import com.digitalsanctuary.spring.user.dto.GdprExportDTO; import com.digitalsanctuary.spring.user.event.UserDeletedEvent; import com.digitalsanctuary.spring.user.event.UserPreDeleteEvent; @@ -57,6 +58,9 @@ class GdprDeletionServiceTest { @BeforeEach void setUp() { + // In production 'self' is the Spring proxy used to apply @Transactional on executeUserDeletion. + // There is no proxy in a unit test, so point it at the SUT to exercise the real call path. + ReflectionTestUtils.setField(gdprDeletionService, "self", gdprDeletionService); testUser = UserTestDataBuilder.aVerifiedUser() .withId(1L) .withEmail("test@example.com") diff --git a/src/test/java/com/digitalsanctuary/spring/user/jdbc/ConnectionManager.java b/src/test/java/com/digitalsanctuary/spring/user/jdbc/ConnectionManager.java deleted file mode 100644 index 4f8e9eda..00000000 --- a/src/test/java/com/digitalsanctuary/spring/user/jdbc/ConnectionManager.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.digitalsanctuary.spring.user.jdbc; - -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.SQLException; - -public class ConnectionManager { - - private static final String driver = "org.mariadb.jdbc.Driver"; - - private static final String url = "jdbc:mariadb://127.0.0.1:3306/springuser"; - - private static final String username = "springuser"; - - private static final String password = "springuser"; - - static { - initDriver(); - } - - private static void initDriver() { - try { - Class.forName(driver); - } catch (ClassNotFoundException e) { - throw new RuntimeException(e); - } - } - - public static Connection open() { - try { - return DriverManager.getConnection(url, username, password); - } catch (SQLException e) { - throw new RuntimeException(e); - } - } - -} diff --git a/src/test/java/com/digitalsanctuary/spring/user/jdbc/Jdbc.java b/src/test/java/com/digitalsanctuary/spring/user/jdbc/Jdbc.java deleted file mode 100644 index 5773bc86..00000000 --- a/src/test/java/com/digitalsanctuary/spring/user/jdbc/Jdbc.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.digitalsanctuary.spring.user.jdbc; - -import com.digitalsanctuary.spring.user.dto.UserDto; -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; - - -/** - * Using for delete/save user test data - */ -public class Jdbc { - private static final String DELETE_VERIFICATION_TOKEN_QUERY = - "DELETE FROM verification_token WHERE user_id = (SELECT id FROM user_account WHERE email = ?)"; - private static final String DELETE_TEST_USER_ROLE = "DELETE FROM users_roles WHERE user_id = (SELECT id FROM user_account WHERE email = ?)"; - private static final String DELETE_TEST_PASSWORD_RESET_TOKEN = - "DELETE FROM password_reset_token WHERE user_id = (SELECT id FROM user_account WHERE email = ?)"; - private static final String DELETE_TEST_USER_QUERY = "DELETE FROM user_account WHERE email = ?"; - private static final String GET_LAST_USER_ID_QUERY = "SELECT max(id) FROM user_account"; - private static final String GET_PASSWORD_RESET_TOKEN_BY_USER_EMAIL_QUERY = - "SELECT token FROM password_reset_token WHERE user_id = (SELECT id FROM user_account WHERE email = ?)"; - - private static final String SAVE_TEST_USER_QUERY = "INSERT INTO user_account (id, first_name, last_name, email, " - + "password, enabled, failed_login_attempts, locked) VALUES (?,?,?,?,?,?,?,?)"; - - public static void deleteTestUser(UserDto userDto) { - try (Connection connection = ConnectionManager.open()) { - String[] params = new String[] {userDto.getEmail()}; - execute(connection, DELETE_VERIFICATION_TOKEN_QUERY, params); - execute(connection, DELETE_TEST_USER_ROLE, params); - execute(connection, DELETE_TEST_PASSWORD_RESET_TOKEN, params); - execute(connection, DELETE_TEST_USER_QUERY, params); - } catch (SQLException e) { - throw new RuntimeException(e); - } - } - - - public static void saveTestUser(UserDto userDto) { - try (Connection connection = ConnectionManager.open()) { - ResultSet resultSet = connection.prepareStatement(GET_LAST_USER_ID_QUERY).executeQuery(); - int id = 0; - if (resultSet.next()) { - id = (resultSet.getInt(1) + 1); - } - Object[] params = new Object[] {id, userDto.getFirstName(), userDto.getLastName(), userDto.getEmail(), "TEST_USER_ENCODED_PASSWORD", true, - 0, false}; - execute(connection, SAVE_TEST_USER_QUERY, params); - } catch (SQLException e) { - throw new RuntimeException(e); - } - } - - private static void execute(Connection connection, String query, Object[] params) throws SQLException { - PreparedStatement statement = connection.prepareStatement(query); - for (int i = 0; i < params.length; i++) { - Object param = params[i]; - if (param instanceof Integer) { - statement.setInt((i + 1), (Integer) param); - } - if (param instanceof String) { - statement.setString((i + 1), (String) param); - } - if (param instanceof Boolean) { - statement.setBoolean((i + 1), (Boolean) param); - } - } - statement.executeUpdate(); - } - - public static String getPasswordRestTokenByUserEmail(String email) { - String token = ""; - try (Connection connection = ConnectionManager.open()) { - PreparedStatement statement = connection.prepareStatement(GET_PASSWORD_RESET_TOKEN_BY_USER_EMAIL_QUERY); - statement.setString(1, email); - ResultSet set = statement.executeQuery(); - if (set.next()) { - token = set.getString(1); - } - } catch (SQLException e) { - throw new RuntimeException(e); - } - return token; - } -} diff --git a/src/test/java/com/digitalsanctuary/spring/user/listener/RegistrationListenerTest.java b/src/test/java/com/digitalsanctuary/spring/user/listener/RegistrationListenerTest.java index 9bedfbb1..dbef822e 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/listener/RegistrationListenerTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/listener/RegistrationListenerTest.java @@ -35,12 +35,14 @@ class RegistrationListenerTest { @BeforeEach void setUp() { + // Default the shared fixture to a DISABLED (not-yet-verified) user so the "send verification email" + // tests exercise the email-sending path. The skip-for-enabled-users behavior is covered separately. testUser = UserTestDataBuilder.aUser() .withId(1L) .withEmail("test@example.com") .withFirstName("Test") .withLastName("User") - .enabled() + .disabled() .build(); appUrl = "https://example.com"; locale = Locale.ENGLISH; @@ -51,12 +53,17 @@ void setUp() { class RegistrationEventHandlingTests { @Test - @DisplayName("onApplicationEvent - sends verification email when enabled") + @DisplayName("onApplicationEvent - sends verification email when enabled and user is not yet verified") void onApplicationEvent_sendsVerificationEmailWhenEnabled() { - // Given + // Given - a DISABLED (not yet verified) user, as produced by the form-registration path when + // email verification is required. ReflectionTestUtils.setField(registrationListener, "sendRegistrationVerificationEmail", true); + User unverifiedUser = UserTestDataBuilder.aUser() + .withEmail("unverified@example.com") + .disabled() + .build(); OnRegistrationCompleteEvent event = OnRegistrationCompleteEvent.builder() - .user(testUser) + .user(unverifiedUser) .locale(locale) .appUrl(appUrl) .build(); @@ -65,7 +72,30 @@ void onApplicationEvent_sendsVerificationEmailWhenEnabled() { registrationListener.onApplicationEvent(event); // Then - verify(userEmailService).sendRegistrationVerificationEmail(testUser, appUrl); + verify(userEmailService).sendRegistrationVerificationEmail(unverifiedUser, appUrl); + } + + @Test + @DisplayName("onApplicationEvent - skips verification email for already-enabled (OAuth/OIDC) user") + void onApplicationEvent_skipsVerificationEmailForEnabledUser() { + // Given - sending is enabled, but the user is already enabled (e.g. a first-time OAuth2/OIDC + // registration where the provider has already verified the email). They must NOT receive an email. + ReflectionTestUtils.setField(registrationListener, "sendRegistrationVerificationEmail", true); + User enabledUser = UserTestDataBuilder.aUser() + .withEmail("oauth@example.com") + .enabled() + .build(); + OnRegistrationCompleteEvent event = OnRegistrationCompleteEvent.builder() + .user(enabledUser) + .locale(locale) + .appUrl(appUrl) + .build(); + + // When + registrationListener.onApplicationEvent(event); + + // Then + verify(userEmailService, never()).sendRegistrationVerificationEmail(any(), any()); } @Test diff --git a/src/test/java/com/digitalsanctuary/spring/user/mail/MailExecutorConfigurationTest.java b/src/test/java/com/digitalsanctuary/spring/user/mail/MailExecutorConfigurationTest.java new file mode 100644 index 00000000..fa923f9e --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/mail/MailExecutorConfigurationTest.java @@ -0,0 +1,95 @@ +package com.digitalsanctuary.spring.user.mail; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Method; +import java.util.concurrent.ThreadPoolExecutor; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +/** + * Verifies the dedicated bounded {@code dsMailExecutor} so an SMTP stall on the mail retry/backoff path cannot starve the shared default async + * executor that the rest of the library's {@code @Async} work relies on. + * + *

    + * The bean-presence assertions run against a minimal {@link ApplicationContextRunner} that imports only {@link MailExecutorConfiguration}, so the test + * never boots the full JPA/security context and avoids JPA-metamodel pollution. + *

    + */ +@DisplayName("Mail Executor Configuration Tests") +class MailExecutorConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner(); + + @Nested + @DisplayName("Bean presence and bounded configuration") + class BeanPresence { + + @Test + @DisplayName("dsMailExecutor bean exists and is a ThreadPoolTaskExecutor") + void dsMailExecutorBeanExists() { + contextRunner.withUserConfiguration(MailExecutorConfiguration.class).run(context -> { + assertThat(context).hasBean("dsMailExecutor"); + assertThat(context.getBean("dsMailExecutor")).isInstanceOf(ThreadPoolTaskExecutor.class); + }); + } + + @Test + @DisplayName("dsMailExecutor is bounded with CallerRunsPolicy backpressure") + void dsMailExecutorIsBounded() { + contextRunner.withUserConfiguration(MailExecutorConfiguration.class).run(context -> { + ThreadPoolTaskExecutor executor = context.getBean("dsMailExecutor", ThreadPoolTaskExecutor.class); + assertThat(executor.getCorePoolSize()).isEqualTo(2); + assertThat(executor.getMaxPoolSize()).isEqualTo(4); + assertThat(executor.getQueueCapacity()).isEqualTo(50); + assertThat(executor.getThreadPoolExecutor().getRejectedExecutionHandler()) + .isInstanceOf(ThreadPoolExecutor.CallerRunsPolicy.class); + }); + } + } + + @Nested + @DisplayName("Override behavior") + class Override { + + @Test + @DisplayName("Consumer can override dsMailExecutor by name") + void consumerCanOverride() { + ThreadPoolTaskExecutor custom = new ThreadPoolTaskExecutor(); + custom.initialize(); + contextRunner.withUserConfiguration(MailExecutorConfiguration.class) + .withBean("dsMailExecutor", ThreadPoolTaskExecutor.class, () -> custom) + .run(context -> { + assertThat(context).hasSingleBean(ThreadPoolTaskExecutor.class); + assertThat(context.getBean("dsMailExecutor")).isSameAs(custom); + }); + } + } + + @Nested + @DisplayName("MailService @Async wiring") + class AsyncWiring { + + @Test + @DisplayName("sendSimpleMessage runs on the dsMailExecutor") + void sendSimpleMessageQualified() throws Exception { + Method method = MailService.class.getMethod("sendSimpleMessage", String.class, String.class, String.class); + Async async = method.getAnnotation(Async.class); + assertThat(async).isNotNull(); + assertThat(async.value()).isEqualTo("dsMailExecutor"); + } + + @Test + @DisplayName("sendTemplateMessage runs on the dsMailExecutor") + void sendTemplateMessageQualified() throws Exception { + Method method = MailService.class.getMethod("sendTemplateMessage", String.class, String.class, java.util.Map.class, String.class); + Async async = method.getAnnotation(Async.class); + assertThat(async).isNotNull(); + assertThat(async.value()).isEqualTo("dsMailExecutor"); + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceOverrideTest.java b/src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceOverrideTest.java new file mode 100644 index 00000000..624c29bb --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceOverrideTest.java @@ -0,0 +1,116 @@ +package com.digitalsanctuary.spring.user.mail; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import java.lang.reflect.Method; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.mail.javamail.JavaMailSender; +import com.digitalsanctuary.spring.user.audit.AuditMailAutoConfiguration; + +/** + * Proves that the library's {@link MailService} is genuinely replaceable by its concrete type. + * + *

    + * Per the Task 3.5 scope, {@link MailService} keeps its concrete type (no interface is extracted) so existing injectors that depend on the concrete + * type are unaffected. A consumer overrides mail delivery by supplying their own {@link MailService} (typically a subclass) bean; the library's default + * then backs off via {@code @ConditionalOnMissingBean(MailService.class)}. Following the H8/Task 3.3 lesson, the default lives on an + * {@code @AutoConfiguration} class (loading AFTER user beans) so the override reliably wins. + *

    + * + *

    + * The test is isolated: it drives {@link AuditMailAutoConfiguration} through an {@link ApplicationContextRunner} with mocked collaborators + * ({@link MailContentBuilder} and an {@link ObjectProvider} of {@link JavaMailSender}), so it never boots the full JPA/security context. + *

    + */ +@DisplayName("MailService Override Tests") +class MailServiceOverrideTest { + + /** + * Drives the real {@link AuditMailAutoConfiguration}. A {@link MailContentBuilder} and an empty {@link ObjectProvider} of {@link JavaMailSender} are + * supplied because the {@link MailService} {@code @Bean} method depends on them. {@code user.mail.fromAddress} is set so the {@code @Value} field on + * a default-created {@link MailService} resolves. + */ + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("user.mail.fromAddress=test@example.com", "user.audit.logEvents=false") + .withBean(MailContentBuilder.class, () -> mock(MailContentBuilder.class)) + .withBean("javaMailSender", JavaMailSender.class, () -> mock(JavaMailSender.class)) + .withConfiguration(AutoConfigurations.of(AuditMailAutoConfiguration.class)); + + @Nested + @DisplayName("Default behavior: library MailService present when consumer supplies none") + class Defaults { + + @Test + @DisplayName("Library provides a MailService by default") + void libraryMailServicePresentByDefault() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(MailService.class); + // The library's default is exactly MailService (not a subclass). + assertThat(context.getBean(MailService.class).getClass()).isEqualTo(MailService.class); + }); + } + } + + @Nested + @DisplayName("Override behavior: consumer MailService wins") + class Overrides { + + @Test + @DisplayName("Consumer MailService subclass replaces the library's default") + void consumerMailServiceWins() { + contextRunner.withUserConfiguration(ConsumerMailServiceConfig.class).run(context -> { + assertThat(context).hasSingleBean(MailService.class); + MailService active = context.getBean(MailService.class); + assertThat(active).as("consumer's mail service must win").isSameAs(ConsumerMailServiceConfig.CONSUMER_MAIL_SERVICE); + assertThat(active).as("consumer's mail service is a subclass, proving it is not the library default").isInstanceOf(CustomMailService.class); + }); + } + } + + @Nested + @DisplayName("Annotation contract on the auto-configuration bean method") + class AnnotationContract { + + @Test + @DisplayName("mailService() is @ConditionalOnMissingBean") + void mailServiceIsConditional() throws Exception { + Method method = AuditMailAutoConfiguration.class.getMethod("mailService", ObjectProvider.class, MailContentBuilder.class); + assertThat(method.getAnnotation(ConditionalOnMissingBean.class)).isNotNull(); + } + } + + // ---- Consumer-supplied stand-in configuration. Not @Configuration so the integration tests' component scan does not pick it up. ---- + + static class ConsumerMailServiceConfig { + static final MailService CONSUMER_MAIL_SERVICE = new CustomMailService(); + + @Bean + MailService consumerMailService() { + return CONSUMER_MAIL_SERVICE; + } + } + + /** + * A consumer subclass of {@link MailService}, proving a consumer override of the concrete type wins. Constructed with nulls because this stand-in is + * never invoked to send mail in the test; {@code init()} is overridden to a no-op so the parent's {@code @PostConstruct} does not dereference the + * null provider. + */ + static class CustomMailService extends MailService { + CustomMailService() { + super(null, null); + } + + @Override + void init() { + // no-op: avoids the parent @PostConstruct touching the null provider in this stand-in + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceTest.java index b2dcb7fa..86f257a2 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceTest.java @@ -7,16 +7,22 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import java.util.HashMap; import java.util.Map; import java.util.concurrent.CompletableFuture; import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import org.slf4j.LoggerFactory; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.ObjectProvider; @@ -595,4 +601,79 @@ void shouldConfigureMimeMessageHelperForTemplateMessage() throws Exception { preparator.prepare(mimeMessage); } } + + @Nested + @DisplayName("From Address Warning Tests") + class FromAddressWarningTests { + + private static final String EXPECTED_WARNING_FRAGMENT = "user.mail.fromAddress"; + + private Logger mailServiceLogger; + private ListAppender listAppender; + + @BeforeEach + void attachAppender() { + mailServiceLogger = (Logger) LoggerFactory.getLogger(MailService.class); + listAppender = new ListAppender<>(); + listAppender.start(); + mailServiceLogger.addAppender(listAppender); + } + + @AfterEach + void detachAppender() { + mailServiceLogger.detachAppender(listAppender); + } + + @Test + @DisplayName("Should warn when JavaMailSender is present but fromAddress is blank") + void shouldWarnWhenSenderPresentAndFromAddressBlank() { + // Given: sender available (mail enabled) but a blank fromAddress + when(mailSenderProvider.getIfAvailable()).thenReturn(mailSender); + MailService service = new MailService(mailSenderProvider, mailContentBuilder); + ReflectionTestUtils.setField(service, "fromAddress", " "); + + // When + service.init(); + + // Then + assertThat(warningMessages()).anyMatch(msg -> msg.contains(EXPECTED_WARNING_FRAGMENT)); + } + + @Test + @DisplayName("Should NOT warn about fromAddress when sender present and fromAddress is valid") + void shouldNotWarnWhenSenderPresentAndFromAddressValid() { + // Given: sender available and a valid fromAddress + when(mailSenderProvider.getIfAvailable()).thenReturn(mailSender); + MailService service = new MailService(mailSenderProvider, mailContentBuilder); + ReflectionTestUtils.setField(service, "fromAddress", "noreply@example.com"); + + // When + service.init(); + + // Then + assertThat(warningMessages()).noneMatch(msg -> msg.contains(EXPECTED_WARNING_FRAGMENT)); + } + + @Test + @DisplayName("Should NOT warn about fromAddress when sender is absent") + void shouldNotWarnAboutFromAddressWhenSenderAbsent() { + // Given: no sender (mail disabled) and a blank fromAddress + when(mailSenderProvider.getIfAvailable()).thenReturn(null); + MailService service = new MailService(mailSenderProvider, mailContentBuilder); + ReflectionTestUtils.setField(service, "fromAddress", ""); + + // When + service.init(); + + // Then: only the no-sender warning is expected, not the fromAddress warning + assertThat(warningMessages()).noneMatch(msg -> msg.contains(EXPECTED_WARNING_FRAGMENT)); + } + + private java.util.List warningMessages() { + return listAppender.list.stream() + .filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .toList(); + } + } } \ No newline at end of file diff --git a/src/test/java/com/digitalsanctuary/spring/user/persistence/model/UserToStringTest.java b/src/test/java/com/digitalsanctuary/spring/user/persistence/model/UserToStringTest.java new file mode 100644 index 00000000..0c8596e6 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/persistence/model/UserToStringTest.java @@ -0,0 +1,28 @@ +package com.digitalsanctuary.spring.user.persistence.model; + +import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.Test; + +/** + * Unit tests verifying that {@link User#toString()} does not leak sensitive material. + * + *

    The {@code password} field holds the bcrypt hash and must never appear in log output, which is + * commonly produced by toString(). This test guards the {@code @ToString.Exclude} annotation on that + * field.

    + */ +class UserToStringTest { + + @Test + void shouldNotIncludePasswordHashWhenToStringCalled() { + // Given a user with a (fake) password hash set + User user = new User(); + user.setEmail("user@test.com"); + user.setPassword("SUPERSECRET_HASH"); + + // When the user is rendered to a string (e.g. via a log statement) + String rendered = user.toString(); + + // Then the password hash must not be present + assertThat(rendered).doesNotContain("SUPERSECRET_HASH"); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordHistoryRepositoryTest.java b/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordHistoryRepositoryTest.java new file mode 100644 index 00000000..a0d58f6b --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/PasswordHistoryRepositoryTest.java @@ -0,0 +1,115 @@ +package com.digitalsanctuary.spring.user.persistence.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; +import org.springframework.data.domain.PageRequest; +import com.digitalsanctuary.spring.user.persistence.model.PasswordHistoryEntry; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.test.annotations.DatabaseTest; +import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder; + +/** + * Repository slice tests for {@link PasswordHistoryRepository}, focusing on the bounded, set-based + * password-history pruning used by {@code UserService.cleanUpPasswordHistory}. These verify that the + * cutoff-id lookup plus {@code deleteByUserAndIdLessThan} keeps exactly the most recent N entries and + * is tolerant of being run repeatedly. + */ +@DatabaseTest +class PasswordHistoryRepositoryTest { + + @Autowired + private PasswordHistoryRepository passwordHistoryRepository; + + @Autowired + private TestEntityManager entityManager; + + private User persistUser(String email) { + User user = UserTestDataBuilder.aUser().withId(null).withEmail(email).build(); + return entityManager.persistAndFlush(user); + } + + private void persistHistoryEntries(User user, int count) { + LocalDateTime base = LocalDateTime.now(); + for (int i = 0; i < count; i++) { + // Same timestamp on purpose for some rows, so the test exercises id-based ordering + PasswordHistoryEntry entry = new PasswordHistoryEntry(user, "hash-" + i, base.plusSeconds(i % 2)); + entityManager.persist(entry); + } + entityManager.flush(); + } + + /** + * Mirrors the keep-N logic in {@code UserService.cleanUpPasswordHistory}: locate the oldest entry + * to keep (0-based index {@code maxEntries - 1}, newest first) and delete everything older. + */ + private int prune(User user, int maxEntries) { + List cutoffIds = passwordHistoryRepository.findIdsByUserOrderByIdDesc(user, PageRequest.of(maxEntries - 1, 1)); + if (cutoffIds.isEmpty()) { + return 0; + } + return passwordHistoryRepository.deleteByUserAndIdLessThan(user, cutoffIds.get(0)); + } + + @Test + void prune_keepsOnlyMostRecentNEntries() { + User user = persistUser("prune-keep-n@test.com"); + persistHistoryEntries(user, 10); + int maxEntries = 5; + + int deleted = prune(user, maxEntries); + + assertThat(deleted).isEqualTo(5); + List remaining = passwordHistoryRepository.findByUserOrderByEntryDateDesc(user); + assertThat(remaining).hasSize(maxEntries); + // The kept entries are the most recent ones (highest ids): hash-5..hash-9 + assertThat(remaining).extracting(PasswordHistoryEntry::getPasswordHash) + .containsExactlyInAnyOrder("hash-5", "hash-6", "hash-7", "hash-8", "hash-9"); + } + + @Test + void prune_isNoOpWhenAtOrBelowLimit() { + User user = persistUser("prune-under-limit@test.com"); + persistHistoryEntries(user, 3); + int maxEntries = 5; + + int deleted = prune(user, maxEntries); + + assertThat(deleted).isZero(); + assertThat(passwordHistoryRepository.findByUserOrderByEntryDateDesc(user)).hasSize(3); + } + + @Test + void prune_isIdempotentWhenCalledRepeatedly() { + User user = persistUser("prune-idempotent@test.com"); + persistHistoryEntries(user, 8); + int maxEntries = 4; + + int firstDeleted = prune(user, maxEntries); + int secondDeleted = prune(user, maxEntries); + int thirdDeleted = prune(user, maxEntries); + + assertThat(firstDeleted).isEqualTo(4); + assertThat(secondDeleted).isZero(); + assertThat(thirdDeleted).isZero(); + assertThat(passwordHistoryRepository.findByUserOrderByEntryDateDesc(user)).hasSize(maxEntries); + } + + @Test + void prune_onlyAffectsTargetUser() { + User target = persistUser("prune-target@test.com"); + User other = persistUser("prune-other@test.com"); + persistHistoryEntries(target, 6); + persistHistoryEntries(other, 6); + + int deleted = prune(target, 2); + + assertThat(deleted).isEqualTo(4); + assertThat(passwordHistoryRepository.findByUserOrderByEntryDateDesc(target)).hasSize(2); + // The other user's history is untouched + assertThat(passwordHistoryRepository.findByUserOrderByEntryDateDesc(other)).hasSize(6); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepositoryTest.java b/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepositoryTest.java new file mode 100644 index 00000000..a8bd9333 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepositoryTest.java @@ -0,0 +1,47 @@ +package com.digitalsanctuary.spring.user.persistence.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.test.annotations.DatabaseTest; +import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder; + +/** + * Repository slice tests for {@link UserRepository}, focusing on the atomic failed-login-attempt increment used to prevent the lockout-evasion + * lost-update race. + */ +@DatabaseTest +class UserRepositoryTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private TestEntityManager entityManager; + + @Test + void incrementFailedAttemptsAtomicallyIncreasesCounterAndReturnsRowsAffected() { + User user = UserTestDataBuilder.aUser().withId(null).withEmail("increment@test.com").withFailedLoginAttempts(0).build(); + entityManager.persistAndFlush(user); + entityManager.clear(); + + int firstUpdated = userRepository.incrementFailedAttempts("increment@test.com"); + int secondUpdated = userRepository.incrementFailedAttempts("increment@test.com"); + + assertThat(firstUpdated).isEqualTo(1); + assertThat(secondUpdated).isEqualTo(1); + + User reloaded = userRepository.findByEmail("increment@test.com"); + assertThat(reloaded).isNotNull(); + assertThat(reloaded.getFailedLoginAttempts()).isEqualTo(2); + } + + @Test + void incrementFailedAttemptsReturnsZeroForNonExistentEmail() { + int updated = userRepository.incrementFailedAttempts("does-not-exist@test.com"); + + assertThat(updated).isZero(); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnCredentialOwnershipTest.java b/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnCredentialOwnershipTest.java new file mode 100644 index 00000000..9bc6d7a6 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnCredentialOwnershipTest.java @@ -0,0 +1,132 @@ +package com.digitalsanctuary.spring.user.persistence.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import java.time.Instant; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.model.WebAuthnCredential; +import com.digitalsanctuary.spring.user.persistence.model.WebAuthnUserEntity; +import com.digitalsanctuary.spring.user.test.annotations.DatabaseTest; +import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder; + +/** + * Repository-slice IDOR (Insecure Direct Object Reference) negative tests for WebAuthn credential + * ownership enforcement. + * + *

    + * The ownership guard lives in {@link WebAuthnCredentialQueryRepository#deleteCredential(String, Long)} + * and {@link WebAuthnCredentialQueryRepository#renameCredential(String, String, Long)}: each loads the + * credential by its id, then verifies the credential's owning {@code WebAuthnUserEntity.user.id} matches + * the supplied {@code userId} before acting. These tests prove that guard holds against a real (H2) + * database — not just a mock — so that user A cannot delete or rename user B's passkey by guessing its + * credential id. + *

    + * + *

    + * This runs as a {@code @DataJpaTest} slice (transactional, rolled back per test), so it does not pollute + * the shared integration database. {@link WebAuthnCredentialQueryRepository} is conditional on + * {@code user.webauthn.enabled} and is not part of the repository slice scan, so it is constructed + * manually from the autowired {@link WebAuthnCredentialRepository}. + *

    + */ +@DatabaseTest +class WebAuthnCredentialOwnershipTest { + + @Autowired + private WebAuthnCredentialRepository credentialRepository; + + @Autowired + private TestEntityManager entityManager; + + private WebAuthnCredentialQueryRepository queryRepository; + + private User userA; + private User userB; + private WebAuthnCredential credA; + private WebAuthnCredential credB; + + private static final String CRED_B_ORIGINAL_LABEL = "User B's iPhone"; + + @BeforeEach + void setUp() { + // The query repository is @ConditionalOnProperty(user.webauthn.enabled) and is not picked up by + // the @DataJpaTest slice scan, so wire it manually from the slice-managed Spring Data repository. + queryRepository = new WebAuthnCredentialQueryRepository(credentialRepository); + + userA = persistUser("idor-owner-a@test.com"); + userB = persistUser("idor-owner-b@test.com"); + + credA = persistCredential("cred-a-id", "User A's YubiKey", userA, "handle-a"); + credB = persistCredential("cred-b-id", CRED_B_ORIGINAL_LABEL, userB, "handle-b"); + } + + private User persistUser(String email) { + User user = UserTestDataBuilder.aUser().withId(null).withEmail(email).build(); + return entityManager.persistAndFlush(user); + } + + private WebAuthnCredential persistCredential(String credentialId, String label, User owner, String userHandle) { + WebAuthnUserEntity userEntity = new WebAuthnUserEntity(); + userEntity.setId(userHandle); + userEntity.setName(owner.getEmail()); + userEntity.setDisplayName(owner.getFirstName() + " " + owner.getLastName()); + userEntity.setUser(owner); + entityManager.persist(userEntity); + + WebAuthnCredential credential = new WebAuthnCredential(); + credential.setCredentialId(credentialId); + credential.setUserEntity(userEntity); + credential.setPublicKey(new byte[] {1, 2, 3, 4}); + credential.setSignatureCount(0L); + credential.setLabel(label); + credential.setCreated(Instant.now()); + entityManager.persist(credential); + entityManager.flush(); + return credential; + } + + @Test + void shouldReturnZeroAndPreserveCredentialWhenDeletingAnotherUsersCredential() { + int deleted = queryRepository.deleteCredential(credB.getCredentialId(), userA.getId()); + + assertThat(deleted).as("user A must not be able to delete user B's credential").isZero(); + + // Reload from the database to confirm the credential still exists. + entityManager.clear(); + assertThat(credentialRepository.findById(credB.getCredentialId())) + .as("user B's credential must still exist after the rejected cross-user delete").isPresent(); + } + + @Test + void shouldReturnZeroAndPreserveLabelWhenRenamingAnotherUsersCredential() { + int renamed = queryRepository.renameCredential(credB.getCredentialId(), "hacked", userA.getId()); + + assertThat(renamed).as("user A must not be able to rename user B's credential").isZero(); + + // Reload from the database to confirm the label is unchanged. + entityManager.clear(); + assertThat(credentialRepository.findById(credB.getCredentialId())) + .as("user B's credential must still exist after the rejected cross-user rename").isPresent() + .get().extracting(WebAuthnCredential::getLabel) + .as("user B's credential label must be unchanged").isEqualTo(CRED_B_ORIGINAL_LABEL); + } + + @Test + void shouldDeleteOwnCredentialWhenOwnerRequestsDeletion() { + // Positive control: the owner CAN delete their own credential. Proves the guard rejects based on + // ownership rather than rejecting everything, so the negative assertions above are meaningful. + int deleted = queryRepository.deleteCredential(credA.getCredentialId(), userA.getId()); + + assertThat(deleted).as("user A must be able to delete their own credential").isEqualTo(1); + + // Flush the pending delete to the DB, then clear so the reload hits the database rather than the + // first-level cache. (clear() alone would discard the un-flushed delete and falsely show the row.) + entityManager.flush(); + entityManager.clear(); + assertThat(credentialRepository.findById(credA.getCredentialId())) + .as("user A's credential must be gone after the owner deletes it").isEmpty(); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/profile/session/SessionScopedProfileTest.java b/src/test/java/com/digitalsanctuary/spring/user/profile/session/SessionScopedProfileTest.java new file mode 100644 index 00000000..803854dd --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/profile/session/SessionScopedProfileTest.java @@ -0,0 +1,123 @@ +package com.digitalsanctuary.spring.user.profile.session; + +import static org.assertj.core.api.Assertions.assertThat; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Scope; +import org.springframework.context.annotation.ScopedProxyMode; +import org.springframework.context.support.SimpleThreadScope; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.stereotype.Component; +import org.springframework.web.context.WebApplicationContext; +import com.digitalsanctuary.spring.user.profile.BaseUserProfile; + +/** + * Tests for the H7 fix: a concrete {@link BaseSessionProfile} subclass must be session-scoped so that one user's + * profile does not leak across sessions. + * + *

    + * Two complementary proofs are provided: + *

    + *
      + *
    • A reflection assertion that the convenience meta-annotation {@link SessionScopedProfile} carries + * {@code @Component} and a session-scoped {@code @Scope} with a {@code TARGET_CLASS} proxy.
    • + *
    • A runtime proof, using a {@code session} scope backed by {@link SimpleThreadScope} (each thread acts as a + * distinct "session"), that correctly scoped subclasses resolve to DISTINCT instances per session, while an + * unscoped subclass falls into the singleton trap and SHARES a single instance across sessions.
    • + *
    + * + *

    + * To avoid comparing the shared scoped proxy (which is the same object regardless of session), the test beans are + * registered with {@link ScopedProxyMode#NO} and resolved on each session thread directly — with + * {@link SimpleThreadScope} this returns the real per-session target instance, so identity comparison is meaningful. + * The meta-annotation reflection test independently confirms the production scope/proxy configuration. + *

    + */ +@DisplayName("Session-scoped profile (H7) Tests") +class SessionScopedProfileTest { + + /** Minimal concrete profile type for the session profile to carry. */ + static class TestUserProfile extends BaseUserProfile { + } + + /** Correctly scoped via the convenience meta-annotation (used by the reflection test). */ + @SessionScopedProfile + static class MetaAnnotatedSessionProfile extends BaseSessionProfile { + } + + /** Correctly session-scoped, no proxy (so per-thread resolution returns the real target for identity checks). */ + @Component + @Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.NO) + static class ScopedNoProxySessionProfile extends BaseSessionProfile { + } + + /** INCORRECTLY scoped: only {@code @Component}, no {@code @Scope}. Demonstrates the singleton trap. */ + @Component + static class UnscopedSessionProfile extends BaseSessionProfile { + } + + /** + * Builds a context with a {@code session} scope backed by {@link SimpleThreadScope}, so each thread acts as a + * distinct "session". + */ + private AnnotationConfigApplicationContext sessionScopedContext(Class... beanClasses) { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.getBeanFactory().registerScope(WebApplicationContext.SCOPE_SESSION, new SimpleThreadScope()); + context.register(beanClasses); + context.refresh(); + return context; + } + + /** Resolves the bean on a fresh thread so that, with {@link SimpleThreadScope}, it represents a new session. */ + private T resolveOnNewSession(AnnotationConfigApplicationContext context, Class type) throws Exception { + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Callable task = () -> context.getBean(type); + return executor.submit(task).get(); + } finally { + executor.shutdownNow(); + } + } + + @Test + @DisplayName("meta-annotation carries @Component and session @Scope with TARGET_CLASS proxy") + void metaAnnotationCarriesCorrectScope() { + // @Component is present (so it is a bean) + assertThat(AnnotatedElementUtils.hasAnnotation(MetaAnnotatedSessionProfile.class, Component.class)).isTrue(); + + // @Scope is present and configured for the session with a TARGET_CLASS proxy + Scope scope = AnnotatedElementUtils.findMergedAnnotation(MetaAnnotatedSessionProfile.class, Scope.class); + assertThat(scope).isNotNull(); + assertThat(scope.value()).isEqualTo(WebApplicationContext.SCOPE_SESSION); + assertThat(scope.proxyMode()).isEqualTo(ScopedProxyMode.TARGET_CLASS); + } + + @Test + @DisplayName("correctly session-scoped subclass yields DISTINCT instances per session") + void scopedProfileIsDistinctPerSession() throws Exception { + try (AnnotationConfigApplicationContext context = sessionScopedContext(ScopedNoProxySessionProfile.class)) { + ScopedNoProxySessionProfile sessionA = resolveOnNewSession(context, ScopedNoProxySessionProfile.class); + ScopedNoProxySessionProfile sessionB = resolveOnNewSession(context, ScopedNoProxySessionProfile.class); + + assertThat(sessionA).isNotNull(); + assertThat(sessionB).isNotNull(); + assertThat(sessionA).isNotSameAs(sessionB); + } + } + + @Test + @DisplayName("the singleton trap: an unscoped subclass SHARES one instance across sessions") + void unscopedProfileIsSharedSingletonTrap() throws Exception { + try (AnnotationConfigApplicationContext context = sessionScopedContext(UnscopedSessionProfile.class)) { + UnscopedSessionProfile sessionA = resolveOnNewSession(context, UnscopedSessionProfile.class); + UnscopedSessionProfile sessionB = resolveOnNewSession(context, UnscopedSessionProfile.class); + + // Same instance shared across sessions: this is the H7 vulnerability the fix warns against. + assertThat(sessionA).isSameAs(sessionB); + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/registration/CompositeRegistrationGuardTest.java b/src/test/java/com/digitalsanctuary/spring/user/registration/CompositeRegistrationGuardTest.java new file mode 100644 index 00000000..a753a5f2 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/registration/CompositeRegistrationGuardTest.java @@ -0,0 +1,163 @@ +package com.digitalsanctuary.spring.user.registration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link CompositeRegistrationGuard} verifying ordered, first-deny-wins composition. + */ +@DisplayName("CompositeRegistrationGuard Tests") +class CompositeRegistrationGuardTest { + + private static final RegistrationContext CONTEXT = + new RegistrationContext("user@example.com", RegistrationSource.FORM, null); + + @Nested + @DisplayName("Cardinality") + class Cardinality { + + @Test + @DisplayName("Zero custom guards (only default) allows") + void zeroGuardsAllows() { + CompositeRegistrationGuard composite = new CompositeRegistrationGuard(List.of()); + + assertThat(composite.evaluate(CONTEXT).allowed()).isTrue(); + } + + @Test + @DisplayName("Null delegate list is treated as empty and allows") + void nullDelegatesAllows() { + CompositeRegistrationGuard composite = new CompositeRegistrationGuard(null); + + assertThat(composite.evaluate(CONTEXT).allowed()).isTrue(); + } + + @Test + @DisplayName("Single allowing guard allows") + void singleAllowingGuardAllows() { + RegistrationGuard guard = ctx -> RegistrationDecision.allow(); + CompositeRegistrationGuard composite = new CompositeRegistrationGuard(List.of(guard)); + + assertThat(composite.evaluate(CONTEXT).allowed()).isTrue(); + } + + @Test + @DisplayName("Single denying guard denies and propagates reason") + void singleDenyingGuardDenies() { + RegistrationGuard guard = ctx -> RegistrationDecision.deny("nope"); + CompositeRegistrationGuard composite = new CompositeRegistrationGuard(List.of(guard)); + + RegistrationDecision decision = composite.evaluate(CONTEXT); + + assertThat(decision.allowed()).isFalse(); + assertThat(decision.reason()).isEqualTo("nope"); + } + } + + @Nested + @DisplayName("Ordering (first-deny-wins)") + class Ordering { + + @Test + @DisplayName("All-allow guards proceed (allow)") + void allAllowProceeds() { + RegistrationGuard first = mock(RegistrationGuard.class); + RegistrationGuard second = mock(RegistrationGuard.class); + when(first.evaluate(CONTEXT)).thenReturn(RegistrationDecision.allow()); + when(second.evaluate(CONTEXT)).thenReturn(RegistrationDecision.allow()); + + CompositeRegistrationGuard composite = new CompositeRegistrationGuard(List.of(first, second)); + + assertThat(composite.evaluate(CONTEXT).allowed()).isTrue(); + verify(first).evaluate(CONTEXT); + verify(second).evaluate(CONTEXT); + } + + @Test + @DisplayName("First guard denies: second is NOT consulted; first reason wins") + void firstDenyShortCircuits() { + RegistrationGuard first = mock(RegistrationGuard.class); + RegistrationGuard second = mock(RegistrationGuard.class); + when(first.evaluate(CONTEXT)).thenReturn(RegistrationDecision.deny("first denied")); + + CompositeRegistrationGuard composite = new CompositeRegistrationGuard(List.of(first, second)); + + RegistrationDecision decision = composite.evaluate(CONTEXT); + + assertThat(decision.allowed()).isFalse(); + assertThat(decision.reason()).isEqualTo("first denied"); + verify(first).evaluate(CONTEXT); + // Short-circuit: the second guard must never be consulted once the first denies. + verify(second, never()).evaluate(CONTEXT); + } + + @Test + @DisplayName("Second guard denies when first allows; second reason propagates") + void secondDenyAfterFirstAllow() { + RegistrationGuard first = mock(RegistrationGuard.class); + RegistrationGuard second = mock(RegistrationGuard.class); + when(first.evaluate(CONTEXT)).thenReturn(RegistrationDecision.allow()); + when(second.evaluate(CONTEXT)).thenReturn(RegistrationDecision.deny("second denied")); + + CompositeRegistrationGuard composite = new CompositeRegistrationGuard(List.of(first, second)); + + RegistrationDecision decision = composite.evaluate(CONTEXT); + + assertThat(decision.allowed()).isFalse(); + assertThat(decision.reason()).isEqualTo("second denied"); + verify(first).evaluate(CONTEXT); + verify(second).evaluate(CONTEXT); + } + } + + @Nested + @DisplayName("Null decisions (fail-fast)") + class NullDecisions { + + @Test + @DisplayName("A delegate returning null throws IllegalStateException instead of failing open") + void nullDecisionThrows() { + RegistrationGuard nullReturning = ctx -> null; + CompositeRegistrationGuard composite = new CompositeRegistrationGuard(List.of(nullReturning)); + + assertThatThrownBy(() -> composite.evaluate(CONTEXT)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("null decision"); + } + + @Test + @DisplayName("A later guard returning null still throws even after earlier guards allow") + void nullDecisionAfterAllowThrows() { + RegistrationGuard first = mock(RegistrationGuard.class); + RegistrationGuard second = mock(RegistrationGuard.class); + when(first.evaluate(CONTEXT)).thenReturn(RegistrationDecision.allow()); + when(second.evaluate(CONTEXT)).thenReturn(null); + + CompositeRegistrationGuard composite = new CompositeRegistrationGuard(List.of(first, second)); + + assertThatThrownBy(() -> composite.evaluate(CONTEXT)) + .isInstanceOf(IllegalStateException.class); + } + } + + @Test + @DisplayName("getDelegates returns the composed guards in order") + void getDelegatesReturnsGuardsInOrder() { + RegistrationGuard first = ctx -> RegistrationDecision.allow(); + RegistrationGuard second = ctx -> RegistrationDecision.allow(); + + CompositeRegistrationGuard composite = new CompositeRegistrationGuard(List.of(first, second)); + + assertThat(composite.getDelegates()).containsExactly(first, second); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/registration/RegistrationGuardConfigurationTest.java b/src/test/java/com/digitalsanctuary/spring/user/registration/RegistrationGuardConfigurationTest.java new file mode 100644 index 00000000..6762023e --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/registration/RegistrationGuardConfigurationTest.java @@ -0,0 +1,145 @@ +package com.digitalsanctuary.spring.user.registration; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.annotation.Order; + +/** + * Verifies the wiring contract of {@link RegistrationGuardConfiguration}: + * + *
      + *
    • With no consumer guard, the permit-all {@link DefaultRegistrationGuard} is registered so the + * composite always has a delegate.
    • + *
    • The {@link CompositeRegistrationGuard} is always registered and is the {@code @Primary} bean, so + * single-valued {@link RegistrationGuard} injection points resolve to it.
    • + *
    • With one or more consumer guards, the default is NOT added and the composite delegates to the + * consumer guards in {@code @Order} with first-deny-wins semantics.
    • + *
    + */ +@DisplayName("RegistrationGuardConfiguration Wiring Tests") +class RegistrationGuardConfigurationTest { + + private static final RegistrationContext CONTEXT = + new RegistrationContext("user@example.com", RegistrationSource.FORM, null); + + // Register RegistrationGuardConfiguration as an auto-configuration so its @ConditionalOnMissingBean + // evaluates AFTER any consumer-supplied guard beans — mirroring production, where this configuration + // is component-scanned by the UserConfiguration auto-configuration (loaded after consumer beans). + // + // The inlined "spring.profiles.active" pins this context to a non-"test" profile so the @Profile("!test") + // guard configs below always load. Without it, the test depends on no "test" profile being active — a + // fragile assumption, because anything that sets the global "spring.profiles.active=test" system property + // (e.g. another test running concurrently) would suppress the guard configs and flake these assertions. + // The inlined property source outranks any leaked system property, making this deterministic. + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withPropertyValues("spring.profiles.active=registrationguardtest") + .withConfiguration(AutoConfigurations.of(RegistrationGuardConfiguration.class)); + + @Test + @DisplayName("No consumer guard: default permit-all registered, composite is primary and allows") + void noConsumerGuard() { + runner.run(ctx -> { + assertThat(ctx).hasSingleBean(DefaultRegistrationGuard.class); + assertThat(ctx).hasSingleBean(CompositeRegistrationGuard.class); + // The primary RegistrationGuard injection point resolves to the composite. + RegistrationGuard primary = ctx.getBean(RegistrationGuard.class); + assertThat(primary).isInstanceOf(CompositeRegistrationGuard.class); + assertThat(primary.evaluate(CONTEXT).allowed()).isTrue(); + }); + } + + @Test + @DisplayName("One consumer guard: default NOT registered, composite delegates to the consumer guard") + void oneConsumerGuard() { + runner.withUserConfiguration(OneDenyingGuardConfig.class).run(ctx -> { + assertThat(ctx).doesNotHaveBean(DefaultRegistrationGuard.class); + assertThat(ctx).hasSingleBean(CompositeRegistrationGuard.class); + + RegistrationGuard primary = ctx.getBean(RegistrationGuard.class); + assertThat(primary).isInstanceOf(CompositeRegistrationGuard.class); + + RegistrationDecision decision = primary.evaluate(CONTEXT); + assertThat(decision.allowed()).isFalse(); + assertThat(decision.reason()).isEqualTo("consumer denied"); + }); + } + + @Test + @DisplayName("Many consumer guards: ordered first-deny-wins; later guard not consulted") + void manyConsumerGuardsFirstDenyWins() { + runner.withUserConfiguration(TwoOrderedGuardsConfig.class).run(ctx -> { + assertThat(ctx).doesNotHaveBean(DefaultRegistrationGuard.class); + + RegistrationGuard primary = ctx.getBean(RegistrationGuard.class); + RegistrationDecision decision = primary.evaluate(CONTEXT); + + // The @Order(1) guard denies first and short-circuits the @Order(2) guard. + assertThat(decision.allowed()).isFalse(); + assertThat(decision.reason()).isEqualTo("first"); + }); + } + + @Test + @DisplayName("Many consumer guards all allowing: registration proceeds") + void manyConsumerGuardsAllAllow() { + runner.withUserConfiguration(TwoAllowingGuardsConfig.class).run(ctx -> { + RegistrationGuard primary = ctx.getBean(RegistrationGuard.class); + assertThat(primary.evaluate(CONTEXT).allowed()).isTrue(); + }); + } + + // These nested guard configurations are registered explicitly via ApplicationContextRunner + // (withUserConfiguration). They are annotated @Profile("!test") so the library's broad + // @ComponentScan("com.digitalsanctuary.spring.user") in UserConfiguration does NOT pick them up + // as real beans inside the full @IntegrationTest context (which activates the "test" profile). + // Without this, their denying RegistrationGuard beans would leak into every integration context's + // CompositeRegistrationGuard and block all registration. The ApplicationContextRunner below + // activates no profile, so "!test" is satisfied and these configs still load for these tests. + @Configuration + @Profile("!test") + static class OneDenyingGuardConfig { + @Bean + RegistrationGuard consumerGuard() { + return context -> RegistrationDecision.deny("consumer denied"); + } + } + + @Configuration + @Profile("!test") + static class TwoOrderedGuardsConfig { + @Bean + @Order(1) + RegistrationGuard firstGuard() { + return context -> RegistrationDecision.deny("first"); + } + + @Bean + @Order(2) + RegistrationGuard secondGuard() { + return context -> RegistrationDecision.deny("second"); + } + } + + @Configuration + @Profile("!test") + static class TwoAllowingGuardsConfig { + @Bean + @Order(1) + RegistrationGuard firstAllow() { + return context -> RegistrationDecision.allow(); + } + + @Bean + @Order(2) + RegistrationGuard secondAllow() { + return context -> RegistrationDecision.allow(); + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/AccountLockoutIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/AccountLockoutIntegrationTest.java new file mode 100644 index 00000000..783f8df6 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/AccountLockoutIntegrationTest.java @@ -0,0 +1,196 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; + +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; +import com.digitalsanctuary.spring.user.service.DSUserDetailsService; +import com.digitalsanctuary.spring.user.test.app.TestApplication; +import com.digitalsanctuary.spring.user.test.config.BaseTestConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Import; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +/** + * Integration test proving that account lockout (brute-force protection) is enforced through the + * real {@code formLogin} authentication path — not just at the unit level. + * + *

    What this exercises end-to-end

    + * + *

    + * A real {@code POST} to the form-login processing URL drives Spring Security, which fires + * {@code AuthenticationFailureBadCredentialsEvent} / {@code AuthenticationSuccessEvent}. The library's + * {@code AuthenticationEventListener} reacts and calls {@code LoginAttemptService.loginFailed(...)}, + * which atomically increments the persisted {@code failedLoginAttempts} counter and, at the threshold, + * sets {@code User.locked = true} in the database. On the next authentication attempt the DB-backed + * {@code DSUserDetailsService.loadUserByUsername} loads the now-locked user and + * {@code LoginHelperService.assertAccountUsable} throws {@code LockedException} — so the login is + * rejected even with the correct password. That correct-password-still-rejected assertion is + * the heart of this test: it proves lockout genuinely blocks authentication. + *

    + * + *

    Why this test does NOT use {@code @SecurityTest}

    + * + *

    + * The real lockout path requires authenticating against the library's DB-backed + * {@link DSUserDetailsService}, so that a lockout committed to the database actually blocks the next login. + * {@code @SecurityTest} imports {@code SecurityTestConfiguration}, which registers a + * {@code @Primary InMemoryUserDetailsManager} and a {@code @Primary TestingAuthenticationProvider} (which + * cannot authenticate username/password tokens) — both intended for mock-security tests, not the real + * DB-backed form login this test needs. This class therefore uses a plain {@code @SpringBootTest} that imports + * only {@link BaseTestConfiguration}, leaving the library's auto-configured DB-backed + * {@link DSUserDetailsService} + {@code DaoAuthenticationProvider} as the sole authentication path. Spring + * Security builds a single authentication manager from that real provider, and form login authenticates + * against the database — so the persisted lock state gates login exactly as in production. + * {@code @AutoConfigureMockMvc(addFilters = true)} keeps the security filter chain active; + * {@link BaseTestConfiguration} supplies the fast BCrypt(4) {@link PasswordEncoder} and an in-memory + * {@code SessionRegistry}. + *

    + * + *

    + * Note: {@code SecurityTestConfiguration} no longer leaks into this non-{@code @SecurityTest} context via the + * library's component scan, because {@code UserConfiguration} now installs the Spring Boot + * {@code TypeExcludeFilter} / {@code AutoConfigurationExcludeFilter} in its {@code @ComponentScan}, which + * excludes {@code @TestConfiguration} classes from the scan. + *

    + * + *

    Why this class uses an isolated in-memory database

    + * + *

    + * The {@code AuthenticationEventListener} delegates to {@code LoginAttemptService}, whose methods are + * {@code @Transactional} and therefore commit the failed-attempt / locked mutations to the + * user row in their own transactions. The standard {@code test} profile points every test at the shared + * {@code jdbc:h2:mem:testdb}; JUnit runs classes in parallel, and other integration tests call + * {@code userRepository.deleteAll()}. Those deletes would race this class's committed lock-state rows, + * producing intermittent failures. {@link TestPropertySource} therefore overrides {@code spring.datasource.url} + * to a dedicated database ({@code jdbc:h2:mem:lockouttest}). The URL options are copied verbatim from the + * shared {@code application-test.properties} URL ({@code DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE}); only the + * database name differs. The distinct datasource also gives this class its own Spring context, so its committed + * rows live in a database no other test's {@code deleteAll()} can see. The schema is created automatically + * because the {@code test} profile sets {@code ddl-auto=create-drop}. + *

    + */ +@SpringBootTest(classes = TestApplication.class) +@AutoConfigureMockMvc(addFilters = true) +@ActiveProfiles("test") +@Import(BaseTestConfiguration.class) +@TestPropertySource(properties = { + // Isolated in-memory DB so this class's COMMITTED lock-state rows are invisible to the shared-DB + // integration tests' deleteAll(). Options copied verbatim from the shared testdb URL + // (application-test.properties) — only the database name (lockouttest) differs. + "spring.datasource.url=jdbc:h2:mem:lockouttest;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", + // Small, known lockout threshold so N failed attempts deterministically locks the account. + "user.security.failedLoginAttempts=3", + // Admin-only unlock (negative duration) so the account never auto-unlocks during the test window, + // making the correct-password-still-rejected assertion deterministic. + "user.security.accountLockoutDuration=-1" +}) +@DisplayName("Account Lockout Integration Tests (real formLogin path)") +class AccountLockoutIntegrationTest { + + /** Must match user.security.failedLoginAttempts above. */ + private static final int MAX_FAILED_ATTEMPTS = 3; + + private static final String LOGIN_URL = "/user/login"; + private static final String FAILURE_URL = "/user/login.html?error"; + + private static final String TEST_EMAIL = "lockout-victim@test.com"; + private static final String CORRECT_PASSWORD = "CorrectPass1!"; + private static final String WRONG_PASSWORD = "WrongPass9!"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @BeforeEach + void seedFreshUnlockedUser() { + // A dedicated user with a clean lock state guarantees repeated runs start from zero, regardless + // of any rows left committed by a previous run in this isolated database. + User existing = userRepository.findByEmail(TEST_EMAIL); + if (existing != null) { + userRepository.delete(existing); + } + + User user = new User(); + user.setEmail(TEST_EMAIL); + user.setFirstName("Lockout"); + user.setLastName("Victim"); + user.setPassword(passwordEncoder.encode(CORRECT_PASSWORD)); + user.setEnabled(true); + user.setLocked(false); + user.setFailedLoginAttempts(0); + userRepository.save(user); + } + + @AfterEach + void cleanup() { + User user = userRepository.findByEmail(TEST_EMAIL); + if (user != null) { + userRepository.delete(user); + } + } + + @Test + @DisplayName("should reject a correct-password login once the account is locked by failed attempts") + void shouldRejectCorrectPasswordWhenAccountLockedByFailedAttempts() throws Exception { + // Sanity check: the correct password authenticates before any lockout, confirming the wiring. + mockMvc.perform(formLogin(LOGIN_URL).user("username", TEST_EMAIL).password(CORRECT_PASSWORD)) + .andExpect(authenticated()); + // Reset the counter so the sanity-check success does not consume an attempt for the rest of the test. + seedFreshUnlockedUser(); + + // Perform N failed attempts with the WRONG password to trip the lockout threshold. + for (int attempt = 1; attempt <= MAX_FAILED_ATTEMPTS; attempt++) { + mockMvc.perform(formLogin(LOGIN_URL).user("username", TEST_EMAIL).password(WRONG_PASSWORD)) + .andExpect(unauthenticated()) + .andExpect(redirectedUrl(FAILURE_URL)); + } + + // The account must now be locked in the database (the listener committed the change). + User locked = userRepository.findByEmail(TEST_EMAIL); + assertThat(locked).isNotNull(); + assertThat(locked.isLocked()).as("account should be locked after %d failed attempts", MAX_FAILED_ATTEMPTS).isTrue(); + assertThat(locked.getFailedLoginAttempts()).isGreaterThanOrEqualTo(MAX_FAILED_ATTEMPTS); + + // KEY ASSERTION: the (N+1)th attempt with the CORRECT password is still rejected, because the + // account is locked — the real DSUserDetailsService load path throws LockedException. + mockMvc.perform(formLogin(LOGIN_URL).user("username", TEST_EMAIL).password(CORRECT_PASSWORD)) + .andExpect(unauthenticated()) + .andExpect(redirectedUrl(FAILURE_URL)); + } + + @Test + @DisplayName("should authenticate normally before the lockout threshold is reached") + void shouldAuthenticateWhenBelowLockoutThreshold() throws Exception { + // One short of the threshold still allows the correct password to succeed. + for (int attempt = 1; attempt < MAX_FAILED_ATTEMPTS; attempt++) { + mockMvc.perform(formLogin(LOGIN_URL).user("username", TEST_EMAIL).password(WRONG_PASSWORD)) + .andExpect(unauthenticated()); + } + + User user = userRepository.findByEmail(TEST_EMAIL); + assertThat(user).isNotNull(); + assertThat(user.isLocked()).as("account should not be locked below the threshold").isFalse(); + + mockMvc.perform(formLogin(LOGIN_URL).user("username", TEST_EMAIL).password(CORRECT_PASSWORD)) + .andExpect(authenticated()); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/CoreBeanOverrideTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/CoreBeanOverrideTest.java new file mode 100644 index 00000000..aab5add7 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/CoreBeanOverrideTest.java @@ -0,0 +1,264 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Method; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.security.access.hierarchicalroles.RoleHierarchy; +import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.session.SessionRegistryImpl; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.NoOpPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import com.digitalsanctuary.spring.user.roles.RolesAndPrivilegesConfig; + +/** + * Proves that the four core, overridable security beans — {@link PasswordEncoder}, {@link SessionRegistry}, {@link RoleHierarchy}, and + * {@link DaoAuthenticationProvider} — are genuinely replaceable by a consuming application. + * + *

    + * The library historically defined these beans on the component-scanned {@link WebSecurityConfig} with no {@code @ConditionalOnMissingBean}, so a + * consumer that defined their own bean of the same type got a bean-definition conflict. Task 3.2 established (and this test re-proves) that + * {@code @ConditionalOnMissingBean} is only reliable on an {@code @AutoConfiguration} class — which loads AFTER user-defined beans — not on + * a component-scanned {@code @Configuration}. These beans therefore live on {@link UserSecurityBeansAutoConfiguration}. + *

    + * + *

    + * The test is deliberately isolated: it drives {@link UserSecurityBeansAutoConfiguration} directly through an {@link ApplicationContextRunner} with a + * tiny set of mock collaborators, so it never boots the full security/JPA context. This avoids polluting the shared JPA metamodel across parallel + * integration contexts while still exercising the real override semantics end-to-end. + *

    + */ +@DisplayName("Core Security Bean Override Tests") +class CoreBeanOverrideTest { + + /** + * Drives the real {@link UserSecurityBeansAutoConfiguration}. A {@link RolesAndPrivilegesConfig} and a {@link UserDetailsService} are supplied as + * collaborators because {@code roleHierarchy()} and {@code authProvider()} depend on them. Registered as an auto-configuration so + * {@code @ConditionalOnMissingBean} evaluates AFTER any user-supplied beans. + */ + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withBean(UserDetailsService.class, () -> username -> User.withUsername("test").password("x").authorities("ROLE_USER").build()) + .withBean(RolesAndPrivilegesConfig.class, CoreBeanOverrideTest::roleConfig) + .withConfiguration(AutoConfigurations.of(UserSecurityBeansAutoConfiguration.class)); + + private static RolesAndPrivilegesConfig roleConfig() { + RolesAndPrivilegesConfig config = new RolesAndPrivilegesConfig(); + // getRoleHierarchyString() is built from the roleHierarchy list; set it so roleHierarchy() returns a real hierarchy by default. + config.setRoleHierarchy(List.of("ROLE_ADMIN > ROLE_USER")); + return config; + } + + @Nested + @DisplayName("Default behavior: library beans present when consumer supplies none") + class Defaults { + + @Test + @DisplayName("Library provides a BCryptPasswordEncoder by default") + void libraryEncoderPresentByDefault() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(PasswordEncoder.class); + assertThat(context.getBean(PasswordEncoder.class)).isInstanceOf(BCryptPasswordEncoder.class); + }); + } + + @Test + @DisplayName("Library provides a SessionRegistryImpl by default") + void librarySessionRegistryPresentByDefault() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(SessionRegistry.class); + assertThat(context.getBean(SessionRegistry.class)).isInstanceOf(SessionRegistryImpl.class); + }); + } + + @Test + @DisplayName("Library provides a DaoAuthenticationProvider by default") + void libraryAuthProviderPresentByDefault() { + contextRunner.run(context -> assertThat(context).hasSingleBean(DaoAuthenticationProvider.class)); + } + + @Test + @DisplayName("Library provides a RoleHierarchy by default when config supplies a hierarchy") + void libraryRoleHierarchyPresentByDefault() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(RoleHierarchy.class); + assertThat(context.getBean(RoleHierarchy.class)).isInstanceOf(RoleHierarchyImpl.class); + }); + } + } + + @Nested + @DisplayName("Override behavior: consumer beans win") + class Overrides { + + @Test + @DisplayName("Consumer PasswordEncoder replaces the library's BCryptPasswordEncoder") + void consumerEncoderWins() { + PasswordEncoder consumerEncoder = NoOpPasswordEncoder.getInstance(); + contextRunner.withUserConfiguration(ConsumerEncoderConfig.class).run(context -> { + assertThat(context).hasSingleBean(PasswordEncoder.class); + PasswordEncoder active = context.getBean(PasswordEncoder.class); + assertThat(active).as("consumer's encoder must win").isSameAs(consumerEncoder); + assertThat(active).as("library BCryptPasswordEncoder must NOT be the active encoder").isNotInstanceOf(BCryptPasswordEncoder.class); + }); + } + + @Test + @DisplayName("Consumer SessionRegistry replaces the library's SessionRegistryImpl") + void consumerSessionRegistryWins() { + contextRunner.withUserConfiguration(ConsumerSessionRegistryConfig.class).run(context -> { + assertThat(context).hasSingleBean(SessionRegistry.class); + SessionRegistry active = context.getBean(SessionRegistry.class); + assertThat(active).as("consumer's session registry must win").isSameAs(ConsumerSessionRegistryConfig.CONSUMER_REGISTRY); + assertThat(active).as("library SessionRegistryImpl must NOT be the active registry").isNotInstanceOf(SessionRegistryImpl.class); + }); + } + + @Test + @DisplayName("Consumer RoleHierarchy replaces the library's RoleHierarchyImpl") + void consumerRoleHierarchyWins() { + contextRunner.withUserConfiguration(ConsumerRoleHierarchyConfig.class).run(context -> { + assertThat(context).hasSingleBean(RoleHierarchy.class); + assertThat(context.getBean(RoleHierarchy.class)).isSameAs(ConsumerRoleHierarchyConfig.CONSUMER_HIERARCHY); + }); + } + + @Test + @DisplayName("Consumer DaoAuthenticationProvider replaces the library's, and is wired with the consumer's encoder") + void consumerAuthProviderWins() { + contextRunner.withUserConfiguration(ConsumerAuthProviderConfig.class).run(context -> { + assertThat(context).hasSingleBean(DaoAuthenticationProvider.class); + assertThat(context.getBean(DaoAuthenticationProvider.class)).isSameAs(ConsumerAuthProviderConfig.CONSUMER_PROVIDER); + }); + } + + @Test + @DisplayName("authProvider() honors a consumer-supplied PasswordEncoder (no intra-class self-call to encoder())") + void authProviderUsesConsumerEncoder() { + PasswordEncoder consumerEncoder = NoOpPasswordEncoder.getInstance(); + contextRunner.withUserConfiguration(ConsumerEncoderConfig.class).run(context -> { + DaoAuthenticationProvider provider = context.getBean(DaoAuthenticationProvider.class); + // The provider must encode using the consumer's encoder, not the library BCrypt one. NoOpPasswordEncoder + // returns the raw password, so a match against the raw value proves the consumer encoder was injected. + assertThat(provider.authenticate(new org.springframework.security.authentication.UsernamePasswordAuthenticationToken("test", "x")) + .isAuthenticated()).as("authProvider must authenticate using the consumer's NoOp encoder").isTrue(); + // Sanity: the consumer's encoder is indeed the active one. + assertThat(context.getBean(PasswordEncoder.class)).isSameAs(consumerEncoder); + }); + } + } + + @Nested + @DisplayName("Annotation contract on the auto-configuration bean methods") + class AnnotationContract { + + @Test + @DisplayName("encoder() is @ConditionalOnMissingBean") + void encoderIsConditional() throws Exception { + Method method = UserSecurityBeansAutoConfiguration.class.getMethod("encoder"); + assertThat(method.getAnnotation(ConditionalOnMissingBean.class)).isNotNull(); + } + + @Test + @DisplayName("sessionRegistry() is @ConditionalOnMissingBean") + void sessionRegistryIsConditional() throws Exception { + Method method = UserSecurityBeansAutoConfiguration.class.getMethod("sessionRegistry"); + assertThat(method.getAnnotation(ConditionalOnMissingBean.class)).isNotNull(); + } + + @Test + @DisplayName("roleHierarchy() is @ConditionalOnMissingBean") + void roleHierarchyIsConditional() throws Exception { + Method method = UserSecurityBeansAutoConfiguration.class.getMethod("roleHierarchy"); + assertThat(method.getAnnotation(ConditionalOnMissingBean.class)).isNotNull(); + } + + @Test + @DisplayName("authProvider() is @ConditionalOnMissingBean and receives PasswordEncoder as a parameter") + void authProviderIsConditionalAndParameterized() throws Exception { + Method method = UserSecurityBeansAutoConfiguration.class.getMethod("authProvider", PasswordEncoder.class); + assertThat(method.getAnnotation(ConditionalOnMissingBean.class)).as("@ConditionalOnMissingBean must be present").isNotNull(); + // authProvider must RECEIVE the PasswordEncoder (so a consumer override is honored) rather than self-call encoder(). + assertThat(method.getParameterTypes()).as("authProvider must receive PasswordEncoder via injection").contains(PasswordEncoder.class); + } + } + + // ---- Consumer-supplied stand-in configurations. Not @Configuration so the integration tests' component scan does not pick them up. ---- + + static class ConsumerEncoderConfig { + static final PasswordEncoder CONSUMER_ENCODER = NoOpPasswordEncoder.getInstance(); + + @Bean + PasswordEncoder consumerEncoder() { + return CONSUMER_ENCODER; + } + } + + static class ConsumerSessionRegistryConfig { + static final SessionRegistry CONSUMER_REGISTRY = new CustomSessionRegistry(); + + @Bean + SessionRegistry consumerSessionRegistry() { + return CONSUMER_REGISTRY; + } + } + + static class ConsumerRoleHierarchyConfig { + static final RoleHierarchy CONSUMER_HIERARCHY = RoleHierarchyImpl.fromHierarchy("ROLE_X > ROLE_Y"); + + @Bean + RoleHierarchy consumerRoleHierarchy() { + return CONSUMER_HIERARCHY; + } + } + + static class ConsumerAuthProviderConfig { + static final DaoAuthenticationProvider CONSUMER_PROVIDER = + new DaoAuthenticationProvider(username -> User.withUsername("c").password("x").authorities("ROLE_USER").build()); + + @Bean + DaoAuthenticationProvider consumerAuthProvider() { + return CONSUMER_PROVIDER; + } + } + + /** + * A trivial custom {@link SessionRegistry} that is NOT a {@link SessionRegistryImpl}, so the test can assert the consumer's instance wins. + */ + static class CustomSessionRegistry implements SessionRegistry { + @Override + public List getAllPrincipals() { + return List.of(); + } + + @Override + public List getAllSessions(Object principal, boolean includeExpiredSessions) { + return List.of(); + } + + @Override + public org.springframework.security.core.session.SessionInformation getSessionInformation(String sessionId) { + return null; + } + + @Override + public void refreshLastRequest(String sessionId) {} + + @Override + public void registerNewSession(String sessionId, Object principal) {} + + @Override + public void removeSessionInformation(String sessionId) {} + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointConfigurationTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointConfigurationTest.java index 87e62e19..cce45d98 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointConfigurationTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointConfigurationTest.java @@ -8,7 +8,6 @@ import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; @@ -84,7 +83,10 @@ void shouldNotRegisterLibraryBeanWhenConsumerProvidesEntryPoint() { } } - @Configuration + // Not annotated with @Configuration so the integration tests' component scan does not pick it up + // (it is registered explicitly via ApplicationContextRunner.withUserConfiguration above). A scanned + // @Configuration here would leak a plain LoginUrlAuthenticationEntryPoint("/custom/login") into every + // @SpringBootTest context, overriding the framework's HtmxAwareAuthenticationEntryPoint. static class ConsumerEntryPointConfiguration { @Bean public AuthenticationEntryPoint consumerEntryPoint() { diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/LogoutAuditIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/LogoutAuditIntegrationTest.java new file mode 100644 index 00000000..f2ac7626 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/LogoutAuditIntegrationTest.java @@ -0,0 +1,99 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.digitalsanctuary.spring.user.audit.AuditEvent; +import com.digitalsanctuary.spring.user.service.LogoutSuccessService; +import jakarta.servlet.http.HttpSession; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import com.digitalsanctuary.spring.user.test.annotations.SecurityTest; + +/** + * Integration test verifying that the {@link LogoutSuccessService} audit-publishing handler is wired into the + * security filter chain and that logout still redirects to the configured {@code logoutSuccessURI}. + */ +@SecurityTest +@Import(LogoutAuditIntegrationTest.RealAuthenticationProviderConfig.class) +class LogoutAuditIntegrationTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ApplicationEventPublisher eventPublisher; + + @Autowired + LogoutSuccessHandler logoutSuccessHandler; + + @Value("${user.security.logoutSuccessURI}") + String logoutSuccessURI; + + @Test + void logoutHandlerIsTheLogoutSuccessService() { + // The configured logout success handler must be the audit-publishing LogoutSuccessService. + assertThat(logoutSuccessHandler).isInstanceOf(LogoutSuccessService.class); + } + + @Test + void logoutPublishesAuditEventAndRedirectsToLogoutSuccessUri() throws Exception { + // Given an authenticated session (SecurityTestConfiguration provides user@test.com / "password"). + MvcResult loginResult = mockMvc.perform(formLogin("/user/login").user("username", "user@test.com").password("password")) + .andReturn(); + HttpSession session = loginResult.getRequest().getSession(false); + assertThat(session).isNotNull(); + + Mockito.clearInvocations(eventPublisher); + + // When the user logs out (POST to the configured logout URL with the authenticated session and a CSRF token). + mockMvc.perform(post("/user/logout").session((org.springframework.mock.web.MockHttpSession) session).with(csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl(logoutSuccessURI)); + + // Then a Logout audit event must have been published by the wired LogoutSuccessService. + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Object.class); + Mockito.verify(eventPublisher, Mockito.atLeastOnce()).publishEvent(eventCaptor.capture()); + + boolean publishedLogoutAudit = eventCaptor.getAllValues().stream().filter(e -> e instanceof AuditEvent) + .map(e -> (AuditEvent) e).anyMatch(e -> "Logout".equals(e.getAction())); + assertThat(publishedLogoutAudit).as("a Logout AuditEvent should be published on logout").isTrue(); + } + + /** + * Provides a real {@link DaoAuthenticationProvider} so that form login with a + * {@code UsernamePasswordAuthenticationToken} can succeed in the security test slice. + */ + @TestConfiguration + static class RealAuthenticationProviderConfig { + + @Bean + @Primary + AuthenticationManager testFormLoginAuthenticationManager(UserDetailsService userDetailsService) { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService); + provider.setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder()); + return new ProviderManager(provider); + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/MfaFilterMergingConfigurationTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/MfaFilterMergingConfigurationTest.java new file mode 100644 index 00000000..09935752 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/MfaFilterMergingConfigurationTest.java @@ -0,0 +1,57 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; + +/** + * Tests for {@link MfaFilterMergingConfiguration}. + *

    + * Guards the most safety-critical MFA runtime behaviour: that {@code setMfaEnabled(true)} is applied to authentication + * processing filters (without which a second factor REPLACES the first and the user can never satisfy all required + * factors — the H4 lockout), and that this behaviour is correctly gated behind {@code user.mfa.enabled=true}. A + * Spring Boot upgrade that changed {@code BeanPostProcessor} ordering, or a regression in the gating, would surface here. + *

    + */ +@DisplayName("MfaFilterMergingConfiguration Tests") +class MfaFilterMergingConfigurationTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner().withUserConfiguration(MfaFilterMergingConfiguration.class); + + @Test + @DisplayName("Post-processor enables MFA mode on authentication processing filters") + void shouldEnableMfaOnProcessingFilter() { + BeanPostProcessor postProcessor = MfaFilterMergingConfiguration.mfaFilterMergingPostProcessor(); + AbstractAuthenticationProcessingFilter filter = mock(AbstractAuthenticationProcessingFilter.class); + + Object result = postProcessor.postProcessAfterInitialization(filter, "someAuthenticationFilter"); + + assertThat(result).isSameAs(filter); + verify(filter).setMfaEnabled(true); + } + + @Test + @DisplayName("Post-processor leaves non-filter beans untouched") + void shouldLeaveNonFilterBeansUntouched() { + BeanPostProcessor postProcessor = MfaFilterMergingConfiguration.mfaFilterMergingPostProcessor(); + Object other = new Object(); + + assertThat(postProcessor.postProcessAfterInitialization(other, "someOtherBean")).isSameAs(other); + } + + @Test + @DisplayName("Post-processor bean is registered ONLY when user.mfa.enabled=true") + void shouldGatePostProcessorOnProperty() { + runner.withPropertyValues("user.mfa.enabled=true") + .run(context -> assertThat(context).hasNotFailed().hasBean("mfaFilterMergingPostProcessor")); + runner.withPropertyValues("user.mfa.enabled=false") + .run(context -> assertThat(context).hasNotFailed().doesNotHaveBean("mfaFilterMergingPostProcessor")); + runner.run(context -> assertThat(context).hasNotFailed().doesNotHaveBean("mfaFilterMergingPostProcessor")); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/MfaLoginIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/MfaLoginIntegrationTest.java new file mode 100644 index 00000000..0e3e53ea --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/MfaLoginIntegrationTest.java @@ -0,0 +1,135 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.FactorGrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.web.FilterChainProxy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import com.digitalsanctuary.spring.user.test.annotations.SecurityTest; +import jakarta.servlet.Filter; + +/** + * Integration test proving that multi-factor login is functional: factor authorities from successive login steps must + * MERGE into a single {@link Authentication} so a user who completes every required factor can actually reach protected + * resources. + *

    + * Spring Security 7's factor merging is performed inside {@code AbstractAuthenticationProcessingFilter.doFilter} ONLY + * when {@code mfaEnabled} has been set to {@code true} on the authentication processing filters. That flag is normally + * flipped by {@code @EnableMultiFactorAuthentication}'s {@code EnableMfaFiltersPostProcessor}. Before the H4 fix, this + * framework configured the enforcement side ({@code AllRequiredFactorsAuthorizationManager}) but never activated the + * merging filters, so completing a second factor REPLACED the authentication (losing the first factor) and the user was + * permanently locked out. + *

    + *

    + * The two tests cover different halves of the fix: + *

    + *
      + *
    • {@link #authenticationProcessingFiltersHaveMfaMergingEnabled()} is what proves the merging side. It + * reflectively asserts {@code mfaEnabled == true} on the authentication processing filters; before the fix the + * form-login and WebAuthn filters have {@code mfaEnabled == false}, so cross-step factor merging never happens.
    • + *
    • {@link #bothFactorsGrantAccessWhileSingleFactorIsDenied()} injects a single, already PRE-MERGED authentication + * (both factor authorities in one token) rather than completing two login steps. It therefore exercises the + * enforcement side — that {@code .authenticated()} requires all configured factors and that an + * authentication carrying every factor is granted — and acts as a regression guard against introducing a second + * {@code AuthorizationManagerFactory} bean that would make Spring Security's by-type lookup ambiguous and silently + * disable factor enforcement. It does NOT exercise the cross-step merging itself; that is the reflective test's job.
    • + *
    + */ +@SecurityTest +@TestPropertySource(properties = {"user.mfa.enabled=true", "user.mfa.factors=PASSWORD,WEBAUTHN", "user.webauthn.enabled=true"}) +@DisplayName("MFA Login Integration Tests (H4)") +class MfaLoginIntegrationTest { + + /** A request path that requires authentication under the test profile's {@code defaultAction=deny}. */ + private static final String PROTECTED_URI = "/protected.html"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private FilterChainProxy filterChainProxy; + + @Test + @DisplayName("authentication processing filters have MFA factor-merging enabled when MFA is configured") + void authenticationProcessingFiltersHaveMfaMergingEnabled() { + List processingFilters = findAuthenticationProcessingFilters(); + + assertThat(processingFilters) + .as("the security filter chain must contain authentication processing filters (e.g. form login, WebAuthn)") + .isNotEmpty(); + + // The actual H4 gap: every authentication processing filter must have mfaEnabled=true, otherwise completing a + // second factor replaces the existing authentication instead of merging factor authorities onto it. + assertThat(processingFilters) + .as("every authentication processing filter must have mfaEnabled=true so factor authorities merge across login steps") + .allSatisfy(filter -> assertThat((Boolean) ReflectionTestUtils.getField(filter, "mfaEnabled")) + .as("mfaEnabled on %s", filter.getClass().getSimpleName()) + .isTrue()); + } + + @Test + @DisplayName("both factor authorities grant access while a single factor is denied") + void bothFactorsGrantAccessWhileSingleFactorIsDenied() throws Exception { + // Step 1: only the PASSWORD factor present -> the WEBAUTHN factor is still required, so access is denied. + Authentication passwordOnly = authenticationWithFactors(FactorGrantedAuthority.PASSWORD_AUTHORITY); + mockMvc.perform(get(PROTECTED_URI).with(authentication(passwordOnly))) + .andExpect(result -> assertThat(result.getResponse().getStatus()) + .as("password-only authentication must NOT be granted access to a protected resource") + .isNotEqualTo(200)); + + // Step 2: both factor authorities present in ONE authentication -> all required factors satisfied -> access granted. + Authentication bothFactors = authenticationWithFactors(FactorGrantedAuthority.PASSWORD_AUTHORITY, + FactorGrantedAuthority.WEBAUTHN_AUTHORITY); + mockMvc.perform(get(PROTECTED_URI).with(authentication(bothFactors))) + .andExpect(result -> assertThat(result.getResponse().getStatus()) + .as("authentication carrying BOTH factor authorities must be granted access (no 401/403/redirect)") + .isNotIn(401, 403, 302)); + } + + /** + * Builds an authenticated token for {@code user@test.com} carrying {@code ROLE_USER} plus the supplied factor + * authorities. + * + * @param factorAuthorities the {@link FactorGrantedAuthority} authority strings to attach + * @return an authenticated {@link Authentication} + */ + private Authentication authenticationWithFactors(String... factorAuthorities) { + List authorities = new java.util.ArrayList<>(); + authorities.add(new SimpleGrantedAuthority("ROLE_USER")); + for (String factor : factorAuthorities) { + authorities.add(FactorGrantedAuthority.fromAuthority(factor)); + } + return UsernamePasswordAuthenticationToken.authenticated("user@test.com", null, authorities); + } + + /** + * Walks every {@link SecurityFilterChain} and collects the {@link AbstractAuthenticationProcessingFilter} instances + * (form login, WebAuthn, etc.) that participate in MFA factor merging. + * + * @return the authentication processing filters present in the configured filter chains + */ + private List findAuthenticationProcessingFilters() { + List result = new java.util.ArrayList<>(); + for (SecurityFilterChain chain : filterChainProxy.getFilterChains()) { + for (Filter filter : chain.getFilters()) { + if (filter instanceof AbstractAuthenticationProcessingFilter processingFilter) { + result.add(processingFilter); + } + } + } + return result; + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandlerTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandlerTest.java new file mode 100644 index 00000000..3cdf6ff2 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandlerTest.java @@ -0,0 +1,112 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; +import jakarta.servlet.http.HttpSession; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.LockedException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; + +/** + * Tests for {@link SanitizingOAuth2AuthenticationFailureHandler}, which must never leak raw exception + * messages (which may contain account emails from Locked/Disabled exceptions) to the user-facing session. + */ +@DisplayName("SanitizingOAuth2AuthenticationFailureHandler Tests") +class SanitizingOAuth2AuthenticationFailureHandlerTest { + + private static final String LOGIN_PAGE_URI = "/user/login.html"; + private static final String SESSION_ATTRIBUTE = SanitizingOAuth2AuthenticationFailureHandler.ERROR_MESSAGE_SESSION_ATTRIBUTE; + + private SanitizingOAuth2AuthenticationFailureHandler handler; + + @BeforeEach + void setUp() { + handler = new SanitizingOAuth2AuthenticationFailureHandler(LOGIN_PAGE_URI); + } + + @Test + @DisplayName("Should store generic message (not raw exception) for LockedException with email") + void shouldNotLeakRawMessageForLockedException() throws Exception { + // Given - a LockedException whose message contains the account email (per Task 1.4) + MockHttpServletRequest request = new MockHttpServletRequest(); + // A real OAuth2 callback already has a session (the authorization request is stored there during the + // redirect phase), so the handler stores the user-facing message on the existing session. + request.getSession(true); + MockHttpServletResponse response = new MockHttpServletResponse(); + LockedException raw = new LockedException("Account is locked for user secret.email@example.com"); + + // When + handler.onAuthenticationFailure(request, response, raw); + + // Then + HttpSession session = request.getSession(false); + assertThat(session).isNotNull(); + Object stored = session.getAttribute(SESSION_ATTRIBUTE); + assertThat(stored).isInstanceOf(String.class); + assertThat((String) stored).doesNotContain("secret.email@example.com"); + assertThat((String) stored).doesNotContain("Account is locked"); + assertThat((String) stored).isEqualTo(SanitizingOAuth2AuthenticationFailureHandler.GENERIC_FAILURE_MESSAGE); + assertThat(response.getRedirectedUrl()).isEqualTo(LOGIN_PAGE_URI); + } + + @Test + @DisplayName("Should store generic message for arbitrary OAuth2AuthenticationException") + void shouldNotLeakRawMessageForOAuth2Exception() throws Exception { + // Given + MockHttpServletRequest request = new MockHttpServletRequest(); + request.getSession(true); + MockHttpServletResponse response = new MockHttpServletResponse(); + OAuth2AuthenticationException raw = new OAuth2AuthenticationException( + new OAuth2Error("User Registered With Alternate Provider"), + "Looks like you're signed up with your GOOGLE account leaking@example.com"); + + // When + handler.onAuthenticationFailure(request, response, raw); + + // Then + String stored = (String) request.getSession(false).getAttribute(SESSION_ATTRIBUTE); + assertThat(stored).doesNotContain("leaking@example.com"); + assertThat(stored).isEqualTo(SanitizingOAuth2AuthenticationFailureHandler.GENERIC_FAILURE_MESSAGE); + assertThat(response.getRedirectedUrl()).isEqualTo(LOGIN_PAGE_URI); + } + + @Test + @DisplayName("Should map email_not_verified error to a specific generic message") + void shouldMapEmailNotVerifiedToSpecificGenericMessage() throws Exception { + // Given + MockHttpServletRequest request = new MockHttpServletRequest(); + request.getSession(true); + MockHttpServletResponse response = new MockHttpServletResponse(); + OAuth2AuthenticationException raw = new OAuth2AuthenticationException( + new OAuth2Error("email_not_verified"), "Email verified=false for victim@example.com"); + + // When + handler.onAuthenticationFailure(request, response, raw); + + // Then + String stored = (String) request.getSession(false).getAttribute(SESSION_ATTRIBUTE); + assertThat(stored).doesNotContain("victim@example.com"); + assertThat(stored).isEqualTo(SanitizingOAuth2AuthenticationFailureHandler.EMAIL_NOT_VERIFIED_MESSAGE); + assertThat(response.getRedirectedUrl()).isEqualTo(LOGIN_PAGE_URI); + } + + @Test + @DisplayName("Should NOT allocate a session for a sessionless request (no session forced by scanners)") + void shouldNotCreateSessionWhenNonePresent() throws Exception { + // Given - a cold request with no existing session (e.g. an unauthenticated scanner hitting the callback) + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + LockedException raw = new LockedException("Account is locked for user secret.email@example.com"); + + // When + handler.onAuthenticationFailure(request, response, raw); + + // Then - no session is created (the handler uses getSession(false)) and the redirect still happens. + assertThat(request.getSession(false)).isNull(); + assertThat(response.getRedirectedUrl()).isEqualTo(LOGIN_PAGE_URI); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/SessionInvalidationIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/SessionInvalidationIntegrationTest.java new file mode 100644 index 00000000..15646000 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/SessionInvalidationIntegrationTest.java @@ -0,0 +1,64 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; + +import com.digitalsanctuary.spring.user.test.annotations.SecurityTest; +import jakarta.servlet.http.HttpSession; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.test.web.servlet.MockMvc; + +@SecurityTest +@Import(SessionInvalidationIntegrationTest.RealAuthenticationProviderConfig.class) +class SessionInvalidationIntegrationTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + SessionRegistry sessionRegistry; + + @Test + void registryIsPopulatedAfterLogin() throws Exception { + // SecurityTestConfiguration provides user@test.com / "password" + var result = mockMvc.perform(formLogin("/user/login").user("username", "user@test.com").password("password")) + .andExpect(authenticated()) + .andReturn(); + HttpSession session = result.getRequest().getSession(false); + assertThat(session).isNotNull(); + + // The registry must now know about at least one principal/session + assertThat(sessionRegistry.getAllPrincipals()).isNotEmpty(); + } + + /** + * Provides a real {@link DaoAuthenticationProvider} so that form login with a + * {@code UsernamePasswordAuthenticationToken} can succeed in the security test slice. The default + * {@code SecurityTestConfiguration} only registers a {@code TestingAuthenticationProvider}, which does not + * support username/password tokens. The delegating password encoder understands the {@code {bcrypt}} prefix + * used by the pre-built test users. + */ + @TestConfiguration + static class RealAuthenticationProviderConfig { + + @Bean + @Primary + AuthenticationManager testFormLoginAuthenticationManager(UserDetailsService userDetailsService) { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService); + provider.setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder()); + return new ProviderManager(provider); + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandlerTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandlerTest.java index c36bb6dd..eda22c85 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandlerTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandlerTest.java @@ -12,9 +12,11 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.authentication.event.InteractiveAuthenticationSuccessEvent; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -40,6 +42,9 @@ class WebAuthnAuthenticationSuccessHandlerTest { @Mock private AuthenticationSuccessHandler delegate; + @Mock + private ApplicationEventPublisher eventPublisher; + private WebAuthnAuthenticationSuccessHandler handler; private MockHttpServletRequest request; @@ -49,7 +54,7 @@ class WebAuthnAuthenticationSuccessHandlerTest { @BeforeEach void setUp() { testUser = TestFixtures.Users.standardUser(); - handler = new WebAuthnAuthenticationSuccessHandler(userDetailsService, delegate); + handler = new WebAuthnAuthenticationSuccessHandler(userDetailsService, delegate, eventPublisher); request = new MockHttpServletRequest(); response = new MockHttpServletResponse(); SecurityContextHolder.clearContext(); @@ -108,6 +113,55 @@ void shouldUpdateSecurityContext() throws Exception { assertThat(((DSUserDetails) contextAuth.getPrincipal()).getUser().getEmail()).isEqualTo(testUser.getEmail()); } + @Test + @DisplayName("should publish InteractiveAuthenticationSuccessEvent for the converted authentication") + void shouldPublishInteractiveAuthenticationSuccessEvent() throws Exception { + // Given + Collection authorities = Set.of(new SimpleGrantedAuthority("ROLE_USER")); + PublicKeyCredentialUserEntity userEntity = ImmutablePublicKeyCredentialUserEntity.builder() + .name(testUser.getEmail()).id(new Bytes(new byte[] {1, 2, 3})).displayName(testUser.getFullName()).build(); + + WebAuthnAuthentication webAuthnAuth = new WebAuthnAuthentication(userEntity, authorities); + + DSUserDetails dsUserDetails = new DSUserDetails(testUser, authorities); + when(userDetailsService.loadUserByUsername(testUser.getEmail())).thenReturn(dsUserDetails); + + // When + handler.onAuthenticationSuccess(request, response, webAuthnAuth); + + // Then - an InteractiveAuthenticationSuccessEvent is published so BaseAuthenticationListener fires + // for passkey logins, matching form/OAuth2 logins. + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(InteractiveAuthenticationSuccessEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + InteractiveAuthenticationSuccessEvent event = eventCaptor.getValue(); + assertThat(event.getAuthentication()).isInstanceOf(WebAuthnAuthenticationToken.class); + assertThat(event.getAuthentication().getPrincipal()).isInstanceOf(DSUserDetails.class); + assertThat(((DSUserDetails) event.getAuthentication().getPrincipal()).getUser().getEmail()) + .isEqualTo(testUser.getEmail()); + } + + @Test + @DisplayName("should not fail when no event publisher is configured") + void shouldNotFailWithoutEventPublisher() throws Exception { + // Given - handler constructed without an event publisher (null) + WebAuthnAuthenticationSuccessHandler handlerNoPublisher = + new WebAuthnAuthenticationSuccessHandler(userDetailsService, delegate); + Collection authorities = Set.of(new SimpleGrantedAuthority("ROLE_USER")); + PublicKeyCredentialUserEntity userEntity = ImmutablePublicKeyCredentialUserEntity.builder() + .name(testUser.getEmail()).id(new Bytes(new byte[] {1, 2, 3})).displayName(testUser.getFullName()).build(); + + WebAuthnAuthentication webAuthnAuth = new WebAuthnAuthentication(userEntity, authorities); + + DSUserDetails dsUserDetails = new DSUserDetails(testUser, authorities); + when(userDetailsService.loadUserByUsername(testUser.getEmail())).thenReturn(dsUserDetails); + + // When / Then - no exception, delegate still invoked + handlerNoPublisher.onAuthenticationSuccess(request, response, webAuthnAuth); + verify(delegate).onAuthenticationSuccess(org.mockito.ArgumentMatchers.eq(request), + org.mockito.ArgumentMatchers.eq(response), org.mockito.ArgumentMatchers.any(Authentication.class)); + } + @Test @DisplayName("should preserve authorities from WebAuthn authentication") void shouldPreserveAuthorities() throws Exception { diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityAuthorizationAllowTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityAuthorizationAllowTest.java new file mode 100644 index 00000000..83722f70 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityAuthorizationAllowTest.java @@ -0,0 +1,68 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import com.digitalsanctuary.spring.user.test.annotations.SecurityTest; + +/** + * Authorization-model tests for {@link WebSecurityConfig} under {@code user.security.defaultAction=allow}. + * + *

    + * The {@code allow} model inverts the default: everything is anonymously accessible except the explicitly + * listed {@code protectedURIs}. These tests drive the real security filter chain via {@code @SecurityTest}'s + * {@code MockMvc(addFilters = true)}. + *

    + * + *

    + * As in the deny tests, the test app has no MVC handler for these paths, so a request that passes authorization + * returns 404 (reached the dispatcher, no handler), while a rejected anonymous request to a protected URI + * returns a 302 redirect to the login page. {@code protectedURIs} is {@code /protected.html} (from + * {@code application-test.yml}); any other path is unprotected under {@code allow}. + *

    + */ +@SecurityTest +@TestPropertySource(properties = {"user.security.defaultAction=allow", "user.security.protectedURIs=/protected.html"}) +@DisplayName("WebSecurityConfig Authorization - defaultAction=allow") +class WebSecurityAuthorizationAllowTest { + + /** Listed in protectedURIs, so it requires authentication even under allow. */ + private static final String PROTECTED_URI = "/protected.html"; + + /** Not listed in protectedURIs, so it is anonymously accessible under allow. */ + private static final String UNLISTED_URI = "/some/random/unlisted/path"; + + private static final String LOGIN_PAGE = "/user/login.html"; + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("should redirect anonymous request for a protected URI to login when defaultAction is allow") + void shouldRejectAnonymousProtectedUriWhenAllow() throws Exception { + mockMvc.perform(get(PROTECTED_URI)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl(LOGIN_PAGE)); + } + + @Test + @DisplayName("should allow anonymous access to an unlisted URI when defaultAction is allow") + void shouldAllowAnonymousUnlistedUriWhenAllow() throws Exception { + // 404 (no handler) proves the request passed authorization; it must NOT redirect to login. + mockMvc.perform(get(UNLISTED_URI)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("should allow an authenticated user to reach a protected URI when defaultAction is allow") + void shouldAllowAuthenticatedUserOnProtectedUriWhenAllow() throws Exception { + mockMvc.perform(get(PROTECTED_URI).with(user("user@test.com").roles("USER"))) + .andExpect(status().isNotFound()); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityAuthorizationDenyTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityAuthorizationDenyTest.java new file mode 100644 index 00000000..85b21e1a --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityAuthorizationDenyTest.java @@ -0,0 +1,93 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import com.digitalsanctuary.spring.user.test.annotations.SecurityTest; + +/** + * Authorization-model tests for {@link WebSecurityConfig} under {@code user.security.defaultAction=deny}. + * + *

    + * The {@code deny} model is the secure default: every request requires authentication except the explicitly + * listed {@code unprotectedURIs} (plus the framework's own login/registration/forgot-password pages, which + * {@code getUnprotectedURIsList()} always adds). These tests drive the real security filter chain via + * {@code @SecurityTest}'s {@code MockMvc(addFilters = true)} so the authorization decisions are made by Spring Security + * itself, not by mocks. + *

    + * + *

    How "rejected" vs "allowed" is asserted

    + * + *

    + * The test application defines no MVC handler for these paths, so a request that passes authorization falls + * through to a 404 (no handler) rather than a 200. A request that is rejected by the anonymous-user + * authentication entry point ({@link HtmxAwareAuthenticationEntryPoint} wrapping + * {@code LoginUrlAuthenticationEntryPoint}) produces a 302 redirect to the login page for ordinary browser requests, or + * a 401 for HTMX requests. So: 302/401 == rejected; 404 == authorization passed (the request reached the dispatcher). + * This makes the authorization decision unambiguous without needing a stub controller. + *

    + * + *

    + * The URI lists come from {@code application-test.yml}: {@code unprotectedURIs} includes {@code /index.html}; + * {@code /protected.html} is NOT in that list, so under {@code deny} it requires authentication. {@code loginPageURI} is + * {@code /user/login.html}. + *

    + */ +@SecurityTest +@TestPropertySource(properties = {"user.security.defaultAction=deny"}) +@DisplayName("WebSecurityConfig Authorization - defaultAction=deny") +class WebSecurityAuthorizationDenyTest { + + /** Not present in unprotectedURIs, so it requires authentication under deny. */ + private static final String UNLISTED_URI = "/protected.html"; + + /** Present in unprotectedURIs (application-test.yml), so it is anonymously accessible under deny. */ + private static final String UNPROTECTED_URI = "/index.html"; + + private static final String LOGIN_PAGE = "/user/login.html"; + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("should redirect anonymous request for an unlisted URI to login when defaultAction is deny") + void shouldRejectAnonymousUnlistedUriWhenDeny() throws Exception { + mockMvc.perform(get(UNLISTED_URI)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl(LOGIN_PAGE)); + } + + @Test + @DisplayName("should return 401 for an HTMX anonymous request to an unlisted URI when defaultAction is deny") + void shouldReturn401ForHtmxAnonymousUnlistedUriWhenDeny() throws Exception { + mockMvc.perform(get(UNLISTED_URI).header("HX-Request", "true")) + .andExpect(status().isUnauthorized()) + .andExpect(header().exists("HX-Redirect")); + } + + @Test + @DisplayName("should allow anonymous access to a listed unprotected URI when defaultAction is deny") + void shouldAllowAnonymousUnprotectedUriWhenDeny() throws Exception { + // 404 (no handler) proves the request passed authorization and reached the dispatcher; it must NOT be a + // 302 redirect to login or a 401/403 rejection. + mockMvc.perform(get(UNPROTECTED_URI)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("should allow an authenticated user to reach an otherwise-protected URI when defaultAction is deny") + void shouldAllowAuthenticatedUserOnProtectedUriWhenDeny() throws Exception { + // Authenticated => authorization passes => falls through to 404 (no handler). The key assertion is that it is + // NOT redirected to login and NOT a 401/403. The user(...) post-processor attaches the authentication to the + // request so it flows through the real security filter chain. + mockMvc.perform(get(UNLISTED_URI).with(user("user@test.com").roles("USER"))) + .andExpect(status().isNotFound()); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityAuthorizationFailClosedTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityAuthorizationFailClosedTest.java new file mode 100644 index 00000000..47eeaacc --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityAuthorizationFailClosedTest.java @@ -0,0 +1,60 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import com.digitalsanctuary.spring.user.test.annotations.SecurityTest; + +/** + * Fail-closed test for {@link WebSecurityConfig} when {@code user.security.defaultAction} is set to an + * unrecognized/typo'd value. + * + *

    + * {@code buildSecurityFilterChain} only treats the literal strings {@code "deny"} and {@code "allow"} as valid. Any + * other value (a typo, an empty/garbage string) falls into the {@code else} branch, which logs an error and configures + * {@code anyRequest().denyAll()}. This is the secure, fail-closed behavior: a misconfiguration denies + * everything rather than silently allowing access. These tests lock that behavior in. + *

    + * + *

    + * The strongest assertion is that even an authenticated user is denied (403) for a path that, under any valid + * configuration, they would be allowed to reach (it would fall through to a 404 no-handler). Under {@code denyAll()}, + * authentication is irrelevant — access is refused outright. For an anonymous user, {@code denyAll()} raises an + * {@code AuthenticationException}, so the authentication entry point redirects to the login page (a 3xx) rather than + * serving the resource. Either way, the resource is never served — confirming fail-closed. + *

    + */ +@SecurityTest +@TestPropertySource(properties = {"user.security.defaultAction=bogus-typo-value"}) +@DisplayName("WebSecurityConfig Authorization - unrecognized defaultAction fails closed") +class WebSecurityAuthorizationFailClosedTest { + + /** A path that is in unprotectedURIs under the test profile; under a VALID config it would be reachable. */ + private static final String NORMALLY_ALLOWED_URI = "/index.html"; + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("should deny an authenticated user even on a normally-allowed URI when defaultAction is unrecognized") + void shouldDenyAuthenticatedUserWhenDefaultActionIsUnrecognized() throws Exception { + // Under deny/allow this same authenticated request would pass authorization and fall through to a 404. + // Fail-closed denyAll() refuses it with 403 regardless of authentication. + mockMvc.perform(get(NORMALLY_ALLOWED_URI).with(user("user@test.com").roles("USER"))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("should not serve the resource to an anonymous user when defaultAction is unrecognized") + void shouldNotServeAnonymousRequestWhenDefaultActionIsUnrecognized() throws Exception { + // Anonymous + denyAll() => AuthenticationException => entry point redirects to login (3xx). The resource is + // never served (no 2xx, no 404 fall-through). + mockMvc.perform(get(NORMALLY_ALLOWED_URI)) + .andExpect(status().is3xxRedirection()); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityConfigCompositionTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityConfigCompositionTest.java new file mode 100644 index 00000000..96b06bcd --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityConfigCompositionTest.java @@ -0,0 +1,167 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Method; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.web.SecurityFilterChain; + +/** + * Tests that the library's {@link SecurityFilterChain} bean is composable: it is contributed at a low precedence + * ({@link SecurityFilterProperties#BASIC_AUTH_ORDER}) as the catch-all chain, and its back-off is keyed on the bean + * name {@code securityFilterChain} (via {@link ConditionalOnMissingBean}). + *

    + * This is deliberately name-based, not type-based. A type-based + * {@code @ConditionalOnMissingBean(SecurityFilterChain.class)} would suppress the entire library chain the moment a + * consumer defined any {@link SecurityFilterChain} — even a narrow, single-purpose one (e.g. a test-API or + * actuator chain). That breaks the standard Spring Security multi-chain {@code @Order} layering pattern and silently + * leaves the library's URIs unprotected. Keying on the bean name lets additional, differently-named consumer chains + * coexist with the library chain, while still letting a consumer fully replace it by naming their bean + * {@code securityFilterChain}. + *

    + *

    + * The real bean method requires the full security context and many collaborators to invoke. Rather than boot that + * heavyweight context (which would also risk polluting the shared JPA metamodel across parallel integration contexts), + * this test verifies the composition contract in two complementary, isolated ways: a reflection assertion on the real + * annotation, and an {@link ApplicationContextRunner} test against a lightweight stand-in that mirrors the exact same + * name-based conditional/order semantics. + *

    + */ +@DisplayName("WebSecurityConfig SecurityFilterChain Composition Tests") +class WebSecurityConfigCompositionTest { + + @Nested + @DisplayName("Annotation Contract on the auto-configuration bean method") + class AnnotationContract { + + @Test + @DisplayName("securityFilterChain backs off by bean NAME (not by type), so additional consumer chains coexist") + void securityFilterChainIsConditionalOnMissingBeanByName() throws Exception { + Method method = WebSecurityFilterChainAutoConfiguration.class.getMethod("securityFilterChain", HttpSecurity.class, SessionRegistry.class); + ConditionalOnMissingBean conditional = method.getAnnotation(ConditionalOnMissingBean.class); + assertThat(conditional).as("@ConditionalOnMissingBean must be present").isNotNull(); + assertThat(conditional.name()) + .as("must back off only when a bean NAMED securityFilterChain is present (an explicit full replacement), " + + "so narrower consumer chains coexist instead of suppressing the library chain") + .contains("securityFilterChain"); + assertThat(conditional.value()) + .as("must NOT be type-based: a type match would suppress the library chain whenever ANY SecurityFilterChain exists") + .isEmpty(); + } + + @Test + @DisplayName("securityFilterChain is annotated with a low-precedence @Order so it is the catch-all and consumer chains win their matched paths") + void securityFilterChainIsOrderedAtLowPrecedence() throws Exception { + Method method = WebSecurityFilterChainAutoConfiguration.class.getMethod("securityFilterChain", HttpSecurity.class, SessionRegistry.class); + Order order = method.getAnnotation(Order.class); + assertThat(order).as("@Order must be present").isNotNull(); + // Matches Spring Boot's own default servlet security chain order (SecurityFilterProperties.BASIC_AUTH_ORDER, + // relocated from SecurityProperties.BASIC_AUTH_ORDER in Spring Boot 4.0). + assertThat(order.value()).as("library chain must be low precedence so consumer chains win") + .isEqualTo(SecurityFilterProperties.BASIC_AUTH_ORDER); + assertThat(WebSecurityFilterChainAutoConfiguration.SECURITY_FILTER_CHAIN_ORDER).isEqualTo(SecurityFilterProperties.BASIC_AUTH_ORDER); + } + } + + @Nested + @DisplayName("Composition semantics via ApplicationContextRunner") + class CompositionSemantics { + + // Register the library stand-in as an auto-configuration so it is processed AFTER user-defined beans, + // which is required for @ConditionalOnMissingBean to evaluate correctly. + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(LibraryChainConfiguration.class)); + + @Test + @DisplayName("Library SecurityFilterChain is present by default") + void libraryChainPresentByDefault() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(SecurityFilterChain.class); + assertThat(context).hasBean("securityFilterChain"); + }); + } + + @Test + @DisplayName("Library chain COEXISTS with an additional, differently-named consumer chain (the standard multi-chain pattern)") + void libraryChainCoexistsWithAdditionalConsumerChain() { + contextRunner + .withUserConfiguration(AdditionalConsumerChainConfiguration.class) + .run(context -> { + // Both chains are present: a narrower, differently-named consumer chain must NOT suppress the + // library's catch-all chain. This is the regression guard for the test-API / actuator-chain case. + assertThat(context.getBeansOfType(SecurityFilterChain.class)).hasSize(2); + assertThat(context).hasBean("securityFilterChain"); + assertThat(context).hasBean("apiSecurityFilterChain"); + }); + } + + @Test + @DisplayName("Library chain backs off ONLY when the consumer defines a bean NAMED securityFilterChain (explicit full replacement)") + void libraryChainBacksOffOnNamedReplacement() { + contextRunner + .withUserConfiguration(NamedReplacementChainConfiguration.class) + .run(context -> { + assertThat(context).hasSingleBean(SecurityFilterChain.class); + // The consumer's bean (named securityFilterChain) wins; the library's stand-in backs off. + assertThat(context).hasBean("securityFilterChain"); + }); + } + } + + /** + * Lightweight stand-in mirroring the auto-configuration's composition contract (same name-based conditional and + * order as {@link WebSecurityFilterChainAutoConfiguration#securityFilterChain}). The bean is named + * {@code securityFilterChain} to match the real method's bean name. Returns a Mockito mock so no servlet/security + * context is required. + *

    + * Intentionally NOT annotated with {@code @Configuration}: that would make this nested class eligible for the + * integration tests' component scan ({@code com.digitalsanctuary.spring.user}) and pollute those contexts with stray + * {@link SecurityFilterChain} beans. The {@link ApplicationContextRunner} registers these classes explicitly via + * {@code withConfiguration}/{@code withUserConfiguration}, so they are still processed as (lite) configuration + * sources without being scannable. + *

    + */ + static class LibraryChainConfiguration { + @Bean + @Order(SecurityFilterProperties.BASIC_AUTH_ORDER) + @ConditionalOnMissingBean(name = "securityFilterChain") + public SecurityFilterChain securityFilterChain() { + return org.mockito.Mockito.mock(SecurityFilterChain.class); + } + } + + /** + * An ADDITIONAL, narrower consumer chain with a different bean name and higher precedence (lower {@code @Order}), + * exactly like a test-API or actuator chain. It must coexist with the library's catch-all chain. Intentionally NOT + * annotated with {@code @Configuration} (see {@link LibraryChainConfiguration}). + */ + static class AdditionalConsumerChainConfiguration { + @Bean + @Order(1) + public SecurityFilterChain apiSecurityFilterChain() { + return org.mockito.Mockito.mock(SecurityFilterChain.class); + } + } + + /** + * A FULL-replacement consumer chain named exactly {@code securityFilterChain}; this is the explicit opt-in that + * suppresses the library's chain. Intentionally NOT annotated with {@code @Configuration} (see + * {@link LibraryChainConfiguration}). + */ + static class NamedReplacementChainConfiguration { + @Bean + public SecurityFilterChain securityFilterChain() { + return org.mockito.Mockito.mock(SecurityFilterChain.class); + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityCsrfTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityCsrfTest.java new file mode 100644 index 00000000..b0ff0b33 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/WebSecurityCsrfTest.java @@ -0,0 +1,74 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import com.digitalsanctuary.spring.user.test.annotations.SecurityTest; + +/** + * CSRF-enforcement tests for {@link WebSecurityConfig}. + * + *

    + * Spring Security enables CSRF protection by default; {@link WebSecurityConfig} additionally exempts the paths listed in + * {@code user.security.disableCSRFURIs} via {@code csrf().ignoringRequestMatchers(...)}. Under the test profile + * ({@code application-test.yml}) {@code disableCSRFURIs=/no-csrf-test}. These tests drive the real filter chain via + * {@code @SecurityTest}'s {@code MockMvc(addFilters = true)}. + *

    + * + *

    Isolating CSRF from authorization

    + * + *

    + * A CSRF rejection is a 403 produced by the {@code CsrfFilter}, which runs before authorization. To make CSRF + * the only gate under test, the authenticated cases use {@code @WithMockUser} so the authorization layer would otherwise + * let the request through (the test app has no handler for these paths, so a fully-passing request yields a 404). Thus: + * a 403 here means CSRF rejected the request; a 404 means it passed CSRF (and authorization) and reached the dispatcher. + *

    + */ +@SecurityTest +@TestPropertySource(properties = {"user.security.defaultAction=deny", "user.security.disableCSRFURIs=/no-csrf-test"}) +@DisplayName("WebSecurityConfig CSRF Enforcement") +class WebSecurityCsrfTest { + + /** A CSRF-protected path (not in disableCSRFURIs). */ + private static final String CSRF_PROTECTED_URI = "/protected.html"; + + /** A path listed in disableCSRFURIs, so the CsrfFilter ignores it. */ + private static final String CSRF_EXEMPT_URI = "/no-csrf-test"; + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("should reject a POST without a CSRF token with 403 on a CSRF-protected endpoint") + void shouldRejectPostWithoutCsrfTokenOnProtectedEndpoint() throws Exception { + // Authenticated, so authorization would pass; the only thing that can reject this is the missing CSRF token. + mockMvc.perform(post(CSRF_PROTECTED_URI).with(user("user@test.com").roles("USER"))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("should pass the CSRF filter when a POST carries a valid CSRF token") + void shouldPassCsrfFilterWhenValidTokenProvided() throws Exception { + // With a valid token the request clears CSRF; authorization also passes (authenticated), so it falls through + // to a 404 (no handler). The load-bearing assertion is that it is NOT a 403 CSRF rejection. + mockMvc.perform(post(CSRF_PROTECTED_URI).with(user("user@test.com").roles("USER")).with(csrf())) + .andExpect(status().is(Matchers.not(403))); + } + + @Test + @DisplayName("should not reject a POST without a CSRF token on a disableCSRFURIs path") + void shouldNotEnforceCsrfOnExemptPath() throws Exception { + // The path is in disableCSRFURIs, so the CsrfFilter ignores it; with an authenticated user the request passes + // authorization too and falls through to a 404. The key assertion: NOT a 403 CSRF rejection despite no token. + mockMvc.perform(post(CSRF_EXEMPT_URI).with(user("user@test.com").roles("USER"))) + .andExpect(status().is(Matchers.not(403))) + .andExpect(status().isNotFound()); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/AbstractConcurrentRegistrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/AbstractConcurrentRegistrationTest.java new file mode 100644 index 00000000..4d65f03f --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/service/AbstractConcurrentRegistrationTest.java @@ -0,0 +1,189 @@ +package com.digitalsanctuary.spring.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import com.digitalsanctuary.spring.user.dto.UserDto; +import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; +import com.digitalsanctuary.spring.user.test.app.TestApplication; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.RepeatedTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +/** + * Validates that the SERIALIZABLE duplicate-registration race protection (UserService.registerNewUserAccount -> + * persistNewUserAccount, isolation = SERIALIZABLE, with DataIntegrityViolationException / ConcurrencyFailureException + * translated to UserAlreadyExistException) actually holds on a real, production-grade database — not just on H2. + * + *

    + * Two threads race to register the SAME email at the same instant (released together via a CountDownLatch). On a real + * Postgres (SSI) or MariaDB/InnoDB (next-key locks) under SERIALIZABLE plus the unique email constraint, exactly one + * thread must win (one persisted User) and the other must fail with the handled, translated + * {@link UserAlreadyExistException} — never a raw serialization/constraint exception bubbling up as a 500, and never a + * second user row. + *

    + * + *

    + * Subclasses provide a real database via Testcontainers and point {@code spring.datasource.*} at it via + * {@code @DynamicPropertySource}. The {@code test} profile is active so {@code RolePrivilegeSetupService} seeds the + * {@code ROLE_USER} role on context refresh (the registration path requires it). This class deliberately is NOT + * {@code @Transactional}: each registration must run in its own service-managed transaction on its own thread, and a + * test-managed transaction would defeat that. + *

    + */ +@SpringBootTest(classes = TestApplication.class) +@ActiveProfiles("test") +abstract class AbstractConcurrentRegistrationTest { + + /** Password that satisfies upper/lower/digit/special policy requirements. */ + private static final String VALID_PASSWORD = "Str0ng!Passw0rd#2024"; + + @Autowired + private UserService userService; + + @Autowired + private UserRepository userRepository; + + @AfterEach + void cleanUp() { + // No @Transactional rollback here (the threads commit their own transactions), so clean up explicitly. + userRepository.deleteAll(); + } + + @RepeatedTest(value = 3, name = "{displayName} [run {currentRepetition}/{totalRepetitions}]") + @DisplayName("should serialize concurrent duplicate registration into exactly one user and one UserAlreadyExistException") + void shouldSerializeConcurrentDuplicateRegistrationWhenTwoThreadsRaceSameEmail() throws InterruptedException { + final String email = "race-" + System.nanoTime() + "@test.com"; + + final int threadCount = 2; + final CountDownLatch readyLatch = new CountDownLatch(threadCount); + final CountDownLatch startLatch = new CountDownLatch(1); + final ExecutorService executor = Executors.newFixedThreadPool(threadCount); + + try { + final List> futures = new ArrayList<>(); + for (int i = 0; i < threadCount; i++) { + futures.add(executor.submit(registrationTask(email, readyLatch, startLatch))); + } + + // Wait until both threads are parked at the start gate, then release them simultaneously + // to maximize the registration race. + assertThat(readyLatch.await(30, TimeUnit.SECONDS)) + .as("both registration threads should reach the start gate") + .isTrue(); + startLatch.countDown(); + + final AtomicInteger successCount = new AtomicInteger(); + final AtomicInteger alreadyExistCount = new AtomicInteger(); + final List unexpectedFailures = new ArrayList<>(); + final List persistedUsers = new ArrayList<>(); + + for (Future future : futures) { + final RegistrationOutcome outcome = collect(future); + if (outcome.user != null) { + successCount.incrementAndGet(); + persistedUsers.add(outcome.user); + } else if (outcome.error instanceof UserAlreadyExistException) { + alreadyExistCount.incrementAndGet(); + } else { + unexpectedFailures.add(outcome.error); + } + } + + assertThat(unexpectedFailures) + .as("neither thread should fail with a raw serialization/constraint exception (it must be " + + "translated to UserAlreadyExistException)") + .isEmpty(); + assertThat(successCount.get()) + .as("exactly one thread should successfully register the user") + .isEqualTo(1); + assertThat(alreadyExistCount.get()) + .as("the losing thread should fail with the handled UserAlreadyExistException") + .isEqualTo(1); + + final User found = userRepository.findByEmail(email.toLowerCase()); + assertThat(found) + .as("exactly one user row should exist for the raced email") + .isNotNull(); + + final long rowCount = userRepository.findAll().stream() + .filter(u -> email.toLowerCase().equals(u.getEmail())) + .count(); + assertThat(rowCount) + .as("the database must contain EXACTLY ONE user row for the raced email — two rows would mean " + + "SERIALIZABLE failed to prevent the duplicate") + .isEqualTo(1); + } finally { + executor.shutdownNow(); + } + } + + private Callable registrationTask(final String email, final CountDownLatch readyLatch, + final CountDownLatch startLatch) { + return () -> { + final UserDto dto = new UserDto(); + dto.setFirstName("Race"); + dto.setLastName("Condition"); + dto.setEmail(email); + dto.setPassword(VALID_PASSWORD); + dto.setMatchingPassword(VALID_PASSWORD); + + readyLatch.countDown(); + if (!startLatch.await(30, TimeUnit.SECONDS)) { + throw new IllegalStateException("start gate was never opened"); + } + + try { + return RegistrationOutcome.success(userService.registerNewUserAccount(dto)); + } catch (Throwable t) { + return RegistrationOutcome.failure(t); + } + }; + } + + private RegistrationOutcome collect(final Future future) { + try { + return future.get(60, TimeUnit.SECONDS); + } catch (ExecutionException e) { + // The task catches everything and returns an outcome, so a raw ExecutionException is itself unexpected. + return RegistrationOutcome.failure(e.getCause() != null ? e.getCause() : e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while collecting registration outcome", e); + } catch (Exception e) { + return RegistrationOutcome.failure(e); + } + } + + /** Captures the result of a single registration attempt: either the persisted user or the thrown error. */ + private static final class RegistrationOutcome { + private final User user; + private final Throwable error; + + private RegistrationOutcome(final User user, final Throwable error) { + this.user = user; + this.error = error; + } + + static RegistrationOutcome success(final User user) { + return new RegistrationOutcome(user, null); + } + + static RegistrationOutcome failure(final Throwable error) { + return new RegistrationOutcome(null, error); + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/AbstractConcurrentTokenConsumeTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/AbstractConcurrentTokenConsumeTest.java new file mode 100644 index 00000000..a4a0006e --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/service/AbstractConcurrentTokenConsumeTest.java @@ -0,0 +1,163 @@ +package com.digitalsanctuary.spring.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import com.digitalsanctuary.spring.user.persistence.model.PasswordResetToken; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.model.VerificationToken; +import com.digitalsanctuary.spring.user.persistence.repository.PasswordResetTokenRepository; +import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; +import com.digitalsanctuary.spring.user.persistence.repository.VerificationTokenRepository; +import com.digitalsanctuary.spring.user.test.app.TestApplication; +import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.RepeatedTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +/** + * Validates that single-use token consumption is truly atomic under concurrency on a real, production-grade database + * (not just H2). The fix makes the conditional {@code DELETE} (which returns the affected row count) the atomicity + * guard: the row lock serializes concurrent deletes, so exactly one caller observes a count of {@code 1} (and applies + * the effect) while the rest observe {@code 0} (and are rejected). A plain read-check-delete would let two requests + * both read the token under READ_COMMITTED and both succeed — the replay this test guards against. + * + *

    + * Two threads race to consume the SAME token at the same instant (released together via a {@link CountDownLatch}). + * Exactly one must win. This is asserted for both the password-reset consume path + * ({@link UserService#validateAndConsumePasswordResetToken(String)}) and the email-verification consume path + * ({@link UserVerificationService#validateVerificationToken(String)}). + *

    + * + *

    + * Subclasses provide a real database via Testcontainers. The class is deliberately NOT {@code @Transactional}: each + * consume must run in its own service-managed transaction on its own thread, and a test-managed transaction would + * defeat the race. + *

    + */ +@SpringBootTest(classes = TestApplication.class) +@ActiveProfiles("test") +abstract class AbstractConcurrentTokenConsumeTest { + + @Autowired + private UserService userService; + + @Autowired + private UserVerificationService userVerificationService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordResetTokenRepository passwordResetTokenRepository; + + @Autowired + private VerificationTokenRepository verificationTokenRepository; + + @Autowired + private TokenHasher tokenHasher; + + @AfterEach + void cleanUp() { + // The threads commit their own transactions, so clean up explicitly. + passwordResetTokenRepository.deleteAll(); + verificationTokenRepository.deleteAll(); + userRepository.deleteAll(); + } + + @RepeatedTest(value = 3, name = "{displayName} [run {currentRepetition}/{totalRepetitions}]") + @DisplayName("password-reset token is consumed by exactly one of two racing threads") + void shouldConsumePasswordResetTokenExactlyOnceUnderConcurrency() throws InterruptedException { + final User user = userRepository.save(UserTestDataBuilder.aUser() + .withId(null) // let the DB assign the id so save() performs an INSERT, not a merge + .withEmail("pwd-race-" + System.nanoTime() + "@test.com") + .withFirstName("Pwd").withLastName("Race").enabled().build()); + final String rawToken = "pwd-reset-" + System.nanoTime(); + passwordResetTokenRepository.save(new PasswordResetToken(tokenHasher.hash(rawToken), user, 60)); + + final List outcomes = raceTwoThreads(() -> userService.validateAndConsumePasswordResetToken(rawToken)); + + final long wins = outcomes.stream().filter(o -> o instanceof User).count(); + assertThat(wins).as("exactly one thread may consume the password-reset token").isEqualTo(1); + assertThat(passwordResetTokenRepository.findByToken(tokenHasher.hash(rawToken))) + .as("the token must be gone after consumption").isNull(); + } + + @RepeatedTest(value = 3, name = "{displayName} [run {currentRepetition}/{totalRepetitions}]") + @DisplayName("verification token is consumed by exactly one of two racing threads") + void shouldConsumeVerificationTokenExactlyOnceUnderConcurrency() throws InterruptedException { + final User user = userRepository.save(UserTestDataBuilder.aUser() + .withId(null) // let the DB assign the id so save() performs an INSERT, not a merge + .withEmail("verify-race-" + System.nanoTime() + "@test.com") + .withFirstName("Verify").withLastName("Race").unverified().build()); + final String rawToken = "verify-" + System.nanoTime(); + verificationTokenRepository.save(new VerificationToken(tokenHasher.hash(rawToken), user, 60)); + + final List outcomes = + raceTwoThreads(() -> userVerificationService.validateVerificationToken(rawToken)); + + final long valid = outcomes.stream() + .filter(o -> o == UserService.TokenValidationResult.VALID).count(); + assertThat(valid).as("exactly one thread may validate (consume) the verification token").isEqualTo(1); + assertThat(verificationTokenRepository.findByToken(tokenHasher.hash(rawToken))) + .as("the token must be gone after consumption").isNull(); + assertThat(userRepository.findById(user.getId())) + .get().extracting(User::isEnabled).as("the user is enabled exactly once").isEqualTo(true); + } + + /** + * Runs the given consume action on two threads released simultaneously and returns both outcomes. + * + * @param consume the consume action under test + * @return the two outcomes (a result object, or {@code null} for the losing/rejected call) + */ + private List raceTwoThreads(final Callable consume) throws InterruptedException { + final int threadCount = 2; + final CountDownLatch readyLatch = new CountDownLatch(threadCount); + final CountDownLatch startLatch = new CountDownLatch(1); + final ExecutorService executor = Executors.newFixedThreadPool(threadCount); + try { + final List> futures = new ArrayList<>(); + for (int i = 0; i < threadCount; i++) { + futures.add(executor.submit(() -> { + readyLatch.countDown(); + if (!startLatch.await(30, TimeUnit.SECONDS)) { + throw new IllegalStateException("start gate was never opened"); + } + return consume.call(); + })); + } + + assertThat(readyLatch.await(30, TimeUnit.SECONDS)) + .as("both consume threads should reach the start gate").isTrue(); + startLatch.countDown(); + + final AtomicInteger unexpected = new AtomicInteger(); + final List outcomes = new ArrayList<>(); + for (Future future : futures) { + try { + outcomes.add(future.get(60, TimeUnit.SECONDS)); + } catch (ExecutionException e) { + unexpected.incrementAndGet(); + } catch (Exception e) { + unexpected.incrementAndGet(); + } + } + assertThat(unexpected.get()).as("neither consume call should throw").isZero(); + return outcomes; + } finally { + executor.shutdownNow(); + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceRegistrationGuardTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceRegistrationGuardTest.java index e3487050..90c91401 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceRegistrationGuardTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceRegistrationGuardTest.java @@ -3,6 +3,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -24,10 +28,14 @@ import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.persistence.repository.RoleRepository; import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; -import com.digitalsanctuary.spring.user.registration.RegistrationContext; -import com.digitalsanctuary.spring.user.registration.RegistrationDecision; -import com.digitalsanctuary.spring.user.registration.RegistrationGuard; - +import com.digitalsanctuary.spring.user.registration.RegistrationDeniedException; +import com.digitalsanctuary.spring.user.registration.RegistrationSource; + +/** + * Verifies that {@link DSOAuth2UserService} enforces the centralized {@link com.digitalsanctuary.spring.user.registration.RegistrationGuard} + * (via {@link UserService#enforceRegistrationGuard}) on first-time OAuth2 registration only, and translates a + * {@link RegistrationDeniedException} into the same {@code registration_denied} {@link OAuth2AuthenticationException}. + */ @ExtendWith(MockitoExtension.class) @DisplayName("DSOAuth2UserService RegistrationGuard Tests") class DSOAuth2UserServiceRegistrationGuardTest { @@ -42,7 +50,7 @@ class DSOAuth2UserServiceRegistrationGuardTest { private LoginHelperService loginHelperService; @Mock - private RegistrationGuard registrationGuard; + private UserService userService; @Mock private ApplicationEventPublisher eventPublisher; @@ -70,8 +78,8 @@ void shouldRejectNewOAuth2UserWhenGuardDenies() { .build(); when(userRepository.findByEmail("new@gmail.com")).thenReturn(null); - when(registrationGuard.evaluate(any(RegistrationContext.class))) - .thenReturn(RegistrationDecision.deny("Domain not allowed")); + doThrow(new RegistrationDeniedException("Domain not allowed")) + .when(userService).enforceRegistrationGuard(eq("new@gmail.com"), eq(RegistrationSource.OAUTH2), anyString()); assertThatThrownBy(() -> service.handleOAuthLoginSuccess("google", googleUser)) .isInstanceOf(OAuth2AuthenticationException.class) @@ -92,8 +100,8 @@ void shouldAllowNewOAuth2UserWhenGuardAllows() { .build(); when(userRepository.findByEmail("allowed@gmail.com")).thenReturn(null); - when(registrationGuard.evaluate(any(RegistrationContext.class))) - .thenReturn(RegistrationDecision.allow()); + doNothing().when(userService) + .enforceRegistrationGuard(eq("allowed@gmail.com"), eq(RegistrationSource.OAUTH2), anyString()); when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); User result = service.handleOAuthLoginSuccess("google", googleUser); @@ -123,6 +131,6 @@ void shouldNotCallGuardForExistingOAuth2User() { User result = service.handleOAuthLoginSuccess("google", googleUser); assertThat(result).isNotNull(); - verifyNoInteractions(registrationGuard); + verifyNoInteractions(userService); } } diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceTest.java index 85dcf515..b1598f00 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserServiceTest.java @@ -30,13 +30,12 @@ import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.user.OAuth2User; import com.digitalsanctuary.spring.user.audit.AuditEvent; +import com.digitalsanctuary.spring.user.event.OnRegistrationCompleteEvent; import com.digitalsanctuary.spring.user.fixtures.OAuth2UserTestDataBuilder; import com.digitalsanctuary.spring.user.persistence.model.Role; import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.persistence.repository.RoleRepository; import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; -import com.digitalsanctuary.spring.user.registration.RegistrationDecision; -import com.digitalsanctuary.spring.user.registration.RegistrationGuard; /** * Comprehensive unit tests for DSOAuth2UserService that verify actual business logic, @@ -57,7 +56,7 @@ class DSOAuth2UserServiceTest { private LoginHelperService loginHelperService; @Mock - private RegistrationGuard registrationGuard; + private UserService userService; @Mock private ApplicationEventPublisher eventPublisher; @@ -73,7 +72,8 @@ void setUp() { userRole.setName("ROLE_USER"); userRole.setId(1L); lenient().when(roleRepository.findByName("ROLE_USER")).thenReturn(userRole); - lenient().when(registrationGuard.evaluate(any())).thenReturn(RegistrationDecision.allow()); + // userService.enforceRegistrationGuard is a void method: by default the mock does nothing, + // which represents an allow decision (no RegistrationDeniedException thrown). } @Nested @@ -116,6 +116,13 @@ void shouldCreateNewUserFromGoogleOAuth2() { assertThat(auditEvent.getAction()).isEqualTo("OAuth2 Registration Success"); assertThat(auditEvent.getActionStatus()).isEqualTo("Success"); assertThat(auditEvent.getUser().getEmail()).isEqualTo("john.doe@gmail.com"); + + // Verify a registration event was published for the first-time social registration so consumers + // can observe OAuth2 registrations the same way they observe form registrations. + ArgumentCaptor regCaptor = ArgumentCaptor.forClass(OnRegistrationCompleteEvent.class); + verify(eventPublisher).publishEvent(regCaptor.capture()); + assertThat(regCaptor.getValue().getUser().getEmail()).isEqualTo("john.doe@gmail.com"); + assertThat(regCaptor.getValue().getUser().isEnabled()).isTrue(); } @Test @@ -195,6 +202,105 @@ void shouldConvertEmailToLowercase() { } } + @Nested + @DisplayName("Google email_verified Tests") + class GoogleEmailVerifiedTests { + + @Test + @DisplayName("Should accept Google login when email_verified is Boolean true") + void shouldAcceptWhenEmailVerifiedBooleanTrue() { + // Given + OAuth2User googleUser = OAuth2UserTestDataBuilder.google() + .withEmail("verified@gmail.com") + .withAttribute("email_verified", Boolean.TRUE) + .build(); + + when(userRepository.findByEmail("verified@gmail.com")).thenReturn(null); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + User result = service.handleOAuthLoginSuccess("google", googleUser); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getEmail()).isEqualTo("verified@gmail.com"); + } + + @Test + @DisplayName("Should accept Google login when email_verified is String \"true\"") + void shouldAcceptWhenEmailVerifiedStringTrue() { + // Given + OAuth2User googleUser = OAuth2UserTestDataBuilder.google() + .withEmail("verified-str@gmail.com") + .withAttribute("email_verified", "true") + .build(); + + when(userRepository.findByEmail("verified-str@gmail.com")).thenReturn(null); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + User result = service.handleOAuthLoginSuccess("google", googleUser); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getEmail()).isEqualTo("verified-str@gmail.com"); + } + + @Test + @DisplayName("Should accept Google login when email_verified is absent (trusted)") + void shouldAcceptWhenEmailVerifiedAbsent() { + // Given + OAuth2User googleUser = OAuth2UserTestDataBuilder.google() + .withEmail("noclaim@gmail.com") + .withoutAttribute("email_verified") + .build(); + + when(userRepository.findByEmail("noclaim@gmail.com")).thenReturn(null); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + User result = service.handleOAuthLoginSuccess("google", googleUser); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getEmail()).isEqualTo("noclaim@gmail.com"); + } + + @Test + @DisplayName("Should reject Google login when email_verified is Boolean false") + void shouldRejectWhenEmailVerifiedBooleanFalse() { + // Given + OAuth2User googleUser = OAuth2UserTestDataBuilder.google() + .withEmail("unverified@gmail.com") + .withAttribute("email_verified", Boolean.FALSE) + .build(); + + // When/Then + assertThatThrownBy(() -> service.handleOAuthLoginSuccess("google", googleUser)) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()) + .isEqualTo("email_not_verified")); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + @DisplayName("Should reject Google login when email_verified is String \"false\"") + void shouldRejectWhenEmailVerifiedStringFalse() { + // Given - String "false" with mixed case to verify case-insensitive handling + OAuth2User googleUser = OAuth2UserTestDataBuilder.google() + .withEmail("unverified-str@gmail.com") + .withAttribute("email_verified", "False") + .build(); + + // When/Then + assertThatThrownBy(() -> service.handleOAuthLoginSuccess("google", googleUser)) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()) + .isEqualTo("email_not_verified")); + verify(userRepository, never()).save(any(User.class)); + } + } + @Nested @DisplayName("Facebook OAuth2 Tests") class FacebookOAuth2Tests { diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceRegistrationGuardTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceRegistrationGuardTest.java index 27a8f936..e09e334d 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceRegistrationGuardTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceRegistrationGuardTest.java @@ -3,6 +3,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -24,10 +28,14 @@ import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.persistence.repository.RoleRepository; import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; -import com.digitalsanctuary.spring.user.registration.RegistrationContext; -import com.digitalsanctuary.spring.user.registration.RegistrationDecision; -import com.digitalsanctuary.spring.user.registration.RegistrationGuard; - +import com.digitalsanctuary.spring.user.registration.RegistrationDeniedException; +import com.digitalsanctuary.spring.user.registration.RegistrationSource; + +/** + * Verifies that {@link DSOidcUserService} enforces the centralized {@link com.digitalsanctuary.spring.user.registration.RegistrationGuard} + * (via {@link UserService#enforceRegistrationGuard}) on first-time OIDC registration only, and translates a + * {@link RegistrationDeniedException} into the same {@code registration_denied} {@link OAuth2AuthenticationException}. + */ @ExtendWith(MockitoExtension.class) @DisplayName("DSOidcUserService RegistrationGuard Tests") class DSOidcUserServiceRegistrationGuardTest { @@ -42,7 +50,7 @@ class DSOidcUserServiceRegistrationGuardTest { private LoginHelperService loginHelperService; @Mock - private RegistrationGuard registrationGuard; + private UserService userService; @Mock private ApplicationEventPublisher eventPublisher; @@ -70,8 +78,8 @@ void shouldRejectNewOidcUserWhenGuardDenies() { .build(); when(userRepository.findByEmail("new@company.com")).thenReturn(null); - when(registrationGuard.evaluate(any(RegistrationContext.class))) - .thenReturn(RegistrationDecision.deny("Organization not whitelisted")); + doThrow(new RegistrationDeniedException("Organization not whitelisted")) + .when(userService).enforceRegistrationGuard(eq("new@company.com"), eq(RegistrationSource.OIDC), anyString()); assertThatThrownBy(() -> service.handleOidcLoginSuccess("keycloak", keycloakUser)) .isInstanceOf(OAuth2AuthenticationException.class) @@ -92,8 +100,8 @@ void shouldAllowNewOidcUserWhenGuardAllows() { .build(); when(userRepository.findByEmail("allowed@company.com")).thenReturn(null); - when(registrationGuard.evaluate(any(RegistrationContext.class))) - .thenReturn(RegistrationDecision.allow()); + doNothing().when(userService) + .enforceRegistrationGuard(eq("allowed@company.com"), eq(RegistrationSource.OIDC), anyString()); when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); User result = service.handleOidcLoginSuccess("keycloak", keycloakUser); @@ -123,6 +131,6 @@ void shouldNotCallGuardForExistingOidcUser() { User result = service.handleOidcLoginSuccess("keycloak", keycloakUser); assertThat(result).isNotNull(); - verifyNoInteractions(registrationGuard); + verifyNoInteractions(userService); } } diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceTest.java index 65f90f59..1733fa71 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/DSOidcUserServiceTest.java @@ -29,13 +29,12 @@ import org.springframework.security.oauth2.core.oidc.OidcUserInfo; import org.springframework.security.oauth2.core.oidc.user.OidcUser; import com.digitalsanctuary.spring.user.audit.AuditEvent; +import com.digitalsanctuary.spring.user.event.OnRegistrationCompleteEvent; import com.digitalsanctuary.spring.user.fixtures.OidcUserTestDataBuilder; import com.digitalsanctuary.spring.user.persistence.model.Role; import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.persistence.repository.RoleRepository; import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; -import com.digitalsanctuary.spring.user.registration.RegistrationDecision; -import com.digitalsanctuary.spring.user.registration.RegistrationGuard; /** * Comprehensive unit tests for DSOidcUserService that verify actual business logic @@ -55,7 +54,7 @@ class DSOidcUserServiceTest { private LoginHelperService loginHelperService; @Mock - private RegistrationGuard registrationGuard; + private UserService userService; @Mock private ApplicationEventPublisher eventPublisher; @@ -71,7 +70,8 @@ void setUp() { userRole.setName("ROLE_USER"); userRole.setId(1L); lenient().when(roleRepository.findByName("ROLE_USER")).thenReturn(userRole); - lenient().when(registrationGuard.evaluate(any())).thenReturn(RegistrationDecision.allow()); + // userService.enforceRegistrationGuard is a void method: by default the mock does nothing, + // which represents an allow decision (no RegistrationDeniedException thrown). } @Nested @@ -110,6 +110,14 @@ void shouldCreateNewUserFromKeycloakOidc() { verify(userRepository).save(any(User.class)); verify(eventPublisher).publishEvent(any(AuditEvent.class)); + + // Verify a registration event was published for the first-time social registration so consumers + // can observe OIDC registrations the same way they observe form registrations. + org.mockito.ArgumentCaptor regCaptor = + org.mockito.ArgumentCaptor.forClass(OnRegistrationCompleteEvent.class); + verify(eventPublisher).publishEvent(regCaptor.capture()); + assertThat(regCaptor.getValue().getUser().getEmail()).isEqualTo("john.doe@company.com"); + assertThat(regCaptor.getValue().getUser().isEnabled()).isTrue(); } @Test @@ -212,6 +220,68 @@ void shouldHandleNullOidcUserInfo() { } } + @Nested + @DisplayName("OIDC email_verified Tests") + class OidcEmailVerifiedTests { + + @Test + @DisplayName("Should accept OIDC login when email_verified claim is true") + void shouldAcceptWhenEmailVerifiedTrue() { + // Given + OidcUser keycloakUser = OidcUserTestDataBuilder.keycloak() + .withEmail("verified@keycloak.com") + .withUserInfoClaim("email_verified", true) + .build(); + + when(userRepository.findByEmail("verified@keycloak.com")).thenReturn(null); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + User result = service.handleOidcLoginSuccess("keycloak", keycloakUser); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getEmail()).isEqualTo("verified@keycloak.com"); + } + + @Test + @DisplayName("Should accept OIDC login when email_verified claim is absent (trusted)") + void shouldAcceptWhenEmailVerifiedAbsent() { + // Given + OidcUser keycloakUser = OidcUserTestDataBuilder.keycloak() + .withEmail("noclaim@keycloak.com") + .withoutUserInfoClaim("email_verified") + .build(); + + when(userRepository.findByEmail("noclaim@keycloak.com")).thenReturn(null); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + User result = service.handleOidcLoginSuccess("keycloak", keycloakUser); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getEmail()).isEqualTo("noclaim@keycloak.com"); + } + + @Test + @DisplayName("Should reject OIDC login when email_verified claim is false") + void shouldRejectWhenEmailVerifiedFalse() { + // Given + OidcUser keycloakUser = OidcUserTestDataBuilder.keycloak() + .withEmail("unverified@keycloak.com") + .withUserInfoClaim("email_verified", false) + .build(); + + // When/Then + assertThatThrownBy(() -> service.handleOidcLoginSuccess("keycloak", keycloakUser)) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()) + .isEqualTo("email_not_verified")); + verify(userRepository, never()).save(any(User.class)); + } + } + @Nested @DisplayName("Provider Conflict Tests") class ProviderConflictTests { diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/DSUserDetailsSerializationTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/DSUserDetailsSerializationTest.java new file mode 100644 index 00000000..b463550d --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/service/DSUserDetailsSerializationTest.java @@ -0,0 +1,79 @@ +package com.digitalsanctuary.spring.user.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import com.digitalsanctuary.spring.user.persistence.model.Privilege; +import com.digitalsanctuary.spring.user.persistence.model.Role; +import com.digitalsanctuary.spring.user.persistence.model.User; + +/** + * Verifies that the authenticated principal graph stored in the HTTP session is serializable. + * + *

    This is required for distributed/persistent session stores (e.g. Spring Session JDBC/Redis), + * where the {@link DSUserDetails} principal and its reachable object graph ({@link User} -> + * {@link Role} -> {@link Privilege}) must round-trip through Java serialization.

    + */ +@DisplayName("DSUserDetails Serialization Tests") +class DSUserDetailsSerializationTest { + + /** + * Builds a fully-populated, eagerly-initialized principal and asserts it survives a Java + * serialization round-trip with its key fields intact. This exercises the entire reachable + * object graph (User -> Role -> Privilege), proving the session-stored principal is serializable. + */ + @Test + @DisplayName("Should round-trip DSUserDetails principal graph through Java serialization") + void shouldSerializeAndDeserializePrincipalGraph() throws Exception { + Privilege readPrivilege = new Privilege("READ_PRIVILEGE", "Read access"); + readPrivilege.setId(100L); + + Role userRole = new Role("ROLE_USER", "Standard user"); + userRole.setId(10L); + userRole.setPrivileges(Set.of(readPrivilege)); + + User user = new User(); + user.setId(1L); + user.setEmail("serialize@test.com"); + user.setFirstName("Serial"); + user.setLastName("Izable"); + user.setEnabled(true); + user.setRolesAsSet(Set.of(userRole)); + + GrantedAuthority authority = new SimpleGrantedAuthority("ROLE_USER"); + DSUserDetails principal = new DSUserDetails(user, List.of(authority)); + + byte[] bytes; + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(principal); + oos.flush(); + bytes = baos.toByteArray(); + } + + DSUserDetails roundTripped; + try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes))) { + roundTripped = (DSUserDetails) ois.readObject(); + } + + assertThat(roundTripped).isNotNull(); + assertThat(roundTripped.getUsername()).isEqualTo("serialize@test.com"); + assertThat(roundTripped.getUser().getEmail()).isEqualTo("serialize@test.com"); + assertThat(roundTripped.getUser().getFirstName()).isEqualTo("Serial"); + assertThat(roundTripped.isEnabled()).isTrue(); + assertThat(roundTripped.getAuthorities()).extracting(GrantedAuthority::getAuthority).containsExactly("ROLE_USER"); + assertThat(roundTripped.getUser().getRoles()).extracting(Role::getName).containsExactly("ROLE_USER"); + assertThat(roundTripped.getUser().getRolesAsSet().iterator().next().getPrivileges()).extracting(Privilege::getName) + .containsExactly("READ_PRIVILEGE"); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/DSUserDetailsTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/DSUserDetailsTest.java index df125dff..192124b9 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/DSUserDetailsTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/DSUserDetailsTest.java @@ -251,6 +251,31 @@ void shouldPreserveOidcTokensAlongsideAttributes() { } } + @Nested + @DisplayName("Account Locked Status") + class AccountLockedStatusTests { + + @Test + @DisplayName("isAccountNonLocked should return false when the wrapped User is locked") + void shouldReturnNotAccountNonLockedWhenUserLocked() { + testUser.setLocked(true); + + DSUserDetails details = new DSUserDetails(testUser); + + assertThat(details.isAccountNonLocked()).isFalse(); + } + + @Test + @DisplayName("isAccountNonLocked should return true when the wrapped User is not locked") + void shouldReturnAccountNonLockedWhenUserNotLocked() { + testUser.setLocked(false); + + DSUserDetails details = new DSUserDetails(testUser); + + assertThat(details.isAccountNonLocked()).isTrue(); + } + } + @Nested @DisplayName("Builder") class BuilderTests { diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/LoginAttemptServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/LoginAttemptServiceTest.java index c90c82ae..fb7fa75f 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/LoginAttemptServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/LoginAttemptServiceTest.java @@ -1,11 +1,8 @@ package com.digitalsanctuary.spring.user.service; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -51,24 +48,66 @@ void loginSucceeded_resetsFailedAttempts() { loginAttemptService.loginSucceeded(testUser.getEmail()); - assertEquals(0, testUser.getFailedLoginAttempts()); - assertFalse(testUser.isLocked()); - assertNull(testUser.getLockedDate()); + assertThat(testUser.getFailedLoginAttempts()).isZero(); + assertThat(testUser.isLocked()).isFalse(); + assertThat(testUser.getLockedDate()).isNull(); verify(userRepository).save(testUser); } @Test - void loginFailed_incrementsFailedAttempts() { - when(userRepository.findByEmail(anyString())).thenReturn(testUser); + void loginFailed_callsAtomicIncrementAndLocksAtThreshold() { + // The atomic UPDATE reports one row affected (the user exists). + when(userRepository.incrementFailedAttempts(testUser.getEmail())).thenReturn(1); + // Re-read returns the user whose counter has reached the threshold (simulating the fresh DB value after the bulk update + clear). + testUser.setFailedLoginAttempts(failedLoginAttempts); + when(userRepository.findByEmail(testUser.getEmail())).thenReturn(testUser); + + loginAttemptService.loginFailed(testUser.getEmail()); + + verify(userRepository).incrementFailedAttempts(testUser.getEmail()); + verify(userRepository).findByEmail(testUser.getEmail()); + assertThat(testUser.isLocked()).isTrue(); + assertThat(testUser.getLockedDate()).isNotNull(); + verify(userRepository).save(testUser); + } + + @Test + void loginFailed_doesNotLockBelowThreshold() { + when(userRepository.incrementFailedAttempts(testUser.getEmail())).thenReturn(1); + // Re-read returns the user with a count below the lockout threshold. + testUser.setFailedLoginAttempts(failedLoginAttempts - 1); + when(userRepository.findByEmail(testUser.getEmail())).thenReturn(testUser); + + loginAttemptService.loginFailed(testUser.getEmail()); + + verify(userRepository).incrementFailedAttempts(testUser.getEmail()); + assertThat(testUser.isLocked()).isFalse(); + assertThat(testUser.getLockedDate()).isNull(); + verify(userRepository, never()).save(testUser); + } + + @Test + void loginFailed_warnsAndStopsWhenUserNotFound() { + // The atomic UPDATE affected no rows, meaning the user does not exist. + when(userRepository.incrementFailedAttempts(anyString())).thenReturn(0); + + loginAttemptService.loginFailed("missing@example.com"); + + verify(userRepository).incrementFailedAttempts("missing@example.com"); + verify(userRepository, never()).findByEmail(anyString()); + verify(userRepository, never()).save(testUser); + } + + @Test + void loginFailed_doesNothingWhenLockoutDisabled() { + loginAttemptService.setMaxFailedLoginAttempts(0); - for (int i = 1; i <= failedLoginAttempts; i++) { - loginAttemptService.loginFailed(testUser.getEmail()); - } + loginAttemptService.loginFailed(testUser.getEmail()); - assertEquals(failedLoginAttempts, testUser.getFailedLoginAttempts()); - assertTrue(testUser.isLocked()); - assertNotNull(testUser.getLockedDate()); - verify(userRepository, times(failedLoginAttempts)).save(testUser); + // When the feature is disabled, the atomic increment must not be invoked at all. + verify(userRepository, never()).incrementFailedAttempts(anyString()); + verify(userRepository, never()).findByEmail(anyString()); + verify(userRepository, never()).save(testUser); } @Test @@ -78,14 +117,14 @@ void isLocked_returnsTrueWhenUserIsLocked() { when(userRepository.findByEmail(anyString())).thenReturn(testUser); - assertTrue(loginAttemptService.isLocked(testUser.getEmail())); + assertThat(loginAttemptService.isLocked(testUser.getEmail())).isTrue(); } @Test void isLocked_returnsFalseWhenUserIsNotLocked() { when(userRepository.findByEmail(anyString())).thenReturn(testUser); - assertFalse(loginAttemptService.isLocked(testUser.getEmail())); + assertThat(loginAttemptService.isLocked(testUser.getEmail())).isFalse(); } @Test @@ -96,12 +135,41 @@ void isLocked_unlocksUserAfterLockoutDuration() { when(userRepository.findByEmail(anyString())).thenReturn(testUser); - assertFalse(loginAttemptService.isLocked(testUser.getEmail())); - assertFalse(testUser.isLocked()); - assertNull(testUser.getLockedDate()); - assertEquals(0, testUser.getFailedLoginAttempts()); + assertThat(loginAttemptService.isLocked(testUser.getEmail())).isFalse(); + assertThat(testUser.isLocked()).isFalse(); + assertThat(testUser.getLockedDate()).isNull(); + assertThat(testUser.getFailedLoginAttempts()).isZero(); verify(userRepository).save(testUser); } + @Test + void checkIfUserShouldBeUnlocked_adminOnlyUnlockKeepsLockedDespitePastLockedDate() { + // A negative accountLockoutDuration means the account can ONLY be unlocked by an administrator, + // never automatically by elapsed time — even with a lockedDate far in the past. + loginAttemptService.setAccountLockoutDuration(-1); + testUser.setLocked(true); + testUser.setLockedDate(new Date(System.currentTimeMillis() - 60L * 60 * 1000)); // locked an hour ago + + User result = loginAttemptService.checkIfUserShouldBeUnlocked(testUser); + + assertThat(result.isLocked()).isTrue(); + assertThat(result.getLockedDate()).isNotNull(); + // No auto-unlock occurred, so nothing should have been persisted. + verify(userRepository, never()).save(testUser); + } + + @Test + void isLocked_adminOnlyUnlockKeepsUserLockedDespitePastLockedDate() { + // End-to-end through isLocked(): with admin-only unlock, a long-locked user stays locked. + loginAttemptService.setAccountLockoutDuration(-1); + testUser.setLocked(true); + testUser.setLockedDate(new Date(System.currentTimeMillis() - 60L * 60 * 1000)); + when(userRepository.findByEmail(anyString())).thenReturn(testUser); + + assertThat(loginAttemptService.isLocked(testUser.getEmail())).isTrue(); + assertThat(testUser.isLocked()).isTrue(); + verify(userRepository, never()).save(testUser); + } + // Additional tests can be written for edge cases and exception handling } diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/LoginHelperServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/LoginHelperServiceTest.java index 73de7064..66060683 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/LoginHelperServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/LoginHelperServiceTest.java @@ -1,8 +1,11 @@ package com.digitalsanctuary.spring.user.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.time.Instant; @@ -21,6 +24,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.LockedException; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.core.oidc.OidcIdToken; @@ -121,6 +126,7 @@ void shouldCheckUserUnlockStatus() { unlockedUser.setEmail(testUser.getEmail()); unlockedUser.setLocked(false); unlockedUser.setLockedDate(null); + unlockedUser.setEnabled(true); when(loginAttemptService.checkIfUserShouldBeUnlocked(testUser)).thenReturn(unlockedUser); doReturn(testAuthorities).when(authorityService).getAuthoritiesFromUser(unlockedUser); @@ -170,8 +176,8 @@ void shouldHandleUserWithNoAuthorities() { } @Test - @DisplayName("Should handle locked user that remains locked") - void shouldHandleLockedUserThatRemainsLocked() { + @DisplayName("Should reject locked user that remains locked") + void shouldRejectLockedUserThatRemainsLocked() { // Given testUser.setLocked(true); testUser.setFailedLoginAttempts(5); @@ -179,35 +185,20 @@ void shouldHandleLockedUserThatRemainsLocked() { testUser.setLockedDate(lockedDate); when(loginAttemptService.checkIfUserShouldBeUnlocked(testUser)).thenReturn(testUser); // User remains locked - doReturn(testAuthorities).when(authorityService).getAuthoritiesFromUser(testUser); - - // When - DSUserDetails result = loginHelperService.userLoginHelper(testUser); - // Then - assertThat(result).isNotNull(); - assertThat(result.getUser().isLocked()).isTrue(); - assertThat(result.isAccountNonLocked()).isFalse(); - assertThat(result.getUser().getFailedLoginAttempts()).isEqualTo(5); + // When / Then - a still-locked account must not be allowed to authenticate + assertThatThrownBy(() -> loginHelperService.userLoginHelper(testUser)).isInstanceOf(LockedException.class); } @Test - @DisplayName("Should handle disabled user correctly") - void shouldHandleDisabledUser() { + @DisplayName("Should reject disabled user") + void shouldRejectDisabledUser() { // Given testUser.setEnabled(false); when(loginAttemptService.checkIfUserShouldBeUnlocked(testUser)).thenReturn(testUser); - doReturn(testAuthorities).when(authorityService).getAuthoritiesFromUser(testUser); - // When - DSUserDetails result = loginHelperService.userLoginHelper(testUser); - - // Then - assertThat(result).isNotNull(); - assertThat(result.isEnabled()).isFalse(); - assertThat(result.getUser().isEnabled()).isFalse(); - // Even disabled users should get their authorities - assertThat(result.getAuthorities()).isNotEmpty(); + // When / Then - a disabled account must not be allowed to authenticate + assertThatThrownBy(() -> loginHelperService.userLoginHelper(testUser)).isInstanceOf(DisabledException.class); } @Test @@ -540,4 +531,86 @@ void shouldFallBackToIdTokenClaimsWhenOidcAttributesNull() { assertThat(result.getAttributes()).containsEntry("email", "oidc@example.com"); } } + + @Nested + @DisplayName("Account Status Enforcement Tests (H3)") + class AccountStatusEnforcementTests { + + @Test + @DisplayName("Should reject disabled account on OAuth2 login") + void rejectsDisabledAccountOnOAuthLogin() { + User user = new User(); + user.setEmail("x@test.com"); + user.setEnabled(false); + user.setLocked(false); + lenient().when(loginAttemptService.checkIfUserShouldBeUnlocked(user)).thenReturn(user); + + assertThatThrownBy(() -> loginHelperService.userLoginHelper(user, (Map) null)).isInstanceOf(DisabledException.class); + } + + @Test + @DisplayName("Should reject locked account on OAuth2 login") + void rejectsLockedAccountOnOAuthLogin() { + User user = new User(); + user.setEmail("x@test.com"); + user.setEnabled(true); + user.setLocked(true); + when(loginAttemptService.checkIfUserShouldBeUnlocked(user)).thenReturn(user); + + assertThatThrownBy(() -> loginHelperService.userLoginHelper(user, (Map) null)).isInstanceOf(LockedException.class); + } + + @Test + @DisplayName("Should reject disabled account on OIDC login") + void rejectsDisabledAccountOnOidcLogin() { + User user = new User(); + user.setEmail("x@test.com"); + user.setEnabled(false); + user.setLocked(false); + OidcIdToken idToken = new OidcIdToken("token", Instant.now(), Instant.now().plusSeconds(3600), Map.of("sub", "s")); + OidcUserInfo userInfo = new OidcUserInfo(Map.of("sub", "s")); + lenient().when(loginAttemptService.checkIfUserShouldBeUnlocked(user)).thenReturn(user); + + assertThatThrownBy(() -> loginHelperService.userLoginHelper(user, userInfo, idToken)).isInstanceOf(DisabledException.class); + } + + @Test + @DisplayName("Should reject locked account on OIDC login") + void rejectsLockedAccountOnOidcLogin() { + User user = new User(); + user.setEmail("x@test.com"); + user.setEnabled(true); + user.setLocked(true); + OidcIdToken idToken = new OidcIdToken("token", Instant.now(), Instant.now().plusSeconds(3600), Map.of("sub", "s")); + OidcUserInfo userInfo = new OidcUserInfo(Map.of("sub", "s")); + when(loginAttemptService.checkIfUserShouldBeUnlocked(user)).thenReturn(user); + + assertThatThrownBy(() -> loginHelperService.userLoginHelper(user, userInfo, idToken)).isInstanceOf(LockedException.class); + } + + @Test + @DisplayName("Should prefer LockedException when both locked and disabled") + void prefersLockedExceptionWhenLockedAndDisabled() { + User user = new User(); + user.setEmail("x@test.com"); + user.setEnabled(false); + user.setLocked(true); + when(loginAttemptService.checkIfUserShouldBeUnlocked(user)).thenReturn(user); + + assertThatThrownBy(() -> loginHelperService.userLoginHelper(user, (Map) null)).isInstanceOf(LockedException.class); + } + + @Test + @DisplayName("Should allow enabled and unlocked account") + void allowsEnabledUnlockedAccount() { + User user = new User(); + user.setEmail("x@test.com"); + user.setEnabled(true); + user.setLocked(false); + when(loginAttemptService.checkIfUserShouldBeUnlocked(user)).thenReturn(user); + when(authorityService.getAuthoritiesFromUser(any(User.class))).thenReturn(Collections.emptyList()); + + assertThatCode(() -> loginHelperService.userLoginHelper(user, (Map) null)).doesNotThrowAnyException(); + } + } } diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/LogoutSuccessServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/LogoutSuccessServiceTest.java index 030ca254..0721b91c 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/LogoutSuccessServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/LogoutSuccessServiceTest.java @@ -239,7 +239,7 @@ class IPAddressExtractionTests { void shouldExtractIpFromXForwardedForHeader() throws IOException, ServletException { // Given String forwardedIp = "203.0.113.195"; - when(request.getHeader("X-Forwarded-For")).thenReturn(forwardedIp); + lenient().when(request.getHeader("X-Forwarded-For")).thenReturn(forwardedIp); when(authentication.getPrincipal()).thenReturn(userDetails); when(userDetails.getUser()).thenReturn(testUser); @@ -259,8 +259,8 @@ void shouldExtractIpFromXForwardedForHeader() throws IOException, ServletExcepti void shouldExtractIpFromXRealIpHeader() throws IOException, ServletException { // Given String realIp = "198.51.100.178"; - when(request.getHeader("X-Forwarded-For")).thenReturn(null); - when(request.getHeader("X-Real-IP")).thenReturn(realIp); + lenient().when(request.getHeader("X-Forwarded-For")).thenReturn(null); + lenient().when(request.getHeader("X-Real-IP")).thenReturn(realIp); when(authentication.getPrincipal()).thenReturn(userDetails); when(userDetails.getUser()).thenReturn(testUser); @@ -280,9 +280,9 @@ void shouldExtractIpFromXRealIpHeader() throws IOException, ServletException { void shouldFallBackToRemoteAddress() throws IOException, ServletException { // Given String remoteAddr = "192.0.2.146"; - when(request.getHeader("X-Forwarded-For")).thenReturn(null); - when(request.getHeader("X-Real-IP")).thenReturn(null); - when(request.getHeader("CF-Connecting-IP")).thenReturn(null); + lenient().when(request.getHeader("X-Forwarded-For")).thenReturn(null); + lenient().when(request.getHeader("X-Real-IP")).thenReturn(null); + lenient().when(request.getHeader("CF-Connecting-IP")).thenReturn(null); when(request.getRemoteAddr()).thenReturn(remoteAddr); when(authentication.getPrincipal()).thenReturn(userDetails); when(userDetails.getUser()).thenReturn(testUser); @@ -331,7 +331,7 @@ void shouldCreateAuditEventWithAllUserDetails() throws IOException, ServletExcep @DisplayName("Should handle null user agent gracefully") void shouldHandleNullUserAgentGracefully() throws IOException, ServletException { // Given - when(request.getHeader("User-Agent")).thenReturn(null); + lenient().when(request.getHeader("User-Agent")).thenReturn(null); when(authentication.getPrincipal()).thenReturn(userDetails); when(userDetails.getUser()).thenReturn(testUser); diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/MariaDBConcurrentRegistrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/MariaDBConcurrentRegistrationTest.java new file mode 100644 index 00000000..83c7223d --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/service/MariaDBConcurrentRegistrationTest.java @@ -0,0 +1,36 @@ +package com.digitalsanctuary.spring.user.service; + +import org.junit.jupiter.api.DisplayName; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MariaDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Runs the concurrent duplicate-registration race against a real MariaDB container. MariaDB/InnoDB implements + * SERIALIZABLE using next-key locks; a losing concurrent transaction sees either the unique-email constraint violation + * or a lock/serialization failure, both of which UserService.persistNewUserAccount translates to + * UserAlreadyExistException. + */ +@Testcontainers +@DisplayName("MariaDB Concurrent Registration Tests") +class MariaDBConcurrentRegistrationTest extends AbstractConcurrentRegistrationTest { + + @Container + static final MariaDBContainer MARIADB = new MariaDBContainer<>("mariadb:11.4") + .withDatabaseName("testdb") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MARIADB::getJdbcUrl); + registry.add("spring.datasource.username", MARIADB::getUsername); + registry.add("spring.datasource.password", MARIADB::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.mariadb.jdbc.Driver"); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.MariaDBDialect"); + registry.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.MariaDBDialect"); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/MariaDBConcurrentTokenConsumeTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/MariaDBConcurrentTokenConsumeTest.java new file mode 100644 index 00000000..0d176c46 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/service/MariaDBConcurrentTokenConsumeTest.java @@ -0,0 +1,34 @@ +package com.digitalsanctuary.spring.user.service; + +import org.junit.jupiter.api.DisplayName; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MariaDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Runs the concurrent single-use token-consume race against a real MariaDB container, proving the conditional DELETE + * guard prevents token replay under InnoDB's default isolation. + */ +@Testcontainers +@DisplayName("MariaDB Concurrent Token Consume Tests") +class MariaDBConcurrentTokenConsumeTest extends AbstractConcurrentTokenConsumeTest { + + @Container + static final MariaDBContainer MARIADB = new MariaDBContainer<>("mariadb:11.4") + .withDatabaseName("testdb") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MARIADB::getJdbcUrl); + registry.add("spring.datasource.username", MARIADB::getUsername); + registry.add("spring.datasource.password", MARIADB::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.mariadb.jdbc.Driver"); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.MariaDBDialect"); + registry.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.MariaDBDialect"); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/PostgreSQLConcurrentRegistrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/PostgreSQLConcurrentRegistrationTest.java new file mode 100644 index 00000000..4fd33e35 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/service/PostgreSQLConcurrentRegistrationTest.java @@ -0,0 +1,35 @@ +package com.digitalsanctuary.spring.user.service; + +import org.junit.jupiter.api.DisplayName; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Runs the concurrent duplicate-registration race against a real PostgreSQL container. PostgreSQL implements + * SERIALIZABLE via Serializable Snapshot Isolation (SSI), so a losing concurrent transaction is aborted with a + * serialization failure that UserService.persistNewUserAccount translates to UserAlreadyExistException. + */ +@Testcontainers +@DisplayName("PostgreSQL Concurrent Registration Tests") +class PostgreSQLConcurrentRegistrationTest extends AbstractConcurrentRegistrationTest { + + @Container + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:17") + .withDatabaseName("testdb") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + registry.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.PostgreSQLDialect"); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/PostgreSQLConcurrentTokenConsumeTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/PostgreSQLConcurrentTokenConsumeTest.java new file mode 100644 index 00000000..c793e0ba --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/service/PostgreSQLConcurrentTokenConsumeTest.java @@ -0,0 +1,34 @@ +package com.digitalsanctuary.spring.user.service; + +import org.junit.jupiter.api.DisplayName; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Runs the concurrent single-use token-consume race against a real PostgreSQL container, proving the conditional + * DELETE guard prevents token replay under READ_COMMITTED. + */ +@Testcontainers +@DisplayName("PostgreSQL Concurrent Token Consume Tests") +class PostgreSQLConcurrentTokenConsumeTest extends AbstractConcurrentTokenConsumeTest { + + @Container + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:17") + .withDatabaseName("testdb") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + registry.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.PostgreSQLDialect"); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/SessionInvalidationServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/SessionInvalidationServiceTest.java index f392aa6e..e2441806 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/SessionInvalidationServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/SessionInvalidationServiceTest.java @@ -8,6 +8,7 @@ import java.util.Collections; import java.util.List; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -16,9 +17,13 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpSession; import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.session.SessionRegistry; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder; @@ -274,4 +279,97 @@ void includesPrincipalCountInInfoLog() { verify(session).expireNow(); } } + + @Nested + @DisplayName("invalidateSessionsAfterPasswordChange Tests") + class InvalidateSessionsAfterPasswordChangeTests { + + @AfterEach + void clearRequestContext() { + RequestContextHolder.resetRequestAttributes(); + } + + private void bindRequestWithSession(String sessionId) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSession(new MockHttpSession(null, sessionId)); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + + @Test + @DisplayName("by default preserves and regenerates the current session, invalidating only the user's OTHER sessions") + void preservesAndRegeneratesCurrentSession() { + ReflectionTestUtils.setField(sessionInvalidationService, "keepCurrentSessionOnPasswordChange", true); + bindRequestWithSession("current-session"); + + SessionInformation currentSession = mock(SessionInformation.class); + SessionInformation otherSession = mock(SessionInformation.class); + when(currentSession.getSessionId()).thenReturn("current-session"); + when(otherSession.getSessionId()).thenReturn("other-session"); + when(sessionRegistry.getAllPrincipals()).thenReturn(List.of(testUser)); + when(sessionRegistry.getAllSessions(testUser, false)).thenReturn(Arrays.asList(currentSession, otherSession)); + + int invalidated = sessionInvalidationService.invalidateSessionsAfterPasswordChange(testUser); + + // Only the OTHER session is invalidated; the current one is preserved (kept logged in). + assertThat(invalidated).isEqualTo(1); + verify(otherSession).expireNow(); + verify(currentSession, never()).expireNow(); + // The current session's id is regenerated (fixation protection) and the registry kept consistent. + ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + String newSessionId = attrs.getRequest().getSession(false).getId(); + assertThat(newSessionId).isNotEqualTo("current-session"); + verify(sessionRegistry).removeSessionInformation("current-session"); + verify(sessionRegistry).registerNewSession(newSessionId, testUser); + } + + @Test + @DisplayName("when policy is disabled, invalidates ALL sessions including the current one") + void invalidatesAllWhenPolicyDisabled() { + ReflectionTestUtils.setField(sessionInvalidationService, "keepCurrentSessionOnPasswordChange", false); + bindRequestWithSession("current-session"); + + SessionInformation currentSession = mock(SessionInformation.class); + when(currentSession.getSessionId()).thenReturn("current-session"); + when(sessionRegistry.getAllPrincipals()).thenReturn(List.of(testUser)); + when(sessionRegistry.getAllSessions(testUser, false)).thenReturn(List.of(currentSession)); + + int invalidated = sessionInvalidationService.invalidateSessionsAfterPasswordChange(testUser); + + assertThat(invalidated).isEqualTo(1); + verify(currentSession).expireNow(); + verify(sessionRegistry, never()).registerNewSession(anyString(), any()); + } + + @Test + @DisplayName("with no current request (e.g. token-based reset), invalidates all of the user's sessions") + void invalidatesAllWhenNoCurrentRequest() { + ReflectionTestUtils.setField(sessionInvalidationService, "keepCurrentSessionOnPasswordChange", true); + // No RequestContextHolder bound: there is no current session to preserve. + + SessionInformation session1 = mock(SessionInformation.class); + SessionInformation session2 = mock(SessionInformation.class); + when(session1.getSessionId()).thenReturn("s1"); + when(session2.getSessionId()).thenReturn("s2"); + when(sessionRegistry.getAllPrincipals()).thenReturn(List.of(testUser)); + when(sessionRegistry.getAllSessions(testUser, false)).thenReturn(Arrays.asList(session1, session2)); + + int invalidated = sessionInvalidationService.invalidateSessionsAfterPasswordChange(testUser); + + assertThat(invalidated).isEqualTo(2); + verify(session1).expireNow(); + verify(session2).expireNow(); + verify(sessionRegistry, never()).registerNewSession(anyString(), any()); + } + + @Test + @DisplayName("returns 0 and does nothing when user is null") + void returnsZeroWhenUserIsNull() { + ReflectionTestUtils.setField(sessionInvalidationService, "keepCurrentSessionOnPasswordChange", true); + + int invalidated = sessionInvalidationService.invalidateSessionsAfterPasswordChange(null); + + assertThat(invalidated).isEqualTo(0); + verify(sessionRegistry, never()).getAllPrincipals(); + } + } } diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/TokenHasherTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/TokenHasherTest.java new file mode 100644 index 00000000..9213b3b4 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/service/TokenHasherTest.java @@ -0,0 +1,70 @@ +package com.digitalsanctuary.spring.user.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link TokenHasher}. + */ +@DisplayName("TokenHasher Tests") +class TokenHasherTest { + + @Test + @DisplayName("hash is deterministic - same input yields same output (plain SHA-256)") + void shouldProduceDeterministicHashWhenNoSecretConfigured() { + TokenHasher hasher = new TokenHasher(null); + String raw = "my-high-entropy-token"; + + String first = hasher.hash(raw); + String second = hasher.hash(raw); + + assertThat(first).isEqualTo(second); + } + + @Test + @DisplayName("hashed value is not equal to the raw token") + void shouldNotReturnRawTokenWhenHashing() { + TokenHasher hasher = new TokenHasher(null); + String raw = "my-high-entropy-token"; + + assertThat(hasher.hash(raw)).isNotEqualTo(raw); + } + + @Test + @DisplayName("hash output is a 64-char lowercase hex string (SHA-256)") + void shouldReturnHexEncodedSha256() { + TokenHasher hasher = new TokenHasher(null); + + assertThat(hasher.hash("token")).matches("[0-9a-f]{64}"); + } + + @Test + @DisplayName("keyed HMAC differs from plain SHA-256 for the same input") + void shouldProduceDifferentHashWhenSecretConfigured() { + TokenHasher plain = new TokenHasher(null); + TokenHasher keyed = new TokenHasher("super-secret-key"); + String raw = "my-high-entropy-token"; + + assertThat(keyed.hash(raw)).isNotEqualTo(plain.hash(raw)); + } + + @Test + @DisplayName("keyed HMAC is deterministic with the same secret") + void shouldProduceDeterministicHashWhenSecretConfigured() { + TokenHasher keyed = new TokenHasher("super-secret-key"); + String raw = "my-high-entropy-token"; + + assertThat(keyed.hash(raw)).isEqualTo(keyed.hash(raw)); + } + + @Test + @DisplayName("blank secret falls back to plain SHA-256 behavior") + void shouldTreatBlankSecretAsUnset() { + TokenHasher blank = new TokenHasher(" "); + TokenHasher plain = new TokenHasher(null); + + assertThat(blank.hash("token")).isEqualTo(plain.hash("token")); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java new file mode 100644 index 00000000..de8d32f6 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java @@ -0,0 +1,389 @@ +package com.digitalsanctuary.spring.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Calendar; +import java.util.Date; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; +import com.digitalsanctuary.spring.user.mail.MailService; +import com.digitalsanctuary.spring.user.persistence.model.PasswordResetToken; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.model.VerificationToken; +import com.digitalsanctuary.spring.user.persistence.repository.PasswordResetTokenRepository; +import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; +import com.digitalsanctuary.spring.user.persistence.repository.VerificationTokenRepository; +import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder; + +/** + * Security tests for token-at-rest hashing, single-active-token enforcement, dual-read backward + * compatibility, configurable lifetime, and atomic consume. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("Token Hashing Security Tests") +class TokenHashingSecurityTest { + + private final TokenHasher tokenHasher = new TokenHasher(null); + + private User testUser; + + @BeforeEach + void setUp() { + testUser = UserTestDataBuilder.aUser().withId(1L).withEmail("test@example.com").enabled().build(); + } + + private Date future(int minutes) { + Calendar cal = Calendar.getInstance(); + cal.add(Calendar.MINUTE, minutes); + return cal.getTime(); + } + + private Date past(int minutes) { + Calendar cal = Calendar.getInstance(); + cal.add(Calendar.MINUTE, -minutes); + return cal.getTime(); + } + + // --------------------------------------------------------------------------------------------- + // Password Reset Token tests + // --------------------------------------------------------------------------------------------- + @Nested + @DisplayName("Password Reset Token") + class PasswordResetTokenTests { + + @Mock + private MailService mailService; + @Mock + private UserVerificationService userVerificationService; + @Mock + private PasswordResetTokenRepository passwordTokenRepository; + @Mock + private ApplicationEventPublisher eventPublisher; + @Mock + private SessionInvalidationService sessionInvalidationService; + + private UserEmailService userEmailService; + + @BeforeEach + void initService() { + userEmailService = new UserEmailService(mailService, userVerificationService, passwordTokenRepository, + eventPublisher, sessionInvalidationService, tokenHasher); + } + + @Test + @DisplayName("(a) stored token value is the HASH, not the raw token") + void shouldStoreHashedTokenNotRawToken() { + String rawToken = "raw-reset-token-value"; + + userEmailService.createPasswordResetTokenForUser(testUser, rawToken); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PasswordResetToken.class); + verify(passwordTokenRepository).save(captor.capture()); + String stored = captor.getValue().getToken(); + + assertThat(stored).isNotEqualTo(rawToken); + assertThat(stored).isEqualTo(tokenHasher.hash(rawToken)); + } + + @Test + @DisplayName("(c) creating a second reset token deletes the first (single active token)") + void shouldDeleteExistingTokenWhenCreatingNewOne() { + userEmailService.createPasswordResetTokenForUser(testUser, "raw"); + + verify(passwordTokenRepository).deleteByUser(testUser); + } + + @Test + @DisplayName("(e) expiry honors the configured minutes") + void shouldHonorConfiguredLifetime() { + ReflectionTestUtils.setField(userEmailService, "passwordResetTokenValidityMinutes", 30); + long before = System.currentTimeMillis(); + + userEmailService.createPasswordResetTokenForUser(testUser, "raw"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PasswordResetToken.class); + verify(passwordTokenRepository).save(captor.capture()); + long expiry = captor.getValue().getExpiryDate().getTime(); + // ~30 minutes out, well below the 1440 default + assertThat(expiry).isBetween(before + 25L * 60 * 1000, before + 35L * 60 * 1000); + } + } + + @Nested + @DisplayName("Password Reset Lookup / Consume") + class PasswordResetLookupTests { + + @Mock + private PasswordResetTokenRepository passwordTokenRepository; + + private UserService userService; + + @BeforeEach + void initService() { + userService = new UserService(null, null, passwordTokenRepository, null, null, null, null, null, null, null, + null, null, null, tokenHasher, null); + } + + @Test + @DisplayName("(b) lookup by RAW token resolves the entity stored under the HASH") + void shouldResolveByRawTokenWhenStoredAsHash() { + String rawToken = "raw-reset-token"; + String hashed = tokenHasher.hash(rawToken); + PasswordResetToken entity = new PasswordResetToken(); + entity.setToken(hashed); + entity.setUser(testUser); + entity.setExpiryDate(future(60)); + when(passwordTokenRepository.findByToken(hashed)).thenReturn(entity); + + assertThat(userService.getUserByPasswordResetToken(rawToken)).contains(testUser); + } + + @Test + @DisplayName("(f) DUAL-READ: non-expired PLAINTEXT token (pre-upgrade) still resolves") + void shouldResolvePreUpgradePlaintextTokenWhenNotExpired() { + String rawToken = "legacy-plaintext-token"; + String hashed = tokenHasher.hash(rawToken); + PasswordResetToken legacy = new PasswordResetToken(); + legacy.setToken(rawToken); // stored as plaintext before upgrade + legacy.setUser(testUser); + legacy.setExpiryDate(future(60)); + // hash lookup misses, raw lookup hits + when(passwordTokenRepository.findByToken(hashed)).thenReturn(null); + when(passwordTokenRepository.findByToken(rawToken)).thenReturn(legacy); + + assertThat(userService.validatePasswordResetToken(rawToken)) + .isEqualTo(UserService.TokenValidationResult.VALID); + assertThat(userService.getUserByPasswordResetToken(rawToken)).contains(testUser); + } + + @Test + @DisplayName("(g) DUAL-READ: EXPIRED plaintext token is REJECTED by validate") + void shouldRejectExpiredPreUpgradePlaintextToken() { + String rawToken = "legacy-expired-token"; + String hashed = tokenHasher.hash(rawToken); + PasswordResetToken legacy = new PasswordResetToken(); + legacy.setToken(rawToken); + legacy.setUser(testUser); + legacy.setExpiryDate(past(60)); + when(passwordTokenRepository.findByToken(hashed)).thenReturn(null); + when(passwordTokenRepository.findByToken(rawToken)).thenReturn(legacy); + + assertThat(userService.validatePasswordResetToken(rawToken)) + .isEqualTo(UserService.TokenValidationResult.EXPIRED); + } + + @Test + @DisplayName("(d) reusing a consumed token fails (atomic consume deletes it)") + void shouldFailWhenReusingConsumedToken() { + String rawToken = "consume-me"; + String hashed = tokenHasher.hash(rawToken); + PasswordResetToken entity = new PasswordResetToken(); + entity.setToken(hashed); + entity.setUser(testUser); + entity.setExpiryDate(future(60)); + + // First consume: token found, then the conditional delete removes it (count 1 = we won the race) + when(passwordTokenRepository.findByToken(hashed)).thenReturn(entity, (PasswordResetToken) null); + lenient().when(passwordTokenRepository.findByToken(rawToken)).thenReturn(null); + when(passwordTokenRepository.deleteByToken(hashed)).thenReturn(1); + + User consumed = userService.validateAndConsumePasswordResetToken(rawToken); + assertThat(consumed).isEqualTo(testUser); + verify(passwordTokenRepository).deleteByToken(hashed); + + // Second consume: token no longer present -> null user + User second = userService.validateAndConsumePasswordResetToken(rawToken); + assertThat(second).isNull(); + } + + @Test + @DisplayName("(h) consuming an EXPIRED token returns null AND deletes it (cleanup)") + void shouldRejectAndCleanUpExpiredTokenOnConsume() { + String rawToken = "expired-consume-me"; + String hashed = tokenHasher.hash(rawToken); + PasswordResetToken expired = new PasswordResetToken(); + expired.setToken(hashed); + expired.setUser(testUser); + expired.setExpiryDate(past(60)); + when(passwordTokenRepository.findByToken(hashed)).thenReturn(expired); + when(passwordTokenRepository.deleteByToken(hashed)).thenReturn(1); + + User result = userService.validateAndConsumePasswordResetToken(rawToken); + + assertThat(result).isNull(); + // The expired token is cleaned up (deleted) even though consumption is rejected. + verify(passwordTokenRepository).deleteByToken(hashed); + } + } + + // --------------------------------------------------------------------------------------------- + // Verification Token tests + // --------------------------------------------------------------------------------------------- + @Nested + @DisplayName("Verification Token") + class VerificationTokenTests { + + @Mock + private UserRepository userRepository; + @Mock + private VerificationTokenRepository tokenRepository; + + private UserVerificationService verificationService; + + @BeforeEach + void initService() { + verificationService = new UserVerificationService(userRepository, tokenRepository, tokenHasher); + } + + @Test + @DisplayName("(a) stored verification token value is the HASH, not the raw token") + void shouldStoreHashedVerificationToken() { + String rawToken = "raw-verification-token"; + + verificationService.createVerificationTokenForUser(testUser, rawToken); + + ArgumentCaptor captor = ArgumentCaptor.forClass(VerificationToken.class); + verify(tokenRepository).save(captor.capture()); + assertThat(captor.getValue().getToken()).isEqualTo(tokenHasher.hash(rawToken)); + assertThat(captor.getValue().getToken()).isNotEqualTo(rawToken); + } + + @Test + @DisplayName("(c) creating a second verification token deletes the first") + void shouldDeleteExistingVerificationTokenWhenCreatingNewOne() { + verificationService.createVerificationTokenForUser(testUser, "raw"); + + verify(tokenRepository).deleteByUser(testUser); + } + + @Test + @DisplayName("(b) lookup by RAW token resolves entity stored under HASH") + void shouldResolveVerificationByRawTokenWhenStoredAsHash() { + String rawToken = "raw-verification"; + String hashed = tokenHasher.hash(rawToken); + VerificationToken entity = new VerificationToken(); + entity.setToken(hashed); + entity.setUser(testUser); + entity.setExpiryDate(future(60)); + when(tokenRepository.findByToken(hashed)).thenReturn(entity); + + assertThat(verificationService.getUserByVerificationToken(rawToken)).isEqualTo(testUser); + } + + @Test + @DisplayName("(f) DUAL-READ: non-expired PLAINTEXT verification token resolves and validates") + void shouldValidatePreUpgradePlaintextVerificationToken() { + String rawToken = "legacy-verify-token"; + String hashed = tokenHasher.hash(rawToken); + VerificationToken legacy = new VerificationToken(); + legacy.setToken(rawToken); + legacy.setUser(testUser); + legacy.setExpiryDate(future(60)); + when(tokenRepository.findByToken(hashed)).thenReturn(null); + when(tokenRepository.findByToken(rawToken)).thenReturn(legacy); + // Stored as plaintext under the raw value: the hashed delete removes 0, the raw fallback removes 1. + when(tokenRepository.deleteByToken(hashed)).thenReturn(0); + when(tokenRepository.deleteByToken(rawToken)).thenReturn(1); + + assertThat(verificationService.validateVerificationToken(rawToken)) + .isEqualTo(UserService.TokenValidationResult.VALID); + } + + @Test + @DisplayName("(g) DUAL-READ: EXPIRED plaintext verification token is REJECTED") + void shouldRejectExpiredPreUpgradePlaintextVerificationToken() { + String rawToken = "legacy-verify-expired"; + String hashed = tokenHasher.hash(rawToken); + VerificationToken legacy = new VerificationToken(); + legacy.setToken(rawToken); + legacy.setUser(testUser); + legacy.setExpiryDate(past(60)); + when(tokenRepository.findByToken(hashed)).thenReturn(null); + when(tokenRepository.findByToken(rawToken)).thenReturn(legacy); + // Stored as plaintext under the raw value: the hashed delete removes 0, the raw fallback removes 1. + when(tokenRepository.deleteByToken(hashed)).thenReturn(0); + when(tokenRepository.deleteByToken(rawToken)).thenReturn(1); + + assertThat(verificationService.validateVerificationToken(rawToken)) + .isEqualTo(UserService.TokenValidationResult.EXPIRED); + } + + @Test + @DisplayName("(e) verification expiry honors the configured minutes") + void shouldHonorConfiguredVerificationLifetime() { + ReflectionTestUtils.setField(verificationService, "verificationTokenValidityMinutes", 45); + long before = System.currentTimeMillis(); + + verificationService.createVerificationTokenForUser(testUser, "raw"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(VerificationToken.class); + verify(tokenRepository).save(captor.capture()); + long expiry = captor.getValue().getExpiryDate().getTime(); + assertThat(expiry).isBetween(before + 40L * 60 * 1000, before + 50L * 60 * 1000); + } + + @Test + @DisplayName("(h) generateNewVerificationToken stores the HASH and exposes the RAW token via plainToken") + void shouldStoreHashAndExposeRawTokenOnRegenerate() { + // An existing token resolved by raw value for regeneration. + VerificationToken existing = new VerificationToken(); + existing.setToken(tokenHasher.hash("old-raw")); + existing.setUser(testUser); + existing.setExpiryDate(future(10)); + when(tokenRepository.findByToken(tokenHasher.hash("old-raw"))).thenReturn(existing); + when(tokenRepository.save(any(VerificationToken.class))).thenAnswer(inv -> inv.getArgument(0)); + + VerificationToken regenerated = verificationService.generateNewVerificationToken("old-raw"); + + // The raw token is exposed for email-link building... + String raw = regenerated.getPlainToken(); + assertThat(raw).isNotBlank(); + // ...but the persisted column holds the HASH of that raw token, not the raw value. + assertThat(regenerated.getToken()).isEqualTo(tokenHasher.hash(raw)); + assertThat(regenerated.getToken()).isNotEqualTo(raw); + + // And the dual-read lookup path resolves the entity from the raw token. + when(tokenRepository.findByToken(tokenHasher.hash(raw))).thenReturn(regenerated); + assertThat(verificationService.getUserByVerificationToken(raw)).isEqualTo(testUser); + } + + @Test + @DisplayName("(i) regenerated token expiry honors the configured minutes, not a hardcoded 24h") + void shouldHonorConfiguredLifetimeOnRegenerate() { + ReflectionTestUtils.setField(verificationService, "verificationTokenValidityMinutes", 45); + VerificationToken existing = new VerificationToken(); + existing.setToken(tokenHasher.hash("old-raw")); + existing.setUser(testUser); + existing.setExpiryDate(future(10)); + when(tokenRepository.findByToken(tokenHasher.hash("old-raw"))).thenReturn(existing); + when(tokenRepository.save(any(VerificationToken.class))).thenAnswer(inv -> inv.getArgument(0)); + long before = System.currentTimeMillis(); + + VerificationToken regenerated = verificationService.generateNewVerificationToken("old-raw"); + + long expiry = regenerated.getExpiryDate().getTime(); + // ~45 minutes out, clearly distinct from the hardcoded 24h (1440m) default. + assertThat(expiry).isBetween(before + 40L * 60 * 1000, before + 50L * 60 * 1000); + } + + @Test + @DisplayName("(j) plainToken is JPA @Transient and never persisted") + void plainTokenIsNotPersisted() throws NoSuchFieldException { + assertThat(VerificationToken.class.getDeclaredField("plainToken") + .isAnnotationPresent(jakarta.persistence.Transient.class)).isTrue(); + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/UserEmailServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/UserEmailServiceTest.java index ef10400f..987d1e7e 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserEmailServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserEmailServiceTest.java @@ -19,7 +19,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; @@ -27,6 +26,7 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; import java.lang.reflect.Method; import java.util.Map; @@ -50,7 +50,9 @@ class UserEmailServiceTest { @Mock private SessionInvalidationService sessionInvalidationService; - @InjectMocks + // Real hasher (not a mock) so stored-vs-raw token assertions reflect production behavior. + private final TokenHasher tokenHasher = new TokenHasher(null); + private UserEmailService userEmailService; private User testUser; @@ -59,6 +61,11 @@ class UserEmailServiceTest { @BeforeEach void setUp() { + userEmailService = new UserEmailService(mailService, userVerificationService, passwordTokenRepository, + eventPublisher, sessionInvalidationService, tokenHasher); + // In production 'self' is the Spring proxy used to apply @Transactional on createPasswordResetTokenForUser. + // There is no proxy in a unit test, so point it at the instance itself to exercise the real call path. + ReflectionTestUtils.setField(userEmailService, "self", userEmailService); testUser = UserTestDataBuilder.aUser() .withId(1L) .withEmail("test@example.com") @@ -116,8 +123,8 @@ void sendForgotPasswordVerificationEmail_sendsEmailWithCorrectParameters() { PasswordResetToken savedToken = tokenCaptor.getValue(); assertThat(savedToken.getUser()).isEqualTo(testUser); assertThat(savedToken.getToken()).isNotNull(); - // Base64 URL-safe encoded 32-byte token = 43 characters - assertThat(savedToken.getToken()).matches("[A-Za-z0-9_-]{43}"); + // Stored token is the HASH (64-char hex SHA-256), NOT the raw 43-char token. + assertThat(savedToken.getToken()).matches("[0-9a-f]{64}"); // Verify audit event was published ArgumentCaptor auditCaptor = ArgumentCaptor.forClass(AuditEvent.class); @@ -144,8 +151,13 @@ void sendForgotPasswordVerificationEmail_sendsEmailWithCorrectParameters() { assertThat(variables).containsKey("user"); assertThat(variables.get("appUrl")).isEqualTo(appUrl); assertThat(variables.get("user")).isEqualTo(testUser); - assertThat(variables.get("confirmationUrl")).asString() - .startsWith(appUrl + "/user/changePassword?token="); + // The emailed link must carry the RAW token (43-char URL-safe), while the DB stores its hash. + String confirmationUrl = (String) variables.get("confirmationUrl"); + assertThat(confirmationUrl).startsWith(appUrl + "/user/changePassword?token="); + String rawTokenInUrl = confirmationUrl.substring(confirmationUrl.indexOf("token=") + "token=".length()); + assertThat(rawTokenInUrl).matches("[A-Za-z0-9_-]{43}"); + // The stored (hashed) value is the hash of the raw token emailed to the user. + assertThat(savedToken.getToken()).isEqualTo(tokenHasher.hash(rawTokenInUrl)); } @Test @@ -191,7 +203,8 @@ void createPasswordResetTokenForUser_createsAndSavesToken() { ArgumentCaptor tokenCaptor = ArgumentCaptor.forClass(PasswordResetToken.class); verify(passwordTokenRepository).save(tokenCaptor.capture()); PasswordResetToken savedToken = tokenCaptor.getValue(); - assertThat(savedToken.getToken()).isEqualTo(token); + // The stored token is the HASH of the raw token, not the raw token itself. + assertThat(savedToken.getToken()).isEqualTo(tokenHasher.hash(token)); assertThat(savedToken.getUser()).isEqualTo(testUser); assertThat(savedToken.getExpiryDate()).isNotNull(); } diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceRegistrationGuardTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceRegistrationGuardTest.java new file mode 100644 index 00000000..c1d22fce --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceRegistrationGuardTest.java @@ -0,0 +1,189 @@ +package com.digitalsanctuary.spring.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +import com.digitalsanctuary.spring.user.dto.PasswordlessRegistrationDto; +import com.digitalsanctuary.spring.user.dto.UserDto; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.repository.PasswordHistoryRepository; +import com.digitalsanctuary.spring.user.persistence.repository.PasswordResetTokenRepository; +import com.digitalsanctuary.spring.user.persistence.repository.RoleRepository; +import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; +import com.digitalsanctuary.spring.user.persistence.repository.VerificationTokenRepository; +import com.digitalsanctuary.spring.user.registration.RegistrationContext; +import com.digitalsanctuary.spring.user.registration.RegistrationDecision; +import com.digitalsanctuary.spring.user.registration.RegistrationDeniedException; +import com.digitalsanctuary.spring.user.registration.RegistrationGuard; +import com.digitalsanctuary.spring.user.registration.RegistrationSource; +import com.digitalsanctuary.spring.user.test.annotations.ServiceTest; + +/** + * Verifies that the {@link com.digitalsanctuary.spring.user.registration.RegistrationGuard} is enforced + * INSIDE {@link UserService} so that direct callers of the registration methods cannot bypass it (the + * guard-bypass this task closes), and that the correct {@link RegistrationSource} is supplied per path. + */ +@ServiceTest +class UserServiceRegistrationGuardTest { + + private static final String USER_ROLE_NAME = "ROLE_USER"; + + @Mock + private UserRepository userRepository; + @Mock + private VerificationTokenRepository tokenRepository; + @Mock + private PasswordResetTokenRepository passwordTokenRepository; + @Mock + private PasswordEncoder passwordEncoder; + @Mock + private RoleRepository roleRepository; + @Mock + private SessionRegistry sessionRegistry; + @Mock + private UserEmailService userEmailService; + @Mock + private UserVerificationService userVerificationService; + @Mock + private DSUserDetailsService dsUserDetailsService; + @Mock + private ApplicationEventPublisher eventPublisher; + @Mock + private AuthorityService authorityService; + @Mock + private PasswordHistoryRepository passwordHistoryRepository; + @Mock + private SessionInvalidationService sessionInvalidationService; + @Mock + private TokenHasher tokenHasher; + @Mock + private RegistrationGuard registrationGuard; + + @InjectMocks + private UserService userService; + + @BeforeEach + void setUp() { + // The public registration entry methods delegate the DB write to a @Transactional persist method + // invoked through the Spring proxy ("self"). Under @InjectMocks there is no proxy, so wire self + // back to the unit-under-test. + ReflectionTestUtils.setField(userService, "self", userService); + } + + private UserDto formDto() { + UserDto dto = new UserDto(); + dto.setEmail("blocked@example.com"); + dto.setFirstName("Blocked"); + dto.setLastName("User"); + dto.setPassword("password123"); + dto.setMatchingPassword("password123"); + return dto; + } + + private PasswordlessRegistrationDto passwordlessDto() { + PasswordlessRegistrationDto dto = new PasswordlessRegistrationDto(); + dto.setEmail("blocked@example.com"); + dto.setFirstName("Blocked"); + dto.setLastName("User"); + return dto; + } + + @Test + @DisplayName("Direct call to registerNewUserAccount is DENIED when guard denies (bypass closed, FORM source)") + void formRegistrationDeniedWhenGuardDenies() { + when(registrationGuard.evaluate(any(RegistrationContext.class))) + .thenReturn(RegistrationDecision.deny("Registration is by invitation only")); + + assertThatThrownBy(() -> userService.registerNewUserAccount(formDto())) + .isInstanceOf(RegistrationDeniedException.class) + .hasMessageContaining("Registration is by invitation only"); + + // No user was persisted — the bypass is closed. + verify(userRepository, never()).save(any(User.class)); + + // The guard received the correct source (FORM) and email. + ArgumentCaptor captor = ArgumentCaptor.forClass(RegistrationContext.class); + verify(registrationGuard).evaluate(captor.capture()); + assertThat(captor.getValue().source()).isEqualTo(RegistrationSource.FORM); + assertThat(captor.getValue().email()).isEqualTo("blocked@example.com"); + } + + @Test + @DisplayName("Direct call to registerPasswordlessAccount is DENIED when guard denies (bypass closed, PASSWORDLESS source)") + void passwordlessRegistrationDeniedWhenGuardDenies() { + when(registrationGuard.evaluate(any(RegistrationContext.class))) + .thenReturn(RegistrationDecision.deny("Beta access required")); + + assertThatThrownBy(() -> userService.registerPasswordlessAccount(passwordlessDto())) + .isInstanceOf(RegistrationDeniedException.class) + .hasMessageContaining("Beta access required"); + + verify(userRepository, never()).save(any(User.class)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RegistrationContext.class); + verify(registrationGuard).evaluate(captor.capture()); + assertThat(captor.getValue().source()).isEqualTo(RegistrationSource.PASSWORDLESS); + } + + @Test + @DisplayName("enforceRegistrationGuard throws RegistrationDeniedException for denied OAuth registration") + void oauthEnforceThrowsWhenGuardDenies() { + when(registrationGuard.evaluate(any(RegistrationContext.class))) + .thenReturn(RegistrationDecision.deny("Domain not allowed")); + + assertThatThrownBy(() -> + userService.enforceRegistrationGuard("social@example.com", RegistrationSource.OAUTH2, "google")) + .isInstanceOf(RegistrationDeniedException.class) + .hasMessageContaining("Domain not allowed"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RegistrationContext.class); + verify(registrationGuard).evaluate(captor.capture()); + assertThat(captor.getValue().source()).isEqualTo(RegistrationSource.OAUTH2); + assertThat(captor.getValue().providerName()).isEqualTo("google"); + } + + @Test + @DisplayName("enforceRegistrationGuard is a no-op when the guard allows") + void oauthEnforceAllows() { + when(registrationGuard.evaluate(any(RegistrationContext.class))) + .thenReturn(RegistrationDecision.allow()); + + userService.enforceRegistrationGuard("social@example.com", RegistrationSource.OIDC, "keycloak"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RegistrationContext.class); + verify(registrationGuard).evaluate(captor.capture()); + assertThat(captor.getValue().source()).isEqualTo(RegistrationSource.OIDC); + } + + @Test + @DisplayName("Form registration proceeds to persistence when the guard allows") + void formRegistrationProceedsWhenGuardAllows() { + when(registrationGuard.evaluate(any(RegistrationContext.class))) + .thenReturn(RegistrationDecision.allow()); + when(passwordEncoder.encode(any())).thenReturn("encoded"); + when(roleRepository.findByName(USER_ROLE_NAME)) + .thenReturn(com.digitalsanctuary.spring.user.test.builders.RoleTestDataBuilder.aUserRole().build()); + when(userRepository.findByEmail(any())).thenReturn(null); + when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); + + User saved = userService.registerNewUserAccount(formDto()); + + assertThat(saved).isNotNull(); + verify(userRepository).save(any(User.class)); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java index 540e1534..8f29e569 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java @@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; @@ -21,10 +22,13 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockedStatic; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.CannotAcquireLockException; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.authentication.event.InteractiveAuthenticationSuccessEvent; @@ -35,11 +39,16 @@ import org.springframework.security.core.session.SessionRegistry; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import com.digitalsanctuary.spring.user.dto.PasswordlessRegistrationDto; import com.digitalsanctuary.spring.user.dto.UserDto; +import com.digitalsanctuary.spring.user.event.UserDeletedEvent; +import com.digitalsanctuary.spring.user.event.UserDisabledEvent; import com.digitalsanctuary.spring.user.event.UserPreDeleteEvent; import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException; import com.digitalsanctuary.spring.user.persistence.model.PasswordHistoryEntry; @@ -52,6 +61,8 @@ import com.digitalsanctuary.spring.user.persistence.repository.RoleRepository; import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; import com.digitalsanctuary.spring.user.persistence.repository.VerificationTokenRepository; +import com.digitalsanctuary.spring.user.registration.RegistrationDecision; +import com.digitalsanctuary.spring.user.registration.RegistrationGuard; import com.digitalsanctuary.spring.user.test.annotations.ServiceTest; import com.digitalsanctuary.spring.user.test.builders.RoleTestDataBuilder; import com.digitalsanctuary.spring.user.test.builders.TokenTestDataBuilder; @@ -90,6 +101,10 @@ public class UserServiceTest { private PasswordHistoryRepository passwordHistoryRepository; @Mock private SessionInvalidationService sessionInvalidationService; + @Mock + private TokenHasher tokenHasher; + @Mock + private RegistrationGuard registrationGuard; @InjectMocks private UserService userService; private User testUser; @@ -100,6 +115,19 @@ void setUp() { // Use centralized test fixtures for consistent test data testUser = TestFixtures.Users.standardUser(); testUserDto = TestFixtures.DTOs.validUserRegistration(); + + // The public entry methods (registerNewUserAccount/changeUserPassword/setInitialPassword) run + // with NO transaction so bcrypt never holds a DB connection, then delegate the DB write to a + // @Transactional persist method invoked through the Spring proxy (the "self" reference). Under + // @InjectMocks there is no proxy and "self" is null, so wire it back to the unit-under-test so + // the real persist logic executes during these unit tests. + ReflectionTestUtils.setField(userService, "self", userService); + + // The RegistrationGuard is now enforced inside the registration entry points. Default it to + // allow so existing registration tests are unaffected; guard-denial behavior is exercised by + // the dedicated UserServiceRegistrationGuardTest. + org.mockito.Mockito.lenient().when(registrationGuard.evaluate(any())) + .thenReturn(RegistrationDecision.allow()); } @Test @@ -136,6 +164,57 @@ void registerNewUserAccount_throwsExceptionWhenUserExist() { .hasMessageContaining("There is an account with that email address"); } + @Test + @DisplayName("registerNewUserAccount - translates DataIntegrityViolationException from save into UserAlreadyExistException") + void registerNewUserAccount_translatesDataIntegrityViolationToUserAlreadyExist() { + // Given: pre-check passes (email not found) but the concurrent insert loses the race at commit + Role userRole = RoleTestDataBuilder.aUserRole().build(); + when(passwordEncoder.encode(anyString())).thenReturn("encodedPassword"); + when(roleRepository.findByName(USER_ROLE_NAME)).thenReturn(userRole); + when(userRepository.findByEmail(anyString())).thenReturn(null); + when(userRepository.save(any(User.class))) + .thenThrow(new DataIntegrityViolationException("unique constraint violation")); + + // When & Then + assertThatThrownBy(() -> userService.registerNewUserAccount(testUserDto)) + .isInstanceOf(UserAlreadyExistException.class) + .hasMessageContaining("There is an account with that email address"); + } + + @Test + @DisplayName("registerNewUserAccount - translates serialization failure (ConcurrencyFailureException) into UserAlreadyExistException") + void registerNewUserAccount_translatesConcurrencyFailureToUserAlreadyExist() { + // Given: pre-check passes but the SERIALIZABLE transaction cannot acquire the lock at commit + Role userRole = RoleTestDataBuilder.aUserRole().build(); + when(passwordEncoder.encode(anyString())).thenReturn("encodedPassword"); + when(roleRepository.findByName(USER_ROLE_NAME)).thenReturn(userRole); + when(userRepository.findByEmail(anyString())).thenReturn(null); + when(userRepository.save(any(User.class))) + .thenThrow(new CannotAcquireLockException("could not serialize access")); + + // When & Then + assertThatThrownBy(() -> userService.registerNewUserAccount(testUserDto)) + .isInstanceOf(UserAlreadyExistException.class) + .hasMessageContaining("There is an account with that email address"); + } + + @Test + @DisplayName("registerNewUserAccount - does not swallow unrelated runtime exceptions from save") + void registerNewUserAccount_doesNotSwallowUnrelatedExceptions() { + // Given + Role userRole = RoleTestDataBuilder.aUserRole().build(); + when(passwordEncoder.encode(anyString())).thenReturn("encodedPassword"); + when(roleRepository.findByName(USER_ROLE_NAME)).thenReturn(userRole); + when(userRepository.findByEmail(anyString())).thenReturn(null); + when(userRepository.save(any(User.class))) + .thenThrow(new IllegalStateException("unrelated failure")); + + // When & Then: the unrelated exception must propagate, not be translated to 409 + assertThatThrownBy(() -> userService.registerNewUserAccount(testUserDto)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("unrelated failure"); + } + @Test void findByEmail_returnsUserWhenEmailExist() { // Given @@ -189,6 +268,20 @@ void changeUserPassword_encodesAndSavesNewPassword() { verify(userRepository).save(testUser); } + @Test + void changeUserPassword_invalidatesExistingSessions() { + // Given + String newPassword = "newTestPassword"; + when(passwordEncoder.encode(newPassword)).thenReturn("encodedNewPassword"); + when(userRepository.save(any(User.class))).thenReturn(testUser); + + // When + userService.changeUserPassword(testUser, newPassword); + + // Then + verify(sessionInvalidationService).invalidateSessionsAfterPasswordChange(testUser); + } + // Additional tests for comprehensive coverage @Test @DisplayName("saveRegisteredUser - saves and returns user") @@ -231,9 +324,9 @@ void deleteOrDisableUser_whenActuallyDeleteTrue_deletesUserAndTokens() { } @Test - @DisplayName("deleteOrDisableUser - when actuallyDeleteAccount is false - disables user") + @DisplayName("deleteOrDisableUser - when actuallyDeleteAccount is false - disables user and publishes UserDisabledEvent") void deleteOrDisableUser_whenActuallyDeleteFalse_disablesUser() { - // Given + // Given: no active transaction, so the disable event is published immediately (fallback path) ReflectionTestUtils.setField(userService, "actuallyDeleteAccount", false); when(userRepository.save(any(User.class))).thenReturn(testUser); @@ -244,7 +337,90 @@ void deleteOrDisableUser_whenActuallyDeleteFalse_disablesUser() { assertThat(testUser.isEnabled()).isFalse(); verify(userRepository).save(testUser); verify(userRepository, never()).delete(any()); - verify(eventPublisher, never()).publishEvent(any()); + + // The soft-delete path is now observable via UserDisabledEvent. + ArgumentCaptor captor = ArgumentCaptor.forClass(UserDisabledEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().getUserId()).isEqualTo(testUser.getId()); + assertThat(captor.getValue().getUserEmail()).isEqualTo(testUser.getEmail()); + // No delete-path events should be published on the disable branch. + verify(eventPublisher, never()).publishEvent(any(UserPreDeleteEvent.class)); + verify(eventPublisher, never()).publishEvent(any(UserDeletedEvent.class)); + } + + @Test + @DisplayName("deleteOrDisableUser - UserDisabledEvent is deferred until after transaction commit") + void deleteOrDisableUser_publishesUserDisabledEventAfterCommit() { + // Given: an active transaction synchronization (simulating the surrounding @Transactional) + ReflectionTestUtils.setField(userService, "actuallyDeleteAccount", false); + when(userRepository.save(any(User.class))).thenReturn(testUser); + TransactionSynchronizationManager.initSynchronization(); + try { + // When + userService.deleteOrDisableUser(testUser); + + // Then: the disable event must NOT yet be published + verify(eventPublisher, never()).publishEvent(any(UserDisabledEvent.class)); + + // A synchronization was registered for after-commit delivery + List syncs = TransactionSynchronizationManager.getSynchronizations(); + assertThat(syncs).hasSize(1); + + // When the transaction commits, the disable event is delivered + syncs.forEach(TransactionSynchronization::afterCommit); + + // Then + ArgumentCaptor captor = ArgumentCaptor.forClass(UserDisabledEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().getUserId()).isEqualTo(testUser.getId()); + assertThat(captor.getValue().getUserEmail()).isEqualTo(testUser.getEmail()); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + @Test + @DisplayName("deleteOrDisableUser - UserDeletedEvent is deferred until after transaction commit") + void deleteOrDisableUser_publishesUserDeletedEventAfterCommit() { + // Given: an active transaction synchronization (simulating the surrounding @Transactional) + ReflectionTestUtils.setField(userService, "actuallyDeleteAccount", true); + TransactionSynchronizationManager.initSynchronization(); + try { + // When + userService.deleteOrDisableUser(testUser); + + // Then: the pre-delete event fires immediately, but the deleted event must NOT yet + verify(eventPublisher).publishEvent(any(UserPreDeleteEvent.class)); + verify(eventPublisher, never()).publishEvent(any(UserDeletedEvent.class)); + + // A synchronization was registered for after-commit delivery + List syncs = TransactionSynchronizationManager.getSynchronizations(); + assertThat(syncs).hasSize(1); + + // When the transaction commits, the deleted event is delivered + syncs.forEach(TransactionSynchronization::afterCommit); + + // Then + ArgumentCaptor captor = ArgumentCaptor.forClass(UserDeletedEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().getUserId()).isEqualTo(testUser.getId()); + assertThat(captor.getValue().getUserEmail()).isEqualTo(testUser.getEmail()); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + @Test + @DisplayName("deleteOrDisableUser - UserDeletedEvent still fires when no transaction is active") + void deleteOrDisableUser_publishesUserDeletedEventWhenNoTransaction() { + // Given: no active transaction synchronization + ReflectionTestUtils.setField(userService, "actuallyDeleteAccount", true); + + // When + userService.deleteOrDisableUser(testUser); + + // Then: the deleted event is published immediately (fallback path) + verify(eventPublisher).publishEvent(any(UserDeletedEvent.class)); } @Test @@ -287,6 +463,15 @@ void registerNewUserAccount_enablesUserWhenVerificationDisabled() { @DisplayName("Password Reset Token Tests") class PasswordResetTokenTests { + @BeforeEach + void stubHasher() { + // These tests stub findByToken with the raw token string. The service hashes the token + // before lookup (dual-read), so make the hasher identity here to keep the existing + // stubs valid. The hashing behavior itself is covered by TokenHashingSecurityTest. + org.mockito.Mockito.lenient().when(tokenHasher.hash(anyString())) + .thenAnswer(invocation -> invocation.getArgument(0)); + } + @Test @DisplayName("getPasswordResetToken - returns token when exists") void getPasswordResetToken_returnsTokenWhenExists() { @@ -618,6 +803,45 @@ void authWithoutPassword_handlesNoRequestContext() { // Should not throw exception even when request context is null } } + + @Test + @DisplayName("authWithoutPassword - rotates the session id to defend against session fixation") + void shouldRotateSessionIdWhenAuthSucceeds() { + // Given a real request/session bound to the RequestContextHolder so that the servlet + // changeSessionId() contract is exercised faithfully (MockHttpServletRequest rotates the + // underlying MockHttpSession id while preserving attributes). + DSUserDetails userDetails = new DSUserDetails(testUser); + Collection authorities = Arrays.asList(new SimpleGrantedAuthority("ROLE_USER")); + + when(dsUserDetailsService.loadUserByUsername(testUser.getEmail())).thenReturn(userDetails); + when(authorityService.getAuthoritiesFromUser(testUser)).thenReturn((Collection) authorities); + + MockHttpServletRequest mockRequest = new MockHttpServletRequest(); + // Ensure a pre-auth session exists with a fixed id and a pre-existing attribute + HttpSession preAuthSession = mockRequest.getSession(true); + preAuthSession.setAttribute("preAuthAttr", "value"); + String preAuthSessionId = preAuthSession.getId(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(mockRequest)); + + try { + // When + userService.authWithoutPassword(testUser); + + // Then - the session id must have rotated (fixation defense)... + HttpSession postAuthSession = mockRequest.getSession(false); + assertThat(postAuthSession).isNotNull(); + assertThat(postAuthSession.getId()) + .as("session id should change after programmatic login") + .isNotEqualTo(preAuthSessionId); + // ...while preserving existing session attributes... + assertThat(postAuthSession.getAttribute("preAuthAttr")).isEqualTo("value"); + // ...and the security context must be stored on the (rotated) session. + assertThat(postAuthSession.getAttribute("SPRING_SECURITY_CONTEXT")).isNotNull(); + } finally { + RequestContextHolder.resetRequestAttributes(); + SecurityContextHolder.clearContext(); + } + } } @Nested @DisplayName("Password Status Tests") @@ -694,7 +918,7 @@ void shouldRemovePasswordAndClearHistory() { assertThat(testUser.getPassword()).isNull(); verify(userRepository).save(testUser); verify(passwordHistoryRepository).deleteByUser(testUser); - verify(sessionInvalidationService).invalidateUserSessions(testUser); + verify(sessionInvalidationService).invalidateSessionsAfterPasswordChange(testUser); } } @@ -786,6 +1010,99 @@ void shouldThrowWhenEmailExists() { } } + @Nested + @DisplayName("Password Hashing Outside Transaction Tests") + class PasswordHashingOutsideTransactionTests { + + /** + * bcrypt is deliberately slow, so it must run BEFORE the connection-holding DB write. Since the + * encode now happens in the non-transactional public entry method and the save happens in the + * proxied @Transactional persist method, asserting that {@code passwordEncoder.encode(...)} + * fires strictly before {@code userRepository.save(...)} proves the hash is computed outside the + * transactional persistence step. + */ + @Test + @DisplayName("registerNewUserAccount - encodes password BEFORE the persisting save (hash outside the DB write)") + void registerNewUserAccount_encodesBeforeSave() { + // Given + Role userRole = RoleTestDataBuilder.aUserRole().build(); + when(passwordEncoder.encode(anyString())).thenReturn("encodedPassword"); + when(roleRepository.findByName(USER_ROLE_NAME)).thenReturn(userRole); + when(userRepository.findByEmail(anyString())).thenReturn(null); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + userService.registerNewUserAccount(testUserDto); + + // Then: encode runs before the repository save (hash computed outside the persist step) + InOrder inOrder = inOrder(passwordEncoder, userRepository); + inOrder.verify(passwordEncoder).encode(anyString()); + inOrder.verify(userRepository).save(any(User.class)); + } + + /** + * The transactional persist method must receive an ALREADY-encoded password: it does no + * encoding itself, confirming the (slow) hash happened in the non-transactional caller. We also + * assert the saved entity carries the encoded value, not the raw password. + */ + @Test + @DisplayName("registerNewUserAccount - the persisted user carries the already-encoded password; persist does not re-encode") + void registerNewUserAccount_persistReceivesEncodedPassword() { + // Given + Role userRole = RoleTestDataBuilder.aUserRole().build(); + when(passwordEncoder.encode(testUserDto.getPassword())).thenReturn("encodedPassword"); + when(roleRepository.findByName(USER_ROLE_NAME)).thenReturn(userRole); + when(userRepository.findByEmail(anyString())).thenReturn(null); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + userService.registerNewUserAccount(testUserDto); + + // Then: the entity handed to save already holds the encoded password (not the raw value), + // and encode was invoked exactly once (only in the non-transactional entry method). + ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(captor.capture()); + assertThat(captor.getValue().getPassword()).isEqualTo("encodedPassword"); + verify(passwordEncoder).encode(testUserDto.getPassword()); + } + + @Test + @DisplayName("changeUserPassword - encodes password BEFORE the persisting save (hash outside the DB write)") + void changeUserPassword_encodesBeforeSave() { + // Given + String newPassword = "newTestPassword"; + when(passwordEncoder.encode(newPassword)).thenReturn("encodedNewPassword"); + when(userRepository.save(any(User.class))).thenReturn(testUser); + + // When + userService.changeUserPassword(testUser, newPassword); + + // Then: encode runs before save, and session invalidation still happens (in the persist). + InOrder inOrder = inOrder(passwordEncoder, userRepository, sessionInvalidationService); + inOrder.verify(passwordEncoder).encode(newPassword); + inOrder.verify(userRepository).save(testUser); + inOrder.verify(sessionInvalidationService).invalidateSessionsAfterPasswordChange(testUser); + } + + @Test + @DisplayName("setInitialPassword - encodes password BEFORE the persisting save (hash outside the DB write)") + void setInitialPassword_encodesBeforeSave() { + // Given + testUser.setPassword(null); + String rawPassword = "NewSecurePassword123!"; + when(passwordEncoder.encode(rawPassword)).thenReturn("encodedNewPassword"); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + userService.setInitialPassword(testUser, rawPassword); + + // Then + InOrder inOrder = inOrder(passwordEncoder, userRepository); + inOrder.verify(passwordEncoder).encode(rawPassword); + inOrder.verify(userRepository).save(testUser); + } + } + // Tests temporarily disabled until OAuth2 dependency issue is resolved // @Test // void checkIfValidOldPassword_returnFalseIfInvalid() { diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java index adefb743..a1477568 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java @@ -9,12 +9,14 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import java.util.Calendar; import java.util.Date; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -40,7 +42,7 @@ void setUp() { testToken = new VerificationToken(); testToken.setUser(testUser); - userVerificationService = new UserVerificationService(userRepository, verificationTokenRepository); + userVerificationService = new UserVerificationService(userRepository, verificationTokenRepository, new TokenHasher(null)); } @Test @@ -54,17 +56,34 @@ void getUserByVerificationToken_returnsUserIfTokenExist() { void validateVerificationToken_returnsValidIfTokenValid() { testToken.setExpiryDate(getExpirationDate(1)); when(verificationTokenRepository.findByToken(anyString())).thenReturn(testToken); - UserService.TokenValidationResult result = userVerificationService.validateVerificationToken(anyString()); - Assertions.assertEquals(result, UserService.TokenValidationResult.VALID); + // The conditional delete is the single-use guard: a count of 1 means THIS call consumed the token. + when(verificationTokenRepository.deleteByToken(anyString())).thenReturn(1); + UserService.TokenValidationResult result = userVerificationService.validateVerificationToken("raw-token"); + Assertions.assertEquals(UserService.TokenValidationResult.VALID, result); + // The user is enabled only after winning the delete; the token is consumed so it is strictly single-use. + Assertions.assertTrue(testUser.isEnabled()); + Mockito.verify(userRepository).save(testUser); + Mockito.verify(verificationTokenRepository).deleteByToken(anyString()); } - // @Test - // void validateVerificationToken_returnsExpiredIfTokenExpired() { - // testToken.setExpiryDate(getExpirationDate(0)); - // when(verificationTokenRepository.findByToken(anyString())).thenReturn(testToken); - // UserService.TokenValidationResult result = userVerificationService.validateVerificationToken(anyString()); - // Assertions.assertEquals(result, UserService.TokenValidationResult.EXPIRED); - // } + @Test + void validateVerificationToken_returnsExpiredIfTokenExpired() { + // Clearly-past expiry so the token is unambiguously expired. + testToken.setExpiryDate(getExpirationDate(-1)); + // Dual-read: validateVerificationToken first looks up by hash(raw), then by raw. With a null + // secret the hash is a deterministic SHA-256 of the raw value, so stub findByToken for any + // argument to resolve the token regardless of which lookup the service performs. + when(verificationTokenRepository.findByToken(anyString())).thenReturn(testToken); + when(verificationTokenRepository.deleteByToken(anyString())).thenReturn(1); + + UserService.TokenValidationResult result = userVerificationService.validateVerificationToken("raw-token"); + + Assertions.assertEquals(UserService.TokenValidationResult.EXPIRED, result); + // Expired tokens are still consumed (deleted) as part of validation, but the user is NOT enabled. + Mockito.verify(verificationTokenRepository).deleteByToken(anyString()); + Mockito.verify(userRepository, never()).save(testToken.getUser()); + Assertions.assertFalse(testUser.isEnabled()); + } @Test void validateVerificationToken_returnInvalidTokenIfTokenNotFound() { @@ -73,6 +92,21 @@ void validateVerificationToken_returnInvalidTokenIfTokenNotFound() { Assertions.assertEquals(result, UserService.TokenValidationResult.INVALID_TOKEN); } + @Test + void validateVerificationToken_returnsInvalidWhenConcurrentlyConsumed() { + // The token resolves (a concurrent caller has not yet committed its delete) but our conditional delete + // removes 0 rows because the other caller won the race. We must NOT enable the user in that case. + testToken.setExpiryDate(getExpirationDate(1)); + when(verificationTokenRepository.findByToken(anyString())).thenReturn(testToken); + when(verificationTokenRepository.deleteByToken(anyString())).thenReturn(0); + + UserService.TokenValidationResult result = userVerificationService.validateVerificationToken("raw-token"); + + Assertions.assertEquals(UserService.TokenValidationResult.INVALID_TOKEN, result); + Assertions.assertFalse(testUser.isEnabled()); + Mockito.verify(userRepository, never()).save(testToken.getUser()); + } + private Date getExpirationDate(int amount) { Date dt = new Date(); Calendar c = Calendar.getInstance(); diff --git a/src/test/java/com/digitalsanctuary/spring/user/test/config/BaseTestConfiguration.java b/src/test/java/com/digitalsanctuary/spring/user/test/config/BaseTestConfiguration.java index 2e3d4a4a..508f1c1f 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/test/config/BaseTestConfiguration.java +++ b/src/test/java/com/digitalsanctuary/spring/user/test/config/BaseTestConfiguration.java @@ -93,9 +93,16 @@ public TestPropertySourcesConfigurer testPropertySourcesConfigurer() { /** * Helper class to configure test properties programmatically. + * + *

    Note: {@code spring.profiles.active=test} is set globally here so that integration tests using a + * bare {@code @SpringBootTest} (no {@code @ActiveProfiles}) still resolve the test datasource and share + * a single H2 configuration across the JVM. Without it, default-profile contexts spin up a second + * datasource and contend on H2 locks under parallel execution. Tests that must run with a different + * profile (e.g. {@code RegistrationGuardConfigurationTest}) override this locally via a + * higher-precedence inlined property source rather than relying on this ambient value.

    */ public static class TestPropertySourcesConfigurer { - + public TestPropertySourcesConfigurer() { // Set system properties for tests System.setProperty("spring.profiles.active", "test"); @@ -103,4 +110,5 @@ public TestPropertySourcesConfigurer() { System.setProperty("spring.datasource.initialization-mode", "always"); } } + } \ No newline at end of file diff --git a/src/test/java/com/digitalsanctuary/spring/user/util/JpaAuditingConfigTest.java b/src/test/java/com/digitalsanctuary/spring/user/util/JpaAuditingConfigTest.java new file mode 100644 index 00000000..c3dde4aa --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/util/JpaAuditingConfigTest.java @@ -0,0 +1,103 @@ +package com.digitalsanctuary.spring.user.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.data.domain.AuditorAware; + +/** + * Tests for the conditional gating of JPA auditing (H5). + *

    + * The library's {@link JpaAuditingConfig} is gated by {@code user.jpa.auditing.enabled} (default {@code true}). When the + * property is {@code false}, the whole configuration — including {@code @EnableJpaAuditing} and the + * {@code auditorProvider} {@link AuditorAware} bean — is skipped, so a consuming application can run its own JPA + * auditing without the library hijacking it. + *

    + *

    + * Why the "enabled" cases use a stand-in: {@link JpaAuditingConfig} carries {@code @EnableJpaAuditing}, + * which eagerly initializes a {@code JpaMetamodelMappingContext}. Bootstrapping that machinery inside a unit-slice + * context (and supplying a mock {@code EntityManagerFactory} to satisfy it) pollutes the JPA metamodel shared by this + * module's parallel integration-test contexts, causing unrelated "domain class can not be found in the given Metamodel" + * failures. So the "enabled" assertions use {@link GatedConfiguration}, a stand-in that mirrors + * {@link JpaAuditingConfig}'s exact class-level {@code @ConditionalOnProperty} but omits {@code @EnableJpaAuditing}. + * The "disabled" case is asserted against the real {@link JpaAuditingConfig}, which is safe because the gate skips the + * auditing machinery entirely. The full {@code @EnableJpaAuditing} wiring (and that auditing works by default) is + * exercised by the integration tests running against the real {@code TestApplication} context. + *

    + * + * @see JpaAuditingConfig + */ +@DisplayName("JpaAuditingConfig Conditional Gating Tests") +class JpaAuditingConfigTest { + + /** Runner over the REAL config — used only for the disabled case, where no auditing machinery initializes. */ + private final ApplicationContextRunner realConfigRunner = + new ApplicationContextRunner().withUserConfiguration(JpaAuditingConfig.class); + + /** Runner over the stand-in — used for the enabled cases to avoid bootstrapping the JPA metamodel. */ + private final ApplicationContextRunner gatedRunner = + new ApplicationContextRunner().withUserConfiguration(GatedConfiguration.class); + + /** + * Mirrors {@link JpaAuditingConfig}'s class-level gate exactly, exposing a marker bean that reflects whether the + * gated configuration is active. Deliberately omits {@code @EnableJpaAuditing} so no JPA metamodel is initialized. + */ + // @TestConfiguration (not @Configuration) so the library's @ComponentScan TypeExcludeFilter keeps this + // stand-in out of integration contexts; it is still applied where used via withUserConfiguration(...). + @TestConfiguration + @ConditionalOnProperty(name = "user.jpa.auditing.enabled", havingValue = "true", matchIfMissing = true) + static class GatedConfiguration { + @Bean + String auditingGateMarker() { + return "active"; + } + } + + @Test + @DisplayName("Should NOT register auditorProvider / AuditorAware when user.jpa.auditing.enabled=false") + void shouldNotRegisterAuditorProviderWhenDisabled() { + realConfigRunner.withPropertyValues("user.jpa.auditing.enabled=false").run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context.containsBean("auditorProvider")).isFalse(); + assertThat(context).doesNotHaveBean(AuditorAware.class); + }); + } + + @Test + @DisplayName("Should be ENABLED by default when the property is absent (backward compatible)") + void shouldEnableByDefaultWhenPropertyAbsent() { + gatedRunner.run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context.containsBean("auditingGateMarker")).isTrue(); + }); + } + + @Test + @DisplayName("Should be ENABLED when user.jpa.auditing.enabled=true") + void shouldEnableWhenPropertyTrue() { + gatedRunner.withPropertyValues("user.jpa.auditing.enabled=true").run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context.containsBean("auditingGateMarker")).isTrue(); + }); + } + + @Test + @DisplayName("Should carry the exact @ConditionalOnProperty gate on the real JpaAuditingConfig class") + void shouldCarryExactConditionalOnPropertyGateOnRealConfig() { + // Pure reflection on the class annotation — no Spring context boot, so no @EnableJpaAuditing + // metamodel initialization and zero metamodel-pollution risk. Fails fast if the gate is removed or weakened. + var attributes = + AnnotatedElementUtils.findMergedAnnotationAttributes(JpaAuditingConfig.class, ConditionalOnProperty.class, false, false); + + assertThat(attributes).as("@ConditionalOnProperty must be present on JpaAuditingConfig").isNotNull(); + assertThat(attributes.getStringArray("name")).containsExactly("user.jpa.auditing.enabled"); + assertThat(attributes.getString("havingValue")).isEqualTo("true"); + assertThat(attributes.getBoolean("matchIfMissing")).isTrue(); + } +} diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 7ef1b59d..b6a79f8e 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -1,7 +1,12 @@ # Test Profile Configuration # Database Configuration for H2 -spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +# Per-context unique DB name (${random.uuid}) so each Spring context gets its own isolated in-memory +# database. JUnit runs test classes in parallel; a single shared name (jdbc:h2:mem:testdb) lets +# concurrently-booting contexts collide on schema DDL + role seeding (Table "ROLE" already exists / +# LockTimeoutException). The placeholder resolves once per context, so all connections within a context +# share one DB while distinct contexts stay isolated. +spring.datasource.url=jdbc:h2:mem:testdb-${random.uuid};DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE spring.datasource.driver-class-name=org.h2.Driver spring.datasource.username=sa spring.datasource.password= diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 9c6e6f72..d8ca720c 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -25,7 +25,8 @@ user.webauthn.enabled=false spring.datasource.driver-class-name=org.h2.Driver -spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MySQL +# Per-context unique DB name so parallel test contexts don't collide on a shared in-memory DB. +spring.datasource.url=jdbc:h2:mem:testdb-${random.uuid};DB_CLOSE_DELAY=-1;MODE=MySQL spring.datasource.username=sa spring.datasource.password=sa diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index e0c60de3..6a0edab3 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -1,7 +1,8 @@ # Test configuration to ensure H2 uses proper dialect spring: datasource: - url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + # Per-context unique DB name so parallel test contexts don't collide on a shared in-memory DB. + url: jdbc:h2:mem:testdb-${random.uuid};DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE driver-class-name: org.h2.Driver username: sa password: