Initial Reactive OTT Sample

Closes gh-326
This commit is contained in:
Josh Cummings
2024-09-26 16:44:10 -06:00
parent c91daf2684
commit 8fecde68da
22 changed files with 793 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
/*
* Copyright 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.example.magiclink;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MagicLinkApplication {
public static void main(String[] args) {
SpringApplication.run(MagicLinkApplication.class, args);
}
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright 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.example.magiclink;
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.security.web.server.authentication.ott.ServerOneTimeTokenGenerationSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.util.UriComponentsBuilder;
@Component
public class MagicLinkOneTimeTokenGenerationSuccessHandler implements ServerOneTimeTokenGenerationSuccessHandler {
private final MailSender mailSender;
private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy();
public MagicLinkOneTimeTokenGenerationSuccessHandler(MailSender mailSender) {
this.mailSender = mailSender;
}
@Override
public Mono<Void> handle(ServerWebExchange exchange, OneTimeToken oneTimeToken) {
UriComponentsBuilder builder = UriComponentsBuilder.fromUri(exchange.getRequest().getURI())
.replacePath(null)
.replaceQuery(null)
.fragment(null)
.path("/login/ott")
.queryParam("token", oneTimeToken.getTokenValue());
String magicLink = builder.toUriString();
builder
.replacePath(null)
.replaceQuery(null)
.path("/ott/sent");
String redirectLink = builder.toUriString();
return this.mailSender.send("johndoe@example.com", "Your Spring Security One Time Token",
"Use the following link to sign in into the application: " + magicLink)
.then(this.redirectStrategy.sendRedirect(exchange, URI.create(redirectLink)));
}
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright 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.example.magiclink;
import reactor.core.publisher.Mono;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;
@Service("myMailSender")
public class MailSender {
private final JavaMailSender mailSender;
public MailSender(JavaMailSender mailSender) {
this.mailSender = mailSender;
}
public Mono<Void> send(String to, String subject, String text) {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom("noreply@example.com");
message.setTo(to);
message.setSubject(subject);
message.setText(text);
return Mono.fromRunnable(() -> this.mailSender.send(message));
}
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright 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.example.magiclink;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
class PagesController {
@RequestMapping("/ott/sent")
String ottSent() {
return "ott-sent";
}
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright 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.example.magiclink;
import org.springframework.boot.autoconfigure.security.reactive.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.core.userdetails.MapReactiveUserDetailsService;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.server.SecurityWebFilterChain;
@Configuration(proxyBeanMethods = false)
@EnableWebFluxSecurity
public class SecurityConfig {
@Bean
public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeExchange((authz) -> authz
.matchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.pathMatchers("/ott/sent").permitAll()
.anyExchange().authenticated()
)
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin(Customizer.withDefaults());
// @formatter:on
return http.build();
}
@Bean
MapReactiveUserDetailsService userDetailsService() {
UserDetails user = User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("USER")
.build();
return new MapReactiveUserDetailsService(user);
}
}

View File

@@ -0,0 +1,11 @@
spring:
application:
name: magiclink
mail:
port: 1025
host: localhost
docker:
compose:
readiness:
wait: never # for some reason it does not detect whether maildev is ready
file: ./compose.yml

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org" xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<head>
<title>Hello Spring Security</title>
<meta charset="utf-8" />
</head>
<body>
<div th:fragment="logout" sec:authorize="isAuthenticated()">
Logged in user: <span sec:authentication="name"></span> |
Roles: <span sec:authentication="principal.authorities"></span>
<div>
<form action="#" th:action="@{/logout}" method="post">
<input type="submit" value="Logout" />
</form>
</div>
</div>
<h1>Hello Spring Security</h1>
<p>This is a secured page</p>
</body>
</html>

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
<head>
<title>Link sent</title>
<meta charset="utf-8"/>
</head>
<body>
<p>If your user exists, an email has been sent to you. Use the provided link to log in.</p>
<p><a href="/login" th:href="@{/login}">Back to login</a></p>
</body>
</html>

View File

@@ -0,0 +1,80 @@
/*
* Copyright 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.example.magiclink;
import com.icegreen.greenmail.junit5.GreenMailExtension;
import com.icegreen.greenmail.util.GreenMailUtil;
import com.icegreen.greenmail.util.ServerSetupTest;
import jakarta.mail.internet.MimeMessage;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class MagicLinkApplicationTests {
@RegisterExtension
static GreenMailExtension greenMail = new GreenMailExtension(ServerSetupTest.SMTP);
@Autowired
MockMvc mockMvc;
@Test
void ottLoginWhenUserExistsThenSendEmailAndAuthenticate() throws Exception {
this.mockMvc.perform(post("/ott/generate").param("username", "user").with(csrf()))
.andExpectAll(status().isFound(), redirectedUrl("/ott/sent"));
greenMail.waitForIncomingEmail(1);
MimeMessage receivedMessage = greenMail.getReceivedMessages()[0];
String content = GreenMailUtil.getBody(receivedMessage);
String url = content.split(": ")[1];
UriComponents uriComponents = UriComponentsBuilder.fromUriString(url).build();
String token = uriComponents.getQueryParams().get("token").get(0);
assertThat(token).isNotEmpty();
this.mockMvc.perform(post("/login/ott").param("token", token).with(csrf()))
.andExpectAll(status().isFound(), redirectedUrl("/"), authenticated());
}
@Test
void ottLoginWhenInvalidTokenThenFails() throws Exception {
this.mockMvc.perform(post("/ott/generate").param("username", "user").with(csrf()))
.andExpectAll(status().isFound(), redirectedUrl("/ott/sent"));
String token = "1234;";
this.mockMvc.perform(post("/login/ott").param("token", token).with(csrf()))
.andExpectAll(status().isFound(), redirectedUrl("/login?error"), unauthenticated());
}
}

View File

@@ -0,0 +1,6 @@
spring:
config:
import: classpath:application.yml
mail:
port: 3025
host: localhost