diff --git a/api/src/main/java/run/halo/app/core/extension/UserConnection.java b/api/src/main/java/run/halo/app/core/extension/UserConnection.java index 1b9fb728d6..8639ba6340 100644 --- a/api/src/main/java/run/halo/app/core/extension/UserConnection.java +++ b/api/src/main/java/run/halo/app/core/extension/UserConnection.java @@ -48,36 +48,9 @@ public static class UserConnectionSpec { private String providerUserId; /** - * The display name for the user's connection to the OAuth provider. + * The time when the user connection was last updated. */ - @Schema(requiredMode = REQUIRED) - private String displayName; - - /** - * The URL to the user's profile page on the OAuth provider. - * For example, the user's GitHub profile URL. - */ - private String profileUrl; - - /** - * The URL to the user's avatar image on the OAuth provider. - * For example, the user's GitHub avatar URL. - */ - private String avatarUrl; - - /** - * The access token provided by the OAuth provider. - */ - @Schema(requiredMode = REQUIRED) - private String accessToken; - - /** - * The refresh token provided by the OAuth provider (if applicable). - */ - private String refreshToken; - - private Instant expiresAt; - private Instant updatedAt; + } } diff --git a/api/src/main/java/run/halo/app/security/HttpBasicSecurityWebFilter.java b/api/src/main/java/run/halo/app/security/HttpBasicSecurityWebFilter.java new file mode 100644 index 0000000000..7043a10e48 --- /dev/null +++ b/api/src/main/java/run/halo/app/security/HttpBasicSecurityWebFilter.java @@ -0,0 +1,14 @@ +package run.halo.app.security; + +import org.pf4j.ExtensionPoint; +import org.springframework.web.server.WebFilter; + +/** + * Security web filter for HTTP basic. + * + * @author johnniang + * @since 2.20.0 + */ +public interface HttpBasicSecurityWebFilter extends WebFilter, ExtensionPoint { + +} diff --git a/api/src/main/java/run/halo/app/security/OAuth2AuthorizationCodeSecurityWebFilter.java b/api/src/main/java/run/halo/app/security/OAuth2AuthorizationCodeSecurityWebFilter.java new file mode 100644 index 0000000000..2aa0f28ebc --- /dev/null +++ b/api/src/main/java/run/halo/app/security/OAuth2AuthorizationCodeSecurityWebFilter.java @@ -0,0 +1,14 @@ +package run.halo.app.security; + +import org.pf4j.ExtensionPoint; +import org.springframework.web.server.WebFilter; + +/** + * Security web filter for OAuth2 authorization code. + * + * @author johnniang + * @since 2.20.0 + */ +public interface OAuth2AuthorizationCodeSecurityWebFilter extends WebFilter, ExtensionPoint { + +} 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 new file mode 100644 index 0000000000..fd344740c2 --- /dev/null +++ b/api/src/main/java/run/halo/app/security/authentication/oauth2/HaloOAuth2AuthenticationToken.java @@ -0,0 +1,96 @@ +package run.halo.app.security.authentication.oauth2; + +import java.util.ArrayList; +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.UserDetails; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.user.OAuth2User; + +/** + * Halo OAuth2 authentication token which combines {@link UserDetails} and original + * {@link OAuth2AuthenticationToken}. + * + * @author johnniang + * @since 2.20.0 + */ +public class HaloOAuth2AuthenticationToken extends AbstractAuthenticationToken { + + @Getter + private final UserDetails userDetails; + + @Getter + private final OAuth2AuthenticationToken original; + + /** + * Constructs an {@code HaloOAuth2AuthenticationToken} using {@link UserDetails} and original + * {@link OAuth2AuthenticationToken}. + * + * @param userDetails the {@link UserDetails} + * @param original the original {@link OAuth2AuthenticationToken} + */ + public HaloOAuth2AuthenticationToken(UserDetails userDetails, + OAuth2AuthenticationToken original) { + super(combineAuthorities(userDetails, original)); + this.userDetails = userDetails; + this.original = original; + setAuthenticated(true); + } + + @Override + public String getName() { + return userDetails.getUsername(); + } + + @Override + public Collection getAuthorities() { + var originalAuthorities = super.getAuthorities(); + var userDetailsAuthorities = getUserDetails().getAuthorities(); + var authorities = new ArrayList( + originalAuthorities.size() + userDetailsAuthorities.size() + ); + authorities.addAll(originalAuthorities); + authorities.addAll(userDetailsAuthorities); + 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}. + * + * @param userDetails the {@link UserDetails} + * @param original the original {@link OAuth2AuthenticationToken} + * @return an authenticated {@link HaloOAuth2AuthenticationToken} + */ + public static HaloOAuth2AuthenticationToken authenticated( + UserDetails userDetails, OAuth2AuthenticationToken original + ) { + return new HaloOAuth2AuthenticationToken(userDetails, original); + } + + 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/application/src/main/java/run/halo/app/core/endpoint/console/UserEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/console/UserEndpoint.java index 8a907e84c2..861390cb0a 100644 --- a/application/src/main/java/run/halo/app/core/endpoint/console/UserEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/console/UserEndpoint.java @@ -88,7 +88,6 @@ import run.halo.app.infra.exception.RateLimitExceededException; import run.halo.app.infra.exception.UnsatisfiedAttributeValueException; import run.halo.app.infra.utils.JsonUtils; -import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; @Component @RequiredArgsConstructor @@ -600,7 +599,7 @@ record ChangePasswordRequest( Mono me(ServerRequest request) { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) - .filter(auth -> !(auth instanceof TwoFactorAuthentication)) + .filter(Authentication::isAuthenticated) .flatMap(auth -> userService.getUser(auth.getName()) .flatMap(user -> { var roleNames = authoritiesToRoles(auth.getAuthorities()); diff --git a/application/src/main/java/run/halo/app/core/user/service/UserConnectionService.java b/application/src/main/java/run/halo/app/core/user/service/UserConnectionService.java new file mode 100644 index 0000000000..d695b07615 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/user/service/UserConnectionService.java @@ -0,0 +1,35 @@ +package run.halo.app.core.user.service; + +import org.springframework.security.oauth2.core.user.OAuth2User; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.UserConnection; + +public interface UserConnectionService { + + /** + * Create user connection. + * + * @param username Username + * @param registrationId Registration id + * @param oauth2User OAuth2 user + * @return Created user connection + */ + Mono createUserConnection( + String username, + String registrationId, + OAuth2User oauth2User + ); + + /** + * Update the user connection if present. + * If found, update updatedAt timestamp of the user connection. + * + * @param registrationId Registration id + * @param oauth2User OAuth2 user + * @return Updated user connection or empty + */ + Mono updateUserConnectionIfPresent( + 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 new file mode 100644 index 0000000000..291fec60bf --- /dev/null +++ b/application/src/main/java/run/halo/app/core/user/service/impl/UserConnectionServiceImpl.java @@ -0,0 +1,103 @@ +package run.halo.app.core.user.service.impl; + +import static run.halo.app.extension.ExtensionUtil.defaultSort; +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.equal; + +import java.time.Clock; +import java.util.HashMap; +import java.util.Optional; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.UserConnection; +import run.halo.app.core.extension.UserConnection.UserConnectionSpec; +import run.halo.app.core.user.service.UserConnectionService; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.MetadataOperator; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.exception.OAuth2UserAlreadyBoundException; +import run.halo.app.infra.utils.JsonUtils; + +@Service +public class UserConnectionServiceImpl implements UserConnectionService { + + private final ReactiveExtensionClient client; + + private Clock clock = Clock.systemDefaultZone(); + + public UserConnectionServiceImpl(ReactiveExtensionClient client) { + this.client = client; + } + + void setClock(Clock clock) { + this.clock = clock; + } + + @Override + public Mono createUserConnection( + String username, + String registrationId, + OAuth2User oauth2User + ) { + return getUserConnection(registrationId, username) + .flatMap(connection -> Mono.error( + () -> new OAuth2UserAlreadyBoundException(connection)) + ) + .switchIfEmpty(Mono.defer(() -> { + var connection = new UserConnection(); + connection.setMetadata(new Metadata()); + var metadata = connection.getMetadata(); + updateUserInfo(metadata, oauth2User); + metadata.setGenerateName(username + "-"); + connection.setSpec(new UserConnectionSpec()); + var spec = connection.getSpec(); + spec.setUsername(username); + spec.setProviderUserId(oauth2User.getName()); + spec.setRegistrationId(registrationId); + spec.setUpdatedAt(clock.instant()); + return client.create(connection); + })); + } + + private Mono updateUserConnection(UserConnection connection, + OAuth2User oauth2User) { + connection.getSpec().setUpdatedAt(clock.instant()); + updateUserInfo(connection.getMetadata(), oauth2User); + return client.update(connection); + } + + private Mono getUserConnection(String registrationId, String username) { + var listOptions = ListOptions.builder() + .fieldQuery(and( + equal("spec.registrationId", registrationId), + equal("spec.username", username) + )) + .build(); + return client.listAll(UserConnection.class, listOptions, defaultSort()).next(); + } + + @Override + public Mono updateUserConnectionIfPresent(String registrationId, + OAuth2User oauth2User) { + var listOptions = ListOptions.builder() + .fieldQuery(and( + equal("spec.registrationId", registrationId), + equal("spec.providerUserId", oauth2User.getName()) + )) + .build(); + return client.listAll(UserConnection.class, listOptions, defaultSort()).next() + .flatMap(connection -> updateUserConnection(connection, oauth2User)); + } + + private void updateUserInfo(MetadataOperator metadata, OAuth2User oauth2User) { + var annotations = Optional.ofNullable(metadata.getAnnotations()) + .orElseGet(HashMap::new); + metadata.setAnnotations(annotations); + annotations.put( + "auth.halo.run/oauth2-user-info", + JsonUtils.objectToJson(oauth2User.getAttributes()) + ); + } +} diff --git a/application/src/main/java/run/halo/app/infra/SchemeInitializer.java b/application/src/main/java/run/halo/app/infra/SchemeInitializer.java index 036fc1bc34..12e407be6a 100644 --- a/application/src/main/java/run/halo/app/infra/SchemeInitializer.java +++ b/application/src/main/java/run/halo/app/infra/SchemeInitializer.java @@ -630,6 +630,22 @@ public void onApplicationEvent(@NonNull ApplicationContextInitializedEvent event .map(UserConnectionSpec::getUsername) .orElse(null) ))); + is.add(new IndexSpec() + .setName("spec.registrationId") + .setIndexFunc(simpleAttribute(UserConnection.class, + connection -> Optional.ofNullable(connection.getSpec()) + .map(UserConnectionSpec::getRegistrationId) + .orElse(null) + )) + ); + is.add(new IndexSpec() + .setName("spec.providerUserId") + .setIndexFunc(simpleAttribute(UserConnection.class, + connection -> Optional.ofNullable(connection.getSpec()) + .map(UserConnectionSpec::getProviderUserId) + .orElse(null) + )) + ); }); // security.halo.run 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 dcf576c46c..4acfecc70e 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 9de837d9c0..14925846fb 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 @@ -4,6 +4,7 @@ import static org.springframework.security.web.server.authentication.ServerWebExchangeDelegatingReactiveAuthenticationManagerResolver.builder; import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; +import java.util.Collections; import java.util.concurrent.ConcurrentHashMap; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.ObjectProvider; @@ -12,17 +13,24 @@ import org.springframework.context.annotation.Bean; 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; import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.springframework.security.web.server.context.WebSessionServerSecurityContextRepository; import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +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; @@ -34,7 +42,7 @@ import run.halo.app.security.authentication.impl.RsaKeyService; import run.halo.app.security.authentication.pat.PatAuthenticationManager; import run.halo.app.security.authentication.pat.PatServerWebExchangeMatcher; -import run.halo.app.security.authentication.twofactor.TwoFactorAuthorizationManager; +import run.halo.app.security.authorization.AuthorityUtils; import run.halo.app.security.authorization.RequestInfoAuthorizationManager; import run.halo.app.security.session.InMemoryReactiveIndexedSessionRepository; import run.halo.app.security.session.ReactiveIndexedSessionRepository; @@ -63,8 +71,6 @@ SecurityWebFilterChain filterChain(ServerHttpSecurity http, var staticResourcesMatcher = pathMatchers(HttpMethod.GET, "/themes/{themeName}/assets/{*resourcePaths}", "/plugins/{pluginName}/assets/**", - "/console/**", - "/uc/**", "/upload/**", "/webjars/**", "/js/**", @@ -80,15 +86,26 @@ SecurityWebFilterChain filterChain(ServerHttpSecurity http, .authorizeExchange(spec -> spec.pathMatchers( "/api/**", "/apis/**", - "/oauth2/**", "/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)) - .pathMatchers("/challenges/two-factor/**") - .access(new TwoFactorAuthorizationManager()) .anyExchange().permitAll()) .anonymous(spec -> { - spec.authorities(AnonymousUserConst.Role); + spec.authorities(AuthorityUtils.ROLE_PREFIX + AnonymousUserConst.Role); spec.principal(AnonymousUserConst.PRINCIPAL); }) .securityContextRepository(securityContextRepository) @@ -158,4 +175,14 @@ CryptoService cryptoService(HaloProperties haloProperties) { return new RsaKeyService(haloProperties.getWorkDir().resolve("keys")); } + private static ServerWebExchangeMatcher createHtmlMatcher() { + ServerWebExchangeMatcher get = + ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, "/**"); + ServerWebExchangeMatcher notFavicon = new NegatedServerWebExchangeMatcher( + ServerWebExchangeMatchers.pathMatchers("/favicon.*")); + MediaTypeServerWebExchangeMatcher html = + new MediaTypeServerWebExchangeMatcher(MediaType.TEXT_HTML); + html.setIgnoredMediaTypes(Collections.singleton(MediaType.ALL)); + return new AndServerWebExchangeMatcher(get, notFavicon, html); + } } diff --git a/application/src/main/java/run/halo/app/infra/exception/OAuth2UserAlreadyBoundException.java b/application/src/main/java/run/halo/app/infra/exception/OAuth2UserAlreadyBoundException.java new file mode 100644 index 0000000000..3c5cca3458 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/OAuth2UserAlreadyBoundException.java @@ -0,0 +1,23 @@ +package run.halo.app.infra.exception; + +import org.springframework.web.server.ServerWebInputException; +import run.halo.app.core.extension.UserConnection; + +/** + * An exception that the user has been bound to another OAuth2 user. + * + * @author johnniang + * @since 2.20.0 + */ +public class OAuth2UserAlreadyBoundException extends ServerWebInputException { + + public OAuth2UserAlreadyBoundException(UserConnection connection) { + super("The user has been bound to another account", null, null, null, new Object[] { + connection.getSpec().getUsername(), + connection.getSpec().getProviderUserId(), + connection.getSpec().getRegistrationId(), + connection.getSpec().getUpdatedAt() + }); + } + +} 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 bdb5979e75..f3f9c1e03a 100644 --- a/application/src/main/java/run/halo/app/security/ExceptionSecurityConfigurer.java +++ b/application/src/main/java/run/halo/app/security/ExceptionSecurityConfigurer.java @@ -1,21 +1,73 @@ package run.halo.app.security; +import java.util.ArrayList; +import org.springframework.context.MessageSource; +import org.springframework.http.HttpStatus; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.oauth2.server.resource.web.access.server.BearerTokenServerAccessDeniedHandler; +import org.springframework.security.oauth2.server.resource.web.server.authentication.ServerBearerTokenAuthenticationConverter; +import org.springframework.security.web.server.DelegatingServerAuthenticationEntryPoint; +import org.springframework.security.web.server.authentication.AuthenticationConverterServerWebExchangeMatcher; +import org.springframework.security.web.server.authorization.HttpStatusServerAccessDeniedHandler; +import org.springframework.security.web.server.authorization.ServerWebExchangeDelegatingServerAccessDeniedHandler; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; 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.twofactor.TwoFactorAuthenticationEntryPoint; @Component public class ExceptionSecurityConfigurer implements SecurityConfigurer { + private final MessageSource messageSource; + + private final ServerResponse.Context context; + + public ExceptionSecurityConfigurer(MessageSource messageSource, + ServerResponse.Context context) { + this.messageSource = messageSource; + this.context = context; + } + @Override public void configure(ServerHttpSecurity http) { http.exceptionHandling(exception -> { - var accessDeniedHandler = new BearerTokenServerAccessDeniedHandler(); - var entryPoint = new DefaultServerAuthenticationEntryPoint(); - exception - .authenticationEntryPoint(entryPoint) - .accessDeniedHandler(accessDeniedHandler); + var accessDeniedHandlers = + new ArrayList( + 2 + ); + accessDeniedHandlers.add( + new ServerWebExchangeDelegatingServerAccessDeniedHandler.DelegateEntry( + new AuthenticationConverterServerWebExchangeMatcher( + new ServerBearerTokenAuthenticationConverter() + ), + new BearerTokenServerAccessDeniedHandler() + )); + accessDeniedHandlers.add( + new ServerWebExchangeDelegatingServerAccessDeniedHandler.DelegateEntry( + ServerWebExchangeMatchers.anyExchange(), + new HttpStatusServerAccessDeniedHandler(HttpStatus.FORBIDDEN) + ) + ); + + var entryPoints = + new ArrayList(3); + entryPoints.add(new DelegatingServerAuthenticationEntryPoint.DelegateEntry( + TwoFactorAuthenticationEntryPoint.MATCHER, + new TwoFactorAuthenticationEntryPoint(messageSource, context) + )); + entryPoints.add(new DelegatingServerAuthenticationEntryPoint.DelegateEntry( + exchange -> ServerWebExchangeMatcher.MatchResult.match(), + new DefaultServerAuthenticationEntryPoint() + )); + + exception.authenticationEntryPoint( + new DelegatingServerAuthenticationEntryPoint(entryPoints) + ) + .accessDeniedHandler( + new ServerWebExchangeDelegatingServerAccessDeniedHandler(accessDeniedHandlers) + ); }); } diff --git a/application/src/main/java/run/halo/app/security/LoginHandlerEnhancerImpl.java b/application/src/main/java/run/halo/app/security/LoginHandlerEnhancerImpl.java index c4497927fe..b23ed667e5 100644 --- a/application/src/main/java/run/halo/app/security/LoginHandlerEnhancerImpl.java +++ b/application/src/main/java/run/halo/app/security/LoginHandlerEnhancerImpl.java @@ -6,6 +6,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; +import run.halo.app.security.authentication.oauth2.OAuth2LoginHandlerEnhancer; import run.halo.app.security.authentication.rememberme.RememberMeRequestCache; import run.halo.app.security.authentication.rememberme.RememberMeServices; import run.halo.app.security.authentication.rememberme.WebSessionRememberMeRequestCache; @@ -29,12 +30,17 @@ public class LoginHandlerEnhancerImpl implements LoginHandlerEnhancer { private final RememberMeRequestCache rememberMeRequestCache = new WebSessionRememberMeRequestCache(); + private final OAuth2LoginHandlerEnhancer oauth2LoginHandlerEnhancer; + @Override public Mono onLoginSuccess(ServerWebExchange exchange, Authentication successfulAuthentication) { - return rememberMeServices.loginSuccess(exchange, successfulAuthentication) - .then(deviceService.loginSuccess(exchange, successfulAuthentication)) - .then(rememberMeRequestCache.removeRememberMe(exchange)); + return Mono.when( + rememberMeServices.loginSuccess(exchange, successfulAuthentication), + deviceService.loginSuccess(exchange, successfulAuthentication), + rememberMeRequestCache.removeRememberMe(exchange), + oauth2LoginHandlerEnhancer.loginSuccess(exchange, successfulAuthentication) + ); } @Override diff --git a/application/src/main/java/run/halo/app/security/SecurityWebFiltersConfigurer.java b/application/src/main/java/run/halo/app/security/SecurityWebFiltersConfigurer.java index daef125f6a..9559b5b037 100644 --- a/application/src/main/java/run/halo/app/security/SecurityWebFiltersConfigurer.java +++ b/application/src/main/java/run/halo/app/security/SecurityWebFiltersConfigurer.java @@ -4,7 +4,9 @@ import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.AUTHENTICATION; import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.FIRST; import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.FORM_LOGIN; +import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.HTTP_BASIC; import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.LAST; +import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.OAUTH2_AUTHORIZATION_CODE; import lombok.Setter; import org.pf4j.ExtensionPoint; @@ -35,6 +37,10 @@ public void configure(ServerHttpSecurity http) { new SecurityWebFilterChainProxy(BeforeSecurityWebFilter.class), FIRST ) + .addFilterAt( + new SecurityWebFilterChainProxy(HttpBasicSecurityWebFilter.class), + HTTP_BASIC + ) .addFilterAt( new SecurityWebFilterChainProxy(FormLoginSecurityWebFilter.class), FORM_LOGIN @@ -47,6 +53,10 @@ public void configure(ServerHttpSecurity http) { new SecurityWebFilterChainProxy(AnonymousAuthenticationSecurityWebFilter.class), ANONYMOUS_AUTHENTICATION ) + .addFilterAt( + new SecurityWebFilterChainProxy(OAuth2AuthorizationCodeSecurityWebFilter.class), + OAUTH2_AUTHORIZATION_CODE + ) .addFilterAt( new SecurityWebFilterChainProxy(AfterSecurityWebFilter.class), LAST 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 new file mode 100644 index 0000000000..40ca4bdbfa --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/oauth2/DefaultOAuth2LoginHandlerEnhancer.java @@ -0,0 +1,66 @@ +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.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import run.halo.app.core.user.service.UserConnectionService; + +/** + * Default implementation of {@link OAuth2LoginHandlerEnhancer}. + * + * @author johnniang + * @since 2.20.0 + */ +@Slf4j +@Component +public class DefaultOAuth2LoginHandlerEnhancer implements OAuth2LoginHandlerEnhancer { + + 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 (!authenticationTrustResolver.isFullyAuthenticated(authentication)) { + // Should never happen + // Remove token directly if not fully authenticated + return oauth2TokenCache.removeToken(exchange).then(); + } + return oauth2TokenCache.getToken(exchange) + .flatMap(oauth2Token -> { + var oauth2User = oauth2Token.getPrincipal(); + var username = authentication.getName(); + var registrationId = oauth2Token.getAuthorizedClientRegistrationId(); + return connectionService.updateUserConnectionIfPresent(registrationId, oauth2User) + .doOnNext(connection -> { + if (log.isDebugEnabled()) { + log.debug( + "User connection already exists, skip creating. connection: [{}]", + connection + ); + } + }) + .switchIfEmpty(Mono.defer(() -> connectionService.createUserConnection( + username, + registrationId, + oauth2User + ))) + .then(oauth2TokenCache.removeToken(exchange)); + }); + } + +} 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 0000000000..e4e5ba64c0 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/oauth2/MapOAuth2AuthenticationFilter.java @@ -0,0 +1,128 @@ +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.updateUserConnectionIfPresent( + 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/OAuth2AuthenticationTokenCache.java b/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2AuthenticationTokenCache.java new file mode 100644 index 0000000000..9c2bf964bd --- /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/OAuth2LoginHandlerEnhancer.java b/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2LoginHandlerEnhancer.java new file mode 100644 index 0000000000..dbf9f8ec70 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2LoginHandlerEnhancer.java @@ -0,0 +1,17 @@ +package run.halo.app.security.authentication.oauth2; + +import org.springframework.security.core.Authentication; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * OAuth2 login handler enhancer. + * + * @author johnniang + * @since 2.20.0 + */ +public interface OAuth2LoginHandlerEnhancer { + + Mono loginSuccess(ServerWebExchange exchange, Authentication authentication); + +} 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 new file mode 100644 index 0000000000..d4bf79f447 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2SecurityConfigurer.java @@ -0,0 +1,40 @@ +package run.halo.app.security.authentication.oauth2; + +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 +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 mapOAuth2Filter = new MapOAuth2AuthenticationFilter( + securityContextRepository, connectionService, userDetailsService + ); + http.addFilterBefore(mapOAuth2Filter, SecurityWebFiltersOrder.AUTHENTICATION); + } +} 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 0000000000..44995a8f93 --- /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 new file mode 100644 index 0000000000..b7af274a78 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthenticationEntryPoint.java @@ -0,0 +1,58 @@ +package run.halo.app.security.authentication.twofactor; + +import java.net.URI; +import org.springframework.context.MessageSource; +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.reactive.function.server.ServerResponse; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import run.halo.app.infra.exception.Exceptions; + +public class TwoFactorAuthenticationEntryPoint implements ServerAuthenticationEntryPoint { + + public static ServerWebExchangeMatcher MATCHER = exchange -> exchange.getPrincipal() + .filter(TwoFactorAuthentication.class::isInstance) + .flatMap(a -> ServerWebExchangeMatcher.MatchResult.match()) + .switchIfEmpty(ServerWebExchangeMatcher.MatchResult.notMatch()); + + private static final String REDIRECT_LOCATION = "/challenges/two-factor/totp"; + + private final RedirectServerAuthenticationEntryPoint redirectEntryPoint = + new RedirectServerAuthenticationEntryPoint(REDIRECT_LOCATION); + + private final MessageSource messageSource; + + private final ServerResponse.Context context; + + private static final ServerWebExchangeMatcher XHR_MATCHER = exchange -> { + if (exchange.getRequest().getHeaders().getOrEmpty("X-Requested-With") + .contains("XMLHttpRequest")) { + return ServerWebExchangeMatcher.MatchResult.match(); + } + return ServerWebExchangeMatcher.MatchResult.notMatch(); + }; + + public TwoFactorAuthenticationEntryPoint(MessageSource messageSource, + ServerResponse.Context context) { + this.messageSource = messageSource; + this.context = context; + } + + @Override + public Mono commence(ServerWebExchange exchange, AuthenticationException ex) { + return XHR_MATCHER.matches(exchange) + .filter(ServerWebExchangeMatcher.MatchResult::isMatch) + .switchIfEmpty(redirectEntryPoint.commence(exchange, ex).then(Mono.empty())) + .flatMap(isXhr -> { + var errorResponse = Exceptions.createErrorResponse( + new TwoFactorAuthRequiredException(URI.create(REDIRECT_LOCATION)), + null, exchange, messageSource); + return ServerResponse.status(errorResponse.getStatusCode()) + .bodyValue(errorResponse.getBody()) + .flatMap(response -> response.writeTo(exchange, context)); + }); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthorizationManager.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthorizationManager.java deleted file mode 100644 index f61cb9390b..0000000000 --- a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthorizationManager.java +++ /dev/null @@ -1,20 +0,0 @@ -package run.halo.app.security.authentication.twofactor; - -import org.springframework.security.authorization.AuthorizationDecision; -import org.springframework.security.authorization.ReactiveAuthorizationManager; -import org.springframework.security.core.Authentication; -import org.springframework.security.web.server.authorization.AuthorizationContext; -import reactor.core.publisher.Mono; - -public class TwoFactorAuthorizationManager - implements ReactiveAuthorizationManager { - - @Override - public Mono check(Mono authentication, - AuthorizationContext context) { - return authentication.map(TwoFactorAuthentication.class::isInstance) - .defaultIfEmpty(false) - .map(AuthorizationDecision::new); - } - -} diff --git a/application/src/main/java/run/halo/app/security/authorization/Attributes.java b/application/src/main/java/run/halo/app/security/authorization/Attributes.java index a68317ba76..55cf3f3976 100644 --- a/application/src/main/java/run/halo/app/security/authorization/Attributes.java +++ b/application/src/main/java/run/halo/app/security/authorization/Attributes.java @@ -1,7 +1,5 @@ package run.halo.app.security.authorization; -import java.security.Principal; - /** * Attributes is used by an Authorizer to get information about a request * that is used to make an authorization decision. @@ -10,10 +8,6 @@ * @since 2.0.0 */ public interface Attributes { - /** - * @return the UserDetails object to authorize - */ - Principal getPrincipal(); /** * @return the verb associated with API requests(this includes get, list, diff --git a/application/src/main/java/run/halo/app/security/authorization/AttributesRecord.java b/application/src/main/java/run/halo/app/security/authorization/AttributesRecord.java index 3fb6833671..af7eaf0637 100644 --- a/application/src/main/java/run/halo/app/security/authorization/AttributesRecord.java +++ b/application/src/main/java/run/halo/app/security/authorization/AttributesRecord.java @@ -1,23 +1,14 @@ package run.halo.app.security.authorization; -import java.security.Principal; - /** * @author guqing * @since 2.0.0 */ public class AttributesRecord implements Attributes { private final RequestInfo requestInfo; - private final Principal principal; - public AttributesRecord(Principal principal, RequestInfo requestInfo) { + public AttributesRecord(RequestInfo requestInfo) { this.requestInfo = requestInfo; - this.principal = principal; - } - - @Override - public Principal getPrincipal() { - return this.principal; } @Override diff --git a/application/src/main/java/run/halo/app/security/authorization/AuthorityUtils.java b/application/src/main/java/run/halo/app/security/authorization/AuthorityUtils.java index 64460ea9c2..75605e35f2 100644 --- a/application/src/main/java/run/halo/app/security/authorization/AuthorityUtils.java +++ b/application/src/main/java/run/halo/app/security/authorization/AuthorityUtils.java @@ -39,8 +39,8 @@ public static Set authoritiesToRoles( Collection authorities) { return authorities.stream() .map(GrantedAuthority::getAuthority) + .filter(authority -> StringUtils.startsWith(authority, ROLE_PREFIX)) .map(authority -> { - authority = StringUtils.removeStart(authority, SCOPE_PREFIX); authority = StringUtils.removeStart(authority, ROLE_PREFIX); return authority; }) diff --git a/application/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java b/application/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java index 0294d55de4..d13b774dc2 100644 --- a/application/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java +++ b/application/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java @@ -27,7 +27,7 @@ public DefaultRuleResolver(RoleService roleService) { public Mono visitRules(Authentication authentication, RequestInfo requestInfo) { var roleNames = AuthorityUtils.authoritiesToRoles(authentication.getAuthorities()); - var record = new AttributesRecord(authentication, requestInfo); + var record = new AttributesRecord(requestInfo); var visitor = new AuthorizingVisitor(record); // If the request is an userspace scoped request, 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 0000000000..eb6e807844 --- /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 ff3687f77f..5114386942 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 @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.module.SimpleModule; import org.springframework.security.jackson2.SecurityJackson2Modules; import run.halo.app.security.authentication.login.HaloUser; +import run.halo.app.security.authentication.oauth2.HaloOAuth2AuthenticationToken; import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; /** @@ -21,8 +22,12 @@ public HaloSecurityJackson2Module() { public void setupModule(SetupContext context) { SecurityJackson2Modules.enableDefaultTyping(context.getOwner()); context.setMixInAnnotations(HaloUser.class, HaloUserMixin.class); - context.setMixInAnnotations(TwoFactorAuthentication.class, - TwoFactorAuthenticationMixin.class); + context.setMixInAnnotations( + TwoFactorAuthentication.class, TwoFactorAuthenticationMixin.class + ); + context.setMixInAnnotations( + HaloOAuth2AuthenticationToken.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 71c1f737ea..d38ae936b9 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 @@ -20,6 +20,8 @@ abstract class TwoFactorAuthenticationMixin { @JsonCreator - TwoFactorAuthenticationMixin(@JsonProperty("previous") Authentication previous) { + TwoFactorAuthenticationMixin( + @JsonProperty("previous") Authentication previous + ) { } } diff --git a/application/src/main/java/run/halo/app/theme/dialect/HaloSpringSecurityDialect.java b/application/src/main/java/run/halo/app/theme/dialect/HaloSpringSecurityDialect.java index 31f2b623a1..7c7a44082b 100644 --- a/application/src/main/java/run/halo/app/theme/dialect/HaloSpringSecurityDialect.java +++ b/application/src/main/java/run/halo/app/theme/dialect/HaloSpringSecurityDialect.java @@ -13,6 +13,7 @@ import org.thymeleaf.extras.springsecurity6.dialect.SpringSecurityDialect; import org.thymeleaf.extras.springsecurity6.util.SpringSecurityContextUtils; import org.thymeleaf.extras.springsecurity6.util.SpringVersionUtils; +import run.halo.app.security.authorization.AuthorityUtils; /** * HaloSpringSecurityDialect overwrites value of thymeleafSpringSecurityContext. @@ -40,7 +41,9 @@ public void afterPropertiesSet() { // We have to build an anonymous authentication token here because the token won't be saved // into repository during anonymous authentication. var anonymousAuthentication = - new AnonymousAuthenticationToken("fallback", PRINCIPAL, createAuthorityList(Role)); + new AnonymousAuthenticationToken( + "fallback", PRINCIPAL, createAuthorityList(AuthorityUtils.ROLE_PREFIX + Role) + ); var anonymousSecurityContext = new SecurityContextImpl(anonymousAuthentication); final Function secCtxInitializer = diff --git a/application/src/main/resources/config/i18n/messages.properties b/application/src/main/resources/config/i18n/messages.properties index d90b894f46..b053fc49c9 100644 --- a/application/src/main/resources/config/i18n/messages.properties +++ b/application/src/main/resources/config/i18n/messages.properties @@ -30,6 +30,7 @@ problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$NotFo problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$WrongVersionsException=Wrong Dependency Version problemDetail.title.run.halo.app.infra.exception.PluginDependentsNotDisabledException=Dependents Not Disabled problemDetail.title.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=Dependencies Not Enabled +problemDetail.title.run.halo.app.infra.exception.OAuth2UserAlreadyBoundException=User Already Bound Error problemDetail.title.internalServerError=Internal Server Error problemDetail.title.conflict=Conflict @@ -55,6 +56,7 @@ problemDetail.run.halo.app.infra.exception.PluginDependencyException$NotFoundExc problemDetail.run.halo.app.infra.exception.PluginDependencyException$WrongVersionsException=Dependencies have wrong version: {0}. problemDetail.run.halo.app.infra.exception.PluginDependentsNotDisabledException=Plugin dependents {0} are not fully disabled, please disable them first. problemDetail.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=Plugin dependencies {0} are not fully enabled, please enable them first. +problemDetail.run.halo.app.infra.exception.OAuth2UserAlreadyBoundException=The user {0} has already been bound to another OAuth2 user, cannot automatically bind the current OAuth2 user. problemDetail.index.duplicateKey=The value of {0} already exists for unique index {1}, please rename it and retry. problemDetail.user.email.verify.maxAttempts=Too many verification attempts, please try again later. diff --git a/application/src/main/resources/config/i18n/messages_zh.properties b/application/src/main/resources/config/i18n/messages_zh.properties index 613e9f7c32..8655c96a08 100644 --- a/application/src/main/resources/config/i18n/messages_zh.properties +++ b/application/src/main/resources/config/i18n/messages_zh.properties @@ -18,6 +18,8 @@ problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$NotFo problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$WrongVersionsException=依赖版本错误 problemDetail.title.run.halo.app.infra.exception.PluginDependentsNotDisabledException=子插件未禁用 problemDetail.title.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=依赖未启用 +problemDetail.title.run.halo.app.infra.exception.OAuth2UserAlreadyBoundException=用户已绑定错误 + problemDetail.title.internalServerError=服务器内部错误 problemDetail.title.conflict=冲突 @@ -32,6 +34,7 @@ problemDetail.run.halo.app.infra.exception.PluginDependencyException$NotFoundExc problemDetail.run.halo.app.infra.exception.PluginDependencyException$WrongVersionsException=依赖版本有误:{0}。 problemDetail.run.halo.app.infra.exception.PluginDependentsNotDisabledException=子插件 {0} 未完全禁用,请先禁用它们。 problemDetail.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=插件依赖 {0} 未完全启用,请先启用它们。 +problemDetail.run.halo.app.infra.exception.OAuth2UserAlreadyBoundException=用户 {0} 已经绑定到另一个 OAuth2 用户,无法自动绑定当前 OAuth2 用户。 problemDetail.index.duplicateKey=唯一索引 {1} 中的值 {0} 已存在,请更名后重试。 problemDetail.user.email.verify.maxAttempts=尝试次数过多,请稍候再试。 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 87e8fe64ca..b9cdf7148b 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 5752dae82f..99ab4a3175 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 90daeec12b..dbe74bc626 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,5 +1,6 @@ messages.loginError=Invalid credentials. messages.logoutSuccess=Logout successfully. +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. messages.signupSuccess=Congratulations! Sign up successfully, please login now. error.invalid-credential=Invalid credentials. diff --git a/application/src/test/java/run/halo/app/config/WebFluxConfigTest.java b/application/src/test/java/run/halo/app/config/WebFluxConfigTest.java index 55b7fec6f1..dc94b34907 100644 --- a/application/src/test/java/run/halo/app/config/WebFluxConfigTest.java +++ b/application/src/test/java/run/halo/app/config/WebFluxConfigTest.java @@ -10,6 +10,8 @@ import org.hamcrest.core.StringStartsWith; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; import org.springframework.boot.test.context.SpringBootTest; @@ -17,6 +19,7 @@ import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; +import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.filter.reactive.ServerWebExchangeContextFilter; @@ -121,23 +124,32 @@ public WebSocketHandler handler() { @Nested class ConsoleRequest { + @WithMockUser + @ParameterizedTest + @ValueSource(strings = { + "/console", + "/console/index", + "/console/index.html", + "/console/dashboard", + "/console/fake" + }) + void shouldRequestConsoleIndex(String uri) { + webClient.get().uri(uri) + .exchange() + .expectStatus().isOk() + .expectBody(String.class).value(StringStartsWith.startsWith("console index")); + } + @Test - void shouldRequestConsoleIndex() { - List.of( - "/console", - "/console/index", - "/console/index.html", - "/console/dashboard", - "/console/fake" - ) - .forEach(uri -> webClient.get().uri(uri) - .exchange() - .expectStatus().isOk() - .expectBody(String.class).value(StringStartsWith.startsWith("console index")) - ); + void shouldRedirectToLoginPageIfUnauthenticated() { + webClient.get().uri("/console") + .exchange() + .expectStatus().isFound() + .expectHeader().location("/login?authentication_required"); } @Test + @WithMockUser void shouldRequestConsoleAssetsCorrectly() { webClient.get().uri("/console/assets/fake.txt") .exchange() @@ -146,6 +158,7 @@ void shouldRequestConsoleAssetsCorrectly() { } @Test + @WithMockUser void shouldResponseNotFoundWhenAssetsNotExist() { webClient.get().uri("/console/assets/not-found.txt") .exchange() diff --git a/application/src/test/java/run/halo/app/security/authorization/AuthorityUtilsTest.java b/application/src/test/java/run/halo/app/security/authorization/AuthorityUtilsTest.java index f9266434b0..4ac5082d3d 100644 --- a/application/src/test/java/run/halo/app/security/authorization/AuthorityUtilsTest.java +++ b/application/src/test/java/run/halo/app/security/authorization/AuthorityUtilsTest.java @@ -29,7 +29,7 @@ void authoritiesToRolesTest() { var roles = authoritiesToRoles(authorities); - assertEquals(Set.of("admin", "owner", "manager", "faker", "system:read"), roles); + assertEquals(Set.of("admin", "owner", "manager"), roles); } @Test diff --git a/application/src/test/java/run/halo/app/security/authorization/DefaultRuleResolverTest.java b/application/src/test/java/run/halo/app/security/authorization/DefaultRuleResolverTest.java index bc91cd9d1a..9190fbafb6 100644 --- a/application/src/test/java/run/halo/app/security/authorization/DefaultRuleResolverTest.java +++ b/application/src/test/java/run/halo/app/security/authorization/DefaultRuleResolverTest.java @@ -37,7 +37,7 @@ class DefaultRuleResolverTest { void visitRules() { when(roleService.listDependenciesFlux(Set.of("ruleReadPost"))) .thenReturn(Flux.just(mockRole())); - var fakeUser = new User("admin", "123456", createAuthorityList("ruleReadPost")); + var fakeUser = new User("admin", "123456", createAuthorityList("ROLE_ruleReadPost")); var authentication = authenticated(fakeUser, fakeUser.getPassword(), fakeUser.getAuthorities()); @@ -59,7 +59,7 @@ void visitRules() { void visitRulesForUserspaceScope() { when(roleService.listDependenciesFlux(Set.of("ruleReadPost"))) .thenReturn(Flux.just(mockRole())); - var fakeUser = new User("admin", "123456", createAuthorityList("ruleReadPost")); + var fakeUser = new User("admin", "123456", createAuthorityList("ROLE_ruleReadPost")); var authentication = authenticated(fakeUser, fakeUser.getPassword(), fakeUser.getAuthorities()); var cases = List.of( 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 55804bbf7c..a548c25c69 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 @@ -4,6 +4,8 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.Map; import java.util.function.Function; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -14,7 +16,10 @@ import org.springframework.security.core.context.SecurityContextImpl; import org.springframework.security.core.userdetails.User; import org.springframework.security.jackson2.SecurityJackson2Modules; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; import run.halo.app.security.authentication.login.HaloUser; +import run.halo.app.security.authentication.oauth2.HaloOAuth2AuthenticationToken; import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; class HaloSecurityJacksonModuleTest { @@ -39,10 +44,21 @@ void codecHaloUserTest() throws JsonProcessingException { @Test void codecTwoFactorAuthenticationTokenTest() throws JsonProcessingException { - codecAssert(haloUser -> new TwoFactorAuthentication( - UsernamePasswordAuthenticationToken.authenticated(haloUser, + codecAssert(haloUser -> { + var authentication = UsernamePasswordAuthenticationToken.authenticated(haloUser, haloUser.getPassword(), - haloUser.getAuthorities()))); + haloUser.getAuthorities()); + return new TwoFactorAuthentication(authentication); + }); + } + + @Test + void codecHaloOAuth2AuthenticationTokenTest() throws JsonProcessingException { + codecAssert(haloUser -> { + var oauth2User = new DefaultOAuth2User(List.of(), Map.of("name", "halo"), "name"); + var oauth2Token = new OAuth2AuthenticationToken(oauth2User, List.of(), "github"); + return new HaloOAuth2AuthenticationToken(haloUser, oauth2Token); + }); } void codecAssert(Function authenticationConverter)