Skip to content

Commit e9fe636

Browse files
franticticktickjzheaux
authored andcommitted
Add Reactive One-Time Token Login Kotlin DSL Support
Closes gh-15887
1 parent 562ba01 commit e9fe636

File tree

3 files changed

+337
-0
lines changed

3 files changed

+337
-0
lines changed

config/src/main/kotlin/org/springframework/security/config/web/server/ServerHttpSecurityDsl.kt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,36 @@ class ServerHttpSecurityDsl(private val http: ServerHttpSecurity, private val in
714714
this.http.sessionManagement(sessionManagementCustomizer)
715715
}
716716

717+
/**
718+
* Configures One-Time Token Login support.
719+
*
720+
* Example:
721+
*
722+
* ```
723+
* @Configuration
724+
* @EnableWebFluxSecurity
725+
* open class SecurityConfig {
726+
*
727+
* @Bean
728+
* open fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
729+
* return http {
730+
* oneTimeTokenLogin {
731+
* tokenGenerationSuccessHandler = MyMagicLinkServerOneTimeTokenGenerationSuccessHandler()
732+
* }
733+
* }
734+
* }
735+
* }
736+
* ```
737+
*
738+
* @param oneTimeTokenLoginConfiguration custom configuration to configure the One-Time Token Login
739+
* @since 6.4
740+
* @see [ServerOneTimeTokenLoginDsl]
741+
*/
742+
fun oneTimeTokenLogin(oneTimeTokenLoginConfiguration: ServerOneTimeTokenLoginDsl.()-> Unit){
743+
val oneTimeTokenLoginCustomizer = ServerOneTimeTokenLoginDsl().apply(oneTimeTokenLoginConfiguration).get()
744+
this.http.oneTimeTokenLogin(oneTimeTokenLoginCustomizer)
745+
}
746+
717747
/**
718748
* Apply all configurations to the provided [ServerHttpSecurity]
719749
*/
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.config.web.server
18+
19+
import org.springframework.security.authentication.ReactiveAuthenticationManager
20+
import org.springframework.security.authentication.ott.reactive.ReactiveOneTimeTokenService
21+
import org.springframework.security.web.server.authentication.ServerAuthenticationConverter
22+
import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler
23+
import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler
24+
import org.springframework.security.web.server.authentication.ott.ServerOneTimeTokenGenerationSuccessHandler
25+
import org.springframework.security.web.server.context.ServerSecurityContextRepository
26+
27+
/**
28+
* A Kotlin DSL to configure [ServerHttpSecurity] form login using idiomatic Kotlin code.
29+
*
30+
* @author Max Batischev
31+
* @since 6.4
32+
* @property tokenService configures the [ReactiveOneTimeTokenService] used to generate and consume
33+
* @property authenticationManager configures the [ReactiveAuthenticationManager] used to generate and consume
34+
* @property authenticationConverter Use this [ServerAuthenticationConverter] when converting incoming requests to an authentication
35+
* @property authenticationFailureHandler the [ServerAuthenticationFailureHandler] to use when authentication
36+
* @property authenticationSuccessHandler the [ServerAuthenticationSuccessHandler] to be used
37+
* @property defaultSubmitPageUrl sets the URL that the default submit page will be generated
38+
* @property showDefaultSubmitPage configures whether the default one-time token submit page should be shown
39+
* @property loginProcessingUrl the URL to process the login request
40+
* @property tokenGeneratingUrl the URL that a One-Time Token generate request will be processed
41+
* @property tokenGenerationSuccessHandler the strategy to be used to handle generated one-time tokens
42+
* @property securityContextRepository the [ServerSecurityContextRepository] used to save the [Authentication]. For the [SecurityContext] to be loaded on subsequent requests the [ReactorContextWebFilter] must be configured to be able to load the value (they are not implicitly linked).
43+
*/
44+
@ServerSecurityMarker
45+
class ServerOneTimeTokenLoginDsl {
46+
var authenticationManager: ReactiveAuthenticationManager? = null
47+
var tokenService: ReactiveOneTimeTokenService? = null
48+
var authenticationConverter: ServerAuthenticationConverter? = null
49+
var authenticationFailureHandler: ServerAuthenticationFailureHandler? = null
50+
var authenticationSuccessHandler: ServerAuthenticationSuccessHandler? = null
51+
var tokenGenerationSuccessHandler: ServerOneTimeTokenGenerationSuccessHandler? = null
52+
var securityContextRepository: ServerSecurityContextRepository? = null
53+
var defaultSubmitPageUrl: String? = null
54+
var loginProcessingUrl: String? = null
55+
var tokenGeneratingUrl: String? = null
56+
var showDefaultSubmitPage: Boolean? = true
57+
58+
internal fun get(): (ServerHttpSecurity.OneTimeTokenLoginSpec) -> Unit {
59+
return { oneTimeTokenLogin ->
60+
authenticationManager?.also { oneTimeTokenLogin.authenticationManager(authenticationManager) }
61+
tokenService?.also { oneTimeTokenLogin.tokenService(tokenService) }
62+
authenticationConverter?.also { oneTimeTokenLogin.authenticationConverter(authenticationConverter) }
63+
authenticationFailureHandler?.also {
64+
oneTimeTokenLogin.authenticationFailureHandler(
65+
authenticationFailureHandler
66+
)
67+
}
68+
authenticationSuccessHandler?.also {
69+
oneTimeTokenLogin.authenticationSuccessHandler(
70+
authenticationSuccessHandler
71+
)
72+
}
73+
securityContextRepository?.also { oneTimeTokenLogin.securityContextRepository(securityContextRepository) }
74+
defaultSubmitPageUrl?.also { oneTimeTokenLogin.defaultSubmitPageUrl(defaultSubmitPageUrl) }
75+
showDefaultSubmitPage?.also { oneTimeTokenLogin.showDefaultSubmitPage(showDefaultSubmitPage!!) }
76+
loginProcessingUrl?.also { oneTimeTokenLogin.loginProcessingUrl(loginProcessingUrl) }
77+
tokenGeneratingUrl?.also { oneTimeTokenLogin.tokenGeneratingUrl(tokenGeneratingUrl) }
78+
tokenGenerationSuccessHandler?.also {
79+
oneTimeTokenLogin.tokenGenerationSuccessHandler(
80+
tokenGenerationSuccessHandler
81+
)
82+
}
83+
}
84+
}
85+
}
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.config.web.server
18+
19+
import org.junit.jupiter.api.Test
20+
import org.junit.jupiter.api.extension.ExtendWith
21+
import reactor.core.publisher.Mono
22+
23+
import org.springframework.beans.factory.annotation.Autowired
24+
import org.springframework.context.annotation.Bean
25+
import org.springframework.context.annotation.Configuration
26+
import org.springframework.context.annotation.Import
27+
import org.springframework.context.ApplicationContext
28+
import org.springframework.http.MediaType
29+
import org.springframework.security.authentication.ott.OneTimeToken
30+
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity
31+
import org.springframework.security.config.test.SpringTestContext
32+
import org.springframework.security.config.test.SpringTestContextExtension
33+
import org.springframework.security.core.userdetails.MapReactiveUserDetailsService
34+
import org.springframework.security.core.userdetails.ReactiveUserDetailsService
35+
import org.springframework.security.core.userdetails.User
36+
import org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers
37+
import org.springframework.security.web.server.SecurityWebFilterChain
38+
import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler
39+
import org.springframework.security.web.server.authentication.ott.ServerOneTimeTokenGenerationSuccessHandler
40+
import org.springframework.security.web.server.authentication.ott.ServerRedirectOneTimeTokenGenerationSuccessHandler
41+
import org.springframework.test.web.reactive.server.WebTestClient
42+
import org.springframework.web.reactive.config.EnableWebFlux
43+
import org.springframework.web.reactive.function.BodyInserters
44+
import org.springframework.web.server.ServerWebExchange
45+
import org.springframework.web.util.UriBuilder
46+
47+
/**
48+
* Tests for [ServerOneTimeTokenLoginDsl]
49+
*
50+
* @author Max Batischev
51+
*/
52+
@ExtendWith(SpringTestContextExtension::class)
53+
class ServerOneTimeTokenLoginDslTests {
54+
@JvmField
55+
val spring = SpringTestContext(this)
56+
57+
private lateinit var client: WebTestClient
58+
59+
@Autowired
60+
fun setup(context: ApplicationContext) {
61+
this.client = WebTestClient
62+
.bindToApplicationContext(context)
63+
.configureClient()
64+
.build()
65+
}
66+
67+
@Test
68+
fun `oneTimeToken when correct token then can authenticate`() {
69+
spring.register(OneTimeTokenConfig::class.java).autowire()
70+
71+
// @formatter:off
72+
client.mutateWith(SecurityMockServerConfigurers.csrf())
73+
.post()
74+
.uri{ uriBuilder: UriBuilder -> uriBuilder
75+
.path("/ott/generate")
76+
.build()
77+
}
78+
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
79+
.body(BodyInserters.fromFormData("username", "user"))
80+
.exchange()
81+
.expectStatus()
82+
.is3xxRedirection()
83+
.expectHeader().valueEquals("Location", "/login/ott")
84+
85+
client.mutateWith(SecurityMockServerConfigurers.csrf())
86+
.post()
87+
.uri{ uriBuilder:UriBuilder -> uriBuilder
88+
.path("/ott/generate")
89+
.build()
90+
}
91+
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
92+
.body(BodyInserters.fromFormData("username", "user"))
93+
.exchange()
94+
.expectStatus()
95+
.is3xxRedirection()
96+
.expectHeader().valueEquals("Location", "/login/ott")
97+
98+
val token = TestServerOneTimeTokenGenerationSuccessHandler.lastToken?.tokenValue
99+
100+
client.mutateWith(SecurityMockServerConfigurers.csrf())
101+
.post()
102+
.uri{ uriBuilder:UriBuilder -> uriBuilder
103+
.path("/login/ott")
104+
.queryParam("token", token)
105+
.build()
106+
}
107+
.exchange()
108+
.expectStatus()
109+
.is3xxRedirection()
110+
.expectHeader().valueEquals("Location", "/")
111+
// @formatter:on
112+
}
113+
114+
@Test
115+
fun `oneTimeToken when different authentication urls then can authenticate`() {
116+
spring.register(OneTimeTokenDifferentUrlsConfig::class.java).autowire()
117+
118+
// @formatter:off
119+
client.mutateWith(SecurityMockServerConfigurers.csrf())
120+
.post()
121+
.uri{ uriBuilder: UriBuilder -> uriBuilder
122+
.path("/generateurl")
123+
.build()
124+
}
125+
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
126+
.body(BodyInserters.fromFormData("username", "user"))
127+
.exchange()
128+
.expectStatus()
129+
.is3xxRedirection()
130+
.expectHeader().valueEquals("Location", "/redirected")
131+
132+
val token = TestServerOneTimeTokenGenerationSuccessHandler.lastToken?.tokenValue
133+
134+
client.mutateWith(SecurityMockServerConfigurers.csrf())
135+
.post()
136+
.uri{ uriBuilder: UriBuilder -> uriBuilder
137+
.path("/loginprocessingurl")
138+
.build()
139+
}
140+
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
141+
.body(BodyInserters.fromFormData("token", token!!))
142+
.exchange()
143+
.expectStatus()
144+
.is3xxRedirection()
145+
.expectHeader().valueEquals("Location", "/authenticated")
146+
// @formatter:on
147+
}
148+
149+
@Configuration
150+
@EnableWebFlux
151+
@EnableWebFluxSecurity
152+
@Import(UserDetailsServiceConfig::class)
153+
open class OneTimeTokenConfig {
154+
155+
@Bean
156+
open fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
157+
// @formatter:off
158+
return http {
159+
authorizeExchange {
160+
authorize(anyExchange, authenticated)
161+
}
162+
oneTimeTokenLogin {
163+
tokenGenerationSuccessHandler = TestServerOneTimeTokenGenerationSuccessHandler()
164+
}
165+
}
166+
// @formatter:on
167+
}
168+
}
169+
170+
@Configuration
171+
@EnableWebFlux
172+
@EnableWebFluxSecurity
173+
@Import(UserDetailsServiceConfig::class)
174+
open class OneTimeTokenDifferentUrlsConfig {
175+
176+
@Bean
177+
open fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
178+
// @formatter:off
179+
return http {
180+
authorizeExchange {
181+
authorize(anyExchange, authenticated)
182+
}
183+
oneTimeTokenLogin {
184+
tokenGeneratingUrl = "/generateurl"
185+
tokenGenerationSuccessHandler = TestServerOneTimeTokenGenerationSuccessHandler("/redirected")
186+
loginProcessingUrl = "/loginprocessingurl"
187+
authenticationSuccessHandler = RedirectServerAuthenticationSuccessHandler("/authenticated")
188+
}
189+
}
190+
// @formatter:on
191+
}
192+
}
193+
194+
@Configuration(proxyBeanMethods = false)
195+
open class UserDetailsServiceConfig {
196+
197+
@Bean
198+
open fun userDetailsService(): ReactiveUserDetailsService =
199+
MapReactiveUserDetailsService(User("user", "password", listOf()))
200+
}
201+
202+
private class TestServerOneTimeTokenGenerationSuccessHandler: ServerOneTimeTokenGenerationSuccessHandler {
203+
private var delegate: ServerRedirectOneTimeTokenGenerationSuccessHandler? = null
204+
205+
companion object {
206+
var lastToken: OneTimeToken? = null
207+
}
208+
209+
constructor() {
210+
this.delegate = ServerRedirectOneTimeTokenGenerationSuccessHandler("/login/ott")
211+
}
212+
213+
constructor(redirectUrl: String?) {
214+
this.delegate = ServerRedirectOneTimeTokenGenerationSuccessHandler(redirectUrl)
215+
}
216+
217+
override fun handle(exchange: ServerWebExchange?, oneTimeToken: OneTimeToken?): Mono<Void> {
218+
lastToken = oneTimeToken
219+
return delegate!!.handle(exchange, oneTimeToken)
220+
}
221+
}
222+
}

0 commit comments

Comments
 (0)