diff --git a/.env.release.example b/.env.release.example index 098c3b03..902b63b2 100644 --- a/.env.release.example +++ b/.env.release.example @@ -121,6 +121,8 @@ OAUTH2_GITLAB_DISPLAY_NAME=GitLab OAUTH2_FEISHU_CLIENT_ID= OAUTH2_FEISHU_CLIENT_SECRET= OAUTH2_FEISHU_BASE_URI=https://open.feishu.cn +# Host of the OAuth authorize (consent) page; override for Lark/international deployments. +OAUTH2_FEISHU_AUTHORIZE_URI=https://accounts.feishu.cn OAUTH2_FEISHU_DISPLAY_NAME=飞书 # Optional: OIDC login (e.g. Keycloak, Okta, Azure AD). diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index 9c43c43a..303358b6 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -287,8 +287,11 @@ Spring Security OAuth2 Client 原生支持多 Provider 并存,新增 Provider 飞书 OAuth 与标准 OAuth2 存在偏差,接入时做了以下定制,可作为后续非标准 Provider 的参考: -1. **授权端点参数**:飞书要求 `app_id` 而非 `client_id`,且不接受 `scope` 参数(权限在开放平台应用内配置)。 - `SkillHubOAuth2AuthorizationRequestResolver` 对 `feishu` registration 重建授权 URI。 +1. **授权端点**:使用官方当前文档的标准 OAuth2 授权端点 + `https://accounts.feishu.cn/open-apis/authen/v1/authorize`(`client_id` + 可选 `scope`, + 权限在开放平台应用内配置),授权请求由 Spring Security 默认 resolver 构建, + host 可用 `OAUTH2_FEISHU_AUTHORIZE_URI` 覆盖;token / userinfo 端点仍在 `open.feishu.cn` + (`OAUTH2_FEISHU_BASE_URI` 覆盖)。 2. **userinfo 响应包裹**:响应为 `{code, msg, data}` 结构且错误以 HTTP 200 返回。 通过 `ProviderOAuth2UserService` 扩展点实现 `FeishuOAuth2UserService`,覆盖默认的 user info 加载并解包 `data`; `OAuthLoginFlowService` 按 registrationId 选择 loader,其余 Provider 仍走 `DefaultOAuth2UserService`。 @@ -296,6 +299,8 @@ Spring Security OAuth2 Client 原生支持多 Provider 并存,新增 Provider 4. **subject 选择**:绑定主体使用 `open_id`(应用内唯一);`union_id` 保留在 extra 中, 未来若同一部署接入多个飞书应用可基于它做身份归并。 5. **准入策略注意**:邮箱域名策略(EMAIL_DOMAIN)模式下,未绑定邮箱的飞书用户会被拒绝。 +6. **email_verified 语义**:飞书 user-info 返回的邮箱由组织管理员导入,无实时验证信号, + `FeishuClaimsExtractor` 恒置 `emailVerified=false`;EMAIL_DOMAIN 策略仅匹配邮箱域名,不依赖该标志。 ## 4. 核心接口设计 diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index ac5c1da4..e6138d07 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -87,7 +87,7 @@ spring: 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 + authorization-uri: ${OAUTH2_FEISHU_AUTHORIZE_URI:https://accounts.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 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 index 73ba7b3b..25a6ecc8 100644 --- 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 @@ -33,7 +33,9 @@ public class FeishuClaimsExtractor implements OAuthClaimsExtractor { if (email == null) { email = (String) attrs.get("email"); } - boolean emailVerified = email != null; + // Feishu emails are imported by the organization admin and not verified with the user + // in real time, so they carry no verification signal; keep emailVerified false. + boolean emailVerified = false; String username = (String) attrs.get("name"); if (username == null || username.isBlank()) { 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 acccb3d1..311de757 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,19 +5,16 @@ import org.springframework.security.oauth2.client.registration.ClientRegistratio 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 - * the HTTP session. + * the HTTP session. Authorization URIs are taken verbatim from the client registration; Feishu's + * current authorize endpoint accepts standard OAuth2 parameters (client_id, optional scope). */ @Component 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; @@ -34,51 +31,13 @@ public class SkillHubOAuth2AuthorizationRequestResolver public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request); oauthLoginFlowService.rememberReturnTo(request); - return customizeFeishu(authorizationRequest, registrationIdFrom(request)); + return authorizationRequest; } @Override public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) { OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request, clientRegistrationId); oauthLoginFlowService.rememberReturnTo(request); - 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(); + return authorizationRequest; } } 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 index 36f9f993..53adad50 100644 --- 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 @@ -31,7 +31,8 @@ class FeishuClaimsExtractorTest { assertThat(claims.provider()).isEqualTo("feishu"); assertThat(claims.subject()).isEqualTo("ou_123"); assertThat(claims.email()).isEqualTo("zhangsan@corp.example"); - assertThat(claims.emailVerified()).isTrue(); + // Feishu emails are admin-imported; the extractor must not claim verification. + assertThat(claims.emailVerified()).isFalse(); assertThat(claims.providerLogin()).isEqualTo("张三"); } 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 d8887a4a..61ebe9dc 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 @@ -33,7 +33,7 @@ class OAuth2AuthorizationRequestResolverTest { ClientRegistration feishu = ClientRegistration.withRegistrationId("feishu") .clientId("cli_test123") .clientSecret("secret") - .authorizationUri("https://open.feishu.cn/open-apis/authen/v1/authorize") + .authorizationUri("https://accounts.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") @@ -80,18 +80,18 @@ class OAuth2AuthorizationRequestResolverTest { } @Test - void resolve_feishu_usesAppIdInsteadOfClientId() { + void resolve_feishu_usesStandardOAuth2Parameters() { 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).startsWith("https://accounts.feishu.cn/open-apis/authen/v1/authorize"); + assertThat(uri).contains("client_id=cli_test123"); assertThat(uri).contains("response_type=code"); assertThat(uri).contains("state="); - assertThat(uri).doesNotContain("client_id="); - assertThat(uri).doesNotContain("scope="); + assertThat(uri).doesNotContain("app_id="); } @Test