diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index 5880f58a..3039de42 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -2,7 +2,7 @@ package com.iflytek.skillhub.auth.config; import com.iflytek.skillhub.auth.oauth.CustomOAuth2UserService; import com.iflytek.skillhub.auth.oauth.CustomOidcUserService; -import com.iflytek.skillhub.auth.oauth.FeishuOAuth2AccessTokenResponseClient; +import com.iflytek.skillhub.auth.oauth.DispatchingTokenResponseClient; import com.iflytek.skillhub.auth.oauth.OAuth2LoginFailureHandler; import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler; import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver; @@ -62,7 +62,7 @@ public class SecurityConfig { private final CustomOAuth2UserService customOAuth2UserService; private final CustomOidcUserService customOidcUserService; - private final FeishuOAuth2AccessTokenResponseClient feishuOAuth2AccessTokenResponseClient; + private final DispatchingTokenResponseClient tokenResponseClient; private final SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver; private final OAuth2LoginSuccessHandler successHandler; private final OAuth2LoginFailureHandler failureHandler; @@ -77,7 +77,7 @@ public class SecurityConfig { public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, CustomOidcUserService customOidcUserService, - FeishuOAuth2AccessTokenResponseClient feishuOAuth2AccessTokenResponseClient, + DispatchingTokenResponseClient tokenResponseClient, SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver, OAuth2LoginSuccessHandler successHandler, OAuth2LoginFailureHandler failureHandler, @@ -91,7 +91,7 @@ public class SecurityConfig { @Value("${server.servlet.session.cookie.name:SESSION}") String sessionCookieName) { this.customOAuth2UserService = customOAuth2UserService; this.customOidcUserService = customOidcUserService; - this.feishuOAuth2AccessTokenResponseClient = feishuOAuth2AccessTokenResponseClient; + this.tokenResponseClient = tokenResponseClient; this.authorizationRequestResolver = authorizationRequestResolver; this.successHandler = successHandler; this.failureHandler = failureHandler; @@ -136,8 +136,7 @@ public class SecurityConfig { }) .oauth2Login(oauth2 -> oauth2 .authorizationEndpoint(endpoint -> endpoint.authorizationRequestResolver(authorizationRequestResolver)) - .tokenEndpoint(tokenEndpoint -> tokenEndpoint - .accessTokenResponseClient(feishuOAuth2AccessTokenResponseClient)) + .tokenEndpoint(token -> token.accessTokenResponseClient(tokenResponseClient)) .userInfoEndpoint(userInfo -> userInfo .userService(customOAuth2UserService) .oidcUserService(customOidcUserService)) diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DispatchingTokenResponseClient.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DispatchingTokenResponseClient.java new file mode 100644 index 00000000..934e89c7 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DispatchingTokenResponseClient.java @@ -0,0 +1,48 @@ +package com.iflytek.skillhub.auth.oauth; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.stereotype.Component; + +/** + * Routes the authorization-code token exchange to a {@link ProviderTokenResponseClient} when one + * claims the registration, and to the standard Spring client otherwise. + * + *

Spring's {@code tokenEndpoint} accepts a single client, so per-provider exchange needs one + * dispatcher rather than a branch inside the security configuration. + */ +@Component +public class DispatchingTokenResponseClient + implements OAuth2AccessTokenResponseClient { + + private final Map overrides; + private final OAuth2AccessTokenResponseClient delegate; + + @Autowired + public DispatchingTokenResponseClient(List providerClients) { + this(providerClients, new DefaultAuthorizationCodeTokenResponseClient()); + } + + DispatchingTokenResponseClient( + List providerClients, + OAuth2AccessTokenResponseClient delegate + ) { + this.overrides = providerClients.stream() + .collect(Collectors.toMap(ProviderTokenResponseClient::getProvider, Function.identity())); + this.delegate = delegate; + } + + @Override + public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest request) { + String registrationId = request.getClientRegistration().getRegistrationId(); + ProviderTokenResponseClient override = overrides.get(registrationId); + return (override != null ? override : delegate).getTokenResponse(request); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderAuthorizationRequestCustomizer.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderAuthorizationRequestCustomizer.java new file mode 100644 index 00000000..a8504181 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderAuthorizationRequestCustomizer.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.auth.oauth; + +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; + +/** + * Strategy interface for provider-specific authorization request tweaks, for providers whose + * authorize endpoint deviates from the standard parameter contract. + * + *

The token and userinfo counterparts are {@link ProviderTokenResponseClient} and + * {@link ProviderOAuth2UserService}. + */ +public interface ProviderAuthorizationRequestCustomizer { + + String getProvider(); + + void customize(OAuth2AuthorizationRequest.Builder builder); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderTokenResponseClient.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderTokenResponseClient.java new file mode 100644 index 00000000..9dc85aef --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderTokenResponseClient.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.auth.oauth; + +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; + +/** + * Strategy interface for provider-specific token exchange. Implementations override the default + * exchange for providers whose token endpoints deviate from the standard form-urlencoded contract. + * + *

The userinfo counterpart is {@link ProviderOAuth2UserService}. + */ +public interface ProviderTokenResponseClient + extends OAuth2AccessTokenResponseClient { + + String getProvider(); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java index df985f8f..04b387d1 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java @@ -1,11 +1,17 @@ package com.iflytek.skillhub.auth.oauth; import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Autowired; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.stereotype.Component; /** @@ -21,13 +27,36 @@ public class SkillHubOAuth2AuthorizationRequestResolver private final DefaultOAuth2AuthorizationRequestResolver delegate; private final OAuthLoginFlowService oauthLoginFlowService; - public SkillHubOAuth2AuthorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository, - OAuthLoginFlowService oauthLoginFlowService) { + SkillHubOAuth2AuthorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository, + OAuthLoginFlowService oauthLoginFlowService) { + this(clientRegistrationRepository, oauthLoginFlowService, List.of()); + } + + @Autowired + public SkillHubOAuth2AuthorizationRequestResolver( + ClientRegistrationRepository clientRegistrationRepository, + OAuthLoginFlowService oauthLoginFlowService, + List customizers) { this.delegate = new DefaultOAuth2AuthorizationRequestResolver( clientRegistrationRepository, "/oauth2/authorization" ); this.oauthLoginFlowService = oauthLoginFlowService; + Map byProvider = customizers.stream() + .collect(Collectors.toMap( + ProviderAuthorizationRequestCustomizer::getProvider, + Function.identity() + )); + // Spring resolves the registration id into the builder attributes, so one customizer hook + // can dispatch per provider instead of this class knowing about any of them. + this.delegate.setAuthorizationRequestCustomizer(builder -> { + OAuth2AuthorizationRequest probe = builder.build(); + String registrationId = probe.getAttribute(OAuth2ParameterNames.REGISTRATION_ID); + ProviderAuthorizationRequestCustomizer customizer = byProvider.get(registrationId); + if (customizer != null) { + customizer.customize(builder); + } + }); } @Override diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DispatchingTokenResponseClientTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DispatchingTokenResponseClientTest.java new file mode 100644 index 00000000..d2c18586 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/DispatchingTokenResponseClientTest.java @@ -0,0 +1,99 @@ +package com.iflytek.skillhub.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse; + +class DispatchingTokenResponseClientTest { + + @Test + void routesToProviderOverrideWhenOneClaimsTheRegistration() { + OAuth2AccessTokenResponse overrideResponse = response("from-override"); + OAuth2AccessTokenResponse defaultResponse = response("from-default"); + DispatchingTokenResponseClient client = new DispatchingTokenResponseClient( + List.of(stubProvider("dingtalk", overrideResponse)), + request -> defaultResponse + ); + + OAuth2AccessTokenResponse result = client.getTokenResponse(grantRequest("dingtalk")); + + assertThat(result.getAccessToken().getTokenValue()).isEqualTo("from-override"); + } + + @Test + void fallsBackToDefaultClientForUnclaimedRegistrations() { + OAuth2AccessTokenResponse overrideResponse = response("from-override"); + OAuth2AccessTokenResponse defaultResponse = response("from-default"); + DispatchingTokenResponseClient client = new DispatchingTokenResponseClient( + List.of(stubProvider("dingtalk", overrideResponse)), + request -> defaultResponse + ); + + // GitHub must keep the standard exchange even while a DingTalk override is registered. + OAuth2AccessTokenResponse result = client.getTokenResponse(grantRequest("github")); + + assertThat(result.getAccessToken().getTokenValue()).isEqualTo("from-default"); + } + + private static ProviderTokenResponseClient stubProvider( + String provider, + OAuth2AccessTokenResponse response + ) { + return new ProviderTokenResponseClient() { + @Override + public String getProvider() { + return provider; + } + + @Override + public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest request) { + return response; + } + }; + } + + private static OAuth2AccessTokenResponse response(String tokenValue) { + return OAuth2AccessTokenResponse.withToken(tokenValue) + .tokenType(org.springframework.security.oauth2.core.OAuth2AccessToken.TokenType.BEARER) + .expiresIn(3600) + .build(); + } + + private static OAuth2AuthorizationCodeGrantRequest grantRequest(String registrationId) { + ClientRegistration registration = ClientRegistration.withRegistrationId(registrationId) + .clientId("client") + .clientSecret("secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .redirectUri("https://skillhub.example/login/oauth2/code/" + registrationId) + .authorizationUri("https://provider.example/authorize") + .tokenUri("https://provider.example/token") + .userInfoUri("https://provider.example/me") + .userNameAttributeName("id") + .build(); + OAuth2AuthorizationRequest authorizationRequest = OAuth2AuthorizationRequest.authorizationCode() + .authorizationUri("https://provider.example/authorize") + .clientId("client") + .redirectUri("https://skillhub.example/login/oauth2/code/" + registrationId) + .state("state-1") + .build(); + OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponse.success("code-1") + .redirectUri("https://skillhub.example/login/oauth2/code/" + registrationId) + .state("state-1") + .build(); + return new OAuth2AuthorizationCodeGrantRequest( + registration, + new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse) + ); + } + +}