mirror of https://github.com/halo-dev/halo
feat: add logout handler (#2110)
* feat: add logout handler * chore: delete unused code * fix: merge conflictspull/2111/head
parent
c5df6d3dbb
commit
0f4ae08fd8
|
@ -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
|
||||
|
|
|
@ -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 "Introspected" OAuth 2.0 Access Token
|
||||
* could expose the attributes of the Introspection Response via
|
||||
* {@link #getTokenAttributes()}.
|
||||
|
|
|
@ -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}.
|
||||
*
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.";
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue