From e6d84fd5f7f9a76a50d6e757ab40930ac5c57bfb Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 13 Aug 2026 21:23:35 -0600 Subject: [PATCH 01/24] docs: design spec for typed user.security @ConfigurationProperties Design for converting the user.security.* namespace from ~40 scattered @Value injections into a cohesive family of typed @ConfigurationProperties (UserSecurityConfigProperties, PasswordPolicyConfigProperties, RememberMeConfigProperties), with full internal migration, generated config metadata, and a secret-free template view object exposed as ${userSecurity}. Additive: zero config-key changes for consumers. Motivated by DemoApp#82 (Boot 4.1.0 / Thymeleaf 3.1.5 restricted expressions). --- ...-user-security-config-properties-design.md | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 docs/design/2026-08-13-user-security-config-properties-design.md diff --git a/docs/design/2026-08-13-user-security-config-properties-design.md b/docs/design/2026-08-13-user-security-config-properties-design.md new file mode 100644 index 0000000..5a17c83 --- /dev/null +++ b/docs/design/2026-08-13-user-security-config-properties-design.md @@ -0,0 +1,122 @@ +# Design: Typed `@ConfigurationProperties` for `user.security.*` + +**Date:** 2026-08-13 +**Repo:** `SpringUserFramework` (library) +**Status:** Approved design — pending implementation plan +**Motivating issue:** `SpringUserFrameworkDemoApp#82` (Boot 4.1.0 / Thymeleaf 3.1.5 breaks the demo's `${@environment.getProperty('user.security.*')}` template idiom) + +## 1. Problem + +The `user.security.*` configuration namespace is the only framework config area still wired as ~40 scattered `@Value` injections across 14 classes. Every other area — MFA, WebAuthn, Captcha, GDPR, Audit, DevLogin, Roles — is already a typed `@ConfigurationProperties` class. Consequences of the `@Value` approach: + +- Defaults are split three ways and already drift: the shipped `config/dsspringuserconfig.properties`, inline `@Value(":default")` fragments, and 48 hand-maintained entries in `META-INF/additional-spring-configuration-metadata.json`. Two live disagreements exist today (see §6). +- Config metadata (IDE completion, docs) is hand-maintained instead of generated. +- Consumers cannot read these values in templates without SpEL bean access (`${@environment.getProperty(...)}`), which Thymeleaf 3.1.5 (Spring Boot 4.1.0) evaluates in a restricted context during `thymeleaf-layout-dialect` decoration and rejects with *"access to static classes or parameters is forbidden."* This is what breaks `DemoApp#82`. + +The framework ships only email templates; the affected page templates live in consuming apps. So the library's role is to (a) model this config properly and (b) offer a first-class, non-bean-access way for consumers to read the URIs in templates. + +## 2. Goals / non-goals + +**Goals** +- Model `user.security.*` as a small family of typed `@ConfigurationProperties` classes. +- Migrate all 14 internal `@Value` consumers to inject the typed beans. +- Replace the 48 hand-maintained metadata entries with generated metadata. +- Ship a template-facing, secret-free view object so consumers can drop `${@environment...}`. +- Zero behavior change; zero config-key change for consumers. + +**Non-goals (explicit out-of-scope)** +- Any renaming/regrouping of config keys (reserved for a future major version). +- `@Validated`/JSR-380 startup validation (behavior change; contradicts `defaultAction`'s deliberate runtime degrade — see §7). +- Password-policy / passay redesign. +- The demo app changes (Boot 4.1.0 adoption + template switch closing `#82`) — a separate follow-up PR that consumes the snapshot this PR publishes. +- Trimming the shipped `dsspringuserconfig.properties` (see §4 — this would break compatibility). + +## 3. Decisions (settled with maintainer) + +1. **Additive, zero key changes** — bind the exact existing keys; no renames. +2. **Full internal migration** — convert every `user.security.*` `@Value` field site to the beans in this PR. +3. **Cohesive class family** — not one mega-class. +4. Template view object also carries `copyrightFirstYear`. +5. Placeholder/bean divergence guarded by **docs + a fail-fast test**. + +## 4. Architecture + +### 4.1 Three `@ConfigurationProperties` classes + +Lombok `@Data`, JavaDoc on every field (drives generated metadata), matching the existing `*ConfigProperties` house style. + +- **`UserSecurityConfigProperties(prefix = "user.security")`** — the flat keys: + - URIs/action paths (Java fields in lowerCamel with lowercase acronym, e.g. `loginPageUri`, so generated metadata is clean `login-page-uri`; relaxed binding still binds the existing `user.security.loginPageURI` keys): `loginPageUri`, `loginActionUri`, `loginSuccessUri`, `logoutActionUri`, `logoutSuccessUri`, `forgotPasswordUri`, `forgotPasswordChangeUri`, `forgotPasswordPendingUri`, `registrationUri`, `registrationPendingUri`, `registrationSuccessUri`, `registrationNewVerificationUri`, `registrationConfirmUri`, `updateUserUri`, `updatePasswordUri`, `deleteAccountUri`, `changePasswordUri`. + - URI lists as `List` (fields `protectedUris`, `unprotectedUris`, `disableCsrfUris`; relaxed binding still binds the existing `user.security.protectedURIs`/`unprotectedURIs`/`disableCSRFURIs` keys) — **must preserve empty-segment filtering** (see §5). + - Scalars: `defaultAction`, `appUrl`, `trustedHosts`, `requireCanonicalAppUrl`, `bcryptStrength`, `failedLoginAttempts`, `accountLockoutDuration`, `passwordResetTokenValidityMinutes`, `tokenHashSecret` (**`@ToString.Exclude`**), `testHashTime`, `alwaysUseDefaultTargetUrl`, `allowInitialPasswordSetWithoutStepUp`. + +- **`PasswordPolicyConfigProperties(prefix = "user.security.password")`** — 11 fields: `enabled`, `minLength`, `maxLength`, `requireUppercase`, `requireLowercase`, `requireDigit`, `requireSpecial`, `specialChars`, `preventCommonPasswords`, `historyCount`, `similarityThreshold`. + +- **`RememberMeConfigProperties(prefix = "user.security.remember-me")`** — 7 fields (prefix **must** be kebab; camel `rememberMe` is an invalid `@ConfigurationProperties` prefix and fails at startup): `enabled`, `key` (**`@ToString.Exclude`**), `tokenValiditySeconds`, `rememberMeParameter`, `rememberMeCookieName`, `useSecureCookie` (**`Boolean`**, tri-state — null means "Spring default"), `usePersistentTokens`. + +### 4.2 Enablement + +`@EnableConfigurationProperties({UserSecurityConfigProperties.class, PasswordPolicyConfigProperties.class, RememberMeConfigProperties.class})` on `UserSecurityBeansAutoConfiguration` (the natural host for this area). + +### 4.3 Internal migration (14 files) + +Convert injected `@Value` **fields** to `@RequiredArgsConstructor` injection of the beans: +`WebSecurityConfig`, `UserSecurityBeansAutoConfiguration`, `HtmxAwareAuthenticationEntryPointConfiguration`, `WebInterceptorConfig`, `UserActionController`, `UserAPI`, `UserService`, `PasswordPolicyService` (+`UserService` read `password.*`), `TokenHasher`, `LoginAttemptService`, `LoginSuccessService`, `LogoutSuccessService`, `UserEmailService`, `PasswordHashTimeTester`. `rememberMe.*` is consumed by `WebSecurityConfig`. + +`@GetMapping("${user.security.changePasswordURI:/user/changePassword}")`-style **annotation placeholders stay as placeholders** — they are request-mapping metadata, not injectable state. Only injected `@Value` fields migrate. `usePersistentTokens` stays a `@ConditionalOnProperty` condition on `UserSecurityBeansAutoConfiguration`; the field exists on the bean so its metadata survives the JSON deletion. + +Preserve `WebSecurityConfig`'s existing public getters by delegating to the beans, to avoid a source break for anyone calling e.g. `getLoginPageURI()`. Note in the changelog either way. + +### 4.4 Defaults — single documented source, **file left intact** + +Canonical defaults become **field initializers** matching today's *effective* (shipped-file) values. The shipped `config/dsspringuserconfig.properties` is loaded into the Spring `Environment` via `@PropertySource` on multiple config classes, which makes its keys **observable API**: 14 `@GetMapping` placeholder mappings resolve against it, several framework `@Value`s have no inline fallback and rely on it, and consumers legitimately read the same keys from the Environment (the demo's `@environment.getProperty(...)` templates are exactly this). Field initializers on a `@ConfigurationProperties` bean **never reach the Environment**, so removing file entries would break those resolutions at startup for consumers who never touched the keys. + +Therefore: **do not trim the properties file in this PR.** Keep it byte-for-byte. Add a test asserting, for every `user.security.*` key, that the shipped-file value equals the bean field initializer (equality both directions) — this closes the drift risk without shrinking the Environment surface. Any trim, and the announced `requireCanonicalAppUrl` default flip, ride a future major version. + +Delete the 48 `user.security.*` entries from `additional-spring-configuration-metadata.json` (generated metadata replaces them), guarded by a test that parses the retained old JSON (as a test resource) and asserts every `user.security.*` key it declares appears in the generated `spring-configuration-metadata.json` under dash/case-insensitive compare. Do **not** touch the JSON's non-`user.security` entries (`user.audit.*`, `user.registration.*`, `user.copyrightFirstYear`, `spring.*` passthroughs). The JSON has no `hints` section, so none are lost. + +### 4.5 Template access (the `#82` enabler) + +A `@ControllerAdvice(annotations = Controller.class)` — mirroring `CaptchaSiteKeyControllerAdvice`, registered from the auto-configuration — exposes a **narrow, immutable view record** (built once from the beans) as `@ModelAttribute("userSecurity")`. The record carries only the page/action URIs plus `copyrightFirstYear`. It **must not** expose the `UserSecurityConfigProperties` bean itself, which holds `tokenHashSecret` and would otherwise render into any template model dump or error page. Registration is gated by `@ConditionalOnProperty(matchIfMissing = true)` so consumers can opt out; `userSecurity` is now a reserved model-attribute name and is documented as such. + +Templates then use `${userSecurity.loginPageUri}` — an ordinary model variable, not subject to Thymeleaf's restricted-expression rules. + +## 5. Data / binding semantics + +`List` binding for `protectedURIs`/`unprotectedURIs`/`disableCSRFURIs`: Boot's delimited-string conversion trims but **does not drop empty segments**, whereas the current `WebSecurityConfig.splitAndFilterProperty` drops them. A consumer override with a trailing comma (`unprotectedURIs=/a,/b,`) must not start producing `requestMatchers("")`. Preserve empty-filtering at the consumption site (or via a defensive getter). Binding tests cover `"a,,b,"`, trailing comma, and empty string (`disableCSRFURIs=` → empty list). + +## 6. Effective-value defaults to encode (file wins over inline fallback) + +The field initializers use the **file** values, which are the effective runtime values today: +- `bcryptStrength` → **12** (file), not 10 (inline `@Value` fallback in `UserSecurityBeansAutoConfiguration`). +- `password.historyCount` → **3** (file), not 0 (inline fallback in `UserService`). +- `appUrl` → **`""`** (file), not `null` (`@Value` `#{null}` fallback). `AppUrlResolver` treats blank and null alike, but the equality test needs one canonical form; keep `""`. + +## 7. Error handling / behavior preservation + +- `defaultAction` keeps its runtime degrade-to-`denyAll` on an invalid value (`WebSecurityConfig`); no startup validation is added. +- `requireCanonicalAppUrl` keeps its existing opt-in fail-fast. +- `useSecureCookie` stays tri-state `Boolean` so null continues to mean "use Spring's default." +- Placeholder/bean divergence: the generated metadata advertises kebab spellings while the 14 `@GetMapping`/`@Value` placeholders resolve the exact camelCase key only. Guard with (a) CONFIG.md + metadata descriptions stating camelCase spellings are canonical, and (b) a fail-fast test/startup check asserting each mapping-relevant placeholder value equals the corresponding bean getter, with a message naming any kebab/camel mismatch. + +## 8. Testing + +- Per-class binding tests: defaults, override, relaxed binding (incl. an env-var form `USER_SECURITY_REMEMBERME_*` → `remember-me` prefix to pin the binder equivalence). +- Effective-defaults equality test: shipped-file value == field initializer for every `user.security.*` key (both directions). +- Metadata coverage test: every `user.security.*` key in the retained old JSON appears in generated metadata. +- List-binding edge tests: `"a,,b,"`, trailing comma, empty string. +- ControllerAdvice test: `userSecurity` present with populated URIs + `copyrightFirstYear`, and **absent** when opted out; assert `tokenHashSecret` is not reachable from the exposed object. +- Placeholder/bean parity test (§7b). +- Existing ArchUnit / integration / security suites as the migration safety net. +- Naming `should[Behavior]When[Condition]`; use custom annotations (`@ServiceTest`, `@SecurityTest`, etc.) per `context/conventions.md`. + +## 9. Documentation + +- `CONFIG.md`: note the typed properties and that camelCase key spellings are canonical. +- `CHANGELOG.md`: internal refactor; new `${userSecurity}` model attribute; any `WebSecurityConfig` getter note. +- `MIGRATION.md`: "no action required — keys unchanged; `${userSecurity.*}` now available to templates." + +## 10. Sequencing + +1. This library PR → merge → publish `5.2.x`-SNAPSHOT. +2. Follow-up demo PR: adopt Boot 4.1.0, replace `${@environment.getProperty('user.security.*')}` and `copyrightFirstYear` with `${userSecurity.*}`, and handle the two remaining non-`user.security` template expressions (`@environment.acceptsProfiles('dev','local')` stays a demo-side computed model attribute; it is not a config value). Closes `DemoApp#82`. From 525e179e3f7e964260c7efaf86247a1fedc79740 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 13 Aug 2026 21:28:51 -0600 Subject: [PATCH 02/24] docs: link tracking issue #355 in design spec --- docs/design/2026-08-13-user-security-config-properties-design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/design/2026-08-13-user-security-config-properties-design.md b/docs/design/2026-08-13-user-security-config-properties-design.md index 5a17c83..3f21981 100644 --- a/docs/design/2026-08-13-user-security-config-properties-design.md +++ b/docs/design/2026-08-13-user-security-config-properties-design.md @@ -3,6 +3,7 @@ **Date:** 2026-08-13 **Repo:** `SpringUserFramework` (library) **Status:** Approved design — pending implementation plan +**Tracking issue:** `#355` **Motivating issue:** `SpringUserFrameworkDemoApp#82` (Boot 4.1.0 / Thymeleaf 3.1.5 breaks the demo's `${@environment.getProperty('user.security.*')}` template idiom) ## 1. Problem From a1fae0a8309e18dd4f14be35d276de16bfcede11 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 13 Aug 2026 21:38:33 -0600 Subject: [PATCH 03/24] docs: implementation plan for typed user.security @ConfigurationProperties --- ...13-user-security-config-properties-plan.md | 1139 +++++++++++++++++ 1 file changed, 1139 insertions(+) create mode 100644 docs/plans/2026-08-13-user-security-config-properties-plan.md diff --git a/docs/plans/2026-08-13-user-security-config-properties-plan.md b/docs/plans/2026-08-13-user-security-config-properties-plan.md new file mode 100644 index 0000000..6fbd712 --- /dev/null +++ b/docs/plans/2026-08-13-user-security-config-properties-plan.md @@ -0,0 +1,1139 @@ +# Typed `user.security.*` `@ConfigurationProperties` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Convert the `user.security.*` config namespace from ~40 scattered `@Value` injections into a cohesive family of typed `@ConfigurationProperties`, migrate all internal consumers, generate config metadata, and expose a secret-free template view object — with zero config-key changes for consumers. + +**Architecture:** Three `@ConfigurationProperties` classes (`UserSecurityConfigProperties`, `PasswordPolicyConfigProperties`, `RememberMeConfigProperties`) bound to the existing keys via relaxed binding, enabled on `UserSecurityBeansAutoConfiguration`. Internal `@Value` field injections are replaced by constructor-injected beans. A `@ControllerAdvice` exposes a narrow immutable `UserSecurityUriView` record (URIs + `copyrightFirstYear`) as `${userSecurity}` so consuming templates stop using `${@environment.getProperty(...)}`. + +**Tech Stack:** Java 21, Spring Boot 4.1.0, Lombok, `spring-boot-configuration-processor`, JUnit 5 + AssertJ, `ApplicationContextRunner`, MockMvc standalone, ArchUnit. + +**Spec:** `docs/design/2026-08-13-user-security-config-properties-design.md` + +## Global Constraints + +- **Additive only:** no config key renamed, moved, or removed. New Java fields use lowerCamel with lowercased acronyms (`loginPageUri`); relaxed binding still binds the existing camelCase keys (`user.security.loginPageURI`). +- **Do not modify** `src/main/resources/config/dsspringuserconfig.properties` — it is `@PropertySource`-loaded (Environment-visible API). Field initializers mirror it; they do not replace it. +- **Effective defaults win:** field initializers use the shipped-file values, not inline `@Value` fallbacks — `bcryptStrength=12` (not 10), `password.history-count=3` (not 0), `appUrl=""` (not null). +- **Never expose the raw properties bean to templates.** `@ToString.Exclude` on `tokenHashSecret` and remember-me `key`. +- No `@Validated`/JSR-380; no constructor-binding/records for the CP classes — mutable Lombok `@Data`, matching `MfaConfigProperties`/`CaptchaConfigProperties`. +- Conventions (`context/conventions.md`): 4-space indent, alphabetical non-wildcard imports, JavaDoc on public classes/methods, `@RequiredArgsConstructor` + `final` fields, `@Slf4j`, test names `should[Behavior]When[Condition]`, Conventional Commits, do not hand-edit versions. + +--- + +### Task 1: `PasswordPolicyConfigProperties` + +**Files:** +- Create: `src/main/java/com/digitalsanctuary/spring/user/security/PasswordPolicyConfigProperties.java` +- Test: `src/test/java/com/digitalsanctuary/spring/user/security/PasswordPolicyConfigPropertiesTest.java` + +**Interfaces:** +- Produces: `PasswordPolicyConfigProperties` with getters `isEnabled()`, `getMinLength()`, `getMaxLength()`, `isRequireUppercase()`, `isRequireLowercase()`, `isRequireDigit()`, `isRequireSpecial()`, `getSpecialChars()`, `isPreventCommonPasswords()`, `getHistoryCount()`, `getSimilarityThreshold()`. + +Defaults from `dsspringuserconfig.properties` lines 165-185. + +- [ ] **Step 1: Write the failing binding test** + +```java +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; + +@DisplayName("PasswordPolicyConfigProperties binding") +class PasswordPolicyConfigPropertiesTest { + + private final ApplicationContextRunner contextRunner = + new ApplicationContextRunner().withUserConfiguration(TestConfig.class); + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(PasswordPolicyConfigProperties.class) + static class TestConfig { + } + + @Test + void shouldApplyShippedDefaultsWhenUnset() { + contextRunner.run(context -> { + PasswordPolicyConfigProperties p = context.getBean(PasswordPolicyConfigProperties.class); + assertThat(p.isEnabled()).isTrue(); + assertThat(p.getMinLength()).isEqualTo(8); + assertThat(p.getMaxLength()).isEqualTo(128); + assertThat(p.getHistoryCount()).isEqualTo(3); + assertThat(p.getSimilarityThreshold()).isEqualTo(70); + }); + } + + @Test + void shouldBindKebabKeysWhenConfigured() { + contextRunner.withPropertyValues("user.security.password.min-length=12", + "user.security.password.require-special=false", + "user.security.password.history-count=5").run(context -> { + PasswordPolicyConfigProperties p = context.getBean(PasswordPolicyConfigProperties.class); + assertThat(p.getMinLength()).isEqualTo(12); + assertThat(p.isRequireSpecial()).isFalse(); + assertThat(p.getHistoryCount()).isEqualTo(5); + }); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew test --tests "com.digitalsanctuary.spring.user.security.PasswordPolicyConfigPropertiesTest"` +Expected: FAIL — `PasswordPolicyConfigProperties` does not exist (compilation error). + +- [ ] **Step 3: Create the class** + +```java +package com.digitalsanctuary.spring.user.security; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import lombok.Data; + +/** + * Configuration properties for the password policy enforced by + * {@link com.digitalsanctuary.spring.user.service.PasswordPolicyService}. + * + *

Bound from {@code user.security.password.*}. Defaults mirror the shipped + * {@code config/dsspringuserconfig.properties} values.

+ */ +@Data +@ConfigurationProperties(prefix = "user.security.password") +public class PasswordPolicyConfigProperties { + + /** Whether password-policy enforcement is active. */ + private boolean enabled = true; + + /** Minimum password length. */ + private int minLength = 8; + + /** Maximum password length. */ + private int maxLength = 128; + + /** Whether at least one uppercase character is required. */ + private boolean requireUppercase = true; + + /** Whether at least one lowercase character is required. */ + private boolean requireLowercase = true; + + /** Whether at least one digit is required. */ + private boolean requireDigit = true; + + /** Whether at least one special character is required. */ + private boolean requireSpecial = true; + + /** The set of characters treated as "special". */ + private String specialChars = "~`!@#$%^&*()_-+={}[]|\\:;\"'<>,.?/"; + + /** Whether passwords are checked against the common-passwords dictionary. */ + private boolean preventCommonPasswords = true; + + /** Number of previous passwords retained and rejected on reuse. */ + private int historyCount = 3; + + /** Levenshtein similarity threshold (0-100) against username/email. */ + private int similarityThreshold = 70; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew test --tests "com.digitalsanctuary.spring.user.security.PasswordPolicyConfigPropertiesTest"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/digitalsanctuary/spring/user/security/PasswordPolicyConfigProperties.java \ + src/test/java/com/digitalsanctuary/spring/user/security/PasswordPolicyConfigPropertiesTest.java +git commit -m "feat: add PasswordPolicyConfigProperties bound to user.security.password.*" +``` + +--- + +### Task 2: `RememberMeConfigProperties` + +**Files:** +- Create: `src/main/java/com/digitalsanctuary/spring/user/security/RememberMeConfigProperties.java` +- Test: `src/test/java/com/digitalsanctuary/spring/user/security/RememberMeConfigPropertiesTest.java` + +**Interfaces:** +- Produces: `RememberMeConfigProperties` with `isEnabled()`, `getKey()`, `getTokenValiditySeconds()`, `getRememberMeParameter()`, `getRememberMeCookieName()`, `getUseSecureCookie()` (returns `Boolean`, may be null), `isUsePersistentTokens()`. + +Prefix MUST be kebab (`user.security.remember-me`); the camel form is an invalid `@ConfigurationProperties` prefix and fails at startup. Relaxed binding still binds the existing `user.security.rememberMe.*` keys. + +- [ ] **Step 1: Write the failing binding test** + +```java +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; + +@DisplayName("RememberMeConfigProperties binding") +class RememberMeConfigPropertiesTest { + + private final ApplicationContextRunner contextRunner = + new ApplicationContextRunner().withUserConfiguration(TestConfig.class); + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(RememberMeConfigProperties.class) + static class TestConfig { + } + + @Test + void shouldApplyDefaultsWhenUnset() { + contextRunner.run(context -> { + RememberMeConfigProperties p = context.getBean(RememberMeConfigProperties.class); + assertThat(p.isEnabled()).isFalse(); + assertThat(p.getKey()).isNull(); + assertThat(p.getTokenValiditySeconds()).isEqualTo(1209600); + assertThat(p.getRememberMeParameter()).isEqualTo("remember-me"); + assertThat(p.getRememberMeCookieName()).isEqualTo("remember-me"); + assertThat(p.getUseSecureCookie()).isNull(); + assertThat(p.isUsePersistentTokens()).isFalse(); + }); + } + + @Test + void shouldBindLegacyCamelCaseKeysViaRelaxedBinding() { + contextRunner.withPropertyValues("user.security.rememberMe.enabled=true", + "user.security.rememberMe.tokenValiditySeconds=60", + "user.security.rememberMe.useSecureCookie=true").run(context -> { + RememberMeConfigProperties p = context.getBean(RememberMeConfigProperties.class); + assertThat(p.isEnabled()).isTrue(); + assertThat(p.getTokenValiditySeconds()).isEqualTo(60); + assertThat(p.getUseSecureCookie()).isTrue(); + }); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew test --tests "com.digitalsanctuary.spring.user.security.RememberMeConfigPropertiesTest"` +Expected: FAIL — class does not exist. + +- [ ] **Step 3: Create the class** + +```java +package com.digitalsanctuary.spring.user.security; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import lombok.Data; +import lombok.ToString; + +/** + * Configuration properties for Spring Security "remember-me". Bound from + * {@code user.security.remember-me.*} (relaxed binding also accepts the legacy + * {@code user.security.rememberMe.*} spelling). Defaults mirror the shipped + * {@code config/dsspringuserconfig.properties} values. + */ +@Data +@ConfigurationProperties(prefix = "user.security.remember-me") +public class RememberMeConfigProperties { + + /** Whether remember-me is enabled. */ + private boolean enabled = false; + + /** + * The remember-me signing key. Excluded from {@code toString} so the secret never leaks through bean logging. + * When null, Spring Security generates an ephemeral key at startup. + */ + @ToString.Exclude + private String key; + + /** Token validity in seconds. */ + private int tokenValiditySeconds = 1209600; + + /** Name of the remember-me request parameter. */ + private String rememberMeParameter = "remember-me"; + + /** Name of the remember-me cookie. */ + private String rememberMeCookieName = "remember-me"; + + /** + * Whether the remember-me cookie is marked {@code Secure}. Left null (unset) by default so Spring Security's own + * behavior applies: the cookie is secure whenever the request that created it was made over HTTPS. + */ + private Boolean useSecureCookie; + + /** Whether persistent (database-backed) remember-me tokens are used instead of the hash-based scheme. */ + private boolean usePersistentTokens = false; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew test --tests "com.digitalsanctuary.spring.user.security.RememberMeConfigPropertiesTest"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/digitalsanctuary/spring/user/security/RememberMeConfigProperties.java \ + src/test/java/com/digitalsanctuary/spring/user/security/RememberMeConfigPropertiesTest.java +git commit -m "feat: add RememberMeConfigProperties bound to user.security.remember-me.*" +``` + +--- + +### Task 3: `UserSecurityConfigProperties` + +**Files:** +- Create: `src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigProperties.java` +- Test: `src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigPropertiesTest.java` + +**Interfaces:** +- Produces: `UserSecurityConfigProperties` with getters for every field below. Getters `getProtectedUris()`, `getUnprotectedUris()`, `getDisableCsrfUris()` return the **filtered** list (blank segments dropped), matching the old `WebSecurityConfig.splitAndFilterProperty` behavior. + +Field defaults from `dsspringuserconfig.properties` (URIs lines 129-163, scalars 70-93, 135; `bcryptStrength=12` per §6). `tokenHashSecret` and `appUrl` have no shipped scalar default beyond empty; `appUrl=""`, `tokenHashSecret=null`. + +- [ ] **Step 1: Write the failing test (defaults + list filtering)** + +```java +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; + +@DisplayName("UserSecurityConfigProperties binding") +class UserSecurityConfigPropertiesTest { + + private final ApplicationContextRunner contextRunner = + new ApplicationContextRunner().withUserConfiguration(TestConfig.class); + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(UserSecurityConfigProperties.class) + static class TestConfig { + } + + @Test + void shouldApplyShippedDefaultsWhenUnset() { + contextRunner.run(context -> { + UserSecurityConfigProperties p = context.getBean(UserSecurityConfigProperties.class); + assertThat(p.getLoginPageUri()).isEqualTo("/user/login.html"); + assertThat(p.getRegistrationUri()).isEqualTo("/user/register.html"); + assertThat(p.getDefaultAction()).isEqualTo("deny"); + assertThat(p.getBcryptStrength()).isEqualTo(12); + assertThat(p.getAppUrl()).isEqualTo(""); + assertThat(p.getTokenHashSecret()).isNull(); + }); + } + + @Test + void shouldBindLegacyCamelCaseUriKeys() { + contextRunner.withPropertyValues("user.security.loginPageURI=/custom/login").run(context -> { + assertThat(context.getBean(UserSecurityConfigProperties.class).getLoginPageUri()) + .isEqualTo("/custom/login"); + }); + } + + @Test + void shouldDropBlankSegmentsFromUriLists() { + contextRunner.withPropertyValues("user.security.unprotectedURIs=/a,,/b,").run(context -> { + assertThat(context.getBean(UserSecurityConfigProperties.class).getUnprotectedUris()) + .containsExactly("/a", "/b"); + }); + } + + @Test + void shouldReturnEmptyListForBlankUriListProperty() { + contextRunner.withPropertyValues("user.security.disableCSRFURIs=").run(context -> { + assertThat(context.getBean(UserSecurityConfigProperties.class).getDisableCsrfUris()).isEmpty(); + }); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew test --tests "com.digitalsanctuary.spring.user.security.UserSecurityConfigPropertiesTest"` +Expected: FAIL — class does not exist. + +- [ ] **Step 3: Create the class** + +Use `@Data` for the mutable getters/setters, but override the three list getters to filter blanks. Store the raw bound list in a private field; the getter returns the filtered copy. + +```java +package com.digitalsanctuary.spring.user.security; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import lombok.Data; +import lombok.ToString; + +/** + * Configuration properties for the flat {@code user.security.*} namespace: page/action URIs, URI lists, and + * security scalars. Password policy and remember-me live in their own classes + * ({@link PasswordPolicyConfigProperties}, {@link RememberMeConfigProperties}). + * + *

Defaults mirror the shipped {@code config/dsspringuserconfig.properties}. The camelCase key spellings + * (e.g. {@code user.security.loginPageURI}) are canonical; relaxed binding also accepts kebab-case.

+ */ +@Data +@ConfigurationProperties(prefix = "user.security") +public class UserSecurityConfigProperties { + + // --- Access control --- + /** Default filter-chain action for URIs not otherwise matched: {@code deny} or {@code allow}. */ + private String defaultAction = "deny"; + private List protectedUris = new ArrayList<>(); + private List unprotectedUris = new ArrayList<>(); + private List disableCsrfUris = new ArrayList<>(); + + // --- Page / action URIs --- + private String loginPageUri = "/user/login.html"; + private String loginActionUri = "/user/login"; + private String loginSuccessUri = "/index.html?messageKey=message.login.success"; + private String logoutActionUri = "/user/logout"; + private String logoutSuccessUri = "/index.html?messageKey=message.logout.success"; + private boolean alwaysUseDefaultTargetUrl = false; + private String registrationUri = "/user/register.html"; + private String registrationPendingUri = "/user/registration-pending-verification.html"; + private String registrationSuccessUri = "/user/registration-complete.html"; + private String registrationNewVerificationUri = "/user/request-new-verification-email.html"; + private String registrationConfirmUri = "/user/registrationConfirm"; + private String forgotPasswordUri = "/user/forgot-password.html"; + private String forgotPasswordPendingUri = "/user/forgot-password-pending-verification.html"; + private String forgotPasswordChangeUri = "/user/forgot-password-change.html"; + private String updateUserUri = "/user/update-user.html"; + private String updatePasswordUri = "/user/update-password.html"; + private String deleteAccountUri = "/user/delete-account.html"; + private String changePasswordUri = "/user/changePassword"; + + // --- Security scalars --- + private int bcryptStrength = 12; + private int failedLoginAttempts = 10; + private int accountLockoutDuration = 30; + private int passwordResetTokenValidityMinutes = 1440; + private boolean requireCanonicalAppUrl = false; + private boolean testHashTime = true; + private boolean allowInitialPasswordSetWithoutStepUp = false; + private String appUrl = ""; + private List trustedHosts = new ArrayList<>(); + + /** HMAC secret used to hash password-reset tokens at rest. Excluded from {@code toString}. */ + @ToString.Exclude + private String tokenHashSecret; + + public List getProtectedUris() { + return filterBlank(protectedUris); + } + + public List getUnprotectedUris() { + return filterBlank(unprotectedUris); + } + + public List getDisableCsrfUris() { + return filterBlank(disableCsrfUris); + } + + private static List filterBlank(List values) { + if (values == null) { + return new ArrayList<>(); + } + List filtered = new ArrayList<>(values.size()); + for (String value : values) { + if (value != null && !value.isBlank()) { + filtered.add(value.trim()); + } + } + return filtered; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew test --tests "com.digitalsanctuary.spring.user.security.UserSecurityConfigPropertiesTest"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigProperties.java \ + src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigPropertiesTest.java +git commit -m "feat: add UserSecurityConfigProperties bound to flat user.security.* keys" +``` + +--- + +### Task 4: Enable the beans + effective-defaults regression test + +**Files:** +- Modify: `src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java:69-72` (add `@EnableConfigurationProperties`) +- Test: `src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityDefaultsParityTest.java` + +**Interfaces:** +- Consumes: the three classes from Tasks 1-3. +- Produces: the three beans registered in the framework context (later migration tasks inject them). + +- [ ] **Step 1: Add the enablement annotation** + +On `UserSecurityBeansAutoConfiguration`, above the class declaration (line 70-72), add: + +```java +@EnableConfigurationProperties({UserSecurityConfigProperties.class, PasswordPolicyConfigProperties.class, + RememberMeConfigProperties.class}) +``` + +Add the import `org.springframework.boot.context.properties.EnableConfigurationProperties;` in alphabetical order. + +- [ ] **Step 2: Write the effective-defaults parity test** + +This asserts each field initializer equals the value shipped in `dsspringuserconfig.properties`, so the initializers can never drift from the file we intentionally leave intact. + +```java +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Properties; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.support.ResourcePropertySource; +import org.springframework.mock.env.MockEnvironment; + +@DisplayName("user.security defaults parity with shipped properties file") +class UserSecurityDefaultsParityTest { + + private Properties shipped() throws Exception { + ResourcePropertySource source = + new ResourcePropertySource(new ClassPathResource("config/dsspringuserconfig.properties")); + Properties props = new Properties(); + source.getSource().forEach(props::put); + return props; + } + + @Test + void flatFieldInitializersMatchShippedFile() throws Exception { + Properties p = shipped(); + UserSecurityConfigProperties bean = new UserSecurityConfigProperties(); + assertThat(bean.getLoginPageUri()).isEqualTo(p.getProperty("user.security.loginPageURI")); + assertThat(bean.getRegistrationUri()).isEqualTo(p.getProperty("user.security.registrationURI")); + assertThat(bean.getChangePasswordUri()).isEqualTo(p.getProperty("user.security.changePasswordURI")); + assertThat(bean.getDefaultAction()).isEqualTo(p.getProperty("user.security.defaultAction")); + assertThat(String.valueOf(bean.getBcryptStrength())).isEqualTo(p.getProperty("user.security.bcryptStrength")); + assertThat(String.valueOf(bean.getFailedLoginAttempts())) + .isEqualTo(p.getProperty("user.security.failedLoginAttempts")); + } + + @Test + void passwordFieldInitializersMatchShippedFile() throws Exception { + Properties p = shipped(); + PasswordPolicyConfigProperties bean = new PasswordPolicyConfigProperties(); + assertThat(String.valueOf(bean.getMinLength())).isEqualTo(p.getProperty("user.security.password.min-length")); + assertThat(String.valueOf(bean.getHistoryCount())) + .isEqualTo(p.getProperty("user.security.password.history-count")); + assertThat(String.valueOf(bean.getSimilarityThreshold())) + .isEqualTo(p.getProperty("user.security.password.similarity-threshold")); + } + + @Test + void bindingTheShippedFileYieldsTheSameValuesAsTheInitializers() throws Exception { + MockEnvironment env = new MockEnvironment(); + new ResourcePropertySource(new ClassPathResource("config/dsspringuserconfig.properties")).getSource() + .forEach((k, v) -> env.setProperty(k, String.valueOf(v))); + UserSecurityConfigProperties bound = Binder.get(env) + .bind("user.security", UserSecurityConfigProperties.class).get(); + assertThat(bound.getLoginPageUri()).isEqualTo(new UserSecurityConfigProperties().getLoginPageUri()); + assertThat(bound.getBcryptStrength()).isEqualTo(new UserSecurityConfigProperties().getBcryptStrength()); + } +} +``` + +- [ ] **Step 3: Run the test** + +Run: `./gradlew test --tests "com.digitalsanctuary.spring.user.security.UserSecurityDefaultsParityTest"` +Expected: PASS. If any assertion fails, fix the field initializer to match the shipped file (do NOT change the file). + +- [ ] **Step 4: Commit** + +```bash +git add src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java \ + src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityDefaultsParityTest.java +git commit -m "feat: register user.security config properties beans; add defaults parity test" +``` + +--- + +### Task 5: Migrate password-policy consumers + +**Files:** +- Modify: `src/main/java/com/digitalsanctuary/spring/user/service/PasswordPolicyService.java:60-91,98-100` +- Modify: `src/main/java/com/digitalsanctuary/spring/user/service/UserService.java:280` + +**Interfaces:** +- Consumes: `PasswordPolicyConfigProperties` (Task 1). + +- [ ] **Step 1: Migrate `PasswordPolicyService`** + +Delete the 11 `@Value` fields (lines 60-91). Add a final bean field to the injected group (near line 98-100): + +```java + private final PasswordPolicyConfigProperties passwordPolicy; +``` + +Replace every internal read of the removed fields with the bean getter: `enabled` → `passwordPolicy.isEnabled()`, `minLength` → `passwordPolicy.getMinLength()`, `maxLength` → `passwordPolicy.getMaxLength()`, `requireUppercase/Lowercase/Digit/Special` → `passwordPolicy.isRequire...()`, `specialChars` → `passwordPolicy.getSpecialChars()`, `preventCommonPasswords` → `passwordPolicy.isPreventCommonPasswords()`, `historyCount` → `passwordPolicy.getHistoryCount()`, `similarityThreshold` → `passwordPolicy.getSimilarityThreshold()`. Keep the non-`user.security` `@Value("classpath:common_passwords.txt")` field. Remove the now-unused `org.springframework.beans.factory.annotation.Value` import if no `@Value` remains — it still does (common_passwords), so keep it. + +- [ ] **Step 2: Migrate `UserService`** + +Replace the `@Value("${user.security.password.history-count:0}")` field (line 280) with the injected `PasswordPolicyConfigProperties` bean (add `private final PasswordPolicyConfigProperties passwordPolicy;` to its constructor group) and read `passwordPolicy.getHistoryCount()` at the use site. Remove the `@Value` field. Add the import for `PasswordPolicyConfigProperties`. + +- [ ] **Step 3: Run the affected suites** + +Run: `./gradlew test --tests "*PasswordPolicyService*" --tests "*UserService*"` +Expected: PASS (existing password and user-service tests exercise these paths). + +- [ ] **Step 4: Commit** + +```bash +git add src/main/java/com/digitalsanctuary/spring/user/service/PasswordPolicyService.java \ + src/main/java/com/digitalsanctuary/spring/user/service/UserService.java +git commit -m "refactor: inject PasswordPolicyConfigProperties into password consumers" +``` + +--- + +### Task 6: Migrate `WebSecurityConfig` + +**Files:** +- Modify: `src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java:53-126` and all internal references. + +**Interfaces:** +- Consumes: `UserSecurityConfigProperties`, `RememberMeConfigProperties`. +- Produces: preserved public getters `getLoginPageURI()`, `getProtectedURIsProperty()`-equivalents, etc. (see Step 3) so existing callers/tests keep compiling. + +This is the largest migration. `WebSecurityConfig` is `@Data @Configuration @RequiredArgsConstructor`; removing the `@Value` fields also removes their Lombok getters, an API break. Preserve the public getter surface with explicit delegating methods. + +- [ ] **Step 1: Inject the beans** + +Add to the final-field group (after line ~135, alongside the other `private final` collaborators): + +```java + private final UserSecurityConfigProperties userSecurityConfig; + private final RememberMeConfigProperties rememberMeConfig; +``` + +- [ ] **Step 2: Remove the migrated `@Value` fields and repoint reads** + +Delete lines 53-99 (`defaultAction` through `registrationNewVerificationURI`) and 104-126 (the `rememberMe.*` block). Keep the non-`user.security` fields (`oauth2Enabled` line 101-102, `devAutoLoginEnabled` line 128-129). Repoint every internal use: +- `defaultAction` → `userSecurityConfig.getDefaultAction()` +- `protectedURIsProperty`/`unprotectedURIsProperty`/`disableCSRFURIsProperty` fed through `splitAndFilterProperty(...)` → use `userSecurityConfig.getProtectedUris()` / `getUnprotectedUris()` / `getDisableCsrfUris()` directly (they are already filtered — delete the now-redundant `splitAndFilterProperty` calls for these three; if `splitAndFilterProperty` has no other callers, remove it too). +- Each `xxxURI` field → `userSecurityConfig.getXxxUri()`. +- `rememberMeEnabled` → `rememberMeConfig.isEnabled()`, `rememberMeKey` → `rememberMeConfig.getKey()`, `rememberMeTokenValiditySeconds` → `rememberMeConfig.getTokenValiditySeconds()`, `rememberMeParameter` → `rememberMeConfig.getRememberMeParameter()`, `rememberMeCookieName` → `rememberMeConfig.getRememberMeCookieName()`, `rememberMeUseSecureCookie` → `rememberMeConfig.getUseSecureCookie()`. + +- [ ] **Step 3: Preserve the public getter API** + +Add explicit delegating getters for anything previously exposed by `@Data` that external callers/tests may use (keep the historical camelCase names): + +```java + public String getLoginPageURI() { + return userSecurityConfig.getLoginPageUri(); + } + // ...one per previously-public URI getter that has external callers +``` + +Search for external references first: `grep -rn "getLoginPageURI\|getRegistrationURI\|getDefaultAction\|getProtectedURIsProperty" src/test src/main` and add a delegate for each name still referenced. If a name has zero references outside the class, no delegate is needed. + +- [ ] **Step 4: Run the security suites** + +Run: `./gradlew test --tests "*WebSecurityConfig*" --tests "*SecurityConfiguration*" --tests "*Security*"` +Expected: PASS. The filter-chain integration tests are the safety net for this migration. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java +git commit -m "refactor: inject user.security config properties into WebSecurityConfig" +``` + +--- + +### Task 7: Migrate `UserSecurityBeansAutoConfiguration` + +**Files:** +- Modify: `src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java:77-78,313-315` + +**Interfaces:** +- Consumes: `UserSecurityConfigProperties`. + +- [ ] **Step 1: Migrate `bcryptStrength`** + +Add `private final UserSecurityConfigProperties userSecurityConfig;` to the final-field group (lines 74-75). Delete the `@Value("${user.security.bcryptStrength:10}")` field (77-78). In `encoder()` use `new BCryptPasswordEncoder(userSecurityConfig.getBcryptStrength())`. + +- [ ] **Step 2: Migrate `appUrlResolver`** + +Change the bean method to read from the injected bean instead of method-parameter `@Value`s: + +```java + @Bean + public AppUrlResolver appUrlResolver() { + return new AppUrlResolver(userSecurityConfig.getAppUrl(), userSecurityConfig.getTrustedHosts(), + userSecurityConfig.isRequireCanonicalAppUrl()); + } +``` + +Confirm `AppUrlResolver`'s constructor signature is `(String, List, boolean)` before editing; keep the exact order. + +- [ ] **Step 3: Leave the persistent-token condition unchanged** + +`@ConditionalOnProperty(name = "user.security.rememberMe.usePersistentTokens", havingValue = "true")` (line 205) stays as-is — conditions read the raw Environment key, not the bean. Do not change it. + +- [ ] **Step 4: Run the suite** + +Run: `./gradlew test --tests "*UserSecurityBeansAutoConfiguration*" --tests "*AppUrlResolver*" --tests "*Encoder*"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java +git commit -m "refactor: inject UserSecurityConfigProperties into UserSecurityBeansAutoConfiguration" +``` + +--- + +### Task 8: Migrate remaining service consumers + +**Files:** +- Modify: `service/LoginSuccessService.java:58-62`, `service/LogoutSuccessService.java:38`, `service/LoginAttemptService.java:36,43`, `service/UserEmailService.java:92`, `service/TokenHasher.java:59`, `util/PasswordHashTimeTester.java:30` + +**Interfaces:** +- Consumes: `UserSecurityConfigProperties`. + +For each file: add `private final UserSecurityConfigProperties userSecurityConfig;` to the `@RequiredArgsConstructor` group (or add a constructor parameter where the class uses an explicit constructor, e.g. `TokenHasher`), delete the `user.security.*` `@Value`, and repoint reads: +- `LoginSuccessService`: `loginSuccessURI` → `userSecurityConfig.getLoginSuccessUri()`; `alwaysUseDefaultTargetUrl` → `userSecurityConfig.isAlwaysUseDefaultTargetUrl()`. +- `LogoutSuccessService`: `logoutSuccessURI` → `userSecurityConfig.getLogoutSuccessUri()`. +- `LoginAttemptService`: `failedLoginAttempts` → `userSecurityConfig.getFailedLoginAttempts()`; `accountLockoutDuration` → `userSecurityConfig.getAccountLockoutDuration()`. +- `UserEmailService`: `passwordResetTokenValidityMinutes` → `userSecurityConfig.getPasswordResetTokenValidityMinutes()`. +- `TokenHasher`: replace the constructor param `@Value("${user.security.tokenHashSecret:#{null}}") final String tokenHashSecret` with injecting `UserSecurityConfigProperties` and reading `userSecurityConfig.getTokenHashSecret()` (preserve the existing null-handling logic exactly). +- `PasswordHashTimeTester`: `testHashTime` → `userSecurityConfig.isTestHashTime()`. + +- [ ] **Step 1: Apply the six migrations above.** + +- [ ] **Step 2: Run the suites** + +Run: `./gradlew test --tests "*LoginSuccessService*" --tests "*LogoutSuccessService*" --tests "*LoginAttemptService*" --tests "*UserEmailService*" --tests "*TokenHasher*" --tests "*PasswordHashTime*"` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add src/main/java/com/digitalsanctuary/spring/user/service/ src/main/java/com/digitalsanctuary/spring/user/util/PasswordHashTimeTester.java +git commit -m "refactor: inject UserSecurityConfigProperties into remaining service consumers" +``` + +--- + +### Task 9: Migrate controller / web-config consumers + +**Files:** +- Modify: `controller/UserActionController.java:52-68`, `api/UserAPI.java:100-114`, `security/HtmxAwareAuthenticationEntryPointConfiguration.java:31`, `web/WebInterceptorConfig.java:27,31` + +**Interfaces:** +- Consumes: `UserSecurityConfigProperties`. + +For each, inject `UserSecurityConfigProperties` and repoint the injected `@Value` **fields**. Leave any `@GetMapping("${user.security.*}")` / `@RequestMapping` **annotation placeholders** untouched — they resolve against the Environment, not the bean. +- `UserActionController`: `registrationPendingURI`→`getRegistrationPendingUri()`, `registrationSuccessURI`→`getRegistrationSuccessUri()`, `registrationNewVerificationURI`→`getRegistrationNewVerificationUri()`, `forgotPasswordPendingURI`→`getForgotPasswordPendingUri()`, `forgotPasswordChangeURI`→`getForgotPasswordChangeUri()`. +- `UserAPI`: `registrationPendingURI`, `registrationSuccessURI`, `forgotPasswordPendingURI` → corresponding getters; `allowInitialPasswordSetWithoutStepUp`→`isAllowInitialPasswordSetWithoutStepUp()`. +- `HtmxAwareAuthenticationEntryPointConfiguration`: `loginPageURI`→`getLoginPageUri()`. +- `WebInterceptorConfig`: `changePasswordURI`→`getChangePasswordUri()`, `forgotPasswordChangeURI`→`getForgotPasswordChangeUri()`. + +- [ ] **Step 1: Apply the four migrations.** + +- [ ] **Step 2: Run the suites** + +Run: `./gradlew test --tests "*UserActionController*" --tests "*UserAPI*" --tests "*Htmx*" --tests "*WebInterceptor*"` +Expected: PASS. + +- [ ] **Step 3: Verify no `user.security.*` field injections remain** + +Run: `grep -rnE '@Value\("\$\{user\.security\.' src/main/java` +Expected: only annotation-placeholder usages on `@GetMapping`/`@RequestMapping`/`@ConditionalOnProperty` remain; zero injected-field `@Value`s. + +- [ ] **Step 4: Commit** + +```bash +git add src/main/java/com/digitalsanctuary/spring/user/controller/UserActionController.java \ + src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java \ + src/main/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointConfiguration.java \ + src/main/java/com/digitalsanctuary/spring/user/web/WebInterceptorConfig.java +git commit -m "refactor: inject UserSecurityConfigProperties into controller/web consumers" +``` + +--- + +### Task 10: Replace hand-maintained metadata with generated metadata + +**Files:** +- Modify: `src/main/resources/META-INF/additional-spring-configuration-metadata.json` (delete the migrated `user.security.*` entries) +- Test: `src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityMetadataCoverageTest.java` +- Test resource: `src/test/resources/metadata/legacy-user-security-keys.json` (copy of the deleted entries, retained for the coverage assertion) + +**Interfaces:** +- Consumes: the generated `META-INF/spring-configuration-metadata.json` produced by `spring-boot-configuration-processor` from Tasks 1-3. + +- [ ] **Step 1: Snapshot the keys being removed** + +Before editing, capture the property names that the new classes now own: + +Run: `grep -oE '"name": "user\.security\.[^"]+"' src/main/resources/META-INF/additional-spring-configuration-metadata.json` + +Save the exact list into `src/test/resources/metadata/legacy-user-security-keys.json` as a JSON array of the name strings. Exclude any `user.security.captcha.*` entries (Captcha is already `@ConfigurationProperties` and generates its own metadata) and any `user.security.mfa`/`webauthn` entries if present. + +- [ ] **Step 2: Write the failing coverage test** + +```java +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +@DisplayName("Generated metadata covers the retired hand-maintained user.security keys") +class UserSecurityMetadataCoverageTest { + + private static String canonical(String name) { + return name.toLowerCase().replace("-", ""); + } + + @Test + void generatedMetadataContainsEveryRetiredKey() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + List legacy = mapper.readValue( + new ClassPathResource("metadata/legacy-user-security-keys.json").getInputStream(), + mapper.getTypeFactory().constructCollectionType(List.class, String.class)); + + JsonNode generated = mapper.readTree( + new ClassPathResource("META-INF/spring-configuration-metadata.json").getInputStream()); + Set generatedNames = generated.get("properties").findValuesAsText("name").stream() + .map(UserSecurityMetadataCoverageTest::canonical).collect(Collectors.toSet()); + + assertThat(legacy.stream().map(UserSecurityMetadataCoverageTest::canonical)) + .allMatch(generatedNames::contains); + } +} +``` + +- [ ] **Step 3: Run to verify it fails** + +Run: `./gradlew test --tests "*UserSecurityMetadataCoverageTest*"` +Expected: FAIL initially if `spring-configuration-metadata.json` is stale — run `./gradlew classes` first to generate it. If a legacy key is genuinely absent from the generated file, add the missing field/JavaDoc to the relevant class in Tasks 1-3. + +- [ ] **Step 4: Delete the migrated entries from the additional-metadata file** + +Remove exactly the property entries whose `name` is in `legacy-user-security-keys.json`. Leave all `user.audit.*`, `user.registration.*`, `user.copyrightFirstYear`, `spring.*`, and `user.security.captcha.*` entries intact. + +- [ ] **Step 5: Rebuild metadata and run the test** + +Run: `./gradlew clean classes && ./gradlew test --tests "*UserSecurityMetadataCoverageTest*"` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/main/resources/META-INF/additional-spring-configuration-metadata.json \ + src/test/resources/metadata/legacy-user-security-keys.json \ + src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityMetadataCoverageTest.java +git commit -m "chore: generate user.security config metadata; drop hand-maintained entries" +``` + +--- + +### Task 11: Template view object + `@ControllerAdvice` + +**Files:** +- Create: `src/main/java/com/digitalsanctuary/spring/user/web/UserSecurityUriView.java` +- Create: `src/main/java/com/digitalsanctuary/spring/user/web/UserSecurityUriControllerAdvice.java` +- Modify: `security/UserSecurityBeansAutoConfiguration.java` (register the advice bean) OR annotate the advice as a `@ControllerAdvice` component picked up by scanning — match how `CaptchaSiteKeyControllerAdvice` is registered. +- Test: `src/test/java/com/digitalsanctuary/spring/user/web/UserSecurityUriControllerAdviceTest.java` + +**Interfaces:** +- Consumes: `UserSecurityConfigProperties`, and `user.copyrightFirstYear` (read via injected `@Value`, since it is outside `user.security`). +- Produces: model attribute `userSecurity` of type `UserSecurityUriView`. + +- [ ] **Step 1: Write the failing advice test** + +```java +package com.digitalsanctuary.spring.user.web; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; +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.stereotype.Controller; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; + +import com.digitalsanctuary.spring.user.security.UserSecurityConfigProperties; + +@DisplayName("UserSecurityUriControllerAdvice") +class UserSecurityUriControllerAdviceTest { + + @Controller + static class TestPageController { + @GetMapping("/user-security-advice-test-page") + public String page() { + return "test"; + } + } + + @Test + void shouldExposeUserSecurityViewWithUrisAndCopyrightYear() throws Exception { + UserSecurityConfigProperties props = new UserSecurityConfigProperties(); + UserSecurityUriControllerAdvice advice = new UserSecurityUriControllerAdvice(props, "2020"); + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TestPageController()) + .setControllerAdvice(advice).build(); + + mockMvc.perform(get("/user-security-advice-test-page")).andExpect(status().isOk()) + .andExpect(model().attributeExists("userSecurity")); + + UserSecurityUriView view = advice.userSecurity(); + assertThat(view.loginPageUri()).isEqualTo("/user/login.html"); + assertThat(view.copyrightFirstYear()).isEqualTo("2020"); + } + + @Test + void viewMustNotExposeTheTokenHashSecret() { + // The view is a fixed record of URIs + copyright; it has no accessor for secrets. + for (var component : UserSecurityUriView.class.getRecordComponents()) { + assertThat(component.getName()).doesNotContainIgnoringCase("secret"); + } + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `./gradlew test --tests "*UserSecurityUriControllerAdviceTest*"` +Expected: FAIL — types do not exist. + +- [ ] **Step 3: Create the view record** + +Fields: the page/action URIs consuming templates use, plus `copyrightFirstYear`. Include the full URI set so downstream templates have everything. + +```java +package com.digitalsanctuary.spring.user.web; + +/** + * Immutable, secret-free view of the {@code user.security.*} URIs (plus the copyright first year) for templates. + * Exposed as the {@code userSecurity} model attribute so views reference e.g. {@code ${userSecurity.loginPageUri}} + * instead of SpEL bean access, which Thymeleaf 3.1.5 forbids in restricted (layout-decorated) contexts. + */ +public record UserSecurityUriView(String loginPageUri, String loginActionUri, String loginSuccessUri, + String logoutActionUri, String logoutSuccessUri, String registrationUri, String registrationPendingUri, + String registrationSuccessUri, String registrationNewVerificationUri, String registrationConfirmUri, + String forgotPasswordUri, String forgotPasswordPendingUri, String forgotPasswordChangeUri, + String updateUserUri, String updatePasswordUri, String deleteAccountUri, String changePasswordUri, + String copyrightFirstYear) { +} +``` + +- [ ] **Step 4: Create the advice** + +```java +package com.digitalsanctuary.spring.user.web; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ModelAttribute; + +import com.digitalsanctuary.spring.user.security.UserSecurityConfigProperties; + +/** + * Exposes {@link UserSecurityUriView} as the {@code userSecurity} model attribute on every {@code @Controller} + * request, so consuming templates read framework URIs without SpEL bean access. Registered by default; opt out + * with {@code user.security.expose-uris-to-model=false}. {@code userSecurity} is a reserved model-attribute name. + */ +@ConditionalOnProperty(name = "user.security.expose-uris-to-model", havingValue = "true", matchIfMissing = true) +@ControllerAdvice(annotations = Controller.class) +public class UserSecurityUriControllerAdvice { + + private final UserSecurityConfigProperties config; + private final String copyrightFirstYear; + + public UserSecurityUriControllerAdvice(UserSecurityConfigProperties config, + @Value("${user.copyrightFirstYear:}") String copyrightFirstYear) { + this.config = config; + this.copyrightFirstYear = copyrightFirstYear; + } + + /** + * @return the immutable URI view exposed to templates as {@code userSecurity} + */ + @ModelAttribute("userSecurity") + public UserSecurityUriView userSecurity() { + return new UserSecurityUriView(config.getLoginPageUri(), config.getLoginActionUri(), + config.getLoginSuccessUri(), config.getLogoutActionUri(), config.getLogoutSuccessUri(), + config.getRegistrationUri(), config.getRegistrationPendingUri(), config.getRegistrationSuccessUri(), + config.getRegistrationNewVerificationUri(), config.getRegistrationConfirmUri(), + config.getForgotPasswordUri(), config.getForgotPasswordPendingUri(), + config.getForgotPasswordChangeUri(), config.getUpdateUserUri(), config.getUpdatePasswordUri(), + config.getDeleteAccountUri(), config.getChangePasswordUri(), copyrightFirstYear); + } +} +``` + +- [ ] **Step 5: Register the advice** the same way `CaptchaSiteKeyControllerAdvice` is registered (component scan or an explicit `@Bean` in the auto-configuration). Verify by checking how the captcha advice becomes a bean and mirror it. Add the `expose-uris-to-model` key to `additional-spring-configuration-metadata.json` with a description (it has no field on a CP class). + +- [ ] **Step 6: Run to verify it passes** + +Run: `./gradlew test --tests "*UserSecurityUriControllerAdviceTest*"` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/java/com/digitalsanctuary/spring/user/web/UserSecurityUriView.java \ + src/main/java/com/digitalsanctuary/spring/user/web/UserSecurityUriControllerAdvice.java \ + src/main/resources/META-INF/additional-spring-configuration-metadata.json \ + src/test/java/com/digitalsanctuary/spring/user/web/UserSecurityUriControllerAdviceTest.java +git commit -m "feat: expose secret-free UserSecurityUriView to templates as \${userSecurity}" +``` + +--- + +### Task 12: Placeholder/bean parity guard + docs + +**Files:** +- Test: `src/test/java/com/digitalsanctuary/spring/user/security/UriPlaceholderParityTest.java` +- Modify: `CONFIG.md`, `CHANGELOG.md`, `MIGRATION.md` + +**Interfaces:** +- Consumes: `UserSecurityConfigProperties`, the running framework context. + +- [ ] **Step 1: Write the parity test** + +The 14 `@GetMapping`/`@RequestMapping` placeholders resolve the exact camelCase key; the generated metadata advertises kebab. This test fails fast if the effective Environment value for a mapping-relevant key ever diverges from the bean getter (e.g. a consumer sets only the kebab spelling). + +```java +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +@DisplayName("Mapping-placeholder keys stay in sync with the bound bean") +class UriPlaceholderParityTest { + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(UserSecurityConfigProperties.class) + static class TestConfig { + } + + @Test + void placeholderKeyValuesEqualBeanGetters() { + // Keys used as @GetMapping/@RequestMapping/@ConditionalOnProperty placeholders elsewhere in the framework. + Map> mappingKeys = Map.of( + "user.security.loginPageURI", UserSecurityConfigProperties::getLoginPageUri, + "user.security.registrationURI", UserSecurityConfigProperties::getRegistrationUri, + "user.security.changePasswordURI", UserSecurityConfigProperties::getChangePasswordUri, + "user.security.forgotPasswordChangeURI", UserSecurityConfigProperties::getForgotPasswordChangeUri, + "user.security.registrationConfirmURI", UserSecurityConfigProperties::getRegistrationConfirmUri); + + new ApplicationContextRunner().withUserConfiguration(TestConfig.class) + .withPropertyValues("user.security.loginPageURI=/user/login.html", + "user.security.registrationURI=/user/register.html", + "user.security.changePasswordURI=/user/changePassword", + "user.security.forgotPasswordChangeURI=/user/forgot-password-change.html", + "user.security.registrationConfirmURI=/user/registrationConfirm") + .run(context -> { + Environment env = context.getEnvironment(); + UserSecurityConfigProperties bean = context.getBean(UserSecurityConfigProperties.class); + mappingKeys.forEach((key, getter) -> assertThat(getter.apply(bean)) + .as("bean value for %s must equal the placeholder-resolved Environment value", key) + .isEqualTo(env.getProperty(key))); + }); + } +} +``` + +- [ ] **Step 2: Run the test** + +Run: `./gradlew test --tests "*UriPlaceholderParityTest*"` +Expected: PASS. + +- [ ] **Step 3: Update docs** + +- `CONFIG.md`: add a short note that `user.security.*` is now typed configuration and that the **camelCase key spellings are canonical** (kebab is accepted via relaxed binding but the request-mapping placeholders resolve camelCase); mention the `${userSecurity}` model attribute and the `user.security.expose-uris-to-model` opt-out. +- `CHANGELOG.md`: add an entry under the unreleased section — internal refactor to typed `@ConfigurationProperties`; keys unchanged; new `${userSecurity}` model attribute; note that `WebSecurityConfig`'s URI getters are preserved as delegates. +- `MIGRATION.md`: add a "no action required" note — keys unchanged; new template attribute available; consumers using `${@environment.getProperty('user.security.*')}` in templates can switch to `${userSecurity.*}` (required on Spring Boot 4.1.0+ where Thymeleaf 3.1.5 rejects the bean-access form in layout-decorated templates). + +- [ ] **Step 4: Commit** + +```bash +git add src/test/java/com/digitalsanctuary/spring/user/security/UriPlaceholderParityTest.java CONFIG.md CHANGELOG.md MIGRATION.md +git commit -m "test: guard uri placeholder/bean parity; docs: typed user.security config" +``` + +--- + +### Task 13: Full verification + +- [ ] **Step 1: Full build + all tests** + +Run: `./gradlew clean build` +Expected: BUILD SUCCESSFUL, all tests pass (incl. ArchUnit, integration, security). + +- [ ] **Step 2: Confirm no injected `user.security` `@Value` fields remain** + +Run: `grep -rnE '@Value\("\$\{user\.security\.' src/main/java` +Expected: only `@GetMapping`/`@RequestMapping`/`@ConditionalOnProperty`/`@Value` annotation-placeholder usages that intentionally read the Environment; no injected-field `@Value`s on service/config classes. + +- [ ] **Step 3: Confirm the shipped defaults file is untouched** + +Run: `git diff --stat main -- src/main/resources/config/dsspringuserconfig.properties` +Expected: no output (file unchanged). + +## Self-Review Notes + +- **Spec coverage:** §4.1 classes → Tasks 1-3; §4.2 enablement → Task 4; §4.3 migration → Tasks 5-9; §4.4 defaults/metadata → Tasks 4 & 10; §4.5 template access → Task 11; §5 list filtering → Task 3; §6 effective defaults → Tasks 3-4; §7 parity guard → Task 12; §8 tests → per-task + Task 13; §9 docs → Task 12; §10 sequencing → out of scope (demo follow-up). +- **`historyCount` double-read** (UserService + PasswordPolicyService) both handled in Task 5. +- **Getter preservation** for `WebSecurityConfig` in Task 6 Step 3 avoids the `@Data` API break Fable flagged. From 6a62a0b36c62a51f90fd460d62bf9c807ba3f118 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 13 Aug 2026 21:44:03 -0600 Subject: [PATCH 04/24] feat: add PasswordPolicyConfigProperties bound to user.security.password.* --- .../PasswordPolicyConfigProperties.java | 50 +++++++++++++++++++ .../PasswordPolicyConfigPropertiesTest.java | 45 +++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/security/PasswordPolicyConfigProperties.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/security/PasswordPolicyConfigPropertiesTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/PasswordPolicyConfigProperties.java b/src/main/java/com/digitalsanctuary/spring/user/security/PasswordPolicyConfigProperties.java new file mode 100644 index 0000000..597127b --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/security/PasswordPolicyConfigProperties.java @@ -0,0 +1,50 @@ +package com.digitalsanctuary.spring.user.security; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import lombok.Data; + +/** + * Configuration properties for the password policy enforced by + * {@link com.digitalsanctuary.spring.user.service.PasswordPolicyService}. + * + *

Bound from {@code user.security.password.*}. Defaults mirror the shipped + * {@code config/dsspringuserconfig.properties} values.

+ */ +@Data +@ConfigurationProperties(prefix = "user.security.password") +public class PasswordPolicyConfigProperties { + + /** Whether password-policy enforcement is active. */ + private boolean enabled = true; + + /** Minimum password length. */ + private int minLength = 8; + + /** Maximum password length. */ + private int maxLength = 128; + + /** Whether at least one uppercase character is required. */ + private boolean requireUppercase = true; + + /** Whether at least one lowercase character is required. */ + private boolean requireLowercase = true; + + /** Whether at least one digit is required. */ + private boolean requireDigit = true; + + /** Whether at least one special character is required. */ + private boolean requireSpecial = true; + + /** The set of characters treated as "special". */ + private String specialChars = "~`!@#$%^&*()_-+={}[]|\\:;\"'<>,.?/"; + + /** Whether passwords are checked against the common-passwords dictionary. */ + private boolean preventCommonPasswords = true; + + /** Number of previous passwords retained and rejected on reuse. */ + private int historyCount = 3; + + /** Levenshtein similarity threshold (0-100) against username/email. */ + private int similarityThreshold = 70; +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/PasswordPolicyConfigPropertiesTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/PasswordPolicyConfigPropertiesTest.java new file mode 100644 index 0000000..2e528df --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/PasswordPolicyConfigPropertiesTest.java @@ -0,0 +1,45 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; + +@DisplayName("PasswordPolicyConfigProperties binding") +class PasswordPolicyConfigPropertiesTest { + + private final ApplicationContextRunner contextRunner = + new ApplicationContextRunner().withUserConfiguration(TestConfig.class); + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(PasswordPolicyConfigProperties.class) + static class TestConfig { + } + + @Test + void shouldApplyShippedDefaultsWhenUnset() { + contextRunner.run(context -> { + PasswordPolicyConfigProperties p = context.getBean(PasswordPolicyConfigProperties.class); + assertThat(p.isEnabled()).isTrue(); + assertThat(p.getMinLength()).isEqualTo(8); + assertThat(p.getMaxLength()).isEqualTo(128); + assertThat(p.getHistoryCount()).isEqualTo(3); + assertThat(p.getSimilarityThreshold()).isEqualTo(70); + }); + } + + @Test + void shouldBindKebabKeysWhenConfigured() { + contextRunner.withPropertyValues("user.security.password.min-length=12", + "user.security.password.require-special=false", + "user.security.password.history-count=5").run(context -> { + PasswordPolicyConfigProperties p = context.getBean(PasswordPolicyConfigProperties.class); + assertThat(p.getMinLength()).isEqualTo(12); + assertThat(p.isRequireSpecial()).isFalse(); + assertThat(p.getHistoryCount()).isEqualTo(5); + }); + } +} From 697e40ed872e9e4d6e4d65544f9461165a807b49 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 13 Aug 2026 21:46:57 -0600 Subject: [PATCH 05/24] feat: add RememberMeConfigProperties bound to user.security.remember-me.* --- .../security/RememberMeConfigProperties.java | 45 ++++++++++++++++++ .../RememberMeConfigPropertiesTest.java | 47 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/security/RememberMeConfigProperties.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/security/RememberMeConfigPropertiesTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/RememberMeConfigProperties.java b/src/main/java/com/digitalsanctuary/spring/user/security/RememberMeConfigProperties.java new file mode 100644 index 0000000..c11b58b --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/security/RememberMeConfigProperties.java @@ -0,0 +1,45 @@ +package com.digitalsanctuary.spring.user.security; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import lombok.Data; +import lombok.ToString; + +/** + * Configuration properties for Spring Security "remember-me". Bound from + * {@code user.security.remember-me.*} (relaxed binding also accepts the legacy + * {@code user.security.rememberMe.*} spelling). Defaults mirror the shipped + * {@code config/dsspringuserconfig.properties} values. + */ +@Data +@ConfigurationProperties(prefix = "user.security.remember-me") +public class RememberMeConfigProperties { + + /** Whether remember-me is enabled. */ + private boolean enabled = false; + + /** + * The remember-me signing key. Excluded from {@code toString} so the secret never leaks through bean logging. + * When null, Spring Security generates an ephemeral key at startup. + */ + @ToString.Exclude + private String key; + + /** Token validity in seconds. */ + private int tokenValiditySeconds = 1209600; + + /** Name of the remember-me request parameter. */ + private String rememberMeParameter = "remember-me"; + + /** Name of the remember-me cookie. */ + private String rememberMeCookieName = "remember-me"; + + /** + * Whether the remember-me cookie is marked {@code Secure}. Left null (unset) by default so Spring Security's own + * behavior applies: the cookie is secure whenever the request that created it was made over HTTPS. + */ + private Boolean useSecureCookie; + + /** Whether persistent (database-backed) remember-me tokens are used instead of the hash-based scheme. */ + private boolean usePersistentTokens = false; +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/RememberMeConfigPropertiesTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/RememberMeConfigPropertiesTest.java new file mode 100644 index 0000000..800fce5 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/RememberMeConfigPropertiesTest.java @@ -0,0 +1,47 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; + +@DisplayName("RememberMeConfigProperties binding") +class RememberMeConfigPropertiesTest { + + private final ApplicationContextRunner contextRunner = + new ApplicationContextRunner().withUserConfiguration(TestConfig.class); + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(RememberMeConfigProperties.class) + static class TestConfig { + } + + @Test + void shouldApplyDefaultsWhenUnset() { + contextRunner.run(context -> { + RememberMeConfigProperties p = context.getBean(RememberMeConfigProperties.class); + assertThat(p.isEnabled()).isFalse(); + assertThat(p.getKey()).isNull(); + assertThat(p.getTokenValiditySeconds()).isEqualTo(1209600); + assertThat(p.getRememberMeParameter()).isEqualTo("remember-me"); + assertThat(p.getRememberMeCookieName()).isEqualTo("remember-me"); + assertThat(p.getUseSecureCookie()).isNull(); + assertThat(p.isUsePersistentTokens()).isFalse(); + }); + } + + @Test + void shouldBindLegacyCamelCaseKeysViaRelaxedBinding() { + contextRunner.withPropertyValues("user.security.rememberMe.enabled=true", + "user.security.rememberMe.tokenValiditySeconds=60", + "user.security.rememberMe.useSecureCookie=true").run(context -> { + RememberMeConfigProperties p = context.getBean(RememberMeConfigProperties.class); + assertThat(p.isEnabled()).isTrue(); + assertThat(p.getTokenValiditySeconds()).isEqualTo(60); + assertThat(p.getUseSecureCookie()).isTrue(); + }); + } +} From 0d1d77bf6c158c1bc4ed123d0ce4702a00d7ccf1 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 13 Aug 2026 21:50:01 -0600 Subject: [PATCH 06/24] test: rename remember-me relaxed-binding test to follow should...When convention --- .../spring/user/security/RememberMeConfigPropertiesTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/RememberMeConfigPropertiesTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/RememberMeConfigPropertiesTest.java index 800fce5..b0f5141 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/RememberMeConfigPropertiesTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/RememberMeConfigPropertiesTest.java @@ -34,7 +34,7 @@ void shouldApplyDefaultsWhenUnset() { } @Test - void shouldBindLegacyCamelCaseKeysViaRelaxedBinding() { + void shouldBindLegacyCamelCaseKeysWhenRelaxedBindingApplies() { contextRunner.withPropertyValues("user.security.rememberMe.enabled=true", "user.security.rememberMe.tokenValiditySeconds=60", "user.security.rememberMe.useSecureCookie=true").run(context -> { From 78199ab7920b0e3b74e68cc6da9a47f7692a7d80 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 13 Aug 2026 21:52:22 -0600 Subject: [PATCH 07/24] feat: add UserSecurityConfigProperties bound to flat user.security.* keys --- .../UserSecurityConfigProperties.java | 89 +++++++++++++++++++ .../UserSecurityConfigPropertiesTest.java | 57 ++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigProperties.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigPropertiesTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigProperties.java b/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigProperties.java new file mode 100644 index 0000000..7f76909 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigProperties.java @@ -0,0 +1,89 @@ +package com.digitalsanctuary.spring.user.security; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import lombok.Data; +import lombok.ToString; + +/** + * Configuration properties for the flat {@code user.security.*} namespace: page/action URIs, URI lists, and + * security scalars. Password policy and remember-me live in their own classes + * ({@link PasswordPolicyConfigProperties}, {@link RememberMeConfigProperties}). + * + *

Defaults mirror the shipped {@code config/dsspringuserconfig.properties}. The camelCase key spellings + * (e.g. {@code user.security.loginPageURI}) are canonical; relaxed binding also accepts kebab-case.

+ */ +@Data +@ConfigurationProperties(prefix = "user.security") +public class UserSecurityConfigProperties { + + // --- Access control --- + /** Default filter-chain action for URIs not otherwise matched: {@code deny} or {@code allow}. */ + private String defaultAction = "deny"; + private List protectedUris = new ArrayList<>(); + private List unprotectedUris = new ArrayList<>(); + private List disableCsrfUris = new ArrayList<>(); + + // --- Page / action URIs --- + private String loginPageUri = "/user/login.html"; + private String loginActionUri = "/user/login"; + private String loginSuccessUri = "/index.html?messageKey=message.login.success"; + private String logoutActionUri = "/user/logout"; + private String logoutSuccessUri = "/index.html?messageKey=message.logout.success"; + private boolean alwaysUseDefaultTargetUrl = false; + private String registrationUri = "/user/register.html"; + private String registrationPendingUri = "/user/registration-pending-verification.html"; + private String registrationSuccessUri = "/user/registration-complete.html"; + private String registrationNewVerificationUri = "/user/request-new-verification-email.html"; + private String registrationConfirmUri = "/user/registrationConfirm"; + private String forgotPasswordUri = "/user/forgot-password.html"; + private String forgotPasswordPendingUri = "/user/forgot-password-pending-verification.html"; + private String forgotPasswordChangeUri = "/user/forgot-password-change.html"; + private String updateUserUri = "/user/update-user.html"; + private String updatePasswordUri = "/user/update-password.html"; + private String deleteAccountUri = "/user/delete-account.html"; + private String changePasswordUri = "/user/changePassword"; + + // --- Security scalars --- + private int bcryptStrength = 12; + private int failedLoginAttempts = 10; + private int accountLockoutDuration = 30; + private int passwordResetTokenValidityMinutes = 1440; + private boolean requireCanonicalAppUrl = false; + private boolean testHashTime = true; + private boolean allowInitialPasswordSetWithoutStepUp = false; + private String appUrl = ""; + private List trustedHosts = new ArrayList<>(); + + /** HMAC secret used to hash password-reset tokens at rest. Excluded from {@code toString}. */ + @ToString.Exclude + private String tokenHashSecret; + + public List getProtectedUris() { + return filterBlank(protectedUris); + } + + public List getUnprotectedUris() { + return filterBlank(unprotectedUris); + } + + public List getDisableCsrfUris() { + return filterBlank(disableCsrfUris); + } + + private static List filterBlank(List values) { + if (values == null) { + return new ArrayList<>(); + } + List filtered = new ArrayList<>(values.size()); + for (String value : values) { + if (value != null && !value.isBlank()) { + filtered.add(value.trim()); + } + } + return filtered; + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigPropertiesTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigPropertiesTest.java new file mode 100644 index 0000000..cfc58f3 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigPropertiesTest.java @@ -0,0 +1,57 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; + +@DisplayName("UserSecurityConfigProperties binding") +class UserSecurityConfigPropertiesTest { + + private final ApplicationContextRunner contextRunner = + new ApplicationContextRunner().withUserConfiguration(TestConfig.class); + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(UserSecurityConfigProperties.class) + static class TestConfig { + } + + @Test + void shouldApplyShippedDefaultsWhenUnset() { + contextRunner.run(context -> { + UserSecurityConfigProperties p = context.getBean(UserSecurityConfigProperties.class); + assertThat(p.getLoginPageUri()).isEqualTo("/user/login.html"); + assertThat(p.getRegistrationUri()).isEqualTo("/user/register.html"); + assertThat(p.getDefaultAction()).isEqualTo("deny"); + assertThat(p.getBcryptStrength()).isEqualTo(12); + assertThat(p.getAppUrl()).isEqualTo(""); + assertThat(p.getTokenHashSecret()).isNull(); + }); + } + + @Test + void shouldBindLegacyCamelCaseUriKeys() { + contextRunner.withPropertyValues("user.security.loginPageURI=/custom/login").run(context -> { + assertThat(context.getBean(UserSecurityConfigProperties.class).getLoginPageUri()) + .isEqualTo("/custom/login"); + }); + } + + @Test + void shouldDropBlankSegmentsFromUriLists() { + contextRunner.withPropertyValues("user.security.unprotectedURIs=/a,,/b,").run(context -> { + assertThat(context.getBean(UserSecurityConfigProperties.class).getUnprotectedUris()) + .containsExactly("/a", "/b"); + }); + } + + @Test + void shouldReturnEmptyListForBlankUriListProperty() { + contextRunner.withPropertyValues("user.security.disableCSRFURIs=").run(context -> { + assertThat(context.getBean(UserSecurityConfigProperties.class).getDisableCsrfUris()).isEmpty(); + }); + } +} From eb877196b8e22aa9159658661c078da290043fcf Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 13 Aug 2026 21:58:44 -0600 Subject: [PATCH 08/24] fix: populate default URI lists and add JavaDoc to all fields in UserSecurityConfigProperties --- .../UserSecurityConfigProperties.java | 41 ++++++++++++++++--- .../UserSecurityConfigPropertiesTest.java | 11 +++-- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigProperties.java b/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigProperties.java index 7f76909..ca62fc7 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigProperties.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigProperties.java @@ -20,42 +20,73 @@ @ConfigurationProperties(prefix = "user.security") public class UserSecurityConfigProperties { - // --- Access control --- /** Default filter-chain action for URIs not otherwise matched: {@code deny} or {@code allow}. */ private String defaultAction = "deny"; - private List protectedUris = new ArrayList<>(); - private List unprotectedUris = new ArrayList<>(); + /** Comma-delimited URIs protected by Spring Security when defaultAction is allow. */ + private List protectedUris = new ArrayList<>(List.of("/protected.html")); + /** Comma-delimited URIs not protected by Spring Security when defaultAction is deny. */ + private List unprotectedUris = + new ArrayList<>(List.of("/", "/index.html", "/favicon.ico", "/css/*", "/js/*", "/img/*", + "/user/registration", "/user/resendRegistrationToken", "/user/resetPassword", + "/user/registrationConfirm", "/user/changePassword", "/user/savePassword", + "/oauth2/authorization/*", "/login", "/error")); + /** Comma-delimited URIs exempt from CSRF protection. Empty by default. */ private List disableCsrfUris = new ArrayList<>(); - // --- Page / action URIs --- + /** The URI for the login page. */ private String loginPageUri = "/user/login.html"; + /** The URI for the login action. */ private String loginActionUri = "/user/login"; + /** The URI for the login success page. */ private String loginSuccessUri = "/index.html?messageKey=message.login.success"; + /** The URI for the logout action. */ private String logoutActionUri = "/user/logout"; + /** The URI for the logout success page. */ private String logoutSuccessUri = "/index.html?messageKey=message.logout.success"; + /** Whether to always redirect to loginSuccessUri or use saved requests (default: false for better UX). */ private boolean alwaysUseDefaultTargetUrl = false; + /** The URI for the registration page. */ private String registrationUri = "/user/register.html"; + /** The URI for the registration pending verification page. */ private String registrationPendingUri = "/user/registration-pending-verification.html"; + /** The URI for the registration success page. */ private String registrationSuccessUri = "/user/registration-complete.html"; + /** The URI for the request new verification email page. */ private String registrationNewVerificationUri = "/user/request-new-verification-email.html"; + /** The URI for the registration confirm page. */ private String registrationConfirmUri = "/user/registrationConfirm"; + /** The URI for the forgot password page. */ private String forgotPasswordUri = "/user/forgot-password.html"; + /** The URI for the forgot password pending verification page. */ private String forgotPasswordPendingUri = "/user/forgot-password-pending-verification.html"; + /** The URI for the forgot password change page. */ private String forgotPasswordChangeUri = "/user/forgot-password-change.html"; + /** The URI for the update user page. */ private String updateUserUri = "/user/update-user.html"; + /** The URI for the update password page. */ private String updatePasswordUri = "/user/update-password.html"; + /** The URI for the delete account page. */ private String deleteAccountUri = "/user/delete-account.html"; + /** The URI for the change password action. */ private String changePasswordUri = "/user/changePassword"; - // --- Security scalars --- + /** Password hash strength (bcrypt log rounds). */ private int bcryptStrength = 12; + /** Maximum failed login attempts before account lockout. */ private int failedLoginAttempts = 10; + /** Account lockout duration in minutes. */ private int accountLockoutDuration = 30; + /** Password reset token validity duration in minutes. */ private int passwordResetTokenValidityMinutes = 1440; + /** Whether to require canonical app URL for redirect validation. */ private boolean requireCanonicalAppUrl = false; + /** Whether to perform hash time tests during startup. */ private boolean testHashTime = true; + /** Whether to allow initial password set without step-up authentication. */ private boolean allowInitialPasswordSetWithoutStepUp = false; + /** Base application URL for redirect validation and email links. */ private String appUrl = ""; + /** List of trusted hosts for redirect validation. */ private List trustedHosts = new ArrayList<>(); /** HMAC secret used to hash password-reset tokens at rest. Excluded from {@code toString}. */ diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigPropertiesTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigPropertiesTest.java index cfc58f3..d341db9 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigPropertiesTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigPropertiesTest.java @@ -29,11 +29,16 @@ void shouldApplyShippedDefaultsWhenUnset() { assertThat(p.getBcryptStrength()).isEqualTo(12); assertThat(p.getAppUrl()).isEqualTo(""); assertThat(p.getTokenHashSecret()).isNull(); + assertThat(p.getProtectedUris()).containsExactly("/protected.html"); + assertThat(p.getUnprotectedUris()).containsExactly("/", "/index.html", "/favicon.ico", "/css/*", + "/js/*", "/img/*", "/user/registration", "/user/resendRegistrationToken", + "/user/resetPassword", "/user/registrationConfirm", "/user/changePassword", + "/user/savePassword", "/oauth2/authorization/*", "/login", "/error"); }); } @Test - void shouldBindLegacyCamelCaseUriKeys() { + void shouldBindLegacyCamelCaseUriKeysWhenSetWithOldSpelling() { contextRunner.withPropertyValues("user.security.loginPageURI=/custom/login").run(context -> { assertThat(context.getBean(UserSecurityConfigProperties.class).getLoginPageUri()) .isEqualTo("/custom/login"); @@ -41,7 +46,7 @@ void shouldBindLegacyCamelCaseUriKeys() { } @Test - void shouldDropBlankSegmentsFromUriLists() { + void shouldDropBlankSegmentsWhenBindingUriLists() { contextRunner.withPropertyValues("user.security.unprotectedURIs=/a,,/b,").run(context -> { assertThat(context.getBean(UserSecurityConfigProperties.class).getUnprotectedUris()) .containsExactly("/a", "/b"); @@ -49,7 +54,7 @@ void shouldDropBlankSegmentsFromUriLists() { } @Test - void shouldReturnEmptyListForBlankUriListProperty() { + void shouldReturnEmptyListWhenUriListPropertyBlank() { contextRunner.withPropertyValues("user.security.disableCSRFURIs=").run(context -> { assertThat(context.getBean(UserSecurityConfigProperties.class).getDisableCsrfUris()).isEmpty(); }); From 58553bf282e0791886039fe8e4a0bb99fba14ad5 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 13 Aug 2026 22:01:43 -0600 Subject: [PATCH 09/24] feat: register user.security config properties beans; add defaults parity test --- .../UserSecurityBeansAutoConfiguration.java | 3 + .../UserSecurityDefaultsParityTest.java | 59 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityDefaultsParityTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java index 852705c..5e4bfdc 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java @@ -7,6 +7,7 @@ import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; @@ -68,6 +69,8 @@ */ @Slf4j @AutoConfiguration(after = UserConfiguration.class) +@EnableConfigurationProperties({UserSecurityConfigProperties.class, PasswordPolicyConfigProperties.class, + RememberMeConfigProperties.class}) @RequiredArgsConstructor public class UserSecurityBeansAutoConfiguration { diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityDefaultsParityTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityDefaultsParityTest.java new file mode 100644 index 0000000..dd06012 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityDefaultsParityTest.java @@ -0,0 +1,59 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Properties; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.support.ResourcePropertySource; +import org.springframework.mock.env.MockEnvironment; + +@DisplayName("user.security defaults parity with shipped properties file") +class UserSecurityDefaultsParityTest { + + private Properties shipped() throws Exception { + ResourcePropertySource source = + new ResourcePropertySource(new ClassPathResource("config/dsspringuserconfig.properties")); + Properties props = new Properties(); + source.getSource().forEach(props::put); + return props; + } + + @Test + void flatFieldInitializersMatchShippedFile() throws Exception { + Properties p = shipped(); + UserSecurityConfigProperties bean = new UserSecurityConfigProperties(); + assertThat(bean.getLoginPageUri()).isEqualTo(p.getProperty("user.security.loginPageURI")); + assertThat(bean.getRegistrationUri()).isEqualTo(p.getProperty("user.security.registrationURI")); + assertThat(bean.getChangePasswordUri()).isEqualTo(p.getProperty("user.security.changePasswordURI")); + assertThat(bean.getDefaultAction()).isEqualTo(p.getProperty("user.security.defaultAction")); + assertThat(String.valueOf(bean.getBcryptStrength())).isEqualTo(p.getProperty("user.security.bcryptStrength")); + assertThat(String.valueOf(bean.getFailedLoginAttempts())) + .isEqualTo(p.getProperty("user.security.failedLoginAttempts")); + } + + @Test + void passwordFieldInitializersMatchShippedFile() throws Exception { + Properties p = shipped(); + PasswordPolicyConfigProperties bean = new PasswordPolicyConfigProperties(); + assertThat(String.valueOf(bean.getMinLength())).isEqualTo(p.getProperty("user.security.password.min-length")); + assertThat(String.valueOf(bean.getHistoryCount())) + .isEqualTo(p.getProperty("user.security.password.history-count")); + assertThat(String.valueOf(bean.getSimilarityThreshold())) + .isEqualTo(p.getProperty("user.security.password.similarity-threshold")); + } + + @Test + void bindingTheShippedFileYieldsTheSameValuesAsTheInitializers() throws Exception { + MockEnvironment env = new MockEnvironment(); + new ResourcePropertySource(new ClassPathResource("config/dsspringuserconfig.properties")).getSource() + .forEach((k, v) -> env.setProperty(k, String.valueOf(v))); + UserSecurityConfigProperties bound = Binder.get(env) + .bind("user.security", UserSecurityConfigProperties.class).get(); + assertThat(bound.getLoginPageUri()).isEqualTo(new UserSecurityConfigProperties().getLoginPageUri()); + assertThat(bound.getBcryptStrength()).isEqualTo(new UserSecurityConfigProperties().getBcryptStrength()); + } +} From 83ac2e75301f2197534adc0d12c24d121279d056 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 13 Aug 2026 22:04:36 -0600 Subject: [PATCH 10/24] test: rename parity tests to should/when convention; add rememberMe parity coverage --- .../UserSecurityDefaultsParityTest.java | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityDefaultsParityTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityDefaultsParityTest.java index dd06012..237b884 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityDefaultsParityTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityDefaultsParityTest.java @@ -23,7 +23,7 @@ private Properties shipped() throws Exception { } @Test - void flatFieldInitializersMatchShippedFile() throws Exception { + void shouldMatchShippedFileWhenBindingFlatFields() throws Exception { Properties p = shipped(); UserSecurityConfigProperties bean = new UserSecurityConfigProperties(); assertThat(bean.getLoginPageUri()).isEqualTo(p.getProperty("user.security.loginPageURI")); @@ -36,7 +36,7 @@ void flatFieldInitializersMatchShippedFile() throws Exception { } @Test - void passwordFieldInitializersMatchShippedFile() throws Exception { + void shouldMatchShippedFileWhenBindingPasswordFields() throws Exception { Properties p = shipped(); PasswordPolicyConfigProperties bean = new PasswordPolicyConfigProperties(); assertThat(String.valueOf(bean.getMinLength())).isEqualTo(p.getProperty("user.security.password.min-length")); @@ -47,7 +47,7 @@ void passwordFieldInitializersMatchShippedFile() throws Exception { } @Test - void bindingTheShippedFileYieldsTheSameValuesAsTheInitializers() throws Exception { + void shouldEqualInitializersWhenBindingShippedFile() throws Exception { MockEnvironment env = new MockEnvironment(); new ResourcePropertySource(new ClassPathResource("config/dsspringuserconfig.properties")).getSource() .forEach((k, v) -> env.setProperty(k, String.valueOf(v))); @@ -56,4 +56,17 @@ void bindingTheShippedFileYieldsTheSameValuesAsTheInitializers() throws Exceptio assertThat(bound.getLoginPageUri()).isEqualTo(new UserSecurityConfigProperties().getLoginPageUri()); assertThat(bound.getBcryptStrength()).isEqualTo(new UserSecurityConfigProperties().getBcryptStrength()); } + + @Test + void shouldMatchShippedFileWhenBindingRememberMeFields() throws Exception { + Properties p = shipped(); + RememberMeConfigProperties bean = new RememberMeConfigProperties(); + assertThat(String.valueOf(bean.isEnabled())).isEqualTo(p.getProperty("user.security.rememberMe.enabled")); + assertThat(String.valueOf(bean.getTokenValiditySeconds())) + .isEqualTo(p.getProperty("user.security.rememberMe.tokenValiditySeconds")); + assertThat(bean.getRememberMeParameter()).isEqualTo(p.getProperty("user.security.rememberMe.rememberMeParameter")); + assertThat(bean.getRememberMeCookieName()).isEqualTo(p.getProperty("user.security.rememberMe.rememberMeCookieName")); + assertThat(String.valueOf(bean.isUsePersistentTokens())) + .isEqualTo(p.getProperty("user.security.rememberMe.usePersistentTokens")); + } } From 2ca92a65d9602ce7b8133fee5660223fcf854ca3 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 13 Aug 2026 22:10:42 -0600 Subject: [PATCH 11/24] refactor: inject PasswordPolicyConfigProperties into password consumers Update tests that construct PasswordPolicyService/UserService directly (constructor args, @Mock fields) for the new constructor parameter. --- .../user/service/PasswordPolicyService.java | 63 +++------ .../spring/user/service/UserService.java | 10 +- .../service/PasswordPolicyServiceTest.java | 126 +++++++++--------- .../service/TokenHashingSecurityTest.java | 2 +- .../UserServiceRegistrationGuardTest.java | 3 + .../spring/user/service/UserServiceTest.java | 3 + 6 files changed, 93 insertions(+), 114 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/PasswordPolicyService.java b/src/main/java/com/digitalsanctuary/spring/user/service/PasswordPolicyService.java index a62362e..78bf0fd 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/PasswordPolicyService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/PasswordPolicyService.java @@ -28,6 +28,7 @@ import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.persistence.repository.PasswordHistoryRepository; +import com.digitalsanctuary.spring.user.security.PasswordPolicyConfigProperties; import jakarta.annotation.PostConstruct; @@ -57,39 +58,6 @@ @Service("dsPasswordPolicyService") public class PasswordPolicyService { - @Value("${user.security.password.enabled}") - private boolean enabled; - - @Value("${user.security.password.min-length}") - private int minLength; - - @Value("${user.security.password.max-length}") - private int maxLength; - - @Value("${user.security.password.require-uppercase}") - private boolean requireUppercase; - - @Value("${user.security.password.require-lowercase}") - private boolean requireLowercase; - - @Value("${user.security.password.require-digit}") - private boolean requireDigit; - - @Value("${user.security.password.require-special}") - private boolean requireSpecial; - - @Value("${user.security.password.special-chars}") - private String specialChars; - - @Value("${user.security.password.prevent-common-passwords}") - private boolean preventCommonPasswords; - - @Value("${user.security.password.history-count}") - private int historyCount; - - @Value("${user.security.password.similarity-threshold}") - private int similarityThreshold; - @Value("classpath:common_passwords.txt") private Resource commonPasswordsResource; @@ -98,10 +66,11 @@ public class PasswordPolicyService { private final PasswordHistoryRepository passwordHistoryRepository; private final PasswordEncoder passwordEncoder; private final MessageSource messages; + private final PasswordPolicyConfigProperties passwordPolicy; @PostConstruct private void initCommonPasswords() { - if (preventCommonPasswords) { + if (passwordPolicy.isPreventCommonPasswords()) { log.debug("Initializing common passwords dictionary from file"); try ( Reader reader = new BufferedReader( @@ -135,7 +104,7 @@ private void initCommonPasswords() { * @return list of error messages if validation fails, empty if valid */ public List validate(User user, String password, String usernameOrEmail, Locale locale) { - if (!enabled) { + if (!passwordPolicy.isEnabled()) { log.debug("Password policy enforcement is disabled. Skipping validation."); return List.of(); } @@ -168,24 +137,24 @@ private List buildPassayRules() { List rules = new ArrayList<>(); // Length rule - rules.add(new LengthRule(minLength, maxLength)); + rules.add(new LengthRule(passwordPolicy.getMinLength(), passwordPolicy.getMaxLength())); // Character rules - if (requireUppercase) { + if (passwordPolicy.isRequireUppercase()) { rules.add(new CharacterRule(EnglishCharacterData.UpperCase, 1)); } - if (requireLowercase) { + if (passwordPolicy.isRequireLowercase()) { rules.add(new CharacterRule(EnglishCharacterData.LowerCase, 1)); } - if (requireDigit) { + if (passwordPolicy.isRequireDigit()) { rules.add(new CharacterRule(EnglishCharacterData.Digit, 1)); } - if (requireSpecial) { + if (passwordPolicy.isRequireSpecial()) { rules.add(createSpecialCharacterRule()); } // Common Passwords Dictionary Rule - if (preventCommonPasswords && commonPasswordRule != null) { + if (passwordPolicy.isPreventCommonPasswords() && commonPasswordRule != null) { rules.add(commonPasswordRule); } @@ -206,7 +175,7 @@ public String getErrorCode() { @Override public String getCharacters() { - return specialChars; + return passwordPolicy.getSpecialChars(); } }; return new CharacterRule(specialCharacterData, 1); @@ -221,17 +190,17 @@ public String getCharacters() { * @return Optional containing error message if password was reused, empty otherwise */ private Optional checkPasswordHistory(User user, String password, Locale locale) { - if (user == null || historyCount <= 0) { + if (user == null || passwordPolicy.getHistoryCount() <= 0) { return Optional.empty(); } List oldHashes = passwordHistoryRepository.findRecentPasswordHashes(user, - PageRequest.of(0, historyCount)); + PageRequest.of(0, passwordPolicy.getHistoryCount())); for (String hash : oldHashes) { if (passwordEncoder.matches(password, hash)) { String msg = messages.getMessage("password.error.history.reuse", - new Object[] { historyCount }, locale); + new Object[] { passwordPolicy.getHistoryCount() }, locale); log.debug("Password rejected: matches historical password"); return Optional.of(msg); } @@ -249,7 +218,7 @@ private Optional checkPasswordHistory(User user, String password, Locale * @return Optional containing error message if too similar, empty otherwise */ private Optional checkPasswordSimilarity(String password, String usernameOrEmail, Locale locale) { - if (!StringUtils.hasText(usernameOrEmail) || similarityThreshold <= 0) { + if (!StringUtils.hasText(usernameOrEmail) || passwordPolicy.getSimilarityThreshold() <= 0) { return Optional.empty(); } @@ -264,7 +233,7 @@ private Optional checkPasswordSimilarity(String password, String usernam double similarityPercent = (100.0 * (maxLength - distance)) / maxLength; log.debug("Password similarity to username/email: {}%", similarityPercent); - if (similarityPercent >= similarityThreshold) { + if (similarityPercent >= passwordPolicy.getSimilarityThreshold()) { String msg = messages.getMessage("password.error.similarity", new Object[] { String.format("%.2f", similarityPercent) }, locale); return Optional.of(msg); 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 20fa906..4f662c6 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java @@ -53,6 +53,7 @@ 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.security.PasswordPolicyConfigProperties; import com.digitalsanctuary.spring.user.util.TimeLogger; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; @@ -238,6 +239,8 @@ public String getValue() { private final PasswordHistoryRepository passwordHistoryRepository; + private final PasswordPolicyConfigProperties passwordPolicy; + private final SessionInvalidationService sessionInvalidationService; /** Hashes tokens before they are stored / looked up at rest. */ @@ -277,9 +280,6 @@ public String getValue() { @Value("${user.actuallyDeleteAccount:false}") private boolean actuallyDeleteAccount; - @Value("${user.security.password.history-count:0}") - private int historyCount; - /** * Registers a new user account with the provided user data. If the email * already exists, throws a UserAlreadyExistException. If @@ -461,13 +461,13 @@ private void savePasswordHistory(User user, String encodedPassword) { * @param user the user whose password history should be cleaned up */ private void cleanUpPasswordHistory(User user) { - if (user == null || historyCount <= 0) { + if (user == null || passwordPolicy.getHistoryCount() <= 0) { return; } // 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; + int maxEntries = passwordPolicy.getHistoryCount() + 1; // 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. diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/PasswordPolicyServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/PasswordPolicyServiceTest.java index eb2f751..10e3423 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/PasswordPolicyServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/PasswordPolicyServiceTest.java @@ -21,6 +21,7 @@ import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.persistence.repository.PasswordHistoryRepository; +import com.digitalsanctuary.spring.user.security.PasswordPolicyConfigProperties; @ExtendWith(MockitoExtension.class) class PasswordPolicyServiceTest { @@ -34,13 +35,16 @@ class PasswordPolicyServiceTest { @Mock private MessageSource messages; + private PasswordPolicyConfigProperties passwordPolicy; + private PasswordPolicyService service; private static final Locale LOCALE = Locale.ENGLISH; @BeforeEach void setUp() { - service = new PasswordPolicyService(passwordHistoryRepository, passwordEncoder, messages); + passwordPolicy = new PasswordPolicyConfigProperties(); + service = new PasswordPolicyService(passwordHistoryRepository, passwordEncoder, messages, passwordPolicy); // Default message resolver: return the error code as-is for deterministic // assertions @@ -48,22 +52,22 @@ void setUp() { // .thenAnswer(inv -> inv.getArgument(0, String.class)); // Baseline config (can be overridden per test) - ReflectionTestUtils.setField(service, "enabled", true); - ReflectionTestUtils.setField(service, "minLength", 8); - ReflectionTestUtils.setField(service, "maxLength", 128); - ReflectionTestUtils.setField(service, "requireUppercase", true); - ReflectionTestUtils.setField(service, "requireLowercase", true); - ReflectionTestUtils.setField(service, "requireDigit", true); - ReflectionTestUtils.setField(service, "requireSpecial", true); - ReflectionTestUtils.setField(service, "specialChars", "!@#$%^&*()_-+={}[]|:;<>,.?"); - ReflectionTestUtils.setField(service, "preventCommonPasswords", false); - ReflectionTestUtils.setField(service, "historyCount", 0); - ReflectionTestUtils.setField(service, "similarityThreshold", 0); + passwordPolicy.setEnabled(true); + passwordPolicy.setMinLength(8); + passwordPolicy.setMaxLength(128); + passwordPolicy.setRequireUppercase(true); + passwordPolicy.setRequireLowercase(true); + passwordPolicy.setRequireDigit(true); + passwordPolicy.setRequireSpecial(true); + passwordPolicy.setSpecialChars("!@#$%^&*()_-+={}[]|:;<>,.?"); + passwordPolicy.setPreventCommonPasswords(false); + passwordPolicy.setHistoryCount(0); + passwordPolicy.setSimilarityThreshold(0); } @Test void validate_returnsEmpty_whenPolicyDisabled() { - ReflectionTestUtils.setField(service, "enabled", false); + passwordPolicy.setEnabled(false); List errors = service.validate(null, "anything", null, LOCALE); @@ -77,8 +81,8 @@ void validate_enforcesMinLength() { .thenAnswer(inv -> inv.getArgument(0, String.class)); // Keep only length rule active (the test password already satisfies the others) - ReflectionTestUtils.setField(service, "minLength", 8); - ReflectionTestUtils.setField(service, "maxLength", 128); + passwordPolicy.setMinLength(8); + passwordPolicy.setMaxLength(128); // "Ab1@" = 4 chars; still contains upper/lower/digit/special so only TOO_SHORT // should fail @@ -93,7 +97,7 @@ void validate_enforcesMaxLength() { when(messages.getMessage(anyString(), any(), eq(LOCALE))) .thenAnswer(inv -> inv.getArgument(0, String.class)); - ReflectionTestUtils.setField(service, "maxLength", 5); + passwordPolicy.setMaxLength(5); // 8 chars => should trigger TOO_LONG; other rules satisfied List errors = service.validate(null, "Abcdef1@", null, LOCALE); @@ -107,11 +111,11 @@ void validate_requiresUppercase() { when(messages.getMessage(anyString(), any(), eq(LOCALE))) .thenAnswer(inv -> inv.getArgument(0, String.class)); // Isolate uppercase rule - ReflectionTestUtils.setField(service, "requireUppercase", true); - ReflectionTestUtils.setField(service, "requireLowercase", false); - ReflectionTestUtils.setField(service, "requireDigit", false); - ReflectionTestUtils.setField(service, "requireSpecial", false); - ReflectionTestUtils.setField(service, "minLength", 1); + passwordPolicy.setRequireUppercase(true); + passwordPolicy.setRequireLowercase(false); + passwordPolicy.setRequireDigit(false); + passwordPolicy.setRequireSpecial(false); + passwordPolicy.setMinLength(1); List errors = service.validate(null, "abc123!", null, LOCALE); @@ -124,11 +128,11 @@ void validate_requiresLowercase() { when(messages.getMessage(anyString(), any(), eq(LOCALE))) .thenAnswer(inv -> inv.getArgument(0, String.class)); - ReflectionTestUtils.setField(service, "requireUppercase", false); - ReflectionTestUtils.setField(service, "requireLowercase", true); - ReflectionTestUtils.setField(service, "requireDigit", false); - ReflectionTestUtils.setField(service, "requireSpecial", false); - ReflectionTestUtils.setField(service, "minLength", 1); + passwordPolicy.setRequireUppercase(false); + passwordPolicy.setRequireLowercase(true); + passwordPolicy.setRequireDigit(false); + passwordPolicy.setRequireSpecial(false); + passwordPolicy.setMinLength(1); List errors = service.validate(null, "ABC123!", null, LOCALE); @@ -140,11 +144,11 @@ void validate_requiresLowercase() { void validate_requiresDigit() { when(messages.getMessage(anyString(), any(), eq(LOCALE))) .thenAnswer(inv -> inv.getArgument(0, String.class)); - ReflectionTestUtils.setField(service, "requireUppercase", false); - ReflectionTestUtils.setField(service, "requireLowercase", false); - ReflectionTestUtils.setField(service, "requireDigit", true); - ReflectionTestUtils.setField(service, "requireSpecial", false); - ReflectionTestUtils.setField(service, "minLength", 1); + passwordPolicy.setRequireUppercase(false); + passwordPolicy.setRequireLowercase(false); + passwordPolicy.setRequireDigit(true); + passwordPolicy.setRequireSpecial(false); + passwordPolicy.setMinLength(1); List errors = service.validate(null, "Abcdef@", null, LOCALE); @@ -157,12 +161,12 @@ void validate_requiresAllowedSpecial_whenRequireSpecialTrue() { when(messages.getMessage(anyString(), any(), eq(LOCALE))) .thenAnswer(inv -> inv.getArgument(0, String.class)); // Only require special; restrict allowed specials to "!@#" - ReflectionTestUtils.setField(service, "requireUppercase", false); - ReflectionTestUtils.setField(service, "requireLowercase", false); - ReflectionTestUtils.setField(service, "requireDigit", false); - ReflectionTestUtils.setField(service, "requireSpecial", true); - ReflectionTestUtils.setField(service, "specialChars", "!@#"); - ReflectionTestUtils.setField(service, "minLength", 1); + passwordPolicy.setRequireUppercase(false); + passwordPolicy.setRequireLowercase(false); + passwordPolicy.setRequireDigit(false); + passwordPolicy.setRequireSpecial(true); + passwordPolicy.setSpecialChars("!@#"); + passwordPolicy.setMinLength(1); // Uses '$' which is NOT in allowed set => INSUFFICIENT_SPECIAL List errors = service.validate(null, "abc1$", null, LOCALE); @@ -176,13 +180,13 @@ void validate_rejectsCommonPasswords_whenDictionaryEnabled() { when(messages.getMessage(anyString(), any(), eq(LOCALE))) .thenAnswer(inv -> inv.getArgument(0, String.class)); // Disable other rules so we only see the dictionary error - ReflectionTestUtils.setField(service, "requireUppercase", false); - ReflectionTestUtils.setField(service, "requireLowercase", false); - ReflectionTestUtils.setField(service, "requireDigit", false); - ReflectionTestUtils.setField(service, "requireSpecial", false); - ReflectionTestUtils.setField(service, "minLength", 1); + passwordPolicy.setRequireUppercase(false); + passwordPolicy.setRequireLowercase(false); + passwordPolicy.setRequireDigit(false); + passwordPolicy.setRequireSpecial(false); + passwordPolicy.setMinLength(1); - ReflectionTestUtils.setField(service, "preventCommonPasswords", true); + passwordPolicy.setPreventCommonPasswords(true); // Provide an in-memory dictionary and invoke @PostConstruct // String dict = "password\n123456\nqwerty\n"; @@ -200,7 +204,7 @@ void validate_rejectsCommonPasswords_whenDictionaryEnabled() { @Test void validate_rejectsPasswordReuse_whenInHistory() { - ReflectionTestUtils.setField(service, "historyCount", 3); + passwordPolicy.setHistoryCount(3); User user = new User(); user.setEmail("test@example.com"); @@ -223,7 +227,7 @@ void validate_rejectsPasswordReuse_whenInHistory() { @Test void validate_allowsNewPassword_whenNotInHistory() { - ReflectionTestUtils.setField(service, "historyCount", 3); + passwordPolicy.setHistoryCount(3); User user = new User(); user.setEmail("test@example.com"); @@ -242,14 +246,14 @@ void validate_allowsNewPassword_whenNotInHistory() { @Test void validate_rejectsWhenSimilarityAboveThreshold() { // Turn off other constraints to isolate similarity check - ReflectionTestUtils.setField(service, "requireUppercase", false); - ReflectionTestUtils.setField(service, "requireLowercase", false); - ReflectionTestUtils.setField(service, "requireDigit", false); - ReflectionTestUtils.setField(service, "requireSpecial", false); - ReflectionTestUtils.setField(service, "minLength", 1); - ReflectionTestUtils.setField(service, "historyCount", 0); - ReflectionTestUtils.setField(service, "preventCommonPasswords", false); - ReflectionTestUtils.setField(service, "similarityThreshold", 80); + passwordPolicy.setRequireUppercase(false); + passwordPolicy.setRequireLowercase(false); + passwordPolicy.setRequireDigit(false); + passwordPolicy.setRequireSpecial(false); + passwordPolicy.setMinLength(1); + passwordPolicy.setHistoryCount(0); + passwordPolicy.setPreventCommonPasswords(false); + passwordPolicy.setSimilarityThreshold(80); when(messages.getMessage(eq("password.error.similarity"), any(), eq(LOCALE))) .thenReturn("password.error.similarity"); @@ -264,14 +268,14 @@ void validate_rejectsWhenSimilarityAboveThreshold() { @Test void validate_allowsPassword_whenNotSimilar() { // Same setup but use a very different password - ReflectionTestUtils.setField(service, "requireUppercase", false); - ReflectionTestUtils.setField(service, "requireLowercase", false); - ReflectionTestUtils.setField(service, "requireDigit", false); - ReflectionTestUtils.setField(service, "requireSpecial", false); - ReflectionTestUtils.setField(service, "minLength", 1); - ReflectionTestUtils.setField(service, "historyCount", 0); - ReflectionTestUtils.setField(service, "preventCommonPasswords", false); - ReflectionTestUtils.setField(service, "similarityThreshold", 80); + passwordPolicy.setRequireUppercase(false); + passwordPolicy.setRequireLowercase(false); + passwordPolicy.setRequireDigit(false); + passwordPolicy.setRequireSpecial(false); + passwordPolicy.setMinLength(1); + passwordPolicy.setHistoryCount(0); + passwordPolicy.setPreventCommonPasswords(false); + passwordPolicy.setSimilarityThreshold(80); List errors = service.validate(null, "CompletelyDifferent123!", null, LOCALE); diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java index 85c2ac5..0204983 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java @@ -136,7 +136,7 @@ class PasswordResetLookupTests { @BeforeEach void initService() { userService = new UserService(null, null, passwordTokenRepository, null, null, null, null, null, null, - null, null, null, tokenHasher, null); + null, null, null, null, tokenHasher, null); } @Test diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceRegistrationGuardTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceRegistrationGuardTest.java index c1d22fc..be60f2a 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceRegistrationGuardTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceRegistrationGuardTest.java @@ -31,6 +31,7 @@ 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.security.PasswordPolicyConfigProperties; import com.digitalsanctuary.spring.user.test.annotations.ServiceTest; /** @@ -68,6 +69,8 @@ class UserServiceRegistrationGuardTest { @Mock private PasswordHistoryRepository passwordHistoryRepository; @Mock + private PasswordPolicyConfigProperties passwordPolicy; + @Mock private SessionInvalidationService sessionInvalidationService; @Mock private TokenHasher tokenHasher; 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 dd199f6..05ca275 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java @@ -63,6 +63,7 @@ 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.security.PasswordPolicyConfigProperties; import com.digitalsanctuary.spring.user.test.annotations.ServiceTest; import com.digitalsanctuary.spring.user.test.builders.RoleTestDataBuilder; import com.digitalsanctuary.spring.user.test.builders.TokenTestDataBuilder; @@ -98,6 +99,8 @@ public class UserServiceTest { @Mock private PasswordHistoryRepository passwordHistoryRepository; @Mock + private PasswordPolicyConfigProperties passwordPolicy; + @Mock private SessionInvalidationService sessionInvalidationService; @Mock private TokenHasher tokenHasher; From f03d8294da2bf6f662b0e382473e4861c3bb16b5 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 13 Aug 2026 22:17:35 -0600 Subject: [PATCH 12/24] refactor: inject user.security config properties into WebSecurityConfig --- .../user/security/WebSecurityConfig.java | 175 ++++-------------- 1 file changed, 34 insertions(+), 141 deletions(-) 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 a3ade7d..9016745 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java @@ -1,7 +1,6 @@ package com.digitalsanctuary.spring.user.security; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; @@ -31,7 +30,6 @@ import lombok.Data; import lombok.EqualsAndHashCode; import lombok.RequiredArgsConstructor; -import lombok.ToString; import lombok.extern.slf4j.Slf4j; /** @@ -50,81 +48,9 @@ public class WebSecurityConfig { private static final String DEFAULT_ACTION_DENY = "deny"; private static final String DEFAULT_ACTION_ALLOW = "allow"; - @Value("${user.security.defaultAction}") - private String defaultAction; - - @Value("${user.security.protectedURIs}") - private String protectedURIsProperty; - - @Value("${user.security.unprotectedURIs}") - private String unprotectedURIsProperty; - - @Value("${user.security.disableCSRFURIs}") - private String disableCSRFURIsProperty; - - @Value("${user.security.loginPageURI}") - private String loginPageURI; - - @Value("${user.security.loginActionURI}") - private String loginActionURI; - - @Value("${user.security.loginSuccessURI}") - private String loginSuccessURI; - - @Value("${user.security.logoutActionURI}") - private String logoutActionURI; - - @Value("${user.security.logoutSuccessURI}") - private String logoutSuccessURI; - - @Value("${user.security.registrationURI}") - private String registrationURI; - - @Value("${user.security.registrationPendingURI}") - private String registrationPendingURI; - - @Value("${user.security.registrationSuccessURI}") - private String registrationSuccessURI; - - @Value("${user.security.forgotPasswordURI}") - private String forgotPasswordURI; - - @Value("${user.security.forgotPasswordPendingURI}") - private String forgotPasswordPendingURI; - - @Value("${user.security.forgotPasswordChangeURI}") - private String forgotPasswordChangeURI; - - @Value("${user.security.registrationNewVerificationURI}") - private String registrationNewVerificationURI; - @Value("${spring.security.oauth2.enabled:false}") private boolean oauth2Enabled; - @Value("${user.security.rememberMe.enabled:false}") - private boolean rememberMeEnabled; - - // Excluded from the Lombok-generated toString so the signing secret can never leak through bean logging. - @ToString.Exclude - @Value("${user.security.rememberMe.key:#{null}}") - private String rememberMeKey; - - @Value("${user.security.rememberMe.tokenValiditySeconds:1209600}") - private int rememberMeTokenValiditySeconds; - - @Value("${user.security.rememberMe.rememberMeParameter:remember-me}") - private String rememberMeParameter; - - @Value("${user.security.rememberMe.rememberMeCookieName:remember-me}") - private String rememberMeCookieName; - - /** - * Whether the remember-me cookie is marked {@code Secure}. Left {@code null} (unset) by default so Spring Security's - * own behavior applies: the cookie is secure whenever the request that created it was made over HTTPS. - */ - @Value("${user.security.rememberMe.useSecureCookie:#{null}}") - private Boolean rememberMeUseSecureCookie; - @Value("${user.dev.auto-login-enabled:false}") private boolean devAutoLoginEnabled; @@ -140,6 +66,8 @@ public class WebSecurityConfig { private final ApplicationEventPublisher applicationEventPublisher; private final RequestCache requestCache; private final ObjectProvider persistentTokenRepositoryProvider; + private final UserSecurityConfigProperties userSecurityConfig; + private final RememberMeConfigProperties rememberMeConfig; /** * Builds the library's security filter chain for Spring Security. @@ -156,13 +84,13 @@ public class WebSecurityConfig { * @throws Exception if there is an issue creating the SecurityFilterChain */ 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())); + log.debug("WebSecurityConfig.configure: user.security.defaultAction: {}", userSecurityConfig.getDefaultAction()); + log.debug("WebSecurityConfig.configure: unprotectedURIs: {}", userSecurityConfig.getUnprotectedUris()); List unprotectedURIs = getUnprotectedURIsList(); log.debug("WebSecurityConfig.configure: enhanced unprotectedURIs: {}", unprotectedURIs.toString()); - http.formLogin( - formLogin -> formLogin.loginPage(loginPageURI).loginProcessingUrl(loginActionURI).successHandler(loginSuccessService).permitAll()); + http.formLogin(formLogin -> formLogin.loginPage(userSecurityConfig.getLoginPageUri()) + .loginProcessingUrl(userSecurityConfig.getLoginActionUri()).successHandler(loginSuccessService).permitAll()); // Always configure exception handling with the injected entry point (HTMX-aware by default) http.exceptionHandling(handling -> handling.authenticationEntryPoint(authenticationEntryPoint)); @@ -177,12 +105,15 @@ public SecurityFilterChain buildSecurityFilterChain(HttpSecurity http, SessionRe // present this stays on Spring's hash-based TokenBasedRememberMeServices (no server-side state); when a // repository bean exists (e.g. the JdbcTokenRepositoryImpl enabled via user.security.rememberMe.usePersistentTokens, // or a consumer-defined bean) the configurer switches to persistent tokens, which SessionInvalidationService can revoke. - if (rememberMeEnabled && rememberMeKey != null && !rememberMeKey.trim().isEmpty()) { + String rememberMeKey = rememberMeConfig.getKey(); + if (rememberMeConfig.isEnabled() && rememberMeKey != null && !rememberMeKey.trim().isEmpty()) { http.rememberMe(rememberMe -> { - rememberMe.key(rememberMeKey).userDetailsService(userDetailsService).tokenValiditySeconds(rememberMeTokenValiditySeconds) - .rememberMeParameter(rememberMeParameter).rememberMeCookieName(rememberMeCookieName); - if (rememberMeUseSecureCookie != null) { - rememberMe.useSecureCookie(rememberMeUseSecureCookie); + rememberMe.key(rememberMeKey).userDetailsService(userDetailsService) + .tokenValiditySeconds(rememberMeConfig.getTokenValiditySeconds()) + .rememberMeParameter(rememberMeConfig.getRememberMeParameter()) + .rememberMeCookieName(rememberMeConfig.getRememberMeCookieName()); + if (rememberMeConfig.getUseSecureCookie() != null) { + rememberMe.useSecureCookie(rememberMeConfig.getUseSecureCookie()); } PersistentTokenRepository tokenRepository = persistentTokenRepositoryProvider.getIfAvailable(); if (tokenRepository != null) { @@ -193,8 +124,8 @@ public SecurityFilterChain buildSecurityFilterChain(HttpSecurity http, SessionRe // 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")); + http.logout(logout -> logout.logoutUrl(userSecurityConfig.getLogoutActionUri()).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 @@ -203,8 +134,7 @@ public SecurityFilterChain buildSecurityFilterChain(HttpSecurity http, SessionRe 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)); + List csrfIgnoreList = new ArrayList<>(userSecurityConfig.getDisableCsrfUris()); if (devAutoLoginEnabled && environment.matchesProfiles("local")) { csrfIgnoreList.add("/dev/**"); } @@ -230,13 +160,15 @@ public SecurityFilterChain buildSecurityFilterChain(HttpSecurity http, SessionRe } // Configure authorization rules based on the default action - if (DEFAULT_ACTION_DENY.equals(getDefaultAction())) { + String defaultAction = userSecurityConfig.getDefaultAction(); + if (DEFAULT_ACTION_DENY.equals(defaultAction)) { // Allow access to unprotected URIs and require authentication for all other requests http.authorizeHttpRequests( (authorize) -> authorize.requestMatchers(unprotectedURIs.toArray(new String[0])).permitAll().anyRequest().authenticated()); - } else if (DEFAULT_ACTION_ALLOW.equals(getDefaultAction())) { + } else if (DEFAULT_ACTION_ALLOW.equals(defaultAction)) { // Require authentication for protected URIs and allow access to all other requests - http.authorizeHttpRequests((authorize) -> authorize.requestMatchers(getProtectedURIsArray()).authenticated().anyRequest().permitAll()); + http.authorizeHttpRequests((authorize) -> authorize + .requestMatchers(userSecurityConfig.getProtectedUris().toArray(new String[0])).authenticated().anyRequest().permitAll()); } else { // Log an error and deny access to all resources if the default action is not set correctly log.error( @@ -259,6 +191,7 @@ private void setupOAuth2(HttpSecurity http) throws Exception { // 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. + String loginPageURI = userSecurityConfig.getLoginPageUri(); http.oauth2Login(o -> o.loginPage(loginPageURI).successHandler(loginSuccessService) .failureHandler(new SanitizingOAuth2AuthenticationFailureHandler(loginPageURI)).userInfoEndpoint(userInfo -> { userInfo.userService(dsOAuth2UserService); @@ -345,7 +278,7 @@ public O postProcess(O filter) { private List getUnprotectedURIsList() { // Add the required user pages and actions to the unprotected URIs from configuration List unprotectedURIs = new ArrayList(); - unprotectedURIs.addAll(Arrays.asList(getUnprotectedURIsArray())); + unprotectedURIs.addAll(userSecurityConfig.getUnprotectedUris()); // Auto-unprotect the always-public paths that browsers and crawlers probe automatically without any markup // referencing them. Because this widens the permitAll surface for EVERY consumer (who never opted in), the // patterns are deliberately scoped to exactly the auto-probed paths, not a broad prefix: @@ -364,16 +297,16 @@ private List getUnprotectedURIsList() { unprotectedURIs.add("/apple-touch-icon*.png"); unprotectedURIs.add("/favicon.*"); unprotectedURIs.add("/.well-known/**"); - unprotectedURIs.add(loginPageURI); - unprotectedURIs.add(loginActionURI); - unprotectedURIs.add(logoutSuccessURI); - unprotectedURIs.add(registrationURI); - unprotectedURIs.add(registrationPendingURI); - unprotectedURIs.add(registrationNewVerificationURI); - unprotectedURIs.add(forgotPasswordURI); - unprotectedURIs.add(registrationSuccessURI); - unprotectedURIs.add(forgotPasswordPendingURI); - unprotectedURIs.add(forgotPasswordChangeURI); + unprotectedURIs.add(userSecurityConfig.getLoginPageUri()); + unprotectedURIs.add(userSecurityConfig.getLoginActionUri()); + unprotectedURIs.add(userSecurityConfig.getLogoutSuccessUri()); + unprotectedURIs.add(userSecurityConfig.getRegistrationUri()); + unprotectedURIs.add(userSecurityConfig.getRegistrationPendingUri()); + unprotectedURIs.add(userSecurityConfig.getRegistrationNewVerificationUri()); + unprotectedURIs.add(userSecurityConfig.getForgotPasswordUri()); + unprotectedURIs.add(userSecurityConfig.getRegistrationSuccessUri()); + unprotectedURIs.add(userSecurityConfig.getForgotPasswordPendingUri()); + unprotectedURIs.add(userSecurityConfig.getForgotPasswordChangeUri()); if (devAutoLoginEnabled && environment.matchesProfiles("local")) { unprotectedURIs.add("/dev/**"); } @@ -403,44 +336,4 @@ private void addIfHasText(List uris, String uri) { } } - /** - * Helper method to split comma-separated property values and filter out empty strings. - * - * @param property the comma-separated property value - * @return array of non-empty strings - */ - private String[] splitAndFilterProperty(String property) { - if (property == null || property.trim().isEmpty()) { - return new String[0]; - } - return Arrays.stream(property.split(",")).map(String::trim).filter(s -> !s.isEmpty()).toArray(String[]::new); - } - - /** - * Get the protected URIs array with empty values filtered out. - * - * @return array of protected URI patterns - */ - private String[] getProtectedURIsArray() { - return splitAndFilterProperty(protectedURIsProperty); - } - - /** - * Get the unprotected URIs array with empty values filtered out. - * - * @return array of unprotected URI patterns - */ - private String[] getUnprotectedURIsArray() { - return splitAndFilterProperty(unprotectedURIsProperty); - } - - /** - * Get the disable CSRF URIs array with empty values filtered out. - * - * @return array of URI patterns to disable CSRF protection for - */ - private String[] getDisableCSRFURIsArray() { - return splitAndFilterProperty(disableCSRFURIsProperty); - } - } From 6cadda2d7911870c58e35c69335a589e1891516d Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 13 Aug 2026 22:24:13 -0600 Subject: [PATCH 13/24] refactor: inject UserSecurityConfigProperties into UserSecurityBeansAutoConfiguration --- .../UserSecurityBeansAutoConfiguration.java | 17 +++++-------- .../user/security/CoreBeanOverrideTest.java | 2 +- ...serSecurityBeansAutoConfigurationTest.java | 24 +++++++++++++------ 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java index 5e4bfdc..e2fe4b0 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java @@ -3,7 +3,6 @@ import java.util.List; import java.util.Set; import javax.sql.DataSource; -import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -76,9 +75,7 @@ public class UserSecurityBeansAutoConfiguration { private final UserDetailsService userDetailsService; private final RolesAndPrivilegesConfig rolesAndPrivilegesConfig; - - @Value("${user.security.bcryptStrength:10}") - private int bcryptStrength; + private final UserSecurityConfigProperties userSecurityConfig; /** * Creates the library's default {@link PasswordEncoder}, a {@link BCryptPasswordEncoder} using the configured {@code user.security.bcryptStrength}. @@ -89,7 +86,7 @@ public class UserSecurityBeansAutoConfiguration { @Bean @ConditionalOnMissingBean(PasswordEncoder.class) public PasswordEncoder encoder() { - return new BCryptPasswordEncoder(bcryptStrength); + return new BCryptPasswordEncoder(userSecurityConfig.getBcryptStrength()); } /** @@ -306,16 +303,14 @@ private static boolean isStaticAssetOrAutoProbe(HttpServletRequest request) { * {@code user.security.requireCanonicalAppUrl=true} makes it fail startup instead, so an operator who wants a hard guarantee can opt into fail-fast. *

* - * @param appUrl the configured canonical base URL, or {@code null} when unset - * @param trustedHosts the allow-listed hosts (empty when unset) - * @param requireCanonicalAppUrl when {@code true}, fail startup unless {@code appUrl} or a non-empty {@code trustedHosts} is configured * @return the default {@link AppUrlResolver} */ @Bean @ConditionalOnMissingBean(AppUrlResolver.class) - public AppUrlResolver appUrlResolver(@Value("${user.security.appUrl:#{null}}") String appUrl, - @Value("${user.security.trustedHosts:}") List trustedHosts, - @Value("${user.security.requireCanonicalAppUrl:false}") boolean requireCanonicalAppUrl) { + public AppUrlResolver appUrlResolver() { + String appUrl = userSecurityConfig.getAppUrl(); + List trustedHosts = userSecurityConfig.getTrustedHosts(); + boolean requireCanonicalAppUrl = userSecurityConfig.isRequireCanonicalAppUrl(); boolean appUrlConfigured = appUrl != null && !appUrl.isBlank(); boolean trustedHostsConfigured = trustedHosts != null && trustedHosts.stream().anyMatch(h -> h != null && !h.isBlank()); if (!appUrlConfigured && !trustedHostsConfigured) { diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/CoreBeanOverrideTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/CoreBeanOverrideTest.java index 57811e2..09ee998 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/CoreBeanOverrideTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/CoreBeanOverrideTest.java @@ -303,7 +303,7 @@ void authProviderIsConditionalAndParameterized() throws Exception { @Test @DisplayName("appUrlResolver() is @ConditionalOnMissingBean") void appUrlResolverIsConditional() throws Exception { - Method method = UserSecurityBeansAutoConfiguration.class.getMethod("appUrlResolver", String.class, List.class, boolean.class); + Method method = UserSecurityBeansAutoConfiguration.class.getMethod("appUrlResolver"); assertThat(method.getAnnotation(ConditionalOnMissingBean.class)).isNotNull(); } diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfigurationTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfigurationTest.java index 7fca576..42e1db3 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfigurationTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfigurationTest.java @@ -17,13 +17,19 @@ */ class UserSecurityBeansAutoConfigurationTest { - private final UserSecurityBeansAutoConfiguration config = - new UserSecurityBeansAutoConfiguration(mock(UserDetailsService.class), mock(RolesAndPrivilegesConfig.class)); + private static UserSecurityBeansAutoConfiguration configWith(String appUrl, List trustedHosts, boolean requireCanonicalAppUrl) { + UserSecurityConfigProperties props = new UserSecurityConfigProperties(); + props.setAppUrl(appUrl); + props.setTrustedHosts(trustedHosts); + props.setRequireCanonicalAppUrl(requireCanonicalAppUrl); + return new UserSecurityBeansAutoConfiguration(mock(UserDetailsService.class), mock(RolesAndPrivilegesConfig.class), props); + } @Test @DisplayName("strict mode fails startup when neither appUrl nor trustedHosts is configured") void strictMode_failsStartupWhenNothingConfigured() { - assertThatThrownBy(() -> config.appUrlResolver(null, List.of(), true)) + UserSecurityBeansAutoConfiguration config = configWith(null, List.of(), true); + assertThatThrownBy(config::appUrlResolver) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("requireCanonicalAppUrl"); } @@ -31,7 +37,8 @@ void strictMode_failsStartupWhenNothingConfigured() { @Test @DisplayName("strict mode allows startup when a canonical appUrl is configured, and the resolver uses it") void strictMode_allowsStartupWhenAppUrlConfigured() { - AppUrlResolver resolver = config.appUrlResolver("https://app.example.com", List.of(), true); + UserSecurityBeansAutoConfiguration config = configWith("https://app.example.com", List.of(), true); + AppUrlResolver resolver = config.appUrlResolver(); assertThat(resolver).isNotNull(); // Prove the configured appUrl actually flows into the resolver, not just that a bean was returned. assertThat(resolver.resolveAppUrl(new MockHttpServletRequest())).isEqualTo("https://app.example.com"); @@ -40,7 +47,8 @@ void strictMode_allowsStartupWhenAppUrlConfigured() { @Test @DisplayName("strict mode allows startup when a trusted-host allow-list is configured, and the resolver uses it") void strictMode_allowsStartupWhenTrustedHostsConfigured() { - AppUrlResolver resolver = config.appUrlResolver(null, List.of("app.example.com"), true); + UserSecurityBeansAutoConfiguration config = configWith(null, List.of("app.example.com"), true); + AppUrlResolver resolver = config.appUrlResolver(); assertThat(resolver).isNotNull(); // Prove the configured allow-list actually flows into the resolver: a non-allow-listed request host must // fall back to the canonical trusted host rather than being emitted. @@ -55,13 +63,15 @@ void strictMode_allowsStartupWhenTrustedHostsConfigured() { @DisplayName("strict mode treats a blank-only trustedHosts value as unconfigured and fails startup") void strictMode_failsStartupWhenTrustedHostsBlankOnly() { // An empty user.security.trustedHosts= property can bind as [""]; that is not a real allow-list. - assertThatThrownBy(() -> config.appUrlResolver(null, List.of(""), true)) + UserSecurityBeansAutoConfiguration config = configWith(null, List.of(""), true); + assertThatThrownBy(config::appUrlResolver) .isInstanceOf(IllegalStateException.class); } @Test @DisplayName("non-strict mode returns a resolver even when nothing is configured (warns, does not fail)") void nonStrictMode_returnsResolverWhenNothingConfigured() { - assertThat(config.appUrlResolver(null, List.of(), false)).isNotNull(); + UserSecurityBeansAutoConfiguration config = configWith(null, List.of(), false); + assertThat(config.appUrlResolver()).isNotNull(); } } From 2367b0381a8876c8fd58bca7c8d9060ab74d8ad9 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 13 Aug 2026 22:36:44 -0600 Subject: [PATCH 14/24] refactor: inject UserSecurityConfigProperties into remaining service consumers Update tests that construct LoginAttemptService/TokenHasher/UserEmailService directly (constructor args, @Mock fields) and UserApiTest's lockout-threshold read for the new constructor parameter. --- .../user/service/LoginAttemptService.java | 22 +++++---------- .../user/service/LoginSuccessService.java | 24 ++++++++-------- .../user/service/LogoutSuccessService.java | 9 +++--- .../spring/user/service/TokenHasher.java | 10 +++---- .../spring/user/service/UserEmailService.java | 10 +++---- .../user/util/PasswordHashTimeTester.java | 9 +++--- .../spring/user/api/UserApiTest.java | 6 ++-- .../user/service/LoginAttemptServiceTest.java | 18 +++++++----- .../user/service/LoginSuccessServiceTest.java | 9 ++++-- .../service/LogoutSuccessServiceTest.java | 9 ++++-- .../spring/user/service/TokenHasherTest.java | 28 +++++++++++++------ .../service/TokenHashingSecurityTest.java | 9 ++++-- .../user/service/UserEmailServiceTest.java | 8 ++++-- .../service/UserVerificationServiceTest.java | 4 ++- 14 files changed, 97 insertions(+), 78 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/LoginAttemptService.java b/src/main/java/com/digitalsanctuary/spring/user/service/LoginAttemptService.java index 81c8923..eb49a1f 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/LoginAttemptService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/LoginAttemptService.java @@ -1,11 +1,11 @@ package com.digitalsanctuary.spring.user.service; import java.util.Date; -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.repository.UserRepository; +import com.digitalsanctuary.spring.user.security.UserSecurityConfigProperties; import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -32,16 +32,8 @@ public class LoginAttemptService { final private UserRepository userRepository; - /** The max failed login attempts on a given account before it is locked. A value of 0 will disable locking accounts based on failed logins. */ - @Value("${user.security.failedLoginAttempts}") - private int maxFailedLoginAttempts; - - /** - * The account lockout duration. A value less than 0 means accounts can only be unlocked by action, not duration. A value of 0 means account - * lockouts are disabled. A value greater than 0 is the number of minutes that an account will stay locked before automatically unlocking. - */ - @Value("${user.security.accountLockoutDuration}") - private int accountLockoutDuration; + /** The user security configuration properties. */ + final private UserSecurityConfigProperties userSecurityConfig; /** * Login succeeded, reset failed login attempts. @@ -68,7 +60,7 @@ public void loginSucceeded(final String email) { @Transactional public void loginFailed(final String email) { log.debug("Login attempt failed for user: {}", email); - if (maxFailedLoginAttempts > 0) { + if (userSecurityConfig.getFailedLoginAttempts() > 0) { // 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); @@ -78,7 +70,7 @@ public void loginFailed(final String email) { } // 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()) { + if (user != null && user.getFailedLoginAttempts() >= userSecurityConfig.getFailedLoginAttempts() && !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()); @@ -117,12 +109,12 @@ public boolean isLocked(final String email) { */ public User checkIfUserShouldBeUnlocked(User user) { log.debug("Checking if user should be unlocked: {}", user.getEmail()); - if (user.isLocked() && user.getLockedDate() != null && accountLockoutDuration >= 0) { + if (user.isLocked() && user.getLockedDate() != null && userSecurityConfig.getAccountLockoutDuration() >= 0) { Date lockedDate = user.getLockedDate(); Date now = new Date(); long diff = now.getTime() - lockedDate.getTime(); long diffMinutes = diff / (60 * 1000); - if (diffMinutes >= accountLockoutDuration) { + if (diffMinutes >= userSecurityConfig.getAccountLockoutDuration()) { log.debug("User should be unlocked: {}", user.getEmail()); user.setLocked(false); user.setLockedDate(null); 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 cf955ff..52d2b23 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/LoginSuccessService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/LoginSuccessService.java @@ -1,7 +1,6 @@ package com.digitalsanctuary.spring.user.service; import java.io.IOException; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; @@ -10,6 +9,7 @@ import org.thymeleaf.util.StringUtils; import com.digitalsanctuary.spring.user.audit.AuditEvent; import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.security.UserSecurityConfigProperties; import com.digitalsanctuary.spring.user.util.UserUtils; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -34,6 +34,9 @@ public class LoginSuccessService extends SavedRequestAwareAuthenticationSuccessH /** The event publisher. */ private final ApplicationEventPublisher eventPublisher; + /** The user security configuration properties. */ + private final UserSecurityConfigProperties userSecurityConfig; + /** * Constructs the login success handler and wires in the application's effective {@link RequestCache}. * @@ -48,20 +51,15 @@ public class LoginSuccessService extends SavedRequestAwareAuthenticationSuccessH * * @param eventPublisher the application event publisher used to emit login audit events * @param requestCache the effective {@link RequestCache} bean (the library's hardened default, or a consumer override) + * @param userSecurityConfig the user security configuration properties */ - public LoginSuccessService(ApplicationEventPublisher eventPublisher, RequestCache requestCache) { + public LoginSuccessService(ApplicationEventPublisher eventPublisher, RequestCache requestCache, + UserSecurityConfigProperties userSecurityConfig) { this.eventPublisher = eventPublisher; + this.userSecurityConfig = userSecurityConfig; super.setRequestCache(requestCache); } - /** The login success uri. */ - @Value("${user.security.loginSuccessURI}") - private String loginSuccessUri; - - /** Whether to always use the default target URL or respect saved requests for better UX. */ - @Value("${user.security.alwaysUseDefaultTargetUrl:false}") - private boolean alwaysUseDefaultTargetUrl; - /** * On authentication success. * @@ -120,8 +118,8 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo log.debug("Initial targetUrl from super.determineTargetUrl: {}", targetUrl); if (StringUtils.isEmptyOrWhitespace(targetUrl) || StringUtils.equals(targetUrl, "/")) { - targetUrl = loginSuccessUri; - log.debug("Using configured loginSuccessUri: {}", loginSuccessUri); + targetUrl = userSecurityConfig.getLoginSuccessUri(); + log.debug("Using configured loginSuccessUri: {}", targetUrl); this.setDefaultTargetUrl(targetUrl); log.debug("LoginSuccessService.onAuthenticationSuccess: set defaultTargetUrl to: {}", this.getDefaultTargetUrl()); } else { @@ -129,7 +127,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo } // Set the alwaysUseDefaultTargetUrl based on configuration - this.setAlwaysUseDefaultTargetUrl(alwaysUseDefaultTargetUrl); + this.setAlwaysUseDefaultTargetUrl(userSecurityConfig.isAlwaysUseDefaultTargetUrl()); log.debug("AlwaysUseDefaultTargetUrl set to: {} (configurable behavior)", this.isAlwaysUseDefaultTargetUrl()); // Check if there's a redirect URL in the request parameters (common in OAuth2 flows) 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 9644d80..2cbef71 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/LogoutSuccessService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/LogoutSuccessService.java @@ -1,7 +1,6 @@ package com.digitalsanctuary.spring.user.service; import java.io.IOException; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler; @@ -9,6 +8,7 @@ import org.thymeleaf.util.StringUtils; import com.digitalsanctuary.spring.user.audit.AuditEvent; import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.security.UserSecurityConfigProperties; import com.digitalsanctuary.spring.user.util.UserUtils; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -34,9 +34,8 @@ public class LogoutSuccessService extends SimpleUrlLogoutSuccessHandler { /** The event publisher. */ private final ApplicationEventPublisher eventPublisher; - /** The logout success uri. */ - @Value("${user.security.logoutSuccessURI}") - private String logoutSuccessUri; + /** The user security configuration properties. */ + private final UserSecurityConfigProperties userSecurityConfig; /** * On logout success. @@ -68,7 +67,7 @@ public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse resp String targetUrl = super.determineTargetUrl(request, response); if (StringUtils.isEmptyOrWhitespace(targetUrl) || StringUtils.equals(targetUrl, "/")) { - targetUrl = logoutSuccessUri; + targetUrl = userSecurityConfig.getLogoutSuccessUri(); this.setDefaultTargetUrl(targetUrl); } diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/TokenHasher.java b/src/main/java/com/digitalsanctuary/spring/user/service/TokenHasher.java index c88e5f4..97ec2b9 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/TokenHasher.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/TokenHasher.java @@ -5,9 +5,9 @@ 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 com.digitalsanctuary.spring.user.security.UserSecurityConfigProperties; import lombok.extern.slf4j.Slf4j; /** @@ -53,11 +53,11 @@ public class TokenHasher { /** * 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 + * @param userSecurityConfig the user security configuration properties, whose {@code tokenHashSecret} + * 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; + public TokenHasher(final UserSecurityConfigProperties userSecurityConfig) { + this.tokenHashSecret = userSecurityConfig.getTokenHashSecret(); if (StringUtils.hasText(tokenHashSecret)) { log.debug("TokenHasher initialized with a configured secret (HMAC-SHA-256)."); } else { 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 c08f7fa..2593abd 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java @@ -23,6 +23,7 @@ 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.security.UserSecurityConfigProperties; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -67,6 +68,9 @@ public class UserEmailService { /** Hashes tokens before they are stored at rest. */ private final TokenHasher tokenHasher; + /** The user security configuration properties. */ + private final UserSecurityConfigProperties userSecurityConfig; + /** * Self-reference, resolved through the Spring proxy, used to invoke {@link #createPasswordResetTokenForUser} * so its {@code @Transactional} boundary actually applies. @@ -88,10 +92,6 @@ public class UserEmailService { @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(); @@ -272,7 +272,7 @@ public void createPasswordResetTokenForUser(final User user, final String token) 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); + new PasswordResetToken(tokenHasher.hash(token), user, userSecurityConfig.getPasswordResetTokenValidityMinutes()); passwordTokenRepository.save(myToken); } diff --git a/src/main/java/com/digitalsanctuary/spring/user/util/PasswordHashTimeTester.java b/src/main/java/com/digitalsanctuary/spring/user/util/PasswordHashTimeTester.java index 2566f3f..e8f5d4c 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/util/PasswordHashTimeTester.java +++ b/src/main/java/com/digitalsanctuary/spring/user/util/PasswordHashTimeTester.java @@ -1,11 +1,11 @@ package com.digitalsanctuary.spring.user.util; -import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.event.ApplicationStartedEvent; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import com.digitalsanctuary.spring.user.security.UserSecurityConfigProperties; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -26,9 +26,8 @@ public class PasswordHashTimeTester { /** The password encoder. */ private final PasswordEncoder passwordEncoder; - /** The test hash time flag. */ - @Value("${user.security.testHashTime}") - private boolean testHashTime = true; + /** The user security configuration properties. */ + private final UserSecurityConfigProperties userSecurityConfig; /** * Tests the time it takes to hash a password. This method is called when the application starts and tests the performance of the password hashing @@ -37,7 +36,7 @@ public class PasswordHashTimeTester { @Async @EventListener(ApplicationStartedEvent.class) public void testHashTime() { - if (testHashTime) { + if (userSecurityConfig.isTestHashTime()) { int runs = 5; long totalTime = 0; String password = "password"; 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 0b65d20..8128fc9 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/UserApiTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/UserApiTest.java @@ -35,8 +35,8 @@ 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.security.UserSecurityConfigProperties; import com.digitalsanctuary.spring.user.service.DSUserDetails; -import com.digitalsanctuary.spring.user.service.LoginAttemptService; import com.digitalsanctuary.spring.user.service.TokenHasher; import com.digitalsanctuary.spring.user.service.UserEmailService; import com.digitalsanctuary.spring.user.service.UserService; @@ -312,7 +312,7 @@ void shouldAcceptResetPasswordRequest() throws Exception { class UpdatePassword { @Autowired - private LoginAttemptService loginAttemptService; + private UserSecurityConfigProperties userSecurityConfig; @Test @DisplayName("Repeated wrong old passwords lock the account through the real lockout wiring, and the lock is then enforced") @@ -322,7 +322,7 @@ void repeatedWrongOldPasswordLocksAccountEndToEnd() throws Exception { // threshold, and the isLocked() guard — actually enforces lockout, not just that the controller calls a mock. User user = userService.registerNewUserAccount(baseTestUser); DSUserDetails principal = new DSUserDetails(user); - int maxAttempts = loginAttemptService.getMaxFailedLoginAttempts(); + int maxAttempts = userSecurityConfig.getFailedLoginAttempts(); assertThat(maxAttempts).as("test profile must have account lockout enabled").isGreaterThan(0); Map wrong = Map.of("oldPassword", "WrongOldPass9!", "newPassword", NEW_VALID_PASSWORD); 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 fb7fa75..4cd96fe 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/LoginAttemptServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/LoginAttemptServiceTest.java @@ -14,6 +14,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; +import com.digitalsanctuary.spring.user.security.UserSecurityConfigProperties; @ExtendWith(MockitoExtension.class) class LoginAttemptServiceTest { @@ -23,6 +24,8 @@ class LoginAttemptServiceTest { private LoginAttemptService loginAttemptService; + private UserSecurityConfigProperties userSecurityConfig; + private final int failedLoginAttempts = 10; // Assuming these are the values in your application.properties private final int accountLockoutDuration = 1; // Assuming these are the values in your application.properties @@ -36,10 +39,11 @@ void setUp() { testUser.setFailedLoginAttempts(0); testUser.setLocked(false); - // Manually construct the service with mocked dependencies - loginAttemptService = new LoginAttemptService(userRepository); - loginAttemptService.setMaxFailedLoginAttempts(failedLoginAttempts); - loginAttemptService.setAccountLockoutDuration(accountLockoutDuration); + // Manually construct the service with mocked/real dependencies + userSecurityConfig = new UserSecurityConfigProperties(); + userSecurityConfig.setFailedLoginAttempts(failedLoginAttempts); + userSecurityConfig.setAccountLockoutDuration(accountLockoutDuration); + loginAttemptService = new LoginAttemptService(userRepository, userSecurityConfig); } @Test @@ -100,7 +104,7 @@ void loginFailed_warnsAndStopsWhenUserNotFound() { @Test void loginFailed_doesNothingWhenLockoutDisabled() { - loginAttemptService.setMaxFailedLoginAttempts(0); + userSecurityConfig.setFailedLoginAttempts(0); loginAttemptService.loginFailed(testUser.getEmail()); @@ -146,7 +150,7 @@ void isLocked_unlocksUserAfterLockoutDuration() { 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); + userSecurityConfig.setAccountLockoutDuration(-1); testUser.setLocked(true); testUser.setLockedDate(new Date(System.currentTimeMillis() - 60L * 60 * 1000)); // locked an hour ago @@ -161,7 +165,7 @@ void checkIfUserShouldBeUnlocked_adminOnlyUnlockKeepsLockedDespitePastLockedDate @Test void isLocked_adminOnlyUnlockKeepsUserLockedDespitePastLockedDate() { // End-to-end through isLocked(): with admin-only unlock, a long-locked user stays locked. - loginAttemptService.setAccountLockoutDuration(-1); + userSecurityConfig.setAccountLockoutDuration(-1); testUser.setLocked(true); testUser.setLockedDate(new Date(System.currentTimeMillis() - 60L * 60 * 1000)); when(userRepository.findByEmail(anyString())).thenReturn(testUser); diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/LoginSuccessServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/LoginSuccessServiceTest.java index c7ce9c8..12a68d1 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/LoginSuccessServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/LoginSuccessServiceTest.java @@ -29,10 +29,10 @@ import org.springframework.security.core.Authentication; import org.springframework.security.web.savedrequest.RequestCache; import org.springframework.security.web.savedrequest.SavedRequest; -import org.springframework.test.util.ReflectionTestUtils; import com.digitalsanctuary.spring.user.audit.AuditEvent; import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.security.UserSecurityConfigProperties; import com.digitalsanctuary.spring.user.test.annotations.ServiceTest; import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder; @@ -69,6 +69,9 @@ class LoginSuccessServiceTest { @Mock private Authentication authentication; + @Mock + private UserSecurityConfigProperties userSecurityConfig; + @InjectMocks private LoginSuccessService loginSuccessService; @@ -78,8 +81,8 @@ class LoginSuccessServiceTest { @BeforeEach void setUp() { - // Set the loginSuccessUri field - ReflectionTestUtils.setField(loginSuccessService, "loginSuccessUri", LOGIN_SUCCESS_URI); + // Stub the configured login success URI + when(userSecurityConfig.getLoginSuccessUri()).thenReturn(LOGIN_SUCCESS_URI); // Create test user testUser = UserTestDataBuilder.aVerifiedUser() 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 0721b91..e3bb96a 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/LogoutSuccessServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/LogoutSuccessServiceTest.java @@ -27,11 +27,11 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.core.Authentication; -import org.springframework.test.util.ReflectionTestUtils; import com.digitalsanctuary.spring.user.audit.AuditEvent; import com.digitalsanctuary.spring.user.persistence.model.Role; import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.security.UserSecurityConfigProperties; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -60,6 +60,9 @@ class LogoutSuccessServiceTest { @Mock private DSUserDetails userDetails; + @Mock + private UserSecurityConfigProperties userSecurityConfig; + @InjectMocks private LogoutSuccessService logoutSuccessService; @@ -71,8 +74,8 @@ class LogoutSuccessServiceTest { @BeforeEach void setUp() { - // Set the logout success URI via reflection - ReflectionTestUtils.setField(logoutSuccessService, "logoutSuccessUri", logoutSuccessUri); + // Stub the configured logout success URI + lenient().when(userSecurityConfig.getLogoutSuccessUri()).thenReturn(logoutSuccessUri); // Create test user testUser = new User(); diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/TokenHasherTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/TokenHasherTest.java index 9213b3b..8c615c4 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/TokenHasherTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/TokenHasherTest.java @@ -5,16 +5,28 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import com.digitalsanctuary.spring.user.security.UserSecurityConfigProperties; + /** * Unit tests for {@link TokenHasher}. */ @DisplayName("TokenHasher Tests") class TokenHasherTest { + /** + * Builds a real {@link UserSecurityConfigProperties} with the given (possibly {@code null} or blank) + * {@code tokenHashSecret} and constructs a {@link TokenHasher} from it. + */ + private static TokenHasher newTokenHasher(String secret) { + UserSecurityConfigProperties config = new UserSecurityConfigProperties(); + config.setTokenHashSecret(secret); + return new TokenHasher(config); + } + @Test @DisplayName("hash is deterministic - same input yields same output (plain SHA-256)") void shouldProduceDeterministicHashWhenNoSecretConfigured() { - TokenHasher hasher = new TokenHasher(null); + TokenHasher hasher = newTokenHasher(null); String raw = "my-high-entropy-token"; String first = hasher.hash(raw); @@ -26,7 +38,7 @@ void shouldProduceDeterministicHashWhenNoSecretConfigured() { @Test @DisplayName("hashed value is not equal to the raw token") void shouldNotReturnRawTokenWhenHashing() { - TokenHasher hasher = new TokenHasher(null); + TokenHasher hasher = newTokenHasher(null); String raw = "my-high-entropy-token"; assertThat(hasher.hash(raw)).isNotEqualTo(raw); @@ -35,7 +47,7 @@ void shouldNotReturnRawTokenWhenHashing() { @Test @DisplayName("hash output is a 64-char lowercase hex string (SHA-256)") void shouldReturnHexEncodedSha256() { - TokenHasher hasher = new TokenHasher(null); + TokenHasher hasher = newTokenHasher(null); assertThat(hasher.hash("token")).matches("[0-9a-f]{64}"); } @@ -43,8 +55,8 @@ void shouldReturnHexEncodedSha256() { @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"); + TokenHasher plain = newTokenHasher(null); + TokenHasher keyed = newTokenHasher("super-secret-key"); String raw = "my-high-entropy-token"; assertThat(keyed.hash(raw)).isNotEqualTo(plain.hash(raw)); @@ -53,7 +65,7 @@ void shouldProduceDifferentHashWhenSecretConfigured() { @Test @DisplayName("keyed HMAC is deterministic with the same secret") void shouldProduceDeterministicHashWhenSecretConfigured() { - TokenHasher keyed = new TokenHasher("super-secret-key"); + TokenHasher keyed = newTokenHasher("super-secret-key"); String raw = "my-high-entropy-token"; assertThat(keyed.hash(raw)).isEqualTo(keyed.hash(raw)); @@ -62,8 +74,8 @@ void shouldProduceDeterministicHashWhenSecretConfigured() { @Test @DisplayName("blank secret falls back to plain SHA-256 behavior") void shouldTreatBlankSecretAsUnset() { - TokenHasher blank = new TokenHasher(" "); - TokenHasher plain = new TokenHasher(null); + TokenHasher blank = newTokenHasher(" "); + TokenHasher plain = newTokenHasher(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 index 0204983..2e90466 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java @@ -26,6 +26,7 @@ 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.security.UserSecurityConfigProperties; import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder; /** @@ -36,7 +37,7 @@ @DisplayName("Token Hashing Security Tests") class TokenHashingSecurityTest { - private final TokenHasher tokenHasher = new TokenHasher(null); + private final TokenHasher tokenHasher = new TokenHasher(new UserSecurityConfigProperties()); private User testUser; @@ -77,12 +78,14 @@ class PasswordResetTokenTests { @Mock private SessionInvalidationService sessionInvalidationService; + private final UserSecurityConfigProperties userSecurityConfig = new UserSecurityConfigProperties(); + private UserEmailService userEmailService; @BeforeEach void initService() { userEmailService = new UserEmailService(mailService, userVerificationService, passwordTokenRepository, - userRepository, eventPublisher, sessionInvalidationService, tokenHasher); + userRepository, eventPublisher, sessionInvalidationService, tokenHasher, userSecurityConfig); } @Test @@ -111,7 +114,7 @@ void shouldDeleteExistingTokenWhenCreatingNewOne() { @Test @DisplayName("(e) expiry honors the configured minutes") void shouldHonorConfiguredLifetime() { - ReflectionTestUtils.setField(userEmailService, "passwordResetTokenValidityMinutes", 30); + userSecurityConfig.setPasswordResetTokenValidityMinutes(30); long before = System.currentTimeMillis(); userEmailService.createPasswordResetTokenForUser(testUser, "raw"); 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 98aaa99..8c6d16b 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserEmailServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserEmailServiceTest.java @@ -10,6 +10,7 @@ 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.security.UserSecurityConfigProperties; import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder; import org.junit.jupiter.api.AfterEach; @@ -53,8 +54,11 @@ class UserEmailServiceTest { @Mock private SessionInvalidationService sessionInvalidationService; + // Real config (not a mock) so token-lifetime defaults reflect production behavior. + private final UserSecurityConfigProperties userSecurityConfig = new UserSecurityConfigProperties(); + // Real hasher (not a mock) so stored-vs-raw token assertions reflect production behavior. - private final TokenHasher tokenHasher = new TokenHasher(null); + private final TokenHasher tokenHasher = new TokenHasher(userSecurityConfig); private UserEmailService userEmailService; @@ -65,7 +69,7 @@ class UserEmailServiceTest { @BeforeEach void setUp() { userEmailService = new UserEmailService(mailService, userVerificationService, passwordTokenRepository, - userRepository, eventPublisher, sessionInvalidationService, tokenHasher); + userRepository, eventPublisher, sessionInvalidationService, tokenHasher, userSecurityConfig); // 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); 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 a147756..ae7b78e 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java @@ -4,6 +4,7 @@ import com.digitalsanctuary.spring.user.persistence.model.VerificationToken; import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; import com.digitalsanctuary.spring.user.persistence.repository.VerificationTokenRepository; +import com.digitalsanctuary.spring.user.security.UserSecurityConfigProperties; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -42,7 +43,8 @@ void setUp() { testToken = new VerificationToken(); testToken.setUser(testUser); - userVerificationService = new UserVerificationService(userRepository, verificationTokenRepository, new TokenHasher(null)); + userVerificationService = new UserVerificationService(userRepository, verificationTokenRepository, + new TokenHasher(new UserSecurityConfigProperties())); } @Test From 2dcc692a312377a4e880f4659319b7c0da9386a5 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 13 Aug 2026 22:49:26 -0600 Subject: [PATCH 15/24] refactor: inject UserSecurityConfigProperties into controller/web consumers --- .../spring/user/api/UserAPI.java | 38 ++++++------------- .../user/controller/UserActionController.java | 30 +++------------ ...AuthenticationEntryPointConfiguration.java | 11 +++--- .../spring/user/web/WebInterceptorConfig.java | 13 ++----- .../api/UserAPIRegistrationGuardTest.java | 18 ++++++--- .../spring/user/api/UserAPIUnitTest.java | 33 ++++++++++------ .../controller/UserActionControllerTest.java | 27 +++++++------ ...enticationEntryPointConfigurationTest.java | 16 +++++--- 8 files changed, 85 insertions(+), 101 deletions(-) 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 ffd236d..69ccc96 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java +++ b/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java @@ -5,7 +5,6 @@ import java.util.Optional; import jakarta.validation.Valid; import org.springframework.beans.factory.ObjectProvider; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.MessageSource; import org.springframework.http.HttpStatus; @@ -34,6 +33,7 @@ import com.digitalsanctuary.spring.user.registration.RegistrationDeniedException; import com.digitalsanctuary.spring.user.registration.RegistrationGuard; import com.digitalsanctuary.spring.user.security.StepUpService; +import com.digitalsanctuary.spring.user.security.UserSecurityConfigProperties; import com.digitalsanctuary.spring.user.service.DSUserDetails; import com.digitalsanctuary.spring.user.service.LoginAttemptService; import com.digitalsanctuary.spring.user.service.PasswordPolicyService; @@ -96,23 +96,7 @@ public class UserAPI { private final LoginAttemptService loginAttemptService; /** Optional consumer-provided step-up (re-)authentication service; see {@link StepUpService} (SUF-02). */ private final ObjectProvider stepUpServiceProvider; - - @Value("${user.security.registrationPendingURI}") - private String registrationPendingURI; - - @Value("${user.security.registrationSuccessURI}") - private String registrationSuccessURI; - - @Value("${user.security.forgotPasswordPendingURI}") - private String forgotPasswordPendingURI; - - /** - * SUF-02: controls the fallback behavior of {@code /user/setPassword} when no {@link StepUpService} bean is present. - * When {@code false} (the default), setting an initial password on a passwordless account is disabled unless a - * {@link StepUpService} is provided; set to {@code true} to explicitly allow the session-only behavior. - */ - @Value("${user.security.allowInitialPasswordSetWithoutStepUp:false}") - private boolean allowInitialPasswordSetWithoutStepUp; + private final UserSecurityConfigProperties userSecurityConfig; /** * SUF-02: warn at startup when {@code POST /user/setPassword} is disabled by default — i.e. no @@ -124,7 +108,7 @@ public class UserAPI { */ @PostConstruct void warnIfInitialPasswordSetDisabled() { - if (stepUpServiceProvider.getIfAvailable() == null && !allowInitialPasswordSetWithoutStepUp) { + if (stepUpServiceProvider.getIfAvailable() == null && !userSecurityConfig.isAllowInitialPasswordSetWithoutStepUp()) { log.warn("UserAPI: POST /user/setPassword is disabled by default - no StepUpService bean is configured and " + "user.security.allowInitialPasswordSetWithoutStepUp is false, so passwordless (passkey-only) accounts " + "cannot set an initial password (every request returns HTTP 403). Provide a StepUpService bean to " @@ -168,7 +152,7 @@ public ResponseEntity registerUserAccount(@Valid @RequestBody User publishRegistrationEvent(registeredUser, request); logAuditEvent("Registration", "Success", "Registration Successful", registeredUser, request); - String nextURL = registeredUser.isEnabled() ? handleAutoLogin(registeredUser) : registrationPendingURI; + String nextURL = registeredUser.isEnabled() ? handleAutoLogin(registeredUser) : userSecurityConfig.getRegistrationPendingUri(); return buildSuccessResponse(REGISTRATION_GENERIC_MESSAGE, nextURL); } catch (RegistrationDeniedException ex) { @@ -187,7 +171,7 @@ public ResponseEntity registerUserAccount(@Valid @RequestBody User // without skipping auto-login for legitimate new users. log.warn("User already exists with email: {}", userDto.getEmail()); logAuditEvent("Registration", "Failure", "User Already Exists", null, request); - return buildSuccessResponse(REGISTRATION_GENERIC_MESSAGE, registrationPendingURI); + return buildSuccessResponse(REGISTRATION_GENERIC_MESSAGE, userSecurityConfig.getRegistrationPendingUri()); } catch (Exception ex) { log.error("Unexpected error during registration.", ex); logAuditEvent("Registration", "Failure", ex.getMessage(), null, request); @@ -222,7 +206,7 @@ public ResponseEntity resendRegistrationToken(@Valid @RequestBody userEmailService.sendRegistrationVerificationEmail(user, appUrlResolver.resolveAppUrl(request)); logAuditEvent("Resend Reg Token", "Success", "Verification Email Resent", user, request); } - return buildSuccessResponse(RESEND_GENERIC_MESSAGE, registrationPendingURI); + return buildSuccessResponse(RESEND_GENERIC_MESSAGE, userSecurityConfig.getRegistrationPendingUri()); } /** @@ -273,7 +257,7 @@ public ResponseEntity resetPassword(@Valid @RequestBody PasswordRe userEmailService.sendForgotPasswordVerificationEmail(user, appUrlResolver.resolveAppUrl(request)); logAuditEvent("Reset Password", "Success", "Password reset email sent", user, request); } - return buildSuccessResponse("If account exists, password reset email has been sent!", forgotPasswordPendingURI); + return buildSuccessResponse("If account exists, password reset email has been sent!", userSecurityConfig.getForgotPasswordPendingUri()); } /** @@ -514,7 +498,7 @@ public ResponseEntity registerPasswordlessAccount(@Valid @RequestB publishRegistrationEvent(registeredUser, request); logAuditEvent("PasswordlessRegistration", "Success", "Passwordless registration successful", registeredUser, request); - String nextURL = registeredUser.isEnabled() ? handleAutoLogin(registeredUser) : registrationPendingURI; + String nextURL = registeredUser.isEnabled() ? handleAutoLogin(registeredUser) : userSecurityConfig.getRegistrationPendingUri(); return buildSuccessResponse("Registration Successful!", nextURL); } catch (RegistrationDeniedException ex) { @@ -535,7 +519,7 @@ public ResponseEntity registerPasswordlessAccount(@Valid @RequestB // without skipping auto-login for legitimate new users. log.warn("User already exists with email: {}", dto.getEmail()); logAuditEvent("PasswordlessRegistration", "Failure", "User Already Exists", null, request); - return buildSuccessResponse("Registration Successful!", registrationPendingURI); + return buildSuccessResponse("Registration Successful!", userSecurityConfig.getRegistrationPendingUri()); } catch (Exception ex) { log.error("Unexpected error during passwordless registration.", ex); logAuditEvent("PasswordlessRegistration", "Failure", ex.getMessage(), null, request); @@ -591,7 +575,7 @@ public ResponseEntity setPassword(@AuthenticationPrincipal DSUserD return buildErrorResponse(messages.getMessage("message.set-password.step-up-required", null, "Additional verification is required to set a password.", locale), 6, HttpStatus.UNAUTHORIZED); } - } else if (!allowInitialPasswordSetWithoutStepUp) { + } else if (!userSecurityConfig.isAllowInitialPasswordSetWithoutStepUp()) { logAuditEvent("SetPassword", "Failure", "Initial password set disabled (no step-up configured)", user, request); // Distinct code (7) from the step-up-denied branch (6) so callers can tell "disabled on this server" (403) // apart from "step-up verification failed" (401). @@ -655,7 +639,7 @@ private void validateAuthenticatedUser(DSUserDetails userDetails) { */ private String handleAutoLogin(User user) { userService.authWithoutPassword(user); - return registrationSuccessURI; + return userSecurityConfig.getRegistrationSuccessUri(); } /** 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 371bcfd..1cdb932 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/controller/UserActionController.java +++ b/src/main/java/com/digitalsanctuary/spring/user/controller/UserActionController.java @@ -2,7 +2,6 @@ import java.io.UnsupportedEncodingException; import java.util.Locale; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.MessageSource; import org.springframework.stereotype.Controller; @@ -12,6 +11,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.security.UserSecurityConfigProperties; import com.digitalsanctuary.spring.user.service.TokenHasher; import com.digitalsanctuary.spring.user.service.UserService; import com.digitalsanctuary.spring.user.service.UserService.TokenValidationResult; @@ -46,27 +46,7 @@ public class UserActionController { private final UserVerificationService userVerificationService; private final MessageSource messages; private final ApplicationEventPublisher eventPublisher; - - // URIs configured in application.properties - /** The registration pending URI. */ - @Value("${user.security.registrationPendingURI}") - private String registrationPendingURI; - - /** The registration success URI. */ - @Value("${user.security.registrationSuccessURI}") - private String registrationSuccessURI; - - /** The registration new verification URI. */ - @Value("${user.security.registrationNewVerificationURI}") - private String registrationNewVerificationURI; - - /** The forgot password pending URI. */ - @Value("${user.security.forgotPasswordPendingURI}") - private String forgotPasswordPendingURI; - - /** The forgot password change URI. */ - @Value("${user.security.forgotPasswordChangeURI}") - private String forgotPasswordChangeURI; + private final UserSecurityConfigProperties userSecurityConfig; /** * Validate a forgot password token link from an email, and if valid, show the @@ -98,7 +78,7 @@ public ModelAndView showChangePasswordPage(final HttpServletRequest request, fin eventPublisher.publishEvent(changePasswordAuditEvent); if (valid) { model.addAttribute("token", token); - String redirectString = "redirect:" + forgotPasswordChangeURI; + String redirectString = "redirect:" + userSecurityConfig.getForgotPasswordChangeUri(); return new ModelAndView(redirectString, model); } else { String messageKey = AUTH_MESSAGE_PREFIX + result.getValue(); @@ -144,7 +124,7 @@ public ModelAndView confirmRegistration(final HttpServletRequest request, final model.addAttribute("message", messages.getMessage("message.account.verified", null, locale)); log.debug("UserAPI.confirmRegistration: account verified and user logged in!"); - String redirectString = "redirect:" + registrationSuccessURI; + String redirectString = "redirect:" + userSecurityConfig.getRegistrationSuccessUri(); return new ModelAndView(redirectString, model); } @@ -152,7 +132,7 @@ public ModelAndView confirmRegistration(final HttpServletRequest request, final model.addAttribute("expired", result == TokenValidationResult.EXPIRED); model.addAttribute("token", token); log.debug("UserAPI.confirmRegistration: failed. Token not found or expired."); - String redirectString = "redirect:" + registrationNewVerificationURI; + String redirectString = "redirect:" + userSecurityConfig.getRegistrationNewVerificationUri(); return new ModelAndView(redirectString, model); } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointConfiguration.java index 53b7edb..f6efa17 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointConfiguration.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointConfiguration.java @@ -7,6 +7,7 @@ import org.springframework.context.annotation.Primary; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** @@ -26,10 +27,10 @@ */ @Slf4j @Configuration +@RequiredArgsConstructor public class HtmxAwareAuthenticationEntryPointConfiguration { - @Value("${user.security.loginPageURI}") - private String loginPageURI; + private final UserSecurityConfigProperties userSecurityConfig; @Value("${spring.security.oauth2.enabled:false}") private boolean oauth2Enabled; @@ -48,12 +49,12 @@ public AuthenticationEntryPoint authenticationEntryPoint() { if (oauth2Enabled) { // null failureHandler is intentional: OAuth2AuthenticationExceptions without a handler fall through // to the redirect path in CustomOAuth2AuthenticationEntryPoint, which is the desired behavior. - inner = new CustomOAuth2AuthenticationEntryPoint(null, loginPageURI); + inner = new CustomOAuth2AuthenticationEntryPoint(null, userSecurityConfig.getLoginPageUri()); log.debug("Configuring HtmxAwareAuthenticationEntryPoint wrapping CustomOAuth2AuthenticationEntryPoint"); } else { - inner = new LoginUrlAuthenticationEntryPoint(loginPageURI); + inner = new LoginUrlAuthenticationEntryPoint(userSecurityConfig.getLoginPageUri()); log.debug("Configuring HtmxAwareAuthenticationEntryPoint wrapping LoginUrlAuthenticationEntryPoint"); } - return new HtmxAwareAuthenticationEntryPoint(inner, loginPageURI); + return new HtmxAwareAuthenticationEntryPoint(inner, userSecurityConfig.getLoginPageUri()); } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/web/WebInterceptorConfig.java b/src/main/java/com/digitalsanctuary/spring/user/web/WebInterceptorConfig.java index b774b9f..aa73de3 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/web/WebInterceptorConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/web/WebInterceptorConfig.java @@ -1,9 +1,9 @@ package com.digitalsanctuary.spring.user.web; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import com.digitalsanctuary.spring.user.security.UserSecurityConfigProperties; import lombok.RequiredArgsConstructor; /** @@ -22,14 +22,7 @@ public class WebInterceptorConfig implements WebMvcConfigurer { private final GlobalUserModelInterceptor globalUserModelInterceptor; - - /** The password-reset token-validation endpoint; the reset token appears in its redirect URL. */ - @Value("${user.security.changePasswordURI:/user/changePassword}") - private String changePasswordURI; - - /** The change-password page the reset flow redirects to; the reset token appears in its URL. */ - @Value("${user.security.forgotPasswordChangeURI:/user/forgot-password-change.html}") - private String forgotPasswordChangeURI; + private final UserSecurityConfigProperties userSecurityConfig; /** * Add the global user model interceptor to the registry, plus the SUF-05 reset-page security-headers interceptor. @@ -42,6 +35,6 @@ public void addInterceptors(InterceptorRegistry registry) { // SUF-05 (CWE-598): the reset flow carries the reset token in the page URL. Add Referrer-Policy: no-referrer and // Cache-Control: no-store to those pages so the token is not leaked via the Referer header or written to caches. registry.addInterceptor(new PasswordResetSecurityHeadersInterceptor()) - .addPathPatterns(changePasswordURI, forgotPasswordChangeURI); + .addPathPatterns(userSecurityConfig.getChangePasswordUri(), userSecurityConfig.getForgotPasswordChangeUri()); } } 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 2fdc003..27b1a10 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIRegistrationGuardTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIRegistrationGuardTest.java @@ -17,14 +17,12 @@ 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; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.MessageSource; import org.springframework.http.MediaType; -import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; @@ -34,6 +32,7 @@ import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException; import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.registration.RegistrationDeniedException; +import com.digitalsanctuary.spring.user.security.UserSecurityConfigProperties; import com.digitalsanctuary.spring.user.service.PasswordPolicyService; import com.digitalsanctuary.spring.user.service.UserEmailService; import com.digitalsanctuary.spring.user.service.UserService; @@ -75,14 +74,21 @@ class UserAPIRegistrationGuardTest { @Mock private AppUrlResolver appUrlResolver; - @InjectMocks private UserAPI userAPI; @BeforeEach void setUp() { - ReflectionTestUtils.setField(userAPI, "registrationPendingURI", "/user/registration-pending.html"); - ReflectionTestUtils.setField(userAPI, "registrationSuccessURI", "/user/registration-complete.html"); - ReflectionTestUtils.setField(userAPI, "forgotPasswordPendingURI", "/user/forgot-password-pending.html"); + // Real (non-mocked) UserSecurityConfigProperties so getters return the specific URI values these tests + // assert against, matching what the removed @Value fields previously held. + UserSecurityConfigProperties userSecurityConfig = new UserSecurityConfigProperties(); + userSecurityConfig.setRegistrationPendingUri("/user/registration-pending.html"); + userSecurityConfig.setRegistrationSuccessUri("/user/registration-complete.html"); + userSecurityConfig.setForgotPasswordPendingUri("/user/forgot-password-pending.html"); + + // loginAttemptService and stepUpServiceProvider are not mocked here (no @Mock field), matching the + // previous @InjectMocks constructor-injection behavior where unresolved collaborators were left null. + userAPI = new UserAPI(userService, userEmailService, messageSource, eventPublisher, passwordPolicyService, + webAuthnCredentialManagementServiceProvider, appUrlResolver, null, null, userSecurityConfig); mockMvc = MockMvcBuilders.standaloneSetup(userAPI).build(); } 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 74316d8..3303636 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java @@ -29,6 +29,7 @@ import com.digitalsanctuary.spring.user.dto.UserDto; import com.digitalsanctuary.spring.user.dto.UserProfileUpdateDto; import com.digitalsanctuary.spring.user.security.StepUpService; +import com.digitalsanctuary.spring.user.security.UserSecurityConfigProperties; import java.util.List; import org.springframework.beans.factory.ObjectProvider; import com.digitalsanctuary.spring.user.event.OnRegistrationCompleteEvent; @@ -51,7 +52,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.slf4j.LoggerFactory; @@ -113,9 +113,11 @@ public JSONResponse handleSecurityException(SecurityException e) { @Mock private LoginAttemptService loginAttemptService; - @InjectMocks private UserAPI userAPI; + /** Real (non-mocked) config properties, mutated directly by tests that need to flip a flag mid-test. */ + private UserSecurityConfigProperties userSecurityConfig; + private User testUser; private UserDto testUserDto; private DSUserDetails testUserDetails; @@ -147,11 +149,20 @@ void setUp() { testUserDetails = new DSUserDetails(testUser); - // Set field values using reflection - ReflectionTestUtils.setField(userAPI, "registrationPendingURI", "/user/registration-pending.html"); - ReflectionTestUtils.setField(userAPI, "registrationSuccessURI", "/user/registration-complete.html"); - ReflectionTestUtils.setField(userAPI, "forgotPasswordPendingURI", "/user/forgot-password-pending.html"); - + // Real (non-mocked) UserSecurityConfigProperties so getters return the specific URI values these tests + // assert against, matching what the removed @Value fields previously held. + userSecurityConfig = new UserSecurityConfigProperties(); + userSecurityConfig.setRegistrationPendingUri("/user/registration-pending.html"); + userSecurityConfig.setRegistrationSuccessUri("/user/registration-complete.html"); + userSecurityConfig.setForgotPasswordPendingUri("/user/forgot-password-pending.html"); + + // webAuthnCredentialManagementServiceProvider and stepUpServiceProvider are not mocked here (no @Mock + // field), matching the previous @InjectMocks constructor-injection behavior where unresolved + // collaborators were left null. Individual tests below override stepUpServiceProvider via + // ReflectionTestUtils as needed. + userAPI = new UserAPI(userService, userEmailService, messageSource, eventPublisher, passwordPolicyService, + null, appUrlResolver, loginAttemptService, null, userSecurityConfig); + // Build MockMvc with standalone setup, custom argument resolver, and exception handler mockMvc = MockMvcBuilders.standaloneSetup(userAPI) .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) @@ -710,7 +721,7 @@ void setPassword_noStepUpService_allowedWhenFlagEnabled() throws Exception { when(userService.findUserByEmail(testUser.getEmail())).thenReturn(testUser); when(userService.hasPassword(testUser)).thenReturn(false); ReflectionTestUtils.setField(userAPI, "stepUpServiceProvider", stepUpProvider(null)); - ReflectionTestUtils.setField(userAPI, "allowInitialPasswordSetWithoutStepUp", true); + userSecurityConfig.setAllowInitialPasswordSetWithoutStepUp(true); when(passwordPolicyService.validate(eq(testUser), eq("NewValidPass1!"), eq(testUser.getEmail()), any(Locale.class))) .thenReturn(List.of()); @@ -817,7 +828,7 @@ private long disabledWarnings() { @DisplayName("warns at startup when no StepUpService bean and the opt-in flag is false") void warnsWhenDisabledByDefault() { ReflectionTestUtils.setField(userAPI, "stepUpServiceProvider", providerOf(null)); - ReflectionTestUtils.setField(userAPI, "allowInitialPasswordSetWithoutStepUp", false); + userSecurityConfig.setAllowInitialPasswordSetWithoutStepUp(false); userAPI.warnIfInitialPasswordSetDisabled(); @@ -828,7 +839,7 @@ void warnsWhenDisabledByDefault() { @DisplayName("does not warn when a StepUpService bean is present") void doesNotWarnWhenStepUpServicePresent() { ReflectionTestUtils.setField(userAPI, "stepUpServiceProvider", providerOf(mock(StepUpService.class))); - ReflectionTestUtils.setField(userAPI, "allowInitialPasswordSetWithoutStepUp", false); + userSecurityConfig.setAllowInitialPasswordSetWithoutStepUp(false); userAPI.warnIfInitialPasswordSetDisabled(); @@ -839,7 +850,7 @@ void doesNotWarnWhenStepUpServicePresent() { @DisplayName("does not warn when the opt-in flag is enabled") void doesNotWarnWhenFlagEnabled() { ReflectionTestUtils.setField(userAPI, "stepUpServiceProvider", providerOf(null)); - ReflectionTestUtils.setField(userAPI, "allowInitialPasswordSetWithoutStepUp", true); + userSecurityConfig.setAllowInitialPasswordSetWithoutStepUp(true); userAPI.warnIfInitialPasswordSetDisabled(); 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 955b889..7299b25 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/controller/UserActionControllerTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/controller/UserActionControllerTest.java @@ -10,6 +10,7 @@ import com.digitalsanctuary.spring.user.audit.AuditEvent; import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.security.UserSecurityConfigProperties; import com.digitalsanctuary.spring.user.service.UserService; import com.digitalsanctuary.spring.user.service.UserService.TokenValidationResult; import com.digitalsanctuary.spring.user.service.UserVerificationService; @@ -21,12 +22,10 @@ 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; import org.springframework.context.MessageSource; -import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; @@ -50,20 +49,24 @@ class UserActionControllerTest { @Mock private ApplicationEventPublisher eventPublisher; - @InjectMocks private UserActionController userActionController; - + private User testUser; - + @BeforeEach void setUp() { - // Set field values using reflection - ReflectionTestUtils.setField(userActionController, "registrationPendingURI", "/user/registration-pending.html"); - ReflectionTestUtils.setField(userActionController, "registrationSuccessURI", "/user/registration-complete.html"); - ReflectionTestUtils.setField(userActionController, "registrationNewVerificationURI", "/user/request-new-verification-email.html"); - ReflectionTestUtils.setField(userActionController, "forgotPasswordPendingURI", "/user/forgot-password-pending.html"); - ReflectionTestUtils.setField(userActionController, "forgotPasswordChangeURI", "/user/forgot-password-change.html"); - + // Real (non-mocked) UserSecurityConfigProperties so getters return the specific URI values these tests + // assert against, matching what the removed @Value fields previously held. + UserSecurityConfigProperties userSecurityConfig = new UserSecurityConfigProperties(); + userSecurityConfig.setRegistrationPendingUri("/user/registration-pending.html"); + userSecurityConfig.setRegistrationSuccessUri("/user/registration-complete.html"); + userSecurityConfig.setRegistrationNewVerificationUri("/user/request-new-verification-email.html"); + userSecurityConfig.setForgotPasswordPendingUri("/user/forgot-password-pending.html"); + userSecurityConfig.setForgotPasswordChangeUri("/user/forgot-password-change.html"); + + userActionController = + new UserActionController(userService, userVerificationService, messageSource, eventPublisher, userSecurityConfig); + mockMvc = MockMvcBuilders.standaloneSetup(userActionController).build(); testUser = UserTestDataBuilder.aUser() 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 cce45d9..3177e77 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointConfigurationTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointConfigurationTest.java @@ -15,12 +15,18 @@ class HtmxAwareAuthenticationEntryPointConfigurationTest { // Register as auto-configuration so it is processed after user-defined beans, - // which is required for @ConditionalOnMissingBean to evaluate correctly. + // which is required for @ConditionalOnMissingBean to evaluate correctly. The configuration now depends on + // an injected UserSecurityConfigProperties bean instead of a directly-bound @Value, so supply one directly + // (rather than via @EnableConfigurationProperties + a nested @Configuration class) to avoid that nested + // class leaking into every @SpringBootTest context's component scan, mirroring the ConsumerEntryPointConfiguration + // concern below. private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(HtmxAwareAuthenticationEntryPointConfiguration.class)) - .withPropertyValues( - "user.security.loginPageURI=/user/login.html" - ); + .withBean(UserSecurityConfigProperties.class, () -> { + UserSecurityConfigProperties props = new UserSecurityConfigProperties(); + props.setLoginPageUri("/user/login.html"); + return props; + }) + .withConfiguration(AutoConfigurations.of(HtmxAwareAuthenticationEntryPointConfiguration.class)); @Nested @DisplayName("Non-OAuth2 Configuration") From 01ded56fbb12e1fc5ffc956bbf762a84ea8543b2 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 13 Aug 2026 22:55:04 -0600 Subject: [PATCH 16/24] chore: generate user.security config metadata; drop hand-maintained entries --- ...itional-spring-configuration-metadata.json | 239 +----------------- .../UserSecurityMetadataCoverageTest.java | 38 +++ .../metadata/legacy-user-security-keys.json | 48 ++++ 3 files changed, 87 insertions(+), 238 deletions(-) create mode 100644 src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityMetadataCoverageTest.java create mode 100644 src/test/resources/metadata/legacy-user-security-keys.json 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 9be1e00..21dc8af 100644 --- a/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -36,178 +36,11 @@ "type": "java.lang.String", "description": "Cron expression for token purge schedule" }, - { - "name": "user.security.appUrl", - "type": "java.lang.String", - "description": "Canonical base URL for security email links (password reset, verification). STRONGLY recommended in production to prevent Host-header poisoning (CWE-640). When set, X-Forwarded-Host is ignored." - }, - { - "name": "user.security.trustedHosts", - "type": "java.util.List", - "description": "When user.security.appUrl is not set, X-Forwarded-Host is honored only for hosts in this comma-separated allowlist; otherwise the container's own server name is used." - }, - { - "name": "user.security.requireCanonicalAppUrl", - "type": "java.lang.Boolean", - "description": "When true, fail startup unless user.security.appUrl or a non-empty user.security.trustedHosts is configured, so security email links (password reset, verification) can never derive their authority from a spoofable Host header (CWE-640). When false (default), the library logs a startup warning instead of failing.", - "defaultValue": false - }, - { - "name": "user.security.allowInitialPasswordSetWithoutStepUp", - "type": "java.lang.Boolean", - "description": "Controls the fallback behavior of POST /user/setPassword when no StepUpService bean is present. When false (default), setting an initial password on a passwordless (passkey-only) account is disabled (HTTP 403) unless a StepUpService is provided; set to true to explicitly allow the session-only behavior (SUF-02).", - "defaultValue": false - }, - { - "name": "user.security.loginActionURI", - "type": "java.lang.String", - "description": "URI for login form submission" - }, - { - "name": "user.security.protectedURIs", - "type": "java.lang.String", - "description": "Comma-delimited list of URIs that require authentication" - }, - { - "name": "user.security.unprotectedURIs", - "type": "java.lang.String", - "description": "Comma-delimited list of URIs that don't require authentication" - }, - { - "name": "user.security.logoutActionURI", - "type": "java.lang.String", - "description": "URI for logout action" - }, - { - "name": "user.security.loginPageURI", - "type": "java.lang.String", - "description": "URI for login page" - }, - { - "name": "user.security.logoutSuccessURI", - "type": "java.lang.String", - "description": "URI to redirect to after successful logout" - }, - { - "name": "user.security.registrationSuccessURI", - "type": "java.lang.String", - "description": "URI for registration success page" - }, - { - "name": "user.security.loginSuccessURI", - "type": "java.lang.String", - "description": "URI to redirect to after successful login" - }, - { - "name": "user.security.registrationNewVerificationURI", - "type": "java.lang.String", - "description": "URI for requesting new verification email" - }, { "name": "user.mail.fromAddress", "type": "java.lang.String", "description": "From address for emails sent by the system" }, - { - "name": "user.security.failedLoginAttempts", - "type": "java.lang.Integer", - "description": "Number of allowed failed login attempts before account lockout" - }, - { - "name": "user.security.updateUserURI", - "type": "java.lang.String", - "description": "URI for user profile update page" - }, - { - "name": "user.security.forgotPasswordPendingURI", - "type": "java.lang.String", - "description": "URI for forgot password pending page" - }, - { - "name": "user.security.defaultAction", - "type": "java.lang.String", - "description": "Default security action (allow or deny) for URIs not explicitly configured" - }, - { - "name": "user.security.registrationURI", - "type": "java.lang.String", - "description": "URI for registration page" - }, - { - "name": "user.security.disableCSRFURIs", - "type": "java.lang.String", - "description": "Comma-delimited list of URIs that should not be protected by CSRF protection" - }, - { - "name": "user.security.registrationPendingURI", - "type": "java.lang.String", - "description": "URI for registration pending verification page" - }, - { - "name": "user.security.forgotPasswordURI", - "type": "java.lang.String", - "description": "URI for forgot password page" - }, - { - "name": "user.security.forgotPasswordChangeURI", - "type": "java.lang.String", - "description": "URI for password change page after reset" - }, - { - "name": "user.security.password.enabled", - "type": "java.lang.String", - "description": "Enable/disable password policy enforcement" - }, - { - "name": "user.security.password.min-length", - "type": "java.lang.String", - "description": "Minimum password length" - }, - { - "name": "user.security.password.max-length", - "type": "java.lang.String", - "description": "Maximum password length" - }, - { - "name": "user.security.password.require-uppercase", - "type": "java.lang.String", - "description": "Require at least one uppercase character" - }, - { - "name": "user.security.password.require-lowercase", - "type": "java.lang.String", - "description": "Require at least one lowercase character" - }, - { - "name": "user.security.password.require-digit", - "type": "java.lang.String", - "description": "Require at least one digit" - }, - { - "name": "user.security.password.require-special", - "type": "java.lang.String", - "description": "Require at least one special character" - }, - { - "name": "user.security.password.special-chars", - "type": "java.lang.String", - "description": "Allowed special characters" - }, - { - "name": "user.security.password.prevent-common-passwords", - "type": "java.lang.String", - "description": "Prevent use of common passwords (dictionary check)" - }, - { - "name": "user.security.password.history-count", - "type": "java.lang.String", - "description": "Number of previous passwords to prevent reuse" - }, - { - "name": "user.security.password.similarity-threshold", - "type": "java.lang.String", - "description": "Percentage of similarity allowed with username/email" - }, { "name": "hibernate.globally_quoted_identifiers", "type": "java.lang.Boolean", @@ -257,33 +90,12 @@ "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", - "description": "Test BCrypt hash time on startup" - }, { "name": "spring.security.oauth2.enabled", "type": "java.lang.Boolean", @@ -299,55 +111,6 @@ "type": "java.lang.Boolean", "description": "Actually delete user accounts (true) or just disable them (false)" }, - { - "name": "user.security.accountLockoutDuration", - "type": "java.lang.Integer", - "description": "Account lockout duration in minutes" - }, - { - "name": "user.security.rememberMe.enabled", - "type": "java.lang.Boolean", - "description": "Enable remember-me functionality" - }, - { - "name": "user.security.rememberMe.key", - "type": "java.lang.String", - "description": "Secret key for remember-me token generation" - }, - { - "name": "user.security.rememberMe.tokenValiditySeconds", - "type": "java.lang.Integer", - "description": "How long a remember-me token stays valid, in seconds", - "defaultValue": 1209600 - }, - { - "name": "user.security.rememberMe.rememberMeParameter", - "type": "java.lang.String", - "description": "Request parameter the login form posts to opt into remember-me", - "defaultValue": "remember-me" - }, - { - "name": "user.security.rememberMe.rememberMeCookieName", - "type": "java.lang.String", - "description": "Name of the remember-me cookie", - "defaultValue": "remember-me" - }, - { - "name": "user.security.rememberMe.useSecureCookie", - "type": "java.lang.Boolean", - "description": "Force the Secure flag on the remember-me cookie. Unset (default) marks the cookie secure only when the request that created it used HTTPS." - }, - { - "name": "user.security.rememberMe.usePersistentTokens", - "type": "java.lang.Boolean", - "description": "Store remember-me tokens in the database (JdbcTokenRepositoryImpl) instead of the default hash-based cookies. Requires the persistent_logins table (see db-scripts). Enables server-side revocation on session invalidation and password change.", - "defaultValue": false - }, - { - "name": "user.security.alwaysUseDefaultTargetUrl", - "type": "java.lang.Boolean", - "description": "Always redirect to default target URL after login" - }, { "name": "user.web.globalUserModelOptIn", "type": "java.lang.Boolean", @@ -378,4 +141,4 @@ "defaultValue": true } ] -} \ No newline at end of file +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityMetadataCoverageTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityMetadataCoverageTest.java new file mode 100644 index 0000000..42a3765 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityMetadataCoverageTest.java @@ -0,0 +1,38 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +@DisplayName("Generated metadata covers the retired hand-maintained user.security keys") +class UserSecurityMetadataCoverageTest { + + private static String canonical(String name) { + return name.toLowerCase().replace("-", ""); + } + + @Test + void generatedMetadataContainsEveryRetiredKey() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + List legacy = mapper.readValue( + new ClassPathResource("metadata/legacy-user-security-keys.json").getInputStream(), + mapper.getTypeFactory().constructCollectionType(List.class, String.class)); + + JsonNode generated = mapper.readTree( + new ClassPathResource("META-INF/spring-configuration-metadata.json").getInputStream()); + Set generatedNames = generated.get("properties").findValuesAsText("name").stream() + .map(UserSecurityMetadataCoverageTest::canonical).collect(Collectors.toSet()); + + assertThat(legacy.stream().map(UserSecurityMetadataCoverageTest::canonical)) + .allMatch(generatedNames::contains); + } +} diff --git a/src/test/resources/metadata/legacy-user-security-keys.json b/src/test/resources/metadata/legacy-user-security-keys.json new file mode 100644 index 0000000..a5cf503 --- /dev/null +++ b/src/test/resources/metadata/legacy-user-security-keys.json @@ -0,0 +1,48 @@ +[ + "user.security.appUrl", + "user.security.trustedHosts", + "user.security.requireCanonicalAppUrl", + "user.security.allowInitialPasswordSetWithoutStepUp", + "user.security.loginActionURI", + "user.security.protectedURIs", + "user.security.unprotectedURIs", + "user.security.logoutActionURI", + "user.security.loginPageURI", + "user.security.logoutSuccessURI", + "user.security.registrationSuccessURI", + "user.security.loginSuccessURI", + "user.security.registrationNewVerificationURI", + "user.security.failedLoginAttempts", + "user.security.updateUserURI", + "user.security.forgotPasswordPendingURI", + "user.security.defaultAction", + "user.security.registrationURI", + "user.security.disableCSRFURIs", + "user.security.registrationPendingURI", + "user.security.forgotPasswordURI", + "user.security.forgotPasswordChangeURI", + "user.security.password.enabled", + "user.security.password.min-length", + "user.security.password.max-length", + "user.security.password.require-uppercase", + "user.security.password.require-lowercase", + "user.security.password.require-digit", + "user.security.password.require-special", + "user.security.password.special-chars", + "user.security.password.prevent-common-passwords", + "user.security.password.history-count", + "user.security.password.similarity-threshold", + "user.security.bcryptStrength", + "user.security.tokenHashSecret", + "user.security.passwordResetTokenValidityMinutes", + "user.security.testHashTime", + "user.security.accountLockoutDuration", + "user.security.rememberMe.enabled", + "user.security.rememberMe.key", + "user.security.rememberMe.tokenValiditySeconds", + "user.security.rememberMe.rememberMeParameter", + "user.security.rememberMe.rememberMeCookieName", + "user.security.rememberMe.useSecureCookie", + "user.security.rememberMe.usePersistentTokens", + "user.security.alwaysUseDefaultTargetUrl" +] From c48df5b52f947aa377f405dbbc5d64813719cc2b Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 13 Aug 2026 23:00:04 -0600 Subject: [PATCH 17/24] feat: expose secret-free UserSecurityUriView to templates as ${userSecurity} --- .../web/UserSecurityUriControllerAdvice.java | 47 +++++++++++++++++ .../spring/user/web/UserSecurityUriView.java | 14 ++++++ ...itional-spring-configuration-metadata.json | 6 +++ .../UserSecurityUriControllerAdviceTest.java | 50 +++++++++++++++++++ 4 files changed, 117 insertions(+) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/web/UserSecurityUriControllerAdvice.java create mode 100644 src/main/java/com/digitalsanctuary/spring/user/web/UserSecurityUriView.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/web/UserSecurityUriControllerAdviceTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/web/UserSecurityUriControllerAdvice.java b/src/main/java/com/digitalsanctuary/spring/user/web/UserSecurityUriControllerAdvice.java new file mode 100644 index 0000000..eb38be1 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/web/UserSecurityUriControllerAdvice.java @@ -0,0 +1,47 @@ +package com.digitalsanctuary.spring.user.web; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ModelAttribute; + +import com.digitalsanctuary.spring.user.security.UserSecurityConfigProperties; + +/** + * Exposes {@link UserSecurityUriView} as the {@code userSecurity} model attribute on every {@code @Controller} + * request, so consuming templates read framework URIs without SpEL bean access. Registered by default; opt out + * with {@code user.security.expose-uris-to-model=false}. {@code userSecurity} is a reserved model-attribute name. + */ +@ConditionalOnProperty(name = "user.security.expose-uris-to-model", havingValue = "true", matchIfMissing = true) +@ControllerAdvice(annotations = Controller.class) +public class UserSecurityUriControllerAdvice { + + private final UserSecurityConfigProperties config; + private final String copyrightFirstYear; + + /** + * @param config the security URI configuration to read from + * @param copyrightFirstYear the {@code user.copyrightFirstYear} value (outside {@code user.security}, so it is + * injected separately rather than sourced from {@code config}) + */ + public UserSecurityUriControllerAdvice(UserSecurityConfigProperties config, + @Value("${user.copyrightFirstYear:}") String copyrightFirstYear) { + this.config = config; + this.copyrightFirstYear = copyrightFirstYear; + } + + /** + * @return the immutable URI view exposed to templates as {@code userSecurity} + */ + @ModelAttribute("userSecurity") + public UserSecurityUriView userSecurity() { + return new UserSecurityUriView(config.getLoginPageUri(), config.getLoginActionUri(), + config.getLoginSuccessUri(), config.getLogoutActionUri(), config.getLogoutSuccessUri(), + config.getRegistrationUri(), config.getRegistrationPendingUri(), config.getRegistrationSuccessUri(), + config.getRegistrationNewVerificationUri(), config.getRegistrationConfirmUri(), + config.getForgotPasswordUri(), config.getForgotPasswordPendingUri(), + config.getForgotPasswordChangeUri(), config.getUpdateUserUri(), config.getUpdatePasswordUri(), + config.getDeleteAccountUri(), config.getChangePasswordUri(), copyrightFirstYear); + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/web/UserSecurityUriView.java b/src/main/java/com/digitalsanctuary/spring/user/web/UserSecurityUriView.java new file mode 100644 index 0000000..7557a12 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/web/UserSecurityUriView.java @@ -0,0 +1,14 @@ +package com.digitalsanctuary.spring.user.web; + +/** + * Immutable, secret-free view of the {@code user.security.*} URIs (plus the copyright first year) for templates. + * Exposed as the {@code userSecurity} model attribute so views reference e.g. {@code ${userSecurity.loginPageUri}} + * instead of SpEL bean access, which Thymeleaf 3.1.5 forbids in restricted (layout-decorated) contexts. + */ +public record UserSecurityUriView(String loginPageUri, String loginActionUri, String loginSuccessUri, + String logoutActionUri, String logoutSuccessUri, String registrationUri, String registrationPendingUri, + String registrationSuccessUri, String registrationNewVerificationUri, String registrationConfirmUri, + String forgotPasswordUri, String forgotPasswordPendingUri, String forgotPasswordChangeUri, + String updateUserUri, String updatePasswordUri, String deleteAccountUri, String changePasswordUri, + String copyrightFirstYear) { +} 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 21dc8af..4b2d1cc 100644 --- a/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -139,6 +139,12 @@ "type": "java.lang.Boolean", "description": "Enable the library's Spring Security method-level security (@EnableMethodSecurity). Set to false if the consuming application already enables method security globally to avoid double-activation.", "defaultValue": true + }, + { + "name": "user.security.expose-uris-to-model", + "type": "java.lang.Boolean", + "description": "Expose the user.security.* page/action URIs (plus user.copyrightFirstYear) as the secret-free userSecurity model attribute on every @Controller request, so templates can reference e.g. ${userSecurity.loginPageUri} instead of SpEL bean access, which Thymeleaf 3.1.5 forbids in restricted (layout-decorated) contexts. Set to false to disable the advice entirely.", + "defaultValue": true } ] } diff --git a/src/test/java/com/digitalsanctuary/spring/user/web/UserSecurityUriControllerAdviceTest.java b/src/test/java/com/digitalsanctuary/spring/user/web/UserSecurityUriControllerAdviceTest.java new file mode 100644 index 0000000..17752c0 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/web/UserSecurityUriControllerAdviceTest.java @@ -0,0 +1,50 @@ +package com.digitalsanctuary.spring.user.web; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; +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.stereotype.Controller; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; + +import com.digitalsanctuary.spring.user.security.UserSecurityConfigProperties; + +@DisplayName("UserSecurityUriControllerAdvice") +class UserSecurityUriControllerAdviceTest { + + @Controller + static class TestPageController { + @GetMapping("/user-security-advice-test-page") + public String page() { + return "test"; + } + } + + @Test + void shouldExposeUserSecurityViewWithUrisAndCopyrightYear() throws Exception { + UserSecurityConfigProperties props = new UserSecurityConfigProperties(); + UserSecurityUriControllerAdvice advice = new UserSecurityUriControllerAdvice(props, "2020"); + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TestPageController()) + .setControllerAdvice(advice).build(); + + mockMvc.perform(get("/user-security-advice-test-page")).andExpect(status().isOk()) + .andExpect(model().attributeExists("userSecurity")); + + UserSecurityUriView view = advice.userSecurity(); + assertThat(view.loginPageUri()).isEqualTo("/user/login.html"); + assertThat(view.copyrightFirstYear()).isEqualTo("2020"); + } + + @Test + void viewMustNotExposeTheTokenHashSecret() { + // The view is a fixed record of URIs + copyright; it has no accessor for secrets. + for (var component : UserSecurityUriView.class.getRecordComponents()) { + assertThat(component.getName()).doesNotContainIgnoringCase("secret"); + } + } +} From fe71957cdca2433a530f498ed247b25bf69334a4 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 13 Aug 2026 23:06:25 -0600 Subject: [PATCH 18/24] test: guard uri placeholder/bean parity; docs: typed user.security config --- CHANGELOG.md | 8 ++++ CONFIG.md | 4 ++ MIGRATION.md | 13 ++++++ .../security/UriPlaceholderParityTest.java | 46 +++++++++++++++++++ 4 files changed, 71 insertions(+) create mode 100644 src/test/java/com/digitalsanctuary/spring/user/security/UriPlaceholderParityTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aafecc..6a98e15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project are documented here. This project follows [Semantic Versioning](https://semver.org/) for its own public API; the supported Spring Boot versions are tracked separately (see the README compatibility matrix) and are **not** tied to this library's major version. +## [Unreleased] + +### Refactoring +- Internal refactor of `user.security.*` to typed `@ConfigurationProperties`: `UserSecurityConfigProperties` (page/action URIs, URI lists, security scalars), `PasswordPolicyConfigProperties`, and `RememberMeConfigProperties`. Config keys are **unchanged** — no consumer action required. `WebSecurityConfig`'s previously `@Data`-generated public URI getters (e.g. `getLoginPageURI()`) are removed; they had no callers outside the framework. + +### Features +- New `${userSecurity}` model attribute exposes the configured page/action URIs to Thymeleaf templates (e.g. `${userSecurity.loginPageUri}`) without SpEL bean access. Registered by default; opt out with `user.security.expose-uris-to-model=false`. + ## [5.2.0] - 2026-08-12 This release completes remember‑me (“stay signed in”) with real cookies, optional persistent tokens, and revocation on admin sign‑out/password change, and adds an optional, fail‑closed CAPTCHA layer (Turnstile adapter and a provider‑neutral SPI) for unauthenticated, email‑sending APIs. It also closes a CAPTCHA path‑matching bypass and expands docs and tests. diff --git a/CONFIG.md b/CONFIG.md index 836aa05..a11043e 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -78,6 +78,10 @@ user: ## Security Settings +`user.security.*` is bound to a typed `@ConfigurationProperties` class (`UserSecurityConfigProperties`). The **camelCase key spellings shown below are canonical** (e.g. `user.security.loginPageURI`, `user.security.registrationConfirmURI`) — relaxed binding also accepts kebab-case (`user.security.login-page-uri`), but the framework's `@GetMapping`/`@RequestMapping` placeholders resolve the exact camelCase key, so setting only the kebab spelling for a URI property leaves the mapped controller pointed at the default URI while the bean reports your override. Stick to camelCase for anything under `user.security.*`. + +Page and action URIs configured here are also exposed to Thymeleaf templates as the `${userSecurity}` model attribute (e.g. `${userSecurity.loginPageUri}`), registered on every `@Controller` request. Disable it with `user.security.expose-uris-to-model=false` if you don't use it. + - **Failed Login Attempts (`user.security.failedLoginAttempts`)**: Number of failed login attempts before account lockout. Set to `0` to disable lockout. Applies to the login path and to the authenticated password-change endpoint `POST /user/updatePassword` (a locked account is rejected with `HTTP 423`, a wrong current password counts toward lockout, and a correct one resets the counter). - **Account Lockout Duration (`user.security.accountLockoutDuration`)**: Duration (in minutes) for account lockout. `0` disables lockout; a negative value (e.g. `-1`) locks the account until an administrator unlocks it. - **BCrypt Strength (`user.security.bcryptStrength`)**: Adjust the bcrypt strength for password hashing. Default is `12`. diff --git a/MIGRATION.md b/MIGRATION.md index 048eb58..75e38d1 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -7,6 +7,7 @@ This guide covers migrating applications using the Spring User Framework between - [Migration Guide](#migration-guide) - [Table of Contents](#table-of-contents) - [Migrating to 5.2.x](#migrating-to-52x) + - [`user.security.*` moved to typed configuration properties (no action required)](#usersecurity-moved-to-typed-configuration-properties-no-action-required) - [Remember-me completed; two constructors gained parameters](#remember-me-completed-two-constructors-gained-parameters) - [Migrating to 5.0.x](#migrating-to-50x) - [⚠️ ACTION REQUIRED: Reverse-proxy deployments must configure a canonical app URL](#-action-required-reverse-proxy-deployments-must-configure-a-canonical-app-url) @@ -46,6 +47,18 @@ This guide covers migrating applications using the Spring User Framework between ## Migrating to 5.2.x +### `user.security.*` moved to typed configuration properties (no action required) + +`user.security.*` (page/action URIs, URI lists, and security scalars) is now bound to a typed +`@ConfigurationProperties` class (`UserSecurityConfigProperties`) instead of individual `@Value` +fields. **Config keys are unchanged** — nothing to update in `application.yml`/`.properties`. + +A new `${userSecurity.*}` template attribute is available (e.g. `${userSecurity.loginPageUri}`, +`${userSecurity.registrationUri}`). If your templates currently read these values via +`${@environment.getProperty('user.security.*')}`, you can switch to `${userSecurity.*}` — and on +Spring Boot 4.1.0+ you **must**, since Thymeleaf 3.1.5 rejects the SpEL bean-access form +(`@environment...`) in restricted (layout-decorated) template contexts. + ### Remember-me completed; two constructors gained parameters Remember-me support is now fully functional (persistent token store, cookie configuration, token diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/UriPlaceholderParityTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/UriPlaceholderParityTest.java new file mode 100644 index 0000000..daf54c1 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/UriPlaceholderParityTest.java @@ -0,0 +1,46 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +@DisplayName("Mapping-placeholder keys stay in sync with the bound bean") +class UriPlaceholderParityTest { + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(UserSecurityConfigProperties.class) + static class TestConfig { + } + + @Test + void placeholderKeyValuesEqualBeanGetters() { + // Keys used as @GetMapping/@RequestMapping/@ConditionalOnProperty placeholders elsewhere in the framework. + Map> mappingKeys = Map.of( + "user.security.loginPageURI", UserSecurityConfigProperties::getLoginPageUri, + "user.security.registrationURI", UserSecurityConfigProperties::getRegistrationUri, + "user.security.changePasswordURI", UserSecurityConfigProperties::getChangePasswordUri, + "user.security.forgotPasswordChangeURI", UserSecurityConfigProperties::getForgotPasswordChangeUri, + "user.security.registrationConfirmURI", UserSecurityConfigProperties::getRegistrationConfirmUri); + + new ApplicationContextRunner().withUserConfiguration(TestConfig.class) + .withPropertyValues("user.security.loginPageURI=/user/login.html", + "user.security.registrationURI=/user/register.html", + "user.security.changePasswordURI=/user/changePassword", + "user.security.forgotPasswordChangeURI=/user/forgot-password-change.html", + "user.security.registrationConfirmURI=/user/registrationConfirm") + .run(context -> { + Environment env = context.getEnvironment(); + UserSecurityConfigProperties bean = context.getBean(UserSecurityConfigProperties.class); + mappingKeys.forEach((key, getter) -> assertThat(getter.apply(bean)) + .as("bean value for %s must equal the placeholder-resolved Environment value", key) + .isEqualTo(env.getProperty(key))); + }); + } +} From 1965c7ba4a74c4099d82bd9a9a1a72d0dc9024a6 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 13 Aug 2026 23:09:55 -0600 Subject: [PATCH 19/24] test: rename uri placeholder parity test to follow should...When convention --- .../spring/user/security/UriPlaceholderParityTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/UriPlaceholderParityTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/UriPlaceholderParityTest.java index daf54c1..35759ca 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/UriPlaceholderParityTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/UriPlaceholderParityTest.java @@ -20,7 +20,7 @@ static class TestConfig { } @Test - void placeholderKeyValuesEqualBeanGetters() { + void shouldEqualBeanGetterWhenEnvironmentResolvesCamelCaseKey() { // Keys used as @GetMapping/@RequestMapping/@ConditionalOnProperty placeholders elsewhere in the framework. Map> mappingKeys = Map.of( "user.security.loginPageURI", UserSecurityConfigProperties::getLoginPageUri, From 5e119b5dfcc8ee2a75066c0eb30d2de3ed248524 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 13 Aug 2026 23:19:38 -0600 Subject: [PATCH 20/24] test: cover user.security URI-list default parity against shipped file --- .../UserSecurityDefaultsParityTest.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityDefaultsParityTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityDefaultsParityTest.java index 237b884..607bf41 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityDefaultsParityTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityDefaultsParityTest.java @@ -2,6 +2,8 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.util.ArrayList; +import java.util.List; import java.util.Properties; import org.junit.jupiter.api.DisplayName; @@ -69,4 +71,34 @@ void shouldMatchShippedFileWhenBindingRememberMeFields() throws Exception { assertThat(String.valueOf(bean.isUsePersistentTokens())) .isEqualTo(p.getProperty("user.security.rememberMe.usePersistentTokens")); } + + @Test + void shouldMatchShippedFileWhenBindingUriListFields() throws Exception { + Properties p = shipped(); + UserSecurityConfigProperties bean = new UserSecurityConfigProperties(); + + List expectedUnprotectedUris = splitAndTrim(p.getProperty("user.security.unprotectedURIs")); + assertThat(bean.getUnprotectedUris()).isEqualTo(expectedUnprotectedUris); + + List expectedProtectedUris = splitAndTrim(p.getProperty("user.security.protectedURIs")); + assertThat(bean.getProtectedUris()).isEqualTo(expectedProtectedUris); + + List expectedDisableCsrfUris = splitAndTrim(p.getProperty("user.security.disableCSRFURIs")); + assertThat(expectedDisableCsrfUris).isEmpty(); + assertThat(bean.getDisableCsrfUris()).isEmpty(); + } + + private static List splitAndTrim(String value) { + List result = new ArrayList<>(); + if (value == null || value.isBlank()) { + return result; + } + for (String segment : value.split(",")) { + String trimmed = segment.trim(); + if (!trimmed.isBlank()) { + result.add(trimmed); + } + } + return result; + } } From 5c5f2e7c32eab927f0147a099e08ac6ecd7dd445 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 14 Aug 2026 10:22:19 -0600 Subject: [PATCH 21/24] fix: make user.security misconfiguration loud instead of silent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on #356, code portion: - The persistent-token repository condition now uses the canonical kebab key (user.security.remember-me.use-persistent-tokens), which relaxed-matches every spelling; the previous camelCase name only exact-matched the literal camelCase key, so kebab config bound the bean but silently downgraded remember-me to hash-based tokens. WebSecurityConfig now also warns when usePersistentTokens is set without a repository bean, and when remember-me is enabled without a signing key (previously skipped silently). - New UriPlaceholderParityValidator fails startup, naming the keys, when a user.security URI diverges between the bound bean and the exact camelCase Environment key that @GetMapping placeholders resolve (the kebab-only split-brain scenario). Test coverage now spans all 13 placeholder keys and reflectively pins the annotation defaults to the field initializers. - @EnableConfigurationProperties for the three user.security classes moved from UserSecurityBeansAutoConfiguration to UserConfiguration, so excluding the beans auto-config no longer removes the properties beans that ~14 component-scanned consumers inject. - user.security.expose-uris-to-model is now a typed field (exposeUrisToModel) with generated metadata, and the advice's conditional registration (default on, opt-out in both spellings) is tested. - Bean Validation constraints on the properties classes (bcrypt strength 4-31, password-policy min<=max, similarity 0-100, non-empty specialChars when required): startup failures with the property named when a validator is on the classpath, inert otherwise. - getTrustedHosts() gets the same trim/blank-filter treatment as the other URI lists; all list getters now return immutable copies. appUrlResolver drops its duplicated normalization. - JavaDoc corrections that feed generated metadata: remember-me key (no ephemeral-key fallback exists — required when enabled), appUrl/trustedHosts/requireCanonicalAppUrl (email-link Host-header defense, not "redirect validation"), lockout sentinels, step-up fallback, tokenHashSecret scope and SHA-256 fallback. - TokenHasher logs its no-secret fallback at INFO instead of DEBUG. - Defaults-parity test now compares whole bound objects against fresh instances for all three classes; advice test asserts all 18 view components against per-field sentinels and checks secret values, not just component names. --- .../spring/user/UserConfiguration.java | 10 ++ .../PasswordPolicyConfigProperties.java | 25 +++++ .../security/RememberMeConfigProperties.java | 8 +- .../UriPlaceholderParityValidator.java | 95 ++++++++++++++++ .../UserSecurityBeansAutoConfiguration.java | 13 ++- .../UserSecurityConfigProperties.java | 87 ++++++++++++--- .../user/security/WebSecurityConfig.java | 15 ++- .../spring/user/service/TokenHasher.java | 4 +- .../web/UserSecurityUriControllerAdvice.java | 9 +- ...itional-spring-configuration-metadata.json | 6 - .../user/security/CoreBeanOverrideTest.java | 16 ++- .../PasswordPolicyConfigPropertiesTest.java | 30 +++++ ...ersistentTokenRepositoryConditionTest.java | 68 ++++++++++++ .../security/UriPlaceholderParityTest.java | 104 +++++++++++++++--- .../UserSecurityConfigPropertiesTest.java | 27 +++++ .../UserSecurityDefaultsParityTest.java | 31 +++++- .../UserSecurityMetadataCoverageTest.java | 7 +- .../UserSecurityUriControllerAdviceTest.java | 100 +++++++++++++++-- 18 files changed, 583 insertions(+), 72 deletions(-) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/security/UriPlaceholderParityValidator.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/security/PersistentTokenRepositoryConditionTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/UserConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/UserConfiguration.java index 4608b4a..2114444 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/UserConfiguration.java +++ b/src/main/java/com/digitalsanctuary/spring/user/UserConfiguration.java @@ -1,11 +1,15 @@ package com.digitalsanctuary.spring.user; +import com.digitalsanctuary.spring.user.security.PasswordPolicyConfigProperties; +import com.digitalsanctuary.spring.user.security.RememberMeConfigProperties; +import com.digitalsanctuary.spring.user.security.UserSecurityConfigProperties; import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfigurationExcludeFilter; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.TypeExcludeFilter; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.FilterType; @@ -38,6 +42,12 @@ @ComponentScan(basePackages = "com.digitalsanctuary.spring.user", excludeFilters = {@ComponentScan.Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class), @ComponentScan.Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class)}) +// The user.security.* properties beans are registered here — on the class that owns the component scan their +// consumers come from — rather than on UserSecurityBeansAutoConfiguration, so excluding that auto-configuration +// (the supported way to take over its overridable beans wholesale) does not also remove the properties beans that +// ~14 component-scanned services and configurations inject. +@EnableConfigurationProperties({UserSecurityConfigProperties.class, PasswordPolicyConfigProperties.class, + RememberMeConfigProperties.class}) @Import(UserAutoConfigurationRegistrar.class) public class UserConfiguration { diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/PasswordPolicyConfigProperties.java b/src/main/java/com/digitalsanctuary/spring/user/security/PasswordPolicyConfigProperties.java index 597127b..92eac2f 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/PasswordPolicyConfigProperties.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/PasswordPolicyConfigProperties.java @@ -1,7 +1,11 @@ package com.digitalsanctuary.spring.user.security; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import lombok.Data; /** @@ -10,8 +14,14 @@ * *

Bound from {@code user.security.password.*}. Defaults mirror the shipped * {@code config/dsspringuserconfig.properties} values.

+ * + *

Cross-field invariants ({@code minLength <= maxLength}, a non-empty {@code specialChars} set when + * {@code requireSpecial=true}, {@code similarityThreshold} in 0-100) are enforced at startup when a Bean + * Validation implementation is on the classpath (e.g. {@code spring-boot-starter-validation}), turning an + * impossible policy into a named configuration error instead of every registration failing at runtime.

*/ @Data +@Validated @ConfigurationProperties(prefix = "user.security.password") public class PasswordPolicyConfigProperties { @@ -19,9 +29,11 @@ public class PasswordPolicyConfigProperties { private boolean enabled = true; /** Minimum password length. */ + @Min(1) private int minLength = 8; /** Maximum password length. */ + @Min(1) private int maxLength = 128; /** Whether at least one uppercase character is required. */ @@ -43,8 +55,21 @@ public class PasswordPolicyConfigProperties { private boolean preventCommonPasswords = true; /** Number of previous passwords retained and rejected on reuse. */ + @Min(0) private int historyCount = 3; /** Levenshtein similarity threshold (0-100) against username/email. */ + @Min(0) + @Max(100) private int similarityThreshold = 70; + + @AssertTrue(message = "user.security.password.minLength must be less than or equal to maxLength") + private boolean isLengthRangeValid() { + return minLength <= maxLength; + } + + @AssertTrue(message = "user.security.password.specialChars must not be empty when requireSpecial=true") + private boolean isSpecialCharsUsable() { + return !requireSpecial || (specialChars != null && !specialChars.isEmpty()); + } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/RememberMeConfigProperties.java b/src/main/java/com/digitalsanctuary/spring/user/security/RememberMeConfigProperties.java index c11b58b..4cb30a6 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/RememberMeConfigProperties.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/RememberMeConfigProperties.java @@ -19,8 +19,10 @@ public class RememberMeConfigProperties { private boolean enabled = false; /** - * The remember-me signing key. Excluded from {@code toString} so the secret never leaks through bean logging. - * When null, Spring Security generates an ephemeral key at startup. + * The remember-me signing key. Excluded from toString output so the secret never leaks through bean logging. + * Required when enabled=true: without a non-blank key, remember-me is not configured at all (the framework + * logs a warning and skips it). Keep the key stable across restarts and instances; changing it invalidates + * all outstanding remember-me cookies. */ @ToString.Exclude private String key; @@ -35,7 +37,7 @@ public class RememberMeConfigProperties { private String rememberMeCookieName = "remember-me"; /** - * Whether the remember-me cookie is marked {@code Secure}. Left null (unset) by default so Spring Security's own + * Whether the remember-me cookie is marked Secure. Left null (unset) by default so Spring Security's own * behavior applies: the cookie is secure whenever the request that created it was made over HTTPS. */ private Boolean useSecureCookie; diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/UriPlaceholderParityValidator.java b/src/main/java/com/digitalsanctuary/spring/user/security/UriPlaceholderParityValidator.java new file mode 100644 index 0000000..e8515a9 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/security/UriPlaceholderParityValidator.java @@ -0,0 +1,95 @@ +package com.digitalsanctuary.spring.user.security; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; + +/** + * Fails application startup when a {@code user.security.*} URI diverges between the bound + * {@link UserSecurityConfigProperties} bean and the exact camelCase Environment key the framework's + * {@code @GetMapping} placeholders resolve (e.g. {@code @GetMapping("${user.security.loginPageURI:...}")}). + * + *

+ * The bean accepts relaxed spellings (kebab-case, environment variables), but request-mapping placeholders do + * not — they resolve only the literal camelCase key. A kebab-only override therefore moves the security + * configuration (filter chain, interceptors) without moving the mapped controller: for example + * {@code user.security.change-password-uri} would relocate the password-reset security-headers interceptor while + * the token-validation endpoint stays at its default path, silently serving the reset page without its headers. + * Failing startup with the offending keys named turns that silent split into an explicit configuration error. + * The fix is to use the camelCase spelling (canonical for {@code user.security.*}; see CONFIG.md). + *

+ * + *

+ * Deliberately {@code @PostConstruct} rather than a {@code ContextRefreshedEvent} listener, for the same reason + * as {@code CaptchaStartupValidator}: context events may be published on executor threads where a thrown + * exception is discarded, which would silently void the fail-startup guarantee. + *

+ */ +@Component +@RequiredArgsConstructor +public class UriPlaceholderParityValidator { + + /** + * Every {@code user.security.*} key used as a request-mapping placeholder, mapped to the corresponding + * {@link UserSecurityConfigProperties} getter. Package-visible so tests can verify this map stays in sync + * with the {@code @GetMapping} annotations in {@code UserPageController} and {@code UserActionController}. + */ + static final Map> MAPPING_PLACEHOLDER_KEYS = createMappingKeys(); + + private static Map> createMappingKeys() { + Map> keys = new LinkedHashMap<>(); + keys.put("user.security.loginPageURI", UserSecurityConfigProperties::getLoginPageUri); + keys.put("user.security.registrationURI", UserSecurityConfigProperties::getRegistrationUri); + keys.put("user.security.registrationPendingURI", UserSecurityConfigProperties::getRegistrationPendingUri); + keys.put("user.security.registrationSuccessURI", UserSecurityConfigProperties::getRegistrationSuccessUri); + keys.put("user.security.registrationNewVerificationURI", + UserSecurityConfigProperties::getRegistrationNewVerificationUri); + keys.put("user.security.registrationConfirmURI", UserSecurityConfigProperties::getRegistrationConfirmUri); + keys.put("user.security.forgotPasswordURI", UserSecurityConfigProperties::getForgotPasswordUri); + keys.put("user.security.forgotPasswordPendingURI", UserSecurityConfigProperties::getForgotPasswordPendingUri); + keys.put("user.security.forgotPasswordChangeURI", UserSecurityConfigProperties::getForgotPasswordChangeUri); + keys.put("user.security.updateUserURI", UserSecurityConfigProperties::getUpdateUserUri); + keys.put("user.security.updatePasswordURI", UserSecurityConfigProperties::getUpdatePasswordUri); + keys.put("user.security.deleteAccountURI", UserSecurityConfigProperties::getDeleteAccountUri); + keys.put("user.security.changePasswordURI", UserSecurityConfigProperties::getChangePasswordUri); + return keys; + } + + private final Environment environment; + private final UserSecurityConfigProperties userSecurityConfig; + + /** + * Compares each placeholder-resolved value against the bound bean value and fails startup on any mismatch. + * When the Environment does not contain the camelCase key, the placeholder falls back to its inline default, + * which equals the field initializer — so a fresh instance supplies the comparison fallback. + */ + @PostConstruct + public void validateUriPlaceholderParity() { + UserSecurityConfigProperties defaults = new UserSecurityConfigProperties(); + List mismatches = new ArrayList<>(); + MAPPING_PLACEHOLDER_KEYS.forEach((key, getter) -> { + String placeholderValue = environment.getProperty(key, getter.apply(defaults)); + String beanValue = getter.apply(userSecurityConfig); + if (!placeholderValue.equals(beanValue)) { + mismatches.add(key + " resolves to '" + placeholderValue + "' for request mappings but the bound " + + "user.security configuration value is '" + beanValue + "'"); + } + }); + if (!mismatches.isEmpty()) { + throw new IllegalStateException("user.security URI configuration is split between spellings: " + + String.join("; ", mismatches) + ". This usually means the value was set with a kebab-case or " + + "environment-variable spelling, which the typed configuration accepts but request-mapping " + + "placeholders do not — the controller would stay on the default URI while the security " + + "configuration moves. Use the camelCase key spelling (e.g. user.security.loginPageURI) shown " + + "in CONFIG.md."); + } + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java index e2fe4b0..3348515 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java @@ -6,7 +6,6 @@ import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; @@ -68,8 +67,6 @@ */ @Slf4j @AutoConfiguration(after = UserConfiguration.class) -@EnableConfigurationProperties({UserSecurityConfigProperties.class, PasswordPolicyConfigProperties.class, - RememberMeConfigProperties.class}) @RequiredArgsConstructor public class UserSecurityBeansAutoConfiguration { @@ -186,7 +183,8 @@ public AuthenticationEventPublisher authenticationEventPublisher(ApplicationEven /** * Creates the library's persistent remember-me token store, a {@link JdbcTokenRepositoryImpl} backed by the consuming application's - * {@link DataSource}. Only created when {@code user.security.rememberMe.usePersistentTokens=true}, so it is never instantiated unless the + * {@link DataSource}. Only created when {@code user.security.remember-me.use-persistent-tokens=true} (the camelCase spelling + * {@code user.security.rememberMe.usePersistentTokens} is equally accepted via relaxed matching), so it is never instantiated unless the * consumer has opted in — and opting in requires the {@code persistent_logins} table to exist (see {@code db-scripts/}); the repository * does NOT create the table itself. Backs off entirely if the consuming application defines its own {@link PersistentTokenRepository}. * @@ -202,7 +200,9 @@ public AuthenticationEventPublisher authenticationEventPublisher(ApplicationEven * @return the {@link JdbcTokenRepositoryImpl} */ @Bean - @ConditionalOnProperty(name = "user.security.rememberMe.usePersistentTokens", havingValue = "true") + // The canonical kebab-case name relaxed-matches every spelling of the key (kebab, camelCase, env var); a + // camelCase name here would only exact-match the literal camelCase key, silently ignoring kebab config. + @ConditionalOnProperty(name = "user.security.remember-me.use-persistent-tokens", havingValue = "true") @ConditionalOnMissingBean(PersistentTokenRepository.class) public PersistentTokenRepository persistentTokenRepository(DataSource dataSource) { JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl(); @@ -309,10 +309,11 @@ private static boolean isStaticAssetOrAutoProbe(HttpServletRequest request) { @ConditionalOnMissingBean(AppUrlResolver.class) public AppUrlResolver appUrlResolver() { String appUrl = userSecurityConfig.getAppUrl(); + // getTrustedHosts() returns a normalized copy (trimmed, blanks dropped), so non-empty means configured. List trustedHosts = userSecurityConfig.getTrustedHosts(); boolean requireCanonicalAppUrl = userSecurityConfig.isRequireCanonicalAppUrl(); boolean appUrlConfigured = appUrl != null && !appUrl.isBlank(); - boolean trustedHostsConfigured = trustedHosts != null && trustedHosts.stream().anyMatch(h -> h != null && !h.isBlank()); + boolean trustedHostsConfigured = !trustedHosts.isEmpty(); if (!appUrlConfigured && !trustedHostsConfigured) { if (requireCanonicalAppUrl) { throw new IllegalStateException("user.security.requireCanonicalAppUrl is enabled but neither user.security.appUrl nor " diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigProperties.java b/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigProperties.java index ca62fc7..c98909a 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigProperties.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigProperties.java @@ -4,7 +4,10 @@ import java.util.List; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import lombok.Data; import lombok.ToString; @@ -13,24 +16,39 @@ * security scalars. Password policy and remember-me live in their own classes * ({@link PasswordPolicyConfigProperties}, {@link RememberMeConfigProperties}). * - *

Defaults mirror the shipped {@code config/dsspringuserconfig.properties}. The camelCase key spellings - * (e.g. {@code user.security.loginPageURI}) are canonical; relaxed binding also accepts kebab-case.

+ *

Defaults mirror the shipped {@code config/dsspringuserconfig.properties} where that file sets a value. + * Use the camelCase key spellings (e.g. {@code user.security.loginPageURI}): relaxed binding also accepts + * kebab-case for this bean, but the framework's {@code @GetMapping} placeholders resolve only the exact + * camelCase key, so a kebab-only override would move the security configuration without moving the mapped + * controller. {@link UriPlaceholderParityValidator} fails startup if the two ever diverge.

+ * + *

The URI-list getters ({@code getProtectedUris()}, {@code getUnprotectedUris()}, {@code getDisableCsrfUris()}, + * {@code getTrustedHosts()}) return immutable, normalized copies: entries are trimmed and blank entries dropped, + * and mutating the returned list throws rather than silently doing nothing.

+ * + *

Range constraints (e.g. on {@code bcryptStrength}) are enforced at startup when a Bean Validation + * implementation is on the classpath (e.g. {@code spring-boot-starter-validation}); without one they are + * documentation only.

*/ @Data +@Validated @ConfigurationProperties(prefix = "user.security") public class UserSecurityConfigProperties { - /** Default filter-chain action for URIs not otherwise matched: {@code deny} or {@code allow}. */ + /** + * Default filter-chain action for URIs not otherwise matched: "deny" or "allow". Any other value fails + * closed: the filter chain denies all requests and logs an error, forcing intentional configuration. + */ private String defaultAction = "deny"; - /** Comma-delimited URIs protected by Spring Security when defaultAction is allow. */ + /** URIs protected by Spring Security when defaultAction is allow (comma-delimited in .properties files). */ private List protectedUris = new ArrayList<>(List.of("/protected.html")); - /** Comma-delimited URIs not protected by Spring Security when defaultAction is deny. */ + /** URIs not protected by Spring Security when defaultAction is deny (comma-delimited in .properties files). */ private List unprotectedUris = new ArrayList<>(List.of("/", "/index.html", "/favicon.ico", "/css/*", "/js/*", "/img/*", "/user/registration", "/user/resendRegistrationToken", "/user/resetPassword", "/user/registrationConfirm", "/user/changePassword", "/user/savePassword", "/oauth2/authorization/*", "/login", "/error")); - /** Comma-delimited URIs exempt from CSRF protection. Empty by default. */ + /** URIs exempt from CSRF protection (comma-delimited in .properties files). Empty by default. */ private List disableCsrfUris = new ArrayList<>(); /** The URI for the login page. */ @@ -43,7 +61,7 @@ public class UserSecurityConfigProperties { private String logoutActionUri = "/user/logout"; /** The URI for the logout success page. */ private String logoutSuccessUri = "/index.html?messageKey=message.logout.success"; - /** Whether to always redirect to loginSuccessUri or use saved requests (default: false for better UX). */ + /** Whether to always redirect to loginSuccessUri after login instead of honoring the saved request. */ private boolean alwaysUseDefaultTargetUrl = false; /** The URI for the registration page. */ private String registrationUri = "/user/register.html"; @@ -70,26 +88,57 @@ public class UserSecurityConfigProperties { /** The URI for the change password action. */ private String changePasswordUri = "/user/changePassword"; - /** Password hash strength (bcrypt log rounds). */ + /** + * Whether to expose the page/action URIs (plus user.copyrightFirstYear) as the secret-free userSecurity + * model attribute on controller requests, so templates can reference e.g. userSecurity.loginPageUri instead + * of SpEL bean access, which Thymeleaf 3.1.5 forbids in restricted (layout-decorated) contexts. Set to false + * to disable the advice entirely. + */ + private boolean exposeUrisToModel = true; + + /** Password hash strength (bcrypt log rounds). Valid bcrypt range is 4-31. */ + @Min(4) + @Max(31) private int bcryptStrength = 12; - /** Maximum failed login attempts before account lockout. */ + /** Maximum failed login attempts before account lockout. 0 disables lockout. */ + @Min(0) private int failedLoginAttempts = 10; - /** Account lockout duration in minutes. */ + /** Account lockout duration in minutes. 0 disables the lockout window; a negative value locks the account until an administrator unlocks it. */ private int accountLockoutDuration = 30; /** Password reset token validity duration in minutes. */ + @Min(1) private int passwordResetTokenValidityMinutes = 1440; - /** Whether to require canonical app URL for redirect validation. */ + /** + * When true, fail startup unless user.security.appUrl or a non-empty user.security.trustedHosts is + * configured, so security email links (password reset, verification) can never derive their authority from a + * spoofable Host header (CWE-640). When false (default), the library logs a startup warning instead of failing. + */ private boolean requireCanonicalAppUrl = false; /** Whether to perform hash time tests during startup. */ private boolean testHashTime = true; - /** Whether to allow initial password set without step-up authentication. */ + /** + * Controls the fallback behavior of POST /user/setPassword when no StepUpService bean is present. When false + * (default), setting an initial password on a passwordless (passkey-only) account is disabled (HTTP 403) + * unless a StepUpService is provided; set to true to explicitly allow the session-only behavior (SUF-02). + */ private boolean allowInitialPasswordSetWithoutStepUp = false; - /** Base application URL for redirect validation and email links. */ + /** + * Canonical base URL for security email links (password reset, verification). STRONGLY recommended in + * production to prevent Host-header poisoning (CWE-640). When set, X-Forwarded-Host is ignored. + */ private String appUrl = ""; - /** List of trusted hosts for redirect validation. */ + /** + * When user.security.appUrl is not set, X-Forwarded-Host and the ordinary request host are honored for + * security email links only when they appear in this allow-list; a non-allow-listed host falls back to the + * first entry (comma-delimited in .properties files). + */ private List trustedHosts = new ArrayList<>(); - /** HMAC secret used to hash password-reset tokens at rest. Excluded from {@code toString}. */ + /** + * 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 (adequate because the tokens are high-entropy); setting a secret adds + * defense-in-depth against a database-only compromise. Excluded from toString output. + */ @ToString.Exclude private String tokenHashSecret; @@ -105,9 +154,13 @@ public List getDisableCsrfUris() { return filterBlank(disableCsrfUris); } + public List getTrustedHosts() { + return filterBlank(trustedHosts); + } + private static List filterBlank(List values) { if (values == null) { - return new ArrayList<>(); + return List.of(); } List filtered = new ArrayList<>(values.size()); for (String value : values) { @@ -115,6 +168,6 @@ private static List filterBlank(List values) { filtered.add(value.trim()); } } - return filtered; + return List.copyOf(filtered); } } 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 9016745..338710d 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java @@ -33,9 +33,12 @@ import lombok.extern.slf4j.Slf4j; /** - * The WebSecurityConfig class is a Spring Boot configuration class that provides properties for configuring the web security. This class is used to - * define properties that control the behavior of the web security, such as the default action for protected URIs and the URIs that are protected or - * unprotected. + * Builds the library's Spring Security filter chain: form login, logout, remember-me, session registry wiring, + * CSRF exemptions, OAuth2/WebAuthn/MFA setup, and the authorization rules derived from + * {@code user.security.defaultAction} and the protected/unprotected URI lists. All {@code user.security.*} values + * are read from {@link UserSecurityConfigProperties} and {@link RememberMeConfigProperties}; the resulting chain + * is exposed as a bean by {@link WebSecurityFilterChainAutoConfiguration}, which backs off when the consuming + * application defines its own {@link SecurityFilterChain}. */ @Slf4j @Data @@ -118,8 +121,14 @@ public SecurityFilterChain buildSecurityFilterChain(HttpSecurity http, SessionRe PersistentTokenRepository tokenRepository = persistentTokenRepositoryProvider.getIfAvailable(); if (tokenRepository != null) { rememberMe.tokenRepository(tokenRepository); + } else if (rememberMeConfig.isUsePersistentTokens()) { + log.warn("WebSecurityConfig: user.security.rememberMe.usePersistentTokens=true but no PersistentTokenRepository bean is " + + "present; falling back to hash-based remember-me tokens, which SessionInvalidationService cannot revoke."); } }); + } else if (rememberMeConfig.isEnabled()) { + log.warn("WebSecurityConfig: user.security.rememberMe.enabled=true but no user.security.rememberMe.key is configured; " + + "remember-me is NOT configured. Set a stable signing key to activate it."); } // Use the LogoutSuccessService handler (instead of logoutSuccessUrl) so logout publishes an audit event. diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/TokenHasher.java b/src/main/java/com/digitalsanctuary/spring/user/service/TokenHasher.java index 97ec2b9..91d0b66 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/TokenHasher.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/TokenHasher.java @@ -61,7 +61,9 @@ public TokenHasher(final UserSecurityConfigProperties userSecurityConfig) { 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). " + // INFO (not DEBUG) so the security-relevant fallback is visible in production logs; not WARN because + // unkeyed SHA-256 is an acceptable configuration for these high-entropy tokens (see class JavaDoc). + log.info("TokenHasher initialized without a secret (plain SHA-256). " + "Set user.security.tokenHashSecret for keyed hashing."); } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/web/UserSecurityUriControllerAdvice.java b/src/main/java/com/digitalsanctuary/spring/user/web/UserSecurityUriControllerAdvice.java index eb38be1..af0cfec 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/web/UserSecurityUriControllerAdvice.java +++ b/src/main/java/com/digitalsanctuary/spring/user/web/UserSecurityUriControllerAdvice.java @@ -9,9 +9,12 @@ import com.digitalsanctuary.spring.user.security.UserSecurityConfigProperties; /** - * Exposes {@link UserSecurityUriView} as the {@code userSecurity} model attribute on every {@code @Controller} - * request, so consuming templates read framework URIs without SpEL bean access. Registered by default; opt out - * with {@code user.security.expose-uris-to-model=false}. {@code userSecurity} is a reserved model-attribute name. + * Exposes {@link UserSecurityUriView} as the {@code userSecurity} model attribute so consuming templates read + * framework URIs without SpEL bean access. Applies to every {@code @Controller} request — including + * {@code @RestController} handlers, which are meta-annotated with {@code @Controller}; for those the model is + * simply discarded with the response body unaffected. Registered by default; opt out with + * {@code user.security.expose-uris-to-model=false} (bound as {@code exposeUrisToModel} on + * {@link UserSecurityConfigProperties}). {@code userSecurity} is a reserved model-attribute name. */ @ConditionalOnProperty(name = "user.security.expose-uris-to-model", havingValue = "true", matchIfMissing = true) @ControllerAdvice(annotations = Controller.class) 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 4b2d1cc..21dc8af 100644 --- a/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -139,12 +139,6 @@ "type": "java.lang.Boolean", "description": "Enable the library's Spring Security method-level security (@EnableMethodSecurity). Set to false if the consuming application already enables method security globally to avoid double-activation.", "defaultValue": true - }, - { - "name": "user.security.expose-uris-to-model", - "type": "java.lang.Boolean", - "description": "Expose the user.security.* page/action URIs (plus user.copyrightFirstYear) as the secret-free userSecurity model attribute on every @Controller request, so templates can reference e.g. ${userSecurity.loginPageUri} instead of SpEL bean access, which Thymeleaf 3.1.5 forbids in restricted (layout-decorated) contexts. Set to false to disable the advice entirely.", - "defaultValue": true } ] } diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/CoreBeanOverrideTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/CoreBeanOverrideTest.java index 09ee998..03af7fc 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/CoreBeanOverrideTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/CoreBeanOverrideTest.java @@ -57,10 +57,22 @@ class CoreBeanOverrideTest { * {@code @ConditionalOnMissingBean} evaluates AFTER any user-supplied beans. */ private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + // Same attach SpringApplication performs, so @ConditionalOnProperty relaxed-matches camelCase spellings. + .withInitializer(context -> org.springframework.boot.context.properties.source.ConfigurationPropertySources + .attach(context.getEnvironment())) .withBean(UserDetailsService.class, () -> username -> User.withUsername("test").password("x").authorities("ROLE_USER").build()) .withBean(RolesAndPrivilegesConfig.class, CoreBeanOverrideTest::roleConfig) + // The user.security.* properties beans are registered by UserConfiguration in a real boot; this + // isolated runner supplies them directly. + .withUserConfiguration(SecurityPropertiesConfig.class) .withConfiguration(AutoConfigurations.of(UserSecurityBeansAutoConfiguration.class)); + @org.springframework.context.annotation.Configuration(proxyBeanMethods = false) + @org.springframework.boot.context.properties.EnableConfigurationProperties({UserSecurityConfigProperties.class, + RememberMeConfigProperties.class, PasswordPolicyConfigProperties.class}) + static class SecurityPropertiesConfig { + } + 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. @@ -314,7 +326,9 @@ void persistentTokenRepositoryIsConditionalAndGated() throws Exception { assertThat(method.getAnnotation(ConditionalOnMissingBean.class)).as("@ConditionalOnMissingBean must be present").isNotNull(); ConditionalOnProperty onProperty = method.getAnnotation(ConditionalOnProperty.class); assertThat(onProperty).as("@ConditionalOnProperty must gate the bean so it is never created without opt-in").isNotNull(); - assertThat(onProperty.name()).contains("user.security.rememberMe.usePersistentTokens"); + // Canonical kebab-case so the condition relaxed-matches every spelling (kebab, camelCase, env var); + // the previous camelCase name only exact-matched the literal camelCase key. + assertThat(onProperty.name()).contains("user.security.remember-me.use-persistent-tokens"); assertThat(onProperty.havingValue()).isEqualTo("true"); } } diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/PasswordPolicyConfigPropertiesTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/PasswordPolicyConfigPropertiesTest.java index 2e528df..69d362a 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/PasswordPolicyConfigPropertiesTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/PasswordPolicyConfigPropertiesTest.java @@ -42,4 +42,34 @@ void shouldBindKebabKeysWhenConfigured() { assertThat(p.getHistoryCount()).isEqualTo(5); }); } + + // These startup-failure tests exercise Bean Validation on the bound properties, which is active because + // hibernate-validator is on the test classpath. In a consuming app without a validator the constraints are + // inert (documented on the class); with one, an impossible policy fails startup with the property named. + @Test + void shouldFailStartupWhenMinLengthExceedsMaxLength() { + contextRunner.withPropertyValues("user.security.password.min-length=20", + "user.security.password.max-length=10").run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).rootCause() + .hasMessageContaining("minLength must be less than or equal to maxLength"); + }); + } + + @Test + void shouldFailStartupWhenRequireSpecialWithEmptySpecialChars() { + contextRunner.withPropertyValues("user.security.password.special-chars=").run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).rootCause() + .hasMessageContaining("specialChars must not be empty"); + }); + } + + @Test + void shouldFailStartupWhenSimilarityThresholdOutsideRange() { + contextRunner.withPropertyValues("user.security.password.similarity-threshold=150").run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).rootCause().hasMessageContaining("similarityThreshold"); + }); + } } diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/PersistentTokenRepositoryConditionTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/PersistentTokenRepositoryConditionTest.java new file mode 100644 index 0000000..7f8bdd1 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/PersistentTokenRepositoryConditionTest.java @@ -0,0 +1,68 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.context.properties.source.ConfigurationPropertySources; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; + +import com.digitalsanctuary.spring.user.roles.RolesAndPrivilegesConfig; + +/** + * The persistent-token repository condition must relaxed-match every spelling of + * {@code user.security.remember-me.use-persistent-tokens}: an exact-camelCase condition previously ignored the + * kebab spelling advertised by the generated metadata, silently downgrading remember-me to hash-based tokens + * (which {@code SessionInvalidationService} cannot revoke). + */ +@DisplayName("PersistentTokenRepository @ConditionalOnProperty spelling") +class PersistentTokenRepositoryConditionTest { + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties({UserSecurityConfigProperties.class, RememberMeConfigProperties.class, + PasswordPolicyConfigProperties.class}) + static class PropertiesConfig { + } + + // roleHierarchy() must produce a non-null bean for methodSecurityExpressionHandler's injection. + private static RolesAndPrivilegesConfig roleConfig() { + RolesAndPrivilegesConfig config = new RolesAndPrivilegesConfig(); + config.setRoleHierarchy(java.util.List.of("ROLE_ADMIN > ROLE_USER")); + return config; + } + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + // Same attach SpringApplication performs, so relaxed @ConditionalOnProperty matching behaves as in a + // real boot. + .withInitializer(context -> ConfigurationPropertySources.attach(context.getEnvironment())) + .withUserConfiguration(PropertiesConfig.class) + .withBean(UserDetailsService.class, () -> mock(UserDetailsService.class)) + .withBean(RolesAndPrivilegesConfig.class, PersistentTokenRepositoryConditionTest::roleConfig) + .withBean(DataSource.class, () -> mock(DataSource.class)) + .withConfiguration(AutoConfigurations.of(UserSecurityBeansAutoConfiguration.class)); + + @Test + void shouldNotCreateRepositoryWhenPersistentTokensUnset() { + contextRunner.run(context -> assertThat(context).doesNotHaveBean(PersistentTokenRepository.class)); + } + + @Test + void shouldCreateRepositoryWhenKebabSpellingEnablesPersistentTokens() { + contextRunner.withPropertyValues("user.security.remember-me.use-persistent-tokens=true") + .run(context -> assertThat(context).hasSingleBean(PersistentTokenRepository.class)); + } + + @Test + void shouldCreateRepositoryWhenCamelCaseSpellingEnablesPersistentTokens() { + contextRunner.withPropertyValues("user.security.rememberMe.usePersistentTokens=true") + .run(context -> assertThat(context).hasSingleBean(PersistentTokenRepository.class)); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/UriPlaceholderParityTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/UriPlaceholderParityTest.java index 35759ca..734d0db 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/UriPlaceholderParityTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/UriPlaceholderParityTest.java @@ -2,6 +2,8 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.lang.reflect.Method; +import java.util.LinkedHashMap; import java.util.Map; import org.junit.jupiter.api.DisplayName; @@ -10,6 +12,10 @@ import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; +import org.springframework.web.bind.annotation.GetMapping; + +import com.digitalsanctuary.spring.user.controller.UserActionController; +import com.digitalsanctuary.spring.user.controller.UserPageController; @DisplayName("Mapping-placeholder keys stay in sync with the bound bean") class UriPlaceholderParityTest { @@ -19,28 +25,92 @@ class UriPlaceholderParityTest { static class TestConfig { } + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(UserSecurityConfigProperties.class) + static class TestConfigWithValidator { + @org.springframework.context.annotation.Bean + UriPlaceholderParityValidator uriPlaceholderParityValidator(Environment environment, + UserSecurityConfigProperties userSecurityConfig) { + return new UriPlaceholderParityValidator(environment, userSecurityConfig); + } + } + + /** + * Extracts every {@code ${user.security.:}} placeholder from the {@code @GetMapping} + * annotations on the given controller class, mapped key -> inline default. + */ + private static Map mappingPlaceholders(Class controller) { + Map placeholders = new LinkedHashMap<>(); + for (Method method : controller.getDeclaredMethods()) { + GetMapping mapping = method.getAnnotation(GetMapping.class); + if (mapping == null) { + continue; + } + for (String value : mapping.value()) { + if (value.startsWith("${user.security.") && value.endsWith("}")) { + String inner = value.substring(2, value.length() - 1); + int colon = inner.indexOf(':'); + assertThat(colon).as("placeholder %s must carry an inline default", value).isPositive(); + placeholders.put(inner.substring(0, colon), inner.substring(colon + 1)); + } + } + } + return placeholders; + } + + @Test + void shouldCoverEveryControllerPlaceholderWhenValidatorMapIsChecked() { + Map placeholders = new LinkedHashMap<>(); + placeholders.putAll(mappingPlaceholders(UserPageController.class)); + placeholders.putAll(mappingPlaceholders(UserActionController.class)); + + // Every @GetMapping placeholder key must be covered by the startup validator, so a new mapped URI + // property cannot be added without extending the parity guard. + assertThat(UriPlaceholderParityValidator.MAPPING_PLACEHOLDER_KEYS.keySet()) + .containsExactlyInAnyOrderElementsOf(placeholders.keySet()); + + // And every inline placeholder default must equal the field initializer, since both the placeholder + // fallback and the validator's comparison fallback rely on that equivalence. + UserSecurityConfigProperties defaults = new UserSecurityConfigProperties(); + placeholders.forEach((key, inlineDefault) -> assertThat( + UriPlaceholderParityValidator.MAPPING_PLACEHOLDER_KEYS.get(key).apply(defaults)) + .as("inline default of placeholder %s must equal the field initializer", key) + .isEqualTo(inlineDefault)); + } + @Test void shouldEqualBeanGetterWhenEnvironmentResolvesCamelCaseKey() { - // Keys used as @GetMapping/@RequestMapping/@ConditionalOnProperty placeholders elsewhere in the framework. - Map> mappingKeys = Map.of( - "user.security.loginPageURI", UserSecurityConfigProperties::getLoginPageUri, - "user.security.registrationURI", UserSecurityConfigProperties::getRegistrationUri, - "user.security.changePasswordURI", UserSecurityConfigProperties::getChangePasswordUri, - "user.security.forgotPasswordChangeURI", UserSecurityConfigProperties::getForgotPasswordChangeUri, - "user.security.registrationConfirmURI", UserSecurityConfigProperties::getRegistrationConfirmUri); - - new ApplicationContextRunner().withUserConfiguration(TestConfig.class) - .withPropertyValues("user.security.loginPageURI=/user/login.html", - "user.security.registrationURI=/user/register.html", - "user.security.changePasswordURI=/user/changePassword", - "user.security.forgotPasswordChangeURI=/user/forgot-password-change.html", - "user.security.registrationConfirmURI=/user/registrationConfirm") + String[] properties = UriPlaceholderParityValidator.MAPPING_PLACEHOLDER_KEYS.keySet().stream() + .map(key -> key + "=/custom" + key.substring(key.lastIndexOf('.') + 1)) + .toArray(String[]::new); + + new ApplicationContextRunner().withUserConfiguration(TestConfig.class).withPropertyValues(properties) .run(context -> { Environment env = context.getEnvironment(); UserSecurityConfigProperties bean = context.getBean(UserSecurityConfigProperties.class); - mappingKeys.forEach((key, getter) -> assertThat(getter.apply(bean)) - .as("bean value for %s must equal the placeholder-resolved Environment value", key) - .isEqualTo(env.getProperty(key))); + UriPlaceholderParityValidator.MAPPING_PLACEHOLDER_KEYS + .forEach((key, getter) -> assertThat(getter.apply(bean)) + .as("bean value for %s must equal the placeholder-resolved Environment value", key) + .isEqualTo(env.getProperty(key))); + }); + } + + @Test + void shouldStartWhenUriKeysUseCamelCaseSpelling() { + new ApplicationContextRunner().withUserConfiguration(TestConfigWithValidator.class) + .withPropertyValues("user.security.loginPageURI=/custom/login.html").run(context -> { + assertThat(context).hasNotFailed(); + }); + } + + @Test + void shouldFailStartupWhenUriKeyUsesKebabSpellingOnly() { + new ApplicationContextRunner().withUserConfiguration(TestConfigWithValidator.class) + .withPropertyValues("user.security.change-password-uri=/custom/changePassword").run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).rootCause() + .hasMessageContaining("user.security.changePasswordURI") + .hasMessageContaining("camelCase"); }); } } diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigPropertiesTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigPropertiesTest.java index d341db9..11449f1 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigPropertiesTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigPropertiesTest.java @@ -1,6 +1,7 @@ package com.digitalsanctuary.spring.user.security; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -59,4 +60,30 @@ void shouldReturnEmptyListWhenUriListPropertyBlank() { assertThat(context.getBean(UserSecurityConfigProperties.class).getDisableCsrfUris()).isEmpty(); }); } + + @Test + void shouldNormalizeTrustedHostsWhenBindingBlankAndPaddedEntries() { + contextRunner.withPropertyValues("user.security.trustedHosts= app.example.com ,,www.example.com ") + .run(context -> { + assertThat(context.getBean(UserSecurityConfigProperties.class).getTrustedHosts()) + .containsExactly("app.example.com", "www.example.com"); + }); + } + + @Test + void shouldReturnImmutableListsWhenReadingUriGetters() { + UserSecurityConfigProperties p = new UserSecurityConfigProperties(); + assertThatThrownBy(() -> p.getUnprotectedUris().add("/x")) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> p.getTrustedHosts().add("evil.example")) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void shouldFailStartupWhenBcryptStrengthOutsideValidRange() { + contextRunner.withPropertyValues("user.security.bcryptStrength=50").run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).rootCause().hasMessageContaining("bcryptStrength"); + }); + } } diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityDefaultsParityTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityDefaultsParityTest.java index 607bf41..97c79b8 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityDefaultsParityTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityDefaultsParityTest.java @@ -48,15 +48,38 @@ void shouldMatchShippedFileWhenBindingPasswordFields() throws Exception { .isEqualTo(p.getProperty("user.security.password.similarity-threshold")); } - @Test - void shouldEqualInitializersWhenBindingShippedFile() throws Exception { + private static MockEnvironment shippedEnvironment() throws Exception { MockEnvironment env = new MockEnvironment(); new ResourcePropertySource(new ClassPathResource("config/dsspringuserconfig.properties")).getSource() .forEach((k, v) -> env.setProperty(k, String.valueOf(v))); + return env; + } + + // Lombok @Data equals compares via the getters, so these whole-object assertions cover every field of each + // class — any drift between a field initializer and the shipped file (or a new field added to only one of + // the two) fails here, not just the handful of sampled fields. + @Test + void shouldEqualInitializersWhenBindingShippedFile() throws Exception { + MockEnvironment env = shippedEnvironment(); UserSecurityConfigProperties bound = Binder.get(env) .bind("user.security", UserSecurityConfigProperties.class).get(); - assertThat(bound.getLoginPageUri()).isEqualTo(new UserSecurityConfigProperties().getLoginPageUri()); - assertThat(bound.getBcryptStrength()).isEqualTo(new UserSecurityConfigProperties().getBcryptStrength()); + assertThat(bound).isEqualTo(new UserSecurityConfigProperties()); + } + + @Test + void shouldEqualPasswordInitializersWhenBindingShippedFile() throws Exception { + MockEnvironment env = shippedEnvironment(); + PasswordPolicyConfigProperties bound = Binder.get(env) + .bind("user.security.password", PasswordPolicyConfigProperties.class).get(); + assertThat(bound).isEqualTo(new PasswordPolicyConfigProperties()); + } + + @Test + void shouldEqualRememberMeInitializersWhenBindingShippedFile() throws Exception { + MockEnvironment env = shippedEnvironment(); + RememberMeConfigProperties bound = Binder.get(env) + .bind("user.security.remember-me", RememberMeConfigProperties.class).get(); + assertThat(bound).isEqualTo(new RememberMeConfigProperties()); } @Test diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityMetadataCoverageTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityMetadataCoverageTest.java index 42a3765..d0a4bb4 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityMetadataCoverageTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityMetadataCoverageTest.java @@ -21,7 +21,7 @@ private static String canonical(String name) { } @Test - void generatedMetadataContainsEveryRetiredKey() throws Exception { + void shouldContainEveryRetiredKeyWhenMetadataIsGenerated() throws Exception { ObjectMapper mapper = new ObjectMapper(); List legacy = mapper.readValue( new ClassPathResource("metadata/legacy-user-security-keys.json").getInputStream(), @@ -32,7 +32,8 @@ void generatedMetadataContainsEveryRetiredKey() throws Exception { Set generatedNames = generated.get("properties").findValuesAsText("name").stream() .map(UserSecurityMetadataCoverageTest::canonical).collect(Collectors.toSet()); - assertThat(legacy.stream().map(UserSecurityMetadataCoverageTest::canonical)) - .allMatch(generatedNames::contains); + // containsAll (not allMatch) so a failure names the missing keys instead of reporting "false". + assertThat(generatedNames) + .containsAll(legacy.stream().map(UserSecurityMetadataCoverageTest::canonical).toList()); } } diff --git a/src/test/java/com/digitalsanctuary/spring/user/web/UserSecurityUriControllerAdviceTest.java b/src/test/java/com/digitalsanctuary/spring/user/web/UserSecurityUriControllerAdviceTest.java index 17752c0..efd6fc0 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/web/UserSecurityUriControllerAdviceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/web/UserSecurityUriControllerAdviceTest.java @@ -5,8 +5,13 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.lang.reflect.RecordComponent; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; import org.springframework.stereotype.Controller; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; @@ -25,8 +30,16 @@ public String page() { } } + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(UserSecurityConfigProperties.class) + static class PropertiesOnlyConfig { + } + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(PropertiesOnlyConfig.class, UserSecurityUriControllerAdvice.class); + @Test - void shouldExposeUserSecurityViewWithUrisAndCopyrightYear() throws Exception { + void shouldExposeUserSecurityViewWhenHandlingControllerRequest() throws Exception { UserSecurityConfigProperties props = new UserSecurityConfigProperties(); UserSecurityUriControllerAdvice advice = new UserSecurityUriControllerAdvice(props, "2020"); MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TestPageController()) @@ -34,17 +47,88 @@ void shouldExposeUserSecurityViewWithUrisAndCopyrightYear() throws Exception { mockMvc.perform(get("/user-security-advice-test-page")).andExpect(status().isOk()) .andExpect(model().attributeExists("userSecurity")); + } - UserSecurityUriView view = advice.userSecurity(); - assertThat(view.loginPageUri()).isEqualTo("/user/login.html"); - assertThat(view.copyrightFirstYear()).isEqualTo("2020"); + @Test + void shouldMapEveryConfigUriWhenBuildingView() { + // Distinct sentinel per field so a transposition anywhere in the 18-argument constructor call fails. + UserSecurityConfigProperties props = new UserSecurityConfigProperties(); + props.setLoginPageUri("/s/loginPage"); + props.setLoginActionUri("/s/loginAction"); + props.setLoginSuccessUri("/s/loginSuccess"); + props.setLogoutActionUri("/s/logoutAction"); + props.setLogoutSuccessUri("/s/logoutSuccess"); + props.setRegistrationUri("/s/registration"); + props.setRegistrationPendingUri("/s/registrationPending"); + props.setRegistrationSuccessUri("/s/registrationSuccess"); + props.setRegistrationNewVerificationUri("/s/registrationNewVerification"); + props.setRegistrationConfirmUri("/s/registrationConfirm"); + props.setForgotPasswordUri("/s/forgotPassword"); + props.setForgotPasswordPendingUri("/s/forgotPasswordPending"); + props.setForgotPasswordChangeUri("/s/forgotPasswordChange"); + props.setUpdateUserUri("/s/updateUser"); + props.setUpdatePasswordUri("/s/updatePassword"); + props.setDeleteAccountUri("/s/deleteAccount"); + props.setChangePasswordUri("/s/changePassword"); + + UserSecurityUriView view = new UserSecurityUriControllerAdvice(props, "1999").userSecurity(); + + assertThat(view.loginPageUri()).isEqualTo("/s/loginPage"); + assertThat(view.loginActionUri()).isEqualTo("/s/loginAction"); + assertThat(view.loginSuccessUri()).isEqualTo("/s/loginSuccess"); + assertThat(view.logoutActionUri()).isEqualTo("/s/logoutAction"); + assertThat(view.logoutSuccessUri()).isEqualTo("/s/logoutSuccess"); + assertThat(view.registrationUri()).isEqualTo("/s/registration"); + assertThat(view.registrationPendingUri()).isEqualTo("/s/registrationPending"); + assertThat(view.registrationSuccessUri()).isEqualTo("/s/registrationSuccess"); + assertThat(view.registrationNewVerificationUri()).isEqualTo("/s/registrationNewVerification"); + assertThat(view.registrationConfirmUri()).isEqualTo("/s/registrationConfirm"); + assertThat(view.forgotPasswordUri()).isEqualTo("/s/forgotPassword"); + assertThat(view.forgotPasswordPendingUri()).isEqualTo("/s/forgotPasswordPending"); + assertThat(view.forgotPasswordChangeUri()).isEqualTo("/s/forgotPasswordChange"); + assertThat(view.updateUserUri()).isEqualTo("/s/updateUser"); + assertThat(view.updatePasswordUri()).isEqualTo("/s/updatePassword"); + assertThat(view.deleteAccountUri()).isEqualTo("/s/deleteAccount"); + assertThat(view.changePasswordUri()).isEqualTo("/s/changePassword"); + assertThat(view.copyrightFirstYear()).isEqualTo("1999"); } @Test - void viewMustNotExposeTheTokenHashSecret() { - // The view is a fixed record of URIs + copyright; it has no accessor for secrets. - for (var component : UserSecurityUriView.class.getRecordComponents()) { - assertThat(component.getName()).doesNotContainIgnoringCase("secret"); + void shouldNotExposeSecretsWhenConfigContainsThem() throws Exception { + UserSecurityConfigProperties props = new UserSecurityConfigProperties(); + String sentinelSecret = "sentinel-token-hash-secret-value"; + props.setTokenHashSecret(sentinelSecret); + + UserSecurityUriView view = new UserSecurityUriControllerAdvice(props, "2020").userSecurity(); + + // The view must hold only plain strings, and none of them may carry the secret's value — a + // component-name check alone would miss a renamed field that still holds the secret. + for (RecordComponent component : UserSecurityUriView.class.getRecordComponents()) { + assertThat(component.getType()).isEqualTo(String.class); + Object value = component.getAccessor().invoke(view); + assertThat(value).isNotEqualTo(sentinelSecret); } } + + @Test + void shouldRegisterAdviceWhenExposeUrisToModelUnset() { + contextRunner.run(context -> assertThat(context).hasSingleBean(UserSecurityUriControllerAdvice.class)); + } + + @Test + void shouldNotRegisterAdviceWhenExposeUrisToModelFalse() { + contextRunner.withPropertyValues("user.security.expose-uris-to-model=false") + .run(context -> assertThat(context).doesNotHaveBean(UserSecurityUriControllerAdvice.class)); + } + + @Test + void shouldNotRegisterAdviceWhenExposeUrisToModelFalseWithCamelCaseSpelling() { + // Relaxed matching of @ConditionalOnProperty needs the attached ConfigurationPropertySources, exactly as + // SpringApplication provides in a real boot. + contextRunner + .withInitializer(context -> org.springframework.boot.context.properties.source.ConfigurationPropertySources + .attach(context.getEnvironment())) + .withPropertyValues("user.security.exposeUrisToModel=false") + .run(context -> assertThat(context).doesNotHaveBean(UserSecurityUriControllerAdvice.class)); + } } From 5687e83778954e1ba4c6a715ad00c58b072e0b7f Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 14 Aug 2026 10:22:19 -0600 Subject: [PATCH 22/24] docs: document user.security startup guards and constructor changes - CONFIG.md: kebab-only URI spellings now fail startup instead of silently splitting config from controllers; note startup-time Bean Validation of ranges. - MIGRATION.md: heading no longer claims "no action required"; lists the changed constructors (TokenHasher, LoginSuccessService, and the Lombok-generated ones), the new startup checks, and the bcryptStrength code-fallback alignment (10 -> 12, effective behavior unchanged). - CHANGELOG.md: entries for the persistent-token spelling fix, the parity/validation startup checks, and the new warnings. --- CHANGELOG.md | 8 +++++++- CONFIG.md | 4 +++- MIGRATION.md | 23 +++++++++++++++++++++-- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a98e15..49a96c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,16 @@ All notable changes to this project are documented here. This project follows [S ## [Unreleased] ### Refactoring -- Internal refactor of `user.security.*` to typed `@ConfigurationProperties`: `UserSecurityConfigProperties` (page/action URIs, URI lists, security scalars), `PasswordPolicyConfigProperties`, and `RememberMeConfigProperties`. Config keys are **unchanged** — no consumer action required. `WebSecurityConfig`'s previously `@Data`-generated public URI getters (e.g. `getLoginPageURI()`) are removed; they had no callers outside the framework. +- Internal refactor of `user.security.*` to typed `@ConfigurationProperties`: `UserSecurityConfigProperties` (page/action URIs, URI lists, security scalars), `PasswordPolicyConfigProperties`, and `RememberMeConfigProperties`. Config keys are **unchanged** — no consumer action required. `WebSecurityConfig`'s previously `@Data`-generated public URI getters (e.g. `getLoginPageURI()`) are removed; they had no callers outside the framework. Constructors of the migrated consumers now take the typed properties objects (relevant only if you instantiate or subclass them directly — see MIGRATION.md). ### Features - New `${userSecurity}` model attribute exposes the configured page/action URIs to Thymeleaf templates (e.g. `${userSecurity.loginPageUri}`) without SpEL bean access. Registered by default; opt out with `user.security.expose-uris-to-model=false`. +- Startup check: a `user.security.*` URI set with a kebab-case or environment-variable spelling (which the typed configuration accepts but request-mapping placeholders do not) now fails startup with the offending keys named, instead of silently splitting the security configuration from the mapped controllers. +- Startup validation of configuration ranges (bcrypt strength 4–31, password-policy `minLength <= maxLength`, `similarityThreshold` 0–100, non-empty `specialChars` when required) when a Bean Validation implementation is on the classpath. +- Remember-me enabled without a signing key, and `usePersistentTokens=true` without a `PersistentTokenRepository` bean, now log explicit warnings instead of silently skipping/downgrading. + +### Fixed +- `user.security.rememberMe.usePersistentTokens` was only honored in its exact camelCase spelling; the kebab-case spelling advertised by the generated configuration metadata (`user.security.remember-me.use-persistent-tokens`) bound the properties bean but never created the persistent-token repository, silently downgrading remember-me to hash-based tokens (which cannot be revoked server-side). The condition now accepts every relaxed spelling. ## [5.2.0] - 2026-08-12 diff --git a/CONFIG.md b/CONFIG.md index a11043e..2fcb84d 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -78,7 +78,9 @@ user: ## Security Settings -`user.security.*` is bound to a typed `@ConfigurationProperties` class (`UserSecurityConfigProperties`). The **camelCase key spellings shown below are canonical** (e.g. `user.security.loginPageURI`, `user.security.registrationConfirmURI`) — relaxed binding also accepts kebab-case (`user.security.login-page-uri`), but the framework's `@GetMapping`/`@RequestMapping` placeholders resolve the exact camelCase key, so setting only the kebab spelling for a URI property leaves the mapped controller pointed at the default URI while the bean reports your override. Stick to camelCase for anything under `user.security.*`. +`user.security.*` is bound to a typed `@ConfigurationProperties` class (`UserSecurityConfigProperties`). The **camelCase key spellings shown below are canonical** (e.g. `user.security.loginPageURI`, `user.security.registrationConfirmURI`) — relaxed binding also accepts kebab-case (`user.security.login-page-uri`), but the framework's `@GetMapping`/`@RequestMapping` placeholders resolve the exact camelCase key, so a kebab-only spelling for a URI property would move the security configuration without moving the mapped controller. The framework fails startup with the offending keys named if the two ever diverge, so this cannot happen silently. Stick to camelCase for anything under `user.security.*`. + +Range and cross-field checks on these properties (bcrypt strength 4–31, password-policy `minLength <= maxLength`, and similar) are validated at startup when a Bean Validation implementation (e.g. `spring-boot-starter-validation`) is on your classpath; without one they are unenforced. Page and action URIs configured here are also exposed to Thymeleaf templates as the `${userSecurity}` model attribute (e.g. `${userSecurity.loginPageUri}`), registered on every `@Controller` request. Disable it with `user.security.expose-uris-to-model=false` if you don't use it. diff --git a/MIGRATION.md b/MIGRATION.md index 75e38d1..c724537 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -7,7 +7,7 @@ This guide covers migrating applications using the Spring User Framework between - [Migration Guide](#migration-guide) - [Table of Contents](#table-of-contents) - [Migrating to 5.2.x](#migrating-to-52x) - - [`user.security.*` moved to typed configuration properties (no action required)](#usersecurity-moved-to-typed-configuration-properties-no-action-required) + - [`user.security.*` moved to typed configuration properties (config keys unchanged)](#usersecurity-moved-to-typed-configuration-properties-config-keys-unchanged) - [Remember-me completed; two constructors gained parameters](#remember-me-completed-two-constructors-gained-parameters) - [Migrating to 5.0.x](#migrating-to-50x) - [⚠️ ACTION REQUIRED: Reverse-proxy deployments must configure a canonical app URL](#-action-required-reverse-proxy-deployments-must-configure-a-canonical-app-url) @@ -47,7 +47,7 @@ This guide covers migrating applications using the Spring User Framework between ## Migrating to 5.2.x -### `user.security.*` moved to typed configuration properties (no action required) +### `user.security.*` moved to typed configuration properties (config keys unchanged) `user.security.*` (page/action URIs, URI lists, and security scalars) is now bound to a typed `@ConfigurationProperties` class (`UserSecurityConfigProperties`) instead of individual `@Value` @@ -59,6 +59,25 @@ A new `${userSecurity.*}` template attribute is available (e.g. `${userSecurity. Spring Boot 4.1.0+ you **must**, since Thymeleaf 3.1.5 rejects the SpEL bean-access form (`@environment...`) in restricted (layout-decorated) template contexts. +**New startup checks.** The framework now fails startup (with the offending keys named) when a +`user.security.*` URI is set with a kebab-case or environment-variable spelling that the typed +configuration accepts but the framework's request-mapping placeholders do not — a state that +previously split the security configuration from the mapped controllers silently. Use the camelCase +spellings shown in CONFIG.md. Additionally, when a Bean Validation implementation is on your +classpath, out-of-range values (bcrypt strength outside 4–31, password-policy `minLength > +maxLength`, `similarityThreshold` outside 0–100, `requireSpecial` with empty `specialChars`) now +fail startup as named configuration errors instead of misbehaving at runtime. + +**Breaking for direct instantiation/subclassing only** (Spring-injected beans are unaffected): the +`user.security.*` consumers now take the typed properties objects in their constructors — +`TokenHasher(UserSecurityConfigProperties)` replaces `TokenHasher(String)`, `LoginSuccessService` +gained a `UserSecurityConfigProperties` parameter, and the Lombok-generated constructors of +`UserAPI`, `UserActionController`, `LoginAttemptService`, `LogoutSuccessService`, +`UserEmailService`, `UserService`, `PasswordPolicyService`, `WebSecurityConfig`, and +`HtmxAwareAuthenticationEntryPointConfiguration` changed accordingly. Also note the code-level +fallback for `user.security.bcryptStrength` is now 12 (previously a dead `@Value` default of 10 that +the shipped configuration file always overrode with 12 — effective behavior is unchanged). + ### Remember-me completed; two constructors gained parameters Remember-me support is now fully functional (persistent token store, cookie configuration, token From d36ccbdbfa6bd0e09e0578a522571b8fc05139b4 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 14 Aug 2026 10:47:43 -0600 Subject: [PATCH 23/24] docs: move API-surface changes into an explicit Breaking Changes section Copilot review on #356 flagged that the removed WebSecurityConfig getters and changed constructors were documented but framed under Refactoring while the PR claimed to be purely additive. Restructure the Unreleased changelog to match the 5.2.0 format: a dedicated Breaking Changes section scoped to direct instantiation/subclassing, with the getter removal and constructor list spelled out. --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49a96c1..e0526c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ All notable changes to this project are documented here. This project follows [S ## [Unreleased] ### Refactoring -- Internal refactor of `user.security.*` to typed `@ConfigurationProperties`: `UserSecurityConfigProperties` (page/action URIs, URI lists, security scalars), `PasswordPolicyConfigProperties`, and `RememberMeConfigProperties`. Config keys are **unchanged** — no consumer action required. `WebSecurityConfig`'s previously `@Data`-generated public URI getters (e.g. `getLoginPageURI()`) are removed; they had no callers outside the framework. Constructors of the migrated consumers now take the typed properties objects (relevant only if you instantiate or subclass them directly — see MIGRATION.md). +- Internal refactor of `user.security.*` to typed `@ConfigurationProperties`: `UserSecurityConfigProperties` (page/action URIs, URI lists, security scalars), `PasswordPolicyConfigProperties`, and `RememberMeConfigProperties`. Config keys are **unchanged** — no consumer configuration action required. + +### Breaking Changes +- For consumers that subclass or directly instantiate framework components only (Spring-injected beans are unaffected): the migrated `user.security.*` consumers now take the typed properties objects in their constructors — `TokenHasher(UserSecurityConfigProperties)` replaces `TokenHasher(String)`, `LoginSuccessService` gained a `UserSecurityConfigProperties` parameter, and the Lombok-generated constructors of `UserAPI`, `UserActionController`, `LoginAttemptService`, `LogoutSuccessService`, `UserEmailService`, `UserService`, `PasswordPolicyService`, `WebSecurityConfig`, and `HtmxAwareAuthenticationEntryPointConfiguration` changed accordingly. See MIGRATION.md. +- `WebSecurityConfig`'s previously `@Data`-generated public URI getters (e.g. `getLoginPageURI()`, `getUnprotectedURIsProperty()`) are removed. They were byproducts of the removed `@Value` fields, returned raw property strings, and had no callers outside the framework; read the values from `UserSecurityConfigProperties` instead. ### Features - New `${userSecurity}` model attribute exposes the configured page/action URIs to Thymeleaf templates (e.g. `${userSecurity.loginPageUri}`) without SpEL bean access. Registered by default; opt out with `user.security.expose-uris-to-model=false`. From 915219ab218c0d6ae4b06271a9cfeca97a445198 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 14 Aug 2026 13:50:20 -0600 Subject: [PATCH 24/24] fix: restore public LoginAttemptService.getMaxFailedLoginAttempts/getAccountLockoutDuration The @Value-to-@ConfigurationProperties migration dropped LoginAttemptService's public @Data getters (getMaxFailedLoginAttempts/getAccountLockoutDuration). A local integration test against the demo app surfaced that consumers read these directly. Restore them as public accessors delegating to UserSecurityConfigProperties, preserving backward compatibility. --- .../user/service/LoginAttemptService.java | 21 +++++++++++++++++++ .../user/service/LoginAttemptServiceTest.java | 9 ++++++++ 2 files changed, 30 insertions(+) diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/LoginAttemptService.java b/src/main/java/com/digitalsanctuary/spring/user/service/LoginAttemptService.java index eb49a1f..7cea685 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/LoginAttemptService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/LoginAttemptService.java @@ -35,6 +35,27 @@ public class LoginAttemptService { /** The user security configuration properties. */ final private UserSecurityConfigProperties userSecurityConfig; + /** + * The configured maximum number of failed login attempts before an account is locked + * ({@code user.security.failedLoginAttempts}). Retained as a public accessor for backward + * compatibility with consumers that read it directly. + * + * @return the configured maximum failed login attempts + */ + public int getMaxFailedLoginAttempts() { + return userSecurityConfig.getFailedLoginAttempts(); + } + + /** + * The configured account lockout duration in minutes ({@code user.security.accountLockoutDuration}). + * Retained as a public accessor for backward compatibility with consumers that read it directly. + * + * @return the configured account lockout duration in minutes + */ + public int getAccountLockoutDuration() { + return userSecurityConfig.getAccountLockoutDuration(); + } + /** * Login succeeded, reset failed login attempts. * 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 4cd96fe..dab9151 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/LoginAttemptServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/LoginAttemptServiceTest.java @@ -46,6 +46,15 @@ void setUp() { loginAttemptService = new LoginAttemptService(userRepository, userSecurityConfig); } + @Test + void shouldExposeConfiguredThresholdsWhenAccessorsCalled() { + // Backward-compatibility accessors retained after the user.security @ConfigurationProperties migration. + assertThat(loginAttemptService.getMaxFailedLoginAttempts()) + .isEqualTo(userSecurityConfig.getFailedLoginAttempts()); + assertThat(loginAttemptService.getAccountLockoutDuration()) + .isEqualTo(userSecurityConfig.getAccountLockoutDuration()); + } + @Test void loginSucceeded_resetsFailedAttempts() { when(userRepository.findByEmail(anyString())).thenReturn(testUser);