Add Reactive One-Time Token Login support

Closes gh-15699
This commit is contained in:
Max Batischev 2024-10-01 02:03:06 +03:00 committed by Josh Cummings
parent 1adb13db66
commit 2ca2e56383
20 changed files with 2430 additions and 1 deletions

View File

@ -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}
*/ */

View File

@ -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>
* &#064;Configuration
* &#064;EnableWebFluxSecurity
* public class SecurityConfig {
*
* &#064;Bean
* public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) throws Exception {
* http
* // ...
* .oneTimeTokenLogin(Customizer.withDefaults());
* return http.build();
* }
*
* &#064;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;
}
}
} }

View File

@ -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);
}
}
}

View File

@ -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);
}
}

View File

@ -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;
};
}
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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 {
// ...
}
----
======

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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));
}
}

View File

@ -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);
}
}

View File

@ -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>
""";
} }

View File

@ -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}}" />
""";
}

View File

@ -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
}
}

View File

@ -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();
}
}

View File

@ -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");
}
}

View File

@ -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">
""");
}
} }

View File

@ -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&lt;&gt;!@#&quot;\" 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&lt;&gt;!@#&quot;" placeholder="Token" required="true" autofocus="autofocus"/>
</p>
<button class="primary" type="submit">Sign in</button>
</form>
</div>
</body>
</html>
""");
}
}