Add Magic Link Sample
Closes gh-312
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
spring:
|
||||
config:
|
||||
import: classpath:application.yml
|
||||
mail:
|
||||
port: 3025
|
||||
host: localhost
|
||||
Reference in New Issue
Block a user