feat(auth): add DingTalk OAuth2 login support

DingTalk (钉钉) uses a non-standard OAuth2 flow that requires:
- JSON body for token exchange (instead of form-urlencoded)
- Custom header (x-acs-dingtalk-access-token) for user info requests

This PR integrates DingTalk by leveraging the existing OAuthClaimsExtractor
strategy pattern, adding three provider-specific components:

- DingTalkClaimsExtractor: maps DingTalk user fields to normalized OAuthClaims
- DingTalkTokenResponseClient: handles DingTalk's JSON token exchange
- DingTalkOAuth2UserService: fetches user info via DingTalk's custom header

SecurityConfig uses delegating wrappers to route DingTalk requests to
these custom components while preserving standard behavior for all other
providers (GitHub, GitLab, OIDC).

No changes needed to OAuthLoginFlowService, IdentityBindingService,
AuthMethodCatalog, or frontend LoginButton — all are provider-agnostic.

Signed-off-by: konglong87 <38234954+konglong87@users.noreply.github.com>
This commit is contained in:
konglong87 2026-05-28 14:56:20 +08:00
parent 6817d98007
commit 321d2b4d94
7 changed files with 317 additions and 2 deletions

View file

@ -92,6 +92,12 @@ OAUTH2_GITLAB_CLIENT_SECRET=
OAUTH2_GITLAB_BASE_URI=https://gitlab.com
OAUTH2_GITLAB_DISPLAY_NAME=GitLab
# Optional: configure DingTalk (钉钉) OAuth2 login.
# Register your app at https://open-dev.dingtalk.com and request the Contact.User.Read scope.
OAUTH2_DINGTALK_CLIENT_ID=
OAUTH2_DINGTALK_CLIENT_SECRET=
OAUTH2_DINGTALK_DISPLAY_NAME=钉钉
# Optional: OIDC login (e.g. Keycloak, Okta, Azure AD).
# Replace "OIDC" in variable names with your registration id (uppercase).
# The registration id becomes identity_binding.provider_code — keep it stable.

View file

@ -69,6 +69,14 @@ spring:
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
client-name: ${OAUTH2_GITLAB_DISPLAY_NAME:GitLab}
dingtalk:
client-id: ${OAUTH2_DINGTALK_CLIENT_ID:placeholder}
client-secret: ${OAUTH2_DINGTALK_CLIENT_SECRET:placeholder}
scope:
- dingtalk
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
client-name: ${OAUTH2_DINGTALK_DISPLAY_NAME:钉钉}
provider:
github:
user-info-uri: https://api.github.com/user
@ -77,6 +85,11 @@ spring:
token-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/oauth/token
user-info-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/api/v4/user
user-name-attribute: username
dingtalk:
authorization-uri: https://login.dingtalk.com/oauth2/auth
token-uri: https://api.dingtalk.com/v1.0/oauth2/userAccessToken
user-info-uri: https://api.dingtalk.com/v1.0/contact/users/me
user-name-attribute: openId
servlet:
multipart:
max-file-size: 100MB

View file

@ -2,6 +2,8 @@ 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.DingTalkOAuth2UserService;
import com.iflytek.skillhub.auth.oauth.DingTalkTokenResponseClient;
import com.iflytek.skillhub.auth.oauth.OAuth2LoginFailureHandler;
import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler;
import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver;
@ -19,6 +21,12 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
@ -57,6 +65,8 @@ public class SecurityConfig {
private final CustomOAuth2UserService customOAuth2UserService;
private final CustomOidcUserService customOidcUserService;
private final DingTalkOAuth2UserService dingTalkOAuth2UserService;
private final DingTalkTokenResponseClient dingTalkTokenResponseClient;
private final SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver;
private final OAuth2LoginSuccessHandler successHandler;
private final OAuth2LoginFailureHandler failureHandler;
@ -69,6 +79,8 @@ public class SecurityConfig {
public SecurityConfig(CustomOAuth2UserService customOAuth2UserService,
CustomOidcUserService customOidcUserService,
DingTalkOAuth2UserService dingTalkOAuth2UserService,
DingTalkTokenResponseClient dingTalkTokenResponseClient,
SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver,
OAuth2LoginSuccessHandler successHandler,
OAuth2LoginFailureHandler failureHandler,
@ -80,6 +92,8 @@ public class SecurityConfig {
RouteSecurityPolicyRegistry routeSecurityPolicyRegistry) {
this.customOAuth2UserService = customOAuth2UserService;
this.customOidcUserService = customOidcUserService;
this.dingTalkOAuth2UserService = dingTalkOAuth2UserService;
this.dingTalkTokenResponseClient = dingTalkTokenResponseClient;
this.authorizationRequestResolver = authorizationRequestResolver;
this.successHandler = successHandler;
this.failureHandler = failureHandler;
@ -120,8 +134,10 @@ public class SecurityConfig {
})
.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(endpoint -> endpoint.authorizationRequestResolver(authorizationRequestResolver))
.tokenEndpoint(token -> token.accessTokenResponseClient(
new DelegatingAccessTokenResponseClient(dingTalkTokenResponseClient, new DefaultAuthorizationCodeTokenResponseClient())))
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService)
.userService(new DelegatingOAuth2UserService(customOAuth2UserService, dingTalkOAuth2UserService))
.oidcUserService(customOidcUserService))
.successHandler(successHandler)
.failureHandler(failureHandler)
@ -186,7 +202,7 @@ public class SecurityConfig {
}
}
static boolean hasSessionCookie(HttpServletRequest request) {
static boolean hasSessionCookie(HttpServletRequest request) {
if (request.getRequestedSessionId() != null) {
return true;
}
@ -201,4 +217,50 @@ public class SecurityConfig {
}
return false;
}
/**
* Delegates OAuth2 user info loading to the appropriate service based on
* the registrationId. DingTalk uses a custom service due to its non-standard
* user info endpoint; all other providers use the standard service.
*/
private static class DelegatingOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final CustomOAuth2UserService defaultService;
private final DingTalkOAuth2UserService dingTalkService;
DelegatingOAuth2UserService(CustomOAuth2UserService defaultService, DingTalkOAuth2UserService dingTalkService) {
this.defaultService = defaultService;
this.dingTalkService = dingTalkService;
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) {
if ("dingtalk".equals(userRequest.getClientRegistration().getRegistrationId())) {
return dingTalkService.loadUser(userRequest);
}
return defaultService.loadUser(userRequest);
}
}
/**
* Delegates token exchange to the appropriate client based on the
* registrationId. DingTalk requires a JSON body instead of form-urlencoded;
* all other providers use the standard client.
*/
private static class DelegatingAccessTokenResponseClient implements OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> {
private final DingTalkTokenResponseClient dingTalkClient;
private final DefaultAuthorizationCodeTokenResponseClient defaultClient;
DelegatingAccessTokenResponseClient(DingTalkTokenResponseClient dingTalkClient, DefaultAuthorizationCodeTokenResponseClient defaultClient) {
this.dingTalkClient = dingTalkClient;
this.defaultClient = defaultClient;
}
@Override
public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) {
if ("dingtalk".equals(authorizationCodeGrantRequest.getClientRegistration().getRegistrationId())) {
return dingTalkClient.getTokenResponse(authorizationCodeGrantRequest);
}
return defaultClient.getTokenResponse(authorizationCodeGrantRequest);
}
}
}

View file

@ -0,0 +1,53 @@
package com.iflytek.skillhub.auth.oauth;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* Provider-specific claims extractor for DingTalk (钉钉).
*
* <p>Maps DingTalk's non-standard user info fields into normalized {@link OAuthClaims}
* for downstream account provisioning and access policy evaluation.
*
* <p>Field mapping:
* <ul>
* <li>subject → openId</li>
* <li>email → unionId@dingtalk.local (synthetic, DingTalk users may not have email)</li>
* <li>emailVerified → true (synthetic)</li>
* <li>providerLogin → nick</li>
* </ul>
*/
@Component
public class DingTalkClaimsExtractor implements OAuthClaimsExtractor {
@Override
public String getProvider() {
return "dingtalk";
}
@Override
public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) {
Map<String, Object> attrs = oAuth2User.getAttributes();
String openId = (String) attrs.get("openId");
String unionId = (String) attrs.get("unionId");
String nick = (String) attrs.get("nick");
// DingTalk users may not have email; synthesize one for downstream compatibility
String syntheticEmail = (unionId != null && !unionId.isEmpty())
? unionId + "@dingtalk.local"
: (openId != null ? openId + "@dingtalk.local" : null);
return new OAuthClaims(
"dingtalk",
openId,
syntheticEmail,
true,
nick,
attrs
);
}
}

View file

@ -0,0 +1,86 @@
package com.iflytek.skillhub.auth.oauth;
import java.util.HashMap;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import com.iflytek.skillhub.auth.identity.IdentityBindingService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.user.UserStatus;
/**
* OAuth2UserService for DingTalk — handles DingTalk's non-standard user info
* endpoint which uses a custom header {@code x-acs-dingtalk-access-token}
* instead of the standard {@code Authorization: Bearer} header.
*/
@Component
public class DingTalkOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private static final Logger log = LoggerFactory.getLogger(DingTalkOAuth2UserService.class);
private final RestTemplate restTemplate;
private final IdentityBindingService identityBindingService;
private final DingTalkClaimsExtractor claimsExtractor;
public DingTalkOAuth2UserService(IdentityBindingService identityBindingService,
DingTalkClaimsExtractor claimsExtractor) {
this.restTemplate = new RestTemplate();
this.identityBindingService = identityBindingService;
this.claimsExtractor = claimsExtractor;
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) {
String accessToken = userRequest.getAccessToken().getTokenValue();
// Fetch user info using DingTalk's custom header
HttpHeaders headers = new HttpHeaders();
headers.set("x-acs-dingtalk-access-token", accessToken);
HttpEntity<Void> requestEntity = new HttpEntity<>(headers);
ResponseEntity<Map> response = restTemplate.exchange(
"https://api.dingtalk.com/v1.0/contact/users/me",
HttpMethod.GET,
requestEntity,
Map.class
);
Map<String, Object> attributes = response.getBody() != null ? response.getBody() : Map.of();
// Map DingTalk response to standard attributes
Map<String, Object> userAttributes = new HashMap<>(attributes);
userAttributes.putIfAbsent("openId", attributes.get("openId"));
userAttributes.putIfAbsent("nickName", attributes.get("nick"));
userAttributes.putIfAbsent("avatarUrl", attributes.get("avatarUrl"));
// Extract claims and create PlatformPrincipal
OAuthClaims claims = claimsExtractor.extract(userRequest, new DefaultOAuth2User(
java.util.Collections.emptyList(), userAttributes, "openId"));
log.info("DingTalk OAuth2 login: subject={}, providerLogin={}", claims.subject(), claims.providerLogin());
// Bind or create user account
PlatformPrincipal principal = identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE);
// Put platformPrincipal in attributes for OAuth2LoginSuccessHandler
userAttributes.put("platformPrincipal", principal);
return new DefaultOAuth2User(
java.util.Collections.emptyList(),
userAttributes,
"openId"
);
}
}

View file

@ -0,0 +1,91 @@
package com.iflytek.skillhub.auth.oauth;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.util.Collections;
import java.util.Map;
/**
* Custom token response client for DingTalk (钉钉).
*
* <p>DingTalk requires a JSON body for token exchange instead of the standard
* form-urlencoded format. This client adapts the request accordingly.
*
* <p>Request body format:
* <pre>{ "clientId": "...", "clientSecret": "...", "code": "...", "grantType": "authorization_code" }</pre>
*/
@Component
public class DingTalkTokenResponseClient implements OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final RestTemplate restTemplate = new RestTemplate();
@Override
public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest)
throws OAuth2AuthenticationException {
String tokenUri = authorizationCodeGrantRequest.getClientRegistration().getProviderDetails().getTokenUri();
String clientId = authorizationCodeGrantRequest.getClientRegistration().getClientId();
String clientSecret = authorizationCodeGrantRequest.getClientRegistration().getClientSecret();
String code = authorizationCodeGrantRequest.getAuthorizationExchange()
.getAuthorizationResponse()
.getCode();
Map<String, Object> tokenRequest = Map.of(
"clientId", clientId,
"clientSecret", clientSecret,
"code", code,
"grantType", "authorization_code"
);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
ResponseEntity<String> response;
try {
response = restTemplate.postForEntity(tokenUri, new HttpEntity<>(tokenRequest, headers), String.class);
} catch (Exception e) {
throw new OAuth2AuthenticationException(
new OAuth2Error("token_exchange_io_error",
"Failed to exchange code for DingTalk access token: " + e.getMessage(), null), e);
}
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
try {
JsonNode json = MAPPER.readTree(response.getBody());
String accessToken = json.get("accessToken").asText();
if (accessToken == null || accessToken.isEmpty()) {
throw new OAuth2AuthenticationException(
new OAuth2Error("token_response_missing_field",
"DingTalk token response missing accessToken", null));
}
return OAuth2AccessTokenResponse.withToken(accessToken)
.tokenType(OAuth2AccessToken.TokenType.BEARER)
.additionalParameters(Collections.singletonMap("raw_response", response.getBody()))
.build();
} catch (OAuth2AuthenticationException e) {
throw e;
} catch (Exception e) {
throw new OAuth2AuthenticationException(
new OAuth2Error("token_parse_error",
"Failed to parse DingTalk token response", null), e);
}
}
throw new OAuth2AuthenticationException(
new OAuth2Error("token_exchange_failed",
"DingTalk token exchange failed: HTTP " + response.getStatusCode(), null));
}
}

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="800px" height="800px">
<circle cx="512" cy="512" r="512" fill="#0089FF"/>
<path d="M640 448c0-35.3-28.7-64-64-64s-64 28.7-64 64 28.7 64 64 64 64-28.7 64-64zm-192 0c0-35.3-28.7-64-64-64s-64 28.7-64 64 28.7 64 64 64 64-28.7 64-64zm96 224c-106 0-192-86-192-192h384c0 106-86 192-192 192zm0-64c52.9 0 96-43.1 96-96H448c0 52.9 43.1 96 96 96z" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 435 B