From 81e4c7273aec3aceb00d75e47a078ae7f21cac1b Mon Sep 17 00:00:00 2001
From: Max Batischev <Maksim.Batischev@raiffeisen.ru>
Date: Tue, 3 Sep 2024 19:14:24 +0300
Subject: [PATCH] Add One-Time Token Login support to Kotlin DSL

Closes gh-15698
---
 .../config/annotation/web/HttpSecurityDsl.kt  | 124 +++++++-----
 .../annotation/web/OneTimeTokenLoginDsl.kt    |  83 ++++++++
 .../web/OneTimeTokenLoginDslTests.kt          | 179 ++++++++++++++++++
 .../servlet/authentication/onetimetoken.adoc  | 160 +++++++++++++++-
 4 files changed, 498 insertions(+), 48 deletions(-)
 create mode 100644 config/src/main/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDsl.kt
 create mode 100644 config/src/test/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDslTests.kt

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 6e7636c4a9..2b276b4a79 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 <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)
     }
 
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 0000000000..675a40dede
--- /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<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) }
+        }
+    }
+}
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 0000000000..6f28aa9028
--- /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
+        }
+    }
+}
diff --git a/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc b/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc
index 12dcab9220..145e1db0b3 100644
--- a/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc
+++ b/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc
@@ -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 {
+     // ...
+}
+----
 ======