diff --git a/build.gradle b/build.gradle index 8fb0b0e..dda410b 100644 --- a/build.gradle +++ b/build.gradle @@ -15,7 +15,7 @@ repositories { } dependencies { - implementation platform('run.halo.tools.platform:plugin:2.17.0-SNAPSHOT') + implementation platform('run.halo.tools.platform:plugin:2.20.0-SNAPSHOT') compileOnly 'run.halo.app:api' testImplementation 'run.halo.app:api' diff --git a/src/main/java/run/halo/oauth/DefaultOAuth2AuthorizedClientService.java b/src/main/java/run/halo/oauth/DefaultOAuth2AuthorizedClientService.java index 968b34d..d0f035e 100644 --- a/src/main/java/run/halo/oauth/DefaultOAuth2AuthorizedClientService.java +++ b/src/main/java/run/halo/oauth/DefaultOAuth2AuthorizedClientService.java @@ -16,6 +16,7 @@ import reactor.core.publisher.Mono; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.security.authentication.oauth2.HaloOAuth2AuthenticationToken; /** * Implementations of this interface are responsible for the management of Authorized Client(s), @@ -60,6 +61,9 @@ public Mono saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal) { Assert.notNull(authorizedClient, "authorizedClient cannot be null"); Assert.notNull(principal, "principal cannot be null"); + if (principal instanceof HaloOAuth2AuthenticationToken haloOAuthToken) { + principal = haloOAuthToken.getOriginal(); + } String registrationId = authorizedClient.getClientRegistration().getRegistrationId(); return client.fetch(AuthorizedClient.class, authorizedClientName(registrationId, principal.getName()) diff --git a/src/main/java/run/halo/oauth/DefaultSocialUserDetailsService.java b/src/main/java/run/halo/oauth/DefaultSocialUserDetailsService.java deleted file mode 100644 index ad08a79..0000000 --- a/src/main/java/run/halo/oauth/DefaultSocialUserDetailsService.java +++ /dev/null @@ -1,52 +0,0 @@ -package run.halo.oauth; - -import java.util.Comparator; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.userdetails.ReactiveUserDetailsService; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.stereotype.Component; -import reactor.core.publisher.Mono; -import run.halo.app.core.extension.UserConnection; -import run.halo.app.extension.ReactiveExtensionClient; - -/** - * Default social user details service. - * - * @author guqing - * @since 1.0.0 - */ -@Component -@RequiredArgsConstructor -public class DefaultSocialUserDetailsService implements SocialUserDetailsService { - - private final ReactiveExtensionClient client; - private final ReactiveUserDetailsService userDetailsService; - - @Override - public Mono loadUserByUserId(String registrationId, String principalName) - throws UsernameNotFoundException { - return getUserConnectionByProviderUserId(registrationId, principalName) - .flatMap(userConnection -> { - String username = userConnection.getSpec().getUsername(); - return userDetailsService.findByUsername(username) - .switchIfEmpty(Mono.error( - new UsernameNotFoundException("User not found: " + username)) - ); - }); - } - - Mono getUserConnectionByProviderUserId(String registrationId, - String providerUserId) { - return client.list(UserConnection.class, - connection -> connection.getSpec().getRegistrationId().equals(registrationId) - && connection.getSpec().getProviderUserId().equals(providerUserId), - Comparator.comparing(item -> item.getMetadata() - .getCreationTimestamp()) - ) - .next() - .switchIfEmpty(Mono.error(new UsernameNotFoundException( - "The oauth2 account " + providerUserId - + " is not bound to a specified user."))); - } -} diff --git a/src/main/java/run/halo/oauth/DefaultUserDetailsService.java b/src/main/java/run/halo/oauth/DefaultUserDetailsService.java deleted file mode 100644 index 2e15a30..0000000 --- a/src/main/java/run/halo/oauth/DefaultUserDetailsService.java +++ /dev/null @@ -1,62 +0,0 @@ -package run.halo.oauth; - -import static run.halo.app.core.extension.User.GROUP; -import static run.halo.app.core.extension.User.KIND; - -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.userdetails.ReactiveUserDetailsService; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.stereotype.Component; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import run.halo.app.core.extension.Role; -import run.halo.app.core.extension.RoleBinding; -import run.halo.app.core.extension.User; -import run.halo.app.extension.GroupKind; -import run.halo.app.extension.ReactiveExtensionClient; - -/** - * A default implementation for finding the UserDetails by username. - * - * @author guqing - * @since 2.0.0 - */ -@Component -@RequiredArgsConstructor -public class DefaultUserDetailsService implements ReactiveUserDetailsService { - public static final String AUTHENTICATED_ROLE_NAME = "authenticated"; - public static final String ANONYMOUS_ROLE_NAME = "anonymous"; - - private final ReactiveExtensionClient client; - - @Override - public Mono findByUsername(String username) { - return this.client.fetch(User.class, username) - .flatMap(user -> { - var subject = new RoleBinding.Subject(KIND, username, GROUP); - return listRoleRefs(subject) - .filter(this::isRoleRef) - .map(RoleBinding.RoleRef::getName) - .concatWithValues(AUTHENTICATED_ROLE_NAME, ANONYMOUS_ROLE_NAME) - .collectList() - .map(roleNames -> org.springframework.security.core.userdetails.User.builder() - .username(username) - .password(user.getSpec().getPassword()) - .roles(roleNames.toArray(new String[0])) - .build()); - }); - } - - public Flux listRoleRefs(RoleBinding.Subject subject) { - return client.list(RoleBinding.class, - binding -> binding.getSubjects().contains(subject), - null) - .map(RoleBinding::getRoleRef); - } - - private boolean isRoleRef(RoleBinding.RoleRef roleRef) { - var roleGvk = new Role().groupVersionKind(); - var gk = new GroupKind(roleRef.getApiGroup(), roleRef.getKind()); - return gk.equals(roleGvk.groupKind()); - } -} diff --git a/src/main/java/run/halo/oauth/HaloOAuth2AuthenticationWebFilter.java b/src/main/java/run/halo/oauth/HaloOAuth2AuthenticationWebFilter.java new file mode 100644 index 0000000..63be9bd --- /dev/null +++ b/src/main/java/run/halo/oauth/HaloOAuth2AuthenticationWebFilter.java @@ -0,0 +1,58 @@ +package run.halo.oauth; + +import org.springframework.security.oauth2.client.authentication.OAuth2LoginReactiveAuthenticationManager; +import org.springframework.security.oauth2.client.endpoint.WebClientReactiveAuthorizationCodeTokenResponseClient; +import org.springframework.security.oauth2.client.userinfo.DefaultReactiveOAuth2UserService; +import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizationCodeAuthenticationTokenConverter; +import org.springframework.security.oauth2.client.web.server.authentication.OAuth2LoginAuthenticationWebFilter; +import org.springframework.security.web.server.authentication.RedirectServerAuthenticationFailureHandler; +import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler; +import org.springframework.security.web.server.context.ServerSecurityContextRepository; +import org.springframework.stereotype.Component; +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.security.AuthenticationSecurityWebFilter; + +/** + * OAuth2 authentication web filter. + * + * @author johnniang + * @since 2.20.0 + */ +@Component +public class HaloOAuth2AuthenticationWebFilter implements AuthenticationSecurityWebFilter { + + private final WebFilter delegate; + + public HaloOAuth2AuthenticationWebFilter(Oauth2LoginConfiguration configuration, + ServerSecurityContextRepository securityContextRepository) { + var authManager = new OAuth2LoginReactiveAuthenticationManager( + new WebClientReactiveAuthorizationCodeTokenResponseClient(), + new DefaultReactiveOAuth2UserService() + ); + var filter = new OAuth2LoginAuthenticationWebFilter(authManager, + configuration.getAuthorizedClientRepository()); + filter.setRequiresAuthenticationMatcher(configuration.getAuthenticationMatcher()); + var converter = new ServerOAuth2AuthorizationCodeAuthenticationTokenConverter( + configuration.getClientRegistrationRepository() + ); + var successHandler = new RedirectServerAuthenticationSuccessHandler("/uc"); + successHandler.setRequestCache(configuration.getRequestCache()); + filter.setAuthenticationSuccessHandler(successHandler); + filter.setAuthenticationFailureHandler( + new RedirectServerAuthenticationFailureHandler("/login?oauth2_error") + ); + filter.setServerAuthenticationConverter(converter); + filter.setSecurityContextRepository(securityContextRepository); + + this.delegate = filter; + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + return delegate.filter(exchange, chain); + } + +} diff --git a/src/main/java/run/halo/oauth/HaloOAuth2RedirectWebFilter.java b/src/main/java/run/halo/oauth/HaloOAuth2RedirectWebFilter.java new file mode 100644 index 0000000..c160467 --- /dev/null +++ b/src/main/java/run/halo/oauth/HaloOAuth2RedirectWebFilter.java @@ -0,0 +1,33 @@ +package run.halo.oauth; + +import org.springframework.security.oauth2.client.web.server.OAuth2AuthorizationRequestRedirectWebFilter; +import org.springframework.stereotype.Component; +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.security.HttpBasicSecurityWebFilter; + +@Component +public class HaloOAuth2RedirectWebFilter implements HttpBasicSecurityWebFilter { + + private final WebFilter delegate; + + public HaloOAuth2RedirectWebFilter(Oauth2LoginConfiguration configuration) { + this.delegate = createDelegate(configuration); + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + return delegate.filter(exchange, chain); + } + + private static OAuth2AuthorizationRequestRedirectWebFilter createDelegate( + Oauth2LoginConfiguration configuration + ) { + return new OAuth2AuthorizationRequestRedirectWebFilter( + configuration.getClientRegistrationRepository() + ); + } + +} diff --git a/src/main/java/run/halo/oauth/ListedConnection.java b/src/main/java/run/halo/oauth/ListedConnection.java deleted file mode 100644 index a4190ac..0000000 --- a/src/main/java/run/halo/oauth/ListedConnection.java +++ /dev/null @@ -1,31 +0,0 @@ -package run.halo.oauth; - -import lombok.Builder; -import lombok.Value; - -/** - * Connection information for listing. - * - * @author guqing - * @since 1.0.0 - */ -@Builder -@Value -public class ListedConnection { - String registrationId; - String username; - String displayName; - String profileUrl; - String avatarUrl; - SimpleAuthProvider provider; - - @Builder - @Value - public static class SimpleAuthProvider { - String displayName; - String logo; - String website; - String authenticationUrl; - String helpPage; - } -} diff --git a/src/main/java/run/halo/oauth/Oauth2Authenticator.java b/src/main/java/run/halo/oauth/Oauth2Authenticator.java deleted file mode 100644 index 5dd4b5a..0000000 --- a/src/main/java/run/halo/oauth/Oauth2Authenticator.java +++ /dev/null @@ -1,258 +0,0 @@ -package run.halo.oauth; - -import static org.apache.commons.lang3.StringUtils.defaultIfBlank; -import static org.apache.commons.lang3.StringUtils.defaultString; -import static run.halo.oauth.SocialServerOauth2AuthorizationRequestResolver.SOCIAL_CONNECTION; - -import java.net.URI; -import java.util.Map; -import org.apache.commons.lang3.StringUtils; -import org.springframework.http.server.reactive.ServerHttpRequest; -import org.springframework.lang.NonNull; -import org.springframework.security.authentication.ReactiveAuthenticationManager; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.config.web.server.SecurityWebFiltersOrder; -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.oauth2.client.OAuth2AuthorizedClient; -import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken; -import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository; -import org.springframework.security.oauth2.client.web.server.authentication.OAuth2LoginAuthenticationWebFilter; -import org.springframework.security.oauth2.core.user.OAuth2User; -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.AuthenticationWebFilter; -import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler; -import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler; -import org.springframework.security.web.server.context.ServerSecurityContextRepository; -import org.springframework.stereotype.Component; -import org.springframework.util.Assert; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.server.ServerWebExchange; -import org.springframework.web.server.WebFilterChain; -import org.springframework.web.util.UriComponentsBuilder; -import org.springframework.web.util.UriUtils; -import reactor.core.publisher.Mono; -import run.halo.app.security.AdditionalWebFilter; -import run.halo.app.security.LoginHandlerEnhancer; - -/** - * Oauth2 authenticator. - * - * @author guqing - * @since 1.0.0 - */ -@Component -public class Oauth2Authenticator implements AdditionalWebFilter { - private final Oauth2LoginConfiguration oauth2LoginConfiguration; - private final ServerSecurityContextRepository securityContextRepository; - private final AuthenticationWebFilter authenticationWebFilter; - private final SocialUserDetailsService socialUserDetailsService; - private final UserConnectionService userConnectionService; - - public Oauth2Authenticator(Oauth2LoginConfiguration oauth2LoginConfiguration, - ServerSecurityContextRepository securityContextRepository, - SocialUserDetailsService socialUserDetailsService, - UserConnectionService userConnectionService) { - this.oauth2LoginConfiguration = oauth2LoginConfiguration; - this.securityContextRepository = securityContextRepository; - this.socialUserDetailsService = socialUserDetailsService; - this.userConnectionService = userConnectionService; - - this.authenticationWebFilter = createAuthenticationWebFilter(); - } - - @Override - @NonNull - public Mono filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) { - return authenticationWebFilter.filter(exchange, chain); - } - - @Override - public int getOrder() { - return SecurityWebFiltersOrder.AUTHENTICATION.getOrder(); - } - - AuthenticationWebFilter createAuthenticationWebFilter() { - ReactiveAuthenticationManager manager = oauth2LoginConfiguration.getAuthenticationManager(); - - ServerOAuth2AuthorizedClientRepository authorizedClientRepository = - oauth2LoginConfiguration.getAuthorizedClientRepository(); - - AuthenticationWebFilter authenticationFilter = - new SocialLoginAuthenticationWebFilter(manager, - authorizedClientRepository); - authenticationFilter.setRequiresAuthenticationMatcher( - oauth2LoginConfiguration.getAuthenticationMatcher()); - authenticationFilter.setServerAuthenticationConverter( - oauth2LoginConfiguration.getAuthenticationConverter()); - authenticationFilter.setAuthenticationFailureHandler( - oauth2LoginConfiguration.getAuthenticationFailureHandler()); - authenticationFilter.setSecurityContextRepository(this.securityContextRepository); - return authenticationFilter; - } - - private LoginHandlerEnhancer getLoginHandlerEnhancer() { - return oauth2LoginConfiguration.getLoginHandlerEnhancer(); - } - - class SocialLoginAuthenticationWebFilter extends OAuth2LoginAuthenticationWebFilter { - - private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); - private final ServerOAuth2AuthorizedClientRepository authorizedClientRepository; - - /** - * Creates an instance. - * - * @param authenticationManager the authentication manager to use - * @param authorizedClientRepository optional authorized client repository to use - */ - public SocialLoginAuthenticationWebFilter( - ReactiveAuthenticationManager authenticationManager, - ServerOAuth2AuthorizedClientRepository authorizedClientRepository) { - super(authenticationManager, authorizedClientRepository); - - Assert.notNull(authorizedClientRepository, "authorizedClientService cannot be null"); - this.authorizedClientRepository = authorizedClientRepository; - } - - @Override - protected Mono onAuthenticationSuccess(Authentication authentication, - WebFilterExchange webFilterExchange) { - OAuth2LoginAuthenticationToken authenticationResult = - (OAuth2LoginAuthenticationToken) authentication; - OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient( - authenticationResult.getClientRegistration(), authenticationResult.getName(), - authenticationResult.getAccessToken(), authenticationResult.getRefreshToken()); - String registrationId = authorizedClient.getClientRegistration().getRegistrationId(); - - return this.authorizedClientRepository - .saveAuthorizedClient(authorizedClient, authenticationResult, - webFilterExchange.getExchange()) - .then(Mono.defer(() -> { - var additionalParameters = authenticationResult.getAuthorizationExchange() - .getAuthorizationRequest() - .getAdditionalParameters(); - String socialConnection = (String) additionalParameters.get(SOCIAL_CONNECTION); - String bindingRedirectUri = - (String) additionalParameters.get("binding_redirect_uri"); - String loginRedirectUri = - (String) additionalParameters.get("login_redirect_uri"); - if (Boolean.parseBoolean(socialConnection)) { - // Social connect successfully, finish the process - return createConnection(webFilterExchange, authenticationResult) - .then(handleBindSuccessHandler(webFilterExchange, bindingRedirectUri)); - } - return userConnectionService.isConnected(registrationId, - authenticationResult.getName()) - .flatMap(connected -> { - if (connected) { - // login - return mappedToSystemUserAuthentication(registrationId, - authenticationResult) - .flatMap(result -> handleAuthenticationSuccess(result, - webFilterExchange, loginRedirectUri)); - } - // signup - OAuth2User principal = authenticationResult.getPrincipal(); - return registrationPageHandler(registrationId, principal) - .onAuthenticationSuccess(webFilterExchange, authentication); - }); - })); - } - - private ServerAuthenticationSuccessHandler registrationPageHandler(String registrationId, - OAuth2User oauth2User) { - Assert.notNull(registrationId, "registrationId cannot be null"); - Assert.notNull(oauth2User, "oauth2User cannot be null"); - - String loginName = oauth2User.getName(); - String name = defaultIfBlank(oauth2User.getAttribute("name"), loginName); - MultiValueMap queryParams = new LinkedMultiValueMap<>(); - queryParams.add("login", loginName); - queryParams.add("name", name); - - String redirectUri = UriComponentsBuilder.fromPath("/console/binding/{registrationId}") - .uriVariables(Map.of("registrationId", registrationId)) - .queryParams(UriUtils.encodeQueryParams(queryParams)) - .build() - .toUriString(); - return new RedirectServerAuthenticationSuccessHandler(redirectUri); - } - - private Mono createConnection(WebFilterExchange webFilterExchange, - OAuth2LoginAuthenticationToken authenticationToken) { - return securityContextRepository.load(webFilterExchange.getExchange()) - .map(SecurityContext::getAuthentication) - .filter(Authentication::isAuthenticated) - .switchIfEmpty(Mono.error(new AccessDeniedException( - "Binding cannot be completed without user authentication"))) - .flatMap(authentication -> userConnectionService - .createConnection(authentication.getName(), authenticationToken) - ) - .then(); - } - - private Mono handleBindSuccessHandler(WebFilterExchange webFilterExchange, - String redirectUri) { - return getRedirectUri(webFilterExchange.getExchange(), redirectUri) - .defaultIfEmpty(URI.create("/console")) - .flatMap( - uri -> redirectStrategy.sendRedirect(webFilterExchange.getExchange(), uri)); - } - - Mono handleAuthenticationSuccess(Authentication authentication, - WebFilterExchange webFilterExchange, - String redirectUri) { - // Save the authentication result in the SecurityContext - ServerWebExchange exchange = webFilterExchange.getExchange(); - SecurityContextImpl securityContext = new SecurityContextImpl(); - securityContext.setAuthentication(authentication); - return securityContextRepository.save(exchange, securityContext) - .then(authenticationSuccessRedirection(webFilterExchange, - redirectUri) - ) - .contextWrite( - ReactiveSecurityContextHolder.withSecurityContext(Mono.just(securityContext))) - .then(getLoginHandlerEnhancer().onLoginSuccess(exchange, authentication)); - } - - Mono authenticationSuccessRedirection(WebFilterExchange webFilterExchange, - String redirectUri) { - return getRedirectUri(webFilterExchange.getExchange(), redirectUri) - .defaultIfEmpty(URI.create("/console")) - .flatMap(uri -> - this.redirectStrategy.sendRedirect(webFilterExchange.getExchange(), uri) - ) - .then(); - } - - Mono getRedirectUri(ServerWebExchange exchange, String redirectUriString) { - ServerHttpRequest request = exchange.getRequest(); - if (StringUtils.isBlank(redirectUriString)) { - return Mono.empty(); - } - URI redirectUri = URI.create(redirectUriString); - // Only redirect to the same host and port - if (redirectUri.getAuthority() != null - && !redirectUri.getAuthority().equals(request.getURI().getAuthority())) { - return Mono.empty(); - } - return Mono.just(redirectUri); - } - - Mono mappedToSystemUserAuthentication(String registrationId, - Authentication authentication) { - return socialUserDetailsService.loadUserByUserId(registrationId, - authentication.getName()) - .map(userDetails -> UsernamePasswordAuthenticationToken.authenticated( - userDetails.getUsername(), userDetails.getPassword(), - userDetails.getAuthorities()) - ); - } - } -} diff --git a/src/main/java/run/halo/oauth/Oauth2AuthorizationRequestRedirectWebFilter.java b/src/main/java/run/halo/oauth/Oauth2AuthorizationRequestRedirectWebFilter.java deleted file mode 100644 index 0ec0098..0000000 --- a/src/main/java/run/halo/oauth/Oauth2AuthorizationRequestRedirectWebFilter.java +++ /dev/null @@ -1,55 +0,0 @@ -package run.halo.oauth; - -import org.springframework.lang.NonNull; -import org.springframework.security.config.web.server.SecurityWebFiltersOrder; -import org.springframework.security.oauth2.client.web.server.OAuth2AuthorizationRequestRedirectWebFilter; -import org.springframework.security.oauth2.client.web.server.ServerAuthorizationRequestRepository; -import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; -import org.springframework.stereotype.Component; -import org.springframework.web.server.ServerWebExchange; -import org.springframework.web.server.WebFilterChain; -import reactor.core.publisher.Mono; -import run.halo.app.security.AdditionalWebFilter; - -/** - * @author guqing - * @since 1.0.0 - */ -@Component -public class Oauth2AuthorizationRequestRedirectWebFilter implements AdditionalWebFilter { - - private final Oauth2LoginConfiguration oauth2LoginConfiguration; - private final OAuth2AuthorizationRequestRedirectWebFilter oauthRedirectFilter; - - public Oauth2AuthorizationRequestRedirectWebFilter( - Oauth2LoginConfiguration oauth2LoginConfiguration) { - this.oauth2LoginConfiguration = oauth2LoginConfiguration; - - this.oauthRedirectFilter = createAuthenticationWebFilter(); - } - - @Override - @NonNull - public Mono filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) { - return oauthRedirectFilter.filter(exchange, chain); - } - - @Override - public int getOrder() { - return SecurityWebFiltersOrder.HTTP_BASIC.getOrder() - 1; - } - - OAuth2AuthorizationRequestRedirectWebFilter createAuthenticationWebFilter() { - OAuth2AuthorizationRequestRedirectWebFilter oauthRedirectFilter = - oauth2LoginConfiguration.getRedirectWebFilter(); - ServerAuthorizationRequestRepository - authorizationRequestRepository = - oauth2LoginConfiguration.getAuthorizationRequestRepository(); - oauthRedirectFilter.setAuthorizationRequestRepository(authorizationRequestRepository); - oauthRedirectFilter.setAuthorizationRedirectStrategy( - oauth2LoginConfiguration.getAuthorizationRedirectStrategy()); - oauthRedirectFilter.setRequestCache(oauth2LoginConfiguration.getRequestCache()); - - return oauthRedirectFilter; - } -} diff --git a/src/main/java/run/halo/oauth/Oauth2LoginConfiguration.java b/src/main/java/run/halo/oauth/Oauth2LoginConfiguration.java index a765da7..dded09c 100644 --- a/src/main/java/run/halo/oauth/Oauth2LoginConfiguration.java +++ b/src/main/java/run/halo/oauth/Oauth2LoginConfiguration.java @@ -1,55 +1,20 @@ package run.halo.oauth; -import com.google.common.base.Throwables; +import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.REGISTRATION_ID; + import lombok.Getter; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.security.authentication.DelegatingReactiveAuthenticationManager; -import org.springframework.security.authentication.ReactiveAuthenticationManager; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; -import org.springframework.security.core.authority.mapping.SimpleAuthorityMapper; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientService; -import org.springframework.security.oauth2.client.authentication.OAuth2LoginReactiveAuthenticationManager; -import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; -import org.springframework.security.oauth2.client.endpoint.ReactiveOAuth2AccessTokenResponseClient; -import org.springframework.security.oauth2.client.endpoint.WebClientReactiveAuthorizationCodeTokenResponseClient; -import org.springframework.security.oauth2.client.oidc.authentication.OidcAuthorizationCodeReactiveAuthenticationManager; -import org.springframework.security.oauth2.client.oidc.authentication.ReactiveOidcIdTokenDecoderFactory; -import org.springframework.security.oauth2.client.oidc.userinfo.OidcReactiveOAuth2UserService; -import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; -import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; -import org.springframework.security.oauth2.client.userinfo.DefaultReactiveOAuth2UserService; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; -import org.springframework.security.oauth2.client.userinfo.ReactiveOAuth2UserService; import org.springframework.security.oauth2.client.web.server.AuthenticatedPrincipalServerOAuth2AuthorizedClientRepository; -import org.springframework.security.oauth2.client.web.server.OAuth2AuthorizationRequestRedirectWebFilter; -import org.springframework.security.oauth2.client.web.server.ServerAuthorizationRequestRepository; -import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizationCodeAuthenticationTokenConverter; import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository; -import org.springframework.security.oauth2.client.web.server.WebSessionOAuth2ServerAuthorizationRequestRepository; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.OAuth2AuthorizationException; -import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.security.oauth2.jwt.ReactiveJwtDecoderFactory; -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.RedirectServerAuthenticationFailureHandler; -import org.springframework.security.web.server.authentication.ServerAuthenticationConverter; -import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler; import org.springframework.security.web.server.savedrequest.ServerRequestCache; import org.springframework.security.web.server.savedrequest.WebSessionServerRequestCache; import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; -import org.springframework.stereotype.Component; -import org.springframework.util.ClassUtils; -import org.springframework.util.MultiValueMap; -import reactor.core.publisher.Mono; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.security.LoginHandlerEnhancer; @@ -61,23 +26,13 @@ */ @Slf4j @Getter -@Component -public final class Oauth2LoginConfiguration { - private final ReactiveAuthenticationManager authenticationManager; - private final ServerAuthenticationFailureHandler authenticationFailureHandler; +@Configuration +@EnableAsync +public class Oauth2LoginConfiguration { private final ServerWebExchangeMatcher authenticationMatcher; private final ServerOAuth2AuthorizedClientRepository authorizedClientRepository; - private final ServerAuthorizationRequestRepository - authorizationRequestRepository; - private final ServerRedirectStrategy authorizationRedirectStrategy; - private final ServerAuthenticationConverter authenticationConverter; - private final ReactiveOAuth2AccessTokenResponseClient - accessTokenResponseClient; - private final ReactiveOAuth2UserService oauth2UserService; - private final ReactiveOAuth2UserService oidcUserService; private final ReactiveClientRegistrationRepository clientRegistrationRepository; private final ReactiveOAuth2AuthorizedClientService authorizedClientService; - private final OAuth2AuthorizationRequestRedirectWebFilter redirectWebFilter; private final ReactiveExtensionClient extensionClient; @@ -91,20 +46,10 @@ public Oauth2LoginConfiguration(ReactiveExtensionClient extensionClient, this.loginHandlerEnhancer = loginHandlerEnhancer; Initializer initializer = new Initializer(); - this.authenticationManager = initializer.getAuthenticationManager(); - this.authenticationFailureHandler = initializer.getAuthenticationFailureHandler(); this.authenticationMatcher = initializer.getAuthenticationMatcher(); this.authorizedClientRepository = initializer.getAuthorizedClientRepository(); - this.authorizationRequestRepository = initializer.getAuthorizationRequestRepository(); - this.authorizationRedirectStrategy = initializer.getAuthorizationRedirectStrategy(); - this.authenticationConverter = - initializer.getAuthenticationConverter(initializer.getClientRegistrationRepository()); - this.accessTokenResponseClient = initializer.getAccessTokenResponseClient(); - this.oauth2UserService = initializer.getOauth2UserService(); - this.oidcUserService = initializer.getOidcUserService(); this.clientRegistrationRepository = initializer.getClientRegistrationRepository(); this.authorizedClientService = initializer.getAuthorizedClientService(); - this.redirectWebFilter = initializer.getRedirectWebFilter(); } @Autowired(required = false) @@ -114,118 +59,19 @@ public void setRequestCache(ServerRequestCache requestCache) { class Initializer { - ServerAuthenticationFailureHandler getAuthenticationFailureHandler() { - return new RedirectServerAuthenticationFailureHandler("/console/login?error") { - @Override - public Mono onAuthenticationFailure(WebFilterExchange webFilterExchange, - AuthenticationException exception) { - var queryParams = webFilterExchange.getExchange().getRequest().getQueryParams(); - var response = new OAuth2ErrorResponse(queryParams); - log.error("An error occurred while attempting to oauth2 authenticate: \n{}", - response, Throwables.getRootCause(exception)); - return loginHandlerEnhancer.onLoginFailure(webFilterExchange.getExchange(), - exception) - .then(super.onAuthenticationFailure(webFilterExchange, exception)); - } - }; - } - - @RequiredArgsConstructor - static class OAuth2ErrorResponse { - private final MultiValueMap queryParams; - - public String error() { - return queryParams.getFirst("error"); - } - - public String errorDescription() { - return queryParams.getFirst("error_description"); - } - - public String errorUri() { - return queryParams.getFirst("error_uri"); - } - - @Override - public String toString() { - return """ - error: %s - error_description: %s - error_uri: %s - """.formatted(error(), errorDescription(), errorUri()); - } - } - - GrantedAuthoritiesMapper getAuthoritiesMapper() { - return new SimpleAuthorityMapper(); - } - - ReactiveAuthenticationManager getAuthenticationManager() { - return createDefaultAuthenticationManager(); - } - ServerWebExchangeMatcher getAuthenticationMatcher() { return createAttemptAuthenticationRequestMatcher(); } - ReactiveAuthenticationManager createDefaultAuthenticationManager() { - ReactiveOAuth2AccessTokenResponseClient client = - getAccessTokenResponseClient(); - OAuth2LoginReactiveAuthenticationManager oauth2Manager = - new OAuth2LoginReactiveAuthenticationManager( - client, getOauth2UserService()); - - GrantedAuthoritiesMapper authoritiesMapper = getAuthoritiesMapper(); - oauth2Manager.setAuthoritiesMapper(authoritiesMapper); - - boolean oidcAuthenticationProviderEnabled = ClassUtils - .isPresent("org.springframework.security.oauth2.jwt.JwtDecoder", - this.getClass().getClassLoader()); - if (!oidcAuthenticationProviderEnabled) { - return oauth2Manager; - } - OidcAuthorizationCodeReactiveAuthenticationManager oidc = - new OidcAuthorizationCodeReactiveAuthenticationManager( - client, getOidcUserService()); - - oidc.setJwtDecoderFactory(getReactiveJwtDecoderFactory()); - - oidc.setAuthoritiesMapper(authoritiesMapper); - return new DelegatingReactiveAuthenticationManager(oidc, oauth2Manager); - } - - ReactiveJwtDecoderFactory getReactiveJwtDecoderFactory() { - return new ReactiveOidcIdTokenDecoderFactory(); - } - ServerWebExchangeMatcher createAttemptAuthenticationRequestMatcher() { return new PathPatternParserServerWebExchangeMatcher( - "/login/oauth2/code/{registrationId}"); - } - - ReactiveOAuth2UserService getOidcUserService() { - return new OidcReactiveOAuth2UserService(); - } - - ReactiveOAuth2UserService getOauth2UserService() { - return new DefaultReactiveOAuth2UserService(); - } - - ReactiveOAuth2AccessTokenResponseClient - getAccessTokenResponseClient() { - return new WebClientReactiveAuthorizationCodeTokenResponseClient(); + "/login/oauth2/code/{" + REGISTRATION_ID + "}"); } ReactiveClientRegistrationRepository getClientRegistrationRepository() { return new OauthClientRegistrationRepository(extensionClient); } - OAuth2AuthorizationRequestRedirectWebFilter getRedirectWebFilter() { - var requestResolver = new SocialServerOauth2AuthorizationRequestResolver( - getClientRegistrationRepository()); - return new OAuth2AuthorizationRequestRedirectWebFilter(requestResolver); - } - ServerOAuth2AuthorizedClientRepository getAuthorizedClientRepository() { ReactiveOAuth2AuthorizedClientService authorizedClientService = getAuthorizedClientService(); @@ -233,27 +79,6 @@ ServerOAuth2AuthorizedClientRepository getAuthorizedClientRepository() { authorizedClientService); } - ServerAuthenticationConverter getAuthenticationConverter( - ReactiveClientRegistrationRepository clientRegistrationRepository) { - ServerOAuth2AuthorizationCodeAuthenticationTokenConverter delegate = - new ServerOAuth2AuthorizationCodeAuthenticationTokenConverter( - clientRegistrationRepository); - delegate.setAuthorizationRequestRepository(getAuthorizationRequestRepository()); - return (exchange) -> delegate.convert(exchange).onErrorMap( - OAuth2AuthorizationException.class, - (e) -> new OAuth2AuthenticationException(e.getError(), - e.getError().toString())); - } - - ServerAuthorizationRequestRepository - getAuthorizationRequestRepository() { - return new WebSessionOAuth2ServerAuthorizationRequestRepository(); - } - - ServerRedirectStrategy getAuthorizationRedirectStrategy() { - return new DefaultServerRedirectStrategy(); - } - ReactiveOAuth2AuthorizedClientService getAuthorizedClientService() { return new DefaultOAuth2AuthorizedClientService(extensionClient, getClientRegistrationRepository()); diff --git a/src/main/java/run/halo/oauth/SocialUserDetailsService.java b/src/main/java/run/halo/oauth/SocialUserDetailsService.java deleted file mode 100644 index b9b144d..0000000 --- a/src/main/java/run/halo/oauth/SocialUserDetailsService.java +++ /dev/null @@ -1,29 +0,0 @@ -package run.halo.oauth; - -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import reactor.core.publisher.Mono; -import run.halo.app.core.extension.UserConnection; - -/** - * Similar to {@link UserDetailsService} but loads details by user's identity at the provider, - * not username. - * - * @author guqing - * @since 1.0.0 - */ -public interface SocialUserDetailsService { - - /** - * Loads the user details by the user's identity at the provider. - * - * @param registrationId the {@link UserConnection.UserConnectionSpec#getRegistrationId()} - * @param principalName the {@link UserConnection.UserConnectionSpec#getProviderUserId()} used - * to look up the user details - * @return the user details - * @throws UsernameNotFoundException if the user details cannot be found - */ - Mono loadUserByUserId(String registrationId, String principalName) - throws UsernameNotFoundException; -} diff --git a/src/main/java/run/halo/oauth/UserConnectionDisconnectedListener.java b/src/main/java/run/halo/oauth/UserConnectionDisconnectedListener.java new file mode 100644 index 0000000..afdaf54 --- /dev/null +++ b/src/main/java/run/halo/oauth/UserConnectionDisconnectedListener.java @@ -0,0 +1,41 @@ +package run.halo.oauth; + +import java.time.Duration; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientService; +import org.springframework.stereotype.Component; +import run.halo.app.event.user.UserConnectionDisconnectedEvent; + +/** + * An event listener that will remove the authorized client after a user connection is disconnected. + * + * @author johnniang + * @since 2.20.0 + */ +@Slf4j +@Component +public class UserConnectionDisconnectedListener + implements ApplicationListener { + + private final ReactiveOAuth2AuthorizedClientService authorizedClientService; + + public UserConnectionDisconnectedListener(Oauth2LoginConfiguration configuration) { + authorizedClientService = configuration.getAuthorizedClientService(); + } + + @Override + @Async + public void onApplicationEvent(UserConnectionDisconnectedEvent event) { + var connection = event.getUserConnection(); + var registrationId = connection.getSpec().getRegistrationId(); + var providerUserId = connection.getSpec().getProviderUserId(); + authorizedClientService.removeAuthorizedClient(registrationId, providerUserId) + .blockOptional(Duration.ofMinutes(1)); + if (log.isDebugEnabled()) { + log.debug("Cleanup authorized client for user connection [{}].", connection); + } + } + +} diff --git a/src/main/java/run/halo/oauth/UserConnectionEndpoint.java b/src/main/java/run/halo/oauth/UserConnectionEndpoint.java deleted file mode 100644 index 0f6927a..0000000 --- a/src/main/java/run/halo/oauth/UserConnectionEndpoint.java +++ /dev/null @@ -1,100 +0,0 @@ -package run.halo.oauth; - -import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; - -import io.swagger.v3.oas.annotations.enums.ParameterIn; -import java.net.URI; -import java.util.Map; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.BooleanUtils; -import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Component; -import org.springframework.web.reactive.function.server.RequestPredicates; -import org.springframework.web.reactive.function.server.RouterFunction; -import org.springframework.web.reactive.function.server.ServerRequest; -import org.springframework.web.reactive.function.server.ServerResponse; -import org.springframework.web.util.UriComponentsBuilder; -import reactor.core.publisher.Mono; -import run.halo.app.core.extension.endpoint.CustomEndpoint; -import run.halo.app.extension.GroupVersion; - -/** - * User connection endpoint. - * - * @author guqing - * @since 1.0.0 - */ -@Component -@RequiredArgsConstructor -public class UserConnectionEndpoint implements CustomEndpoint { - - private final UserConnectionService userConnectionService; - private final String tag = "api.plugin.halo.run/v1alpha1/Connection"; - - @Override - public RouterFunction endpoint() { - return SpringdocRouteBuilder.route() - .nest(RequestPredicates.path("/plugins/plugin-oauth2"), this::nested, - builder -> builder.operationId("PluginOauthGithubEndpoints") - .description("Plugin OAuth GitHub Endpoints").tag(tag) - ) - .build(); - } - - RouterFunction nested() { - return SpringdocRouteBuilder.route() - .GET("/connect/{registrationId}", this::connect, - builder -> builder.operationId("Connect") - .description("Connect to the third-party platform.") - .tag(tag) - .parameter(parameterBuilder().name("registrationId") - .in(ParameterIn.PATH) - .required(true) - .implementation(String.class)) - ) - .PUT("/disconnect/{registrationId}", this::disconnect, - builder -> builder.operationId("Disconnect") - .description("Disconnect a third-party platform.") - .tag(tag) - .parameter(parameterBuilder().name("registrationId") - .in(ParameterIn.PATH) - .required(true) - .implementation(String.class)) - ) - .build(); - } - - @Override - public GroupVersion groupVersion() { - return GroupVersion.parseAPIVersion("api.plugin.halo.run/v1alpha1"); - } - - Mono connect(ServerRequest request) { - return ServerResponse.temporaryRedirect(buildOauthRedirectUri(request)) - .build(); - } - - Mono disconnect(ServerRequest request) { - String registrationId = request.pathVariable("registrationId"); - return userConnectionService.removeConnection(registrationId) - .collectList() - .flatMap(result -> ServerResponse.ok() - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(result) - ); - } - - URI buildOauthRedirectUri(ServerRequest request) { - String registrationId = request.pathVariable("registrationId"); - Optional redirectUri = request.queryParam("redirect_uri"); - return UriComponentsBuilder.fromPath("/oauth2/authorization/{registrationId}") - .uriVariables(Map.of("registrationId", registrationId)) - .queryParam(SocialServerOauth2AuthorizationRequestResolver.SOCIAL_CONNECTION, - BooleanUtils.TRUE) - .queryParamIfPresent("binding_redirect_uri", redirectUri) - .build() - .toUri(); - } -} diff --git a/src/main/java/run/halo/oauth/UserConnectionService.java b/src/main/java/run/halo/oauth/UserConnectionService.java deleted file mode 100644 index 6947ee5..0000000 --- a/src/main/java/run/halo/oauth/UserConnectionService.java +++ /dev/null @@ -1,30 +0,0 @@ -package run.halo.oauth; - -import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import run.halo.app.core.extension.UserConnection; - -/** - * User connection service. - * - * @author guqing - * @since 1.0.0 - */ -public interface UserConnectionService { - - /** - * Creates a new user connection by the given authentication. - * If the user connection already exists, will update it and return, - * otherwise will create a new user connection and return. - * - * @param authentication oauth2 login authentication token - * @return user connection - */ - Mono createConnection(String username, - OAuth2LoginAuthenticationToken authentication); - - Flux removeConnection(String registrationId); - - Mono isConnected(String registrationId, String providerUserId); -} diff --git a/src/main/java/run/halo/oauth/UserConnectionServiceImpl.java b/src/main/java/run/halo/oauth/UserConnectionServiceImpl.java deleted file mode 100644 index 2e935b0..0000000 --- a/src/main/java/run/halo/oauth/UserConnectionServiceImpl.java +++ /dev/null @@ -1,138 +0,0 @@ -package run.halo.oauth; - -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.StringUtils; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.core.context.ReactiveSecurityContextHolder; -import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.stereotype.Component; -import org.springframework.util.Assert; -import org.springframework.web.server.ServerWebInputException; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import run.halo.app.core.extension.UserConnection; -import run.halo.app.extension.Metadata; -import run.halo.app.extension.ReactiveExtensionClient; - -/** - * User connection service implementation. - * - * @author guqing - * @since 1.0.0 - */ -@Component -@RequiredArgsConstructor -public class UserConnectionServiceImpl implements UserConnectionService { - - private final ReactiveExtensionClient client; - private final Oauth2UserProfileMapperManager oauth2UserProfileMapperManager; - private final Oauth2LoginConfiguration oauth2LoginConfiguration; - - @Override - public Mono createConnection(String username, - OAuth2LoginAuthenticationToken authentication) { - Assert.notNull(authentication, "OAuth2LoginAuthenticationToken must not be null"); - if (StringUtils.isBlank(username)) { - throw new AccessDeniedException( - "Binding cannot be completed without user authentication"); - } - - UserConnection connection = convert(username, authentication); - String providerUserId = authentication.getPrincipal().getName(); - return checkExistingBinding(connection.getSpec().getRegistrationId(), username) - .flatMap(existing -> { - if (existing) { - return Mono.error(new ServerWebInputException( - "已经绑定过其他 " + connection.getSpec().getRegistrationId() - + " 账号,请先解绑后重试")); - } - return upsertUserConnection(connection, providerUserId); - }); - } - - private Mono checkExistingBinding(String registrationId, String username) { - return listByRegistrationIdAndUsername(registrationId, username) - .hasElements(); - } - - private Mono upsertUserConnection(UserConnection connection, - String providerUserId) { - return fetchUserConnection(connection.getSpec().getRegistrationId(), providerUserId) - .flatMap(persisted -> { - connection.getMetadata().setName(persisted.getMetadata().getName()); - connection.getMetadata().setVersion(persisted.getMetadata().getVersion()); - return client.update(connection); - }) - .switchIfEmpty(Mono.defer(() -> client.create(connection))); - } - - @Override - public Flux removeConnection(String registrationId) { - return ReactiveSecurityContextHolder.getContext() - .map(securityContext -> securityContext.getAuthentication().getName()) - .switchIfEmpty(Mono.error( - new AccessDeniedException("Cannot disconnect without user authentication")) - ) - .flatMapMany(username -> listByRegistrationIdAndUsername(registrationId, username) - .flatMap(userConnection -> { - String providerUserId = userConnection.getSpec().getProviderUserId(); - return oauth2LoginConfiguration.getAuthorizedClientService() - .removeAuthorizedClient(registrationId, providerUserId) - .then(Mono.defer(() -> client.delete(userConnection))); - }) - ); - } - - @Override - public Mono isConnected(String registrationId, String providerUserId) { - return client.list(UserConnection.class, persisted -> persisted.getSpec() - .getProviderUserId().equals(providerUserId) - && persisted.getSpec().getRegistrationId().equals(registrationId), null) - .next() - .hasElement(); - } - - Flux listByRegistrationIdAndUsername(String registrationId, String username) { - return client.list(UserConnection.class, persisted -> persisted.getSpec() - .getRegistrationId().equals(registrationId) - && persisted.getSpec().getUsername().equals(username), null); - } - - private Mono fetchUserConnection(String registrationId, String providerUserId) { - return client.list(UserConnection.class, persisted -> persisted.getSpec() - .getProviderUserId().equals(providerUserId) - && persisted.getSpec().getRegistrationId().equals(registrationId), null) - .next(); - } - - UserConnection convert(String username, OAuth2LoginAuthenticationToken authentication) { - UserConnection userConnection = new UserConnection(); - userConnection.setMetadata(new Metadata()); - userConnection.getMetadata().setGenerateName("connection-"); - userConnection.getMetadata().setName(""); - - OAuth2User oauth2User = authentication.getPrincipal(); - final String registrationId = authentication.getClientRegistration().getRegistrationId(); - - UserConnection.UserConnectionSpec spec = - new UserConnection.UserConnectionSpec(); - userConnection.setSpec(spec); - spec.setUsername(username); - spec.setProviderUserId(oauth2User.getName()); - spec.setRegistrationId(registrationId); - spec.setAccessToken(authentication.getAccessToken().getTokenValue()); - spec.setExpiresAt(authentication.getAccessToken().getExpiresAt()); - if (authentication.getRefreshToken() != null) { - spec.setRefreshToken(authentication.getRefreshToken().getTokenValue()); - } - - Oauth2UserProfile oauth2UserProfile = - oauth2UserProfileMapperManager.mapProfile(registrationId, oauth2User); - var displayName = StringUtils.defaultIfBlank(oauth2UserProfile.getDisplayName(), username); - spec.setDisplayName(displayName); - spec.setAvatarUrl(oauth2UserProfile.getAvatarUrl()); - spec.setProfileUrl(oauth2UserProfile.getProfileUrl()); - return userConnection; - } -} diff --git a/src/main/resources/extensions/auth-provider.yaml b/src/main/resources/extensions/auth-provider.yaml index f22377a..1ce20f7 100644 --- a/src/main/resources/extensions/auth-provider.yaml +++ b/src/main/resources/extensions/auth-provider.yaml @@ -11,8 +11,8 @@ spec: website: https://github.com helpPage: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app authenticationUrl: /oauth2/authorization/github - bindingUrl: /apis/api.plugin.halo.run/v1alpha1/plugins/plugin-oauth2/connect/github - unbindUrl: /apis/api.plugin.halo.run/v1alpha1/plugins/plugin-oauth2/disconnect/github + bindingUrl: /oauth2/authorization/github + unbindUrl: /apis/uc.api.auth.halo.run/v1alpha1/user-connections/github/disconnect settingRef: name: generic-oauth2-setting group: genericOauth @@ -31,8 +31,8 @@ spec: logo: /plugins/plugin-oauth2/assets/static/gitee.svg website: https://gitee.com authenticationUrl: /oauth2/authorization/gitee - bindingUrl: /apis/api.plugin.halo.run/v1alpha1/plugins/plugin-oauth2/connect/gitee - unbindUrl: /apis/api.plugin.halo.run/v1alpha1/plugins/plugin-oauth2/disconnect/gitee + bindingUrl: /oauth2/authorization/gitee + unbindUrl: /apis/uc.api.auth.halo.run/v1alpha1/user-connections/gitee/disconnect settingRef: name: generic-oauth2-setting group: genericOauth @@ -51,8 +51,8 @@ spec: logo: /plugins/plugin-oauth2/assets/static/gitlab.svg website: https://gitlab.com authenticationUrl: /oauth2/authorization/gitlab - bindingUrl: /apis/api.plugin.halo.run/v1alpha1/plugins/plugin-oauth2/connect/gitlab - unbindUrl: /apis/api.plugin.halo.run/v1alpha1/plugins/plugin-oauth2/disconnect/gitlab + bindingUrl: /oauth2/authorization/gitlab + unbindUrl: /apis/uc.api.auth.halo.run/v1alpha1/user-connections/gitlab/disconnect settingRef: name: generic-oauth2-setting group: genericOauth diff --git a/src/main/resources/plugin.yaml b/src/main/resources/plugin.yaml index d037f99..ff2e33f 100644 --- a/src/main/resources/plugin.yaml +++ b/src/main/resources/plugin.yaml @@ -6,7 +6,7 @@ metadata: store.halo.run/app-id: app-ESVDK spec: enabled: true - requires: ">=2.17.0" + requires: ">=2.20.0" author: name: Halo website: https://github.com/halo-dev diff --git a/src/test/java/run/halo/oauth/DefaultSocialUserDetailsServiceTest.java b/src/test/java/run/halo/oauth/DefaultSocialUserDetailsServiceTest.java deleted file mode 100644 index e7ae5aa..0000000 --- a/src/test/java/run/halo/oauth/DefaultSocialUserDetailsServiceTest.java +++ /dev/null @@ -1,70 +0,0 @@ -package run.halo.oauth; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.Collections; -import java.util.Comparator; -import java.util.function.Predicate; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.core.userdetails.ReactiveUserDetailsService; -import org.springframework.security.core.userdetails.User; -import org.springframework.security.core.userdetails.UserDetails; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; -import run.halo.app.core.extension.UserConnection; -import run.halo.app.extension.ReactiveExtensionClient; - -/** - * Tests for {@link DefaultSocialUserDetailsService}. - * - * @author guqing - * @since 2.0.0 - */ -@ExtendWith(MockitoExtension.class) -class DefaultSocialUserDetailsServiceTest { - @Mock - private ReactiveExtensionClient client; - - @Mock - private ReactiveUserDetailsService userDetailsService; - - @InjectMocks - private DefaultSocialUserDetailsService service; - - @Test - @SuppressWarnings("unchecked") - void loadUserByUserId() { - // 创建测试数据 - String registrationId = "testRegistrationId"; - String principalName = "testPrincipalName"; - UserConnection userConnection = new UserConnection(); - UserConnection.UserConnectionSpec spec = new UserConnection.UserConnectionSpec(); - spec.setRegistrationId(registrationId); - spec.setProviderUserId(principalName); - spec.setUsername("testUsername"); - userConnection.setSpec(spec); - UserDetails userDetails = new User("testUsername", "testPassword", Collections.emptyList()); - - when(client.list(eq(UserConnection.class), any(Predicate.class), any(Comparator.class))) - .thenReturn(Flux.just(userConnection)); - when(userDetailsService.findByUsername(eq("testUsername"))).thenReturn( - Mono.just(userDetails)); - - Mono result = service.loadUserByUserId(registrationId, principalName); - - // 验证方法的行为和结果是否符合预期 - StepVerifier.create(result) - .expectNext(userDetails) - .verifyComplete(); - verify(client).list(eq(UserConnection.class), any(Predicate.class), any(Comparator.class)); - verify(userDetailsService).findByUsername(eq("testUsername")); - } -} diff --git a/src/test/java/run/halo/oauth/ListedConnectionTest.java b/src/test/java/run/halo/oauth/ListedConnectionTest.java deleted file mode 100644 index f74b752..0000000 --- a/src/test/java/run/halo/oauth/ListedConnectionTest.java +++ /dev/null @@ -1,74 +0,0 @@ -package run.halo.oauth; - -import static org.assertj.core.api.Assertions.assertThat; - -import org.junit.jupiter.api.Test; - -/** - * Tests for {@link ListedConnection}. - * - * @author guqing - * @since 2.0.0 - */ -class ListedConnectionTest { - - @Test - public void testBuilder() { - // 创建测试数据 - ListedConnection.SimpleAuthProvider authProvider = - ListedConnection.SimpleAuthProvider.builder() - .displayName("testDisplayName") - .logo("testLogo") - .website("testWebsite") - .authenticationUrl("testAuthenticationUrl") - .helpPage("testHelpPage") - .build(); - - ListedConnection connection = ListedConnection.builder() - .registrationId("testRegistrationId") - .username("testUsername") - .displayName("testDisplayName") - .profileUrl("testProfileUrl") - .avatarUrl("testAvatarUrl") - .provider(authProvider) - .build(); - - // 验证对象的属性是否符合预期 - assertThat(connection.getRegistrationId()).isEqualTo("testRegistrationId"); - assertThat(connection.getUsername()).isEqualTo("testUsername"); - assertThat(connection.getDisplayName()).isEqualTo("testDisplayName"); - assertThat(connection.getProfileUrl()).isEqualTo("testProfileUrl"); - assertThat(connection.getAvatarUrl()).isEqualTo("testAvatarUrl"); - assertThat(connection.getProvider().getDisplayName()).isEqualTo("testDisplayName"); - assertThat(connection.getProvider().getLogo()).isEqualTo("testLogo"); - assertThat(connection.getProvider().getWebsite()).isEqualTo("testWebsite"); - assertThat(connection.getProvider().getAuthenticationUrl()).isEqualTo( - "testAuthenticationUrl"); - assertThat(connection.getProvider().getHelpPage()).isEqualTo("testHelpPage"); - } - - @Test - public void testValue() { - // 创建测试数据 - ListedConnection.SimpleAuthProvider authProvider = - new ListedConnection.SimpleAuthProvider("testDisplayName", - "testLogo", "testWebsite", "testAuthenticationUrl", "testHelpPage"); - - ListedConnection connection = - new ListedConnection("testRegistrationId", "testUsername", "testDisplayName", - "testProfileUrl", "testAvatarUrl", authProvider); - - // 验证对象的属性是否符合预期 - assertThat(connection.getRegistrationId()).isEqualTo("testRegistrationId"); - assertThat(connection.getUsername()).isEqualTo("testUsername"); - assertThat(connection.getDisplayName()).isEqualTo("testDisplayName"); - assertThat(connection.getProfileUrl()).isEqualTo("testProfileUrl"); - assertThat(connection.getAvatarUrl()).isEqualTo("testAvatarUrl"); - assertThat(connection.getProvider().getDisplayName()).isEqualTo("testDisplayName"); - assertThat(connection.getProvider().getLogo()).isEqualTo("testLogo"); - assertThat(connection.getProvider().getWebsite()).isEqualTo("testWebsite"); - assertThat(connection.getProvider().getAuthenticationUrl()).isEqualTo( - "testAuthenticationUrl"); - assertThat(connection.getProvider().getHelpPage()).isEqualTo("testHelpPage"); - } -} diff --git a/src/test/java/run/halo/oauth/UserConnectionServiceImplTest.java b/src/test/java/run/halo/oauth/UserConnectionServiceImplTest.java deleted file mode 100644 index 63f1048..0000000 --- a/src/test/java/run/halo/oauth/UserConnectionServiceImplTest.java +++ /dev/null @@ -1,123 +0,0 @@ -package run.halo.oauth; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.time.Instant; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.config.oauth2.client.CommonOAuth2Provider; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken; -import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.security.oauth2.core.OAuth2AccessToken; -import org.springframework.security.oauth2.core.OAuth2RefreshToken; -import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; -import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; -import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse; -import org.springframework.security.oauth2.core.user.DefaultOAuth2User; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import run.halo.app.core.extension.UserConnection; -import run.halo.app.extension.Metadata; -import run.halo.app.extension.ReactiveExtensionClient; - -/** - * @author guqing - * @since 2.0.0 - */ -@ExtendWith(MockitoExtension.class) -class UserConnectionServiceImplTest { - - @Mock - private ReactiveExtensionClient client; - - @Mock - private Oauth2UserProfileMapperManager mapperManager; - - @InjectMocks - private UserConnectionServiceImpl service; - - @Test - public void testCreateConnection() { - Map attributes = Map.of("id", "testuser", "name", "Test User", "avatar_url", - "http://test.com/avatar.png", - "html_url", "http://test.com/profile"); - DefaultOAuth2User oauth2User = - new DefaultOAuth2User(List.of(new SimpleGrantedAuthority("read:user")), attributes, - "id"); - // mock OAuth2LoginAuthenticationToken - String tokenValue = "testtoken"; - OAuth2AccessToken accessToken = new OAuth2AccessToken( - OAuth2AccessToken.TokenType.BEARER, tokenValue, Instant.now(), - Instant.now().plusSeconds(3600)); - OAuth2RefreshToken refreshToken = - new OAuth2RefreshToken("testrefresh", Instant.now().plusSeconds(30)); - - ClientRegistration clientRegistration = CommonOAuth2Provider.GITHUB.getBuilder("github") - .clientId("fake-client-id") - .clientSecret("fake-client-secret") - .build(); - OAuth2AuthorizationRequest request = OAuth2AuthorizationRequest.authorizationCode() - .authorizationUri("/oauth2/authorization/github") - .clientId(clientRegistration.getClientId()) - .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") - .build(); - OAuth2AuthorizationResponse response = OAuth2AuthorizationResponse.success("code") - .redirectUri("http://localhost:8080/login/oauth2/code/github") - .build(); - OAuth2AuthorizationExchange exchange = - new OAuth2AuthorizationExchange(request, response); - OAuth2LoginAuthenticationToken authentication = new OAuth2LoginAuthenticationToken( - clientRegistration, exchange, oauth2User, - List.of(new SimpleGrantedAuthority("read:user")), - accessToken); - - // mock UserConnection - UserConnection expectedConnection = new UserConnection(); - expectedConnection.setMetadata(new Metadata()); - expectedConnection.getMetadata().setGenerateName("connection-"); - expectedConnection.getMetadata().setName(""); - - UserConnection.UserConnectionSpec spec = new UserConnection.UserConnectionSpec(); - expectedConnection.setSpec(spec); - String username = "testuser"; - spec.setUsername(username); - spec.setProviderUserId(oauth2User.getName()); - String registrationId = "testreg"; - spec.setRegistrationId(registrationId); - spec.setAccessToken(tokenValue); - spec.setExpiresAt(accessToken.getExpiresAt()); - spec.setRefreshToken(refreshToken.getTokenValue()); - - Oauth2UserProfile userProfile = Oauth2UserProfile.builder() - .displayName("Test User") - .avatarUrl("http://test.com/avatar.png") - .profileUrl("http://test.com/profile") - .username("testuser") - .build(); - - - when(mapperManager.mapProfile(any(), any())).thenReturn(userProfile); - - // mock client - when(client.list(eq(UserConnection.class), any(), eq(null))).thenReturn(Flux.empty()); - when(client.create(any())).thenReturn(Mono.just(expectedConnection)); - - // test createConnection - UserConnection result = service.createConnection(username, authentication).block(); - assertNotNull(result); - assertEquals(expectedConnection, result); - verify(client, times(1)).create(any()); - } -}