Add One-Time Token Login support to Kotlin DSL

Closes gh-15698
This commit is contained in:
Max Batischev 2024-09-03 19:14:24 +03:00 committed by Marcus Hert Da Coregio
parent 3b2afd7a06
commit 81e4c7273a
4 changed files with 498 additions and 48 deletions

View File

@ -18,7 +18,6 @@ package org.springframework.security.config.annotation.web
import jakarta.servlet.Filter
import jakarta.servlet.http.HttpServletRequest
import org.checkerframework.checker.units.qual.C
import org.springframework.context.ApplicationContext
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.config.annotation.SecurityConfigurerAdapter
@ -60,7 +59,7 @@ import org.springframework.security.web.util.matcher.RequestMatcher
* @param httpConfiguration the configurations to apply to [HttpSecurity]
*/
operator fun HttpSecurity.invoke(httpConfiguration: HttpSecurityDsl.() -> Unit) =
HttpSecurityDsl(this, httpConfiguration).build()
HttpSecurityDsl(this, httpConfiguration).build()
/**
* An [HttpSecurity] Kotlin DSL created by [`http { }`][invoke]
@ -104,7 +103,10 @@ class HttpSecurityDsl(private val http: HttpSecurity, private val init: HttpSecu
* @param configurer
* the [SecurityConfigurerAdapter] for further customizations
*/
fun <C : SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity>> apply(configurer: C, configuration: C.() -> Unit = { }): C {
fun <C : SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity>> apply(
configurer: C,
configuration: C.() -> Unit = { }
): C {
return this.http.apply(configurer).apply(configuration)
}
@ -134,7 +136,10 @@ class HttpSecurityDsl(private val http: HttpSecurity, private val init: HttpSecu
* the [HttpSecurity] for further customizations
* @since 6.2
*/
fun <C : SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity>> with(configurer: C, configuration: C.() -> Unit = { }): HttpSecurity? {
fun <C : SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity>> with(
configurer: C,
configuration: C.() -> Unit = { }
): HttpSecurity? {
return this.http.with(configurer, configuration)
}
@ -299,7 +304,8 @@ class HttpSecurityDsl(private val http: HttpSecurity, private val init: HttpSecu
* @since 5.7
*/
fun authorizeHttpRequests(authorizeHttpRequestsConfiguration: AuthorizeHttpRequestsDsl.() -> Unit) {
val authorizeHttpRequestsCustomizer = AuthorizeHttpRequestsDsl(this.context).apply(authorizeHttpRequestsConfiguration).get()
val authorizeHttpRequestsCustomizer =
AuthorizeHttpRequestsDsl(this.context).apply(authorizeHttpRequestsConfiguration).get()
this.http.authorizeHttpRequests(authorizeHttpRequestsCustomizer)
}
@ -772,42 +778,42 @@ class HttpSecurityDsl(private val http: HttpSecurity, private val init: HttpSecu
this.http.saml2Logout(saml2LogoutCustomizer)
}
/**
* Configures a SAML 2.0 relying party metadata endpoint.
*
* A [RelyingPartyRegistrationRepository] is required and must be registered with
* the [ApplicationContext] or configured via
* [Saml2Dsl.relyingPartyRegistrationRepository]
*
* Example:
*
* The following example shows the minimal configuration required, using a
* hypothetical asserting party.
*
* ```
* @Configuration
* @EnableWebSecurity
* class SecurityConfig {
*
* @Bean
* fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
* http {
* saml2Login { }
* saml2Metadata { }
* }
* return http.build()
* }
* }
* ```
* @param saml2MetadataConfiguration custom configuration to configure the
* SAML2 relying party metadata endpoint
* @see [Saml2MetadataDsl]
* @since 6.1
*/
fun saml2Metadata(saml2MetadataConfiguration: Saml2MetadataDsl.() -> Unit) {
val saml2MetadataCustomizer = Saml2MetadataDsl().apply(saml2MetadataConfiguration).get()
this.http.saml2Metadata(saml2MetadataCustomizer)
}
/**
* Configures a SAML 2.0 relying party metadata endpoint.
*
* A [RelyingPartyRegistrationRepository] is required and must be registered with
* the [ApplicationContext] or configured via
* [Saml2Dsl.relyingPartyRegistrationRepository]
*
* Example:
*
* The following example shows the minimal configuration required, using a
* hypothetical asserting party.
*
* ```
* @Configuration
* @EnableWebSecurity
* class SecurityConfig {
*
* @Bean
* fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
* http {
* saml2Login { }
* saml2Metadata { }
* }
* return http.build()
* }
* }
* ```
* @param saml2MetadataConfiguration custom configuration to configure the
* SAML2 relying party metadata endpoint
* @see [Saml2MetadataDsl]
* @since 6.1
*/
fun saml2Metadata(saml2MetadataConfiguration: Saml2MetadataDsl.() -> Unit) {
val saml2MetadataCustomizer = Saml2MetadataDsl().apply(saml2MetadataConfiguration).get()
this.http.saml2Metadata(saml2MetadataCustomizer)
}
/**
* Allows configuring how an anonymous user is represented.
@ -965,6 +971,36 @@ class HttpSecurityDsl(private val http: HttpSecurity, private val init: HttpSecu
this.http.oidcLogout(oidcLogoutCustomizer)
}
/**
* Configures One-Time Token Login Support.
*
* Example:
*
* ```
* @Configuration
* @EnableWebSecurity
* class SecurityConfig {
*
* @Bean
* fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
* http {
* oneTimeTokenLogin {
* generatedOneTimeTokenHandler = MyMagicLinkGeneratedOneTimeTokenHandler()
* }
* }
* return http.build()
* }
* }
*
* ```
* @since 6.4
* @param oneTimeTokenLoginConfiguration custom configuration to configure one-time token login
*/
fun oneTimeTokenLogin(oneTimeTokenLoginConfiguration: OneTimeTokenLoginDsl.() -> Unit) {
val oneTimeTokenLoginCustomizer = OneTimeTokenLoginDsl().apply(oneTimeTokenLoginConfiguration).get()
this.http.oneTimeTokenLogin(oneTimeTokenLoginCustomizer)
}
/**
* Configures Remember Me authentication.
*
@ -1050,7 +1086,7 @@ class HttpSecurityDsl(private val http: HttpSecurity, private val init: HttpSecu
* (i.e. known) with Spring Security.
*/
@Suppress("DEPRECATION")
inline fun <reified T: Filter> addFilterAt(filter: Filter) {
inline fun <reified T : Filter> addFilterAt(filter: Filter) {
this.addFilterAt(filter, T::class.java)
}
@ -1109,7 +1145,7 @@ class HttpSecurityDsl(private val http: HttpSecurity, private val init: HttpSecu
* (i.e. known) with Spring Security.
*/
@Suppress("DEPRECATION")
inline fun <reified T: Filter> addFilterAfter(filter: Filter) {
inline fun <reified T : Filter> addFilterAfter(filter: Filter) {
this.addFilterAfter(filter, T::class.java)
}
@ -1168,7 +1204,7 @@ class HttpSecurityDsl(private val http: HttpSecurity, private val init: HttpSecu
* (i.e. known) with Spring Security.
*/
@Suppress("DEPRECATION")
inline fun <reified T: Filter> addFilterBefore(filter: Filter) {
inline fun <reified T : Filter> addFilterBefore(filter: Filter) {
this.addFilterBefore(filter, T::class.java)
}

View File

@ -0,0 +1,83 @@
/*
* Copyright 2002-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.springframework.security.config.annotation.web
import org.springframework.security.authentication.AuthenticationProvider
import org.springframework.security.authentication.ott.OneTimeTokenService
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configurers.ott.OneTimeTokenLoginConfigurer
import org.springframework.security.web.authentication.AuthenticationConverter
import org.springframework.security.web.authentication.AuthenticationFailureHandler
import org.springframework.security.web.authentication.AuthenticationSuccessHandler
import org.springframework.security.web.authentication.ott.GeneratedOneTimeTokenHandler
/**
* A Kotlin DSL to configure [HttpSecurity] OAuth 2.0 login using idiomatic Kotlin code.
*
* @author Max Batischev
* @since 6.4
* @property oneTimeTokenService configures the [OneTimeTokenService] used to generate and consume
* @property authenticationConverter Use this [AuthenticationConverter] when converting incoming requests to an authentication
* @property authenticationFailureHandler the [AuthenticationFailureHandler] to use when authentication
* @property authenticationSuccessHandler the [AuthenticationSuccessHandler] to be used
* @property defaultSubmitPageUrl sets the URL that the default submit page will be generated
* @property showDefaultSubmitPage configures whether the default one-time token submit page should be shown
* @property loginProcessingUrl the URL to process the login request
* @property generateTokenUrl the URL that a One-Time Token generate request will be processed
* @property generatedOneTimeTokenHandler the strategy to be used to handle generated one-time tokens
* @property authenticationProvider the [AuthenticationProvider] to use when authenticating the user
*/
@SecurityMarker
class OneTimeTokenLoginDsl {
var oneTimeTokenService: OneTimeTokenService? = null
var authenticationConverter: AuthenticationConverter? = null
var authenticationFailureHandler: AuthenticationFailureHandler? = null
var authenticationSuccessHandler: AuthenticationSuccessHandler? = null
var defaultSubmitPageUrl: String? = null
var loginProcessingUrl: String? = null
var generateTokenUrl: String? = null
var showDefaultSubmitPage: Boolean? = true
var generatedOneTimeTokenHandler: GeneratedOneTimeTokenHandler? = null
var authenticationProvider: AuthenticationProvider? = null
internal fun get(): (OneTimeTokenLoginConfigurer<HttpSecurity>) -> Unit {
return { oneTimeTokenLoginConfigurer ->
oneTimeTokenService?.also { oneTimeTokenLoginConfigurer.oneTimeTokenService(oneTimeTokenService) }
authenticationConverter?.also { oneTimeTokenLoginConfigurer.authenticationConverter(authenticationConverter) }
authenticationFailureHandler?.also {
oneTimeTokenLoginConfigurer.authenticationFailureHandler(
authenticationFailureHandler
)
}
authenticationSuccessHandler?.also {
oneTimeTokenLoginConfigurer.authenticationSuccessHandler(
authenticationSuccessHandler
)
}
defaultSubmitPageUrl?.also { oneTimeTokenLoginConfigurer.defaultSubmitPageUrl(defaultSubmitPageUrl) }
showDefaultSubmitPage?.also { oneTimeTokenLoginConfigurer.showDefaultSubmitPage(showDefaultSubmitPage!!) }
loginProcessingUrl?.also { oneTimeTokenLoginConfigurer.loginProcessingUrl(loginProcessingUrl) }
generateTokenUrl?.also { oneTimeTokenLoginConfigurer.generateTokenUrl(generateTokenUrl) }
generatedOneTimeTokenHandler?.also {
oneTimeTokenLoginConfigurer.generatedOneTimeTokenHandler(
generatedOneTimeTokenHandler
)
}
authenticationProvider?.also { oneTimeTokenLoginConfigurer.authenticationProvider(authenticationProvider) }
}
}
}

View File

@ -0,0 +1,179 @@
/*
* Copyright 2002-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.springframework.security.config.annotation.web
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Import
import org.springframework.security.authentication.ott.OneTimeToken
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.test.SpringTestContext
import org.springframework.security.config.test.SpringTestContextExtension
import org.springframework.security.core.userdetails.PasswordEncodedUser
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.provisioning.InMemoryUserDetailsManager
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler
import org.springframework.security.web.authentication.ott.GeneratedOneTimeTokenHandler
import org.springframework.security.web.authentication.ott.RedirectGeneratedOneTimeTokenHandler
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
import org.springframework.test.web.servlet.result.MockMvcResultMatchers
/**
* Tests for [OneTimeTokenLoginDsl]
*
* @author Max Batischev
*/
@ExtendWith(SpringTestContextExtension::class)
class OneTimeTokenLoginDslTests {
@JvmField
val spring = SpringTestContext(this)
@Autowired
private lateinit var mockMvc: MockMvc
@Test
fun `oneTimeToken when correct token then can authenticate`() {
spring.register(OneTimeTokenConfig::class.java).autowire()
this.mockMvc.perform(
MockMvcRequestBuilders.post("/ott/generate").param("username", "user")
.with(SecurityMockMvcRequestPostProcessors.csrf())
).andExpectAll(
MockMvcResultMatchers
.status()
.isFound(),
MockMvcResultMatchers
.redirectedUrl("/login/ott")
)
val token = TestGeneratedOneTimeTokenHandler.lastToken?.tokenValue
this.mockMvc.perform(
MockMvcRequestBuilders.post("/login/ott").param("token", token)
.with(SecurityMockMvcRequestPostProcessors.csrf())
)
.andExpectAll(
MockMvcResultMatchers.status().isFound(),
MockMvcResultMatchers.redirectedUrl("/"),
SecurityMockMvcResultMatchers.authenticated()
)
}
@Test
fun `oneTimeToken when different authentication urls then can authenticate`() {
spring.register(OneTimeTokenDifferentUrlsConfig::class.java).autowire()
this.mockMvc.perform(
MockMvcRequestBuilders.post("/generateurl").param("username", "user")
.with(SecurityMockMvcRequestPostProcessors.csrf())
)
.andExpectAll(MockMvcResultMatchers.status().isFound(), MockMvcResultMatchers.redirectedUrl("/redirected"))
val token = TestGeneratedOneTimeTokenHandler.lastToken?.tokenValue
this.mockMvc.perform(
MockMvcRequestBuilders.post("/loginprocessingurl").param("token", token)
.with(SecurityMockMvcRequestPostProcessors.csrf())
)
.andExpectAll(
MockMvcResultMatchers.status().isFound(),
MockMvcResultMatchers.redirectedUrl("/authenticated"),
SecurityMockMvcResultMatchers.authenticated()
)
}
@Configuration
@EnableWebSecurity
@Import(UserDetailsServiceConfig::class)
open class OneTimeTokenConfig {
@Bean
open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
// @formatter:off
http {
authorizeHttpRequests {
authorize(anyRequest, authenticated)
}
oneTimeTokenLogin {
generatedOneTimeTokenHandler = TestGeneratedOneTimeTokenHandler()
}
}
// @formatter:on
return http.build()
}
}
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
@Import(UserDetailsServiceConfig::class)
open class OneTimeTokenDifferentUrlsConfig {
@Bean
open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
// @formatter:off
http {
authorizeHttpRequests {
authorize(anyRequest, authenticated)
}
oneTimeTokenLogin {
generateTokenUrl = "/generateurl"
generatedOneTimeTokenHandler = TestGeneratedOneTimeTokenHandler("/redirected")
loginProcessingUrl = "/loginprocessingurl"
authenticationSuccessHandler = SimpleUrlAuthenticationSuccessHandler("/authenticated")
}
}
// @formatter:on
return http.build()
}
}
@Configuration(proxyBeanMethods = false)
open class UserDetailsServiceConfig {
@Bean
open fun userDetailsService(): UserDetailsService =
InMemoryUserDetailsManager(PasswordEncodedUser.user(), PasswordEncodedUser.admin())
}
private class TestGeneratedOneTimeTokenHandler : GeneratedOneTimeTokenHandler {
private val delegate: GeneratedOneTimeTokenHandler
constructor() {
this.delegate = RedirectGeneratedOneTimeTokenHandler("/login/ott")
}
constructor(redirectUrl: String?) {
this.delegate = RedirectGeneratedOneTimeTokenHandler(redirectUrl)
}
override fun handle(request: HttpServletRequest, response: HttpServletResponse, oneTimeToken: OneTimeToken) {
lastToken = oneTimeToken
delegate.handle(request, response, oneTimeToken)
}
companion object {
var lastToken: OneTimeToken? = null
}
}
}

View File

@ -77,11 +77,11 @@ import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
@Component <1>
public class MagicLinkGeneratedOneTimeTokenSuccessHandler implements GeneratedOneTimeTokenSuccessHandler {
public class MagicLinkGeneratedOneTimeTokenSuccessHandler implements GeneratedOneTimeTokenHandler {
private final MailSender mailSender;
private final GeneratedOneTimeTokenSuccessHandler redirectHandler = new RedirectGeneratedOneTimeTokenSuccessHandler("/ott/sent");
private final GeneratedOneTimeTokenHandler redirectHandler = new RedirectGeneratedOneTimeTokenHandler("/ott/sent");
// constructor omitted
@ -115,6 +115,65 @@ class PageController {
}
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
open fun filterChain(
http: HttpSecurity,
magicLinkSender: MagicLinkGeneratedOneTimeTokenSuccessHandler?
): SecurityFilterChain {
http{
formLogin {}
oneTimeTokenLogin { }
}
return http.build()
}
}
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
@Component (1)
class MagicLinkGeneratedOneTimeTokenSuccessHandler(
private val mailSender: MailSender,
private val redirectHandler: GeneratedOneTimeTokenHandler = RedirectGeneratedOneTimeTokenHandler("/ott/sent")
) : GeneratedOneTimeTokenHandler {
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"
}
}
----
======
@ -122,7 +181,7 @@ class PageController {
<2> Create a login processing URL with the `token` as a query param
<3> Retrieve the user's email based on the username
<4> Use the `JavaMailSender` API to send the email to the user with the magic link
<5> Use the `RedirectGeneratedOneTimeTokenSuccessHandler` to perform a redirect to your desired URL
<5> Use the `RedirectGeneratedOneTimeTokenHandler` to perform a redirect to your desired URL
The email content will look similar to:
@ -161,10 +220,37 @@ public class SecurityConfig {
}
@Component
public class MagicLinkGeneratedOneTimeTokenSuccessHandler implements GeneratedOneTimeTokenSuccessHandler {
public class MagicLinkGeneratedOneTimeTokenSuccessHandler implements GeneratedOneTimeTokenHandler {
// ...
}
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
http {
//...
formLogin { }
oneTimeTokenLogin {
generateTokenUrl = "/ott/my-generate-url"
}
}
return http.build()
}
}
@Component
class MagicLinkGeneratedOneTimeTokenSuccessHandler : GeneratedOneTimeTokenHandler {
// ...
}
----
======
[[changing-submit-page-url]]
@ -202,6 +288,33 @@ public class MagicLinkGeneratedOneTimeTokenSuccessHandler implements GeneratedOn
// ...
}
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
http {
//...
formLogin { }
oneTimeTokenLogin {
submitPageUrl = "/ott/submit"
}
}
return http.build()
}
}
@Component
class MagicLinkGeneratedOneTimeTokenSuccessHandler : GeneratedOneTimeTokenHandler {
// ...
}
----
======
[[disabling-default-submit-page]]
@ -251,6 +364,45 @@ public class MagicLinkGeneratedOneTimeTokenSuccessHandler implements GeneratedOn
// ...
}
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
@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 MagicLinkGeneratedOneTimeTokenSuccessHandler : GeneratedOneTimeTokenHandler {
// ...
}
----
======