diff --git a/config/src/main/kotlin/org/springframework/security/config/web/servlet/HttpSecurityDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/servlet/HttpSecurityDsl.kt index 137d459ce1..f368522abf 100644 --- a/config/src/main/kotlin/org/springframework/security/config/web/servlet/HttpSecurityDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/web/servlet/HttpSecurityDsl.kt @@ -644,6 +644,33 @@ class HttpSecurityDsl(private val http: HttpSecurity, private val init: HttpSecu this.http.oauth2ResourceServer(oauth2ResourceServerCustomizer) } + /** + * Configures Remember Me authentication. + * + * Example: + * + * ``` + * @EnableWebSecurity + * class SecurityConfig : WebSecurityConfigurerAdapter() { + * + * override fun configure(http: HttpSecurity) { + * http { + * rememberMe { + * tokenValiditySeconds = 604800 + * } + * } + * } + * } + * ``` + * + * @param rememberMeConfiguration custom configuration to configure remember me + * @see [RememberMeDsl] + */ + fun rememberMe(rememberMeConfiguration: RememberMeDsl.() -> Unit) { + val rememberMeCustomizer = RememberMeDsl().apply(rememberMeConfiguration).get() + this.http.rememberMe(rememberMeCustomizer) + } + /** * Adds the [Filter] at the location of the specified [Filter] class. * diff --git a/config/src/main/kotlin/org/springframework/security/config/web/servlet/RememberMeDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/servlet/RememberMeDsl.kt new file mode 100644 index 0000000000..db69d5d4f6 --- /dev/null +++ b/config/src/main/kotlin/org/springframework/security/config/web/servlet/RememberMeDsl.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2002-2021 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.web.servlet + +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configurers.RememberMeConfigurer +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.web.authentication.AuthenticationSuccessHandler +import org.springframework.security.web.authentication.RememberMeServices +import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository + +/** + * A Kotlin DSL to configure [HttpSecurity] Remember me using idiomatic Kotlin code. + * + * @author Ivan Pavlov + * @property authenticationSuccessHandler the [AuthenticationSuccessHandler] used after + * authentication success + * @property key the key to identify tokens + * @property rememberMeServices the [RememberMeServices] to use + * @property rememberMeParameter the HTTP parameter used to indicate to remember + * the user at time of login. Defaults to 'remember-me' + * @property rememberMeCookieName the name of cookie which store the token for + * remember me authentication. Defaults to 'remember-me' + * @property rememberMeCookieDomain the domain name within which the remember me cookie + * is visible + * @property tokenRepository the [PersistentTokenRepository] to use. Defaults to + * [org.springframework.security.web.authentication.rememberme.TokenBasedRememberMeServices] instead + * @property userDetailsService the [UserDetailsService] used to look up the UserDetails + * when a remember me token is valid + * @property tokenValiditySeconds how long (in seconds) a token is valid for. + * Defaults to 2 weeks + * @property useSecureCookie whether the cookie should be flagged as secure or not + * @property alwaysRemember whether the cookie should always be created even if + * the remember-me parameter is not set. Defaults to `false` + */ +@SecurityMarker +class RememberMeDsl { + var authenticationSuccessHandler: AuthenticationSuccessHandler? = null + var key: String? = null + var rememberMeServices: RememberMeServices? = null + var rememberMeParameter: String? = null + var rememberMeCookieName: String? = null + var rememberMeCookieDomain: String? = null + var tokenRepository: PersistentTokenRepository? = null + var userDetailsService: UserDetailsService? = null + var tokenValiditySeconds: Int? = null + var useSecureCookie: Boolean? = null + var alwaysRemember: Boolean? = null + + internal fun get(): (RememberMeConfigurer) -> Unit { + return { rememberMe -> + authenticationSuccessHandler?.also { rememberMe.authenticationSuccessHandler(authenticationSuccessHandler) } + key?.also { rememberMe.key(key) } + rememberMeServices?.also { rememberMe.rememberMeServices(rememberMeServices) } + rememberMeParameter?.also { rememberMe.rememberMeParameter(rememberMeParameter) } + rememberMeCookieName?.also { rememberMe.rememberMeCookieName(rememberMeCookieName) } + rememberMeCookieDomain?.also { rememberMe.rememberMeCookieDomain(rememberMeCookieDomain) } + tokenRepository?.also { rememberMe.tokenRepository(tokenRepository) } + userDetailsService?.also { rememberMe.userDetailsService(userDetailsService) } + tokenValiditySeconds?.also { rememberMe.tokenValiditySeconds(tokenValiditySeconds!!) } + useSecureCookie?.also { rememberMe.useSecureCookie(useSecureCookie!!) } + alwaysRemember?.also { rememberMe.alwaysRemember(alwaysRemember!!) } + } + } +} diff --git a/config/src/test/kotlin/org/springframework/security/config/web/servlet/RememberMeDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/web/servlet/RememberMeDslTests.kt new file mode 100644 index 0000000000..38fa02c26a --- /dev/null +++ b/config/src/test/kotlin/org/springframework/security/config/web/servlet/RememberMeDslTests.kt @@ -0,0 +1,564 @@ +/* + * Copyright 2002-2021 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.web.servlet + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.jupiter.api.fail +import org.mockito.BDDMockito.given +import org.mockito.Mockito.* +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.core.annotation.Order +import org.springframework.mock.web.MockHttpSession +import org.springframework.security.authentication.RememberMeAuthenticationToken +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter +import org.springframework.security.config.test.SpringTestRule +import org.springframework.security.core.Authentication +import org.springframework.security.core.authority.AuthorityUtils +import org.springframework.security.core.userdetails.PasswordEncodedUser +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf +import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers +import org.springframework.security.web.authentication.AuthenticationSuccessHandler +import org.springframework.security.web.authentication.RememberMeServices +import org.springframework.security.web.authentication.rememberme.AbstractRememberMeServices +import org.springframework.security.web.authentication.rememberme.PersistentRememberMeToken +import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository +import org.springframework.security.web.util.matcher.AntPathRequestMatcher +import org.springframework.test.web.servlet.MockHttpServletRequestDsl +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.post +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +/** + * Tests for [RememberMeDsl] + * + * @author Ivan Pavlov + */ +internal class RememberMeDslTests { + @Rule + @JvmField + val spring = SpringTestRule() + + @Autowired + lateinit var mockMvc: MockMvc + + @Test + fun `Remember Me login when remember me true then responds with remember me cookie`() { + this.spring.register(RememberMeConfig::class.java).autowire() + mockMvc.post("/login") + { + loginRememberMeRequest() + }.andExpect { + cookie { + exists("remember-me") + } + } + } + + @Test + fun `Remember Me get when remember me cookie then authentication is remember me authentication token`() { + this.spring.register(RememberMeConfig::class.java).autowire() + val mvcResult = mockMvc.post("/login") + { + loginRememberMeRequest() + }.andReturn() + val rememberMeCookie = mvcResult.response.getCookie("remember-me") + ?: fail { "Missing remember-me cookie in login response" } + mockMvc.get("/abc") + { + cookie(rememberMeCookie) + }.andExpect { + val rememberMeAuthentication = SecurityMockMvcResultMatchers.authenticated() + .withAuthentication { assertThat(it).isInstanceOf(RememberMeAuthenticationToken::class.java) } + match(rememberMeAuthentication) + } + } + + @Test + fun `Remember Me logout when remember me cookie then authentication is remember me cookie expired`() { + this.spring.register(RememberMeConfig::class.java).autowire() + val mvcResult = mockMvc.post("/login") + { + loginRememberMeRequest() + }.andReturn() + val rememberMeCookie = mvcResult.response.getCookie("remember-me") + ?: fail { "Missing remember-me cookie in login response" } + val mockSession = mvcResult.request.session as MockHttpSession + mockMvc.post("/logout") + { + with(csrf()) + cookie(rememberMeCookie) + session = mockSession + }.andExpect { + status { isFound() } + redirectedUrl("/login?logout") + cookie { + maxAge("remember-me", 0) + } + } + } + + @Test + fun `Remember Me get when remember me cookie and logged out then redirects to login`() { + this.spring.register(RememberMeConfig::class.java).autowire() + mockMvc.perform(formLogin()) + val mvcResult = mockMvc.post("/login") + { + loginRememberMeRequest() + }.andReturn() + val rememberMeCookie = mvcResult.response.getCookie("remember-me") + ?: fail { "Missing remember-me cookie in login request" } + val mockSession = mvcResult.request.session as MockHttpSession + val logoutMvcResult = mockMvc.post("/logout") + { + with(csrf()) + cookie(rememberMeCookie) + session = mockSession + }.andReturn() + val expiredRememberMeCookie = logoutMvcResult.response.getCookie("remember-me") + ?: fail { "Missing remember-me cookie in logout response" } + mockMvc.get("/abc") + { + with(csrf()) + cookie(expiredRememberMeCookie) + }.andExpect { + status { isFound() } + redirectedUrl("http://localhost/login") + } + } + + @Test + fun `Remember Me login when remember me domain then remember me cookie has domain`() { + this.spring.register(RememberMeDomainConfig::class.java).autowire() + mockMvc.post("/login") + { + loginRememberMeRequest() + }.andExpect { + cookie { + domain("remember-me", "spring.io") + } + } + } + + @Test + fun `Remember Me when remember me services then uses`() { + RememberMeServicesRefConfig.REMEMBER_ME_SERVICES = mock(RememberMeServices::class.java) + this.spring.register(RememberMeServicesRefConfig::class.java).autowire() + mockMvc.get("/") + verify(RememberMeServicesRefConfig.REMEMBER_ME_SERVICES).autoLogin(any(HttpServletRequest::class.java), + any(HttpServletResponse::class.java)) + mockMvc.post("/login") { + with(csrf()) + } + verify(RememberMeServicesRefConfig.REMEMBER_ME_SERVICES).loginFail(any(HttpServletRequest::class.java), + any(HttpServletResponse::class.java)) + mockMvc.post("/login") { + loginRememberMeRequest() + } + verify(RememberMeServicesRefConfig.REMEMBER_ME_SERVICES).loginSuccess(any(HttpServletRequest::class.java), + any(HttpServletResponse::class.java), any(Authentication::class.java)) + } + + @Test + fun `Remember Me when authentication success handler then uses`() { + RememberMeSuccessHandlerConfig.SUCCESS_HANDLER = mock(AuthenticationSuccessHandler::class.java) + this.spring.register(RememberMeSuccessHandlerConfig::class.java).autowire() + val mvcResult = mockMvc.post("/login") { + loginRememberMeRequest() + }.andReturn() + verifyNoInteractions(RememberMeSuccessHandlerConfig.SUCCESS_HANDLER) + val rememberMeCookie = mvcResult.response.getCookie("remember-me") + ?: fail { "Missing remember-me cookie in login response" } + mockMvc.get("/abc") { + cookie(rememberMeCookie) + } + verify(RememberMeSuccessHandlerConfig.SUCCESS_HANDLER).onAuthenticationSuccess( + any(HttpServletRequest::class.java), any(HttpServletResponse::class.java), + any(Authentication::class.java)) + } + + @Test + fun `Remember Me when key then remember me works only for matching routes`() { + this.spring.register(WithoutKeyConfig::class.java, KeyConfig::class.java).autowire() + val withoutKeyMvcResult = mockMvc.post("/without-key/login") { + loginRememberMeRequest() + }.andReturn() + val withoutKeyRememberMeCookie = withoutKeyMvcResult.response.getCookie("remember-me") + ?: fail { "Missing remember-me cookie in without key login response" } + mockMvc.get("/abc") { + cookie(withoutKeyRememberMeCookie) + }.andExpect { + status { isFound() } + redirectedUrl("http://localhost/login") + } + val keyMvcResult = mockMvc.post("/login") { + loginRememberMeRequest() + }.andReturn() + val keyRememberMeCookie = keyMvcResult.response.getCookie("remember-me") + ?: fail { "Missing remember-me cookie in key login response" } + mockMvc.get("/abc") { + cookie(keyRememberMeCookie) + }.andExpect { + status { isNotFound() } + } + } + + @Test + fun `Remember Me when token repository then uses`() { + RememberMeTokenRepositoryConfig.TOKEN_REPOSITORY = mock(PersistentTokenRepository::class.java) + this.spring.register(RememberMeTokenRepositoryConfig::class.java).autowire() + mockMvc.post("/login") { + loginRememberMeRequest() + } + verify(RememberMeTokenRepositoryConfig.TOKEN_REPOSITORY).createNewToken( + any(PersistentRememberMeToken::class.java)) + } + + @Test + fun `Remember Me when token validity seconds then cookie max age`() { + this.spring.register(RememberMeTokenValidityConfig::class.java).autowire() + mockMvc.post("/login") { + loginRememberMeRequest() + }.andExpect { + cookie { + maxAge("remember-me", 42) + } + } + } + + @Test + fun `Remember Me when using defaults then cookie max age`() { + this.spring.register(RememberMeConfig::class.java).autowire() + mockMvc.post("/login") { + loginRememberMeRequest() + }.andExpect { + cookie { + maxAge("remember-me", AbstractRememberMeServices.TWO_WEEKS_S) + } + } + } + + @Test + fun `Remember Me when use secure cookie then cookie secure`() { + this.spring.register(RememberMeUseSecureCookieConfig::class.java).autowire() + mockMvc.post("/login") { + loginRememberMeRequest() + }.andExpect { + cookie { + secure("remember-me", true) + } + } + } + + @Test + fun `Remember Me when using defaults then cookie secure`() { + this.spring.register(RememberMeConfig::class.java).autowire() + mockMvc.post("/login") { + loginRememberMeRequest() + secure = true + }.andExpect { + cookie { + secure("remember-me", true) + } + } + } + + @Test + fun `Remember Me when parameter then responds with remember me cookie`() { + this.spring.register(RememberMeParameterConfig::class.java).autowire() + mockMvc.post("/login") { + loginRememberMeRequest("rememberMe") + }.andExpect { + cookie { + exists("remember-me") + } + } + } + + @Test + fun `Remember Me when cookie name then responds with remember me cookie with such name`() { + this.spring.register(RememberMeCookieNameConfig::class.java).autowire() + mockMvc.post("/login") { + loginRememberMeRequest() + }.andExpect { + cookie { + exists("rememberMe") + } + } + } + + @Test + fun `Remember Me when global user details service then uses`() { + RememberMeDefaultUserDetailsServiceConfig.USER_DETAIL_SERVICE = mock(UserDetailsService::class.java) + this.spring.register(RememberMeDefaultUserDetailsServiceConfig::class.java).autowire() + mockMvc.post("/login") { + loginRememberMeRequest() + } + verify(RememberMeDefaultUserDetailsServiceConfig.USER_DETAIL_SERVICE).loadUserByUsername("user") + } + + @Test + fun `Remember Me when user details service then uses`() { + RememberMeUserDetailsServiceConfig.USER_DETAIL_SERVICE = mock(UserDetailsService::class.java) + this.spring.register(RememberMeUserDetailsServiceConfig::class.java).autowire() + val user = User("user", "password", AuthorityUtils.createAuthorityList("ROLE_USER")) + given(RememberMeUserDetailsServiceConfig.USER_DETAIL_SERVICE.loadUserByUsername("user")).willReturn(user) + mockMvc.post("/login") { + loginRememberMeRequest() + } + verify(RememberMeUserDetailsServiceConfig.USER_DETAIL_SERVICE).loadUserByUsername("user") + } + + @Test + fun `Remember Me when always remember then remembers without HTTP parameter`() { + this.spring.register(RememberMeAlwaysRememberConfig::class.java).autowire() + mockMvc.post("/login") { + loginRememberMeRequest(rememberMeValue = null) + }.andExpect { + cookie { + exists("remember-me") + } + } + } + + private fun MockHttpServletRequestDsl.loginRememberMeRequest(rememberMeParameter: String = "remember-me", + rememberMeValue: Boolean? = true) { + with(csrf()) + param("username", "user") + param("password", "password") + rememberMeValue?.also { + param(rememberMeParameter, rememberMeValue.toString()) + } + } + + abstract class DefaultUserConfig : WebSecurityConfigurerAdapter() { + @Autowired + open fun configureGlobal(auth: AuthenticationManagerBuilder) { + auth.inMemoryAuthentication() + .withUser(PasswordEncodedUser.user()) + } + } + + @EnableWebSecurity + open class RememberMeConfig : DefaultUserConfig() { + override fun configure(http: HttpSecurity) { + http { + authorizeRequests { + authorize(anyRequest, hasRole("USER")) + } + formLogin {} + rememberMe {} + } + } + } + + @EnableWebSecurity + open class RememberMeDomainConfig : DefaultUserConfig() { + override fun configure(http: HttpSecurity) { + http { + authorizeRequests { + authorize(anyRequest, hasRole("USER")) + } + formLogin {} + rememberMe { + rememberMeCookieDomain = "spring.io" + } + } + } + } + + @EnableWebSecurity + open class RememberMeServicesRefConfig : DefaultUserConfig() { + override fun configure(http: HttpSecurity) { + http { + formLogin {} + rememberMe { + rememberMeServices = REMEMBER_ME_SERVICES + } + } + } + + companion object { + lateinit var REMEMBER_ME_SERVICES: RememberMeServices + } + } + + @EnableWebSecurity + open class RememberMeSuccessHandlerConfig : DefaultUserConfig() { + override fun configure(http: HttpSecurity) { + http { + formLogin {} + rememberMe { + authenticationSuccessHandler = SUCCESS_HANDLER + } + } + } + + companion object { + lateinit var SUCCESS_HANDLER: AuthenticationSuccessHandler + } + } + + @EnableWebSecurity + @Order(0) + open class WithoutKeyConfig : DefaultUserConfig() { + override fun configure(http: HttpSecurity) { + http { + securityMatcher(AntPathRequestMatcher("/without-key/**")) + formLogin { + loginProcessingUrl = "/without-key/login" + } + rememberMe {} + } + } + } + + @EnableWebSecurity + open class KeyConfig : DefaultUserConfig() { + override fun configure(http: HttpSecurity) { + http { + authorizeRequests { + authorize(anyRequest, authenticated) + } + formLogin {} + rememberMe { + key = "RememberMeKey" + } + } + } + } + + @EnableWebSecurity + open class RememberMeTokenRepositoryConfig : DefaultUserConfig() { + override fun configure(http: HttpSecurity) { + http { + formLogin {} + rememberMe { + tokenRepository = TOKEN_REPOSITORY + } + } + } + + companion object { + lateinit var TOKEN_REPOSITORY: PersistentTokenRepository + } + } + + @EnableWebSecurity + open class RememberMeTokenValidityConfig : DefaultUserConfig() { + override fun configure(http: HttpSecurity) { + http { + formLogin {} + rememberMe { + tokenValiditySeconds = 42 + } + } + } + } + + @EnableWebSecurity + open class RememberMeUseSecureCookieConfig : DefaultUserConfig() { + override fun configure(http: HttpSecurity) { + http { + formLogin {} + rememberMe { + useSecureCookie = true + } + } + } + } + + @EnableWebSecurity + open class RememberMeParameterConfig : DefaultUserConfig() { + override fun configure(http: HttpSecurity) { + http { + formLogin {} + rememberMe { + rememberMeParameter = "rememberMe" + } + } + } + } + + @EnableWebSecurity + open class RememberMeCookieNameConfig : DefaultUserConfig() { + override fun configure(http: HttpSecurity) { + http { + formLogin {} + rememberMe { + rememberMeCookieName = "rememberMe" + } + } + } + } + + @EnableWebSecurity + open class RememberMeDefaultUserDetailsServiceConfig : DefaultUserConfig() { + override fun configure(http: HttpSecurity) { + http { + formLogin {} + rememberMe {} + } + } + + override fun configure(auth: AuthenticationManagerBuilder) { + auth.userDetailsService(USER_DETAIL_SERVICE) + } + + companion object { + lateinit var USER_DETAIL_SERVICE: UserDetailsService + } + } + + @EnableWebSecurity + open class RememberMeUserDetailsServiceConfig : DefaultUserConfig() { + override fun configure(http: HttpSecurity) { + http { + formLogin {} + rememberMe { + userDetailsService = USER_DETAIL_SERVICE + } + } + } + + companion object { + lateinit var USER_DETAIL_SERVICE: UserDetailsService + } + } + + @EnableWebSecurity + open class RememberMeAlwaysRememberConfig : DefaultUserConfig() { + override fun configure(http: HttpSecurity) { + http { + formLogin {} + rememberMe { + alwaysRemember = true + } + } + } + } + +}