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