Skip to content

Commit f1ce6e6

Browse files
committed
refactor(mfa): address code review feedback on MFA implementation
- Change MfaStatusResponse from @DaTa to @value for immutability - Use method-injected Authentication instead of SecurityContextHolder - Remove duplicate FACTOR_AUTHORITY_MAP from MfaAPI, delegate to MfaConfiguration.mapFactorToAuthority() (now public) - Replace switch/case in WebSecurityConfig.setupMfa() with HashMap lookup - Add default AccessDeniedHandlerImpl fallback for non-MFA access denied - Add null/blank guards in mfaAuthorizationManagerFactory and validator - Remove redundant @propertysource from MfaConfiguration (already loaded by WebAuthnConfiguration per PR #264 consolidation) - Normalize requiredFactors to uppercase for consistent API response casing across requiredFactors, satisfiedFactors, and missingFactors - Fix inaccurate JavaDoc on validateMfaConfiguration
1 parent 6aa0745 commit f1ce6e6

4 files changed

Lines changed: 48 additions & 45 deletions

File tree

src/main/java/com/digitalsanctuary/spring/user/api/MfaAPI.java

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,16 @@
33
import java.util.ArrayList;
44
import java.util.Collection;
55
import java.util.List;
6-
import java.util.Map;
76
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
87
import org.springframework.http.ResponseEntity;
98
import org.springframework.security.core.Authentication;
109
import org.springframework.security.core.GrantedAuthority;
11-
import org.springframework.security.core.authority.FactorGrantedAuthority;
12-
import org.springframework.security.core.context.SecurityContextHolder;
1310
import org.springframework.web.bind.annotation.GetMapping;
1411
import org.springframework.web.bind.annotation.RequestMapping;
1512
import org.springframework.web.bind.annotation.RestController;
1613
import com.digitalsanctuary.spring.user.dto.MfaStatusResponse;
1714
import com.digitalsanctuary.spring.user.security.MfaConfigProperties;
15+
import com.digitalsanctuary.spring.user.security.MfaConfiguration;
1816
import com.digitalsanctuary.spring.user.util.JSONResponse;
1917
import lombok.RequiredArgsConstructor;
2018
import lombok.extern.slf4j.Slf4j;
@@ -36,13 +34,6 @@
3634
@RequiredArgsConstructor
3735
public class MfaAPI {
3836

39-
/**
40-
* Mapping from user-facing factor names to Spring Security authority strings.
41-
*/
42-
private static final Map<String, String> FACTOR_AUTHORITY_MAP = Map.of(
43-
"PASSWORD", FactorGrantedAuthority.PASSWORD_AUTHORITY,
44-
"WEBAUTHN", FactorGrantedAuthority.WEBAUTHN_AUTHORITY);
45-
4637
private final MfaConfigProperties mfaConfigProperties;
4738

4839
/**
@@ -52,12 +43,13 @@ public class MfaAPI {
5243
* accessible to partially-authenticated users (added to unprotected URIs when MFA is enabled).
5344
* </p>
5445
*
46+
* @param authentication the current authentication, injected by Spring MVC (may be null)
5547
* @return a ResponseEntity containing the MFA status
5648
*/
5749
@GetMapping("/status")
58-
public ResponseEntity<JSONResponse> getMfaStatus() {
59-
List<String> requiredFactors = mfaConfigProperties.getFactors();
60-
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
50+
public ResponseEntity<JSONResponse> getMfaStatus(Authentication authentication) {
51+
List<String> requiredFactors = mfaConfigProperties.getFactors().stream()
52+
.map(String::toUpperCase).toList();
6153

6254
List<String> satisfiedFactors = new ArrayList<>();
6355
List<String> missingFactors = new ArrayList<>();
@@ -66,15 +58,18 @@ public ResponseEntity<JSONResponse> getMfaStatus() {
6658
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
6759

6860
for (String factor : requiredFactors) {
69-
String authorityString = FACTOR_AUTHORITY_MAP.get(factor.toUpperCase());
61+
String normalized = factor.toUpperCase();
62+
String authorityString = MfaConfiguration.mapFactorToAuthority(normalized);
7063
if (authorityString != null && hasAuthority(authorities, authorityString)) {
71-
satisfiedFactors.add(factor);
64+
satisfiedFactors.add(normalized);
7265
} else {
73-
missingFactors.add(factor);
66+
missingFactors.add(normalized);
7467
}
7568
}
7669
} else {
77-
missingFactors.addAll(requiredFactors);
70+
for (String factor : requiredFactors) {
71+
missingFactors.add(factor.toUpperCase());
72+
}
7873
}
7974

8075
MfaStatusResponse status = MfaStatusResponse.builder()

src/main/java/com/digitalsanctuary/spring/user/dto/MfaStatusResponse.java

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import java.util.List;
44
import lombok.Builder;
5-
import lombok.Data;
5+
import lombok.Value;
66

77
/**
88
* Response DTO for the MFA status endpoint.
@@ -13,22 +13,22 @@
1313
*
1414
* @see com.digitalsanctuary.spring.user.api.MfaAPI
1515
*/
16-
@Data
16+
@Value
1717
@Builder
1818
public class MfaStatusResponse {
1919

2020
/** Whether MFA is enabled on the server. */
21-
private boolean mfaEnabled;
21+
boolean mfaEnabled;
2222

2323
/** The list of required factor names (e.g., PASSWORD, WEBAUTHN). */
24-
private List<String> requiredFactors;
24+
List<String> requiredFactors;
2525

2626
/** The list of factor names that the current session has satisfied. */
27-
private List<String> satisfiedFactors;
27+
List<String> satisfiedFactors;
2828

2929
/** The list of factor names that the current session has not yet satisfied. */
30-
private List<String> missingFactors;
30+
List<String> missingFactors;
3131

3232
/** Whether the current session has satisfied all required factors. */
33-
private boolean fullyAuthenticated;
33+
boolean fullyAuthenticated;
3434
}

src/main/java/com/digitalsanctuary/spring/user/security/MfaConfiguration.java

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import org.springframework.boot.context.properties.EnableConfigurationProperties;
88
import org.springframework.context.annotation.Bean;
99
import org.springframework.context.annotation.Configuration;
10-
import org.springframework.context.annotation.PropertySource;
1110
import org.springframework.context.event.ContextRefreshedEvent;
1211
import org.springframework.context.event.EventListener;
1312
import org.springframework.security.authorization.AllRequiredFactorsAuthorizationManager;
@@ -36,7 +35,6 @@
3635
*/
3736
@Slf4j
3837
@Configuration
39-
@PropertySource("classpath:config/dsspringuserconfig.properties")
4038
@EnableConfigurationProperties(MfaConfigProperties.class)
4139
@RequiredArgsConstructor
4240
public class MfaConfiguration {
@@ -65,6 +63,9 @@ public DefaultAuthorizationManagerFactory<Object> mfaAuthorizationManagerFactory
6563
AllRequiredFactorsAuthorizationManager.builder();
6664

6765
for (String factor : mfaConfigProperties.getFactors()) {
66+
if (factor == null || factor.isBlank()) {
67+
continue;
68+
}
6869
String authority = FACTOR_AUTHORITY_MAP.get(factor.toUpperCase());
6970
if (authority != null) {
7071
factorsBuilder.requireFactor(RequiredFactor.withAuthority(authority).build());
@@ -81,7 +82,8 @@ public DefaultAuthorizationManagerFactory<Object> mfaAuthorizationManagerFactory
8182
}
8283

8384
/**
84-
* Validates MFA configuration on application startup. Runs only when MFA is enabled.
85+
* Validates MFA configuration on application startup. Performs validation only when MFA is enabled; returns
86+
* immediately otherwise.
8587
*
8688
* @param event the context refreshed event
8789
*/
@@ -100,6 +102,10 @@ public void validateMfaConfiguration(ContextRefreshedEvent event) {
100102
}
101103

102104
for (String factor : factors) {
105+
if (factor == null || factor.isBlank()) {
106+
throw new IllegalStateException(
107+
"MFA factors list contains a null or blank entry. Check user.mfa.factors configuration.");
108+
}
103109
if (!FACTOR_AUTHORITY_MAP.containsKey(factor.toUpperCase())) {
104110
throw new IllegalStateException(
105111
"Unknown MFA factor: '" + factor + "'. Supported factors: " + FACTOR_AUTHORITY_MAP.keySet());
@@ -120,6 +126,9 @@ public void validateMfaConfiguration(ContextRefreshedEvent event) {
120126

121127
/**
122128
* Resolves the configured factor names to Spring Security authority strings.
129+
* <p>
130+
* Package-private for testing via {@link MfaConfigurationTest}.
131+
* </p>
123132
*
124133
* @return list of Spring Security authority strings
125134
*/
@@ -140,7 +149,10 @@ List<String> resolveFactorAuthorities() {
140149
* @param factorName the factor name (e.g., "PASSWORD", "WEBAUTHN")
141150
* @return the corresponding Spring Security authority string, or null if unknown
142151
*/
143-
static String mapFactorToAuthority(String factorName) {
152+
public static String mapFactorToAuthority(String factorName) {
153+
if (factorName == null || factorName.isBlank()) {
154+
return null;
155+
}
144156
return FACTOR_AUTHORITY_MAP.get(factorName.toUpperCase());
145157
}
146158
}

src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
import java.util.ArrayList;
44
import java.util.Arrays;
55
import java.util.Collections;
6+
import java.util.HashMap;
67
import java.util.List;
8+
import java.util.Map;
79
import java.util.Set;
810
import java.util.stream.Collectors;
911
import org.springframework.beans.factory.annotation.Value;
@@ -27,6 +29,7 @@
2729
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
2830
import org.springframework.security.crypto.password.PasswordEncoder;
2931
import org.springframework.security.web.SecurityFilterChain;
32+
import org.springframework.security.web.access.AccessDeniedHandlerImpl;
3033
import org.springframework.security.web.access.DelegatingMissingAuthorityAccessDeniedHandler;
3134
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
3235
import org.springframework.security.web.session.HttpSessionEventPublisher;
@@ -260,28 +263,21 @@ private void setupMfa(HttpSecurity http) throws Exception {
260263
DelegatingMissingAuthorityAccessDeniedHandler.Builder handlerBuilder =
261264
DelegatingMissingAuthorityAccessDeniedHandler.builder();
262265

266+
Map<String, String> factorToUri = new HashMap<>();
267+
factorToUri.put("PASSWORD", mfaConfigProperties.getPasswordEntryPointUri());
268+
factorToUri.put("WEBAUTHN", mfaConfigProperties.getWebauthnEntryPointUri());
269+
263270
for (String factor : mfaConfigProperties.getFactors()) {
264271
String authority = MfaConfiguration.mapFactorToAuthority(factor);
265-
if (authority == null) {
266-
continue;
267-
}
268-
switch (factor.toUpperCase()) {
269-
case "PASSWORD":
270-
handlerBuilder.addEntryPointFor(
271-
new LoginUrlAuthenticationEntryPoint(mfaConfigProperties.getPasswordEntryPointUri()),
272-
authority);
273-
break;
274-
case "WEBAUTHN":
275-
handlerBuilder.addEntryPointFor(
276-
new LoginUrlAuthenticationEntryPoint(mfaConfigProperties.getWebauthnEntryPointUri()),
277-
authority);
278-
break;
279-
default:
280-
break;
272+
String uri = factorToUri.get(factor.toUpperCase());
273+
if (authority != null && uri != null) {
274+
handlerBuilder.addEntryPointFor(new LoginUrlAuthenticationEntryPoint(uri), authority);
281275
}
282276
}
283277

284-
http.exceptionHandling(handling -> handling.accessDeniedHandler(handlerBuilder.build()));
278+
DelegatingMissingAuthorityAccessDeniedHandler handler = handlerBuilder.build();
279+
handler.setDefaultAccessDeniedHandler(new AccessDeniedHandlerImpl());
280+
http.exceptionHandling(handling -> handling.accessDeniedHandler(handler));
285281
log.info("MFA configured with access denied handler for factors: {}", mfaConfigProperties.getFactors());
286282
}
287283

0 commit comments

Comments
 (0)