1
0
mirror of synced 2026-08-04 01:07:02 +00:00

Add Reactive One-Time Token Login support

Closes gh-15699
This commit is contained in:
Max Batischev
2024-10-01 02:03:06 +03:00
committed by Josh Cummings
parent 1adb13db66
commit 2ca2e56383
20 changed files with 2430 additions and 1 deletions
@@ -0,0 +1,78 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.server.authentication.ott;
import reactor.core.publisher.Mono;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest;
import org.springframework.security.authentication.ott.reactive.ReactiveOneTimeTokenService;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
import org.springframework.util.Assert;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
/**
* {@link WebFilter} implementation that process a One-Time Token generation request.
*
* @author Max Batischev
* @since 6.4
* @see ReactiveOneTimeTokenService
*/
public final class GenerateOneTimeTokenWebFilter implements WebFilter {
private static final String USERNAME = "username";
private final ReactiveOneTimeTokenService oneTimeTokenService;
private ServerWebExchangeMatcher matcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, "/ott/generate");
private final ServerGeneratedOneTimeTokenHandler generatedOneTimeTokenHandler;
public GenerateOneTimeTokenWebFilter(ReactiveOneTimeTokenService oneTimeTokenService,
ServerGeneratedOneTimeTokenHandler generatedOneTimeTokenHandler) {
Assert.notNull(generatedOneTimeTokenHandler, "generatedOneTimeTokenHandler cannot be null");
Assert.notNull(oneTimeTokenService, "oneTimeTokenService cannot be null");
this.generatedOneTimeTokenHandler = generatedOneTimeTokenHandler;
this.oneTimeTokenService = oneTimeTokenService;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
// @formatter:off
return this.matcher.matches(exchange)
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
.flatMap((mathResult) -> exchange.getFormData())
.flatMap((data) -> Mono.justOrEmpty(data.getFirst(USERNAME)))
.switchIfEmpty(chain.filter(exchange).then(Mono.empty()))
.flatMap((username) -> this.oneTimeTokenService.generate(new GenerateOneTimeTokenRequest(username)))
.flatMap((token) -> this.generatedOneTimeTokenHandler.handle(exchange, token));
// @formatter:on
}
/**
* Use the given {@link ServerWebExchangeMatcher} to match the request.
* @param matcher
*/
public void setRequestMatcher(ServerWebExchangeMatcher matcher) {
Assert.notNull(matcher, "matcher cannot be null");
this.matcher = matcher;
}
}
@@ -0,0 +1,41 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.server.authentication.ott;
import reactor.core.publisher.Mono;
import org.springframework.security.authentication.ott.OneTimeToken;
import org.springframework.web.server.ServerWebExchange;
/**
* Defines a reactive strategy to handle generated one-time tokens.
*
* @author Max Batischev
* @since 6.4
*/
@FunctionalInterface
public interface ServerGeneratedOneTimeTokenHandler {
/**
* Handles generated one-time tokens
* @param exchange the {@link ServerWebExchange} to use
* @param oneTimeToken the {@link OneTimeToken} to handle
* @return a completion handling (success or error)
*/
Mono<Void> handle(ServerWebExchange exchange, OneTimeToken oneTimeToken);
}
@@ -0,0 +1,77 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.server.authentication.ott;
import java.util.List;
import reactor.core.publisher.Mono;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.server.authentication.ServerAuthenticationConverter;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
/**
* An implementation of {@link ServerAuthenticationConverter} for resolving
* {@link OneTimeTokenAuthenticationToken} from token parameter.
*
* @author Max Batischev
* @since 6.4
* @see GenerateOneTimeTokenWebFilter
*/
public final class ServerOneTimeTokenAuthenticationConverter implements ServerAuthenticationConverter {
private static final String TOKEN = "token";
@Override
public Mono<Authentication> convert(ServerWebExchange exchange) {
Assert.notNull(exchange, "exchange cannot be null");
if (isFormEncodedRequest(exchange.getRequest())) {
return exchange.getFormData()
.map((data) -> OneTimeTokenAuthenticationToken.unauthenticated(data.getFirst(TOKEN)));
}
String token = resolveTokenFromRequest(exchange.getRequest());
if (!StringUtils.hasText(token)) {
return Mono.empty();
}
return Mono.just(OneTimeTokenAuthenticationToken.unauthenticated(token));
}
private String resolveTokenFromRequest(ServerHttpRequest request) {
List<String> parameterTokens = request.getQueryParams().get(TOKEN);
if (CollectionUtils.isEmpty(parameterTokens)) {
return null;
}
if (parameterTokens.size() == 1) {
return parameterTokens.get(0);
}
return null;
}
private boolean isFormEncodedRequest(ServerHttpRequest request) {
return HttpMethod.POST.equals(request.getMethod()) && MediaType.APPLICATION_FORM_URLENCODED_VALUE
.equals(request.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE));
}
}
@@ -0,0 +1,52 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.server.authentication.ott;
import java.net.URI;
import reactor.core.publisher.Mono;
import org.springframework.security.authentication.ott.OneTimeToken;
import org.springframework.security.web.server.DefaultServerRedirectStrategy;
import org.springframework.security.web.server.ServerRedirectStrategy;
import org.springframework.util.Assert;
import org.springframework.web.server.ServerWebExchange;
/**
* A {@link ServerGeneratedOneTimeTokenHandler} that performs a redirect to a specific
* location
*
* @author Max Batischev
* @since 6.4
*/
public final class ServerRedirectGeneratedOneTimeTokenHandler implements ServerGeneratedOneTimeTokenHandler {
private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy();
private final URI redirectUri;
public ServerRedirectGeneratedOneTimeTokenHandler(String redirectUri) {
Assert.hasText(redirectUri, "redirectUri cannot be empty or null");
this.redirectUri = URI.create(redirectUri);
}
@Override
public Mono<Void> handle(ServerWebExchange exchange, OneTimeToken oneTimeToken) {
return this.redirectStrategy.sendRedirect(exchange, this.redirectUri);
}
}
@@ -34,6 +34,7 @@ import org.springframework.security.web.server.util.matcher.ServerWebExchangeMat
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
import org.springframework.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;
@@ -52,10 +53,32 @@ public class LoginPageGeneratingWebFilter implements WebFilter {
private boolean formLoginEnabled;
private boolean oneTimeTokenEnabled = false;
private String generateOneTimeTokenUrl;
/**
* Specifies the URL that a One-Time Token generate request will be processed.
* @param generateOneTimeTokenUrl
* @since 6.4
*/
public void setGenerateOneTimeTokenUrl(String generateOneTimeTokenUrl) {
Assert.isTrue(StringUtils.hasText(generateOneTimeTokenUrl), "generateOneTimeTokenUrl cannot be null or empty");
this.generateOneTimeTokenUrl = generateOneTimeTokenUrl;
}
public void setFormLoginEnabled(boolean enabled) {
this.formLoginEnabled = enabled;
}
/**
* Set if one-time token login is supported. Defaults to {@code false}.
* @param oneTimeTokenEnabled
*/
public void setOneTimeTokenEnabled(boolean oneTimeTokenEnabled) {
this.oneTimeTokenEnabled = oneTimeTokenEnabled;
}
public void setOauth2AuthenticationUrlToClientName(Map<String, String> oauth2AuthenticationUrlToClientName) {
Assert.notNull(oauth2AuthenticationUrlToClientName, "oauth2AuthenticationUrlToClientName cannot be null");
this.oauth2AuthenticationUrlToClientName = oauth2AuthenticationUrlToClientName;
@@ -92,6 +115,7 @@ public class LoginPageGeneratingWebFilter implements WebFilter {
return HtmlTemplates.fromTemplate(LOGIN_PAGE_TEMPLATE)
.withRawHtml("contextPath", contextPath)
.withRawHtml("formLogin", formLogin(queryParams, contextPath, csrfTokenHtmlInput))
.withRawHtml("oneTimeTokenLogin", renderOneTimeTokenLogin(queryParams, contextPath, csrfTokenHtmlInput))
.withRawHtml("oauth2Login", oauth2Login(queryParams, contextPath, this.oauth2AuthenticationUrlToClientName))
.render()
.getBytes(Charset.defaultCharset());
@@ -113,6 +137,23 @@ public class LoginPageGeneratingWebFilter implements WebFilter {
.render();
}
private String renderOneTimeTokenLogin(MultiValueMap<String, String> queryParams, String contextPath,
String csrfTokenHtmlInput) {
if (!this.oneTimeTokenEnabled) {
return "";
}
boolean isError = queryParams.containsKey("error");
boolean isLogoutSuccess = queryParams.containsKey("logout");
return HtmlTemplates.fromTemplate(ONE_TIME_TEMPLATE)
.withValue("generateOneTimeTokenUrl", contextPath + this.generateOneTimeTokenUrl)
.withRawHtml("errorMessage", createError(isError))
.withRawHtml("logoutMessage", createLogoutSuccess(isLogoutSuccess))
.withRawHtml("csrf", csrfTokenHtmlInput)
.render();
}
private static String oauth2Login(MultiValueMap<String, String> queryParams, String contextPath,
Map<String, String> oauth2AuthenticationUrlToClientName) {
if (oauth2AuthenticationUrlToClientName.isEmpty()) {
@@ -168,6 +209,7 @@ public class LoginPageGeneratingWebFilter implements WebFilter {
<body>
<div class="content">
{{formLogin}}
{{oneTimeTokenLogin}}
{{oauth2Login}}
</div>
</body>
@@ -203,4 +245,17 @@ public class LoginPageGeneratingWebFilter implements WebFilter {
private static final String OAUTH2_ROW_TEMPLATE = """
<tr><td><a href="{{url}}">{{clientName}}</a></td></tr>""";
private static final String ONE_TIME_TEMPLATE = """
<form id="ott-form" class="login-form" method="post" action="{{generateOneTimeTokenUrl}}">
<h2>Request a One-Time Token</h2>
{{errorMessage}}{{logoutMessage}}
<p>
<label for="ott-username" class="screenreader">Username</label>
<input type="text" id="ott-username" name="username" placeholder="Username" required>
</p>
{{csrf}}
<button class="primary" type="submit" form="ott-form">Send Token</button>
</form>
""";
}
@@ -0,0 +1,150 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.server.ui;
import java.nio.charset.Charset;
import reactor.core.publisher.Mono;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.web.server.csrf.CsrfToken;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
import org.springframework.util.Assert;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
/**
* Creates a default one-time token submit page. If the request contains a {@code token}
* query param the page will automatically fill the form with the token value.
*
* @author Max Batischev
* @since 6.4
*/
public final class OneTimeTokenSubmitPageGeneratingWebFilter implements WebFilter {
private ServerWebExchangeMatcher matcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, "/login/ott");
private String loginProcessingUrl = "/login/ott";
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
return this.matcher.matches(exchange)
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
.switchIfEmpty(chain.filter(exchange).then(Mono.empty()))
.flatMap((matchResult) -> render(exchange));
}
private Mono<Void> render(ServerWebExchange exchange) {
ServerHttpResponse result = exchange.getResponse();
result.setStatusCode(HttpStatus.OK);
result.getHeaders().setContentType(MediaType.TEXT_HTML);
return result.writeWith(createBuffer(exchange));
}
private Mono<DataBuffer> createBuffer(ServerWebExchange exchange) {
Mono<CsrfToken> token = exchange.getAttributeOrDefault(CsrfToken.class.getName(), Mono.empty());
return token.map(OneTimeTokenSubmitPageGeneratingWebFilter::csrfToken)
.defaultIfEmpty("")
.map((csrfTokenHtmlInput) -> {
byte[] bytes = createPage(exchange, csrfTokenHtmlInput);
DataBufferFactory bufferFactory = exchange.getResponse().bufferFactory();
return bufferFactory.wrap(bytes);
});
}
private byte[] createPage(ServerWebExchange exchange, String csrfTokenHtmlInput) {
MultiValueMap<String, String> queryParams = exchange.getRequest().getQueryParams();
String token = queryParams.getFirst("token");
String tokenValue = StringUtils.hasText(token) ? token : "";
String contextPath = exchange.getRequest().getPath().contextPath().value();
return HtmlTemplates.fromTemplate(ONE_TIME_TOKEN_SUBMIT_PAGE_TEMPLATE)
.withRawHtml("contextPath", contextPath)
.withValue("tokenValue", tokenValue)
.withRawHtml("csrf", csrfTokenHtmlInput.indent(8))
.withValue("loginProcessingUrl", contextPath + this.loginProcessingUrl)
.render()
.getBytes(Charset.defaultCharset());
}
private static String csrfToken(CsrfToken token) {
return HtmlTemplates.fromTemplate(CSRF_INPUT_TEMPLATE)
.withValue("name", token.getParameterName())
.withValue("value", token.getToken())
.render();
}
/**
* Use this {@link ServerWebExchangeMatcher} to choose whether this filter will handle
* the request. By default, it handles {@code /login/ott}.
* @param requestMatcher {@link ServerWebExchangeMatcher} to use
*/
public void setRequestMatcher(ServerWebExchangeMatcher requestMatcher) {
Assert.notNull(requestMatcher, "requestMatcher cannot be null");
this.matcher = requestMatcher;
}
/**
* Specifies the URL that the submit form should POST to. Defaults to
* {@code /login/ott}.
* @param loginProcessingUrl
*/
public void setLoginProcessingUrl(String loginProcessingUrl) {
Assert.hasText(loginProcessingUrl, "loginProcessingUrl cannot be null or empty");
this.loginProcessingUrl = loginProcessingUrl;
}
private static final String ONE_TIME_TOKEN_SUBMIT_PAGE_TEMPLATE = """
<!DOCTYPE html>
<html lang="en">
<head>
<title>One-Time Token Login</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"/>
<link href="{{contextPath}}/default-ui.css" rel="stylesheet" />
</head>
<body>
<div class="container">
<form class="login-form" action="{{loginProcessingUrl}}" method="post">
<h2>Please input the token</h2>
<p>
<label for="token" class="screenreader">Token</label>
<input type="text" id="token" name="token" value="{{tokenValue}}" placeholder="Token" required="true" autofocus="autofocus"/>
</p>
{{csrf}}
<button class="primary" type="submit">Sign in</button>
</form>
</div>
</body>
</html>
""";
private static final String CSRF_INPUT_TEMPLATE = """
<input name="{{name}}" type="hidden" value="{{value}}" />
""";
}
@@ -0,0 +1,103 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.server.authentication.ott;
import java.time.Instant;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatchers;
import reactor.core.publisher.Mono;
import org.springframework.http.MediaType;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.security.authentication.ott.DefaultOneTimeToken;
import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest;
import org.springframework.security.authentication.ott.reactive.ReactiveOneTimeTokenService;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/**
* Tests for {@link GenerateOneTimeTokenWebFilter}
*
* @author Max Batischev
*/
public class GenerateOneTimeTokenWebFilterTests {
private final ReactiveOneTimeTokenService oneTimeTokenService = mock(ReactiveOneTimeTokenService.class);
private final ServerRedirectGeneratedOneTimeTokenHandler generatedOneTimeTokenHandler = new ServerRedirectGeneratedOneTimeTokenHandler(
"/login/ott");
private static final String TOKEN = "token";
private static final String USERNAME = "user";
@Test
void filterWhenUsernameFormParamIsPresentThenSuccess() {
given(this.oneTimeTokenService.generate(ArgumentMatchers.any(GenerateOneTimeTokenRequest.class)))
.willReturn(Mono.just(new DefaultOneTimeToken(TOKEN, USERNAME, Instant.now())));
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.post("/ott/generate")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body("username=user"));
GenerateOneTimeTokenWebFilter filter = new GenerateOneTimeTokenWebFilter(this.oneTimeTokenService,
this.generatedOneTimeTokenHandler);
filter.filter(exchange, (e) -> Mono.empty()).block();
verify(this.oneTimeTokenService).generate(ArgumentMatchers.any(GenerateOneTimeTokenRequest.class));
Assertions.assertThat(exchange.getResponse().getHeaders().getLocation()).hasPath("/login/ott");
}
@Test
void filterWhenUsernameFormParamIsEmptyThenNull() {
given(this.oneTimeTokenService.generate(ArgumentMatchers.any(GenerateOneTimeTokenRequest.class)))
.willReturn(Mono.just(new DefaultOneTimeToken(TOKEN, USERNAME, Instant.now())));
MockServerHttpRequest.BaseBuilder<?> request = MockServerHttpRequest.post("/ott/generate");
MockServerWebExchange exchange = MockServerWebExchange.from(request);
GenerateOneTimeTokenWebFilter filter = new GenerateOneTimeTokenWebFilter(this.oneTimeTokenService,
this.generatedOneTimeTokenHandler);
filter.filter(exchange, (e) -> Mono.empty()).block();
verify(this.oneTimeTokenService, never()).generate(ArgumentMatchers.any(GenerateOneTimeTokenRequest.class));
}
@Test
public void constructorWhenOneTimeTokenServiceNullThenIllegalArgumentException() {
// @formatter:off
assertThatIllegalArgumentException()
.isThrownBy(() -> new GenerateOneTimeTokenWebFilter(null, this.generatedOneTimeTokenHandler));
// @formatter:on
}
@Test
public void setWhenRequestMatcherNullThenIllegalArgumentException() {
GenerateOneTimeTokenWebFilter filter = new GenerateOneTimeTokenWebFilter(this.oneTimeTokenService,
this.generatedOneTimeTokenHandler);
// @formatter:off
assertThatIllegalArgumentException()
.isThrownBy(() -> filter.setRequestMatcher(null));
// @formatter:on
}
}
@@ -0,0 +1,93 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.server.authentication.ott;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationToken;
import org.springframework.security.core.Authentication;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link ServerOneTimeTokenAuthenticationConverter}
*
* @author Max Batischev
*/
public class ServerOneTimeTokenAuthenticationConverterTests {
private final ServerOneTimeTokenAuthenticationConverter converter = new ServerOneTimeTokenAuthenticationConverter();
private static final String TOKEN = "token";
private static final String USERNAME = "Max";
@Test
void convertWhenTokenParameterThenReturnOneTimeTokenAuthenticationToken() {
MockServerHttpRequest.BaseBuilder<?> request = MockServerHttpRequest.get("/").queryParam("token", TOKEN);
OneTimeTokenAuthenticationToken authentication = (OneTimeTokenAuthenticationToken) this.converter
.convert(MockServerWebExchange.from(request))
.block();
assertThat(authentication).isNotNull();
assertThat(authentication.getTokenValue()).isEqualTo(TOKEN);
assertThat(authentication.getPrincipal()).isNull();
}
@Test
void convertWhenOnlyUsernameParameterThenReturnNull() {
MockServerHttpRequest.BaseBuilder<?> request = MockServerHttpRequest.get("/").queryParam("username", USERNAME);
OneTimeTokenAuthenticationToken authentication = (OneTimeTokenAuthenticationToken) this.converter
.convert(MockServerWebExchange.from(request))
.block();
assertThat(authentication).isNull();
}
@Test
void convertWhenNoTokenParameterThenNull() {
MockServerHttpRequest.BaseBuilder<?> request = MockServerHttpRequest.get("/");
Authentication authentication = this.converter.convert(MockServerWebExchange.from(request)).block();
assertThat(authentication).isNull();
}
@Test
void convertWhenTokenEncodedFormParameterThenReturnOneTimeTokenAuthenticationToken() {
// @formatter:off
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.post("/")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body("token=token"));
// @formatter:on
OneTimeTokenAuthenticationToken authentication = (OneTimeTokenAuthenticationToken) this.converter
.convert(exchange)
.block();
assertThat(authentication).isNotNull();
assertThat(authentication.getTokenValue()).isEqualTo(TOKEN);
assertThat(authentication.getPrincipal()).isNull();
}
}
@@ -0,0 +1,74 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.server.authentication.ott;
import java.time.Instant;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.security.authentication.ott.DefaultOneTimeToken;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
/**
* Tests for {@link ServerRedirectGeneratedOneTimeTokenHandler}
*
* @author Max Batischev
*/
public class ServerRedirectGeneratedOneTimeTokenHandlerTests {
private static final String TOKEN = "token";
private static final String USERNAME = "Max";
private final MockServerHttpRequest request = MockServerHttpRequest.get("/").build();
@Test
void handleThenRedirectToDefaultLocation() {
ServerGeneratedOneTimeTokenHandler handler = new ServerRedirectGeneratedOneTimeTokenHandler("/login/ott");
MockServerWebExchange webExchange = MockServerWebExchange.from(this.request);
handler.handle(webExchange, new DefaultOneTimeToken(TOKEN, USERNAME, Instant.now())).block();
assertThat(webExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FOUND);
assertThat(webExchange.getResponse().getHeaders().getLocation()).hasPath("/login/ott");
}
@Test
void handleWhenUrlChangedThenRedirectToUrl() {
ServerGeneratedOneTimeTokenHandler handler = new ServerRedirectGeneratedOneTimeTokenHandler("/redirected");
MockServerWebExchange webExchange = MockServerWebExchange.from(this.request);
handler.handle(webExchange, new DefaultOneTimeToken(TOKEN, USERNAME, Instant.now())).block();
assertThat(webExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FOUND);
assertThat(webExchange.getResponse().getHeaders().getLocation()).hasPath("/redirected");
}
@Test
void setRedirectUrlWhenNullOrEmptyThenException() {
assertThatIllegalArgumentException().isThrownBy(() -> new ServerRedirectGeneratedOneTimeTokenHandler(null))
.withMessage("redirectUri cannot be empty or null");
assertThatIllegalArgumentException().isThrownBy(() -> new ServerRedirectGeneratedOneTimeTokenHandler(""))
.withMessage("redirectUri cannot be empty or null");
}
}
@@ -84,6 +84,7 @@ public class LoginPageGeneratingWebFilterTests {
<button type="submit" class="primary">Sign in</button>
</form>
<h2>Login with OAuth 2.0</h2>
<table class="table table-striped">
@@ -94,4 +95,20 @@ public class LoginPageGeneratingWebFilterTests {
</html>""");
}
@Test
public void filterWhenOneTimeTokenLoginThenOttForm() {
LoginPageGeneratingWebFilter filter = new LoginPageGeneratingWebFilter();
filter.setOneTimeTokenEnabled(true);
filter.setGenerateOneTimeTokenUrl("/ott/authenticate");
filter.setFormLoginEnabled(true);
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/login"));
filter.filter(exchange, (e) -> Mono.empty()).block();
assertThat(exchange.getResponse().getBodyAsString().block()).contains("Request a One-Time Token");
assertThat(exchange.getResponse().getBodyAsString().block()).contains("""
<form id="ott-form" class="login-form" method="post" action="/ott/authenticate">
""");
}
}
@@ -0,0 +1,129 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.server.ui;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
/**
* Tests for {@link OneTimeTokenSubmitPageGeneratingWebFilter}
*
* @author Max Batischev
*/
public class OneTimeTokenSubmitPageGeneratingWebFilterTests {
private final OneTimeTokenSubmitPageGeneratingWebFilter filter = new OneTimeTokenSubmitPageGeneratingWebFilter();
@Test
void filterWhenTokenQueryParamThenShouldIncludeJavascriptToAutoSubmitFormAndInputHasTokenValue() {
MockServerWebExchange exchange = MockServerWebExchange
.from(MockServerHttpRequest.get("/login/ott").queryParam("token", "test"));
this.filter.filter(exchange, (e) -> Mono.empty()).block();
assertThat(exchange.getResponse().getBodyAsString().block()).contains(
"<input type=\"text\" id=\"token\" name=\"token\" value=\"test\" placeholder=\"Token\" required=\"true\" autofocus=\"autofocus\"/>");
}
@Test
void setRequestMatcherWhenNullThenException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.filter.setRequestMatcher(null));
}
@Test
void setLoginProcessingUrlWhenNullOrEmptyThenException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.filter.setLoginProcessingUrl(null));
assertThatIllegalArgumentException().isThrownBy(() -> this.filter.setLoginProcessingUrl(""));
}
@Test
void setLoginProcessingUrlThenUseItForFormAction() {
this.filter.setLoginProcessingUrl("/login/another");
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/login/ott"));
this.filter.filter(exchange, (e) -> Mono.empty()).block();
assertThat(exchange.getResponse().getBodyAsString().block())
.contains("<form class=\"login-form\" action=\"/login/another\" method=\"post\">");
}
@Test
void setContextThenGenerates() {
MockServerWebExchange exchange = MockServerWebExchange
.from(MockServerHttpRequest.get("/test/login/ott").contextPath("/test"));
this.filter.setLoginProcessingUrl("/login/another");
this.filter.filter(exchange, (e) -> Mono.empty()).block();
assertThat(exchange.getResponse().getBodyAsString().block())
.contains("<form class=\"login-form\" action=\"/test/login/another\" method=\"post\">");
}
@Test
void filterWhenTokenQueryParamUsesSpecialCharactersThenValueIsEscaped() {
MockServerWebExchange exchange = MockServerWebExchange
.from(MockServerHttpRequest.get("/login/ott").queryParam("token", "this<>!@#\""));
this.filter.filter(exchange, (e) -> Mono.empty()).block();
assertThat(exchange.getResponse().getBodyAsString().block()).contains(
"<input type=\"text\" id=\"token\" name=\"token\" value=\"this&lt;&gt;!@#&quot;\" placeholder=\"Token\" required=\"true\" autofocus=\"autofocus\"/>");
}
@Test
void filterThenRenders() {
MockServerWebExchange exchange = MockServerWebExchange
.from(MockServerHttpRequest.get("/login/ott").queryParam("token", "this<>!@#\""));
this.filter.setLoginProcessingUrl("/login/another");
this.filter.filter(exchange, (e) -> Mono.empty()).block();
assertThat(exchange.getResponse().getBodyAsString().block()).isEqualTo(
"""
<!DOCTYPE html>
<html lang="en">
<head>
<title>One-Time Token Login</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"/>
<link href="/default-ui.css" rel="stylesheet" />
</head>
<body>
<div class="container">
<form class="login-form" action="/login/another" method="post">
<h2>Please input the token</h2>
<p>
<label for="token" class="screenreader">Token</label>
<input type="text" id="token" name="token" value="this&lt;&gt;!@#&quot;" placeholder="Token" required="true" autofocus="autofocus"/>
</p>
<button class="primary" type="submit">Sign in</button>
</form>
</div>
</body>
</html>
""");
}
}