接入 WebAuthn 第二因素与 PostgreSQL 凭据仓储

This commit is contained in:
2026-09-27 18:29:52 +00:00
parent 6d040658c8
commit 45994e3919
25 changed files with 874 additions and 23 deletions
@@ -1,10 +1,16 @@
package top.ddupan.iam.login.authentication.infrastructure.security;
import org.springframework.security.core.AuthenticatedPrincipal;
import org.springframework.security.web.webauthn.api.Bytes;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
import top.ddupan.iam.login.authentication.domain.User;
/** An immutable directory snapshot; never contains credentials or connections. */
public record DirectoryPrincipal(User user) implements AuthenticatedPrincipal {
public record DirectoryPrincipal(User user, Bytes credentialUserId)
implements AuthenticatedPrincipal, PublicKeyCredentialUserEntity {
public DirectoryPrincipal(User user) { this(user, null); }
@Override public Bytes getId() { return credentialUserId; }
@Override public String getDisplayName() { return user.displayName(); }
@Override
public String getName() {
return user.id().authority() + ":" + user.id().value();
@@ -0,0 +1,71 @@
package top.ddupan.iam.login.authentication.infrastructure.webauthn;
import org.springframework.jdbc.core.JdbcOperations;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.webauthn.api.*;
import org.springframework.security.web.webauthn.management.*;
import org.springframework.transaction.support.TransactionTemplate;
import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal;
/** Adds directory ownership/enrollment policy; verification and storage remain upstream implementations. */
public final class DirectoryRelyingPartyOperations implements WebAuthnRelyingPartyOperations {
private final WebAuthnRelyingPartyOperations delegate;
private final MfaPolicy policy;
private final PublicKeyCredentialUserEntityRepository users;
private final UserCredentialRepository credentials;
private final JdbcOperations jdbc;
private final TransactionTemplate transactions;
public DirectoryRelyingPartyOperations(WebAuthnRelyingPartyOperations delegate, MfaPolicy policy,
PublicKeyCredentialUserEntityRepository users, UserCredentialRepository credentials,
JdbcOperations jdbc, TransactionTemplate transactions) {
this.delegate = delegate;
this.policy = policy;
this.users = users;
this.credentials = credentials;
this.jdbc = jdbc;
this.transactions = transactions;
}
@Override
public PublicKeyCredentialCreationOptions createPublicKeyCredentialCreationOptions(
PublicKeyCredentialCreationOptionsRequest request) {
policy.current();
policy.requireEnrollment(request.getAuthentication());
return delegate.createPublicKeyCredentialCreationOptions(request);
}
@Override
public CredentialRecord registerCredential(RelyingPartyRegistrationRequest request) {
var principal = policy.current();
var owner = request.getCreationOptions().getUser();
if (!principal.getName().equals(owner.getName())) throw new AccessDeniedException("Credential owner mismatch");
return transactions.execute(status -> {
// Serialize first enrollment across sessions/processes, then recheck existing factors.
jdbc.queryForObject("select id from user_entities where id = ? for update", String.class,
owner.getId().toBase64UrlString());
policy.requireEnrollment(SecurityContextHolder.getContext().getAuthentication());
return delegate.registerCredential(request);
});
}
@Override
public PublicKeyCredentialRequestOptions createCredentialRequestOptions(PublicKeyCredentialRequestOptionsRequest request) {
policy.current();
return delegate.createCredentialRequestOptions(request);
}
@Override
public PublicKeyCredentialUserEntity authenticate(RelyingPartyAuthenticationRequest request) {
var principal = policy.current();
var owner = users.findByUsername(principal.getName());
var credential = credentials.findByCredentialId(request.getPublicKey().getRawId());
if (owner == null || credential == null || !owner.getId().equals(credential.getUserEntityUserId())) {
throw new AccessDeniedException("Credential owner mismatch");
}
var verified = delegate.authenticate(request);
if (!verified.getName().equals(principal.getName())) throw new AccessDeniedException("Credential owner mismatch");
return new DirectoryPrincipal(principal.user(), verified.getId());
}
}
@@ -0,0 +1,46 @@
package top.ddupan.iam.login.authentication.infrastructure.webauthn;
import java.time.Duration;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.authorization.AuthorizationManagerFactories;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository;
import org.springframework.security.web.webauthn.management.UserCredentialRepository;
import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrincipal;
/** Enrollment policy; factor completion and freshness are evaluated by Spring Security. */
public final class MfaPolicy {
private final PublicKeyCredentialUserEntityRepository users;
private final UserCredentialRepository credentials;
public final AuthorizationManager<Object> password = AuthorizationManagerFactories.<Object>multiFactor()
.requireFactor(f -> f.passwordAuthority().validDuration(Duration.ofMinutes(10))).build().authenticated();
public final AuthorizationManager<Object> complete = AuthorizationManagerFactories.<Object>multiFactor()
.requireFactor(f -> f.passwordAuthority().validDuration(Duration.ofMinutes(10)))
.requireFactor(f -> f.webauthnAuthority().validDuration(Duration.ofMinutes(10))).build().authenticated();
public MfaPolicy(PublicKeyCredentialUserEntityRepository users, UserCredentialRepository credentials) {
this.users = users;
this.credentials = credentials;
}
public DirectoryPrincipal current() {
var authentication = SecurityContextHolder.getContext().getAuthentication();
password.verify(() -> authentication, null);
if (!(authentication.getPrincipal() instanceof DirectoryPrincipal principal)) {
throw new AccessDeniedException("Directory identity required");
}
return principal;
}
public boolean enrolled(String name) {
var user = users.findByUsername(name);
return user != null && !credentials.findByUserId(user.getId()).isEmpty();
}
public void requireEnrollment(Authentication authentication) {
password.verify(() -> authentication, null);
if (enrolled(authentication.getName())) complete.verify(() -> authentication, null);
}
}
@@ -0,0 +1,74 @@
package top.ddupan.iam.login.authentication.infrastructure.webauthn;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRequestOptions;
import org.springframework.security.web.webauthn.registration.PublicKeyCredentialCreationOptionsRepository;
import org.springframework.security.web.webauthn.authentication.PublicKeyCredentialRequestOptionsRepository;
/** Framework repository extension: server-side TTL, owner binding and atomic single consumption. */
public final class SessionChallenges {
private final Clock clock;
private final MfaPolicy policy;
private static final Duration LIFETIME = Duration.ofMinutes(5);
private record Challenge(Object options, String owner, Instant expiresAt) { }
public SessionChallenges(Clock clock, MfaPolicy policy) {
this.clock = clock;
this.policy = policy;
}
private void save(HttpServletRequest request, String key, Object options) {
// Upstream clears after load; load already consumes atomically. Do not clear a newer challenge.
if (options == null) return;
var owner = policy.current().getName();
var session = request.getSession();
synchronized (session) {
session.setAttribute(key, new Challenge(options, owner, clock.instant().plus(LIFETIME)));
}
}
private Object consume(HttpServletRequest request, String key) {
var session = request.getSession(false);
if (session == null) return null;
synchronized (session) {
var challenge = (Challenge) session.getAttribute(key);
session.removeAttribute(key);
var authentication = SecurityContextHolder.getContext().getAuthentication();
if (challenge == null || !clock.instant().isBefore(challenge.expiresAt())
|| authentication == null || !challenge.owner().equals(authentication.getName())) return null;
var result = policy.password.authorize(() -> authentication, null);
if (result == null || !result.isGranted()) return null;
return challenge.options();
}
}
public PublicKeyCredentialCreationOptionsRepository registration() {
return new PublicKeyCredentialCreationOptionsRepository() {
private static final String KEY = "iam.webauthn.registration";
@Override public void save(HttpServletRequest r, HttpServletResponse s, PublicKeyCredentialCreationOptions o) {
SessionChallenges.this.save(r, KEY, o);
}
@Override public PublicKeyCredentialCreationOptions load(HttpServletRequest r) {
return (PublicKeyCredentialCreationOptions) consume(r, KEY);
}
};
}
public PublicKeyCredentialRequestOptionsRepository authentication() {
return new PublicKeyCredentialRequestOptionsRepository() {
private static final String KEY = "iam.webauthn.authentication";
@Override public void save(HttpServletRequest r, HttpServletResponse s, PublicKeyCredentialRequestOptions o) {
SessionChallenges.this.save(r, KEY, o);
}
@Override public PublicKeyCredentialRequestOptions load(HttpServletRequest r) {
return (PublicKeyCredentialRequestOptions) consume(r, KEY);
}
};
}
}
@@ -0,0 +1,20 @@
package top.ddupan.iam.login.authentication.infrastructure.webauthn;
import java.net.URI;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("iam.webauthn")
public record WebAuthnProperties(boolean enabled, String rpId, String origin) {
public WebAuthnProperties {
if (enabled) {
if (origin == null) throw new IllegalArgumentException("WebAuthn HTTPS origin is required");
var uri = URI.create(origin);
if (rpId == null || rpId.isBlank() || !"https".equals(uri.getScheme())
|| !rpId.equals(uri.getHost()) || uri.getUserInfo() != null
|| uri.getQuery() != null || uri.getFragment() != null
|| (uri.getPath() != null && !uri.getPath().isEmpty())) {
throw new IllegalArgumentException("WebAuthn requires an exact HTTPS origin and matching RP host");
}
}
}
}
@@ -1,6 +1,8 @@
package top.ddupan.iam.login.authentication.interfaces.web;
import java.util.Map;
import org.springframework.beans.factory.ObjectProvider;
import top.ddupan.iam.login.authentication.infrastructure.webauthn.MfaPolicy;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -17,9 +19,11 @@ import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrin
@ConditionalOnProperty(prefix = "iam.ad", name = "enabled", havingValue = "true")
public class SignInController {
private final PageRenderer renderer;
private final ObjectProvider<MfaPolicy> policies;
public SignInController(PageRenderer renderer) {
public SignInController(PageRenderer renderer, ObjectProvider<MfaPolicy> policies) {
this.renderer = renderer;
this.policies = policies;
}
@GetMapping(value = "/signin", produces = MediaType.TEXT_HTML_VALUE)
@@ -31,8 +35,19 @@ public class SignInController {
@GetMapping(value = "/signin/mfa", produces = MediaType.TEXT_HTML_VALUE)
ResponseEntity<String> pending(@AuthenticationPrincipal DirectoryPrincipal principal, CsrfToken csrf) {
var policy = policies.getIfAvailable();
return identity(principal, csrf, "mfa-pending", policy == null ? "unavailable"
: policy.enrolled(principal.getName()) ? "authenticate" : "register");
}
@GetMapping(value = "/signin/complete", produces = MediaType.TEXT_HTML_VALUE)
ResponseEntity<String> complete(@AuthenticationPrincipal DirectoryPrincipal principal, CsrfToken csrf) {
return identity(principal, csrf, "mfa-complete", "authenticate");
}
private ResponseEntity<String> identity(DirectoryPrincipal principal, CsrfToken csrf, String step, String passkey) {
var user = principal.user();
return renderer.render(Map.of("step", "mfa-pending", "name", user.displayName(),
return renderer.render(Map.of("step", step, "passkey", passkey, "name", user.displayName(),
"error", "", "action", "/signin/restart", "csrf", csrf(csrf),
"identity", Map.of("username", user.username(), "subjectId", user.id().value(),
"email", user.email(),
@@ -41,6 +56,6 @@ public class SignInController {
}
private static Map<String, String> csrf(CsrfToken token) {
return Map.of("name", token.getParameterName(), "value", token.getToken());
return Map.of("name", token.getParameterName(), "value", token.getToken(), "headerName", token.getHeaderName());
}
}
@@ -1,6 +1,7 @@
package top.ddupan.iam.login.configuration;
import java.time.Duration;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -41,18 +42,26 @@ class SecurityConfiguration {
@Bean
@Order(2)
@ConditionalOnProperty(prefix = "iam.ad", name = "enabled", havingValue = "true")
SecurityFilterChain browser(HttpSecurity http, VerifyPassword passwords) throws Exception {
SecurityFilterChain browser(HttpSecurity http, VerifyPassword passwords,
ObjectProvider<WebAuthnBrowserConfigurer> webAuthn) throws Exception {
var passwordFactor = AuthorizationManagerFactories.<RequestAuthorizationContext>multiFactor()
.requireFactor(factor -> factor.passwordAuthority().validDuration(Duration.ofMinutes(10)))
.build();
return http.securityMatcher("/signin", "/signin/**", "/assets/**")
var mfa = webAuthn.getIfAvailable();
http.securityMatcher("/signin", "/signin/**", "/assets/**", "/webauthn/**", "/login/webauthn")
.redirectToHttps(Customizer.withDefaults())
.authenticationManager(new ProviderManager(new DirectoryAuthenticationProvider(passwords)))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/error", "/signin", "/signin/password", "/assets/**").permitAll()
.authorizeHttpRequests(auth -> {
if (mfa != null) {
auth.requestMatchers("/signin/complete").access(mfa.policy.complete);
auth.requestMatchers(org.springframework.http.HttpMethod.POST, "/webauthn/register")
.access(mfa.policy.password);
}
auth.requestMatchers("/error", "/signin", "/signin/password", "/assets/**").permitAll()
.requestMatchers("/signin/mfa").access(passwordFactor.authenticated())
// No complete MFA or Hydra acceptance exists yet. Fail closed until those are implemented.
.anyRequest().denyAll())
// Credential deletion and all unimplemented routes remain closed.
.anyRequest().denyAll();
})
.formLogin(form -> form.loginPage("/signin").loginProcessingUrl("/signin/password")
.defaultSuccessUrl("/signin/mfa", true).failureUrl("/signin?error"))
.logout(logout -> logout.logoutUrl("/signin/restart").logoutSuccessUrl("/signin"))
@@ -64,8 +73,10 @@ class SecurityConfiguration {
.requestCache(cache -> cache.disable())
.headers(headers -> headers.contentSecurityPolicy(csp -> csp.policyDirectives(
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
+ "object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")))
.build();
+ "object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")));
if (mfa != null) mfa.configure(http);
var chain = http.build();
return mfa == null ? chain : mfa.finish(http, chain);
}
@Bean
@@ -0,0 +1,55 @@
package top.ddupan.iam.login.configuration;
import java.util.List;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.authority.FactorGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.webauthn.authentication.PublicKeyCredentialRequestOptionsFilter;
import org.springframework.security.web.webauthn.authentication.WebAuthnAuthenticationFilter;
import top.ddupan.iam.login.authentication.infrastructure.webauthn.*;
/** Wires official filters through their public extension points; no custom authentication filter. */
final class WebAuthnBrowserConfigurer {
private final WebAuthnProperties properties;
final MfaPolicy policy;
private final SessionChallenges challenges;
WebAuthnBrowserConfigurer(WebAuthnProperties properties, MfaPolicy policy, SessionChallenges challenges) {
this.properties = properties;
this.policy = policy;
this.challenges = challenges;
}
void configure(HttpSecurity http) throws Exception {
http.setSharedObject(UserDetailsService.class, name -> {
if (!policy.current().getName().equals(name)) throw new UsernameNotFoundException("Directory identity mismatch");
// Spring Security merges the existing password factor, retaining its original issue time.
return new User(name, "", List.of());
});
http.webAuthn(web -> web.rpId(properties.rpId()).rpName("IAM Login")
.allowedOrigins(properties.origin()).disableDefaultRegistrationPage(true)
.creationOptionsRepository(challenges.registration()));
http.exceptionHandling(exceptions -> exceptions.defaultDeniedHandlerForMissingAuthority(
new LoginUrlAuthenticationEntryPoint("/signin/mfa"), FactorGrantedAuthority.WEBAUTHN_AUTHORITY));
}
SecurityFilterChain finish(HttpSecurity http, SecurityFilterChain chain) {
var repository = challenges.authentication();
// Security 7.1 exposes these setters but does not expose the assertion repository in its DSL.
for (var filter : chain.getFilters()) {
if (filter instanceof PublicKeyCredentialRequestOptionsFilter options) {
options.setRequestOptionsRepository(repository);
}
if (filter instanceof WebAuthnAuthenticationFilter authentication) {
authentication.setRequestOptionsRepository(repository);
authentication.setSessionAuthenticationStrategy(http.getSharedObject(
org.springframework.security.web.authentication.session.SessionAuthenticationStrategy.class));
}
}
return chain;
}
}
@@ -0,0 +1,74 @@
package top.ddupan.iam.login.configuration;
import java.time.Clock;
import java.time.Duration;
import java.util.Set;
import javax.sql.DataSource;
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.jdbc.autoconfigure.DataSourceProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcOperations;
import org.springframework.security.web.webauthn.api.AuthenticatorSelectionCriteria;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRpEntity;
import org.springframework.security.web.webauthn.api.ResidentKeyRequirement;
import org.springframework.security.web.webauthn.api.UserVerificationRequirement;
import org.springframework.security.web.webauthn.management.*;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import top.ddupan.iam.login.authentication.infrastructure.webauthn.*;
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(prefix = "iam.webauthn", name = "enabled", havingValue = "true")
@EnableConfigurationProperties({WebAuthnProperties.class, DataSourceProperties.class})
class WebAuthnConfiguration {
@Bean
DataSource webAuthnDataSource(DataSourceProperties properties) {
return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
}
@Bean
PublicKeyCredentialUserEntityRepository credentialUsers(JdbcOperations jdbc) {
return new JdbcPublicKeyCredentialUserEntityRepository(jdbc);
}
@Bean
UserCredentialRepository credentials(JdbcOperations jdbc) {
return new JdbcUserCredentialRepository(jdbc);
}
@Bean
MfaPolicy mfaPolicy(PublicKeyCredentialUserEntityRepository users, UserCredentialRepository credentials) {
return new MfaPolicy(users, credentials);
}
@Bean
SessionChallenges challenges(MfaPolicy policy) {
return new SessionChallenges(Clock.systemUTC(), policy);
}
@Bean
WebAuthnRelyingPartyOperations relyingParty(WebAuthnProperties properties, MfaPolicy policy,
PublicKeyCredentialUserEntityRepository users, UserCredentialRepository credentials,
JdbcOperations jdbc, PlatformTransactionManager transactions) {
var delegate = new Webauthn4JRelyingPartyOperations(users, credentials,
PublicKeyCredentialRpEntity.builder().id(properties.rpId()).name("IAM Login").build(),
Set.of(properties.origin()));
delegate.setCustomizeCreationOptions(options -> options.timeout(Duration.ofMinutes(5))
.authenticatorSelection(AuthenticatorSelectionCriteria.builder()
.residentKey(ResidentKeyRequirement.REQUIRED)
.userVerification(UserVerificationRequirement.REQUIRED).build()));
delegate.setCustomizeRequestOptions(options -> options.timeout(Duration.ofMinutes(5))
.userVerification(UserVerificationRequirement.REQUIRED));
return new DirectoryRelyingPartyOperations(delegate, policy, users, credentials, jdbc,
new TransactionTemplate(transactions));
}
@Bean
WebAuthnBrowserConfigurer webAuthnBrowser(WebAuthnProperties properties, MfaPolicy policy,
SessionChallenges challenges) {
return new WebAuthnBrowserConfigurer(properties, policy, challenges);
}
}
+2
View File
@@ -1,4 +1,6 @@
spring:
autoconfigure:
exclude: org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration
application:
name: iam-login
management:
@@ -0,0 +1,30 @@
-- Based on Spring Security 7.1.1 WebAuthn JDBC schemas (Apache-2.0).
create table user_entities
(
id varchar(1000) not null,
name varchar(100) not null,
display_name varchar(200),
primary key (id)
);
create table user_credentials
(
credential_id varchar(1000) not null,
user_entity_user_id varchar(1000) not null,
public_key bytea not null,
signature_count bigint,
uv_initialized boolean,
backup_eligible boolean not null,
authenticator_transports varchar(1000),
public_key_credential_type varchar(100),
backup_state boolean not null,
attestation_object bytea,
attestation_client_data_json bytea,
created timestamp,
last_used timestamp,
label varchar(1000) not null,
primary key (credential_id)
);
create unique index user_entities_name on user_entities(name);
create index user_credentials_owner on user_credentials(user_entity_user_id);