diff --git a/.env.release.example b/.env.release.example index 59387e0f..5b2fbc9d 100644 --- a/.env.release.example +++ b/.env.release.example @@ -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. diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 421e27f6..5621c8c9 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -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 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 8c2ff2dc..c58a718c 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,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 { + 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 { + 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); + } + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java new file mode 100644 index 00000000..51f6d27b --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkClaimsExtractor.java @@ -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 (钉钉). + * + *

Maps DingTalk's non-standard user info fields into normalized {@link OAuthClaims} + * for downstream account provisioning and access policy evaluation. + * + *

Field mapping: + *

+ */ +@Component +public class DingTalkClaimsExtractor implements OAuthClaimsExtractor { + + @Override + public String getProvider() { + return "dingtalk"; + } + + @Override + public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) { + Map 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 + ); + } +} \ No newline at end of file diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java new file mode 100644 index 00000000..d22f4ad4 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkOAuth2UserService.java @@ -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 { + + 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 requestEntity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + "https://api.dingtalk.com/v1.0/contact/users/me", + HttpMethod.GET, + requestEntity, + Map.class + ); + + Map attributes = response.getBody() != null ? response.getBody() : Map.of(); + + // Map DingTalk response to standard attributes + Map 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" + ); + } +} \ No newline at end of file diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java new file mode 100644 index 00000000..0fe402a1 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/DingTalkTokenResponseClient.java @@ -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 (钉钉). + * + *

DingTalk requires a JSON body for token exchange instead of the standard + * form-urlencoded format. This client adapts the request accordingly. + * + *

Request body format: + *

{ "clientId": "...", "clientSecret": "...", "code": "...", "grantType": "authorization_code" }
+ */ +@Component +public class DingTalkTokenResponseClient implements OAuth2AccessTokenResponseClient { + + 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 tokenRequest = Map.of( + "clientId", clientId, + "clientSecret", clientSecret, + "code", code, + "grantType", "authorization_code" + ); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity 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)); + } +} \ No newline at end of file diff --git a/web/public/dingtalk-logo.svg b/web/public/dingtalk-logo.svg new file mode 100644 index 00000000..e71bfbbc --- /dev/null +++ b/web/public/dingtalk-logo.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file