feat: add logout handler (#2110)

* feat: add logout handler

* chore: delete unused code

* fix: merge conflicts
pull/2111/head
guqing 2022-05-25 17:58:10 +08:00 committed by GitHub
parent c5df6d3dbb
commit 0f4ae08fd8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 230 additions and 14 deletions

View File

@ -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<Jwt> jwtValidator = new DelegatingOAuth2TokenValidator<>(
new JwtTimestampValidator(),
jwtAccessTokenNonBlockedValidator);
jwtDecoder.setJwtValidator(jwtValidator);
return jwtDecoder;
}
@Bean

View File

@ -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.
*
* <p>
* For example, a {@link Jwt} could expose its {@link Jwt#getClaims() claims} via
* <p>For example, a {@link Jwt} could expose its {@link Jwt#getClaims() claims} via
* {@link #getTokenAttributes()} or an &quot;Introspected&quot; OAuth 2.0 Access Token
* could expose the attributes of the Introspection Response via
* {@link #getTokenAttributes()}.

View File

@ -25,9 +25,8 @@ import org.springframework.util.Assert;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* Authenticates requests that contain an OAuth 2.0
* <a href="https://tools.ietf.org/html/rfc6750#section-1.2">Bearer Token</a>.
* <p>
* <p>Authenticates requests that contain an OAuth 2.0
* <a href="https://tools.ietf.org/html/rfc6750#section-1.2">Bearer Token</a>.</p>
* This filter should be wired with an {@link AuthenticationManager} that can authenticate
* a {@link BearerTokenAuthenticationToken}.
*

View File

@ -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;
/**
* <p>An implementation of {@link OAuth2TokenValidator} for verifying a Jwt-based access token has
* none blocked.</p>
* <p>Because the persistent access token will be manually removed or logged out,
* this token should not continue to be used in this case.</p>
*
* @author guqing
* @see OAuth2TokenValidator
* @see OAuth2AuthorizationService
* @since 2.0.0
*/
public class JwtAccessTokenNonBlockedValidator implements OAuth2TokenValidator<Jwt> {
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<OAuth2AccessToken> accessToken =
oauth2Authorization.getAccessToken();
if (accessToken == null || accessToken.isExpired()) {
return OAuth2TokenValidatorResult.failure(this.error);
}
return OAuth2TokenValidatorResult.success();
}
}

View File

@ -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;
/**
* <p>Performs a logout by {@link OAuth2AuthorizationService}.</p>
* <p>Will remove the {@link Authentication} from the {@link OAuth2AuthorizationService} if the
* specific instance of {@link Authentication} is {@link JwtAuthenticationToken}.</p>
*
* @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);
}
}
}

View File

@ -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.";
}
}
}

View File

@ -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<Jwt> jwtValidator = new DelegatingOAuth2TokenValidator<>(
new JwtTimestampValidator(),
jwtAccessTokenNonBlockedValidator);
jwtDecoder.setJwtValidator(jwtValidator);
return jwtDecoder;
}
@Bean