Skip to content

Commit 6aa0745

Browse files
committed
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.
1 parent d1754fb commit 6aa0745

9 files changed

Lines changed: 672 additions & 0 deletions

File tree

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

0 commit comments

Comments
 (0)