20 changed files with 2430 additions and 1 deletions
@ -0,0 +1,406 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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