mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-10 22:41:02 +00:00
fix(auth): align Feishu OAuth with current official protocol docs
Addresses review on PR #696: 1. The authorize step now uses the currently documented endpoint https://accounts.feishu.cn/open-apis/authen/v1/authorize with standard OAuth2 parameters (client_id, optional scope), so the legacy app_id/no-scope URI rewrite in SkillHubOAuth2AuthorizationRequestResolver is removed. The authorize host is overridable via OAUTH2_FEISHU_AUTHORIZE_URI; token and userinfo endpoints stay on OAUTH2_FEISHU_BASE_URI. 2. FeishuClaimsExtractor no longer claims emailVerified: Feishu emails are admin-imported and carry no real-time verification signal, so emailVerified is always false. EMAIL_DOMAIN policy matches the email domain only and is unaffected. Signed-off-by: yhd <yhd4711499@live.com>
This commit is contained in:
parent
7164822485
commit
e192b94d19
7 changed files with 24 additions and 55 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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. 核心接口设计
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("张三");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue