Skip to content

Commit 84f9c91

Browse files
authored
feat: Wrap Spring Security 7 MFA in simple user.mfa.* properties (#272)
* feat: wrap Spring Security 7 MFA in simple user.mfa.* properties (#268) Add opt-in MFA configuration that wraps Spring Security 7's built-in multi-factor authentication infrastructure behind simple user.mfa.* properties, consistent with how passkeys are wrapped via user.webauthn.*. New files: - MfaConfigProperties: @ConfigurationProperties for user.mfa.* prefix with enabled toggle, factors list, and entry point URIs - MfaConfiguration: unconditional config that registers properties; conditional DefaultAuthorizationManagerFactory bean with AllRequiredFactorsAuthorizationManager; startup validation via @eventlistener for factor/webauthn consistency checks - MfaStatusResponse: DTO reporting required/satisfied/missing factors - MfaAPI: REST controller at /user/mfa/status accessible to partially-authenticated users Modified files: - WebSecurityConfig: MFA config properties injection, setupMfa() method configuring DelegatingMissingAuthorityAccessDeniedHandler, /user/mfa/** added to unprotected URIs when MFA enabled - dsspringuserconfig.properties: MFA default properties block Includes unit tests (MfaConfigurationTest) and integration tests for both enabled and disabled states (MfaFeatureEnabledIntegrationTest, MfaFeatureDisabledIntegrationTest). All 15 new assertions pass. * 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 * test(mfa): rename tests to follow naming convention and add enforcement test Rename 11 test methods across MfaConfigurationTest and MfaFeatureEnabledIntegrationTest to follow the project's should[ExpectedBehavior]When[Condition] naming convention. Add integration test verifying that the DelegatingMissingAuthorityAccessDeniedHandler redirects to the password entry-point URI when a user is missing the PASSWORD factor authority. * refactor(mfa): address second round of PR review feedback - Remove redundant setDefaultAccessDeniedHandler call in setupMfa(); the builder already initializes the default handler - Replace mutable HashMap with immutable Map.of() for factor-to-URI map - Tighten unprotected URI from /user/mfa/** wildcard to /user/mfa/status to avoid silently exposing future MFA endpoints - Remove redundant toUpperCase() calls in MfaAPI.getMfaStatus() since requiredFactors is already uppercased during stream mapping - Remove unused HashMap and AccessDeniedHandlerImpl imports * test(mfa): add WEBAUTHN multi-factor integration test Add MfaMultiFactorIntegrationTest that exercises the PASSWORD+WEBAUTHN path through setupMfa, resolveFactorAuthorities, and the MFA status endpoint. Verifies both factors appear in required/missing lists and that the authorization manager factory is created for multi-factor configurations. * refactor(mfa): address third round of PR review feedback - Remove blank lines in WebSecurityConfig import block - Add clarifying comment in MfaConfiguration for silent skip behavior - Add positive-path MFA tests verifying fullyAuthenticated: true when user has all required FactorGrantedAuthority entries
1 parent 0759931 commit 84f9c91

10 files changed

Lines changed: 813 additions & 0 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package com.digitalsanctuary.spring.user.api;
2+
3+
import java.util.ArrayList;
4+
import java.util.Collection;
5+
import java.util.List;
6+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
7+
import org.springframework.http.ResponseEntity;
8+
import org.springframework.security.core.Authentication;
9+
import org.springframework.security.core.GrantedAuthority;
10+
import org.springframework.web.bind.annotation.GetMapping;
11+
import org.springframework.web.bind.annotation.RequestMapping;
12+
import org.springframework.web.bind.annotation.RestController;
13+
import com.digitalsanctuary.spring.user.dto.MfaStatusResponse;
14+
import com.digitalsanctuary.spring.user.security.MfaConfigProperties;
15+
import com.digitalsanctuary.spring.user.security.MfaConfiguration;
16+
import com.digitalsanctuary.spring.user.util.JSONResponse;
17+
import lombok.RequiredArgsConstructor;
18+
import lombok.extern.slf4j.Slf4j;
19+
20+
/**
21+
* REST API for Multi-Factor Authentication status.
22+
* <p>
23+
* Provides an endpoint for checking the MFA status of the current session. This is accessible to
24+
* partially-authenticated users so the UI can determine which factor challenge to show next.
25+
* </p>
26+
* <p>
27+
* This controller is only registered when MFA is enabled ({@code user.mfa.enabled=true}).
28+
* </p>
29+
*/
30+
@Slf4j
31+
@RestController
32+
@RequestMapping(path = "/user/mfa", produces = "application/json")
33+
@ConditionalOnProperty(name = "user.mfa.enabled", havingValue = "true", matchIfMissing = false)
34+
@RequiredArgsConstructor
35+
public class MfaAPI {
36+
37+
private final MfaConfigProperties mfaConfigProperties;
38+
39+
/**
40+
* Returns the MFA status for the current session.
41+
* <p>
42+
* Reports which factors are required, which have been satisfied, and which are still missing. This endpoint is
43+
* accessible to partially-authenticated users (added to unprotected URIs when MFA is enabled).
44+
* </p>
45+
*
46+
* @param authentication the current authentication, injected by Spring MVC (may be null)
47+
* @return a ResponseEntity containing the MFA status
48+
*/
49+
@GetMapping("/status")
50+
public ResponseEntity<JSONResponse> getMfaStatus(Authentication authentication) {
51+
List<String> requiredFactors = mfaConfigProperties.getFactors().stream()
52+
.map(String::toUpperCase).toList();
53+
54+
List<String> satisfiedFactors = new ArrayList<>();
55+
List<String> missingFactors = new ArrayList<>();
56+
57+
if (authentication != null && authentication.isAuthenticated()) {
58+
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
59+
60+
for (String factor : requiredFactors) {
61+
String authorityString = MfaConfiguration.mapFactorToAuthority(factor);
62+
if (authorityString != null && hasAuthority(authorities, authorityString)) {
63+
satisfiedFactors.add(factor);
64+
} else {
65+
missingFactors.add(factor);
66+
}
67+
}
68+
} else {
69+
missingFactors.addAll(requiredFactors);
70+
}
71+
72+
MfaStatusResponse status = MfaStatusResponse.builder()
73+
.mfaEnabled(true)
74+
.requiredFactors(requiredFactors)
75+
.satisfiedFactors(satisfiedFactors)
76+
.missingFactors(missingFactors)
77+
.fullyAuthenticated(missingFactors.isEmpty())
78+
.build();
79+
80+
return ResponseEntity.ok(JSONResponse.builder().success(true).data(status).build());
81+
}
82+
83+
private boolean hasAuthority(Collection<? extends GrantedAuthority> authorities, String authorityString) {
84+
return authorities.stream().anyMatch(a -> authorityString.equals(a.getAuthority()));
85+
}
86+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.digitalsanctuary.spring.user.dto;
2+
3+
import java.util.List;
4+
import lombok.Builder;
5+
import lombok.Value;
6+
7+
/**
8+
* Response DTO for the MFA status endpoint.
9+
* <p>
10+
* Provides information about the MFA state of the current user session, including which factors are required, which
11+
* have been satisfied, and which are still missing. This enables the UI to display the appropriate MFA challenge pages.
12+
* </p>
13+
*
14+
* @see com.digitalsanctuary.spring.user.api.MfaAPI
15+
*/
16+
@Value
17+
@Builder
18+
public class MfaStatusResponse {
19+
20+
/** Whether MFA is enabled on the server. */
21+
boolean mfaEnabled;
22+
23+
/** The list of required factor names (e.g., PASSWORD, WEBAUTHN). */
24+
List<String> requiredFactors;
25+
26+
/** The list of factor names that the current session has satisfied. */
27+
List<String> satisfiedFactors;
28+
29+
/** The list of factor names that the current session has not yet satisfied. */
30+
List<String> missingFactors;
31+
32+
/** Whether the current session has satisfied all required factors. */
33+
boolean fullyAuthenticated;
34+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.digitalsanctuary.spring.user.security;
2+
3+
import java.util.ArrayList;
4+
import java.util.List;
5+
import org.springframework.boot.context.properties.ConfigurationProperties;
6+
import lombok.Data;
7+
8+
/**
9+
* Configuration properties for Multi-Factor Authentication (MFA).
10+
* <p>
11+
* When enabled, all authenticated endpoints require all configured factors to be satisfied. Spring Security 7's built-in
12+
* MFA infrastructure handles enforcement, redirection between factor login pages, and session management automatically.
13+
* </p>
14+
* <p>
15+
* Example configuration:
16+
* </p>
17+
*
18+
* <pre>
19+
* user.mfa.enabled: true
20+
* user.mfa.factors: PASSWORD, WEBAUTHN
21+
* user.mfa.passwordEntryPointUri: /user/login.html
22+
* user.mfa.webauthnEntryPointUri: /user/webauthn/login.html
23+
* </pre>
24+
*
25+
* @see MfaConfiguration
26+
*/
27+
@Data
28+
@ConfigurationProperties(prefix = "user.mfa")
29+
public class MfaConfigProperties {
30+
31+
/**
32+
* Whether MFA is enabled. When true, all authenticated endpoints require all configured factors.
33+
*/
34+
private boolean enabled = false;
35+
36+
/**
37+
* The list of authentication factors required for MFA. Supported values: PASSWORD, WEBAUTHN.
38+
*/
39+
private List<String> factors = new ArrayList<>();
40+
41+
/**
42+
* The URI to redirect to when the PASSWORD factor is missing. This should point to the password login page.
43+
*/
44+
private String passwordEntryPointUri = "/user/login.html";
45+
46+
/**
47+
* The URI to redirect to when the WEBAUTHN factor is missing. This should point to the WebAuthn/passkey login page.
48+
*/
49+
private String webauthnEntryPointUri = "/user/webauthn/login.html";
50+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package com.digitalsanctuary.spring.user.security;
2+
3+
import java.util.ArrayList;
4+
import java.util.List;
5+
import java.util.Map;
6+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
7+
import org.springframework.boot.context.properties.EnableConfigurationProperties;
8+
import org.springframework.context.annotation.Bean;
9+
import org.springframework.context.annotation.Configuration;
10+
import org.springframework.context.event.ContextRefreshedEvent;
11+
import org.springframework.context.event.EventListener;
12+
import org.springframework.security.authorization.AllRequiredFactorsAuthorizationManager;
13+
import org.springframework.security.authorization.AuthorizationManager;
14+
import org.springframework.security.authorization.DefaultAuthorizationManagerFactory;
15+
import org.springframework.security.authorization.RequiredFactor;
16+
import org.springframework.security.core.authority.FactorGrantedAuthority;
17+
import lombok.RequiredArgsConstructor;
18+
import lombok.extern.slf4j.Slf4j;
19+
20+
/**
21+
* Configuration that registers {@link MfaConfigProperties} and provides MFA-related beans.
22+
* <p>
23+
* This configuration is always active because {@code WebSecurityConfig} requires {@link MfaConfigProperties} regardless
24+
* of whether MFA is enabled. The {@code DefaultAuthorizationManagerFactory} bean is only created when MFA is enabled.
25+
* </p>
26+
* <p>
27+
* When enabled, the {@code DefaultAuthorizationManagerFactory} is configured with an
28+
* {@link AllRequiredFactorsAuthorizationManager} that makes {@code .authenticated()} in
29+
* {@code authorizeHttpRequests} additionally require all configured factor authorities. Spring Security 7's built-in
30+
* infrastructure handles enforcement and session management automatically.
31+
* </p>
32+
*
33+
* @see MfaConfigProperties
34+
* @see WebSecurityConfig
35+
*/
36+
@Slf4j
37+
@Configuration
38+
@EnableConfigurationProperties(MfaConfigProperties.class)
39+
@RequiredArgsConstructor
40+
public class MfaConfiguration {
41+
42+
/**
43+
* Mapping from user-facing factor names to Spring Security {@link FactorGrantedAuthority} authority strings.
44+
*/
45+
private static final Map<String, String> FACTOR_AUTHORITY_MAP = Map.of(
46+
"PASSWORD", FactorGrantedAuthority.PASSWORD_AUTHORITY,
47+
"WEBAUTHN", FactorGrantedAuthority.WEBAUTHN_AUTHORITY);
48+
49+
private final MfaConfigProperties mfaConfigProperties;
50+
private final WebAuthnConfigProperties webAuthnConfigProperties;
51+
52+
/**
53+
* Creates a {@link DefaultAuthorizationManagerFactory} with an additional authorization requirement for all
54+
* configured MFA factors. This makes {@code .authenticated()} in {@code authorizeHttpRequests} require all
55+
* configured factors to be satisfied.
56+
*
57+
* @return the authorization manager factory configured with required factor authorities
58+
*/
59+
@Bean
60+
@ConditionalOnProperty(name = "user.mfa.enabled", havingValue = "true", matchIfMissing = false)
61+
public DefaultAuthorizationManagerFactory<Object> mfaAuthorizationManagerFactory() {
62+
AllRequiredFactorsAuthorizationManager.Builder<Object> factorsBuilder =
63+
AllRequiredFactorsAuthorizationManager.builder();
64+
65+
// Unknown/blank factors are silently skipped here; validateMfaConfiguration() will
66+
// throw before startup completes if the configuration is invalid.
67+
for (String factor : mfaConfigProperties.getFactors()) {
68+
if (factor == null || factor.isBlank()) {
69+
continue;
70+
}
71+
String authority = FACTOR_AUTHORITY_MAP.get(factor.toUpperCase());
72+
if (authority != null) {
73+
factorsBuilder.requireFactor(RequiredFactor.withAuthority(authority).build());
74+
}
75+
}
76+
77+
AuthorizationManager<Object> factorsManager = factorsBuilder.build();
78+
79+
DefaultAuthorizationManagerFactory<Object> factory = new DefaultAuthorizationManagerFactory<>();
80+
factory.setAdditionalAuthorization(factorsManager);
81+
82+
log.info("MFA enabled with required factors: {}", mfaConfigProperties.getFactors());
83+
return factory;
84+
}
85+
86+
/**
87+
* Validates MFA configuration on application startup. Performs validation only when MFA is enabled; returns
88+
* immediately otherwise.
89+
*
90+
* @param event the context refreshed event
91+
*/
92+
@EventListener(ContextRefreshedEvent.class)
93+
public void validateMfaConfiguration(ContextRefreshedEvent event) {
94+
if (!mfaConfigProperties.isEnabled()) {
95+
return;
96+
}
97+
98+
List<String> factors = mfaConfigProperties.getFactors();
99+
100+
if (factors == null || factors.isEmpty()) {
101+
throw new IllegalStateException(
102+
"MFA is enabled (user.mfa.enabled=true) but no factors are configured. "
103+
+ "Set user.mfa.factors to a comma-separated list of factors (e.g., PASSWORD,WEBAUTHN).");
104+
}
105+
106+
for (String factor : factors) {
107+
if (factor == null || factor.isBlank()) {
108+
throw new IllegalStateException(
109+
"MFA factors list contains a null or blank entry. Check user.mfa.factors configuration.");
110+
}
111+
if (!FACTOR_AUTHORITY_MAP.containsKey(factor.toUpperCase())) {
112+
throw new IllegalStateException(
113+
"Unknown MFA factor: '" + factor + "'. Supported factors: " + FACTOR_AUTHORITY_MAP.keySet());
114+
}
115+
}
116+
117+
if (factors.stream().anyMatch(f -> "WEBAUTHN".equalsIgnoreCase(f)) && !webAuthnConfigProperties.isEnabled()) {
118+
throw new IllegalStateException(
119+
"MFA factor WEBAUTHN is configured but WebAuthn is disabled (user.webauthn.enabled=false). "
120+
+ "Enable WebAuthn or remove WEBAUTHN from user.mfa.factors.");
121+
}
122+
123+
if (factors.stream().anyMatch(f -> "PASSWORD".equalsIgnoreCase(f))) {
124+
log.warn("MFA factor PASSWORD is configured. Users with passwordless (passkey-only) accounts "
125+
+ "will not be able to satisfy the PASSWORD factor. Consider your account types carefully.");
126+
}
127+
}
128+
129+
/**
130+
* Resolves the configured factor names to Spring Security authority strings.
131+
* <p>
132+
* Package-private for testing via {@link MfaConfigurationTest}.
133+
* </p>
134+
*
135+
* @return list of Spring Security authority strings
136+
*/
137+
List<String> resolveFactorAuthorities() {
138+
List<String> authorities = new ArrayList<>();
139+
for (String factor : mfaConfigProperties.getFactors()) {
140+
String authority = FACTOR_AUTHORITY_MAP.get(factor.toUpperCase());
141+
if (authority != null) {
142+
authorities.add(authority);
143+
}
144+
}
145+
return authorities;
146+
}
147+
148+
/**
149+
* Maps a user-facing factor name to a Spring Security authority string.
150+
*
151+
* @param factorName the factor name (e.g., "PASSWORD", "WEBAUTHN")
152+
* @return the corresponding Spring Security authority string, or null if unknown
153+
*/
154+
public static String mapFactorToAuthority(String factorName) {
155+
if (factorName == null || factorName.isBlank()) {
156+
return null;
157+
}
158+
return FACTOR_AUTHORITY_MAP.get(factorName.toUpperCase());
159+
}
160+
}

0 commit comments

Comments
 (0)