Add Magic Link Sample

Closes gh-312
This commit is contained in:
Marcus Hert Da Coregio
2024-09-03 15:38:57 -03:00
parent cc0e6f0d33
commit c389b99359
21 changed files with 751 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,43 @@
package org.example.magiclink;
import java.io.IOException;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.ott.OneTimeToken;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.authentication.ott.GeneratedOneTimeTokenHandler;
import org.springframework.security.web.util.UrlUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
@Component
public class MagicLinkGeneratedOneTimeTokenHandler implements GeneratedOneTimeTokenHandler {
private final MailSender mailSender;
private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
public MagicLinkGeneratedOneTimeTokenHandler(MailSender mailSender) {
this.mailSender = mailSender;
}
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, OneTimeToken oneTimeToken)
throws IOException, ServletException {
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(UrlUtils.buildFullRequestUrl(request))
.replacePath(request.getContextPath())
.replaceQuery(null)
.fragment(null)
.path("/login/ott")
.queryParam("token", oneTimeToken.getTokenValue());
String magicLink = builder.toUriString();
this.mailSender.send("johndoe@example.com", "Your Spring Security One Time Token",
"Use the following link to sign in into the application: " + magicLink);
this.redirectStrategy.sendRedirect(request, response, "/ott/sent");
}
}

View File

@@ -0,0 +1,25 @@
package org.example.magiclink;
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 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);
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,58 @@
/*
* 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.servlet.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.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration(proxyBeanMethods = false)
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeHttpRequests((authz) -> authz
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.requestMatchers("/ott/sent").permitAll()
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin(Customizer.withDefaults());
// @formatter:on
return http.build();
}
@Bean
InMemoryUserDetailsManager userDetailsService() {
UserDetails user = User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(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: ./servlet/spring-boot/java/authentication/one-time-token/magic-link/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 jakarta.mail.internet.MimeMessage;
import com.icegreen.greenmail.junit5.GreenMailExtension;
import com.icegreen.greenmail.util.GreenMailUtil;
import com.icegreen.greenmail.util.ServerSetupTest;
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