mirror of
https://github.com/spring-projects/spring-security.git
synced 2025-07-10 04:13:31 +00:00
Add Reactive One-Time Token Login support
Closes gh-15699
This commit is contained in:
parent
1adb13db66
commit
2ca2e56383
@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright 2002-2017 the original author or authors.
|
* Copyright 2002-2024 the original author or authors.
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
@ -67,6 +67,16 @@ public enum SecurityWebFiltersOrder {
|
|||||||
|
|
||||||
LOGOUT_PAGE_GENERATING,
|
LOGOUT_PAGE_GENERATING,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link org.springframework.security.web.server.authentication.ott.GenerateOneTimeTokenWebFilter}
|
||||||
|
*/
|
||||||
|
ONE_TIME_TOKEN,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link org.springframework.security.web.server.ui.OneTimeTokenSubmitPageGeneratingWebFilter}
|
||||||
|
*/
|
||||||
|
ONE_TIME_TOKEN_SUBMIT_PAGE_GENERATING,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@link org.springframework.security.web.server.context.SecurityContextServerWebExchangeWebFilter}
|
* {@link org.springframework.security.web.server.context.SecurityContextServerWebExchangeWebFilter}
|
||||||
*/
|
*/
|
||||||
|
@ -53,6 +53,10 @@ import org.springframework.security.authentication.AbstractAuthenticationToken;
|
|||||||
import org.springframework.security.authentication.DelegatingReactiveAuthenticationManager;
|
import org.springframework.security.authentication.DelegatingReactiveAuthenticationManager;
|
||||||
import org.springframework.security.authentication.ReactiveAuthenticationManager;
|
import org.springframework.security.authentication.ReactiveAuthenticationManager;
|
||||||
import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver;
|
import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver;
|
||||||
|
import org.springframework.security.authentication.ott.OneTimeToken;
|
||||||
|
import org.springframework.security.authentication.ott.reactive.InMemoryReactiveOneTimeTokenService;
|
||||||
|
import org.springframework.security.authentication.ott.reactive.OneTimeTokenReactiveAuthenticationManager;
|
||||||
|
import org.springframework.security.authentication.ott.reactive.ReactiveOneTimeTokenService;
|
||||||
import org.springframework.security.authorization.AuthenticatedReactiveAuthorizationManager;
|
import org.springframework.security.authorization.AuthenticatedReactiveAuthorizationManager;
|
||||||
import org.springframework.security.authorization.AuthorityReactiveAuthorizationManager;
|
import org.springframework.security.authorization.AuthorityReactiveAuthorizationManager;
|
||||||
import org.springframework.security.authorization.AuthorizationDecision;
|
import org.springframework.security.authorization.AuthorizationDecision;
|
||||||
@ -152,6 +156,9 @@ import org.springframework.security.web.server.authentication.logout.LogoutWebFi
|
|||||||
import org.springframework.security.web.server.authentication.logout.SecurityContextServerLogoutHandler;
|
import org.springframework.security.web.server.authentication.logout.SecurityContextServerLogoutHandler;
|
||||||
import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler;
|
import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler;
|
||||||
import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler;
|
import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler;
|
||||||
|
import org.springframework.security.web.server.authentication.ott.GenerateOneTimeTokenWebFilter;
|
||||||
|
import org.springframework.security.web.server.authentication.ott.ServerGeneratedOneTimeTokenHandler;
|
||||||
|
import org.springframework.security.web.server.authentication.ott.ServerOneTimeTokenAuthenticationConverter;
|
||||||
import org.springframework.security.web.server.authorization.AuthorizationContext;
|
import org.springframework.security.web.server.authorization.AuthorizationContext;
|
||||||
import org.springframework.security.web.server.authorization.AuthorizationWebFilter;
|
import org.springframework.security.web.server.authorization.AuthorizationWebFilter;
|
||||||
import org.springframework.security.web.server.authorization.DelegatingReactiveAuthorizationManager;
|
import org.springframework.security.web.server.authorization.DelegatingReactiveAuthorizationManager;
|
||||||
@ -197,6 +204,7 @@ import org.springframework.security.web.server.transport.HttpsRedirectWebFilter;
|
|||||||
import org.springframework.security.web.server.ui.DefaultResourcesWebFilter;
|
import org.springframework.security.web.server.ui.DefaultResourcesWebFilter;
|
||||||
import org.springframework.security.web.server.ui.LoginPageGeneratingWebFilter;
|
import org.springframework.security.web.server.ui.LoginPageGeneratingWebFilter;
|
||||||
import org.springframework.security.web.server.ui.LogoutPageGeneratingWebFilter;
|
import org.springframework.security.web.server.ui.LogoutPageGeneratingWebFilter;
|
||||||
|
import org.springframework.security.web.server.ui.OneTimeTokenSubmitPageGeneratingWebFilter;
|
||||||
import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher;
|
import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher;
|
||||||
import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher;
|
import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher;
|
||||||
import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher;
|
import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher;
|
||||||
@ -348,6 +356,8 @@ public class ServerHttpSecurity {
|
|||||||
|
|
||||||
private AnonymousSpec anonymous;
|
private AnonymousSpec anonymous;
|
||||||
|
|
||||||
|
private OneTimeTokenLoginSpec oneTimeTokenLogin;
|
||||||
|
|
||||||
protected ServerHttpSecurity() {
|
protected ServerHttpSecurity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1549,6 +1559,43 @@ public class ServerHttpSecurity {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures One-Time Token Login Support.
|
||||||
|
*
|
||||||
|
* <h2>Example Configuration</h2>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* @Configuration
|
||||||
|
* @EnableWebFluxSecurity
|
||||||
|
* public class SecurityConfig {
|
||||||
|
*
|
||||||
|
* @Bean
|
||||||
|
* public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) throws Exception {
|
||||||
|
* http
|
||||||
|
* // ...
|
||||||
|
* .oneTimeTokenLogin(Customizer.withDefaults());
|
||||||
|
* return http.build();
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @Bean
|
||||||
|
* public ServerGeneratedOneTimeTokenHandler generatedOneTimeTokenHandler() {
|
||||||
|
* return new MyMagicLinkServerGeneratedOneTimeTokenHandler();
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* }
|
||||||
|
* </pre>
|
||||||
|
* @param oneTimeTokenLoginCustomizer the {@link Customizer} to provide more options
|
||||||
|
* for the {@link OneTimeTokenLoginSpec}
|
||||||
|
* @return the {@link ServerHttpSecurity} for further customizations
|
||||||
|
*/
|
||||||
|
public ServerHttpSecurity oneTimeTokenLogin(Customizer<OneTimeTokenLoginSpec> oneTimeTokenLoginCustomizer) {
|
||||||
|
if (this.oneTimeTokenLogin == null) {
|
||||||
|
this.oneTimeTokenLogin = new OneTimeTokenLoginSpec();
|
||||||
|
}
|
||||||
|
oneTimeTokenLoginCustomizer.customize(this.oneTimeTokenLogin);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the {@link SecurityWebFilterChain}
|
* Builds the {@link SecurityWebFilterChain}
|
||||||
* @return the {@link SecurityWebFilterChain}
|
* @return the {@link SecurityWebFilterChain}
|
||||||
@ -1641,6 +1688,18 @@ public class ServerHttpSecurity {
|
|||||||
this.logout.configure(this);
|
this.logout.configure(this);
|
||||||
}
|
}
|
||||||
this.requestCache.configure(this);
|
this.requestCache.configure(this);
|
||||||
|
if (this.oneTimeTokenLogin != null) {
|
||||||
|
if (this.oneTimeTokenLogin.securityContextRepository != null) {
|
||||||
|
this.oneTimeTokenLogin.securityContextRepository(this.oneTimeTokenLogin.securityContextRepository);
|
||||||
|
}
|
||||||
|
else if (this.securityContextRepository != null) {
|
||||||
|
this.oneTimeTokenLogin.securityContextRepository(this.securityContextRepository);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.oneTimeTokenLogin.securityContextRepository(new WebSessionServerSecurityContextRepository());
|
||||||
|
}
|
||||||
|
this.oneTimeTokenLogin.configure(this);
|
||||||
|
}
|
||||||
this.addFilterAt(new SecurityContextServerWebExchangeWebFilter(),
|
this.addFilterAt(new SecurityContextServerWebExchangeWebFilter(),
|
||||||
SecurityWebFiltersOrder.SECURITY_CONTEXT_SERVER_WEB_EXCHANGE);
|
SecurityWebFiltersOrder.SECURITY_CONTEXT_SERVER_WEB_EXCHANGE);
|
||||||
if (this.authorizeExchange != null) {
|
if (this.authorizeExchange != null) {
|
||||||
@ -5850,4 +5909,295 @@ public class ServerHttpSecurity {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures One-Time Token Login Support
|
||||||
|
*
|
||||||
|
* @author Max Batischev
|
||||||
|
* @since 6.4
|
||||||
|
* @see #oneTimeTokenLogin(Customizer)
|
||||||
|
*/
|
||||||
|
public final class OneTimeTokenLoginSpec {
|
||||||
|
|
||||||
|
private ReactiveAuthenticationManager authenticationManager;
|
||||||
|
|
||||||
|
private ReactiveOneTimeTokenService oneTimeTokenService;
|
||||||
|
|
||||||
|
private ServerAuthenticationConverter authenticationConverter = new ServerOneTimeTokenAuthenticationConverter();
|
||||||
|
|
||||||
|
private ServerAuthenticationFailureHandler authenticationFailureHandler;
|
||||||
|
|
||||||
|
private final RedirectServerAuthenticationSuccessHandler defaultSuccessHandler = new RedirectServerAuthenticationSuccessHandler(
|
||||||
|
"/");
|
||||||
|
|
||||||
|
private final List<ServerAuthenticationSuccessHandler> defaultSuccessHandlers = new ArrayList<>(
|
||||||
|
List.of(this.defaultSuccessHandler));
|
||||||
|
|
||||||
|
private final List<ServerAuthenticationSuccessHandler> authenticationSuccessHandlers = new ArrayList<>();
|
||||||
|
|
||||||
|
private ServerGeneratedOneTimeTokenHandler generatedOneTimeTokenHandler;
|
||||||
|
|
||||||
|
private ServerSecurityContextRepository securityContextRepository;
|
||||||
|
|
||||||
|
private String loginProcessingUrl = "/login/ott";
|
||||||
|
|
||||||
|
private String defaultSubmitPageUrl = "/login/ott";
|
||||||
|
|
||||||
|
private String generateTokenUrl = "/ott/generate";
|
||||||
|
|
||||||
|
private boolean submitPageEnabled = true;
|
||||||
|
|
||||||
|
protected void configure(ServerHttpSecurity http) {
|
||||||
|
configureSubmitPage(http);
|
||||||
|
configureOttGenerateFilter(http);
|
||||||
|
configureOttAuthenticationFilter(http);
|
||||||
|
configureDefaultLoginPage(http);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureOttAuthenticationFilter(ServerHttpSecurity http) {
|
||||||
|
AuthenticationWebFilter ottWebFilter = new AuthenticationWebFilter(getAuthenticationManager());
|
||||||
|
ottWebFilter.setServerAuthenticationConverter(this.authenticationConverter);
|
||||||
|
ottWebFilter.setAuthenticationFailureHandler(getAuthenticationFailureHandler());
|
||||||
|
ottWebFilter.setAuthenticationSuccessHandler(getAuthenticationSuccessHandler());
|
||||||
|
ottWebFilter.setRequiresAuthenticationMatcher(
|
||||||
|
ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, this.loginProcessingUrl));
|
||||||
|
ottWebFilter.setSecurityContextRepository(this.securityContextRepository);
|
||||||
|
http.addFilterAt(ottWebFilter, SecurityWebFiltersOrder.AUTHENTICATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureSubmitPage(ServerHttpSecurity http) {
|
||||||
|
if (!this.submitPageEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
OneTimeTokenSubmitPageGeneratingWebFilter submitPage = new OneTimeTokenSubmitPageGeneratingWebFilter();
|
||||||
|
submitPage.setLoginProcessingUrl(this.loginProcessingUrl);
|
||||||
|
|
||||||
|
if (StringUtils.hasText(this.defaultSubmitPageUrl)) {
|
||||||
|
submitPage.setRequestMatcher(
|
||||||
|
ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, this.defaultSubmitPageUrl));
|
||||||
|
}
|
||||||
|
http.addFilterAt(submitPage, SecurityWebFiltersOrder.ONE_TIME_TOKEN_SUBMIT_PAGE_GENERATING);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureOttGenerateFilter(ServerHttpSecurity http) {
|
||||||
|
GenerateOneTimeTokenWebFilter generateFilter = new GenerateOneTimeTokenWebFilter(getOneTimeTokenService(),
|
||||||
|
getGeneratedOneTimeTokenHandler());
|
||||||
|
generateFilter
|
||||||
|
.setRequestMatcher(ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, this.generateTokenUrl));
|
||||||
|
http.addFilterAt(generateFilter, SecurityWebFiltersOrder.ONE_TIME_TOKEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureDefaultLoginPage(ServerHttpSecurity http) {
|
||||||
|
if (http.formLogin != null) {
|
||||||
|
for (WebFilter webFilter : http.webFilters) {
|
||||||
|
OrderedWebFilter orderedWebFilter = (OrderedWebFilter) webFilter;
|
||||||
|
if (orderedWebFilter.webFilter instanceof LoginPageGeneratingWebFilter loginPageGeneratingFilter) {
|
||||||
|
loginPageGeneratingFilter.setOneTimeTokenEnabled(true);
|
||||||
|
loginPageGeneratingFilter.setGenerateOneTimeTokenUrl(this.generateTokenUrl);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows customizing the list of {@link ServerAuthenticationSuccessHandler}. The
|
||||||
|
* default list contains a {@link RedirectServerAuthenticationSuccessHandler} that
|
||||||
|
* redirects to "/".
|
||||||
|
* @param handlersConsumer the handlers consumer
|
||||||
|
* @return the {@link OneTimeTokenLoginSpec} to continue configuring
|
||||||
|
*/
|
||||||
|
public OneTimeTokenLoginSpec authenticationSuccessHandler(
|
||||||
|
Consumer<List<ServerAuthenticationSuccessHandler>> handlersConsumer) {
|
||||||
|
Assert.notNull(handlersConsumer, "handlersConsumer cannot be null");
|
||||||
|
handlersConsumer.accept(this.authenticationSuccessHandlers);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specifies the {@link ServerAuthenticationSuccessHandler}
|
||||||
|
* @param authenticationSuccessHandler the
|
||||||
|
* {@link ServerAuthenticationSuccessHandler}.
|
||||||
|
*/
|
||||||
|
public OneTimeTokenLoginSpec authenticationSuccessHandler(
|
||||||
|
ServerAuthenticationSuccessHandler authenticationSuccessHandler) {
|
||||||
|
Assert.notNull(authenticationSuccessHandler, "authenticationSuccessHandler cannot be null");
|
||||||
|
authenticationSuccessHandler((handlers) -> {
|
||||||
|
handlers.clear();
|
||||||
|
handlers.add(authenticationSuccessHandler);
|
||||||
|
});
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ServerAuthenticationSuccessHandler getAuthenticationSuccessHandler() {
|
||||||
|
if (this.authenticationSuccessHandlers.isEmpty()) {
|
||||||
|
return new DelegatingServerAuthenticationSuccessHandler(this.defaultSuccessHandlers);
|
||||||
|
}
|
||||||
|
return new DelegatingServerAuthenticationSuccessHandler(this.authenticationSuccessHandlers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specifies the {@link ServerAuthenticationFailureHandler} to use when
|
||||||
|
* authentication fails. The default is redirecting to "/login?error" using
|
||||||
|
* {@link RedirectServerAuthenticationFailureHandler}
|
||||||
|
* @param authenticationFailureHandler the
|
||||||
|
* {@link ServerAuthenticationFailureHandler} to use when authentication fails.
|
||||||
|
*/
|
||||||
|
public OneTimeTokenLoginSpec authenticationFailureHandler(
|
||||||
|
ServerAuthenticationFailureHandler authenticationFailureHandler) {
|
||||||
|
Assert.notNull(authenticationFailureHandler, "authenticationFailureHandler cannot be null");
|
||||||
|
this.authenticationFailureHandler = authenticationFailureHandler;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerAuthenticationFailureHandler getAuthenticationFailureHandler() {
|
||||||
|
if (this.authenticationFailureHandler == null) {
|
||||||
|
this.authenticationFailureHandler = new RedirectServerAuthenticationFailureHandler("/login?error");
|
||||||
|
}
|
||||||
|
return this.authenticationFailureHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specifies {@link ReactiveAuthenticationManager} for one time tokens. Default
|
||||||
|
* implementation is {@link OneTimeTokenReactiveAuthenticationManager}
|
||||||
|
* @param authenticationManager
|
||||||
|
*/
|
||||||
|
public OneTimeTokenLoginSpec authenticationManager(ReactiveAuthenticationManager authenticationManager) {
|
||||||
|
Assert.notNull(authenticationManager, "authenticationManager cannot be null");
|
||||||
|
this.authenticationManager = authenticationManager;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactiveAuthenticationManager getAuthenticationManager() {
|
||||||
|
if (this.authenticationManager == null) {
|
||||||
|
ReactiveUserDetailsService userDetailsService = getBean(ReactiveUserDetailsService.class);
|
||||||
|
return new OneTimeTokenReactiveAuthenticationManager(getOneTimeTokenService(), userDetailsService);
|
||||||
|
}
|
||||||
|
return this.authenticationManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures the {@link ReactiveOneTimeTokenService} used to generate and consume
|
||||||
|
* {@link OneTimeToken}
|
||||||
|
* @param oneTimeTokenService
|
||||||
|
*/
|
||||||
|
public OneTimeTokenLoginSpec oneTimeTokenService(ReactiveOneTimeTokenService oneTimeTokenService) {
|
||||||
|
Assert.notNull(oneTimeTokenService, "oneTimeTokenService cannot be null");
|
||||||
|
this.oneTimeTokenService = oneTimeTokenService;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactiveOneTimeTokenService getOneTimeTokenService() {
|
||||||
|
if (this.oneTimeTokenService != null) {
|
||||||
|
return this.oneTimeTokenService;
|
||||||
|
}
|
||||||
|
ReactiveOneTimeTokenService oneTimeTokenService = getBeanOrNull(ReactiveOneTimeTokenService.class);
|
||||||
|
if (oneTimeTokenService != null) {
|
||||||
|
return oneTimeTokenService;
|
||||||
|
}
|
||||||
|
this.oneTimeTokenService = new InMemoryReactiveOneTimeTokenService();
|
||||||
|
return this.oneTimeTokenService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use this {@link ServerAuthenticationConverter} when converting incoming
|
||||||
|
* requests to an {@link Authentication}. By default, the
|
||||||
|
* {@link ServerOneTimeTokenAuthenticationConverter} is used.
|
||||||
|
* @param authenticationConverter the {@link ServerAuthenticationConverter} to use
|
||||||
|
*/
|
||||||
|
public OneTimeTokenLoginSpec authenticationConverter(ServerAuthenticationConverter authenticationConverter) {
|
||||||
|
Assert.notNull(authenticationConverter, "authenticationConverter cannot be null");
|
||||||
|
this.authenticationConverter = authenticationConverter;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specifies the URL to process the login request, defaults to {@code /login/ott}.
|
||||||
|
* Only POST requests are processed, for that reason make sure that you pass a
|
||||||
|
* valid CSRF token if CSRF protection is enabled.
|
||||||
|
* @param loginProcessingUrl
|
||||||
|
*/
|
||||||
|
public OneTimeTokenLoginSpec loginProcessingUrl(String loginProcessingUrl) {
|
||||||
|
Assert.hasText(loginProcessingUrl, "loginProcessingUrl cannot be null or empty");
|
||||||
|
this.loginProcessingUrl = loginProcessingUrl;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures whether the default one-time token submit page should be shown. This
|
||||||
|
* will prevent the {@link OneTimeTokenSubmitPageGeneratingWebFilter} to be
|
||||||
|
* configured.
|
||||||
|
* @param show
|
||||||
|
*/
|
||||||
|
public OneTimeTokenLoginSpec showDefaultSubmitPage(boolean show) {
|
||||||
|
this.submitPageEnabled = show;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the URL that the default submit page will be generated. Defaults to
|
||||||
|
* {@code /login/ott}. If you don't want to generate the default submit page you
|
||||||
|
* should use {@link #showDefaultSubmitPage(boolean)}. Note that this method
|
||||||
|
* always invoke {@link #showDefaultSubmitPage(boolean)} passing {@code true}.
|
||||||
|
* @param submitPageUrl
|
||||||
|
*/
|
||||||
|
public OneTimeTokenLoginSpec defaultSubmitPageUrl(String submitPageUrl) {
|
||||||
|
Assert.hasText(submitPageUrl, "submitPageUrl cannot be null or empty");
|
||||||
|
this.defaultSubmitPageUrl = submitPageUrl;
|
||||||
|
showDefaultSubmitPage(true);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specifies strategy to be used to handle generated one-time tokens.
|
||||||
|
* @param generatedOneTimeTokenHandler
|
||||||
|
*/
|
||||||
|
public OneTimeTokenLoginSpec generatedOneTimeTokenHandler(
|
||||||
|
ServerGeneratedOneTimeTokenHandler generatedOneTimeTokenHandler) {
|
||||||
|
Assert.notNull(generatedOneTimeTokenHandler, "generatedOneTimeTokenHandler cannot be null");
|
||||||
|
this.generatedOneTimeTokenHandler = generatedOneTimeTokenHandler;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specifies the URL that a One-Time Token generate request will be processed.
|
||||||
|
* Defaults to {@code /ott/generate}.
|
||||||
|
* @param generateTokenUrl
|
||||||
|
*/
|
||||||
|
public OneTimeTokenLoginSpec generateTokenUrl(String generateTokenUrl) {
|
||||||
|
Assert.hasText(generateTokenUrl, "generateTokenUrl cannot be null or empty");
|
||||||
|
this.generateTokenUrl = generateTokenUrl;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link ServerSecurityContextRepository} used to save the
|
||||||
|
* {@code Authentication}. Defaults to
|
||||||
|
* {@link WebSessionServerSecurityContextRepository}. For the
|
||||||
|
* {@code SecurityContext} to be loaded on subsequent requests the
|
||||||
|
* {@link ReactorContextWebFilter} must be configured to be able to load the value
|
||||||
|
* (they are not implicitly linked).
|
||||||
|
* @param securityContextRepository the repository to use
|
||||||
|
* @return the {@link OneTimeTokenLoginSpec} to continue configuring
|
||||||
|
*/
|
||||||
|
public OneTimeTokenLoginSpec securityContextRepository(
|
||||||
|
ServerSecurityContextRepository securityContextRepository) {
|
||||||
|
this.securityContextRepository = securityContextRepository;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ServerGeneratedOneTimeTokenHandler getGeneratedOneTimeTokenHandler() {
|
||||||
|
if (this.generatedOneTimeTokenHandler == null) {
|
||||||
|
this.generatedOneTimeTokenHandler = getBeanOrNull(ServerGeneratedOneTimeTokenHandler.class);
|
||||||
|
}
|
||||||
|
if (this.generatedOneTimeTokenHandler == null) {
|
||||||
|
throw new IllegalStateException("""
|
||||||
|
A ServerGeneratedOneTimeTokenHandler is required to enable oneTimeTokenLogin().
|
||||||
|
Please provide it as a bean or pass it to the oneTimeTokenLogin() DSL.
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
return this.generatedOneTimeTokenHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,406 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2002-2024 the original author or authors.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.springframework.security.config.web.server;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.ApplicationContext;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.security.authentication.ott.OneTimeToken;
|
||||||
|
import org.springframework.security.config.Customizer;
|
||||||
|
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
|
||||||
|
import org.springframework.security.config.test.SpringTestContext;
|
||||||
|
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||||
|
import org.springframework.security.core.userdetails.MapReactiveUserDetailsService;
|
||||||
|
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
|
||||||
|
import org.springframework.security.core.userdetails.User;
|
||||||
|
import org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers;
|
||||||
|
import org.springframework.security.web.server.SecurityWebFilterChain;
|
||||||
|
import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler;
|
||||||
|
import org.springframework.security.web.server.authentication.ott.ServerGeneratedOneTimeTokenHandler;
|
||||||
|
import org.springframework.security.web.server.authentication.ott.ServerRedirectGeneratedOneTimeTokenHandler;
|
||||||
|
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||||
|
import org.springframework.web.reactive.config.EnableWebFlux;
|
||||||
|
import org.springframework.web.reactive.function.BodyInserters;
|
||||||
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link ServerHttpSecurity.OneTimeTokenLoginSpec}
|
||||||
|
*
|
||||||
|
* @author Max Batischev
|
||||||
|
*/
|
||||||
|
@ExtendWith(SpringTestContextExtension.class)
|
||||||
|
public class OneTimeTokenLoginSpecTests {
|
||||||
|
|
||||||
|
public final SpringTestContext spring = new SpringTestContext(this);
|
||||||
|
|
||||||
|
private WebTestClient client;
|
||||||
|
|
||||||
|
private static final String EXPECTED_HTML_HEAD = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<meta name="description" content="">
|
||||||
|
<meta name="author" content="">
|
||||||
|
<title>Please sign in</title>
|
||||||
|
<link href="/default-ui.css" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String LOGIN_PART = """
|
||||||
|
<form class="login-form" method="post" action="/login">
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String GENERATE_OTT_PART = """
|
||||||
|
<form id="ott-form" class="login-form" method="post" action="/ott/generate">
|
||||||
|
""";
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public void setApplicationContext(ApplicationContext context) {
|
||||||
|
this.client = WebTestClient.bindToApplicationContext(context).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oneTimeTokenWhenCorrectTokenThenCanAuthenticate() {
|
||||||
|
this.spring.register(OneTimeTokenDefaultConfig.class).autowire();
|
||||||
|
|
||||||
|
// @formatter:off
|
||||||
|
this.client.mutateWith(SecurityMockServerConfigurers.csrf())
|
||||||
|
.post()
|
||||||
|
.uri((uriBuilder) -> uriBuilder
|
||||||
|
.path("/ott/generate")
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||||
|
.body(BodyInserters.fromFormData("username", "user"))
|
||||||
|
.exchange()
|
||||||
|
.expectStatus()
|
||||||
|
.is3xxRedirection()
|
||||||
|
.expectHeader().valueEquals("Location", "/login/ott");
|
||||||
|
// @formatter:on
|
||||||
|
|
||||||
|
String token = TestServerGeneratedOneTimeTokenHandler.lastToken.getTokenValue();
|
||||||
|
|
||||||
|
// @formatter:off
|
||||||
|
this.client.mutateWith(SecurityMockServerConfigurers.csrf())
|
||||||
|
.post()
|
||||||
|
.uri((uriBuilder) -> uriBuilder
|
||||||
|
.path("/login/ott")
|
||||||
|
.queryParam("token", token)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.exchange()
|
||||||
|
.expectStatus()
|
||||||
|
.is3xxRedirection()
|
||||||
|
.expectHeader().valueEquals("Location", "/");
|
||||||
|
// @formatter:on
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oneTimeTokenWhenDifferentAuthenticationUrlsThenCanAuthenticate() {
|
||||||
|
this.spring.register(OneTimeTokenDifferentUrlsConfig.class).autowire();
|
||||||
|
|
||||||
|
// @formatter:off
|
||||||
|
this.client.mutateWith(SecurityMockServerConfigurers.csrf())
|
||||||
|
.post()
|
||||||
|
.uri((uriBuilder) -> uriBuilder
|
||||||
|
.path("/generateurl")
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||||
|
.body(BodyInserters.fromValue("username=user"))
|
||||||
|
.exchange()
|
||||||
|
.expectStatus()
|
||||||
|
.is3xxRedirection()
|
||||||
|
.expectHeader().valueEquals("Location", "/redirected");
|
||||||
|
// @formatter:on
|
||||||
|
|
||||||
|
String token = TestServerGeneratedOneTimeTokenHandler.lastToken.getTokenValue();
|
||||||
|
|
||||||
|
// @formatter:off
|
||||||
|
this.client.mutateWith(SecurityMockServerConfigurers.csrf())
|
||||||
|
.post()
|
||||||
|
.uri((uriBuilder) -> uriBuilder
|
||||||
|
.path("/loginprocessingurl")
|
||||||
|
.queryParam("token", token)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.exchange()
|
||||||
|
.expectStatus()
|
||||||
|
.is3xxRedirection()
|
||||||
|
.expectHeader().valueEquals("Location", "/authenticated");
|
||||||
|
// @formatter:on
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oneTimeTokenWhenCorrectTokenUsedTwiceThenSecondTimeFails() {
|
||||||
|
this.spring.register(OneTimeTokenDefaultConfig.class).autowire();
|
||||||
|
|
||||||
|
// @formatter:off
|
||||||
|
this.client.mutateWith(SecurityMockServerConfigurers.csrf())
|
||||||
|
.post()
|
||||||
|
.uri((uriBuilder) -> uriBuilder
|
||||||
|
.path("/ott/generate")
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||||
|
.body(BodyInserters.fromValue("username=user"))
|
||||||
|
.exchange()
|
||||||
|
.expectStatus()
|
||||||
|
.is3xxRedirection()
|
||||||
|
.expectHeader().valueEquals("Location", "/login/ott");
|
||||||
|
// @formatter:on
|
||||||
|
|
||||||
|
String token = TestServerGeneratedOneTimeTokenHandler.lastToken.getTokenValue();
|
||||||
|
|
||||||
|
// @formatter:off
|
||||||
|
this.client.mutateWith(SecurityMockServerConfigurers.csrf())
|
||||||
|
.post()
|
||||||
|
.uri((uriBuilder) -> uriBuilder
|
||||||
|
.path("/login/ott")
|
||||||
|
.queryParam("token", token)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.exchange()
|
||||||
|
.expectStatus()
|
||||||
|
.is3xxRedirection()
|
||||||
|
.expectHeader().valueEquals("Location", "/");
|
||||||
|
|
||||||
|
this.client.mutateWith(SecurityMockServerConfigurers.csrf())
|
||||||
|
.post()
|
||||||
|
.uri((uriBuilder) -> uriBuilder
|
||||||
|
.path("/login/ott")
|
||||||
|
.queryParam("token", token)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.exchange()
|
||||||
|
.expectStatus()
|
||||||
|
.is3xxRedirection()
|
||||||
|
.expectHeader().valueEquals("Location", "/login?error");
|
||||||
|
// @formatter:on
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oneTimeTokenWhenWrongTokenThenAuthenticationFail() {
|
||||||
|
this.spring.register(OneTimeTokenDefaultConfig.class).autowire();
|
||||||
|
|
||||||
|
// @formatter:off
|
||||||
|
this.client.mutateWith(SecurityMockServerConfigurers.csrf())
|
||||||
|
.post()
|
||||||
|
.uri((uriBuilder) -> uriBuilder
|
||||||
|
.path("/ott/generate")
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||||
|
.body(BodyInserters.fromValue("username=user"))
|
||||||
|
.exchange()
|
||||||
|
.expectStatus()
|
||||||
|
.is3xxRedirection()
|
||||||
|
.expectHeader().valueEquals("Location", "/login/ott");
|
||||||
|
// @formatter:on
|
||||||
|
|
||||||
|
String token = "wrong";
|
||||||
|
|
||||||
|
// @formatter:off
|
||||||
|
this.client.mutateWith(SecurityMockServerConfigurers.csrf())
|
||||||
|
.post()
|
||||||
|
.uri((uriBuilder) -> uriBuilder
|
||||||
|
.path("/login/ott")
|
||||||
|
.queryParam("token", token)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.exchange()
|
||||||
|
.expectStatus()
|
||||||
|
.is3xxRedirection()
|
||||||
|
.expectHeader().valueEquals("Location", "/login?error");
|
||||||
|
// @formatter:on
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oneTimeTokenWhenFormLoginConfiguredThenRendersRequestTokenForm() {
|
||||||
|
this.spring.register(OneTimeTokenFormLoginConfig.class).autowire();
|
||||||
|
|
||||||
|
//@formatter:off
|
||||||
|
byte[] responseByteArray = this.client.mutateWith(SecurityMockServerConfigurers.csrf())
|
||||||
|
.get()
|
||||||
|
.uri((uriBuilder) -> uriBuilder
|
||||||
|
.path("/login")
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.exchange()
|
||||||
|
.expectBody()
|
||||||
|
.returnResult()
|
||||||
|
.getResponseBody();
|
||||||
|
// @formatter:on
|
||||||
|
|
||||||
|
String response = new String(responseByteArray);
|
||||||
|
|
||||||
|
assertThat(response.contains(EXPECTED_HTML_HEAD)).isTrue();
|
||||||
|
assertThat(response.contains(LOGIN_PART)).isTrue();
|
||||||
|
assertThat(response.contains(GENERATE_OTT_PART)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oneTimeTokenWhenNoGeneratedOneTimeTokenHandlerThenException() {
|
||||||
|
assertThatException()
|
||||||
|
.isThrownBy(() -> this.spring.register(OneTimeTokenNotGeneratedOttHandlerConfig.class).autowire())
|
||||||
|
.havingRootCause()
|
||||||
|
.isInstanceOf(IllegalStateException.class)
|
||||||
|
.withMessage("""
|
||||||
|
A ServerGeneratedOneTimeTokenHandler is required to enable oneTimeTokenLogin().
|
||||||
|
Please provide it as a bean or pass it to the oneTimeTokenLogin() DSL.
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
@EnableWebFlux
|
||||||
|
@EnableWebFluxSecurity
|
||||||
|
@Import(UserDetailsServiceConfig.class)
|
||||||
|
static class OneTimeTokenDefaultConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
|
||||||
|
// @formatter:off
|
||||||
|
http
|
||||||
|
.authorizeExchange((authorize) -> authorize
|
||||||
|
.anyExchange()
|
||||||
|
.authenticated())
|
||||||
|
.oneTimeTokenLogin((ott) -> ott
|
||||||
|
.generatedOneTimeTokenHandler(new TestServerGeneratedOneTimeTokenHandler())
|
||||||
|
);
|
||||||
|
// @formatter:on
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
@EnableWebFlux
|
||||||
|
@EnableWebFluxSecurity
|
||||||
|
@Import(UserDetailsServiceConfig.class)
|
||||||
|
static class OneTimeTokenDifferentUrlsConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) {
|
||||||
|
// @formatter:off
|
||||||
|
http
|
||||||
|
.authorizeExchange((authorize) -> authorize
|
||||||
|
.anyExchange()
|
||||||
|
.authenticated())
|
||||||
|
.oneTimeTokenLogin((ott) -> ott
|
||||||
|
.generateTokenUrl("/generateurl")
|
||||||
|
.generatedOneTimeTokenHandler(new TestServerGeneratedOneTimeTokenHandler("/redirected"))
|
||||||
|
.loginProcessingUrl("/loginprocessingurl")
|
||||||
|
.authenticationSuccessHandler(new RedirectServerAuthenticationSuccessHandler("/authenticated"))
|
||||||
|
);
|
||||||
|
// @formatter:on
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
@EnableWebFlux
|
||||||
|
@EnableWebFluxSecurity
|
||||||
|
@Import(UserDetailsServiceConfig.class)
|
||||||
|
static class OneTimeTokenFormLoginConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) {
|
||||||
|
// @formatter:off
|
||||||
|
http
|
||||||
|
.authorizeExchange((authorize) -> authorize
|
||||||
|
.anyExchange()
|
||||||
|
.authenticated())
|
||||||
|
.formLogin(Customizer.withDefaults())
|
||||||
|
.oneTimeTokenLogin((ott) -> ott
|
||||||
|
.generatedOneTimeTokenHandler(new TestServerGeneratedOneTimeTokenHandler())
|
||||||
|
);
|
||||||
|
// @formatter:on
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
@EnableWebFlux
|
||||||
|
@EnableWebFluxSecurity
|
||||||
|
@Import(UserDetailsServiceConfig.class)
|
||||||
|
static class OneTimeTokenNotGeneratedOttHandlerConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) {
|
||||||
|
// @formatter:off
|
||||||
|
http
|
||||||
|
.authorizeExchange((authorize) -> authorize
|
||||||
|
.anyExchange()
|
||||||
|
.authenticated())
|
||||||
|
.oneTimeTokenLogin(Customizer.withDefaults());
|
||||||
|
// @formatter:on
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
static class UserDetailsServiceConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
ReactiveUserDetailsService userDetailsService() {
|
||||||
|
return new MapReactiveUserDetailsService(
|
||||||
|
Map.of("user", new User("user", "password", Collections.emptyList())));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class TestServerGeneratedOneTimeTokenHandler implements ServerGeneratedOneTimeTokenHandler {
|
||||||
|
|
||||||
|
private static OneTimeToken lastToken;
|
||||||
|
|
||||||
|
private final ServerGeneratedOneTimeTokenHandler delegate;
|
||||||
|
|
||||||
|
TestServerGeneratedOneTimeTokenHandler() {
|
||||||
|
this.delegate = new ServerRedirectGeneratedOneTimeTokenHandler("/login/ott");
|
||||||
|
}
|
||||||
|
|
||||||
|
TestServerGeneratedOneTimeTokenHandler(String redirectUrl) {
|
||||||
|
this.delegate = new ServerRedirectGeneratedOneTimeTokenHandler(redirectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> handle(ServerWebExchange exchange, OneTimeToken oneTimeToken) {
|
||||||
|
lastToken = oneTimeToken;
|
||||||
|
return this.delegate.handle(exchange, oneTimeToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,60 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2002-2024 the original author or authors.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.springframework.security.authentication.ott.reactive;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest;
|
||||||
|
import org.springframework.security.authentication.ott.InMemoryOneTimeTokenService;
|
||||||
|
import org.springframework.security.authentication.ott.OneTimeToken;
|
||||||
|
import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationToken;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive adapter for {@link InMemoryOneTimeTokenService}
|
||||||
|
*
|
||||||
|
* @author Max Batischev
|
||||||
|
* @since 6.4
|
||||||
|
* @see InMemoryOneTimeTokenService
|
||||||
|
*/
|
||||||
|
public final class InMemoryReactiveOneTimeTokenService implements ReactiveOneTimeTokenService {
|
||||||
|
|
||||||
|
private final InMemoryOneTimeTokenService oneTimeTokenService = new InMemoryOneTimeTokenService();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<OneTimeToken> generate(GenerateOneTimeTokenRequest request) {
|
||||||
|
return Mono.just(request).map(this.oneTimeTokenService::generate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<OneTimeToken> consume(OneTimeTokenAuthenticationToken authenticationToken) {
|
||||||
|
return Mono.just(authenticationToken).mapNotNull(this.oneTimeTokenService::consume);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the {@link Clock} used when generating one-time token and checking token
|
||||||
|
* expiry.
|
||||||
|
* @param clock the clock
|
||||||
|
*/
|
||||||
|
public void setClock(Clock clock) {
|
||||||
|
Assert.notNull(clock, "clock cannot be null");
|
||||||
|
this.oneTimeTokenService.setClock(clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,71 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2002-2024 the original author or authors.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.springframework.security.authentication.ott.reactive;
|
||||||
|
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import org.springframework.security.authentication.ReactiveAuthenticationManager;
|
||||||
|
import org.springframework.security.authentication.ott.InvalidOneTimeTokenException;
|
||||||
|
import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationToken;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link ReactiveAuthenticationManager} for one time tokens.
|
||||||
|
*
|
||||||
|
* @author Max Batischev
|
||||||
|
* @since 6.4
|
||||||
|
*/
|
||||||
|
public final class OneTimeTokenReactiveAuthenticationManager implements ReactiveAuthenticationManager {
|
||||||
|
|
||||||
|
private final ReactiveOneTimeTokenService oneTimeTokenService;
|
||||||
|
|
||||||
|
private final ReactiveUserDetailsService userDetailsService;
|
||||||
|
|
||||||
|
public OneTimeTokenReactiveAuthenticationManager(ReactiveOneTimeTokenService oneTimeTokenService,
|
||||||
|
ReactiveUserDetailsService userDetailsService) {
|
||||||
|
Assert.notNull(oneTimeTokenService, "oneTimeTokenService cannot be null");
|
||||||
|
Assert.notNull(userDetailsService, "userDetailsService cannot be null");
|
||||||
|
this.oneTimeTokenService = oneTimeTokenService;
|
||||||
|
this.userDetailsService = userDetailsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Authentication> authenticate(Authentication authentication) {
|
||||||
|
if (!(authentication instanceof OneTimeTokenAuthenticationToken otpAuthenticationToken)) {
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
return this.oneTimeTokenService.consume(otpAuthenticationToken)
|
||||||
|
.switchIfEmpty(Mono.error(new InvalidOneTimeTokenException("Invalid token")))
|
||||||
|
.flatMap((consumed) -> this.userDetailsService.findByUsername(consumed.getUsername()))
|
||||||
|
.map(onSuccess(otpAuthenticationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Function<UserDetails, OneTimeTokenAuthenticationToken> onSuccess(OneTimeTokenAuthenticationToken token) {
|
||||||
|
return (user) -> {
|
||||||
|
OneTimeTokenAuthenticationToken authenticated = OneTimeTokenAuthenticationToken.authenticated(user,
|
||||||
|
user.getAuthorities());
|
||||||
|
authenticated.setDetails(token.getDetails());
|
||||||
|
return authenticated;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2002-2024 the original author or authors.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.springframework.security.authentication.ott.reactive;
|
||||||
|
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest;
|
||||||
|
import org.springframework.security.authentication.ott.OneTimeToken;
|
||||||
|
import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive interface for generating and consuming one-time tokens.
|
||||||
|
*
|
||||||
|
* @author Max Batischev
|
||||||
|
* @since 6.4
|
||||||
|
*/
|
||||||
|
public interface ReactiveOneTimeTokenService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a one-time token based on the provided generate request.
|
||||||
|
* @param request the generate request containing the necessary information to
|
||||||
|
* generate the token
|
||||||
|
* @return the generated {@link OneTimeToken}.
|
||||||
|
*/
|
||||||
|
Mono<OneTimeToken> generate(GenerateOneTimeTokenRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consumes a one-time token based on the provided authentication token.
|
||||||
|
* @param authenticationToken the authentication token containing the one-time token
|
||||||
|
* value to be consumed
|
||||||
|
* @return the consumed {@link OneTimeToken} or empty Mono if the token is invalid
|
||||||
|
*/
|
||||||
|
Mono<OneTimeToken> consume(OneTimeTokenAuthenticationToken authenticationToken);
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,132 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2002-2024 the original author or authors.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.springframework.security.authentication.ott.reactive;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest;
|
||||||
|
import org.springframework.security.authentication.ott.OneTimeToken;
|
||||||
|
import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationToken;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNoException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link InMemoryReactiveOneTimeTokenService}
|
||||||
|
*
|
||||||
|
* @author Max Batischev
|
||||||
|
*/
|
||||||
|
public class InMemoryReactiveOneTimeTokenServiceTests {
|
||||||
|
|
||||||
|
private final InMemoryReactiveOneTimeTokenService oneTimeTokenService = new InMemoryReactiveOneTimeTokenService();
|
||||||
|
|
||||||
|
private static final String USERNAME = "user";
|
||||||
|
|
||||||
|
private static final String TOKEN = "token";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generateThenTokenValueShouldBeValidUuidAndProvidedUsernameIsUsed() {
|
||||||
|
GenerateOneTimeTokenRequest request = new GenerateOneTimeTokenRequest(USERNAME);
|
||||||
|
|
||||||
|
OneTimeToken oneTimeToken = this.oneTimeTokenService.generate(request).block();
|
||||||
|
|
||||||
|
assertThatNoException().isThrownBy(() -> UUID.fromString(oneTimeToken.getTokenValue()));
|
||||||
|
assertThat(oneTimeToken.getUsername()).isEqualTo(USERNAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void consumeWhenTokenDoesNotExistThenNull() {
|
||||||
|
OneTimeTokenAuthenticationToken authenticationToken = new OneTimeTokenAuthenticationToken(TOKEN);
|
||||||
|
|
||||||
|
OneTimeToken oneTimeToken = this.oneTimeTokenService.consume(authenticationToken).block();
|
||||||
|
|
||||||
|
assertThat(oneTimeToken).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void consumeWhenTokenExistsThenReturnItself() {
|
||||||
|
GenerateOneTimeTokenRequest request = new GenerateOneTimeTokenRequest(USERNAME);
|
||||||
|
OneTimeToken generated = this.oneTimeTokenService.generate(request).block();
|
||||||
|
OneTimeTokenAuthenticationToken authenticationToken = new OneTimeTokenAuthenticationToken(
|
||||||
|
generated.getTokenValue());
|
||||||
|
|
||||||
|
OneTimeToken consumed = this.oneTimeTokenService.consume(authenticationToken).block();
|
||||||
|
|
||||||
|
assertThat(consumed.getTokenValue()).isEqualTo(generated.getTokenValue());
|
||||||
|
assertThat(consumed.getUsername()).isEqualTo(generated.getUsername());
|
||||||
|
assertThat(consumed.getExpiresAt()).isEqualTo(generated.getExpiresAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void consumeWhenTokenIsExpiredThenReturnNull() {
|
||||||
|
GenerateOneTimeTokenRequest request = new GenerateOneTimeTokenRequest(USERNAME);
|
||||||
|
OneTimeToken generated = this.oneTimeTokenService.generate(request).block();
|
||||||
|
OneTimeTokenAuthenticationToken authenticationToken = new OneTimeTokenAuthenticationToken(
|
||||||
|
generated.getTokenValue());
|
||||||
|
Clock tenMinutesFromNow = Clock.fixed(Instant.now().plus(10, ChronoUnit.MINUTES), ZoneOffset.UTC);
|
||||||
|
this.oneTimeTokenService.setClock(tenMinutesFromNow);
|
||||||
|
|
||||||
|
OneTimeToken consumed = this.oneTimeTokenService.consume(authenticationToken).block();
|
||||||
|
|
||||||
|
assertThat(consumed).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generateWhenMoreThan100TokensThenClearExpired() {
|
||||||
|
// @formatter:off
|
||||||
|
List<OneTimeToken> toExpire = generate(50); // 50 tokens will expire in 5 minutes from now
|
||||||
|
Clock twoMinutesFromNow = Clock.fixed(Instant.now().plus(2, ChronoUnit.MINUTES), ZoneOffset.UTC);
|
||||||
|
this.oneTimeTokenService.setClock(twoMinutesFromNow);
|
||||||
|
List<OneTimeToken> toKeep = generate(50); // 50 tokens will expire in 7 minutes from now
|
||||||
|
Clock sixMinutesFromNow = Clock.fixed(Instant.now().plus(6, ChronoUnit.MINUTES), ZoneOffset.UTC);
|
||||||
|
this.oneTimeTokenService.setClock(sixMinutesFromNow);
|
||||||
|
|
||||||
|
assertThat(toExpire)
|
||||||
|
.extracting((token) -> this.oneTimeTokenService.consume(new OneTimeTokenAuthenticationToken(token.getTokenValue()))
|
||||||
|
.block()
|
||||||
|
)
|
||||||
|
.containsOnlyNulls();
|
||||||
|
|
||||||
|
assertThat(toKeep)
|
||||||
|
.extracting((token) -> this.oneTimeTokenService.consume(new OneTimeTokenAuthenticationToken(token.getTokenValue()))
|
||||||
|
.block()
|
||||||
|
)
|
||||||
|
.noneMatch(Objects::isNull);
|
||||||
|
// @formatter:on
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<OneTimeToken> generate(int howMany) {
|
||||||
|
List<OneTimeToken> generated = new ArrayList<>(howMany);
|
||||||
|
for (int i = 0; i < howMany; i++) {
|
||||||
|
OneTimeToken oneTimeToken = this.oneTimeTokenService
|
||||||
|
.generate(new GenerateOneTimeTokenRequest("generated" + i))
|
||||||
|
.block();
|
||||||
|
generated.add(oneTimeToken);
|
||||||
|
}
|
||||||
|
return generated;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,141 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2002-2024 the original author or authors.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.springframework.security.authentication.ott.reactive;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Collection;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentMatchers;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import org.springframework.security.authentication.ReactiveAuthenticationManager;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.authentication.ott.DefaultOneTimeToken;
|
||||||
|
import org.springframework.security.authentication.ott.InvalidOneTimeTokenException;
|
||||||
|
import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationToken;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
|
import org.springframework.security.core.authority.AuthorityUtils;
|
||||||
|
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
|
||||||
|
import org.springframework.security.core.userdetails.User;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.util.CollectionUtils;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.BDDMockito.given;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link OneTimeTokenReactiveAuthenticationManager}
|
||||||
|
*
|
||||||
|
* @author Max Batischev
|
||||||
|
*/
|
||||||
|
public class OneTimeTokenReactiveAuthenticationManagerTests {
|
||||||
|
|
||||||
|
private ReactiveAuthenticationManager authenticationManager;
|
||||||
|
|
||||||
|
private static final String USERNAME = "user";
|
||||||
|
|
||||||
|
private static final String PASSWORD = "password";
|
||||||
|
|
||||||
|
private static final String TOKEN = "token";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void constructorWhenOneTimeTokenServiceNullThenIllegalArgumentException() {
|
||||||
|
ReactiveUserDetailsService userDetailsService = mock(ReactiveUserDetailsService.class);
|
||||||
|
// @formatter:off
|
||||||
|
assertThatIllegalArgumentException()
|
||||||
|
.isThrownBy(() -> new OneTimeTokenReactiveAuthenticationManager(null, userDetailsService));
|
||||||
|
// @formatter:on
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void constructorWhenUserDetailsServiceNullThenIllegalArgumentException() {
|
||||||
|
ReactiveOneTimeTokenService oneTimeTokenService = mock(ReactiveOneTimeTokenService.class);
|
||||||
|
// @formatter:off
|
||||||
|
assertThatIllegalArgumentException()
|
||||||
|
.isThrownBy(() -> new OneTimeTokenReactiveAuthenticationManager(oneTimeTokenService, null));
|
||||||
|
// @formatter:on
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void authenticateWhenOneTimeTokenAuthenticationTokenIsPresentThenSuccess() {
|
||||||
|
ReactiveOneTimeTokenService oneTimeTokenService = mock(ReactiveOneTimeTokenService.class);
|
||||||
|
given(oneTimeTokenService.consume(ArgumentMatchers.any(OneTimeTokenAuthenticationToken.class)))
|
||||||
|
.willReturn(Mono.just(new DefaultOneTimeToken(TOKEN, USERNAME, Instant.now())));
|
||||||
|
ReactiveUserDetailsService userDetailsService = mock(ReactiveUserDetailsService.class);
|
||||||
|
User testUser = new User(USERNAME, PASSWORD, AuthorityUtils.createAuthorityList("TEST"));
|
||||||
|
given(userDetailsService.findByUsername(eq(USERNAME))).willReturn(Mono.just(testUser));
|
||||||
|
|
||||||
|
this.authenticationManager = new OneTimeTokenReactiveAuthenticationManager(oneTimeTokenService,
|
||||||
|
userDetailsService);
|
||||||
|
|
||||||
|
Authentication auth = this.authenticationManager
|
||||||
|
.authenticate(OneTimeTokenAuthenticationToken.unauthenticated(TOKEN))
|
||||||
|
.block();
|
||||||
|
|
||||||
|
OneTimeTokenAuthenticationToken token = (OneTimeTokenAuthenticationToken) auth;
|
||||||
|
UserDetails user = (UserDetails) token.getPrincipal();
|
||||||
|
Collection<GrantedAuthority> authorities = token.getAuthorities();
|
||||||
|
|
||||||
|
assertThat(user).isNotNull();
|
||||||
|
assertThat(user.getUsername()).isEqualTo(USERNAME);
|
||||||
|
assertThat(user.getPassword()).isEqualTo(PASSWORD);
|
||||||
|
assertThat(token.isAuthenticated()).isTrue();
|
||||||
|
assertThat(CollectionUtils.isEmpty(authorities)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void authenticateWhenInvalidOneTimeTokenAuthenticationTokenIsPresentThenFail() {
|
||||||
|
ReactiveOneTimeTokenService oneTimeTokenService = mock(ReactiveOneTimeTokenService.class);
|
||||||
|
given(oneTimeTokenService.consume(ArgumentMatchers.any(OneTimeTokenAuthenticationToken.class)))
|
||||||
|
.willReturn(Mono.empty());
|
||||||
|
ReactiveUserDetailsService userDetailsService = mock(ReactiveUserDetailsService.class);
|
||||||
|
|
||||||
|
this.authenticationManager = new OneTimeTokenReactiveAuthenticationManager(oneTimeTokenService,
|
||||||
|
userDetailsService);
|
||||||
|
|
||||||
|
// @formatter:off
|
||||||
|
assertThatExceptionOfType(InvalidOneTimeTokenException.class)
|
||||||
|
.isThrownBy(() -> this.authenticationManager.authenticate(OneTimeTokenAuthenticationToken.unauthenticated(TOKEN))
|
||||||
|
.block());
|
||||||
|
// @formatter:on
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void authenticateWhenIncorrectTypeOfAuthenticationIsPresentThenFail() {
|
||||||
|
ReactiveOneTimeTokenService oneTimeTokenService = mock(ReactiveOneTimeTokenService.class);
|
||||||
|
given(oneTimeTokenService.consume(ArgumentMatchers.any(OneTimeTokenAuthenticationToken.class)))
|
||||||
|
.willReturn(Mono.empty());
|
||||||
|
ReactiveUserDetailsService userDetailsService = mock(ReactiveUserDetailsService.class);
|
||||||
|
|
||||||
|
this.authenticationManager = new OneTimeTokenReactiveAuthenticationManager(oneTimeTokenService,
|
||||||
|
userDetailsService);
|
||||||
|
|
||||||
|
// @formatter:off
|
||||||
|
Authentication authentication = this.authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(USERNAME, PASSWORD))
|
||||||
|
.block();
|
||||||
|
// @formatter:on
|
||||||
|
|
||||||
|
assertThat(authentication).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,341 @@
|
|||||||
|
[[one-time-token-login]]
|
||||||
|
= One-Time Token Login
|
||||||
|
|
||||||
|
Spring Security offers support for One-Time Token (OTT) authentication via the `oneTimeTokenLogin()` DSL.
|
||||||
|
Before diving into implementation details, it's important to clarify the scope of the OTT feature within the framework, highlighting what is supported and what isn't.
|
||||||
|
|
||||||
|
== Understanding One-Time Tokens vs. One-Time Passwords
|
||||||
|
|
||||||
|
It's common to confuse One-Time Tokens (OTT) with https://en.wikipedia.org/wiki/One-time_password[One-Time Passwords] (OTP), but in Spring Security, these concepts differ in several key ways.
|
||||||
|
For clarity, we'll assume OTP refers to https://en.wikipedia.org/wiki/Time-based_one-time_password[TOTP] (Time-Based One-Time Password) or https://en.wikipedia.org/wiki/HMAC-based_one-time_password[HOTP] (HMAC-Based One-Time Password).
|
||||||
|
|
||||||
|
=== Setup Requirements
|
||||||
|
|
||||||
|
- OTT: No initial setup is required. The user doesn't need to configure anything in advance.
|
||||||
|
- OTP: Typically requires setup, such as generating and sharing a secret key with an external tool to produce the one-time passwords.
|
||||||
|
|
||||||
|
=== Token Delivery
|
||||||
|
|
||||||
|
- OTT: Usually a custom javadoc:org.springframework.security.web.server.authentication.ott.ServerGeneratedOneTimeTokenHandler[] must be implemented, responsible for delivering the token to the end user.
|
||||||
|
- OTP: The token is often generated by an external tool, so there's no need to send it to the user via the application.
|
||||||
|
|
||||||
|
=== Token Generation
|
||||||
|
|
||||||
|
- OTT: The javadoc:org.springframework.security.authentication.ott.reactive.ReactiveOneTimeTokenService#generate(org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest)[] method requires a javadoc:org.springframework.security.authentication.ott.OneTimeToken[], wrapped in Mono, to be returned, emphasizing server-side generation.
|
||||||
|
- OTP: The token is not necessarily generated on the server side, it's often created by the client using the shared secret.
|
||||||
|
|
||||||
|
In summary, One-Time Tokens (OTT) provide a way to authenticate users without additional account setup, differentiating them from One-Time Passwords (OTP), which typically involve a more complex setup process and rely on external tools for token generation.
|
||||||
|
|
||||||
|
The One-Time Token Login works in two major steps.
|
||||||
|
|
||||||
|
1. User requests a token by submitting their user identifier, usually the username, and the token is delivered to them, often as a Magic Link, via e-mail, SMS, etc.
|
||||||
|
2. User submits the token to the one-time token login endpoint and, if valid, the user gets logged in.
|
||||||
|
|
||||||
|
In the following sections we will explore how to configure OTT Login for your needs.
|
||||||
|
|
||||||
|
- <<default-pages,Understanding the integration with the default generated login page>>
|
||||||
|
- <<sending-token-to-user,Sending the token to the user>>
|
||||||
|
- <<changing-submit-page-url,Configuring the One-Time Token submit page>>
|
||||||
|
- <<changing-generate-url,Changing the One-Time Token generate URL>>
|
||||||
|
- <<customize-generate-consume-token,Customize how to generate and consume tokens>>
|
||||||
|
|
||||||
|
[[default-pages]]
|
||||||
|
== Default Login Page and Default One-Time Token Submit Page
|
||||||
|
|
||||||
|
The `oneTimeTokenLogin()` DSL can be used in conjunction with `formLogin()`, which will produce an additional One-Time Token Request Form in the xref:servlet/authentication/passwords/form.adoc[default generated login page].
|
||||||
|
It will also set up the javadoc:org.springframework.security.web.server.ui.OneTimeTokenSubmitPageGeneratingWebFilter[] to generate a default One-Time Token submit page.
|
||||||
|
|
||||||
|
[[sending-token-to-user]]
|
||||||
|
== Sending the Token to the User
|
||||||
|
|
||||||
|
It is not possible for Spring Security to reasonably determine the way the token should be delivered to your users.
|
||||||
|
Therefore, a custom javadoc:org.springframework.security.web.server.authentication.ott.ServerGeneratedOneTimeTokenHandler[] must be provided to deliver the token to the user based on your needs.
|
||||||
|
One of the most common delivery strategies is a Magic Link, via e-mail, SMS, etc.
|
||||||
|
In the following example, we are going to create a magic link and sent it to the user's email.
|
||||||
|
|
||||||
|
.One-Time Token Login Configuration
|
||||||
|
[tabs]
|
||||||
|
======
|
||||||
|
Java::
|
||||||
|
+
|
||||||
|
[source,java,role="primary"]
|
||||||
|
----
|
||||||
|
@Configuration
|
||||||
|
@EnableWebFluxSecurity
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityWebFilterChain filterChain(ServerHttpSecurity http, MagicLinkGeneratedOneTimeTokenHandler magicLinkSender) {
|
||||||
|
http
|
||||||
|
// ...
|
||||||
|
.formLogin(Customizer.withDefaults())
|
||||||
|
.oneTimeTokenLogin(Customizer.withDefaults());
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
import org.springframework.mail.SimpleMailMessage;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
|
||||||
|
@Component <1>
|
||||||
|
public class MagicLinkGeneratedOneTimeTokenHandler implements ServerGeneratedOneTimeTokenHandler {
|
||||||
|
|
||||||
|
private final MailSender mailSender;
|
||||||
|
|
||||||
|
private final ServerGeneratedOneTimeTokenHandler redirectHandler = new ServerRedirectGeneratedOneTimeTokenHandler("/ott/sent");
|
||||||
|
|
||||||
|
// constructor omitted
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> handle(ServerWebExchange exchange, OneTimeToken oneTimeToken) {
|
||||||
|
return Mono.just(exchange.getRequest())
|
||||||
|
.map((request) ->
|
||||||
|
UriComponentsBuilder.fromUri(request.getURI())
|
||||||
|
.replacePath(request.getPath().contextPath().value())
|
||||||
|
.replaceQuery(null)
|
||||||
|
.fragment(null)
|
||||||
|
.path("/login/ott")
|
||||||
|
.queryParam("token", oneTimeToken.getTokenValue())
|
||||||
|
.toUriString() <2>
|
||||||
|
)
|
||||||
|
.flatMap((uri) -> this.mailSender.send(getUserEmail(oneTimeToken.getUsername()), <3>
|
||||||
|
"Use the following link to sign in into the application: " + magicLink)) <4>
|
||||||
|
.then(this.redirectHandler.handle(exchange, oneTimeToken)); <5>
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getUserEmail() {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
class PageController {
|
||||||
|
|
||||||
|
@GetMapping("/ott/sent")
|
||||||
|
String ottSent() {
|
||||||
|
return "my-template";
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
----
|
||||||
|
======
|
||||||
|
|
||||||
|
<1> Make the `MagicLinkGeneratedOneTimeTokenHandler` a Spring bean
|
||||||
|
<2> Create a login processing URL with the `token` as a query param
|
||||||
|
<3> Retrieve the user's email based on the username
|
||||||
|
<4> Use the `JavaMailSender` API to send the email to the user with the magic link
|
||||||
|
<5> Use the `ServerRedirectGeneratedOneTimeTokenHandler` to perform a redirect to your desired URL
|
||||||
|
|
||||||
|
The email content will look similar to:
|
||||||
|
|
||||||
|
> Use the following link to sign in into the application: \http://localhost:8080/login/ott?token=a830c444-29d8-4d98-9b46-6aba7b22fe5b
|
||||||
|
|
||||||
|
The default submit page will detect that the URL has the `token` query param and will automatically fill the form field with the token value.
|
||||||
|
|
||||||
|
[[changing-generate-url]]
|
||||||
|
== Changing the One-Time Token Generate URL
|
||||||
|
|
||||||
|
By default, the javadoc:org.springframework.security.web.server.authentication.ott.GenerateOneTimeTokenWebFilter[] listens to `POST /ott/generate` requests.
|
||||||
|
That URL can be changed by using the `generateTokenUrl(String)` DSL method:
|
||||||
|
|
||||||
|
.Changing the Generate URL
|
||||||
|
[tabs]
|
||||||
|
======
|
||||||
|
Java::
|
||||||
|
+
|
||||||
|
[source,java,role="primary"]
|
||||||
|
----
|
||||||
|
@Configuration
|
||||||
|
@EnableWebFluxSecurity
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityWebFilterChain filterChain(ServerHttpSecurity http) {
|
||||||
|
http
|
||||||
|
// ...
|
||||||
|
.formLogin(Customizer.withDefaults())
|
||||||
|
.oneTimeTokenLogin((ott) -> ott
|
||||||
|
.generateTokenUrl("/ott/my-generate-url")
|
||||||
|
);
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class MagicLinkGeneratedOneTimeTokenHandler implements ServerGeneratedOneTimeTokenHandler {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
----
|
||||||
|
======
|
||||||
|
|
||||||
|
[[changing-submit-page-url]]
|
||||||
|
== Changing the Default Submit Page URL
|
||||||
|
|
||||||
|
The default One-Time Token submit page is generated by the javadoc:org.springframework.security.web.server.ui.OneTimeTokenSubmitPageGeneratingWebFilter[] and listens to `GET /login/ott`.
|
||||||
|
The URL can also be changed, like so:
|
||||||
|
|
||||||
|
.Configuring the Default Submit Page URL
|
||||||
|
[tabs]
|
||||||
|
======
|
||||||
|
Java::
|
||||||
|
+
|
||||||
|
[source,java,role="primary"]
|
||||||
|
----
|
||||||
|
@Configuration
|
||||||
|
@EnableWebFluxSecurity
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityWebFilterChain filterChain(ServerHttpSecurity http) {
|
||||||
|
http
|
||||||
|
// ...
|
||||||
|
.formLogin(Customizer.withDefaults())
|
||||||
|
.oneTimeTokenLogin((ott) -> ott
|
||||||
|
.submitPageUrl("/ott/submit")
|
||||||
|
);
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class MagicLinkGeneratedOneTimeTokenHandler implements ServerGeneratedOneTimeTokenHandler {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
----
|
||||||
|
======
|
||||||
|
|
||||||
|
[[disabling-default-submit-page]]
|
||||||
|
== Disabling the Default Submit Page
|
||||||
|
|
||||||
|
If you want to use your own One-Time Token submit page, you can disable the default page and then provide your own endpoint.
|
||||||
|
|
||||||
|
.Disabling the Default Submit Page
|
||||||
|
[tabs]
|
||||||
|
======
|
||||||
|
Java::
|
||||||
|
+
|
||||||
|
[source,java,role="primary"]
|
||||||
|
----
|
||||||
|
@Configuration
|
||||||
|
@EnableWebFluxSecurity
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityWebFilterChain filterChain(ServerHttpSecurity http) {
|
||||||
|
http
|
||||||
|
.authorizeExchange((authorize) -> authorize
|
||||||
|
.pathMatchers("/my-ott-submit").permitAll()
|
||||||
|
.anyExchange().authenticated()
|
||||||
|
)
|
||||||
|
.formLogin(Customizer.withDefaults())
|
||||||
|
.oneTimeTokenLogin((ott) -> ott
|
||||||
|
.showDefaultSubmitPage(false)
|
||||||
|
);
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
public class MyController {
|
||||||
|
|
||||||
|
@GetMapping("/my-ott-submit")
|
||||||
|
public String ottSubmitPage() {
|
||||||
|
return "my-ott-submit";
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class MagicLinkGeneratedOneTimeTokenHandler implements ServerGeneratedOneTimeTokenHandler {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
----
|
||||||
|
======
|
||||||
|
|
||||||
|
[[customize-generate-consume-token]]
|
||||||
|
== Customize How to Generate and Consume One-Time Tokens
|
||||||
|
|
||||||
|
The interface that define the common operations for generating and consuming one-time tokens is the javadoc:org.springframework.security.authentication.ott.reactive.ReactiveOneTimeTokenService[].
|
||||||
|
Spring Security uses the javadoc:org.springframework.security.authentication.ott.reactive.InMemoryReactiveOneTimeTokenService[] as the default implementation of that interface, if none is provided.
|
||||||
|
|
||||||
|
Some of the most common reasons to customize the `ReactiveOneTimeTokenService` are, but not limited to:
|
||||||
|
|
||||||
|
- Changing the one-time token expire time
|
||||||
|
- Storing more information from the generate token request
|
||||||
|
- Changing how the token value is created
|
||||||
|
- Additional validation when consuming a one-time token
|
||||||
|
|
||||||
|
There are two options to customize the `ReactiveOneTimeTokenService`.
|
||||||
|
One option is to provide it as a bean, so it can be automatically be picked-up by the `oneTimeTokenLogin()` DSL:
|
||||||
|
|
||||||
|
.Passing the ReactiveOneTimeTokenService as a Bean
|
||||||
|
[tabs]
|
||||||
|
======
|
||||||
|
Java::
|
||||||
|
+
|
||||||
|
[source,java,role="primary"]
|
||||||
|
----
|
||||||
|
@Configuration
|
||||||
|
@EnableWebFluxSecurity
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityWebFilterChain filterChain(ServerHttpSecurity http) {
|
||||||
|
http
|
||||||
|
// ...
|
||||||
|
.formLogin(Customizer.withDefaults())
|
||||||
|
.oneTimeTokenLogin(Customizer.withDefaults());
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ReactiveOneTimeTokenService oneTimeTokenService() {
|
||||||
|
return new MyCustomReactiveOneTimeTokenService();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class MagicLinkGeneratedOneTimeTokenHandler implements ServerGeneratedOneTimeTokenHandler {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
----
|
||||||
|
======
|
||||||
|
|
||||||
|
The second option is to pass the `ReactiveOneTimeTokenService` instance to the DSL, which is useful if there are multiple ``SecurityWebFilterChain``s and a different ``ReactiveOneTimeTokenService``s is needed for each of them.
|
||||||
|
|
||||||
|
.Passing the ReactiveOneTimeTokenService using the DSL
|
||||||
|
[tabs]
|
||||||
|
======
|
||||||
|
Java::
|
||||||
|
+
|
||||||
|
[source,java,role="primary"]
|
||||||
|
----
|
||||||
|
@Configuration
|
||||||
|
@EnableWebFluxSecurity
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityWebFilterChain filterChain(ServerHttpSecurity http) {
|
||||||
|
http
|
||||||
|
// ...
|
||||||
|
.formLogin(Customizer.withDefaults())
|
||||||
|
.oneTimeTokenLogin((ott) -> ott
|
||||||
|
.oneTimeTokenService(new MyCustomReactiveOneTimeTokenService())
|
||||||
|
);
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class MagicLinkGeneratedOneTimeTokenHandler implements ServerGeneratedOneTimeTokenHandler {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
----
|
||||||
|
======
|
@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2002-2024 the original author or authors.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.springframework.security.web.server.authentication.ott;
|
||||||
|
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest;
|
||||||
|
import org.springframework.security.authentication.ott.reactive.ReactiveOneTimeTokenService;
|
||||||
|
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
|
||||||
|
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
|
import org.springframework.web.server.WebFilter;
|
||||||
|
import org.springframework.web.server.WebFilterChain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link WebFilter} implementation that process a One-Time Token generation request.
|
||||||
|
*
|
||||||
|
* @author Max Batischev
|
||||||
|
* @since 6.4
|
||||||
|
* @see ReactiveOneTimeTokenService
|
||||||
|
*/
|
||||||
|
public final class GenerateOneTimeTokenWebFilter implements WebFilter {
|
||||||
|
|
||||||
|
private static final String USERNAME = "username";
|
||||||
|
|
||||||
|
private final ReactiveOneTimeTokenService oneTimeTokenService;
|
||||||
|
|
||||||
|
private ServerWebExchangeMatcher matcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, "/ott/generate");
|
||||||
|
|
||||||
|
private final ServerGeneratedOneTimeTokenHandler generatedOneTimeTokenHandler;
|
||||||
|
|
||||||
|
public GenerateOneTimeTokenWebFilter(ReactiveOneTimeTokenService oneTimeTokenService,
|
||||||
|
ServerGeneratedOneTimeTokenHandler generatedOneTimeTokenHandler) {
|
||||||
|
Assert.notNull(generatedOneTimeTokenHandler, "generatedOneTimeTokenHandler cannot be null");
|
||||||
|
Assert.notNull(oneTimeTokenService, "oneTimeTokenService cannot be null");
|
||||||
|
this.generatedOneTimeTokenHandler = generatedOneTimeTokenHandler;
|
||||||
|
this.oneTimeTokenService = oneTimeTokenService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
|
||||||
|
// @formatter:off
|
||||||
|
return this.matcher.matches(exchange)
|
||||||
|
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
|
||||||
|
.flatMap((mathResult) -> exchange.getFormData())
|
||||||
|
.flatMap((data) -> Mono.justOrEmpty(data.getFirst(USERNAME)))
|
||||||
|
.switchIfEmpty(chain.filter(exchange).then(Mono.empty()))
|
||||||
|
.flatMap((username) -> this.oneTimeTokenService.generate(new GenerateOneTimeTokenRequest(username)))
|
||||||
|
.flatMap((token) -> this.generatedOneTimeTokenHandler.handle(exchange, token));
|
||||||
|
// @formatter:on
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use the given {@link ServerWebExchangeMatcher} to match the request.
|
||||||
|
* @param matcher
|
||||||
|
*/
|
||||||
|
public void setRequestMatcher(ServerWebExchangeMatcher matcher) {
|
||||||
|
Assert.notNull(matcher, "matcher cannot be null");
|
||||||
|
this.matcher = matcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2002-2024 the original author or authors.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.springframework.security.web.server.authentication.ott;
|
||||||
|
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import org.springframework.security.authentication.ott.OneTimeToken;
|
||||||
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines a reactive strategy to handle generated one-time tokens.
|
||||||
|
*
|
||||||
|
* @author Max Batischev
|
||||||
|
* @since 6.4
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface ServerGeneratedOneTimeTokenHandler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles generated one-time tokens
|
||||||
|
* @param exchange the {@link ServerWebExchange} to use
|
||||||
|
* @param oneTimeToken the {@link OneTimeToken} to handle
|
||||||
|
* @return a completion handling (success or error)
|
||||||
|
*/
|
||||||
|
Mono<Void> handle(ServerWebExchange exchange, OneTimeToken oneTimeToken);
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,77 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2002-2024 the original author or authors.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.springframework.security.web.server.authentication.ott;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||||
|
import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationToken;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.web.server.authentication.ServerAuthenticationConverter;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
|
import org.springframework.util.CollectionUtils;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An implementation of {@link ServerAuthenticationConverter} for resolving
|
||||||
|
* {@link OneTimeTokenAuthenticationToken} from token parameter.
|
||||||
|
*
|
||||||
|
* @author Max Batischev
|
||||||
|
* @since 6.4
|
||||||
|
* @see GenerateOneTimeTokenWebFilter
|
||||||
|
*/
|
||||||
|
public final class ServerOneTimeTokenAuthenticationConverter implements ServerAuthenticationConverter {
|
||||||
|
|
||||||
|
private static final String TOKEN = "token";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Authentication> convert(ServerWebExchange exchange) {
|
||||||
|
Assert.notNull(exchange, "exchange cannot be null");
|
||||||
|
if (isFormEncodedRequest(exchange.getRequest())) {
|
||||||
|
return exchange.getFormData()
|
||||||
|
.map((data) -> OneTimeTokenAuthenticationToken.unauthenticated(data.getFirst(TOKEN)));
|
||||||
|
}
|
||||||
|
String token = resolveTokenFromRequest(exchange.getRequest());
|
||||||
|
if (!StringUtils.hasText(token)) {
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
return Mono.just(OneTimeTokenAuthenticationToken.unauthenticated(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveTokenFromRequest(ServerHttpRequest request) {
|
||||||
|
List<String> parameterTokens = request.getQueryParams().get(TOKEN);
|
||||||
|
if (CollectionUtils.isEmpty(parameterTokens)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (parameterTokens.size() == 1) {
|
||||||
|
return parameterTokens.get(0);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isFormEncodedRequest(ServerHttpRequest request) {
|
||||||
|
return HttpMethod.POST.equals(request.getMethod()) && MediaType.APPLICATION_FORM_URLENCODED_VALUE
|
||||||
|
.equals(request.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,52 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2002-2024 the original author or authors.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.springframework.security.web.server.authentication.ott;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import org.springframework.security.authentication.ott.OneTimeToken;
|
||||||
|
import org.springframework.security.web.server.DefaultServerRedirectStrategy;
|
||||||
|
import org.springframework.security.web.server.ServerRedirectStrategy;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link ServerGeneratedOneTimeTokenHandler} that performs a redirect to a specific
|
||||||
|
* location
|
||||||
|
*
|
||||||
|
* @author Max Batischev
|
||||||
|
* @since 6.4
|
||||||
|
*/
|
||||||
|
public final class ServerRedirectGeneratedOneTimeTokenHandler implements ServerGeneratedOneTimeTokenHandler {
|
||||||
|
|
||||||
|
private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy();
|
||||||
|
|
||||||
|
private final URI redirectUri;
|
||||||
|
|
||||||
|
public ServerRedirectGeneratedOneTimeTokenHandler(String redirectUri) {
|
||||||
|
Assert.hasText(redirectUri, "redirectUri cannot be empty or null");
|
||||||
|
this.redirectUri = URI.create(redirectUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> handle(ServerWebExchange exchange, OneTimeToken oneTimeToken) {
|
||||||
|
return this.redirectStrategy.sendRedirect(exchange, this.redirectUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -34,6 +34,7 @@ import org.springframework.security.web.server.util.matcher.ServerWebExchangeMat
|
|||||||
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
|
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
|
||||||
import org.springframework.util.Assert;
|
import org.springframework.util.Assert;
|
||||||
import org.springframework.util.MultiValueMap;
|
import org.springframework.util.MultiValueMap;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.web.server.ServerWebExchange;
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
import org.springframework.web.server.WebFilter;
|
import org.springframework.web.server.WebFilter;
|
||||||
import org.springframework.web.server.WebFilterChain;
|
import org.springframework.web.server.WebFilterChain;
|
||||||
@ -52,10 +53,32 @@ public class LoginPageGeneratingWebFilter implements WebFilter {
|
|||||||
|
|
||||||
private boolean formLoginEnabled;
|
private boolean formLoginEnabled;
|
||||||
|
|
||||||
|
private boolean oneTimeTokenEnabled = false;
|
||||||
|
|
||||||
|
private String generateOneTimeTokenUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specifies the URL that a One-Time Token generate request will be processed.
|
||||||
|
* @param generateOneTimeTokenUrl
|
||||||
|
* @since 6.4
|
||||||
|
*/
|
||||||
|
public void setGenerateOneTimeTokenUrl(String generateOneTimeTokenUrl) {
|
||||||
|
Assert.isTrue(StringUtils.hasText(generateOneTimeTokenUrl), "generateOneTimeTokenUrl cannot be null or empty");
|
||||||
|
this.generateOneTimeTokenUrl = generateOneTimeTokenUrl;
|
||||||
|
}
|
||||||
|
|
||||||
public void setFormLoginEnabled(boolean enabled) {
|
public void setFormLoginEnabled(boolean enabled) {
|
||||||
this.formLoginEnabled = enabled;
|
this.formLoginEnabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set if one-time token login is supported. Defaults to {@code false}.
|
||||||
|
* @param oneTimeTokenEnabled
|
||||||
|
*/
|
||||||
|
public void setOneTimeTokenEnabled(boolean oneTimeTokenEnabled) {
|
||||||
|
this.oneTimeTokenEnabled = oneTimeTokenEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
public void setOauth2AuthenticationUrlToClientName(Map<String, String> oauth2AuthenticationUrlToClientName) {
|
public void setOauth2AuthenticationUrlToClientName(Map<String, String> oauth2AuthenticationUrlToClientName) {
|
||||||
Assert.notNull(oauth2AuthenticationUrlToClientName, "oauth2AuthenticationUrlToClientName cannot be null");
|
Assert.notNull(oauth2AuthenticationUrlToClientName, "oauth2AuthenticationUrlToClientName cannot be null");
|
||||||
this.oauth2AuthenticationUrlToClientName = oauth2AuthenticationUrlToClientName;
|
this.oauth2AuthenticationUrlToClientName = oauth2AuthenticationUrlToClientName;
|
||||||
@ -92,6 +115,7 @@ public class LoginPageGeneratingWebFilter implements WebFilter {
|
|||||||
return HtmlTemplates.fromTemplate(LOGIN_PAGE_TEMPLATE)
|
return HtmlTemplates.fromTemplate(LOGIN_PAGE_TEMPLATE)
|
||||||
.withRawHtml("contextPath", contextPath)
|
.withRawHtml("contextPath", contextPath)
|
||||||
.withRawHtml("formLogin", formLogin(queryParams, contextPath, csrfTokenHtmlInput))
|
.withRawHtml("formLogin", formLogin(queryParams, contextPath, csrfTokenHtmlInput))
|
||||||
|
.withRawHtml("oneTimeTokenLogin", renderOneTimeTokenLogin(queryParams, contextPath, csrfTokenHtmlInput))
|
||||||
.withRawHtml("oauth2Login", oauth2Login(queryParams, contextPath, this.oauth2AuthenticationUrlToClientName))
|
.withRawHtml("oauth2Login", oauth2Login(queryParams, contextPath, this.oauth2AuthenticationUrlToClientName))
|
||||||
.render()
|
.render()
|
||||||
.getBytes(Charset.defaultCharset());
|
.getBytes(Charset.defaultCharset());
|
||||||
@ -113,6 +137,23 @@ public class LoginPageGeneratingWebFilter implements WebFilter {
|
|||||||
.render();
|
.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String renderOneTimeTokenLogin(MultiValueMap<String, String> queryParams, String contextPath,
|
||||||
|
String csrfTokenHtmlInput) {
|
||||||
|
if (!this.oneTimeTokenEnabled) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isError = queryParams.containsKey("error");
|
||||||
|
boolean isLogoutSuccess = queryParams.containsKey("logout");
|
||||||
|
|
||||||
|
return HtmlTemplates.fromTemplate(ONE_TIME_TEMPLATE)
|
||||||
|
.withValue("generateOneTimeTokenUrl", contextPath + this.generateOneTimeTokenUrl)
|
||||||
|
.withRawHtml("errorMessage", createError(isError))
|
||||||
|
.withRawHtml("logoutMessage", createLogoutSuccess(isLogoutSuccess))
|
||||||
|
.withRawHtml("csrf", csrfTokenHtmlInput)
|
||||||
|
.render();
|
||||||
|
}
|
||||||
|
|
||||||
private static String oauth2Login(MultiValueMap<String, String> queryParams, String contextPath,
|
private static String oauth2Login(MultiValueMap<String, String> queryParams, String contextPath,
|
||||||
Map<String, String> oauth2AuthenticationUrlToClientName) {
|
Map<String, String> oauth2AuthenticationUrlToClientName) {
|
||||||
if (oauth2AuthenticationUrlToClientName.isEmpty()) {
|
if (oauth2AuthenticationUrlToClientName.isEmpty()) {
|
||||||
@ -168,6 +209,7 @@ public class LoginPageGeneratingWebFilter implements WebFilter {
|
|||||||
<body>
|
<body>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{{formLogin}}
|
{{formLogin}}
|
||||||
|
{{oneTimeTokenLogin}}
|
||||||
{{oauth2Login}}
|
{{oauth2Login}}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
@ -203,4 +245,17 @@ public class LoginPageGeneratingWebFilter implements WebFilter {
|
|||||||
private static final String OAUTH2_ROW_TEMPLATE = """
|
private static final String OAUTH2_ROW_TEMPLATE = """
|
||||||
<tr><td><a href="{{url}}">{{clientName}}</a></td></tr>""";
|
<tr><td><a href="{{url}}">{{clientName}}</a></td></tr>""";
|
||||||
|
|
||||||
|
private static final String ONE_TIME_TEMPLATE = """
|
||||||
|
<form id="ott-form" class="login-form" method="post" action="{{generateOneTimeTokenUrl}}">
|
||||||
|
<h2>Request a One-Time Token</h2>
|
||||||
|
{{errorMessage}}{{logoutMessage}}
|
||||||
|
<p>
|
||||||
|
<label for="ott-username" class="screenreader">Username</label>
|
||||||
|
<input type="text" id="ott-username" name="username" placeholder="Username" required>
|
||||||
|
</p>
|
||||||
|
{{csrf}}
|
||||||
|
<button class="primary" type="submit" form="ott-form">Send Token</button>
|
||||||
|
</form>
|
||||||
|
""";
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,150 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2002-2024 the original author or authors.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.springframework.security.web.server.ui;
|
||||||
|
|
||||||
|
import java.nio.charset.Charset;
|
||||||
|
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import org.springframework.core.io.buffer.DataBuffer;
|
||||||
|
import org.springframework.core.io.buffer.DataBufferFactory;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.server.reactive.ServerHttpResponse;
|
||||||
|
import org.springframework.security.web.server.csrf.CsrfToken;
|
||||||
|
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
|
||||||
|
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
|
import org.springframework.util.MultiValueMap;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
|
import org.springframework.web.server.WebFilter;
|
||||||
|
import org.springframework.web.server.WebFilterChain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a default one-time token submit page. If the request contains a {@code token}
|
||||||
|
* query param the page will automatically fill the form with the token value.
|
||||||
|
*
|
||||||
|
* @author Max Batischev
|
||||||
|
* @since 6.4
|
||||||
|
*/
|
||||||
|
public final class OneTimeTokenSubmitPageGeneratingWebFilter implements WebFilter {
|
||||||
|
|
||||||
|
private ServerWebExchangeMatcher matcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, "/login/ott");
|
||||||
|
|
||||||
|
private String loginProcessingUrl = "/login/ott";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
|
||||||
|
return this.matcher.matches(exchange)
|
||||||
|
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
|
||||||
|
.switchIfEmpty(chain.filter(exchange).then(Mono.empty()))
|
||||||
|
.flatMap((matchResult) -> render(exchange));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Void> render(ServerWebExchange exchange) {
|
||||||
|
ServerHttpResponse result = exchange.getResponse();
|
||||||
|
result.setStatusCode(HttpStatus.OK);
|
||||||
|
result.getHeaders().setContentType(MediaType.TEXT_HTML);
|
||||||
|
return result.writeWith(createBuffer(exchange));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<DataBuffer> createBuffer(ServerWebExchange exchange) {
|
||||||
|
Mono<CsrfToken> token = exchange.getAttributeOrDefault(CsrfToken.class.getName(), Mono.empty());
|
||||||
|
return token.map(OneTimeTokenSubmitPageGeneratingWebFilter::csrfToken)
|
||||||
|
.defaultIfEmpty("")
|
||||||
|
.map((csrfTokenHtmlInput) -> {
|
||||||
|
byte[] bytes = createPage(exchange, csrfTokenHtmlInput);
|
||||||
|
DataBufferFactory bufferFactory = exchange.getResponse().bufferFactory();
|
||||||
|
return bufferFactory.wrap(bytes);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] createPage(ServerWebExchange exchange, String csrfTokenHtmlInput) {
|
||||||
|
MultiValueMap<String, String> queryParams = exchange.getRequest().getQueryParams();
|
||||||
|
String token = queryParams.getFirst("token");
|
||||||
|
String tokenValue = StringUtils.hasText(token) ? token : "";
|
||||||
|
|
||||||
|
String contextPath = exchange.getRequest().getPath().contextPath().value();
|
||||||
|
|
||||||
|
return HtmlTemplates.fromTemplate(ONE_TIME_TOKEN_SUBMIT_PAGE_TEMPLATE)
|
||||||
|
.withRawHtml("contextPath", contextPath)
|
||||||
|
.withValue("tokenValue", tokenValue)
|
||||||
|
.withRawHtml("csrf", csrfTokenHtmlInput.indent(8))
|
||||||
|
.withValue("loginProcessingUrl", contextPath + this.loginProcessingUrl)
|
||||||
|
.render()
|
||||||
|
.getBytes(Charset.defaultCharset());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String csrfToken(CsrfToken token) {
|
||||||
|
return HtmlTemplates.fromTemplate(CSRF_INPUT_TEMPLATE)
|
||||||
|
.withValue("name", token.getParameterName())
|
||||||
|
.withValue("value", token.getToken())
|
||||||
|
.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use this {@link ServerWebExchangeMatcher} to choose whether this filter will handle
|
||||||
|
* the request. By default, it handles {@code /login/ott}.
|
||||||
|
* @param requestMatcher {@link ServerWebExchangeMatcher} to use
|
||||||
|
*/
|
||||||
|
public void setRequestMatcher(ServerWebExchangeMatcher requestMatcher) {
|
||||||
|
Assert.notNull(requestMatcher, "requestMatcher cannot be null");
|
||||||
|
this.matcher = requestMatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specifies the URL that the submit form should POST to. Defaults to
|
||||||
|
* {@code /login/ott}.
|
||||||
|
* @param loginProcessingUrl
|
||||||
|
*/
|
||||||
|
public void setLoginProcessingUrl(String loginProcessingUrl) {
|
||||||
|
Assert.hasText(loginProcessingUrl, "loginProcessingUrl cannot be null or empty");
|
||||||
|
this.loginProcessingUrl = loginProcessingUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String ONE_TIME_TOKEN_SUBMIT_PAGE_TEMPLATE = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>One-Time Token Login</title>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"/>
|
||||||
|
<link href="{{contextPath}}/default-ui.css" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<form class="login-form" action="{{loginProcessingUrl}}" method="post">
|
||||||
|
<h2>Please input the token</h2>
|
||||||
|
<p>
|
||||||
|
<label for="token" class="screenreader">Token</label>
|
||||||
|
<input type="text" id="token" name="token" value="{{tokenValue}}" placeholder="Token" required="true" autofocus="autofocus"/>
|
||||||
|
</p>
|
||||||
|
{{csrf}}
|
||||||
|
<button class="primary" type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String CSRF_INPUT_TEMPLATE = """
|
||||||
|
<input name="{{name}}" type="hidden" value="{{value}}" />
|
||||||
|
""";
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,103 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2002-2024 the original author or authors.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.springframework.security.web.server.authentication.ott;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
import org.assertj.core.api.Assertions;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentMatchers;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||||
|
import org.springframework.mock.web.server.MockServerWebExchange;
|
||||||
|
import org.springframework.security.authentication.ott.DefaultOneTimeToken;
|
||||||
|
import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest;
|
||||||
|
import org.springframework.security.authentication.ott.reactive.ReactiveOneTimeTokenService;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
||||||
|
import static org.mockito.BDDMockito.given;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link GenerateOneTimeTokenWebFilter}
|
||||||
|
*
|
||||||
|
* @author Max Batischev
|
||||||
|
*/
|
||||||
|
public class GenerateOneTimeTokenWebFilterTests {
|
||||||
|
|
||||||
|
private final ReactiveOneTimeTokenService oneTimeTokenService = mock(ReactiveOneTimeTokenService.class);
|
||||||
|
|
||||||
|
private final ServerRedirectGeneratedOneTimeTokenHandler generatedOneTimeTokenHandler = new ServerRedirectGeneratedOneTimeTokenHandler(
|
||||||
|
"/login/ott");
|
||||||
|
|
||||||
|
private static final String TOKEN = "token";
|
||||||
|
|
||||||
|
private static final String USERNAME = "user";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void filterWhenUsernameFormParamIsPresentThenSuccess() {
|
||||||
|
given(this.oneTimeTokenService.generate(ArgumentMatchers.any(GenerateOneTimeTokenRequest.class)))
|
||||||
|
.willReturn(Mono.just(new DefaultOneTimeToken(TOKEN, USERNAME, Instant.now())));
|
||||||
|
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.post("/ott/generate")
|
||||||
|
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||||
|
.body("username=user"));
|
||||||
|
GenerateOneTimeTokenWebFilter filter = new GenerateOneTimeTokenWebFilter(this.oneTimeTokenService,
|
||||||
|
this.generatedOneTimeTokenHandler);
|
||||||
|
|
||||||
|
filter.filter(exchange, (e) -> Mono.empty()).block();
|
||||||
|
|
||||||
|
verify(this.oneTimeTokenService).generate(ArgumentMatchers.any(GenerateOneTimeTokenRequest.class));
|
||||||
|
Assertions.assertThat(exchange.getResponse().getHeaders().getLocation()).hasPath("/login/ott");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void filterWhenUsernameFormParamIsEmptyThenNull() {
|
||||||
|
given(this.oneTimeTokenService.generate(ArgumentMatchers.any(GenerateOneTimeTokenRequest.class)))
|
||||||
|
.willReturn(Mono.just(new DefaultOneTimeToken(TOKEN, USERNAME, Instant.now())));
|
||||||
|
MockServerHttpRequest.BaseBuilder<?> request = MockServerHttpRequest.post("/ott/generate");
|
||||||
|
MockServerWebExchange exchange = MockServerWebExchange.from(request);
|
||||||
|
GenerateOneTimeTokenWebFilter filter = new GenerateOneTimeTokenWebFilter(this.oneTimeTokenService,
|
||||||
|
this.generatedOneTimeTokenHandler);
|
||||||
|
|
||||||
|
filter.filter(exchange, (e) -> Mono.empty()).block();
|
||||||
|
|
||||||
|
verify(this.oneTimeTokenService, never()).generate(ArgumentMatchers.any(GenerateOneTimeTokenRequest.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void constructorWhenOneTimeTokenServiceNullThenIllegalArgumentException() {
|
||||||
|
// @formatter:off
|
||||||
|
assertThatIllegalArgumentException()
|
||||||
|
.isThrownBy(() -> new GenerateOneTimeTokenWebFilter(null, this.generatedOneTimeTokenHandler));
|
||||||
|
// @formatter:on
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void setWhenRequestMatcherNullThenIllegalArgumentException() {
|
||||||
|
GenerateOneTimeTokenWebFilter filter = new GenerateOneTimeTokenWebFilter(this.oneTimeTokenService,
|
||||||
|
this.generatedOneTimeTokenHandler);
|
||||||
|
// @formatter:off
|
||||||
|
assertThatIllegalArgumentException()
|
||||||
|
.isThrownBy(() -> filter.setRequestMatcher(null));
|
||||||
|
// @formatter:on
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,93 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2002-2024 the original author or authors.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.springframework.security.web.server.authentication.ott;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||||
|
import org.springframework.mock.web.server.MockServerWebExchange;
|
||||||
|
import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationToken;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link ServerOneTimeTokenAuthenticationConverter}
|
||||||
|
*
|
||||||
|
* @author Max Batischev
|
||||||
|
*/
|
||||||
|
public class ServerOneTimeTokenAuthenticationConverterTests {
|
||||||
|
|
||||||
|
private final ServerOneTimeTokenAuthenticationConverter converter = new ServerOneTimeTokenAuthenticationConverter();
|
||||||
|
|
||||||
|
private static final String TOKEN = "token";
|
||||||
|
|
||||||
|
private static final String USERNAME = "Max";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void convertWhenTokenParameterThenReturnOneTimeTokenAuthenticationToken() {
|
||||||
|
MockServerHttpRequest.BaseBuilder<?> request = MockServerHttpRequest.get("/").queryParam("token", TOKEN);
|
||||||
|
|
||||||
|
OneTimeTokenAuthenticationToken authentication = (OneTimeTokenAuthenticationToken) this.converter
|
||||||
|
.convert(MockServerWebExchange.from(request))
|
||||||
|
.block();
|
||||||
|
|
||||||
|
assertThat(authentication).isNotNull();
|
||||||
|
assertThat(authentication.getTokenValue()).isEqualTo(TOKEN);
|
||||||
|
assertThat(authentication.getPrincipal()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void convertWhenOnlyUsernameParameterThenReturnNull() {
|
||||||
|
MockServerHttpRequest.BaseBuilder<?> request = MockServerHttpRequest.get("/").queryParam("username", USERNAME);
|
||||||
|
|
||||||
|
OneTimeTokenAuthenticationToken authentication = (OneTimeTokenAuthenticationToken) this.converter
|
||||||
|
.convert(MockServerWebExchange.from(request))
|
||||||
|
.block();
|
||||||
|
|
||||||
|
assertThat(authentication).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void convertWhenNoTokenParameterThenNull() {
|
||||||
|
MockServerHttpRequest.BaseBuilder<?> request = MockServerHttpRequest.get("/");
|
||||||
|
|
||||||
|
Authentication authentication = this.converter.convert(MockServerWebExchange.from(request)).block();
|
||||||
|
|
||||||
|
assertThat(authentication).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void convertWhenTokenEncodedFormParameterThenReturnOneTimeTokenAuthenticationToken() {
|
||||||
|
// @formatter:off
|
||||||
|
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.post("/")
|
||||||
|
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||||
|
.body("token=token"));
|
||||||
|
|
||||||
|
// @formatter:on
|
||||||
|
|
||||||
|
OneTimeTokenAuthenticationToken authentication = (OneTimeTokenAuthenticationToken) this.converter
|
||||||
|
.convert(exchange)
|
||||||
|
.block();
|
||||||
|
|
||||||
|
assertThat(authentication).isNotNull();
|
||||||
|
assertThat(authentication.getTokenValue()).isEqualTo(TOKEN);
|
||||||
|
assertThat(authentication.getPrincipal()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,74 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2002-2024 the original author or authors.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.springframework.security.web.server.authentication.ott;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||||
|
import org.springframework.mock.web.server.MockServerWebExchange;
|
||||||
|
import org.springframework.security.authentication.ott.DefaultOneTimeToken;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link ServerRedirectGeneratedOneTimeTokenHandler}
|
||||||
|
*
|
||||||
|
* @author Max Batischev
|
||||||
|
*/
|
||||||
|
public class ServerRedirectGeneratedOneTimeTokenHandlerTests {
|
||||||
|
|
||||||
|
private static final String TOKEN = "token";
|
||||||
|
|
||||||
|
private static final String USERNAME = "Max";
|
||||||
|
|
||||||
|
private final MockServerHttpRequest request = MockServerHttpRequest.get("/").build();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handleThenRedirectToDefaultLocation() {
|
||||||
|
ServerGeneratedOneTimeTokenHandler handler = new ServerRedirectGeneratedOneTimeTokenHandler("/login/ott");
|
||||||
|
MockServerWebExchange webExchange = MockServerWebExchange.from(this.request);
|
||||||
|
|
||||||
|
handler.handle(webExchange, new DefaultOneTimeToken(TOKEN, USERNAME, Instant.now())).block();
|
||||||
|
|
||||||
|
assertThat(webExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FOUND);
|
||||||
|
assertThat(webExchange.getResponse().getHeaders().getLocation()).hasPath("/login/ott");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handleWhenUrlChangedThenRedirectToUrl() {
|
||||||
|
ServerGeneratedOneTimeTokenHandler handler = new ServerRedirectGeneratedOneTimeTokenHandler("/redirected");
|
||||||
|
MockServerWebExchange webExchange = MockServerWebExchange.from(this.request);
|
||||||
|
|
||||||
|
handler.handle(webExchange, new DefaultOneTimeToken(TOKEN, USERNAME, Instant.now())).block();
|
||||||
|
|
||||||
|
assertThat(webExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FOUND);
|
||||||
|
assertThat(webExchange.getResponse().getHeaders().getLocation()).hasPath("/redirected");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void setRedirectUrlWhenNullOrEmptyThenException() {
|
||||||
|
assertThatIllegalArgumentException().isThrownBy(() -> new ServerRedirectGeneratedOneTimeTokenHandler(null))
|
||||||
|
.withMessage("redirectUri cannot be empty or null");
|
||||||
|
assertThatIllegalArgumentException().isThrownBy(() -> new ServerRedirectGeneratedOneTimeTokenHandler(""))
|
||||||
|
.withMessage("redirectUri cannot be empty or null");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -84,6 +84,7 @@ public class LoginPageGeneratingWebFilterTests {
|
|||||||
|
|
||||||
<button type="submit" class="primary">Sign in</button>
|
<button type="submit" class="primary">Sign in</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<h2>Login with OAuth 2.0</h2>
|
<h2>Login with OAuth 2.0</h2>
|
||||||
|
|
||||||
<table class="table table-striped">
|
<table class="table table-striped">
|
||||||
@ -94,4 +95,20 @@ public class LoginPageGeneratingWebFilterTests {
|
|||||||
</html>""");
|
</html>""");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void filterWhenOneTimeTokenLoginThenOttForm() {
|
||||||
|
LoginPageGeneratingWebFilter filter = new LoginPageGeneratingWebFilter();
|
||||||
|
filter.setOneTimeTokenEnabled(true);
|
||||||
|
filter.setGenerateOneTimeTokenUrl("/ott/authenticate");
|
||||||
|
filter.setFormLoginEnabled(true);
|
||||||
|
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/login"));
|
||||||
|
|
||||||
|
filter.filter(exchange, (e) -> Mono.empty()).block();
|
||||||
|
|
||||||
|
assertThat(exchange.getResponse().getBodyAsString().block()).contains("Request a One-Time Token");
|
||||||
|
assertThat(exchange.getResponse().getBodyAsString().block()).contains("""
|
||||||
|
<form id="ott-form" class="login-form" method="post" action="/ott/authenticate">
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,129 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2002-2024 the original author or authors.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.springframework.security.web.server.ui;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||||
|
import org.springframework.mock.web.server.MockServerWebExchange;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link OneTimeTokenSubmitPageGeneratingWebFilter}
|
||||||
|
*
|
||||||
|
* @author Max Batischev
|
||||||
|
*/
|
||||||
|
public class OneTimeTokenSubmitPageGeneratingWebFilterTests {
|
||||||
|
|
||||||
|
private final OneTimeTokenSubmitPageGeneratingWebFilter filter = new OneTimeTokenSubmitPageGeneratingWebFilter();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void filterWhenTokenQueryParamThenShouldIncludeJavascriptToAutoSubmitFormAndInputHasTokenValue() {
|
||||||
|
MockServerWebExchange exchange = MockServerWebExchange
|
||||||
|
.from(MockServerHttpRequest.get("/login/ott").queryParam("token", "test"));
|
||||||
|
|
||||||
|
this.filter.filter(exchange, (e) -> Mono.empty()).block();
|
||||||
|
|
||||||
|
assertThat(exchange.getResponse().getBodyAsString().block()).contains(
|
||||||
|
"<input type=\"text\" id=\"token\" name=\"token\" value=\"test\" placeholder=\"Token\" required=\"true\" autofocus=\"autofocus\"/>");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void setRequestMatcherWhenNullThenException() {
|
||||||
|
assertThatIllegalArgumentException().isThrownBy(() -> this.filter.setRequestMatcher(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void setLoginProcessingUrlWhenNullOrEmptyThenException() {
|
||||||
|
assertThatIllegalArgumentException().isThrownBy(() -> this.filter.setLoginProcessingUrl(null));
|
||||||
|
assertThatIllegalArgumentException().isThrownBy(() -> this.filter.setLoginProcessingUrl(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void setLoginProcessingUrlThenUseItForFormAction() {
|
||||||
|
this.filter.setLoginProcessingUrl("/login/another");
|
||||||
|
|
||||||
|
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/login/ott"));
|
||||||
|
|
||||||
|
this.filter.filter(exchange, (e) -> Mono.empty()).block();
|
||||||
|
|
||||||
|
assertThat(exchange.getResponse().getBodyAsString().block())
|
||||||
|
.contains("<form class=\"login-form\" action=\"/login/another\" method=\"post\">");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void setContextThenGenerates() {
|
||||||
|
MockServerWebExchange exchange = MockServerWebExchange
|
||||||
|
.from(MockServerHttpRequest.get("/test/login/ott").contextPath("/test"));
|
||||||
|
this.filter.setLoginProcessingUrl("/login/another");
|
||||||
|
|
||||||
|
this.filter.filter(exchange, (e) -> Mono.empty()).block();
|
||||||
|
|
||||||
|
assertThat(exchange.getResponse().getBodyAsString().block())
|
||||||
|
.contains("<form class=\"login-form\" action=\"/test/login/another\" method=\"post\">");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void filterWhenTokenQueryParamUsesSpecialCharactersThenValueIsEscaped() {
|
||||||
|
MockServerWebExchange exchange = MockServerWebExchange
|
||||||
|
.from(MockServerHttpRequest.get("/login/ott").queryParam("token", "this<>!@#\""));
|
||||||
|
|
||||||
|
this.filter.filter(exchange, (e) -> Mono.empty()).block();
|
||||||
|
|
||||||
|
assertThat(exchange.getResponse().getBodyAsString().block()).contains(
|
||||||
|
"<input type=\"text\" id=\"token\" name=\"token\" value=\"this<>!@#"\" placeholder=\"Token\" required=\"true\" autofocus=\"autofocus\"/>");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void filterThenRenders() {
|
||||||
|
MockServerWebExchange exchange = MockServerWebExchange
|
||||||
|
.from(MockServerHttpRequest.get("/login/ott").queryParam("token", "this<>!@#\""));
|
||||||
|
this.filter.setLoginProcessingUrl("/login/another");
|
||||||
|
|
||||||
|
this.filter.filter(exchange, (e) -> Mono.empty()).block();
|
||||||
|
|
||||||
|
assertThat(exchange.getResponse().getBodyAsString().block()).isEqualTo(
|
||||||
|
"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>One-Time Token Login</title>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"/>
|
||||||
|
<link href="/default-ui.css" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<form class="login-form" action="/login/another" method="post">
|
||||||
|
<h2>Please input the token</h2>
|
||||||
|
<p>
|
||||||
|
<label for="token" class="screenreader">Token</label>
|
||||||
|
<input type="text" id="token" name="token" value="this<>!@#"" placeholder="Token" required="true" autofocus="autofocus"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button class="primary" type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user