diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDsl.kt index 6e7636c4a92..2b276b4a79e 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDsl.kt @@ -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 > apply(configurer: C, configuration: C.() -> Unit = { }): C { + fun > 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 > with(configurer: C, configuration: C.() -> Unit = { }): HttpSecurity? { + fun > 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 addFilterAt(filter: Filter) { + inline fun 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 addFilterAfter(filter: Filter) { + inline fun 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 addFilterBefore(filter: Filter) { + inline fun addFilterBefore(filter: Filter) { this.addFilterBefore(filter, T::class.java) } diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDsl.kt new file mode 100644 index 00000000000..675a40dede5 --- /dev/null +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDsl.kt @@ -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) -> 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) } + } + } +} diff --git a/config/src/test/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDslTests.kt new file mode 100644 index 00000000000..6f28aa90288 --- /dev/null +++ b/config/src/test/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDslTests.kt @@ -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 + } + } +}