接入 WebAuthn 第二因素与 PostgreSQL 凭据仓储
This commit is contained in:
+7
-1
@@ -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();
|
||||
|
||||
+71
@@ -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());
|
||||
}
|
||||
}
|
||||
+46
@@ -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);
|
||||
}
|
||||
}
|
||||
+74
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+20
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user