一次性Tokens登录

Spring Security 通过 oneTimeTokenLogin() DSL 提供对一次性Tokens(OTT)身份验证的支持。 在深入探讨实现细节之前,有必要明确 OTT 功能在框架中的适用范围,说明哪些内容受支持,哪些不受支持。spring-doc.cadn.net.cn

了解一次性Tokens与一次性密码

人们常常将一次性Tokens(One-Time Tokens,OTT)与一次性密码(One-Time Passwords,OTP)混淆,但在 Spring Security 中,这两个概念在几个关键方面有所不同。 为清晰起见,我们假设 OTP 指的是TOTP(基于时间的一次性密码)或HOTP(基于 HMAC 的一次性密码)。spring-doc.cadn.net.cn

设置要求

  • OTT:无需初始设置。用户无需提前进行任何配置。spring-doc.cadn.net.cn

  • OTP:通常需要进行设置,例如生成一个密钥并与外部工具共享,以生成一次性密码。spring-doc.cadn.net.cn

Tokens交付

Tokens生成

总而言之,一次性Tokens(OTT)提供了一种无需额外账户设置即可对用户进行身份验证的方式,这使其区别于一次性密码(OTP)。一次性密码通常涉及更复杂的设置流程,并依赖外部工具来生成Tokens。spring-doc.cadn.net.cn

一次性Tokens登录分为两个主要步骤。spring-doc.cadn.net.cn

  1. 用户通过提交其用户标识(通常是用户名)来请求一个Tokens,该Tokens通常以魔法链接(Magic Link)的形式通过电子邮件、短信等方式发送给他们。spring-doc.cadn.net.cn

  2. 用户将Tokens提交到一次性Tokens登录端点,如果Tokens有效,用户即可成功登录。spring-doc.cadn.net.cn

在接下来的章节中,我们将探讨如何根据您的需求配置OTT登录。spring-doc.cadn.net.cn

默认登录页面和默认一次性Tokens提交页面

当使用 oneTimeTokenLogin() DSL 时,默认情况下,One-Time Token 登录页面将由 org.springframework.security.web.authentication.ui:DefaultLoginPageGeneratingFilter[] 自动生成。 该 DSL 还将设置 DefaultOneTimeTokenSubmitPageGeneratingFilter 以生成默认的 One-Time Token 提交页面。spring-doc.cadn.net.cn

将Tokens发送给用户

Spring Security 无法合理确定将Tokens交付给用户的方式。 因此,必须提供一个自定义的 OneTimeTokenGenerationSuccessHandler,以便根据您的需要将Tokens交付给用户。 最常见的交付策略之一是通过电子邮件、短信等方式发送魔法链接。 在以下示例中,我们将创建一个魔法链接并将其发送到用户的邮箱。spring-doc.cadn.net.cn

一次性Tokens登录配置
@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"
    }
}
1 MagicLinkOneTimeTokenGenerationSuccessHandler 注册为一个 Spring Bean
2 创建一个登录处理 URL,并将 token 作为查询参数
3 根据用户名获取用户的电子邮件
4 使用 JavaMailSender API 向用户发送包含魔法链接的电子邮件
5 使用 RedirectOneTimeTokenGenerationSuccessHandler 将请求重定向到您指定的 URL

电子邮件内容将类似于:spring-doc.cadn.net.cn

请使用以下链接登录应用程序:http://localhost:8080/login/ott?token=a830c444-29d8-4d98-9b46-6aba7b22fe5bspring-doc.cadn.net.cn

默认的提交页面将检测到 URL 中包含 token 查询参数,并会自动使用该 token 值填充表单字段。spring-doc.cadn.net.cn

更改一次性Tokens生成 URL

默认情况下,GenerateOneTimeTokenFilter 监听 POST /ott/generate 请求。 该 URL 可以通过使用 generateTokenUrl(String) DSL 方法进行更改:spring-doc.cadn.net.cn

更改生成的 URL
@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

默认的“一次性Tokens”提交页面由 DefaultOneTimeTokenSubmitPageGeneratingFilter 生成,并监听 GET /login/ott。 URL 也可以更改,如下所示:spring-doc.cadn.net.cn

配置默认提交页面 URL
@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 {
     // ...
}

禁用默认的提交页面

如果你想使用自己的一次性Tokens(One-Time Token)提交页面,可以禁用默认页面,然后提供你自己的端点。spring-doc.cadn.net.cn

禁用默认的提交页面
@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 {
     // ...
}

自定义如何生成和使用一次性Tokens

定义生成和消费一次性Tokens通用操作的接口是 OneTimeTokenService。 Spring Security 在未提供实现时,默认使用 InMemoryOneTimeTokenService 作为该接口的实现。 对于生产环境,请考虑使用 JdbcOneTimeTokenServicespring-doc.cadn.net.cn

自定义 OneTimeTokenService 的一些最常见原因包括但不限于:spring-doc.cadn.net.cn

有两种方式可以自定义 OneTimeTokenService。 一种方式是将其作为一个 Bean 提供,这样它就能被 oneTimeTokenLogin() DSL 自动识别并使用:spring-doc.cadn.net.cn

将 OneTimeTokenService 作为 Bean 传递
@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 时,这种方式非常有用。spring-doc.cadn.net.cn

使用 DSL 传递 OneTimeTokenService
@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 实例

您可能有多种原因需要调整 GenerateOneTimeTokenRequest。例如,您可能希望将 expiresIn 设置为 10 分钟,而 Spring Security 默认将其设置为 5 分钟。spring-doc.cadn.net.cn

您可以通过将 GenerateOneTimeTokenRequestResolver 发布为一个 @Bean 来自定义 GenerateOneTimeTokenRequest 的元素,如下所示:spring-doc.cadn.net.cn

@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))
    }
}