一次性令牌登录
Spring Security 通过 oneTimeTokenLogin() DSL 提供对一次性令牌(OTT)认证的支持。在深入实现细节之前,有必要先阐明该框架内 OTT 功能的范围,明确其支持与不支持的功能。
理解一次性令牌与一次性密码
人们常常将一次性令牌(OTT)与一次性密码(OTP)混淆,但在 Spring Security 中,这两个概念在几个关键方面有所不同。为了清晰起见,我们假设 OTP 指的是基于时间的一次性密码(TOTP)或基于 HMAC 的一次性密码(HOTP)。
安装要求
-
OTT:无需初始设置。用户无需预先进行任何配置。
-
OTP:通常需要设置,例如生成并与外部工具共享密钥以生成一次性密码。
令牌交付
-
OTT:通常需要自定义一个 OneTimeTokenGenerationSuccessHandler,负责将令牌交付给最终用户。
-
OTP:令牌通常由外部工具生成,因此无需通过应用程序发送给用户。
令牌生成
-
OTT: OneTimeTokenService.generate(GenerateOneTimeTokenRequest) 方法要求返回一个 OneTimeToken,强调服务器端生成。
-
OTP: 令牌不一定在服务器端生成,通常由客户端使用共享密钥创建。
总而言之,一次性令牌(OTT)提供了一种无需额外账户设置即可验证用户身份的方式,这与一次性密码(OTP)不同,后者通常涉及更复杂的设置过程,并依赖外部工具来生成令牌。
一次性令牌登录主要分为两个步骤。
-
用户通过提交其用户标识符(通常是用户名)来请求令牌,随后令牌会通过电子邮件、短信等方式(通常以魔法链接的形式)发送给用户。
-
用户将令牌提交到一次性令牌登录端点,如果令牌有效,用户即可成功登录。
在接下来的章节中,我们将探讨如何根据您的需求配置OTT登录。
默认登录页面与默认一次性令牌提交页面
当使用 oneTimeTokenLogin() DSL 时,默认情况下,一次性令牌登录页面由 org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter 自动生成。该 DSL 还会配置 DefaultOneTimeTokenSubmitPageGeneratingFilter 来生成默认的一次性令牌提交页面。
向用户发送令牌
Spring Security 无法合理确定令牌应如何交付给用户。因此,必须提供一个自定义的 OneTimeTokenGenerationSuccessHandler,以便根据您的需求将令牌交付给用户。最常见的交付策略之一是通过电子邮件、短信等方式发送魔法链接。在以下示例中,我们将创建一个魔法链接并将其发送到用户的邮箱。
- Java
- Kotlin
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
// ...
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin(Customizer.withDefaults());
return http.build();
}
}
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
@Component 1
public class MagicLinkOneTimeTokenGenerationSuccessHandler implements OneTimeTokenGenerationSuccessHandler {
private final MailSender mailSender;
private final OneTimeTokenGenerationSuccessHandler redirectHandler = new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent");
// constructor omitted
@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()); 2
String magicLink = builder.toUriString();
String email = getUserEmail(oneTimeToken.getUsername()); 3
this.mailSender.send(email, "Your Spring Security One Time Token", "Use the following link to sign in into the application: " + magicLink); 4
this.redirectHandler.handle(request, response, oneTimeToken); 5
}
private String getUserEmail() {
// ...
}
}
@Controller
class PageController {
@GetMapping("/ott/sent")
String ottSent() {
return "my-template";
}
}
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
http{
formLogin {}
oneTimeTokenLogin { }
}
return http.build()
}
}
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
@Component (1)
class MagicLinkOneTimeTokenGenerationSuccessHandler(
private val mailSender: MailSender,
private val redirectHandler: OneTimeTokenGenerationSuccessHandler = RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent")
) : OneTimeTokenGenerationSuccessHandler {
override fun handle(request: HttpServletRequest, response: HttpServletResponse, oneTimeToken: OneTimeToken) {
val builder = UriComponentsBuilder.fromHttpUrl(UrlUtils.buildFullRequestUrl(request))
.replacePath(request.contextPath)
.replaceQuery(null)
.fragment(null)
.path("/login/ott")
.queryParam("token", oneTimeToken.getTokenValue()) (2)
val magicLink = builder.toUriString()
val email = getUserEmail(oneTimeToken.getUsername()) (3)
this.mailSender.send(email, "Your Spring Security One Time Token", "Use the following link to sign in into the application: $magicLink")(4)
this.redirectHandler.handle(request, response, oneTimeToken) (5)
}
private fun getUserEmail(): String {
// ...
}
}
@Controller
class PageController {
@GetMapping("/ott/sent")
fun ottSent(): String {
return "my-template"
}
}
将
MagicLinkOneTimeTokenGenerationSuccessHandler设为 Spring Bean创建以
token作为查询参数的登录处理 URL根据用户名检索用户的电子邮件
使用
JavaMailSenderAPI 向用户发送包含魔法链接的电子邮件使用
RedirectOneTimeTokenGenerationSuccessHandler重定向到目标 URL
邮件内容将类似于:
默认提交页面会检测到 URL 中包含 token 查询参数,并自动将令牌值填入表单字段。
修改一次性令牌生成URL
默认情况下,GenerateOneTimeTokenFilter 监听 POST /ott/generate 请求。该 URL 可通过 generateTokenUrl(String) DSL 方法进行修改:
- Java
- Kotlin
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
// ...
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin((ott) -> ott
.generateTokenUrl("/ott/my-generate-url")
);
return http.build();
}
}
@Component
public class MagicLinkOneTimeTokenGenerationSuccessHandler implements OneTimeTokenGenerationSuccessHandler {
// ...
}
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
http {
//...
formLogin { }
oneTimeTokenLogin {
generateTokenUrl = "/ott/my-generate-url"
}
}
return http.build()
}
}
@Component
class MagicLinkOneTimeTokenGenerationSuccessHandler : OneTimeTokenGenerationSuccessHandler {
// ...
}
更改默认提交页面URL
默认的一次性令牌提交页面由 DefaultOneTimeTokenSubmitPageGeneratingFilter 生成,并监听 GET /login/ott 请求。该 URL 也可以进行更改,如下所示:
- Java
- Kotlin
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
// ...
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin((ott) -> ott
.submitPageUrl("/ott/submit")
);
return http.build();
}
}
@Component
public class MagicLinkGenerationSuccessHandler implements OneTimeTokenGenerationSuccessHandler {
// ...
}
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
http {
//...
formLogin { }
oneTimeTokenLogin {
submitPageUrl = "/ott/submit"
}
}
return http.build()
}
}
@Component
class MagicLinkOneTimeTokenGenerationSuccessHandler : OneTimeTokenGenerationSuccessHandler {
// ...
}
禁用默认提交页面
如果您希望使用自定义的一次性令牌提交页面,可以禁用默认页面,然后提供您自己的端点。
- Java
- Kotlin
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/my-ott-submit").permitAll()
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin((ott) -> ott
.showDefaultSubmitPage(false)
);
return http.build();
}
}
@Controller
public class MyController {
@GetMapping("/my-ott-submit")
public String ottSubmitPage() {
return "my-ott-submit";
}
}
@Component
public class OneTimeTokenGenerationSuccessHandler implements OneTimeTokenGenerationSuccessHandler {
// ...
}
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
http {
authorizeHttpRequests {
authorize("/my-ott-submit", authenticated)
authorize(anyRequest, authenticated)
}
formLogin { }
oneTimeTokenLogin {
showDefaultSubmitPage = false
}
}
return http.build()
}
}
@Controller
class MyController {
@GetMapping("/my-ott-submit")
fun ottSubmitPage(): String {
return "my-ott-submit"
}
}
@Component
class MagicLinkOneTimeTokenGenerationSuccessHandler : OneTimeTokenGenerationSuccessHandler {
// ...
}
自定义如何生成和消费一次性令牌
定义一次性令牌生成与消费通用操作的接口是 OneTimeTokenService。如果未提供任何实现,Spring Security 默认使用 InMemoryOneTimeTokenService 作为该接口的默认实现。对于生产环境,请考虑使用 JdbcOneTimeTokenService。
定制 OneTimeTokenService 最常见的原因包括但不限于:
-
修改一次性令牌的过期时间
-
存储更多来自生成令牌请求的信息
-
修改令牌值的创建方式
-
消费一次性令牌时进行额外验证
有两种方式可以自定义 OneTimeTokenService。一种方式是将其作为 Bean 提供,这样它就能被 oneTimeTokenLogin() DSL 自动识别:
- Java
- Kotlin
@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();
}
}
@Component
public class MagicLinkOneTimeTokenGenerationSuccessHandler implements OneTimeTokenGenerationSuccessHandler {
// ...
}
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
http {
//...
formLogin { }
oneTimeTokenLogin { }
}
return http.build()
}
@Bean
open fun oneTimeTokenService(): OneTimeTokenService {
return MyCustomOneTimeTokenService()
}
}
@Component
class MagicLinkOneTimeTokenGenerationSuccessHandler : OneTimeTokenGenerationSuccessHandler {
// ...
}
第二种选择是将 OneTimeTokenService 实例传递给 DSL,这在存在多个 SecurityFilterChain 且每个链需要不同的 OneTimeTokenService 时非常有用。
- Java
- Kotlin
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
// ...
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin((ott) -> ott
.oneTimeTokenService(new MyCustomOneTimeTokenService())
);
return http.build();
}
}
@Component
public class MagicLinkOneTimeTokenGenerationSuccessHandler implements OneTimeTokenGenerationSuccessHandler {
// ...
}
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
http {
//...
formLogin { }
oneTimeTokenLogin {
oneTimeTokenService = MyCustomOneTimeTokenService()
}
}
return http.build()
}
}
@Component
class MagicLinkOneTimeTokenGenerationSuccessHandler : OneTimeTokenGenerationSuccessHandler {
// ...
}
自定义生成一次性令牌请求实例
您可能需要调整 GenerateOneTimeTokenRequest 的原因有多种。例如,您可能希望将 expiresIn 设置为 10 分钟,而 Spring Security 默认将其设置为 5 分钟。
你可以通过发布一个 GenerateOneTimeTokenRequestResolver 作为 @Bean 来定制 GenerateOneTimeTokenRequest 的元素,如下所示:
- Java
- Kotlin
@Bean
GenerateOneTimeTokenRequestResolver generateOneTimeTokenRequestResolver() {
DefaultGenerateOneTimeTokenRequestResolver delegate = new DefaultGenerateOneTimeTokenRequestResolver();
return (request) -> {
GenerateOneTimeTokenRequest generate = delegate.resolve(request);
return new GenerateOneTimeTokenRequest(generate.getUsername(), Duration.ofSeconds(600));
};
}
@Bean
fun generateRequestResolver() : GenerateOneTimeTokenRequestResolver {
return DefaultGenerateOneTimeTokenRequestResolver().apply {
this.setExpiresIn(Duration.ofMinutes(10))
}
}