From 0f4ae08fd8630b53ba5c20df1992ce34cda1dd05 Mon Sep 17 00:00:00 2001 From: guqing <38999863+guqing@users.noreply.github.com> Date: Wed, 25 May 2022 17:58:10 +0800 Subject: [PATCH] feat: add logout handler (#2110) * feat: add logout handler * chore: delete unused code * fix: merge conflicts --- .../halo/app/config/WebSecurityConfig.java | 33 +++++++-- ...bstractOAuth2TokenAuthenticationToken.java | 3 +- .../BearerTokenAuthenticationFilter.java | 5 +- .../JwtAccessTokenNonBlockedValidator.java | 57 ++++++++++++++++ .../entrypoint/Oauth2LogoutHandler.java | 44 ++++++++++++ .../security/LogoutHandlerTest.java | 67 +++++++++++++++++++ .../security/TestWebSecurityConfig.java | 35 ++++++++-- 7 files changed, 230 insertions(+), 14 deletions(-) create mode 100644 src/main/java/run/halo/app/identity/authentication/verifier/JwtAccessTokenNonBlockedValidator.java create mode 100644 src/main/java/run/halo/app/identity/entrypoint/Oauth2LogoutHandler.java create mode 100644 src/test/java/run/halo/app/integration/security/LogoutHandlerTest.java diff --git a/src/main/java/run/halo/app/config/WebSecurityConfig.java b/src/main/java/run/halo/app/config/WebSecurityConfig.java index 36d0eada1..e8ff7fd0f 100644 --- a/src/main/java/run/halo/app/config/WebSecurityConfig.java +++ b/src/main/java/run/halo/app/config/WebSecurityConfig.java @@ -24,14 +24,18 @@ import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtTimestampValidator; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.intercept.FilterSecurityInterceptor; -import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; +import org.springframework.security.web.authentication.logout.LogoutFilter; import org.springframework.security.web.context.SecurityContextHolderFilter; import run.halo.app.extension.ExtensionClient; import run.halo.app.identity.authentication.InMemoryOAuth2AuthorizationService; @@ -43,6 +47,7 @@ import run.halo.app.identity.authentication.OAuth2TokenEndpointFilter; import run.halo.app.identity.authentication.ProviderContextFilter; import run.halo.app.identity.authentication.ProviderSettings; import run.halo.app.identity.authentication.verifier.BearerTokenAuthenticationFilter; +import run.halo.app.identity.authentication.verifier.JwtAccessTokenNonBlockedValidator; import run.halo.app.identity.authentication.verifier.JwtProvidedDecoderAuthenticationManagerResolver; import run.halo.app.identity.authorization.DefaultRoleBindingLister; import run.halo.app.identity.authorization.DefaultRoleGetter; @@ -51,6 +56,7 @@ import run.halo.app.identity.authorization.RoleBindingLister; import run.halo.app.identity.authorization.RoleGetter; import run.halo.app.identity.entrypoint.JwtAccessDeniedHandler; import run.halo.app.identity.entrypoint.JwtAuthenticationEntryPoint; +import run.halo.app.identity.entrypoint.Oauth2LogoutHandler; import run.halo.app.infra.properties.JwtProperties; /** @@ -89,16 +95,21 @@ public class WebSecurityConfig { .antMatchers("/static/js/**").permitAll() // for swagger ui .antMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() + .antMatchers("/logout").authenticated() .antMatchers("/api/**", "/apis/**").access(requestInfoAuthorizationManager()) .anyRequest().access(requestInfoAuthorizationManager()) ) .csrf(AbstractHttpConfigurer::disable) .httpBasic(Customizer.withDefaults()) + .logout(logoutConfigurer -> { + logoutConfigurer.addLogoutHandler(oauth2LogoutHandler()) + .clearAuthentication(true); + }) .addFilterBefore(new OAuth2TokenEndpointFilter(authenticationManager(), providerSettings.getTokenEndpoint()), FilterSecurityInterceptor.class) .addFilterBefore(new BearerTokenAuthenticationFilter(authenticationManagerResolver()), - BasicAuthenticationFilter.class) + LogoutFilter.class) .addFilterAfter(providerContextFilter, SecurityContextHolderFilter.class) .sessionManagement( (session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) @@ -109,6 +120,11 @@ public class WebSecurityConfig { return http.build(); } + @Bean + Oauth2LogoutHandler oauth2LogoutHandler() { + return new Oauth2LogoutHandler(oauth2AuthorizationService()); + } + RequestInfoAuthorizationManager requestInfoAuthorizationManager() { RoleBindingLister roleBindingLister = new DefaultRoleBindingLister(); RoleGetter roleGetter = new DefaultRoleGetter(extensionClient); @@ -120,7 +136,7 @@ public class WebSecurityConfig { } @Bean - AuthenticationManager authenticationManager() throws Exception { + AuthenticationManager authenticationManager() { authenticationManagerBuilder.authenticationProvider(passwordAuthenticationProvider()) .authenticationProvider(oauth2RefreshTokenAuthenticationProvider()); return authenticationManagerBuilder.getOrBuild(); @@ -128,7 +144,16 @@ public class WebSecurityConfig { @Bean JwtDecoder jwtDecoder() { - return NimbusJwtDecoder.withPublicKey(this.key).build(); + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(this.key).build(); + + JwtAccessTokenNonBlockedValidator jwtAccessTokenNonBlockedValidator = + new JwtAccessTokenNonBlockedValidator(oauth2AuthorizationService()); + OAuth2TokenValidator jwtValidator = new DelegatingOAuth2TokenValidator<>( + new JwtTimestampValidator(), + jwtAccessTokenNonBlockedValidator); + + jwtDecoder.setJwtValidator(jwtValidator); + return jwtDecoder; } @Bean diff --git a/src/main/java/run/halo/app/identity/authentication/verifier/AbstractOAuth2TokenAuthenticationToken.java b/src/main/java/run/halo/app/identity/authentication/verifier/AbstractOAuth2TokenAuthenticationToken.java index e9a7f14ad..42f66634c 100644 --- a/src/main/java/run/halo/app/identity/authentication/verifier/AbstractOAuth2TokenAuthenticationToken.java +++ b/src/main/java/run/halo/app/identity/authentication/verifier/AbstractOAuth2TokenAuthenticationToken.java @@ -14,8 +14,7 @@ import org.springframework.util.Assert; * Base class for {@link AbstractAuthenticationToken} implementations that expose common * attributes between different OAuth 2.0 Access Token Formats. * - *

- * For example, a {@link Jwt} could expose its {@link Jwt#getClaims() claims} via + *

For example, a {@link Jwt} could expose its {@link Jwt#getClaims() claims} via * {@link #getTokenAttributes()} or an "Introspected" OAuth 2.0 Access Token * could expose the attributes of the Introspection Response via * {@link #getTokenAttributes()}. diff --git a/src/main/java/run/halo/app/identity/authentication/verifier/BearerTokenAuthenticationFilter.java b/src/main/java/run/halo/app/identity/authentication/verifier/BearerTokenAuthenticationFilter.java index 4dc681dfd..bbd394f96 100644 --- a/src/main/java/run/halo/app/identity/authentication/verifier/BearerTokenAuthenticationFilter.java +++ b/src/main/java/run/halo/app/identity/authentication/verifier/BearerTokenAuthenticationFilter.java @@ -25,9 +25,8 @@ import org.springframework.util.Assert; import org.springframework.web.filter.OncePerRequestFilter; /** - * Authenticates requests that contain an OAuth 2.0 - * Bearer Token. - *

+ *

Authenticates requests that contain an OAuth 2.0 + * Bearer Token.

* This filter should be wired with an {@link AuthenticationManager} that can authenticate * a {@link BearerTokenAuthenticationToken}. * diff --git a/src/main/java/run/halo/app/identity/authentication/verifier/JwtAccessTokenNonBlockedValidator.java b/src/main/java/run/halo/app/identity/authentication/verifier/JwtAccessTokenNonBlockedValidator.java new file mode 100644 index 000000000..5a561ab85 --- /dev/null +++ b/src/main/java/run/halo/app/identity/authentication/verifier/JwtAccessTokenNonBlockedValidator.java @@ -0,0 +1,57 @@ +package run.halo.app.identity.authentication.verifier; + +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; +import run.halo.app.identity.authentication.OAuth2Authorization; +import run.halo.app.identity.authentication.OAuth2AuthorizationService; +import run.halo.app.identity.authentication.OAuth2TokenType; + +/** + *

An implementation of {@link OAuth2TokenValidator} for verifying a Jwt-based access token has + * none blocked.

+ *

Because the persistent access token will be manually removed or logged out, + * this token should not continue to be used in this case.

+ * + * @author guqing + * @see OAuth2TokenValidator + * @see OAuth2AuthorizationService + * @since 2.0.0 + */ +public class JwtAccessTokenNonBlockedValidator implements OAuth2TokenValidator { + + private final OAuth2AuthorizationService oauth2AuthorizationService; + private final OAuth2Error error; + + public JwtAccessTokenNonBlockedValidator( + OAuth2AuthorizationService oauth2AuthorizationService) { + this.oauth2AuthorizationService = oauth2AuthorizationService; + this.error = new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, + "The access token is not valid", + "https://tools.ietf.org/html/rfc6750#section-3.1"); + } + + @Override + public OAuth2TokenValidatorResult validate(Jwt token) { + String tokenValue = token.getTokenValue(); + if (tokenValue == null) { + return OAuth2TokenValidatorResult.failure(this.error); + } + OAuth2Authorization oauth2Authorization = + oauth2AuthorizationService.findByToken(tokenValue, OAuth2TokenType.ACCESS_TOKEN); + if (oauth2Authorization == null) { + return OAuth2TokenValidatorResult.failure(this.error); + } + + OAuth2Authorization.Token accessToken = + oauth2Authorization.getAccessToken(); + if (accessToken == null || accessToken.isExpired()) { + return OAuth2TokenValidatorResult.failure(this.error); + } + + return OAuth2TokenValidatorResult.success(); + } +} diff --git a/src/main/java/run/halo/app/identity/entrypoint/Oauth2LogoutHandler.java b/src/main/java/run/halo/app/identity/entrypoint/Oauth2LogoutHandler.java new file mode 100644 index 000000000..b5b6f9dc8 --- /dev/null +++ b/src/main/java/run/halo/app/identity/entrypoint/Oauth2LogoutHandler.java @@ -0,0 +1,44 @@ +package run.halo.app.identity.entrypoint; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import run.halo.app.identity.authentication.OAuth2AuthorizationService; +import run.halo.app.identity.authentication.OAuth2TokenType; +import run.halo.app.identity.authentication.verifier.JwtAuthenticationToken; + +/** + *

Performs a logout by {@link OAuth2AuthorizationService}.

+ *

Will remove the {@link Authentication} from the {@link OAuth2AuthorizationService} if the + * specific instance of {@link Authentication} is {@link JwtAuthenticationToken}.

+ * + * @author guqing + * @see OAuth2AuthorizationService + * @see Authentication + * @see JwtAuthenticationToken + * @since 2.0.0 + */ +@Slf4j +public class Oauth2LogoutHandler implements LogoutHandler { + + private final OAuth2AuthorizationService oauth2AuthorizationService; + + public Oauth2LogoutHandler(OAuth2AuthorizationService oauth2AuthorizationService) { + this.oauth2AuthorizationService = oauth2AuthorizationService; + } + + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) { + log.debug("Logging out [{}]", authentication); + if (authentication instanceof JwtAuthenticationToken jwtAuthenticationToken) { + String tokenValue = jwtAuthenticationToken.getToken().getTokenValue(); + var oauth2Authorization = oauth2AuthorizationService.findByToken( + tokenValue, OAuth2TokenType.ACCESS_TOKEN); + oauth2AuthorizationService.remove(oauth2Authorization); + log.debug("Removed oauth2Authorization [{}]", oauth2Authorization); + } + } +} diff --git a/src/test/java/run/halo/app/integration/security/LogoutHandlerTest.java b/src/test/java/run/halo/app/integration/security/LogoutHandlerTest.java new file mode 100644 index 000000000..2e7328922 --- /dev/null +++ b/src/test/java/run/halo/app/integration/security/LogoutHandlerTest.java @@ -0,0 +1,67 @@ +package run.halo.app.integration.security; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Tests for {@link LogoutHandler}. + * + * @author guqing + * @since 2.0.0 + */ +public class LogoutHandlerTest extends AuthorizationTestSuit { + + private MockMvc mockMvc; + + private String accessToken; + + @BeforeEach + public void setUp() throws Exception { + mockMvc = setUpMock(SecuredController.class); + OAuth2AccessTokenResponse tokenResponse = mockAuth(); + accessToken = tokenResponse.getAccessToken().getTokenValue(); + } + + @Test + void allowAccessWithAccessToken() throws Exception { + mockMvc.perform(get("/api/v1/posts") + .headers(withBearerToken(accessToken))) + .andDo(print()) + .andExpect(status().is2xxSuccessful()) + .andExpect(content().string("Now you see me.")); + } + + @Test + void cannotAccessAfterLogout() throws Exception { + // logout + mockMvc.perform(get("/logout") + .headers(withBearerToken(accessToken))) + .andDo(print()) + .andExpect(status().is2xxSuccessful()); + // access again. + mockMvc.perform(get("/api/v1/posts") + .headers(withBearerToken(accessToken))) + .andDo(print()) + .andExpect(status().isUnauthorized()); + } + + @RestController + @RequestMapping("/api/v1/posts") + static class SecuredController { + @GetMapping + public String hello() { + return "Now you see me."; + } + } +} diff --git a/src/test/java/run/halo/app/integration/security/TestWebSecurityConfig.java b/src/test/java/run/halo/app/integration/security/TestWebSecurityConfig.java index a2dcd4a5b..cea9ab8f1 100644 --- a/src/test/java/run/halo/app/integration/security/TestWebSecurityConfig.java +++ b/src/test/java/run/halo/app/integration/security/TestWebSecurityConfig.java @@ -26,15 +26,19 @@ import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtTimestampValidator; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.intercept.FilterSecurityInterceptor; -import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; -import org.springframework.security.web.context.SecurityContextPersistenceFilter; +import org.springframework.security.web.authentication.logout.LogoutFilter; +import org.springframework.security.web.context.SecurityContextHolderFilter; import org.springframework.test.context.TestPropertySource; import run.halo.app.extension.Metadata; import run.halo.app.identity.authentication.InMemoryOAuth2AuthorizationService; @@ -46,6 +50,7 @@ import run.halo.app.identity.authentication.OAuth2TokenEndpointFilter; import run.halo.app.identity.authentication.ProviderContextFilter; import run.halo.app.identity.authentication.ProviderSettings; import run.halo.app.identity.authentication.verifier.BearerTokenAuthenticationFilter; +import run.halo.app.identity.authentication.verifier.JwtAccessTokenNonBlockedValidator; import run.halo.app.identity.authentication.verifier.JwtProvidedDecoderAuthenticationManagerResolver; import run.halo.app.identity.authorization.PolicyRule; import run.halo.app.identity.authorization.RequestInfoAuthorizationManager; @@ -53,6 +58,7 @@ import run.halo.app.identity.authorization.Role; import run.halo.app.identity.authorization.RoleBinding; import run.halo.app.identity.authorization.RoleRef; import run.halo.app.identity.authorization.Subject; +import run.halo.app.identity.entrypoint.Oauth2LogoutHandler; import run.halo.app.infra.properties.JwtProperties; /** @@ -86,22 +92,32 @@ public class TestWebSecurityConfig { .authorizeHttpRequests((authorize) -> authorize .antMatchers(providerSettings.getTokenEndpoint()).permitAll() .antMatchers("/static/**").permitAll() + .antMatchers("/logout").authenticated() .antMatchers("/api/**", "/apis/**").access(requestInfoAuthorizationManager()) .anyRequest().access(requestInfoAuthorizationManager()) ) .csrf(AbstractHttpConfigurer::disable) .httpBasic(Customizer.withDefaults()) + .logout(logoutConfigurer -> { + logoutConfigurer.addLogoutHandler(oauth2LogoutHandler()) + .clearAuthentication(true); + }) .addFilterBefore(new OAuth2TokenEndpointFilter(authenticationManager(), providerSettings.getTokenEndpoint()), FilterSecurityInterceptor.class) .addFilterBefore(new BearerTokenAuthenticationFilter(authenticationManagerResolver()), - BasicAuthenticationFilter.class) - .addFilterAfter(providerContextFilter, SecurityContextPersistenceFilter.class) + LogoutFilter.class) + .addFilterAfter(providerContextFilter, SecurityContextHolderFilter.class) .sessionManagement( (session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); return http.build(); } + @Bean + Oauth2LogoutHandler oauth2LogoutHandler() { + return new Oauth2LogoutHandler(oauth2AuthorizationService()); + } + public RequestInfoAuthorizationManager requestInfoAuthorizationManager() { return new RequestInfoAuthorizationManager(name -> { // role getter @@ -156,7 +172,16 @@ public class TestWebSecurityConfig { @Bean JwtDecoder jwtDecoder() { - return NimbusJwtDecoder.withPublicKey(this.key).build(); + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(this.key).build(); + + JwtAccessTokenNonBlockedValidator jwtAccessTokenNonBlockedValidator = + new JwtAccessTokenNonBlockedValidator(oauth2AuthorizationService()); + OAuth2TokenValidator jwtValidator = new DelegatingOAuth2TokenValidator<>( + new JwtTimestampValidator(), + jwtAccessTokenNonBlockedValidator); + + jwtDecoder.setJwtValidator(jwtValidator); + return jwtDecoder; } @Bean