SpringSecurity6.4中一次性令牌登录(One-Time Token Login)实现
作者:Tomas Brunken
本系列Spring Boot 版本 3.4.0
本系列Spring Security 版本 6.4.2
Spring Security为一次性令牌(One-Time Token,OTT)认证提供了支持,通过oneTimeTokenLogin()DSL(Domain Specific Language,领域特定语言,它使用了一种特定的语法和约定来简化配置和管理Spring应用程序的过程。)即可使用该功能。在进行实现之前,需要明确OTT功能在框架中的范围。
一、理解一次性令牌与一次性密码的区别
人们常常会混淆一次性令牌(OTT)和一次性密码(OTP),但在 Spring Security 中,这两个概念在几个关键方面有所不同。为了清晰起见,我们将假设 OTP 指的是基于时间的一次性密码(TOTP)或基于 HMAC 的一次性密码(HOTP)。
1.区别一:设置要求
- OTT:无需初始设置。用户无需提前进行任何配置。
- OTP:通常需要设置,例如使用外部工具生成和共享一个密钥来生成一次性密码。
2.区别二:令牌交付/发送
- OTT:通常需要实现一个自定义的
OneTimeTokenGenerationSuccessHandler,负责将令牌交付给最终用户。 - OTP:令牌通常由外部工具生成,因此无需通过应用程序发送给用户。
3.区别三:令牌生成
- OTT:通过
OneTimeTokenService.generate(GenerateOneTimeTokenRequest)方法在服务器端生成令牌。 - OTP:令牌不一定是在服务器端生成的,通常是由客户端使用共享密钥创建的。
总结来说,一次性令牌(OTT)提供了一种无需额外账户设置即可验证用户的方法,与通常涉及更复杂设置过程并依赖外部工具生成令牌的一次性密码(OTP)不同。
二、一次性令牌登录的流程
一次性令牌登录主要分为两个步骤:
- 用户提交用户标识符(通常为用户名)请求令牌,令牌会以魔法链接(
Magic Link)的形式,通过电子邮件、短信等方式发送给用户。 - 用户将令牌提交到一次性令牌登录端点,也就是点击魔法链接。若令牌有效,则用户成功登录。
- 整体流程
开始→用户访问一次性令牌登录页面
→点击发送令牌按钮
→发送POST /ott/generate 请求
→GenerateOneTimeTokenFilter 监听POST /ott/generate 请求,并通过OneTimeTokenService.generate()方法在服务器端生成令牌
→令牌生成成功后,调用 自定义的OneTimeTokenGenerationSuccessHandler接口实现类的handle()方法
→handle()方法负责通过Email或短信将包含令牌的魔法链接(Magic Link)发送给用户
→用户接收到邮件/短信后,点击魔法链接
→服务器端接收GET /login/ott请求
→由 DefaultOneTimeTokenSubmitPageGeneratingFilter 生成一次性令牌提交页面,并返回给用户
→用户点击提交页面中的登录按钮
→发送POST /login/ott 请求,服务端需要自定义一个HTTP接口,请求路径为POST /login/ott→服务器端进入身份认证流程(FilterChainProxy.doFilter→AuthenticationFilter.doFilterInternal)
→AuthenticationFilter.attemptAuthentication方法中通过调用this.authenticationConverter.convert(request)构造OneTimeTokenAuthenticationToken实例,其中this.authenticationConverter是OneTimeTokenAuthenticationConverter的实例
→AuthenticationFilter.doFilterInternal方法中通过调用AuthenticationManager.authenticate(OneTimeTokenAuthenticationToken)进行认证
→调用ProviderManager.authenticate(OneTimeTokenAuthenticationToken)→交给OneTimeTokenAuthenticationProvider认证
→OneTimeTokenAuthenticationProvider.authenticate()方法调用OneTimeTokenService.consume(OneTimeTokenAuthenticationToken)方法消费token,对token进行验证
→验证通过
→调用userDetailsService.loadUserByUsername获取用户信息和权限信息
→认证完成
→DispatcherServlet接着处理POST /login/ott 请求
→调用服务端自定义的对应Controller方法
→该方法处理登录成功后的一些逻辑,比如重定向到指定页面,记录日志等。
→结束
三、一次性令牌登录的配置
1.OTT与默认生成的登录页面的集成
oneTimeTokenLogin()DSL可与formLogin()结合使用,这会在默认生成的登录页面中添加一个一次性令牌请求表单,同时设置DefaultOneTimeTokenSubmitPageGeneratingFilter来生成默认的一次性令牌提交页面。
2.发送令牌给用户
Spring Security无法确定令牌的交付方式,因此需提供自定义的OneTimeTokenGenerationSuccessHandler。
最常用的发送策略之一是通过电子邮件、短信等发送一个魔法链接。
在下面的示例中,我们将创建一个魔法链接并将其发送到用户的电子邮件。
- 一次性令牌登录配置
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
// ...
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin(Customizer.withDefaults());
return http.build();
}
}
- 自定义
OneTimeTokenGenerationSuccessHandler
@Component // ①
public class MagicLinkOneTimeTokenGenerationSuccessHandler implements OneTimeTokenGenerationSuccessHandler {
private final MailSender mailSender;
private final OneTimeTokenGenerationSuccessHandler redirectHandler = new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent");
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, OneTimeToken oneTimeToken) throws IOException, ServletException {
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(UrlUtils.buildFullRequestUrl(request))
.replacePath(request.getContextPath())
.replaceQuery(null)
.fragment(null)
.path("/login/ott")
.queryParam("token", oneTimeToken.getTokenValue()); // ②
String magicLink = builder.toUriString();
String email = getUserEmail(oneTimeToken.getUsername()); // ③
this.mailSender.send(email, "Your Spring Security One Time Token", "Use the following link to sign in into the application: " + magicLink); // ④
this.redirectHandler.handle(request, response, oneTimeToken); // ⑤
}
private String getUserEmail() {
// ...
}
}
- 发送令牌给用户成功后要跳转到的页面
@Controller
public class PageController {
@GetMapping("/ott/sent")
String ottSent() {
return "my-template";
}
}
① 将 MagicLinkOneTimeTokenGenerationSuccessHandler 配置为 Spring Bean
② 创建一个带有 token 作为查询参数的登录处理 URL
③ 根据用户名检索用户电子邮件
④ 使用 JavaMailSender API 向用户发送带有魔法链接的电子邮件
⑤ 使用 RedirectOneTimeTokenGenerationSuccessHandler 进行重定向到您想要的 URL
邮件内容将类似于:
Use the following link to sign in into the application: http://localhost:8080/login/ott?token=a830c444-29d8-4d98-9b46-6aba7b22fe5b
默认提交页面会检测URL中的token查询参数,并将它的值自动填充到表单字段中。
3.配置一次性令牌提交页面
默认的一次性令牌提交页面由 DefaultOneTimeTokenSubmitPageGeneratingFilter 生成,并监听 GET /login/ott 。URL 也可以这样更改:
- 配置默认提交页面 URL
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
// ...
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin((ott) -> ott
.defaultSubmitPageUrl("/ott/submit")
);
return http.build();
}
}
@Component
public class MagicLinkGenerationSuccessHandler implements OneTimeTokenGenerationSuccessHandler {
// ...
}
TIPS:2025.1.14官方文档示例是使用submitPageUrl(String)DSL 方法进行更改。我使用 Spring Security6.4.2 测试时,只有defaultSubmitPageUrl(String)DSL 方法。因此示例修改为defaultSubmitPageUrl(String)DSL 方法。
4.自定义一次性令牌提交页面
如果您想使用自己的一次性令牌提交页面,您可以禁用默认页面,然后提供自己的端点。
- 禁用默认提交页面
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin((ott) -> ott
// 禁用默认提交页面
.showDefaultSubmitPage(false)
// 自定义提交页面URL
.defaultSubmitPageUrl("/ott/submit")
);
return http.build();
}
}
@Controller
public class MyController {
// 自定义默认提交页面入口
@GetMapping("/ott/submit")
public String ottSubmitPage() {
return "my-ott-submit";
}
}
@Component
public class MagicLinkGenerationSuccessHandler implements OneTimeTokenGenerationSuccessHandler {
// ...
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, OneTimeToken oneTimeToken) throws IOException, ServletException {
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(UrlUtils.buildFullRequestUrl(request))
.replacePath(request.getContextPath())
.replaceQuery(null)
.fragment(null)
.path("/ott/submit")
.queryParam("token", oneTimeToken.getTokenValue()); // ②
String magicLink = builder.toUriString();
String email = getUserEmail(oneTimeToken.getUsername()); // ③
this.mailSender.send(email, "Your Spring Security One Time Token", "Use the following link to sign in into the application: " + magicLink); // ④
this.redirectHandler.handle(request, response, oneTimeToken); // ⑤
}
// ...
}
5.更改一次性令牌生成 URL
默认情况下, GenerateOneTimeTokenFilter 监听 POST /ott/generate 请求。该 URL 可以通过使用 tokenGeneratingUrl(String) DSL 方法进行更改:
// Java示例
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
// ...
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin((ott) -> ott
.tokenGeneratingUrl("/ott/my-generate-url")
);
return http.build();
}
}
TIPS:2025.1.14官方文档示例是使用generateTokenUrl(String)DSL 方法进行更改。我使用 Spring Security6.4.2 测试时,只有tokenGeneratingUrl(String)DSL 方法。因此示例修改为tokenGeneratingUrl(String)DSL 方法。
6.自定义如何生成和消费令牌
定义生成和使用一次性令牌常见操作的接口是OneTimeTokenService。Spring Security默认使用InMemoryOneTimeTokenService,生产环境可考虑使用JdbcOneTimeTokenService。
一些自定义 OneTimeTokenService 最常见的原因包括但不限于:
- 更改一次性令牌过期时间
- 存储更多生成令牌请求的信息
- 更改令牌值的创建方式
- 在使用一次性令牌时进行额外验证
有两种自定义方式:
- 一是将自定义的
OneTimeTokenService作为Bean提供,会被oneTimeTokenLogin()DSL自动识别:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin(Customizer.withDefaults());
return http.build();
}
@Bean
public OneTimeTokenService oneTimeTokenService() {
return new MyCustomOneTimeTokenService();
}
}
- 二是将
OneTimeTokenService实例传递给DSL,当存在多个SecurityFilterChain且每个需要不同的OneTimeTokenService时,这种方式很有用:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin((ott) -> ott
.tokenService(new MyCustomOneTimeTokenService())
);
return http.build();
}
}
TIPS:2025.1.14官方文档示例是使用oneTimeTokenService(OneTimeTokenService)DSL 方法进行更改。我使用 Spring Security6.4.2 测试时,只有tokenService(OneTimeTokenService)DSL 方法。因此示例修改为tokenService(OneTimeTokenService)DSL 方法。
7.完整配置示例
@Configuration
@EnableWebSecurity
public class SecurityConfig {
/**
* 配置Security过滤链,用于处理一次性令牌登录
*
* @param http 用于配置Web安全的HttpSecurity对象
* @param userDetailsService 自定义的用户信息查询服务,实现了UserDetailsService接口
* @param mailSender 邮件服务
* @return 配置好的SecurityFilterChain对象
*/
@Bean
@Order(1)
public SecurityFilterChain ottLoginSecurityFilterChain(HttpSecurity http, final OneTimeTokenService oneTimeTokenService, final UserDetailsServiceImpl userDetailsService, final JavaMailSender mailSender) throws Exception {
http.formLogin(Customizer.withDefaults())
// 配置请求授权,所有请求都需要认证
.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
// 禁用CSRF保护,适用于本例的简单演示场景,在生产环境中需谨慎考虑。若是前后端分离情况,需要禁用
.csrf(CsrfConfigurer::disable)
// 配置一次性令牌登录
.oneTimeTokenLogin(ott->ott
// (可选)设置一次性令牌生成的URL,默认值:/ott/generate
.tokenGeneratingUrl("/ott/generate")
// (可选)设置一次性令牌生成和验证的服务,实现OneTimeTokenService接口,默认值:InMemoryOneTimeTokenService实例
.tokenService(oneTimeTokenService)
// (必做)配置一次性令牌生成成功后的处理,默认值:RedirectOneTimeTokenGenerationSuccessHandler实例
.tokenGenerationSuccessHandler(new CustomOneTimeTokenGenerationSuccessHandler(mailSender))
// (可选)设置是否显示默认的登录提交页面,默认值:true
.showDefaultSubmitPage(true)
// (可选)设置默认登录提交页面的URL,默认值:/login/ott
.defaultSubmitPageUrl("/login/ott")
// (可选)配置认证转换器,用于将HTTP请求中的token转换为OneTimeTokenAuthenticationToken认证对象,默认值:OneTimeTokenAuthenticationConverter实例
.authenticationConverter(new OneTimeTokenAuthenticationConverter())
// (可选)配置认证提供者,用于验证OneTimeTokenAuthenticationToken认证对象,默认值:OneTimeTokenAuthenticationProvider实例
.authenticationProvider(new OneTimeTokenAuthenticationProvider(oneTimeTokenService, userDetailsService))
// (可选)配置认证成功后的处理
.authenticationSuccessHandler(((request, response, authentication) -> {
System.out.println("认证成功");
}))
// (可选)配置认证失败后的处理
.authenticationFailureHandler((request, response, exception) -> {
System.out.println("认证失败:"+exception.getMessage());
new DefaultRedirectStrategy().sendRedirect(request, response, "/login");
})
// (可选)设置一次性令牌登录成功后的URL
// (必做)添加一个 /login/ott 类型的HTTP接口
.loginProcessingUrl("/login/ott")
);
// 构建并返回配置好的Security过滤链
return http.build();
}
@Bean
public OneTimeTokenService oneTimeTokenService() {
// 创建自定义的OneTimeTokenService实例
// 为了测试方便,直接使用内置的InMemoryOneTimeTokenService
return new InMemoryOneTimeTokenService();
}
static class CustomOneTimeTokenGenerationSuccessHandler implements OneTimeTokenGenerationSuccessHandler {
private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
private final JavaMailSender mailSender;
public CustomOneTimeTokenGenerationSuccessHandler(JavaMailSender 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") // 设置默认登录提交页面的URL
.queryParam("token", oneTimeToken.getTokenValue());
String magicLink = builder.toUriString();
// 发件人:从数据库或配置文件中读取,这里只是测试,直接写死
String from = "test@test.com";
String email = getUserEmail(oneTimeToken.getUsername());
SimpleMailMessage msg = new SimpleMailMessage();
msg.setFrom(from);
msg.setTo(email);
msg.setSubject("Your Spring Security One Time Token");
msg.setText("Use the following link to sign in into the application: " + magicLink);
this.mailSender.send(msg);
// 为了测试方便,重定向到token提交页
String redirectUrl = "/login/ott?token=" + oneTimeToken.getTokenValue();
this.redirectStrategy.sendRedirect(request, response, redirectUrl);
}
private String getUserEmail(String username) {
// TODO 获取用户邮箱地址
return "test@test.com";
}
}
}
@RestController
public class OTTLoginController {
private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@PostMapping("/login/ott")
public void login(HttpServletRequest request, HttpServletResponse response) throws IOException {
System.out.println("认证成功后跳转到主页");
this.redirectStrategy.sendRedirect(request,response,"/");
}
}
通过以上配置,可以根据具体需求灵活使用Spring Security的一次性令牌登录功能。
引用
到此这篇关于SpringSecurity6.4中一次性令牌登录(One-Time Token Login)实现的文章就介绍到这了,更多相关SpringSecurity 一次性令牌登录内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
