From 2edb28ccd22fed5da3635f858c8cf8b3077972ed Mon Sep 17 00:00:00 2001 From: JohnNiang Date: Sun, 29 Sep 2024 11:45:27 +0800 Subject: [PATCH] Refine HaloAuthenticationToken Signed-off-by: JohnNiang --- .../oauth2/HaloOAuth2AuthenticationToken.java | 52 ++++---- .../app/core/user/service/RoleService.java | 0 .../user/service/UserConnectionService.java | 5 +- .../impl/UserConnectionServiceImpl.java | 2 +- .../halo/app/infra/config/WebFluxConfig.java | 49 +++---- .../infra/config/WebServerSecurityConfig.java | 22 ++- .../SharedApplicationContextFactory.java | 4 - .../security/ExceptionSecurityConfigurer.java | 8 +- .../DefaultOAuth2LoginHandlerEnhancer.java | 45 +++---- .../HaloOAuth2AuthenticationCacheFilter.java | 59 -------- .../oauth2/MapOAuth2AuthenticationFilter.java | 126 ++++++++++++++++++ .../OAuth2AuthenticationEntryPoint.java | 25 ---- .../OAuth2AuthenticationTokenCache.java | 41 ++++++ .../oauth2/OAuth2SecurityConfigurer.java | 30 ++++- .../OAuth2UserUnboundAccessDeniedHandler.java | 5 +- ...SessionOAuth2AuthenticationTokenCache.java | 42 ++++++ .../TwoFactorAuthenticationEntryPoint.java | 6 +- .../HaloOAuth2AuthenticationTokenMixin.java | 31 +++++ .../jackson2/HaloSecurityJackson2Module.java | 2 + .../TwoFactorAuthenticationMixin.java | 6 +- .../gateway_modules/login_fragments.html | 5 +- .../login_fragments.properties | 1 + .../login_fragments_en.properties | 1 + .../HaloSecurityJacksonModuleTest.java | 3 +- 24 files changed, 375 insertions(+), 195 deletions(-) rename {api => application}/src/main/java/run/halo/app/core/user/service/RoleService.java (100%) rename {api => application}/src/main/java/run/halo/app/core/user/service/UserConnectionService.java (82%) delete mode 100644 application/src/main/java/run/halo/app/security/authentication/oauth2/HaloOAuth2AuthenticationCacheFilter.java create mode 100644 application/src/main/java/run/halo/app/security/authentication/oauth2/MapOAuth2AuthenticationFilter.java delete mode 100644 application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2AuthenticationEntryPoint.java create mode 100644 application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2AuthenticationTokenCache.java create mode 100644 application/src/main/java/run/halo/app/security/authentication/oauth2/WebSessionOAuth2AuthenticationTokenCache.java create mode 100644 application/src/main/java/run/halo/app/security/jackson2/HaloOAuth2AuthenticationTokenMixin.java diff --git a/api/src/main/java/run/halo/app/security/authentication/oauth2/HaloOAuth2AuthenticationToken.java b/api/src/main/java/run/halo/app/security/authentication/oauth2/HaloOAuth2AuthenticationToken.java index 7ca4da5b4fd..fd344740c2e 100644 --- a/api/src/main/java/run/halo/app/security/authentication/oauth2/HaloOAuth2AuthenticationToken.java +++ b/api/src/main/java/run/halo/app/security/authentication/oauth2/HaloOAuth2AuthenticationToken.java @@ -4,11 +4,11 @@ import java.util.Collection; import java.util.Collections; import lombok.Getter; +import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; -import run.halo.app.infra.AnonymousUserConst; +import org.springframework.security.oauth2.core.user.OAuth2User; /** * Halo OAuth2 authentication token which combines {@link UserDetails} and original @@ -17,8 +17,7 @@ * @author johnniang * @since 2.20.0 */ -// TODO Make the class serializable by JSON -public class HaloOAuth2AuthenticationToken extends OAuth2AuthenticationToken { +public class HaloOAuth2AuthenticationToken extends AbstractAuthenticationToken { @Getter private final UserDetails userDetails; @@ -35,11 +34,7 @@ public class HaloOAuth2AuthenticationToken extends OAuth2AuthenticationToken { */ public HaloOAuth2AuthenticationToken(UserDetails userDetails, OAuth2AuthenticationToken original) { - super( - original.getPrincipal(), - original.getAuthorities(), - original.getAuthorizedClientRegistrationId() - ); + super(combineAuthorities(userDetails, original)); this.userDetails = userDetails; this.original = original; setAuthenticated(true); @@ -62,6 +57,16 @@ public Collection getAuthorities() { return Collections.unmodifiableList(authorities); } + @Override + public Object getCredentials() { + return ""; + } + + @Override + public OAuth2User getPrincipal() { + return original.getPrincipal(); + } + /** * Creates an authenticated {@link HaloOAuth2AuthenticationToken} using {@link UserDetails} and * original {@link OAuth2AuthenticationToken}. @@ -76,23 +81,16 @@ public static HaloOAuth2AuthenticationToken authenticated( return new HaloOAuth2AuthenticationToken(userDetails, original); } - /** - * Creates an unauthenticated {@link HaloOAuth2AuthenticationToken} using original {@link - * OAuth2AuthenticationToken}. - * - * @param original the original {@link OAuth2AuthenticationToken} - * @return an unauthenticated {@link HaloOAuth2AuthenticationToken} - */ - public static HaloOAuth2AuthenticationToken unauthenticated( - OAuth2AuthenticationToken original - ) { - var anonymousUser = User.builder() - .username(AnonymousUserConst.PRINCIPAL) - .authorities("ROLE_" + AnonymousUserConst.Role) - .password("") - .build(); - var token = new HaloOAuth2AuthenticationToken(anonymousUser, original); - token.setAuthenticated(false); - return token; + private static Collection combineAuthorities( + UserDetails userDetails, OAuth2AuthenticationToken original) { + var userDetailsAuthorities = userDetails.getAuthorities(); + var originalAuthorities = original.getAuthorities(); + var authorities = new ArrayList( + originalAuthorities.size() + userDetailsAuthorities.size() + ); + authorities.addAll(originalAuthorities); + authorities.addAll(userDetailsAuthorities); + return Collections.unmodifiableList(authorities); } + } diff --git a/api/src/main/java/run/halo/app/core/user/service/RoleService.java b/application/src/main/java/run/halo/app/core/user/service/RoleService.java similarity index 100% rename from api/src/main/java/run/halo/app/core/user/service/RoleService.java rename to application/src/main/java/run/halo/app/core/user/service/RoleService.java diff --git a/api/src/main/java/run/halo/app/core/user/service/UserConnectionService.java b/application/src/main/java/run/halo/app/core/user/service/UserConnectionService.java similarity index 82% rename from api/src/main/java/run/halo/app/core/user/service/UserConnectionService.java rename to application/src/main/java/run/halo/app/core/user/service/UserConnectionService.java index b952bff3fc3..9fe0d5994b2 100644 --- a/api/src/main/java/run/halo/app/core/user/service/UserConnectionService.java +++ b/application/src/main/java/run/halo/app/core/user/service/UserConnectionService.java @@ -21,13 +21,14 @@ Mono createUserConnection( ); /** - * Get user connection and then update it. + * Get user connection by registration id and OAuth2 user. + * If found, update updatedAt timestamp of the user connection. * * @param registrationId Registration id * @param oauth2User OAuth2 user * @return Updated user connection or empty */ - Mono getAndUpdateUserConnection( + Mono getUserConnection( String registrationId, OAuth2User oauth2User ); diff --git a/application/src/main/java/run/halo/app/core/user/service/impl/UserConnectionServiceImpl.java b/application/src/main/java/run/halo/app/core/user/service/impl/UserConnectionServiceImpl.java index 5b6432078ae..28948028726 100644 --- a/application/src/main/java/run/halo/app/core/user/service/impl/UserConnectionServiceImpl.java +++ b/application/src/main/java/run/halo/app/core/user/service/impl/UserConnectionServiceImpl.java @@ -62,7 +62,7 @@ private Mono updateUserConnection(UserConnection connection, } @Override - public Mono getAndUpdateUserConnection(String registrationId, + public Mono getUserConnection(String registrationId, OAuth2User oauth2User) { var listOptions = ListOptions.builder() .fieldQuery(and( diff --git a/application/src/main/java/run/halo/app/infra/config/WebFluxConfig.java b/application/src/main/java/run/halo/app/infra/config/WebFluxConfig.java index dcf576c46c6..4acfecc70e0 100644 --- a/application/src/main/java/run/halo/app/infra/config/WebFluxConfig.java +++ b/application/src/main/java/run/halo/app/infra/config/WebFluxConfig.java @@ -2,9 +2,7 @@ import static org.springframework.util.ResourceUtils.FILE_URL_PREFIX; import static org.springframework.web.reactive.function.server.RequestPredicates.accept; -import static org.springframework.web.reactive.function.server.RequestPredicates.method; import static org.springframework.web.reactive.function.server.RequestPredicates.path; -import static org.springframework.web.reactive.function.server.RouterFunctions.route; import static run.halo.app.infra.utils.FileUtils.checkDirectoryTraversal; import com.fasterxml.jackson.databind.ObjectMapper; @@ -18,7 +16,6 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.http.CacheControl; -import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.codec.CodecConfigurer; import org.springframework.http.codec.HttpMessageWriter; @@ -30,15 +27,14 @@ import org.springframework.web.reactive.config.ResourceHandlerRegistration; import org.springframework.web.reactive.config.ResourceHandlerRegistry; import org.springframework.web.reactive.config.WebFluxConfigurer; -import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.resource.EncodedResourceResolver; import org.springframework.web.reactive.resource.PathResourceResolver; import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerAdapter; import org.springframework.web.reactive.result.view.ViewResolutionResultHandler; import org.springframework.web.reactive.result.view.ViewResolver; -import reactor.core.publisher.Mono; import run.halo.app.core.endpoint.WebSocketHandlerMapping; import run.halo.app.core.endpoint.console.CustomEndpointsBuilder; import run.halo.app.core.extension.endpoint.CustomEndpoint; @@ -126,34 +122,33 @@ public WebSocketHandlerMapping webSocketHandlerMapping() { } @Bean - RouterFunction consoleIndexRedirection() { - var consolePredicate = method(HttpMethod.GET) - .and(path("/console/**").and(path("/console/assets/**").negate())) + RouterFunction consoleEndpoints() { + var consolePredicate = path("/console/**").and(path("/console/assets/**").negate()) .and(accept(MediaType.TEXT_HTML)) .and(new WebSocketRequestPredicate().negate()); - return route(consolePredicate, - request -> this.serveIndex(haloProp.getConsole().getLocation() + "index.html")); - } - @Bean - RouterFunction ucIndexRedirect() { - var consolePredicate = method(HttpMethod.GET) - .and(path("/uc/**").and(path("/uc/assets/**").negate())) + var ucPredicate = path("/uc/**").and(path("/uc/assets/**").negate()) .and(accept(MediaType.TEXT_HTML)) .and(new WebSocketRequestPredicate().negate()); - return route(consolePredicate, - request -> this.serveIndex(haloProp.getUc().getLocation() + "index.html")); - } - private Mono serveIndex(String indexLocation) { - var indexResource = applicationContext.getResource(indexLocation); - try { - return ServerResponse.ok() - .cacheControl(CacheControl.noStore()) - .body(BodyInserters.fromResource(indexResource)); - } catch (Throwable e) { - return Mono.error(e); - } + var consoleIndexHtml = + applicationContext.getResource(haloProp.getConsole().getLocation() + "index.html"); + + var ucIndexHtml = + applicationContext.getResource(haloProp.getUc().getLocation() + "index.html"); + + return RouterFunctions.route() + .GET(consolePredicate, + request -> ServerResponse.ok() + .cacheControl(CacheControl.noStore()) + .bodyValue(consoleIndexHtml) + ) + .GET(ucPredicate, + request -> ServerResponse.ok() + .cacheControl(CacheControl.noStore()) + .bodyValue(ucIndexHtml) + ) + .build(); } @Override diff --git a/application/src/main/java/run/halo/app/infra/config/WebServerSecurityConfig.java b/application/src/main/java/run/halo/app/infra/config/WebServerSecurityConfig.java index 04135ffa621..14925846fb8 100644 --- a/application/src/main/java/run/halo/app/infra/config/WebServerSecurityConfig.java +++ b/application/src/main/java/run/halo/app/infra/config/WebServerSecurityConfig.java @@ -14,8 +14,10 @@ import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import org.springframework.security.authorization.AuthorizationDecision; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.Authentication; import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.server.SecurityWebFilterChain; @@ -28,6 +30,7 @@ import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; import org.springframework.session.MapSession; import org.springframework.session.config.annotation.web.server.EnableSpringWebSession; +import reactor.core.publisher.Mono; import run.halo.app.core.user.service.RoleService; import run.halo.app.core.user.service.UserService; import run.halo.app.extension.ReactiveExtensionClient; @@ -68,8 +71,6 @@ SecurityWebFilterChain filterChain(ServerHttpSecurity http, var staticResourcesMatcher = pathMatchers(HttpMethod.GET, "/themes/{themeName}/assets/{*resourcePaths}", "/plugins/{pluginName}/assets/**", - "/console/**", - "/uc/**", "/upload/**", "/webjars/**", "/js/**", @@ -86,9 +87,22 @@ SecurityWebFilterChain filterChain(ServerHttpSecurity http, "/api/**", "/apis/**", "/actuator/**" + ).access(new RequestInfoAuthorizationManager(roleService)) + .pathMatchers( + "/login/**", + "/challenges/**", + "/password-reset/**", + "/signup", + "/logout" + ).permitAll() + .pathMatchers("/console/**", "/uc/**").authenticated() + .matchers(createHtmlMatcher()).access((authentication, context) -> + // we only need to check the authentication is authenticated + // because we treat anonymous user as authenticated + authentication.map(Authentication::isAuthenticated) + .map(AuthorizationDecision::new) + .switchIfEmpty(Mono.fromSupplier(() -> new AuthorizationDecision(false))) ) - .access(new RequestInfoAuthorizationManager(roleService)) - .matchers(createHtmlMatcher()).authenticated() .anyExchange().permitAll()) .anonymous(spec -> { spec.authorities(AuthorityUtils.ROLE_PREFIX + AnonymousUserConst.Role); diff --git a/application/src/main/java/run/halo/app/plugin/SharedApplicationContextFactory.java b/application/src/main/java/run/halo/app/plugin/SharedApplicationContextFactory.java index 60f268c1804..8bf5a1efa6e 100644 --- a/application/src/main/java/run/halo/app/plugin/SharedApplicationContextFactory.java +++ b/application/src/main/java/run/halo/app/plugin/SharedApplicationContextFactory.java @@ -85,10 +85,6 @@ public static ApplicationContext create(ApplicationContext rootContext) { .ifUnique(userDetailsService -> beanFactory.registerSingleton("userDetailsService", userDetailsService) ); - rootContext.getBeanProvider(UserConnectionService.class) - .ifUnique(userConnectionService -> - beanFactory.registerSingleton("userConnectionService", userConnectionService) - ); // TODO add more shared instance here sharedContext.refresh(); diff --git a/application/src/main/java/run/halo/app/security/ExceptionSecurityConfigurer.java b/application/src/main/java/run/halo/app/security/ExceptionSecurityConfigurer.java index c8963952d12..f84e70a7483 100644 --- a/application/src/main/java/run/halo/app/security/ExceptionSecurityConfigurer.java +++ b/application/src/main/java/run/halo/app/security/ExceptionSecurityConfigurer.java @@ -12,7 +12,6 @@ import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.ServerResponse; import run.halo.app.security.authentication.SecurityConfigurer; -import run.halo.app.security.authentication.oauth2.OAuth2AuthenticationEntryPoint; import run.halo.app.security.authentication.oauth2.OAuth2UserUnboundAccessDeniedHandler; import run.halo.app.security.authentication.twofactor.TwoFactorAuthenticationEntryPoint; @@ -34,7 +33,8 @@ public void configure(ServerHttpSecurity http) { http.exceptionHandling(exception -> { var accessDeniedHandlers = new ArrayList( - 2); + 2 + ); accessDeniedHandlers.add( new ServerWebExchangeDelegatingServerAccessDeniedHandler.DelegateEntry( OAuth2UserUnboundAccessDeniedHandler.MATCHER, @@ -54,10 +54,6 @@ public void configure(ServerHttpSecurity http) { TwoFactorAuthenticationEntryPoint.MATCHER, new TwoFactorAuthenticationEntryPoint(messageSource, context) )); - entryPoints.add(new DelegatingServerAuthenticationEntryPoint.DelegateEntry( - OAuth2AuthenticationEntryPoint.MATCHER, - new OAuth2AuthenticationEntryPoint() - )); entryPoints.add(new DelegatingServerAuthenticationEntryPoint.DelegateEntry( exchange -> ServerWebExchangeMatcher.MatchResult.match(), new DefaultServerAuthenticationEntryPoint() diff --git a/application/src/main/java/run/halo/app/security/authentication/oauth2/DefaultOAuth2LoginHandlerEnhancer.java b/application/src/main/java/run/halo/app/security/authentication/oauth2/DefaultOAuth2LoginHandlerEnhancer.java index 97d45cc93d4..380ace6af6d 100644 --- a/application/src/main/java/run/halo/app/security/authentication/oauth2/DefaultOAuth2LoginHandlerEnhancer.java +++ b/application/src/main/java/run/halo/app/security/authentication/oauth2/DefaultOAuth2LoginHandlerEnhancer.java @@ -1,8 +1,10 @@ package run.halo.app.security.authentication.oauth2; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.AuthenticationTrustResolver; +import org.springframework.security.authentication.AuthenticationTrustResolverImpl; import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; @@ -20,28 +22,30 @@ public class DefaultOAuth2LoginHandlerEnhancer implements OAuth2LoginHandlerEnha private final UserConnectionService connectionService; + @Setter + private OAuth2AuthenticationTokenCache oauth2TokenCache = + new WebSessionOAuth2AuthenticationTokenCache(); + + private final AuthenticationTrustResolver authenticationTrustResolver = + new AuthenticationTrustResolverImpl(); + public DefaultOAuth2LoginHandlerEnhancer(UserConnectionService connectionService) { this.connectionService = connectionService; } @Override public Mono loginSuccess(ServerWebExchange exchange, Authentication authentication) { - if (authentication instanceof HaloOAuth2AuthenticationToken) { - // Skip handling if logging in with OAuth2 - return Mono.empty(); + if (!authenticationTrustResolver.isFullyAuthenticated(authentication)) { + // Should never happen + // Remove token directly if not fully authenticated + return oauth2TokenCache.removeToken(exchange).then(); } - return exchange.getSession() - .flatMap(session -> { - var oauth2TokenObject = - session.getAttribute(HaloOAuth2AuthenticationCacheFilter.CACHE_KEY); - if (!(oauth2TokenObject instanceof HaloOAuth2AuthenticationToken haloOAuth2Token)) { - return Mono.empty(); - } - var oauth2User = haloOAuth2Token.getPrincipal(); + return oauth2TokenCache.getToken(exchange) + .flatMap(oauth2Token -> { + var oauth2User = oauth2Token.getPrincipal(); var username = authentication.getName(); - var registrationId = haloOAuth2Token.getAuthorizedClientRegistrationId(); - var providerUserId = oauth2User.getName(); - return connectionService.getAndUpdateUserConnection(registrationId, oauth2User) + var registrationId = oauth2Token.getAuthorizedClientRegistrationId(); + return connectionService.getUserConnection(registrationId, oauth2User) .doOnNext(connection -> { if (log.isDebugEnabled()) { log.debug( @@ -54,14 +58,9 @@ public Mono loginSuccess(ServerWebExchange exchange, Authentication authen username, registrationId, oauth2User - ).doOnNext(connection -> { - log.info("Bound user {} to {} in registration {}", - username, providerUserId, registrationId - ); - session.getAttributes().remove(OAuth2AuthenticationToken.class.getName()); - }))); - }) - .then(); + ))) + .then(oauth2TokenCache.removeToken(exchange)); + }); } } diff --git a/application/src/main/java/run/halo/app/security/authentication/oauth2/HaloOAuth2AuthenticationCacheFilter.java b/application/src/main/java/run/halo/app/security/authentication/oauth2/HaloOAuth2AuthenticationCacheFilter.java deleted file mode 100644 index 0c30c22baaa..00000000000 --- a/application/src/main/java/run/halo/app/security/authentication/oauth2/HaloOAuth2AuthenticationCacheFilter.java +++ /dev/null @@ -1,59 +0,0 @@ -package run.halo.app.security.authentication.oauth2; - -import org.springframework.security.authentication.AuthenticationTrustResolver; -import org.springframework.security.authentication.AuthenticationTrustResolverImpl; -import org.springframework.security.core.context.ReactiveSecurityContextHolder; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.web.server.ServerWebExchange; -import org.springframework.web.server.WebFilter; -import org.springframework.web.server.WebFilterChain; -import reactor.core.publisher.Mono; - -/** - * Filter to cache {@link HaloOAuth2AuthenticationToken} into session. - * - * @author johnniang - * @since 2.20.0 - */ -public class HaloOAuth2AuthenticationCacheFilter implements WebFilter { - - public static final String CACHE_KEY = - HaloOAuth2AuthenticationToken.class.getName() + ".CACHE"; - - private final AuthenticationTrustResolver authenticationTrustResolver = - new AuthenticationTrustResolverImpl(); - - @Override - public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { - return ReactiveSecurityContextHolder.getContext() - .map(SecurityContext::getAuthentication) - .filter(HaloOAuth2AuthenticationToken.class::isInstance) - .filter(hoa -> !authenticationTrustResolver.isAuthenticated(hoa)) - .switchIfEmpty(Mono.defer(() -> chain.filter(exchange) - .then(Mono.defer(() -> ReactiveSecurityContextHolder.getContext() - .map(SecurityContext::getAuthentication) - .filter(authenticationTrustResolver::isFullyAuthenticated) - // Means the authentication is authenticated successfully - // So we assume the HaloOAuth2AuthenticationToken was handled - .flatMap(a -> exchange.getSession() - .doOnNext(session -> session.getAttributes().remove(CACHE_KEY)) - ) - )) - .then(Mono.empty())) - ) - .flatMap(hoa -> chain.filter(exchange) - .then(Mono.defer(() -> ReactiveSecurityContextHolder.getContext() - .map(SecurityContext::getAuthentication) - .filter(a -> !authenticationTrustResolver.isAnonymous(a)) - .filter(a -> !authenticationTrustResolver.isRememberMe(a)) - .filter(a -> !authenticationTrustResolver.isAuthenticated(a)) - // Means the authentication needs to be authenticated further - .flatMap(a -> exchange.getSession() - .doOnNext(session -> session.getAttributes().put(CACHE_KEY, hoa)) - ) - )) - ) - .then(); - } - -} diff --git a/application/src/main/java/run/halo/app/security/authentication/oauth2/MapOAuth2AuthenticationFilter.java b/application/src/main/java/run/halo/app/security/authentication/oauth2/MapOAuth2AuthenticationFilter.java new file mode 100644 index 00000000000..afa64941554 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/oauth2/MapOAuth2AuthenticationFilter.java @@ -0,0 +1,126 @@ +package run.halo.app.security.authentication.oauth2; + +import static run.halo.app.security.authentication.oauth2.HaloOAuth2AuthenticationToken.authenticated; + +import java.net.URI; +import lombok.Setter; +import org.springframework.security.authentication.AuthenticationTrustResolver; +import org.springframework.security.authentication.AuthenticationTrustResolverImpl; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.web.server.DefaultServerRedirectStrategy; +import org.springframework.security.web.server.ServerRedirectStrategy; +import org.springframework.security.web.server.WebFilterExchange; +import org.springframework.security.web.server.authentication.logout.SecurityContextServerLogoutHandler; +import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler; +import org.springframework.security.web.server.context.ServerSecurityContextRepository; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; +import run.halo.app.core.user.service.UserConnectionService; + +/** + * A filter to map OAuth2 authentication to authenticated user. + * + * @author johnniang + * @since 2.20.0 + */ +class MapOAuth2AuthenticationFilter implements WebFilter { + + private static final String PRE_AUTHENTICATION = + MapOAuth2AuthenticationFilter.class.getName() + ".PRE_AUTHENTICATION"; + + private final UserConnectionService connectionService; + + private final ServerSecurityContextRepository securityContextRepository; + + @Setter + private OAuth2AuthenticationTokenCache authenticationCache = + new WebSessionOAuth2AuthenticationTokenCache(); + + private final ReactiveUserDetailsService userDetailsService; + + private final ServerLogoutHandler logoutHandler; + + private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); + + @Setter + private AuthenticationTrustResolver authenticationTrustResolver + = new AuthenticationTrustResolverImpl(); + + public MapOAuth2AuthenticationFilter( + ServerSecurityContextRepository securityContextRepository, + UserConnectionService connectionService, + ReactiveUserDetailsService userDetailsService) { + this.connectionService = connectionService; + this.securityContextRepository = securityContextRepository; + this.userDetailsService = userDetailsService; + var logoutHandler = new SecurityContextServerLogoutHandler(); + logoutHandler.setSecurityContextRepository(securityContextRepository); + this.logoutHandler = logoutHandler; + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .filter(authenticationTrustResolver::isAuthenticated) + .doOnNext( + // cache the pre-authentication + authentication -> exchange.getAttributes().put(PRE_AUTHENTICATION, authentication) + ) + .then(chain.filter(exchange)) + .then(Mono.defer(() -> ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .filter(OAuth2AuthenticationToken.class::isInstance) + .cast(OAuth2AuthenticationToken.class) + .flatMap(oauth2Token -> { + var registrationId = oauth2Token.getAuthorizedClientRegistrationId(); + var oauth2User = oauth2Token.getPrincipal(); + // check the connection + return connectionService.getUserConnection(registrationId, oauth2User) + .switchIfEmpty(Mono.defer(() -> { + var preAuthenticationObject = exchange.getAttribute(PRE_AUTHENTICATION); + if (preAuthenticationObject instanceof Authentication preAuth + && authenticationTrustResolver.isAuthenticated(preAuth)) { + // check the authentication again + // try to bind the user automatically + return connectionService.createUserConnection( + preAuth.getName(), registrationId, oauth2User + ); + } + // save the OAuth2Authentication into session + return authenticationCache.saveToken(exchange, oauth2Token) + .then(Mono.defer(() -> { + var webFilterExchange = new WebFilterExchange(exchange, chain); + // clear the security context + return logoutHandler.logout(webFilterExchange, oauth2Token); + })) + .then(Mono.defer(() -> redirectStrategy.sendRedirect(exchange, + URI.create("/login?oauth2_bind") + ))) + // skip handling + .then(Mono.empty()); + })) + // user bound and remap the authentication + .flatMap(connection -> + userDetailsService.findByUsername(connection.getSpec().getUsername()) + ) + .map(userDetails -> authenticated(userDetails, oauth2Token)) + .flatMap(haloOAuth2Token -> { + var securityContext = new SecurityContextImpl(haloOAuth2Token); + return securityContextRepository.save(exchange, securityContext); + // because this happens after the filter, there is no need to + // write SecurityContext to the context + }); + }) + .then()) + ); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2AuthenticationEntryPoint.java b/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2AuthenticationEntryPoint.java deleted file mode 100644 index 0835d039806..00000000000 --- a/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2AuthenticationEntryPoint.java +++ /dev/null @@ -1,25 +0,0 @@ -package run.halo.app.security.authentication.oauth2; - -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.web.server.ServerAuthenticationEntryPoint; -import org.springframework.security.web.server.authentication.RedirectServerAuthenticationEntryPoint; -import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; -import org.springframework.web.server.ServerWebExchange; -import reactor.core.publisher.Mono; - -public class OAuth2AuthenticationEntryPoint implements ServerAuthenticationEntryPoint { - - public static ServerWebExchangeMatcher MATCHER = exchange -> exchange.getPrincipal() - .filter(HaloOAuth2AuthenticationToken.class::isInstance) - .flatMap(a -> ServerWebExchangeMatcher.MatchResult.match()) - .switchIfEmpty(ServerWebExchangeMatcher.MatchResult.notMatch()); - - private final RedirectServerAuthenticationEntryPoint redirectEntryPoint = - new RedirectServerAuthenticationEntryPoint("/console/login?oauth2-bind"); - - @Override - public Mono commence(ServerWebExchange exchange, AuthenticationException ex) { - return redirectEntryPoint.commence(exchange, ex); - } - -} diff --git a/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2AuthenticationTokenCache.java b/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2AuthenticationTokenCache.java new file mode 100644 index 00000000000..9c2bf964bdc --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2AuthenticationTokenCache.java @@ -0,0 +1,41 @@ +package run.halo.app.security.authentication.oauth2; + +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * OAuth2 authentication token cache. Saving OAuth2AuthenticationToken is mainly for further binding + * to Halo user. + * + * @author johnniang + * @since 2.20.0 + */ +public interface OAuth2AuthenticationTokenCache { + + /** + * Save OAuth2AuthenticationToken into cache. + * + * @param exchange Server web exchange + * @param oauth2Token OAuth2AuthenticationToken + * @return empty + */ + Mono saveToken(ServerWebExchange exchange, OAuth2AuthenticationToken oauth2Token); + + /** + * Get OAuth2AuthenticationToken from cache. + * + * @param exchange Server web exchange + * @return an {@link OAuth2AuthenticationToken} if present, empty otherwise + */ + Mono getToken(ServerWebExchange exchange); + + /** + * Remove OAuth2AuthenticationToken from cache. + * + * @param exchange Server web exchange + * @return empty + */ + Mono removeToken(ServerWebExchange exchange); + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2SecurityConfigurer.java b/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2SecurityConfigurer.java index 49f5e7f6cdf..d4bf79f4473 100644 --- a/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2SecurityConfigurer.java +++ b/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2SecurityConfigurer.java @@ -2,15 +2,39 @@ import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.springframework.stereotype.Component; +import run.halo.app.core.user.service.UserConnectionService; import run.halo.app.security.authentication.SecurityConfigurer; +/** + * OAuth2 security configurer. + * + * @author johnniang + * @since 2.20.0 + */ @Component -public class OAuth2SecurityConfigurer implements SecurityConfigurer { +class OAuth2SecurityConfigurer implements SecurityConfigurer { + + private final ServerSecurityContextRepository securityContextRepository; + + private final UserConnectionService connectionService; + + private final ReactiveUserDetailsService userDetailsService; + + public OAuth2SecurityConfigurer(ServerSecurityContextRepository securityContextRepository, + UserConnectionService connectionService, ReactiveUserDetailsService userDetailsService) { + this.securityContextRepository = securityContextRepository; + this.connectionService = connectionService; + this.userDetailsService = userDetailsService; + } @Override public void configure(ServerHttpSecurity http) { - var cacheFilter = new HaloOAuth2AuthenticationCacheFilter(); - http.addFilterAfter(cacheFilter, SecurityWebFiltersOrder.REACTOR_CONTEXT); + var mapOAuth2Filter = new MapOAuth2AuthenticationFilter( + securityContextRepository, connectionService, userDetailsService + ); + http.addFilterBefore(mapOAuth2Filter, SecurityWebFiltersOrder.AUTHENTICATION); } } diff --git a/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2UserUnboundAccessDeniedHandler.java b/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2UserUnboundAccessDeniedHandler.java index 899f7fdbce6..8f15a3a4366 100644 --- a/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2UserUnboundAccessDeniedHandler.java +++ b/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2UserUnboundAccessDeniedHandler.java @@ -1,7 +1,6 @@ package run.halo.app.security.authentication.oauth2; import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.web.server.authentication.RedirectServerAuthenticationEntryPoint; import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; @@ -11,12 +10,12 @@ public class OAuth2UserUnboundAccessDeniedHandler implements ServerAccessDeniedHandler { public static ServerWebExchangeMatcher MATCHER = exchange -> exchange.getPrincipal() - .filter(OAuth2AuthenticationToken.class::isInstance) + .filter(HaloOAuth2AuthenticationToken.class::isInstance) .flatMap(a -> ServerWebExchangeMatcher.MatchResult.match()) .switchIfEmpty(ServerWebExchangeMatcher.MatchResult.notMatch()); private final RedirectServerAuthenticationEntryPoint oauth2AuthEntryPoint = - new RedirectServerAuthenticationEntryPoint("/console/login?oauth2-bind"); + new RedirectServerAuthenticationEntryPoint("/login?oauth2_bind"); @Override public Mono handle(ServerWebExchange exchange, AccessDeniedException denied) { diff --git a/application/src/main/java/run/halo/app/security/authentication/oauth2/WebSessionOAuth2AuthenticationTokenCache.java b/application/src/main/java/run/halo/app/security/authentication/oauth2/WebSessionOAuth2AuthenticationTokenCache.java new file mode 100644 index 00000000000..44995a8f933 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/oauth2/WebSessionOAuth2AuthenticationTokenCache.java @@ -0,0 +1,42 @@ +package run.halo.app.security.authentication.oauth2; + +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * WebSession cache implementation of {@link OAuth2AuthenticationTokenCache}. + * + * @author johnniang + * @since 2.20.0 + */ +public class WebSessionOAuth2AuthenticationTokenCache implements OAuth2AuthenticationTokenCache { + + private static final String SESSION_ATTRIBUTE_KEY = + OAuth2AuthenticationTokenCache.class + ".OAUTH2_TOKEN"; + + @Override + public Mono saveToken(ServerWebExchange exchange, OAuth2AuthenticationToken oauth2Token) { + return exchange.getSession() + .doOnNext(session -> { + session.getAttributes().put(SESSION_ATTRIBUTE_KEY, oauth2Token); + }) + .then(); + } + + @Override + public Mono getToken(ServerWebExchange exchange) { + return exchange.getSession() + .mapNotNull(session -> session.getAttribute(SESSION_ATTRIBUTE_KEY)) + .filter(OAuth2AuthenticationToken.class::isInstance) + .cast(OAuth2AuthenticationToken.class); + } + + @Override + public Mono removeToken(ServerWebExchange exchange) { + return exchange.getSession() + .doOnNext(session -> session.getAttributes().remove(SESSION_ATTRIBUTE_KEY)) + .then(); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthenticationEntryPoint.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthenticationEntryPoint.java index 102efa68138..b7af274a786 100644 --- a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthenticationEntryPoint.java +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthenticationEntryPoint.java @@ -18,10 +18,10 @@ public class TwoFactorAuthenticationEntryPoint implements ServerAuthenticationEn .flatMap(a -> ServerWebExchangeMatcher.MatchResult.match()) .switchIfEmpty(ServerWebExchangeMatcher.MatchResult.notMatch()); - private final RedirectServerAuthenticationEntryPoint redirectEntryPoint = - new RedirectServerAuthenticationEntryPoint("/console/login/mfa"); + private static final String REDIRECT_LOCATION = "/challenges/two-factor/totp"; - private static final String REDIRECT_LOCATION = "/console/login/mfa"; + private final RedirectServerAuthenticationEntryPoint redirectEntryPoint = + new RedirectServerAuthenticationEntryPoint(REDIRECT_LOCATION); private final MessageSource messageSource; diff --git a/application/src/main/java/run/halo/app/security/jackson2/HaloOAuth2AuthenticationTokenMixin.java b/application/src/main/java/run/halo/app/security/jackson2/HaloOAuth2AuthenticationTokenMixin.java new file mode 100644 index 00000000000..eb6e807844b --- /dev/null +++ b/application/src/main/java/run/halo/app/security/jackson2/HaloOAuth2AuthenticationTokenMixin.java @@ -0,0 +1,31 @@ +package run.halo.app.security.jackson2; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import run.halo.app.security.authentication.oauth2.HaloOAuth2AuthenticationToken; + +/** + * Mixin for {@link HaloOAuth2AuthenticationToken}. + * + * @author johnniang + * @since 2.20.0 + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, + getterVisibility = JsonAutoDetect.Visibility.NONE, + isGetterVisibility = JsonAutoDetect.Visibility.NONE) +@JsonIgnoreProperties(ignoreUnknown = true) +abstract class HaloOAuth2AuthenticationTokenMixin { + + @JsonCreator + HaloOAuth2AuthenticationTokenMixin( + @JsonProperty("userDetails") UserDetails userDetails, + @JsonProperty("original") OAuth2AuthenticationToken original + ) { + } +} diff --git a/application/src/main/java/run/halo/app/security/jackson2/HaloSecurityJackson2Module.java b/application/src/main/java/run/halo/app/security/jackson2/HaloSecurityJackson2Module.java index ff3687f77f9..2fc0ff8ad1a 100644 --- a/application/src/main/java/run/halo/app/security/jackson2/HaloSecurityJackson2Module.java +++ b/application/src/main/java/run/halo/app/security/jackson2/HaloSecurityJackson2Module.java @@ -23,6 +23,8 @@ public void setupModule(SetupContext context) { context.setMixInAnnotations(HaloUser.class, HaloUserMixin.class); context.setMixInAnnotations(TwoFactorAuthentication.class, TwoFactorAuthenticationMixin.class); + context.setMixInAnnotations(HaloOAuth2AuthenticationTokenMixin.class, + HaloOAuth2AuthenticationTokenMixin.class); } } diff --git a/application/src/main/java/run/halo/app/security/jackson2/TwoFactorAuthenticationMixin.java b/application/src/main/java/run/halo/app/security/jackson2/TwoFactorAuthenticationMixin.java index aa9c0a2222f..d38ae936b9f 100644 --- a/application/src/main/java/run/halo/app/security/jackson2/TwoFactorAuthenticationMixin.java +++ b/application/src/main/java/run/halo/app/security/jackson2/TwoFactorAuthenticationMixin.java @@ -6,8 +6,6 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonTypeInfo; import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import java.util.Collection; /** * This mixin class is used to serialize/deserialize TwoFactorAuthentication. @@ -23,9 +21,7 @@ abstract class TwoFactorAuthenticationMixin { @JsonCreator TwoFactorAuthenticationMixin( - @JsonProperty("previous") Authentication previous, - @JsonProperty("authorities") Collection authorities, - @JsonProperty("rememberMe") boolean rememberMe + @JsonProperty("previous") Authentication previous ) { } } diff --git a/application/src/main/resources/templates/gateway_modules/login_fragments.html b/application/src/main/resources/templates/gateway_modules/login_fragments.html index 87e8fe64cac..b9cdf7148b7 100644 --- a/application/src/main/resources/templates/gateway_modules/login_fragments.html +++ b/application/src/main/resources/templates/gateway_modules/login_fragments.html @@ -19,7 +19,10 @@ +
diff --git a/application/src/main/resources/templates/gateway_modules/login_fragments.properties b/application/src/main/resources/templates/gateway_modules/login_fragments.properties index 5752dae82fe..99ab4a31757 100644 --- a/application/src/main/resources/templates/gateway_modules/login_fragments.properties +++ b/application/src/main/resources/templates/gateway_modules/login_fragments.properties @@ -1,6 +1,7 @@ messages.loginError=无效的凭证。 messages.logoutSuccess=登出成功。 messages.signupSuccess=恭喜!注册成功,请立即登录。 +messages.oauth2_bind=当前登录未绑定账号,请尝试通过其他方式登录,登录成功后会自动绑定账号。 error.invalid-credential=无效的凭证。 error.rate-limit-exceeded=请求过于频繁,请稍后再试。 diff --git a/application/src/main/resources/templates/gateway_modules/login_fragments_en.properties b/application/src/main/resources/templates/gateway_modules/login_fragments_en.properties index d3b5b33f466..c9d1deccd8f 100644 --- a/application/src/main/resources/templates/gateway_modules/login_fragments_en.properties +++ b/application/src/main/resources/templates/gateway_modules/login_fragments_en.properties @@ -1,6 +1,7 @@ messages.loginError=Invalid credentials. messages.logoutSuccess=Logout successfully. messages.signupSuccess=Congratulations! Sign up successfully, please sign in now. +messages.oauth2_bind=The current login is not bound to an account. Please try to log in through other methods. After successful login, the account will be automatically bound. error.invalid-credential=Invalid credentials. error.rate-limit-exceeded=Too many requests, please try again later. diff --git a/application/src/test/java/run/halo/app/security/jackson2/HaloSecurityJacksonModuleTest.java b/application/src/test/java/run/halo/app/security/jackson2/HaloSecurityJacksonModuleTest.java index 71873f894f0..907719ce28c 100644 --- a/application/src/test/java/run/halo/app/security/jackson2/HaloSecurityJacksonModuleTest.java +++ b/application/src/test/java/run/halo/app/security/jackson2/HaloSecurityJacksonModuleTest.java @@ -43,8 +43,7 @@ void codecTwoFactorAuthenticationTokenTest() throws JsonProcessingException { var authentication = UsernamePasswordAuthenticationToken.authenticated(haloUser, haloUser.getPassword(), haloUser.getAuthorities()); - return new TwoFactorAuthentication(authentication, authentication.getAuthorities(), - false); + return new TwoFactorAuthentication(authentication); }); }