Skip to content

Type user.security.* as @ConfigurationProperties + secret-free ${userSecurity} template attribute - #356

Merged
devondragon merged 24 commits into
mainfrom
feature/user-security-config-properties
Aug 15, 2026
Merged

Type user.security.* as @ConfigurationProperties + secret-free ${userSecurity} template attribute#356
devondragon merged 24 commits into
mainfrom
feature/user-security-config-properties

Conversation

@devondragon

@devondragon devondragon commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Closes #355.

Converts the user.security.* configuration namespace from ~40 scattered @Value injections into a cohesive family of typed @ConfigurationProperties, migrates all internal consumers, replaces hand-maintained config metadata with generated metadata, and adds a secret-free ${userSecurity} template model attribute. Config-compatible — no config key changed for consumers. Constructor signatures of the migrated internal components did change (breaking only for direct instantiation or subclassing; documented in MIGRATION.md and the CHANGELOG's Breaking Changes section).

Design doc: docs/design/2026-08-13-user-security-config-properties-design.md · Plan: docs/plans/2026-08-13-user-security-config-properties-plan.md

What changed

  • Three typed properties classes (com.digitalsanctuary.spring.user.security): UserSecurityConfigProperties (flat URIs/lists/scalars), PasswordPolicyConfigProperties (user.security.password.*), RememberMeConfigProperties (user.security.remember-me.*), registered via @EnableConfigurationProperties on UserSecurityBeansAutoConfiguration. This makes user.security.* consistent with every other config area (MFA, WebAuthn, Captcha, GDPR, …).
  • All 14 internal consumers migrated off user.security.* @Value onto the beans — services, WebSecurityConfig, the auto-config, controllers, and the web interceptor. @GetMapping/@ConditionalOnProperty annotation placeholders are intentionally left as Environment placeholders. WebSecurityConfig's old @Data URI getters were removed (no external callers); splitAndFilterProperty folded into the list getters.
  • Generated config metadata: the 46 hand-maintained user.security.* entries in additional-spring-configuration-metadata.json are deleted and now generated from the typed fields + JavaDoc. A coverage test guards that every retired key is still described.
  • ${userSecurity} template attribute: UserSecurityUriControllerAdvice exposes a narrow, immutable UserSecurityUriView (the page/action URIs + copyrightFirstYear) — never the config bean, so tokenHashSecret can't leak. Opt out with user.security.expose-uris-to-model=false. This lets consuming apps replace ${@environment.getProperty('user.security.*')} (which Thymeleaf 3.1.5 forbids in layout-decorated templates under Spring Boot 4.1.0) with ${userSecurity.*}.

Backward compatibility

  • No config key renamed, moved, or removed; relaxed binding preserves the existing camelCase keys.
  • The shipped config/dsspringuserconfig.properties is byte-identical to main (it stays Environment-visible for @GetMapping placeholder resolution). Field initializers mirror the file's effective values (incl. bcryptStrength=12, password.history-count=3, appUrl=""), guarded by a defaults-parity test.
  • tokenHashSecret and remember-me key carry @ToString.Exclude.

Testing

./gradlew clean build — BUILD SUCCESSFUL, 1152 tests, 0 failures. Added: per-class binding tests, a defaults-parity regression test (scalars + URI lists), a metadata-coverage test, the controller-advice test (asserts the secret is unreachable), and a placeholder/bean parity guard. Filter-chain behavior verified equivalent to main. Migrated test wiring uses real config instances or explicitly-stubbed values (no Mockito zero/false/null standing in for real config).

Notes / follow-ups

  • Documented that camelCase key spellings are canonical (kebab is accepted via relaxed binding, but request-mapping placeholders resolve camelCase). CONFIG.md / CHANGELOG.md / MIGRATION.md updated.
  • Follow-up (separate, in the demo app): adopt Spring Boot 4.1.0 and switch templates to ${userSecurity.*}, closing Adopt Spring Boot 4.1.0: migrate ${@environment.getProperty(...)} template pattern for Thymeleaf 3.1.5 restricted expressions SpringUserFrameworkDemoApp#82.
  • The kebab-only divergence scenario is now covered: UriPlaceholderParityValidator fails startup (naming the keys) when a user.security URI diverges between the bound bean and the camelCase placeholder key, and the parity test covers all 13 placeholder keys reflectively. A multi-agent review pass also fixed the usePersistentTokens conditional (camelCase-only exact match → relaxed), moved @EnableConfigurationProperties to UserConfiguration, typed exposeUrisToModel, added Bean Validation on the properties classes, and corrected the metadata-feeding JavaDoc.

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).
Update tests that construct PasswordPolicyService/UserService directly
(constructor args, @mock fields) for the new constructor parameter.
…consumers

Update tests that construct LoginAttemptService/TokenHasher/UserEmailService
directly (constructor args, @mock fields) and UserApiTest's lockout-threshold
read for the new constructor parameter.
Copilot AI lite review requested due to automatic review settings August 14, 2026 05:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR refactors the library’s user.security.* configuration from scattered @Value injections into typed @ConfigurationProperties beans, migrates internal consumers to those beans, replaces hand-maintained security metadata with generated metadata + coverage tests, and introduces a secret-free ${userSecurity} model attribute for template access.

Changes:

  • Added typed config properties classes for user.security.*, user.security.password.*, and user.security.remember-me.*, and migrated internal wiring to constructor-injected beans.
  • Replaced hand-maintained user.security.* metadata with generated metadata and added tests to guard legacy-key coverage + default parity.
  • Added UserSecurityUriControllerAdvice + UserSecurityUriView to expose secret-free URI data to MVC templates via ${userSecurity.*} (opt-out supported).

Reviewed changes

Copilot reviewed 50 out of 50 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigProperties.java New typed properties for flat user.security.* keys + URI list filtering.
src/main/java/com/digitalsanctuary/spring/user/security/PasswordPolicyConfigProperties.java New typed properties for password policy keys.
src/main/java/com/digitalsanctuary/spring/user/security/RememberMeConfigProperties.java New typed properties for remember-me keys.
src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java Enables the new properties beans; migrates encoder/appUrlResolver wiring to bean-backed values.
src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java Migrates security filter-chain config from @Value fields to injected properties beans.
src/main/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointConfiguration.java Migrates login page URI resolution to injected properties bean.
src/main/java/com/digitalsanctuary/spring/user/service/PasswordPolicyService.java Migrates password policy reads to PasswordPolicyConfigProperties.
src/main/java/com/digitalsanctuary/spring/user/service/UserService.java Migrates password history retention to PasswordPolicyConfigProperties.
src/main/java/com/digitalsanctuary/spring/user/service/LoginAttemptService.java Migrates lockout thresholds/durations to UserSecurityConfigProperties.
src/main/java/com/digitalsanctuary/spring/user/service/LoginSuccessService.java Migrates login redirect + always-use-default behavior to UserSecurityConfigProperties.
src/main/java/com/digitalsanctuary/spring/user/service/LogoutSuccessService.java Migrates logout redirect to UserSecurityConfigProperties.
src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java Migrates reset token validity to UserSecurityConfigProperties.
src/main/java/com/digitalsanctuary/spring/user/service/TokenHasher.java Migrates token secret source to UserSecurityConfigProperties.
src/main/java/com/digitalsanctuary/spring/user/util/PasswordHashTimeTester.java Migrates startup hash-timing flag to UserSecurityConfigProperties.
src/main/java/com/digitalsanctuary/spring/user/controller/UserActionController.java Migrates MVC redirects to UserSecurityConfigProperties.
src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java Migrates API redirect URIs + opt-in flag to UserSecurityConfigProperties.
src/main/java/com/digitalsanctuary/spring/user/web/WebInterceptorConfig.java Migrates interceptor path patterns to UserSecurityConfigProperties.
src/main/java/com/digitalsanctuary/spring/user/web/UserSecurityUriView.java Adds secret-free, template-facing URI view record.
src/main/java/com/digitalsanctuary/spring/user/web/UserSecurityUriControllerAdvice.java Adds ${userSecurity} model attribute (opt-out via property).
src/main/resources/META-INF/additional-spring-configuration-metadata.json Removes hand-maintained user.security.* entries; adds metadata for expose-uris-to-model.
src/test/resources/metadata/legacy-user-security-keys.json Snapshot of retired metadata keys for coverage assertions.
src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityMetadataCoverageTest.java Asserts generated metadata still covers all retired keys.
src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityDefaultsParityTest.java Guards initializer defaults parity vs shipped properties file.
src/test/java/com/digitalsanctuary/spring/user/security/UriPlaceholderParityTest.java Guards mapping-placeholder keys vs bean getter parity.
src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityConfigPropertiesTest.java Binding/defaults tests for UserSecurityConfigProperties.
src/test/java/com/digitalsanctuary/spring/user/security/PasswordPolicyConfigPropertiesTest.java Binding/defaults tests for PasswordPolicyConfigProperties.
src/test/java/com/digitalsanctuary/spring/user/security/RememberMeConfigPropertiesTest.java Binding/defaults tests for RememberMeConfigProperties.
src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfigurationTest.java Updates auto-config tests for new constructor wiring.
src/test/java/com/digitalsanctuary/spring/user/security/HtmxAwareAuthenticationEntryPointConfigurationTest.java Updates entry-point config test to provide properties bean.
src/test/java/com/digitalsanctuary/spring/user/security/CoreBeanOverrideTest.java Updates reflection assertions for changed bean method signature.
src/test/java/com/digitalsanctuary/spring/user/web/UserSecurityUriControllerAdviceTest.java Tests ${userSecurity} attribute + verifies secrets aren’t exposed.
src/test/java/com/digitalsanctuary/spring/user/service/PasswordPolicyServiceTest.java Updates unit tests to mutate PasswordPolicyConfigProperties instead of reflection.
src/test/java/com/digitalsanctuary/spring/user/service/LoginAttemptServiceTest.java Updates unit tests to use UserSecurityConfigProperties wiring.
src/test/java/com/digitalsanctuary/spring/user/service/LoginSuccessServiceTest.java Updates unit tests to stub UserSecurityConfigProperties.
src/test/java/com/digitalsanctuary/spring/user/service/LogoutSuccessServiceTest.java Updates unit tests to stub UserSecurityConfigProperties.
src/test/java/com/digitalsanctuary/spring/user/service/UserEmailServiceTest.java Updates unit tests to pass real properties bean and assert lifetimes.
src/test/java/com/digitalsanctuary/spring/user/service/UserVerificationServiceTest.java Updates unit tests to construct TokenHasher with properties bean.
src/test/java/com/digitalsanctuary/spring/user/service/TokenHasherTest.java Updates unit tests to construct TokenHasher via properties bean.
src/test/java/com/digitalsanctuary/spring/user/service/TokenHashingSecurityTest.java Updates security tests for token hashing + lifetime via properties.
src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java Updates service tests to include PasswordPolicyConfigProperties dependency.
src/test/java/com/digitalsanctuary/spring/user/service/UserServiceRegistrationGuardTest.java Updates service tests to include PasswordPolicyConfigProperties dependency.
src/test/java/com/digitalsanctuary/spring/user/controller/UserActionControllerTest.java Updates controller tests to pass real properties bean.
src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java Updates API unit tests to pass real properties bean and mutate flags directly.
src/test/java/com/digitalsanctuary/spring/user/api/UserAPIRegistrationGuardTest.java Updates API guard tests to pass real properties bean.
src/test/java/com/digitalsanctuary/spring/user/api/UserApiTest.java Updates integration test wiring to read lockout threshold from properties bean.
CONFIG.md Documents canonical camelCase keys + ${userSecurity} template attribute.
MIGRATION.md Adds migration note (no config key change; template access guidance).
CHANGELOG.md Adds unreleased notes for the refactor + ${userSecurity} feature.
docs/design/2026-08-13-user-security-config-properties-design.md Adds design doc for the refactor.
docs/plans/2026-08-13-user-security-config-properties-plan.md Adds implementation plan (long-form).

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +56 to +60
* @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();

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Declining the deprecated TokenHasher(String) overload. TokenHasher is a Spring-wired @Component; the only direct construction is in this repo's own tests, and this project's conventions avoid carrying deprecated compatibility shims for framework-internal wiring. The precedent is 5.2.0, which shipped bean-constructor changes (WebSecurityConfig, SessionInvalidationService) in a minor with a MIGRATION.md note — this change is documented the same way (MIGRATION.md "Breaking for direct instantiation/subclassing only" plus the changelog's Breaking Changes section, d36ccbd).

Comment thread CHANGELOG.md Outdated
Comment on lines +7 to +8
### 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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in d36ccbd: the Unreleased changelog now has a dedicated Breaking Changes section (matching the 5.2.0 format) that spells out the removed WebSecurityConfig getters and the constructor signature changes, scoped to direct instantiation/subclassing. The PR description now says "config-compatible — no config key changed" instead of "purely additive", with the constructor changes called out. MIGRATION.md already documents the migration path.

Comment on lines 48 to 52
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;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Declining the delegating getters, keeping the removal — now explicitly documented as a breaking change rather than implied additive. The removed methods were @Data byproducts of the deleted @Value fields that returned raw property strings (e.g. getUnprotectedURIsProperty()); a review pass over the codebase found no callers outside the framework and no plausible external use for them. The typed UserSecurityConfigProperties bean is the supported way to read these values. The removal and migration path are spelled out in MIGRATION.md and the changelog's new Breaking Changes section (d36ccbd).

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.
- 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.
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.
@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

Review: PR #356 — user.security. to typed @ConfigurationProperties*

Reviewed the full diff (~5,100 lines across 50 files), prioritizing WebSecurityConfig.java, UserSecurityConfigProperties.java, UriPlaceholderParityValidator.java, PasswordPolicyConfigProperties.java/PasswordPolicyService.java, RememberMeConfigProperties.java, UserSecurityUriControllerAdvice.java/UserSecurityUriView.java, UserSecurityBeansAutoConfiguration.java, and the associated tests, cross-checked against config/dsspringuserconfig.properties, MIGRATION.md, and CHANGELOG.md.

Overall: this is a strong, well-tested PR. No blockers or major issues found.

Correctness

  • Spot-checked every default I could find against the shipped properties file (bcryptStrength=12, failedLoginAttempts=10, accountLockoutDuration=30, passwordResetTokenValidityMinutes=1440, password.history-count=3, password.similarity-threshold=70, password.min/max-length=8/128, appUrl="", remember-me defaults, URI lists) — all match the new field initializers byte-for-byte. One nuance worth flagging for anyone diffing history: the old inline @value("${user.security.bcryptStrength:10}") default was 10, vs the new field default of 12. Effective behavior is unchanged (the shipped file always set 12), and MIGRATION.md calls this out explicitly — good catch documenting it rather than letting it hide.
  • filterBlank() in UserSecurityConfigProperties faithfully reproduces the old splitAndFilterProperty trim/drop-blank semantics, and is directly tested.
  • All ~40 traced @value usages across the 14 migrated consumers have a corresponding typed-property getter — nothing appears silently dropped.
  • Nice incidental bug fix: the @ConditionalOnProperty for usePersistentTokens is corrected from a stray camelCase key to the canonical kebab form, fixing a case where the properties bean bound but the PersistentTokenRepository bean was never actually created. Covered by a new dedicated test (PersistentTokenRepositoryConditionTest). Worth double-checking this is called out in the changelog as a fix, since it's a behavior change beyond the described refactor.

Security

  • tokenHashSecret and remember-me key are both @ToString.Exclude'd, and UserSecurityUriView/UserSecurityUriControllerAdvice only expose the 17 URI getters plus copyrightFirstYear — never the secret-bearing fields. UserSecurityUriControllerAdviceTest reflects over every UserSecurityUriView component and asserts none carries a sentinel secret value, which is a genuinely load-bearing regression guard rather than a superficial check.
  • UriPlaceholderParityValidator is fail-closed by design (@PostConstruct, not an async event listener, specifically to avoid a swallowed startup exception) and throws IllegalStateException on any mismatch between the Environment-resolved placeholder value used by @GetMapping and the bound properties value. This correctly catches kebab-only/env-var-only overrides that would otherwise silently desync security config from routing. Good defensive design.

Breaking changes

  • MIGRATION.md and CHANGELOG.md both appear to accurately document the constructor signature changes across migrated consumers and the removal of WebSecurityConfig's Lombok @DaTa URI getters. No additional API-visible breaks beyond what's documented were found.

Test coverage

  • Notably thorough: UserSecurityDefaultsParityTest binds each properties class against the shipped .properties file and asserts whole-object equality (catches drift if a field is added to only one side); UserSecurityMetadataCoverageTest verifies every one of the 46 deleted hand-maintained metadata keys is still described by the annotation-processor-generated metadata; the @ConditionalOnProperty tests correctly exercise real relaxed-binding via ConfigurationPropertySources rather than mocking it away. No migrated consumer appeared to lose coverage in the process.

Minor / nit

  1. TokenHasher.java — the "no secret configured" log line changed from log.debug to log.info as a side effect of this migration. If unintentional, consider reverting to debug; if intentional, it's a behavior change (new INFO-level line on every startup for consumers who don't set tokenHashSecret) worth a one-line changelog mention since it's otherwise easy to miss in a refactor-focused PR.
  2. Design/plan docs in the shipped tree — docs/design/2026-08-13-...md and docs/plans/2026-08-13-...md (~1,300 lines combined) add internal working notes into the repo alongside library source. No functional risk, just a repo-hygiene question of whether these belong long-term in docs/ vs. being squashed out before merge or kept elsewhere.

Nice work on this one — the parity/coverage test suite in particular (defaults parity, metadata coverage, URI placeholder parity) is exactly the kind of guardrail that makes a mechanical-but-risky refactor like this trustworthy.


Generated with Claude Code

…AccountLockoutDuration

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.
@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

Review

Reviewed the diff and the merged working tree (design/plan docs, all security/service source changes, config metadata, and the new tests). This is a large but disciplined refactor — I read closely rather than skimming, and it holds up well.

Overall impression

This is a genuinely careful piece of engineering. The migration from ~40 scattered @Value fields to three typed @ConfigurationProperties classes is clean, the backward-compatibility story (byte-identical shipped properties file, relaxed binding, preserved public getters) is well thought through, and the PR's own commit history shows a real second pass fixing subtle issues (the usePersistentTokens kebab/camelCase split-brain bug, the missing LoginAttemptService getters a downstream demo-app test caught). That kind of self-correction after local integration testing is exactly what you want to see before merge.

Strengths worth calling out

  • UriPlaceholderParityValidator (security/UriPlaceholderParityValidator.java) is a nice piece of defensive engineering: it fails startup, naming the offending key, when a user.security.* URI is set with a kebab-only spelling that the properties bean accepts via relaxed binding but the @GetMapping placeholder doesn't. Turning a silent split-brain config bug into a loud startup failure is the right call, and UriPlaceholderParityTest reflectively verifies the guard's key list stays in sync with the actual @GetMapping annotations (shouldCoverEveryControllerPlaceholderWhenValidatorMapIsChecked) — so a future URI addition can't silently bypass the check.
  • PersistentTokenRepositoryConditionTest directly covers the exact bug the mid-PR fix addresses (kebab vs. camelCase @ConditionalOnProperty matching for use-persistent-tokens) with both spellings exercised against a real ApplicationContextRunner with ConfigurationPropertySources.attach(...), matching real Boot relaxed-binding behavior rather than trusting it by inspection.
  • Secret hygiene: tokenHashSecret and remember-me key both carry @ToString.Exclude, and UserSecurityUriView (the ${userSecurity} template attribute) is a narrow record containing only URIs + copyrightFirstYear — it's structurally impossible for it to leak tokenHashSecret since the field isn't in the record at all. Good defense-in-depth beyond just excluding it from toString().
  • List getters (getProtectedUris(), getUnprotectedUris(), getDisableCsrfUris(), getTrustedHosts()) all filter blanks/trim and return List.copyOf(...) (immutable), preserving the old splitAndFilterProperty semantics that a naive Boot delimited-string bind would have silently dropped (trailing-comma configs producing an empty-string matcher).
  • Bean Validation constraints (@Min/@Max/@AssertTrue cross-field checks like minLength <= maxLength) are additive-only — inert without a validator on the classpath, consistent with the "no behavior change for consumers" goal.
  • Documentation (CHANGELOG/MIGRATION/CONFIG.md) accurately separates the "purely additive, no config key changes" framing from the genuinely breaking constructor-signature changes, and the CHANGELOG was itself restructured mid-PR in response to review feedback (Copilot flagging the mixed framing) — that's a good instinct rather than defensiveness.

Minor observations (non-blocking)

  1. UserSecurityUriControllerAdvice.userSecurity() (web/UserSecurityUriControllerAdvice.java:40-49) constructs a new UserSecurityUriView on every request via @ModelAttribute, even though the design doc describes it as "built once from the beans." Since the underlying UserSecurityConfigProperties bean is immutable after startup, this could be built once (e.g., in a @PostConstruct field) and reused across requests instead of reallocating on every controller invocation. Very low cost as-is (17 string field copies), so this is a nice-to-have, not a real problem.
  2. WebSecurityConfig still carries a class-level @Data annotation (security/WebSecurityConfig.java:44) generating equals/hashCode/toString over fields including Environment, ApplicationEventPublisher, and ObjectProvider — this predates this PR (the PR only removed the old URI getters), but since the file was already being touched substantially here, it might have been a good opportunity to drop @Data in favor of just @RequiredArgsConstructor/@Slf4j, since a @Configuration class has no legitimate use for value-object semantics. Not something this PR needs to fix, just flagging since you were already in the file.
  3. allowInitialPasswordSetWithoutStepUp/requireCanonicalAppUrl/testHashTime etc. all lack explicit @Min/@Max bounds, which is fine since they're booleans, but accountLockoutDuration (security/UserSecurityConfigProperties.java:107) intentionally allows negative values (documented as "lock until admin unlocks") with no validation constraint — worth double-checking that PasswordPolicyConfigProperties-style @AssertTrue guards aren't warranted here too, though given the documented semantics this looks intentional rather than an oversight.
  4. Nice touch that TokenHasher now logs its unkeyed-fallback at INFO instead of DEBUG (service/TokenHasher.java:64-68) — the reasoning in the comment (security-relevant fallback should be visible in production logs) is correct and appropriately scoped (not WARN, since unkeyed SHA-256 is genuinely fine for high-entropy tokens).

Test coverage

Coverage is thorough: per-class binding tests (default + relaxed/legacy-spelling binding), a defaults-parity test comparing shipped-file values against field initializers in both directions, a metadata-coverage test asserting every retired hand-maintained key still resolves in generated metadata, and full request-mapping-reflection-based parity tests. I didn't find gaps worth flagging — the mid-PR commits already closed the ones a first pass would typically catch (persistent-token condition spelling, missing back-compat getters).

Security

No concerns. The ${userSecurity} template attribute is correctly scoped to avoid secret leakage, the URI-parity validator closes a real (if narrow) misconfiguration class, and the password/remember-me secret fields are consistently excluded from toString(). I did not find any new attack surface introduced by this refactor — it's a config-modeling change with equivalent runtime behavior, backed by tests that specifically pin the "effective defaults" equivalence.

Nice work — this is a good example of a large mechanical refactor done with enough test coverage and self-review that I'd be comfortable merging it.

@devondragon
devondragon merged commit 157917e into main Aug 15, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Model user.security.* as typed @ConfigurationProperties (retire scattered @Value; add template-safe access)

2 participants