Add Reactive One-Time Token Login support
Closes gh-15699
This commit is contained in:
committed by
Josh Cummings
parent
1adb13db66
commit
2ca2e56383
+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;
|
||||
}
|
||||
|
||||
}
|
||||
+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);
|
||||
|
||||
}
|
||||
+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));
|
||||
}
|
||||
|
||||
}
|
||||
+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);
|
||||
}
|
||||
|
||||
}
|
||||
+55
@@ -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>
|
||||
""";
|
||||
|
||||
}
|
||||
|
||||
+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}}" />
|
||||
""";
|
||||
|
||||
}
|
||||
+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
|
||||
}
|
||||
|
||||
}
|
||||
+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();
|
||||
}
|
||||
|
||||
}
|
||||
+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");
|
||||
}
|
||||
|
||||
}
|
||||
+17
@@ -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">
|
||||
""");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+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>
|
||||
""");
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user