mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-24 00:55:35 +00:00
feat(auth): let providers override token exchange and authorization params
Extends the per-provider strategy pattern from the userinfo step to the two earlier stages of the authorization-code flow, so a provider whose endpoints deviate from the standard contract needs no branch in shared code: - ProviderTokenResponseClient for a non-standard token exchange, dispatched by DispatchingTokenResponseClient because Spring's tokenEndpoint accepts only one client - ProviderAuthorizationRequestCustomizer for authorization parameters, dispatched through the resolver's existing customizer hook Registrations without an override keep the standard Spring behaviour. Together with ProviderOAuth2UserService this covers all three stages where a provider can deviate: authorize, token, userinfo. Account decisions stay outside these hooks, in the unified identity core. Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
934cfa6ded
commit
5c92c9eeed
6 changed files with 216 additions and 8 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
* <p>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<OAuth2AuthorizationCodeGrantRequest> {
|
||||
|
||||
private final Map<String, ProviderTokenResponseClient> overrides;
|
||||
private final OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> delegate;
|
||||
|
||||
@Autowired
|
||||
public DispatchingTokenResponseClient(List<ProviderTokenResponseClient> providerClients) {
|
||||
this(providerClients, new DefaultAuthorizationCodeTokenResponseClient());
|
||||
}
|
||||
|
||||
DispatchingTokenResponseClient(
|
||||
List<ProviderTokenResponseClient> providerClients,
|
||||
OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
* <p>The token and userinfo counterparts are {@link ProviderTokenResponseClient} and
|
||||
* {@link ProviderOAuth2UserService}.
|
||||
*/
|
||||
public interface ProviderAuthorizationRequestCustomizer {
|
||||
|
||||
String getProvider();
|
||||
|
||||
void customize(OAuth2AuthorizationRequest.Builder builder);
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
* <p>The userinfo counterpart is {@link ProviderOAuth2UserService}.
|
||||
*/
|
||||
public interface ProviderTokenResponseClient
|
||||
extends OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> {
|
||||
|
||||
String getProvider();
|
||||
}
|
||||
|
|
@ -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<ProviderAuthorizationRequestCustomizer> customizers) {
|
||||
this.delegate = new DefaultOAuth2AuthorizationRequestResolver(
|
||||
clientRegistrationRepository,
|
||||
"/oauth2/authorization"
|
||||
);
|
||||
this.oauthLoginFlowService = oauthLoginFlowService;
|
||||
Map<String, ProviderAuthorizationRequestCustomizer> 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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue