From 6a54955cf90058e8508d0622cafcff6d89d11574 Mon Sep 17 00:00:00 2001 From: yhd Date: Fri, 7 Aug 2026 14:19:04 +0800 Subject: [PATCH 1/2] feat(auth): add Feishu OAuth2 login provider Feishu deviates from standard OAuth2: the authorize endpoint requires app_id instead of client_id, userinfo returns a {code, msg, data} envelope with errors reported as HTTP 200, and the token endpoint uses client_secret_post. Reuse the Spring oauth2Login pipeline and override only the userinfo loading step via a ProviderOAuth2UserService extension point, keeping GitHub/GitLab behavior unchanged. Bindings use open_id as subject; union_id is kept in extra for future cross-app migration. --- .env.release.example | 11 ++ docs/03-authentication-design.md | 20 ++- .../src/main/resources/application.yml | 14 ++ .../auth/oauth/FeishuClaimsExtractor.java | 58 ++++++++ .../auth/oauth/FeishuOAuth2UserService.java | 127 ++++++++++++++++++ .../auth/oauth/OAuthLoginFlowService.java | 13 +- .../auth/oauth/ProviderOAuth2UserService.java | 14 ++ ...HubOAuth2AuthorizationRequestResolver.java | 46 ++++++- .../auth/oauth/FeishuClaimsExtractorTest.java | 87 ++++++++++++ .../oauth/FeishuOAuth2UserServiceTest.java | 105 +++++++++++++++ ...Auth2AuthorizationRequestResolverTest.java | 43 +++++- .../auth/oauth/OAuthLoginFlowServiceTest.java | 3 + web/public/feishu-logo.svg | 4 + 13 files changed, 537 insertions(+), 8 deletions(-) create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractor.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserService.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderOAuth2UserService.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractorTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserServiceTest.java create mode 100644 web/public/feishu-logo.svg diff --git a/.env.release.example b/.env.release.example index 049d887ff..7658be8ef 100644 --- a/.env.release.example +++ b/.env.release.example @@ -105,6 +105,17 @@ OAUTH2_GITLAB_CLIENT_SECRET= OAUTH2_GITLAB_BASE_URI=https://gitlab.com OAUTH2_GITLAB_DISPLAY_NAME=GitLab +# Optional: configure Feishu (Lark) OAuth. Create a self-built app (企业自建应用) on the +# Feishu Open Platform, grant the contact:user.base:readonly and contact:user.email:readonly +# scopes, publish a version, and add /login/oauth2/code/feishu to the app's +# redirect URLs (安全设置 -> 重定向 URL). +# Note: users without an email are denied when EMAIL_DOMAIN access policy is enabled; +# SUBJECT_WHITELIST entries must use the Feishu open_id (ou_...). +OAUTH2_FEISHU_CLIENT_ID= +OAUTH2_FEISHU_CLIENT_SECRET= +OAUTH2_FEISHU_BASE_URI=https://open.feishu.cn +OAUTH2_FEISHU_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/docs/03-authentication-design.md b/docs/03-authentication-design.md index 2f4b77052..9c43c43aa 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -279,9 +279,23 @@ spring: ``` Spring Security OAuth2 Client 原生支持多 Provider 并存,新增 Provider 只需: -1. `application.yml` 添加 registration 配置 -2. `CustomOAuth2UserService` 中按 `registrationId` 分支处理用户属性映射 -3. 前端登录页增加对应按钮(通过 `/api/v1/auth/providers` 自动发现) +1. `application.yml` 添加 registration 配置(client-id 默认 `placeholder` 时登录页自动隐藏该入口) +2. 新增一个 `OAuthClaimsExtractor` 实现(`@Component`,按 `registrationId` 自动注册),完成用户属性到标准 claims 的映射 +3. 前端无需改动:登录按钮通过 `/api/v1/auth/methods` 自动发现,图标约定 `web/public/{provider}-logo.svg` + +### 非标准 Provider 接入样板:飞书(Feishu) + +飞书 OAuth 与标准 OAuth2 存在偏差,接入时做了以下定制,可作为后续非标准 Provider 的参考: + +1. **授权端点参数**:飞书要求 `app_id` 而非 `client_id`,且不接受 `scope` 参数(权限在开放平台应用内配置)。 + `SkillHubOAuth2AuthorizationRequestResolver` 对 `feishu` registration 重建授权 URI。 +2. **userinfo 响应包裹**:响应为 `{code, msg, data}` 结构且错误以 HTTP 200 返回。 + 通过 `ProviderOAuth2UserService` 扩展点实现 `FeishuOAuth2UserService`,覆盖默认的 user info 加载并解包 `data`; + `OAuthLoginFlowService` 按 registrationId 选择 loader,其余 Provider 仍走 `DefaultOAuth2UserService`。 +3. **token 端点认证**:使用 `client_secret_post`(表单传 client_id/client_secret)。 +4. **subject 选择**:绑定主体使用 `open_id`(应用内唯一);`union_id` 保留在 extra 中, + 未来若同一部署接入多个飞书应用可基于它做身份归并。 +5. **准入策略注意**:邮箱域名策略(EMAIL_DOMAIN)模式下,未绑定邮箱的飞书用户会被拒绝。 ## 4. 核心接口设计 diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 10e9638eb..ac5c1da46 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -69,6 +69,15 @@ spring: authorization-grant-type: authorization_code redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" client-name: ${OAUTH2_GITLAB_DISPLAY_NAME:GitLab} + feishu: + client-id: ${OAUTH2_FEISHU_CLIENT_ID:placeholder} + client-secret: ${OAUTH2_FEISHU_CLIENT_SECRET:placeholder} + # Feishu scopes are configured on the open platform app itself + # (contact:user.base:readonly, contact:user.email:readonly). + authorization-grant-type: authorization_code + client-authentication-method: client_secret_post + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + client-name: ${OAUTH2_FEISHU_DISPLAY_NAME:飞书} provider: github: user-info-uri: https://api.github.com/user @@ -77,6 +86,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 + feishu: + authorization-uri: ${OAUTH2_FEISHU_BASE_URI:https://open.feishu.cn}/open-apis/authen/v1/authorize + token-uri: ${OAUTH2_FEISHU_BASE_URI:https://open.feishu.cn}/open-apis/authen/v2/oauth/token + user-info-uri: ${OAUTH2_FEISHU_BASE_URI:https://open.feishu.cn}/open-apis/authen/v1/user_info + user-name-attribute: open_id servlet: multipart: max-file-size: 100MB diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractor.java new file mode 100644 index 000000000..73ba7b3b5 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractor.java @@ -0,0 +1,58 @@ +package com.iflytek.skillhub.auth.oauth; + +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Component; + +/** + * Provider-specific claims extractor for Feishu (Lark) OAuth users. Attributes are already + * unwrapped from the Feishu response envelope by {@link FeishuOAuth2UserService}. + */ +@Component +public class FeishuClaimsExtractor implements OAuthClaimsExtractor { + + private static final Logger log = LoggerFactory.getLogger(FeishuClaimsExtractor.class); + + @Override + public String getProvider() { + return FeishuOAuth2UserService.PROVIDER; + } + + @Override + public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) { + Map attrs = oAuth2User.getAttributes(); + + // open_id is unique within the Feishu app; union_id is kept in extra for potential + // cross-app identity migration later. + String subject = String.valueOf(attrs.get("open_id")); + + String email = (String) attrs.get("enterprise_email"); + if (email == null) { + email = (String) attrs.get("email"); + } + boolean emailVerified = email != null; + + String username = (String) attrs.get("name"); + if (username == null || username.isBlank()) { + username = (String) attrs.get("en_name"); + } + if (username == null || username.isBlank()) { + username = "feishu-" + subject; + } + + log.info("Feishu OAuth claims extracted - subject: {}, username: {}, email present: {}", + subject, username, email != null); + + return new OAuthClaims( + FeishuOAuth2UserService.PROVIDER, + subject, + email, + emailVerified, + username, + attrs + ); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserService.java new file mode 100644 index 000000000..3c44bb698 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserService.java @@ -0,0 +1,127 @@ +package com.iflytek.skillhub.auth.oauth; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +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.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +/** + * Loads Feishu (Lark) user info, which deviates from the standard OAuth format: the response is + * wrapped in a {@code {code, msg, data}} envelope and errors are reported with HTTP 200. + */ +@Component +public class FeishuOAuth2UserService implements ProviderOAuth2UserService { + + static final String PROVIDER = "feishu"; + + private final RestClient restClient; + + /** + * Uses an external-service client that is intentionally not customized with application + * tracing. Trace context must not be propagated to the external Feishu service. + */ + @Autowired + public FeishuOAuth2UserService() { + this(RestClient.builder()); + } + + public FeishuOAuth2UserService(RestClient.Builder restClientBuilder) { + this.restClient = restClientBuilder + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + } + + @Override + public String getProvider() { + return PROVIDER; + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + String userInfoUri = userRequest.getClientRegistration().getProviderDetails() + .getUserInfoEndpoint().getUri(); + + FeishuUserResponse response; + try { + response = restClient.get() + .uri(userInfoUri) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + userRequest.getAccessToken().getTokenValue()) + .retrieve() + .body(new ParameterizedTypeReference() {}); + } catch (Exception e) { + throw new OAuth2AuthenticationException( + new OAuth2Error("feishu_userinfo_error", "Failed to load Feishu user info: " + e.getMessage(), null), + e + ); + } + + if (response == null || response.code() != 0 || response.data() == null) { + String msg = response != null ? response.msg() : "empty response"; + throw new OAuth2AuthenticationException( + new OAuth2Error("feishu_userinfo_error", "Feishu user info error: " + msg, null) + ); + } + + String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails() + .getUserInfoEndpoint().getUserNameAttributeName(); + + Map attributes = flatten(response.data(), userNameAttributeName); + return new DefaultOAuth2User( + Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")), + attributes, + userNameAttributeName + ); + } + + private Map flatten(FeishuUserData data, String userNameAttributeName) { + Map attributes = new LinkedHashMap<>(); + putIfPresent(attributes, "open_id", data.openId()); + putIfPresent(attributes, "union_id", data.unionId()); + putIfPresent(attributes, "name", data.name()); + putIfPresent(attributes, "en_name", data.enName()); + putIfPresent(attributes, "avatar_url", data.avatarUrl()); + putIfPresent(attributes, "email", data.email()); + putIfPresent(attributes, "enterprise_email", data.enterpriseEmail()); + putIfPresent(attributes, "mobile", data.mobile()); + if (!attributes.containsKey(userNameAttributeName)) { + throw new OAuth2AuthenticationException( + new OAuth2Error("feishu_userinfo_error", "Feishu user info missing " + userNameAttributeName, null) + ); + } + return attributes; + } + + private void putIfPresent(Map attributes, String key, String value) { + if (value != null && !value.isBlank()) { + attributes.put(key, value); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + record FeishuUserResponse(int code, String msg, @JsonProperty("data") FeishuUserData data) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + record FeishuUserData( + @JsonProperty("open_id") String openId, + @JsonProperty("union_id") String unionId, + @JsonProperty("name") String name, + @JsonProperty("en_name") String enName, + @JsonProperty("avatar_url") String avatarUrl, + @JsonProperty("email") String email, + @JsonProperty("enterprise_email") String enterpriseEmail, + @JsonProperty("mobile") String mobile + ) {} +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java index e5c7dc3de..43985f4f9 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java @@ -16,6 +16,7 @@ import org.springframework.security.core.AuthenticationException; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.user.OAuth2User; @@ -29,23 +30,31 @@ @Service public class OAuthLoginFlowService { - private final DefaultOAuth2UserService delegate = new DefaultOAuth2UserService(); + private final OAuth2UserService defaultUserService = new DefaultOAuth2UserService(); private final Map extractors; + private final Map userServiceOverrides; private final AccessPolicy accessPolicy; private final IdentityBindingService identityBindingService; public OAuthLoginFlowService(List extractorList, + List userServiceList, AccessPolicy accessPolicy, IdentityBindingService identityBindingService) { this.extractors = extractorList.stream() .collect(Collectors.toMap(OAuthClaimsExtractor::getProvider, Function.identity())); + this.userServiceOverrides = userServiceList.stream() + .collect(Collectors.toMap(ProviderOAuth2UserService::getProvider, Function.identity())); this.accessPolicy = accessPolicy; this.identityBindingService = identityBindingService; } public AuthenticatedLoginContext loadLoginContext(OAuth2UserRequest request) { - OAuth2User upstreamUser = delegate.loadUser(request); String registrationId = request.getClientRegistration().getRegistrationId(); + OAuth2UserService userService = userServiceOverrides.get(registrationId); + if (userService == null) { + userService = defaultUserService; + } + OAuth2User upstreamUser = userService.loadUser(request); OAuthClaimsExtractor extractor = extractors.get(registrationId); if (extractor == null) { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderOAuth2UserService.java new file mode 100644 index 000000000..bf587e812 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderOAuth2UserService.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.auth.oauth; + +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.user.OAuth2User; + +/** + * Strategy interface for provider-specific OAuth user loading. Implementations override the + * default user info loading for providers whose endpoints deviate from the standard + * flat-attribute response format. + */ +public interface ProviderOAuth2UserService extends OAuth2UserService { + 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 c72b1d9d6..acccb3d15 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 @@ -5,6 +5,7 @@ import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; /** * OAuth2 authorization request resolver that preserves a sanitized post-login redirect target in @@ -14,6 +15,9 @@ public class SkillHubOAuth2AuthorizationRequestResolver implements org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver { + private static final String FEISHU_REGISTRATION_ID = "feishu"; + private static final String AUTHORIZATION_BASE_PATH = "/oauth2/authorization/"; + private final DefaultOAuth2AuthorizationRequestResolver delegate; private final OAuthLoginFlowService oauthLoginFlowService; @@ -30,13 +34,51 @@ public SkillHubOAuth2AuthorizationRequestResolver(ClientRegistrationRepository c public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request); oauthLoginFlowService.rememberReturnTo(request); - return authorizationRequest; + return customizeFeishu(authorizationRequest, registrationIdFrom(request)); } @Override public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) { OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request, clientRegistrationId); oauthLoginFlowService.rememberReturnTo(request); - return authorizationRequest; + return customizeFeishu(authorizationRequest, clientRegistrationId); + } + + private String registrationIdFrom(HttpServletRequest request) { + String uri = request.getRequestURI(); + int index = uri.indexOf(AUTHORIZATION_BASE_PATH); + if (index < 0) { + return null; + } + return uri.substring(index + AUTHORIZATION_BASE_PATH.length()); + } + + /** + * Feishu's authorize endpoint identifies the client with {@code app_id} rather than + * {@code client_id}, and scopes are controlled by the app's permission configuration rather + * than a {@code scope} request parameter. + */ + private OAuth2AuthorizationRequest customizeFeishu(OAuth2AuthorizationRequest authorizationRequest, + String registrationId) { + if (authorizationRequest == null || !FEISHU_REGISTRATION_ID.equals(registrationId)) { + return authorizationRequest; + } + String authorizationUri = UriComponentsBuilder + .fromUriString(authorizationRequest.getAuthorizationUri()) + .queryParam("app_id", authorizationRequest.getClientId()) + .queryParam("redirect_uri", authorizationRequest.getRedirectUri()) + .queryParam("response_type", "code") + .queryParam("state", authorizationRequest.getState()) + .build() + .toUriString(); + return OAuth2AuthorizationRequest.authorizationCode() + .authorizationUri(authorizationRequest.getAuthorizationUri()) + .clientId(authorizationRequest.getClientId()) + .redirectUri(authorizationRequest.getRedirectUri()) + .scopes(authorizationRequest.getScopes()) + .state(authorizationRequest.getState()) + .attributes(attributes -> attributes.putAll(authorizationRequest.getAttributes())) + .authorizationRequestUri(authorizationUri) + .build(); } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractorTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractorTest.java new file mode 100644 index 000000000..36f9f993a --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractorTest.java @@ -0,0 +1,87 @@ +package com.iflytek.skillhub.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; + +class FeishuClaimsExtractorTest { + + private final FeishuClaimsExtractor extractor = new FeishuClaimsExtractor(); + + @Test + void extract_prefersEnterpriseEmailOverPersonalEmail() { + Map attrs = new HashMap<>(Map.of( + "open_id", "ou_123", + "name", "张三", + "email", "zhangsan@personal.example", + "enterprise_email", "zhangsan@corp.example" + )); + + OAuthClaims claims = extractor.extract(userRequest(), user(attrs)); + + assertThat(claims.provider()).isEqualTo("feishu"); + assertThat(claims.subject()).isEqualTo("ou_123"); + assertThat(claims.email()).isEqualTo("zhangsan@corp.example"); + assertThat(claims.emailVerified()).isTrue(); + assertThat(claims.providerLogin()).isEqualTo("张三"); + } + + @Test + void extract_allowsNullEmailAndFallsBackUsername() { + Map attrs = new HashMap<>(Map.of("open_id", "ou_456")); + + OAuthClaims claims = extractor.extract(userRequest(), user(attrs)); + + assertThat(claims.subject()).isEqualTo("ou_456"); + assertThat(claims.email()).isNull(); + assertThat(claims.emailVerified()).isFalse(); + assertThat(claims.providerLogin()).isEqualTo("feishu-ou_456"); + } + + @Test + void extract_fallsBackToEnglishNameWhenChineseNameBlank() { + Map attrs = new HashMap<>(Map.of( + "open_id", "ou_789", + "en_name", "Alice" + )); + + OAuthClaims claims = extractor.extract(userRequest(), user(attrs)); + + assertThat(claims.providerLogin()).isEqualTo("Alice"); + } + + private DefaultOAuth2User user(Map attrs) { + return new DefaultOAuth2User(java.util.List.of(), attrs, "open_id"); + } + + private OAuth2UserRequest userRequest() { + ClientRegistration registration = ClientRegistration.withRegistrationId("feishu") + .clientId("cli_test123") + .clientSecret("client-secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .authorizationUri("https://open.feishu.cn/open-apis/authen/v1/authorize") + .tokenUri("https://open.feishu.cn/open-apis/authen/v2/oauth/token") + .userInfoUri("https://open.feishu.cn/open-apis/authen/v1/user_info") + .userNameAttributeName("open_id") + .clientName("飞书") + .build(); + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + "token-123", + Instant.now(), + Instant.now().plusSeconds(3600) + ); + return new OAuth2UserRequest(registration, accessToken); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserServiceTest.java new file mode 100644 index 000000000..4151d1981 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserServiceTest.java @@ -0,0 +1,105 @@ +package com.iflytek.skillhub.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +class FeishuOAuth2UserServiceTest { + + @Test + void loadUser_unwrapsFeishuEnvelopeIntoFlatAttributes() { + RestClient.Builder restClientBuilder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build(); + server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v1/user_info")) + .andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer token-123")) + .andRespond(withSuccess( + """ + { + "code": 0, + "msg": "success", + "data": { + "open_id": "ou_123", + "union_id": "on_456", + "name": "张三", + "avatar_url": "https://avatar.example/zhangsan.png", + "enterprise_email": "zhangsan@corp.example", + "email": "zhangsan@personal.example" + } + } + """, + MediaType.APPLICATION_JSON + )); + FeishuOAuth2UserService service = new FeishuOAuth2UserService(restClientBuilder); + + OAuth2User user = service.loadUser(userRequest()); + + assertThat(user.getName()).isEqualTo("ou_123"); + assertThat(user.getAttributes()) + .containsEntry("open_id", "ou_123") + .containsEntry("union_id", "on_456") + .containsEntry("name", "张三") + .containsEntry("avatar_url", "https://avatar.example/zhangsan.png") + .containsEntry("enterprise_email", "zhangsan@corp.example") + .doesNotContainKey("code") + .doesNotContainKey("data"); + server.verify(); + } + + @Test + void loadUser_throwsWhenFeishuReportsErrorCode() { + RestClient.Builder restClientBuilder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build(); + server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v1/user_info")) + .andRespond(withSuccess( + """ + {"code": 99991663, "msg": "invalid access token"} + """, + MediaType.APPLICATION_JSON + )); + FeishuOAuth2UserService service = new FeishuOAuth2UserService(restClientBuilder); + + assertThatThrownBy(() -> service.loadUser(userRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()) + .isEqualTo("feishu_userinfo_error")); + server.verify(); + } + + private OAuth2UserRequest userRequest() { + ClientRegistration registration = ClientRegistration.withRegistrationId("feishu") + .clientId("cli_test123") + .clientSecret("client-secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .authorizationUri("https://open.feishu.cn/open-apis/authen/v1/authorize") + .tokenUri("https://open.feishu.cn/open-apis/authen/v2/oauth/token") + .userInfoUri("https://open.feishu.cn/open-apis/authen/v1/user_info") + .userNameAttributeName("open_id") + .clientName("飞书") + .build(); + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + "token-123", + Instant.now(), + Instant.now().plusSeconds(3600) + ); + return new OAuth2UserRequest(registration, accessToken); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java index 357ada331..d8887a4af 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java @@ -30,13 +30,26 @@ void setUp() { .scope("read:user") .clientName("GitHub") .build(); + ClientRegistration feishu = ClientRegistration.withRegistrationId("feishu") + .clientId("cli_test123") + .clientSecret("secret") + .authorizationUri("https://open.feishu.cn/open-apis/authen/v1/authorize") + .tokenUri("https://open.feishu.cn/open-apis/authen/v2/oauth/token") + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .userInfoUri("https://open.feishu.cn/open-apis/authen/v1/user_info") + .userNameAttributeName("open_id") + .authorizationGrantType(org.springframework.security.oauth2.core.AuthorizationGrantType.AUTHORIZATION_CODE) + .clientAuthenticationMethod(org.springframework.security.oauth2.core.ClientAuthenticationMethod.CLIENT_SECRET_POST) + .clientName("飞书") + .build(); OAuthLoginFlowService oauthLoginFlowService = new OAuthLoginFlowService( + java.util.List.of(), java.util.List.of(), mock(AccessPolicy.class), mock(IdentityBindingService.class) ); resolver = new SkillHubOAuth2AuthorizationRequestResolver( - new InMemoryClientRegistrationRepository(github), + new InMemoryClientRegistrationRepository(github, feishu), oauthLoginFlowService ); } @@ -65,4 +78,32 @@ void resolve_ignoresUnsafeReturnTo() { assertThat(session).isNotNull(); assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull(); } + + @Test + void resolve_feishu_usesAppIdInsteadOfClientId() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/feishu"); + + var authorizationRequest = resolver.resolve(request, "feishu"); + + assertThat(authorizationRequest).isNotNull(); + String uri = authorizationRequest.getAuthorizationRequestUri(); + assertThat(uri).contains("app_id=cli_test123"); + assertThat(uri).contains("response_type=code"); + assertThat(uri).contains("state="); + assertThat(uri).doesNotContain("client_id="); + assertThat(uri).doesNotContain("scope="); + } + + @Test + void resolve_github_keepsStandardParameters() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/github"); + + var authorizationRequest = resolver.resolve(request, "github"); + + assertThat(authorizationRequest).isNotNull(); + String uri = authorizationRequest.getAuthorizationRequestUri(); + assertThat(uri).contains("client_id=client"); + assertThat(uri).contains("scope=read:user"); + assertThat(uri).doesNotContain("app_id="); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java index 029ec2944..f42064bad 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java @@ -17,6 +17,7 @@ class OAuthLoginFlowServiceTest { @Test void rememberReturnTo_stores_sanitized_return_target() { OAuthLoginFlowService service = new OAuthLoginFlowService( + List.of(), List.of(), mock(AccessPolicy.class), mock(IdentityBindingService.class) @@ -35,6 +36,7 @@ void rememberReturnTo_stores_sanitized_return_target() { @Test void resolveFailureRedirect_maps_access_denied_to_user_facing_page() { OAuthLoginFlowService service = new OAuthLoginFlowService( + List.of(), List.of(), mock(AccessPolicy.class), mock(IdentityBindingService.class) @@ -51,6 +53,7 @@ void resolveFailureRedirect_maps_access_denied_to_user_facing_page() { @Test void consumeReturnTo_clearsUnsafeSessionValue() { OAuthLoginFlowService service = new OAuthLoginFlowService( + List.of(), List.of(), mock(AccessPolicy.class), mock(IdentityBindingService.class) diff --git a/web/public/feishu-logo.svg b/web/public/feishu-logo.svg new file mode 100644 index 000000000..c5250f7d8 --- /dev/null +++ b/web/public/feishu-logo.svg @@ -0,0 +1,4 @@ + + + + From 93ef04a637cf94b727a72330f596c85c4f24058c Mon Sep 17 00:00:00 2001 From: yhd Date: Fri, 7 Aug 2026 14:32:36 +0800 Subject: [PATCH 2/2] fix(deploy): define SKILLHUB_TRUST_FORWARDED_PROTO in staging compose The nginx template references this variable and envsubst leaves undefined variables untouched, causing nginx to fail with 'unknown skillhub_trust_forwarded_proto variable' when the staging web container starts. --- docker-compose.staging.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index 48d1fd1ad..81a5bc6d8 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -71,6 +71,7 @@ services: SKILLHUB_API_UPSTREAM: http://server:8080 SKILLHUB_WEB_API_BASE_URL: "" SKILLHUB_PUBLIC_BASE_URL: "" + SKILLHUB_TRUST_FORWARDED_PROTO: "false" depends_on: server: condition: service_healthy