Skip to content

Commit accfd1e

Browse files
authored
Merge pull request #72 from devondragon/feature/upgrade-spring-user-framework-4.4.0
Upgrade to SpringUserFramework 4.4.0
2 parents 2595da6 + a93fdff commit accfd1e

16 files changed

Lines changed: 169 additions & 468 deletions

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/api/UserApiIntegrationTestFixed.java

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
66
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
77
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
8+
import org.junit.jupiter.api.AfterEach;
89
import org.junit.jupiter.api.BeforeEach;
910
import org.junit.jupiter.api.DisplayName;
1011
import org.junit.jupiter.api.Test;
@@ -16,12 +17,16 @@
1617
import org.springframework.test.context.bean.override.mockito.MockitoBean;
1718
import org.springframework.test.web.servlet.MockMvc;
1819
import org.springframework.test.web.servlet.MvcResult;
20+
import org.springframework.transaction.PlatformTransactionManager;
21+
import org.springframework.transaction.TransactionDefinition;
1922
import org.springframework.transaction.annotation.Transactional;
23+
import org.springframework.transaction.support.TransactionTemplate;
2024
import com.digitalsanctuary.spring.demo.UserDemoApplication;
2125
import com.digitalsanctuary.spring.user.dto.UserDto;
2226
import com.digitalsanctuary.spring.user.mail.MailService;
2327
import com.digitalsanctuary.spring.user.persistence.model.User;
2428
import com.digitalsanctuary.spring.user.persistence.repository.UserRepository;
29+
import com.digitalsanctuary.spring.user.persistence.repository.VerificationTokenRepository;
2530
import com.digitalsanctuary.spring.user.service.UserService;
2631
import com.fasterxml.jackson.databind.ObjectMapper;
2732
import jakarta.persistence.EntityManager;
@@ -50,24 +55,61 @@ class UserApiIntegrationTestFixed {
5055
@Autowired
5156
private UserService userService;
5257

58+
@Autowired
59+
private VerificationTokenRepository verificationTokenRepository;
60+
61+
@Autowired
62+
private PlatformTransactionManager transactionManager;
63+
5364
@MockitoBean
5465
private MailService mailService;
5566

5667
@PersistenceContext
5768
private EntityManager entityManager;
5869

70+
private static final String TEST_EMAIL = "test@example.com";
71+
5972
private UserDto testUserDto;
6073

6174
@BeforeEach
6275
void setUp() {
76+
// Start from a clean slate. Registration (via the API and via UserService.registerNewUserAccount)
77+
// commits the new user in its own transaction as of 4.4.0 — it does NOT roll back with the test's
78+
// @Transactional. Without a committed cleanup, the user persisted by one test method collides with
79+
// the next (UserAlreadyExistException / 409 Conflict).
80+
deleteTestUserCommitted();
81+
6382
testUserDto = new UserDto();
6483
testUserDto.setFirstName("Test");
6584
testUserDto.setLastName("User");
66-
testUserDto.setEmail("test@example.com");
85+
testUserDto.setEmail(TEST_EMAIL);
6786
testUserDto.setPassword("SecurePass123!");
6887
testUserDto.setMatchingPassword("SecurePass123!");
6988
}
7089

90+
@AfterEach
91+
void tearDown() {
92+
// Remove the user committed by this test so it cannot leak into other tests.
93+
deleteTestUserCommitted();
94+
}
95+
96+
/**
97+
* Deletes the test user (and any verification token) in its own committed transaction. A
98+
* REQUIRES_NEW transaction is required because the class is {@code @Transactional}: a plain delete here
99+
* would roll back with the test and never actually remove the committed registration row.
100+
*/
101+
private void deleteTestUserCommitted() {
102+
TransactionTemplate tx = new TransactionTemplate(transactionManager);
103+
tx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
104+
tx.executeWithoutResult(status -> {
105+
User existing = userRepository.findByEmail(TEST_EMAIL);
106+
if (existing != null) {
107+
verificationTokenRepository.deleteByUser(existing);
108+
userRepository.delete(existing);
109+
}
110+
});
111+
}
112+
71113
@Test
72114
@DisplayName("Should successfully register new user")
73115
void shouldRegisterNewUser() throws Exception {

src/test/java/com/digitalsanctuary/spring/user/integration/AuthorityServiceIntegrationTest.java

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,9 @@ void getAuthoritiesFromUser_persistedUser_loadsAuthoritiesWithLazyLoading() {
139139
Collection<? extends GrantedAuthority> authorities = authorityService
140140
.getAuthoritiesFromUser(fetchedUser);
141141

142-
// Then
143-
assertThat(authorities).hasSize(3).extracting(GrantedAuthority::getAuthority).containsExactlyInAnyOrder(
144-
"LOGIN_PRIVILEGE",
142+
// Then - as of 4.4.0 the role name itself is also granted (needed for hasRole() checks)
143+
assertThat(authorities).hasSize(4).extracting(GrantedAuthority::getAuthority).containsExactlyInAnyOrder(
144+
"ROLE_USER", "LOGIN_PRIVILEGE",
145145
"UPDATE_OWN_USER_PRIVILEGE", "RESET_OWN_PASSWORD_PRIVILEGE");
146146
}
147147

@@ -159,9 +159,10 @@ void getAuthoritiesFromUser_multipleRoles_combinesAllPrivileges() {
159159
Collection<? extends GrantedAuthority> authorities = authorityService
160160
.getAuthoritiesFromUser(multiRoleUser);
161161

162-
// Then
163-
assertThat(authorities).hasSize(6) // 3 from user role + 3 from manager role
164-
.extracting(GrantedAuthority::getAuthority).containsExactlyInAnyOrder("LOGIN_PRIVILEGE",
162+
// Then - 6 privileges + the two role names (ROLE_USER, ROLE_MANAGER) granted as of 4.4.0
163+
assertThat(authorities).hasSize(8) // 3 user + 3 manager privileges + 2 role names
164+
.extracting(GrantedAuthority::getAuthority).containsExactlyInAnyOrder("ROLE_USER",
165+
"ROLE_MANAGER", "LOGIN_PRIVILEGE",
165166
"UPDATE_OWN_USER_PRIVILEGE",
166167
"RESET_OWN_PASSWORD_PRIVILEGE", "ADD_USER_TO_TEAM_PRIVILEGE",
167168
"REMOVE_USER_FROM_TEAM_PRIVILEGE",
@@ -180,9 +181,9 @@ void getAuthoritiesFromUser_adminUser_hasAllAdminPrivileges() {
180181
// When
181182
Collection<? extends GrantedAuthority> authorities = authorityService.getAuthoritiesFromUser(adminUser);
182183

183-
// Then
184-
assertThat(authorities).hasSize(5).extracting(GrantedAuthority::getAuthority).containsExactlyInAnyOrder(
185-
"ADMIN_PRIVILEGE",
184+
// Then - 5 admin privileges + the ROLE_ADMIN role name granted as of 4.4.0
185+
assertThat(authorities).hasSize(6).extracting(GrantedAuthority::getAuthority).containsExactlyInAnyOrder(
186+
"ROLE_ADMIN", "ADMIN_PRIVILEGE",
186187
"INVITE_USER_PRIVILEGE", "READ_USER_PRIVILEGE", "ASSIGN_MANAGER_PRIVILEGE",
187188
"RESET_ANY_USER_PASSWORD_PRIVILEGE");
188189
}
@@ -206,9 +207,9 @@ void getAuthoritiesFromRoles_sharedPrivileges_deduplicatesCorrectly() {
206207
Collection<? extends GrantedAuthority> authorities = authorityService
207208
.getAuthoritiesFromRoles(Arrays.asList(role1, role2));
208209

209-
// Then - Should only have 4 unique privileges (3 from user + 1 shared)
210-
assertThat(authorities).hasSize(4).extracting(GrantedAuthority::getAuthority).containsExactlyInAnyOrder(
211-
"LOGIN_PRIVILEGE",
210+
// Then - 4 unique privileges (3 from user + 1 shared) + the two role names granted as of 4.4.0
211+
assertThat(authorities).hasSize(6).extracting(GrantedAuthority::getAuthority).containsExactlyInAnyOrder(
212+
"ROLE_TEST1", "ROLE_TEST2", "LOGIN_PRIVILEGE",
212213
"UPDATE_OWN_USER_PRIVILEGE", "RESET_OWN_PASSWORD_PRIVILEGE", "SHARED_PRIVILEGE");
213214
}
214215

@@ -247,9 +248,11 @@ void getAuthoritiesFromRoles_separatelyLoadedRoles_worksCorrectly() {
247248
Collection<? extends GrantedAuthority> authorities = authorityService.getAuthoritiesFromRoles(
248249
Arrays.asList(loadedUserRole, loadedManagerRole, loadedAdminRole));
249250

250-
// Then - Should have all unique privileges from all three roles
251-
assertThat(authorities).hasSize(11) // 3 + 3 + 5 unique privileges
251+
// Then - all unique privileges from all three roles, plus the three role names granted as of 4.4.0
252+
assertThat(authorities).hasSize(14) // 3 + 3 + 5 privileges + 3 role names
252253
.extracting(GrantedAuthority::getAuthority).contains(
254+
// Role names
255+
"ROLE_USER", "ROLE_MANAGER", "ROLE_ADMIN",
253256
// User privileges
254257
"LOGIN_PRIVILEGE", "UPDATE_OWN_USER_PRIVILEGE",
255258
"RESET_OWN_PASSWORD_PRIVILEGE",

0 commit comments

Comments
 (0)