Add Reactive One-Time Token Login support
Closes gh-15699
This commit is contained in:
committed by
Josh Cummings
parent
1adb13db66
commit
2ca2e56383
@@ -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<>!@#"\" 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