Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.release.example
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ 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.
# The scope must be "openid" (not "dingtalk") — DingTalk uses openid for OAuth2 authorization.
# Add "openid corpid" if you also need corporate identity information.
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.
Expand Down
3 changes: 3 additions & 0 deletions document/docs/02-administration/deployment/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ SkillHub 通过环境变量进行配置,主要配置项如下:
|---------|------|--------|
| `OAUTH2_GITHUB_CLIENT_ID` | GitHub OAuth Client ID | - |
| `OAUTH2_GITHUB_CLIENT_SECRET` | GitHub OAuth Client Secret | - |
| `OAUTH2_DINGTALK_CLIENT_ID` | 钉钉 OAuth AppKey | - |
| `OAUTH2_DINGTALK_CLIENT_SECRET` | 钉钉 OAuth AppSecret | - |
| `OAUTH2_DINGTALK_DISPLAY_NAME` | 钉钉登录按钮显示名 | `钉钉` |

### 首登管理员配置

Expand Down
14 changes: 14 additions & 0 deletions document/docs/02-administration/security/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ SkillHub 支持多种认证方式,满足不同企业的安全需求。
OAUTH2_GITHUB_CLIENT_SECRET=your-client-secret
```

### 钉钉 OAuth2

1. 在[钉钉开放平台](https://open-dev.dingtalk.com/)创建 H5 微应用,获取 AppKey 和 AppSecret
2. 开通 `Contact.User.Read` 权限(获取用户信息)
3. 发布应用版本以激活 OAuth2 凭证
4. 回调地址填写 `{baseUrl}/login/oauth2/code/dingtalk`
5. 配置环境变量:
```bash
OAUTH2_DINGTALK_CLIENT_ID=你的AppKey
OAUTH2_DINGTALK_CLIENT_SECRET=你的AppSecret
```

> 钉钉使用 `corpid` scope(非标准 OIDC `openid`),用户以 `unionId` 作为唯一标识。

### 扩展 OAuth Provider

架构支持扩展其他 OAuth Provider,如 GitLab、Gitee 等。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ SkillHub is configured through environment variables. The main configuration ite
|---------------------|-------------|---------------|
| `OAUTH2_GITHUB_CLIENT_ID` | GitHub OAuth Client ID | - |
| `OAUTH2_GITHUB_CLIENT_SECRET` | GitHub OAuth Client Secret | - |
| `OAUTH2_DINGTALK_CLIENT_ID` | DingTalk OAuth AppKey | - |
| `OAUTH2_DINGTALK_CLIENT_SECRET` | DingTalk OAuth AppSecret | - |
| `OAUTH2_DINGTALK_DISPLAY_NAME` | DingTalk login button display name | `钉钉` |

### Bootstrap Admin Configuration

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ SkillHub supports multiple authentication methods to meet different enterprise s
OAUTH2_GITHUB_CLIENT_SECRET=your-client-secret
```

### DingTalk OAuth2

1. Create an H5 micro-app on [DingTalk Open Platform](https://open-dev.dingtalk.com/) and obtain AppKey and AppSecret
2. Enable the `Contact.User.Read` permission (required for fetching user info)
3. Publish the app version to activate OAuth2 credentials
4. Set the callback URL to `{baseUrl}/login/oauth2/code/dingtalk`
5. Configure environment variables:
```bash
OAUTH2_DINGTALK_CLIENT_ID=your-appkey
OAUTH2_DINGTALK_CLIENT_SECRET=your-appsecret
```

> DingTalk uses `corpid` scope (not standard OIDC `openid`). Users are identified by `unionId`.

### Extend OAuth Provider

The architecture supports extending to other OAuth providers like GitLab, Gitee, etc.
Expand Down
6 changes: 4 additions & 2 deletions server/skillhub-app/src/main/resources/application-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ spring:
github:
client-id: ${OAUTH2_GITHUB_CLIENT_ID:local-placeholder}
client-secret: ${OAUTH2_GITHUB_CLIENT_SECRET:local-placeholder}
dingtalk:
client-id: ${OAUTH2_DINGTALK_CLIENT_ID:local-placeholder}
client-secret: ${OAUTH2_DINGTALK_CLIENT_SECRET:local-placeholder}

skillhub:
auth:
Expand Down Expand Up @@ -49,5 +52,4 @@ skillhub:

logging:
level:
com.iflytek.skillhub: INFO
org.springframework.security: WARN
com.iflytek.skillhub.auth: DEBUG
13 changes: 13 additions & 0 deletions server/skillhub-app/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,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:
- corpid
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
Expand All @@ -76,6 +84,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: unionId
servlet:
multipart:
max-file-size: 100MB
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,12 +142,13 @@ void providersShouldExposeGithubLoginEntry() throws Exception {
mockMvc.perform(get("/api/v1/auth/providers"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.length()").value(3))
.andExpect(jsonPath("$.data[*].id", hasItems("github", "gitee", "gitlab")))
.andExpect(jsonPath("$.data.length()").value(4))
.andExpect(jsonPath("$.data[*].id", hasItems("github", "gitee", "gitlab", "dingtalk")))
.andExpect(jsonPath("$.data[*].authorizationUrl", hasItems(
"/oauth2/authorization/github",
"/oauth2/authorization/gitee",
"/oauth2/authorization/gitlab"
"/oauth2/authorization/gitlab",
"/oauth2/authorization/dingtalk"
)))
.andExpect(jsonPath("$.timestamp").isNotEmpty())
.andExpect(jsonPath("$.requestId").isNotEmpty());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

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;
Expand All @@ -17,6 +19,13 @@
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.core.endpoint.OAuth2AccessTokenResponse;
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;
Expand All @@ -37,7 +46,7 @@
* Central Spring Security configuration for browser sessions, API tokens, and
* public versus protected endpoints.
*/
@Configuration
@Configuration(proxyBeanMethods = false)
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
Expand All @@ -55,6 +64,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;
Expand All @@ -67,6 +78,8 @@ public class SecurityConfig {

public SecurityConfig(CustomOAuth2UserService customOAuth2UserService,
CustomOidcUserService customOidcUserService,
DingTalkOAuth2UserService dingTalkOAuth2UserService,
DingTalkTokenResponseClient dingTalkTokenResponseClient,
SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver,
OAuth2LoginSuccessHandler successHandler,
OAuth2LoginFailureHandler failureHandler,
Expand All @@ -78,6 +91,8 @@ public SecurityConfig(CustomOAuth2UserService customOAuth2UserService,
RouteSecurityPolicyRegistry routeSecurityPolicyRegistry) {
this.customOAuth2UserService = customOAuth2UserService;
this.customOidcUserService = customOidcUserService;
this.dingTalkOAuth2UserService = dingTalkOAuth2UserService;
this.dingTalkTokenResponseClient = dingTalkTokenResponseClient;
this.authorizationRequestResolver = authorizationRequestResolver;
this.successHandler = successHandler;
this.failureHandler = failureHandler;
Expand Down Expand Up @@ -118,8 +133,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
})
.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)
Expand Down Expand Up @@ -183,4 +200,50 @@ private void configureRoutePolicies(AuthorizeHttpRequestsConfigurer<HttpSecurity
}
}
}

/**
* 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.iflytek.skillhub.auth.oauth;

import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
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 → unionId (unique across all apps under the same developer account)</li>
* <li>email → unionId@dingtalk.local (synthetic, DingTalk users may not have email)</li>
* <li>emailVerified → true (synthetic)</li>
* <li>providerLogin → nick</li>
* </ul>
*
* <p>Note: unionId is used instead of openId because openId is only unique within
* a single DingTalk application. If a user logs in through different DingTalk apps
* under the same developer account, openId would differ, causing duplicate accounts.
* unionId remains stable across all apps under the same developer.
*/
@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 unionId = (String) attrs.get("unionId");
String openId = (String) attrs.get("openId");
String nick = (String) attrs.get("nick");

// unionId is required — it is the cross-app stable identity for DingTalk users
if (unionId == null || unionId.isEmpty()) {
throw new OAuth2AuthenticationException(
new OAuth2Error("missing_union_id",
"DingTalk response missing required unionId field. "
+ "Ensure the 'openid' scope is configured and the DingTalk app "
+ "has the Contact.User.Read permission.", null));
}

// DingTalk users may not have email; synthesize one for downstream compatibility
String syntheticEmail = unionId + "@dingtalk.local";

return new OAuthClaims(
"dingtalk",
unionId, // Use unionId (cross-app unique) instead of openId (single-app only)
syntheticEmail,
true,
nick,
attrs
);
}
}
Loading
Loading