Skip to content

Commit 1c2b910

Browse files
committed
build: upgrade ds-spring-user-framework to 4.4.0
Point the demo at the released 4.4.0 security batch on Maven Central (was 4.3.1) and adapt the application code/config to its API and behavior changes. Dependency: - ds-spring-user-framework 4.3.1 -> 4.4.0. - Drop the direct org.passay:passay pin. passay is supplied transitively by the framework at the version it requires; pinning it here forced a conflicting downgrade. Application adaptations to 4.4.0: - CustomUserEmailService: UserEmailService's constructor now takes a TokenHasher (token-hashing security fix); pass it through. - Remove MfaSecurityConfig: 4.4.0 installs @EnableMultiFactorAuthentication / the MFA filter-merging post-processor itself, so the demo no longer needs its own copy. - application.yml / application-mfa.yml: the framework now auto-unprotects the configured MFA factor entry-point URIs (#313), so the WebAuthn challenge page no longer has to be listed in unprotectedURIs by hand. - MfaConfigConsistencyTest: assert only what is still the demo's own responsibility (MFA opt-in, passkey enrollment endpoints reachable). - EmailVerificationEdgeCaseSimpleTest: verification tokens are now atomically consumed on the valid path (single-use), so assert the token is gone and the user enabled after validation. Drop framework-internal unit tests that duplicated the library's own suite and no longer match its internals: - UserServiceTest, UserVerificationServiceTest, LoginAttemptServiceTest.
1 parent 2595da6 commit 1c2b910

10 files changed

Lines changed: 57 additions & 396 deletions

File tree

build.gradle

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ repositories {
3939

4040
dependencies {
4141
// DigitalSanctuary Spring User Framework
42-
implementation 'com.digitalsanctuary:ds-spring-user-framework:4.3.1'
42+
implementation 'com.digitalsanctuary:ds-spring-user-framework:4.4.0'
4343

4444
// WebAuthn support (Passkey authentication)
4545
implementation 'org.springframework.security:spring-security-webauthn'
@@ -80,7 +80,9 @@ dependencies {
8080
developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
8181

8282
// Utility libraries
83-
implementation 'org.passay:passay:1.6.6'
83+
// (passay is provided transitively by ds-spring-user-framework at the version it requires; the demo
84+
// app does not use passay directly, so declaring/pinning it here previously forced a conflicting
85+
// downgrade of the library's required passay version.)
8486
implementation 'com.google.guava:guava:33.5.0-jre'
8587
implementation 'org.hibernate.validator:hibernate-validator'
8688

src/main/java/com/digitalsanctuary/spring/demo/config/MfaSecurityConfig.java

Lines changed: 0 additions & 28 deletions
This file was deleted.

src/main/java/com/digitalsanctuary/spring/demo/service/CustomUserEmailService.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import com.digitalsanctuary.spring.user.persistence.model.User;
1313
import com.digitalsanctuary.spring.user.mail.MailService;
1414
import com.digitalsanctuary.spring.user.service.SessionInvalidationService;
15+
import com.digitalsanctuary.spring.user.service.TokenHasher;
1516
import com.digitalsanctuary.spring.user.service.UserEmailService;
1617
import com.digitalsanctuary.spring.user.service.UserVerificationService;
1718

@@ -36,8 +37,9 @@ public CustomUserEmailService(
3637
UserVerificationService userVerificationService,
3738
PasswordResetTokenRepository passwordTokenRepository,
3839
ApplicationEventPublisher eventPublisher,
39-
SessionInvalidationService sessionInvalidationService) {
40-
super(mailService, userVerificationService, passwordTokenRepository, eventPublisher, sessionInvalidationService);
40+
SessionInvalidationService sessionInvalidationService,
41+
TokenHasher tokenHasher) {
42+
super(mailService, userVerificationService, passwordTokenRepository, eventPublisher, sessionInvalidationService, tokenHasher);
4143
}
4244

4345
@Override

src/main/resources/application-mfa.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
# ./gradlew bootRun --args='--spring.profiles.active=local,mfa'
88
#
99
# Notes:
10-
# - The challenge page (user.mfa.webauthnEntryPointUri) must be in unprotectedURIs. The framework
11-
# redirects partially-authenticated users to it; if the page itself required full authentication,
12-
# the redirect would loop forever.
10+
# - The challenge page (user.mfa.webauthnEntryPointUri) is auto-unprotected by the framework: it
11+
# unprotects the configured factor entry-point URIs so the partial-auth redirect cannot loop. (No
12+
# need to list it in unprotectedURIs by hand.)
1313
# - The passkey registration endpoints (/webauthn/register/options, /webauthn/register) are also
1414
# unprotected here so that a partially-authenticated user can enroll their first passkey. Spring
1515
# Security still requires an authenticated principal to register a credential; this only relaxes
@@ -19,4 +19,4 @@ user:
1919
mfa:
2020
enabled: true
2121
security:
22-
unprotectedURIs: /,/index.html,/favicon.ico,/apple-touch-icon-precomposed.png,/css/*,/js/*,/js/user/*,/js/event/*,/js/utils/*,/img/**,/user/registration,/user/registration/passwordless,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword,/oauth2/authorization/*,/login,/user/login,/user/login.html,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**,/event/,/event/list.html,/event/**,/about.html,/error,/error.html,/webauthn/authenticate/**,/login/webauthn,/user/mfa/webauthn-challenge.html,/webauthn/register/options,/webauthn/register
22+
unprotectedURIs: /,/index.html,/favicon.ico,/apple-touch-icon-precomposed.png,/css/*,/js/*,/js/user/*,/js/event/*,/js/utils/*,/img/**,/user/registration,/user/registration/passwordless,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword,/oauth2/authorization/*,/login,/user/login,/user/login.html,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**,/event/,/event/list.html,/event/**,/about.html,/error,/error.html,/webauthn/authenticate/**,/login/webauthn,/webauthn/register/options,/webauthn/register

src/main/resources/application.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ user:
136136
bcryptStrength: 12 # The bcrypt strength to use for password hashing. The higher the number, the longer it takes to hash the password. The default is 12. The minimum is 4. The maximum is 31.
137137
testHashTime: true # If true, the test hash time will be logged to the console on startup. This is useful for determining the optimal bcryptStrength value.
138138
defaultAction: deny # The default action for all requests. This can be either deny or allow.
139-
unprotectedURIs: /,/index.html,/favicon.ico,/apple-touch-icon-precomposed.png,/css/*,/js/*,/js/user/*,/js/event/*,/js/utils/*,/img/**,/user/registration,/user/registration/passwordless,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword,/oauth2/authorization/*,/login,/user/login,/user/login.html,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**,/event/,/event/list.html,/event/**,/about.html,/error,/error.html,/webauthn/authenticate/**,/login/webauthn,/user/mfa/webauthn-challenge.html # A comma delimited list of URIs that should not be protected by Spring Security if the defaultAction is deny.
139+
unprotectedURIs: /,/index.html,/favicon.ico,/apple-touch-icon-precomposed.png,/css/*,/js/*,/js/user/*,/js/event/*,/js/utils/*,/img/**,/user/registration,/user/registration/passwordless,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword,/oauth2/authorization/*,/login,/user/login,/user/login.html,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**,/event/,/event/list.html,/event/**,/about.html,/error,/error.html,/webauthn/authenticate/**,/login/webauthn # A comma delimited list of URIs that should not be protected by Spring Security if the defaultAction is deny.
140140
protectedURIs: /protected.html # A comma delimited list of URIs that should be protected by Spring Security if the defaultAction is allow.
141141
disableCSRFdURIs: /no-csrf-test # A comma delimited list of URIs that should not be protected by CSRF protection. This may include API endpoints that need to be called without a CSRF token.
142142

src/test/java/com/digitalsanctuary/spring/demo/mfa/MfaConfigConsistencyTest.java

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@
1212
/**
1313
* Guards the consistency of the MFA configuration files themselves.
1414
*
15-
* The MFA entry point pages must be listed in {@code user.security.unprotectedURIs}: the framework's
16-
* access-denied handler redirects partially-authenticated users to the entry point URI, and if that page is itself
17-
* protected the redirect loops forever. The framework only auto-unprotects {@code /user/mfa/status}, not the entry
18-
* point pages, so the demo config has to keep these two settings in sync by hand.
15+
* As of the framework release containing the #313 fix, the framework auto-unprotects the configured MFA factor
16+
* entry-point URIs ({@code user.mfa.passwordEntryPointUri} / {@code user.mfa.webauthnEntryPointUri}), so the demo no
17+
* longer has to list the WebAuthn challenge page in {@code user.security.unprotectedURIs} by hand. These tests now only
18+
* assert what is still the demo's own responsibility: MFA must be opt-in (disabled by default), and the passkey
19+
* <em>enrollment</em> endpoints (which are not factor entry points) must be reachable by partially-authenticated users.
1920
*/
2021
@DisplayName("MFA Config Consistency Tests")
2122
class MfaConfigConsistencyTest {
@@ -44,19 +45,6 @@ private static List<String> unprotectedUris(Map<String, Object> yaml) {
4445
return Arrays.stream(uris.toString().split(",")).map(String::trim).toList();
4546
}
4647

47-
@Test
48-
@DisplayName("application.yml unprotects the configured WebAuthn challenge page")
49-
void baseConfigUnprotectsWebauthnEntryPoint() {
50-
Map<String, Object> yaml = loadYaml("/application.yml");
51-
Map<String, Object> mfa = section(yaml, "user", "mfa");
52-
53-
String webauthnEntryPoint = String.valueOf(mfa.get("webauthnEntryPointUri"));
54-
assertThat(webauthnEntryPoint).as("user.mfa.webauthnEntryPointUri should be configured").isNotEqualTo("null");
55-
assertThat(unprotectedUris(yaml))
56-
.as("the WebAuthn challenge page must be unprotected or MFA redirects loop forever")
57-
.contains(webauthnEntryPoint);
58-
}
59-
6048
@Test
6149
@DisplayName("application.yml leaves MFA disabled by default (opt-in via the mfa profile)")
6250
void baseConfigLeavesMfaDisabled() {
@@ -67,16 +55,16 @@ void baseConfigLeavesMfaDisabled() {
6755
}
6856

6957
@Test
70-
@DisplayName("mfa profile enables MFA and unprotects the challenge page and passkey enrollment endpoints")
71-
void mfaProfileEnablesMfaAndUnprotectsEntryPoint() {
58+
@DisplayName("mfa profile enables MFA and unprotects the passkey enrollment endpoints")
59+
void mfaProfileEnablesMfaAndUnprotectsEnrollmentEndpoints() {
7260
Map<String, Object> yaml = loadYaml("/application-mfa.yml");
7361
Map<String, Object> mfa = section(yaml, "user", "mfa");
7462
assertThat(mfa.get("enabled")).isEqualTo(Boolean.TRUE);
7563

64+
// The WebAuthn challenge page (the configured factor entry point) is now auto-unprotected by the
65+
// framework, so it no longer needs to be listed here. The passkey ENROLLMENT endpoints are not factor
66+
// entry points, so partially-authenticated users still need them unprotected to enroll a first passkey.
7667
List<String> uris = unprotectedUris(yaml);
77-
assertThat(uris).contains("/user/mfa/webauthn-challenge.html");
78-
// Partially-authenticated users need to be able to enroll their first passkey, otherwise new
79-
// accounts can never satisfy the WEBAUTHN factor.
8068
assertThat(uris).contains("/webauthn/register/options", "/webauthn/register");
8169
}
8270
}

src/test/java/com/digitalsanctuary/spring/user/security/EmailVerificationEdgeCaseSimpleTest.java

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ void testExpiredTokenRejection() throws Exception {
108108
}
109109

110110
@Test
111-
@DisplayName("Valid token should enable user")
111+
@DisplayName("Valid token enables the user and is consumed (single-use)")
112112
void testValidToken() throws Exception {
113113
// Create valid token
114114
VerificationToken validToken = new VerificationToken();
@@ -123,8 +123,14 @@ void testValidToken() throws Exception {
123123

124124
assertThat(result).isEqualTo(UserService.TokenValidationResult.VALID);
125125

126-
// Verify token still exists (validation doesn't consume it, confirmation does)
127-
assertThat(verificationTokenRepository.findByToken(validToken.getToken())).isNotNull();
126+
// The user is now enabled...
127+
User updatedUser = userRepository.findById(testUser.getId()).orElse(null);
128+
assertThat(updatedUser).isNotNull();
129+
assertThat(updatedUser.isEnabled()).isTrue();
130+
131+
// ...and the token is atomically consumed on the valid path: validation is now single-use
132+
// (it both enables the user and deletes the token in one transaction), so it cannot be replayed.
133+
assertThat(verificationTokenRepository.findByToken(validToken.getToken())).isNull();
128134
}
129135

130136
@Test
@@ -233,16 +239,24 @@ void testCrossUserTokenSecurity() {
233239
otherUserToken.setExpiryDate(Date.from(Instant.now().plus(24, ChronoUnit.HOURS)));
234240
verificationTokenRepository.saveAndFlush(otherUserToken);
235241

236-
// Token should be valid (it exists and isn't expired)
237-
UserService.TokenValidationResult result = userVerificationService
238-
.validateVerificationToken(otherUserToken.getToken());
239-
assertThat(result).isEqualTo(UserService.TokenValidationResult.VALID);
240-
241-
// But it should be associated with the correct user
242+
// The token resolves to the correct user (read it before validation consumes the token).
242243
User userFromToken = userVerificationService.getUserByVerificationToken(otherUserToken.getToken());
243244
assertThat(userFromToken).isNotNull();
244245
assertThat(userFromToken.getEmail()).isEqualTo(otherEmail);
245246
assertThat(userFromToken.getEmail()).isNotEqualTo(testEmail);
247+
248+
// Validating it enables ONLY its owner, never the original test user.
249+
UserService.TokenValidationResult result = userVerificationService
250+
.validateVerificationToken(otherUserToken.getToken());
251+
assertThat(result).isEqualTo(UserService.TokenValidationResult.VALID);
252+
253+
User enabledOther = userRepository.findById(otherUser.getId()).orElse(null);
254+
assertThat(enabledOther).isNotNull();
255+
assertThat(enabledOther.isEnabled()).as("the token's owner is enabled").isTrue();
256+
257+
User stillDisabled = userRepository.findById(testUser.getId()).orElse(null);
258+
assertThat(stillDisabled).isNotNull();
259+
assertThat(stillDisabled.isEnabled()).as("a different user is NOT enabled by another user's token").isFalse();
246260
}
247261

248262
@Test
@@ -273,20 +287,21 @@ void testServiceVerificationFlow() {
273287
validToken.setExpiryDate(Date.from(Instant.now().plus(24, ChronoUnit.HOURS)));
274288
verificationTokenRepository.saveAndFlush(validToken);
275289

276-
// Validate token
277-
UserService.TokenValidationResult result = userVerificationService
278-
.validateVerificationToken(validToken.getToken());
279-
assertThat(result).isEqualTo(UserService.TokenValidationResult.VALID);
280-
281-
// Get user from token
290+
// The token resolves to the correct user (read it before validation consumes the token).
282291
User userFromToken = userVerificationService.getUserByVerificationToken(validToken.getToken());
283292
assertThat(userFromToken).isNotNull();
284293
assertThat(userFromToken.getEmail()).isEqualTo(testEmail);
285294

286-
// Check user status - the service may return the current state
287-
// The important part is we can retrieve the correct user by token
295+
// Validation enables the user and consumes the token in a single transaction.
296+
UserService.TokenValidationResult result = userVerificationService
297+
.validateVerificationToken(validToken.getToken());
298+
assertThat(result).isEqualTo(UserService.TokenValidationResult.VALID);
299+
300+
User updatedUser = userRepository.findById(testUser.getId()).orElse(null);
301+
assertThat(updatedUser).isNotNull();
302+
assertThat(updatedUser.isEnabled()).isTrue();
288303

289-
// Token still exists (validation doesn't consume it)
290-
assertThat(verificationTokenRepository.findByToken(validToken.getToken())).isNotNull();
304+
// The token is consumed (single-use), so it no longer resolves.
305+
assertThat(verificationTokenRepository.findByToken(validToken.getToken())).isNull();
291306
}
292307
}

src/test/java/com/digitalsanctuary/spring/user/service/LoginAttemptServiceTest.java

Lines changed: 0 additions & 107 deletions
This file was deleted.

0 commit comments

Comments
 (0)