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

View File

@@ -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
}
}

View File

@@ -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();
}
}

View File

@@ -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");
}
}

View File

@@ -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">
""");
}
}

View File

@@ -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>
""");
}
}