mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-07 08:26:00 +00:00
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.
Signed-off-by: yhd <yhd4711499@live.com>
This commit is contained in:
parent
d2403bb591
commit
b4388ee2a5
13 changed files with 537 additions and 8 deletions
|
|
@ -112,6 +112,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 <base-url>/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.
|
||||
|
|
|
|||
|
|
@ -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. 核心接口设计
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<String, Object> 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<FeishuUserResponse>() {});
|
||||
} 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<String, Object> attributes = flatten(response.data(), userNameAttributeName);
|
||||
return new DefaultOAuth2User(
|
||||
Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
|
||||
attributes,
|
||||
userNameAttributeName
|
||||
);
|
||||
}
|
||||
|
||||
private Map<String, Object> flatten(FeishuUserData data, String userNameAttributeName) {
|
||||
Map<String, Object> 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<String, Object> 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
|
||||
) {}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import java.util.stream.Collectors;
|
|||
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 @@ import org.springframework.stereotype.Service;
|
|||
@Service
|
||||
public class OAuthLoginFlowService {
|
||||
|
||||
private final DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();
|
||||
private final OAuth2UserService<OAuth2UserRequest, OAuth2User> defaultUserService = new DefaultOAuth2UserService();
|
||||
private final Map<String, OAuthClaimsExtractor> extractors;
|
||||
private final Map<String, ProviderOAuth2UserService> userServiceOverrides;
|
||||
private final AccessPolicy accessPolicy;
|
||||
private final IdentityBindingService identityBindingService;
|
||||
|
||||
public OAuthLoginFlowService(List<OAuthClaimsExtractor> extractorList,
|
||||
List<ProviderOAuth2UserService> 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<OAuth2UserRequest, OAuth2User> userService = userServiceOverrides.get(registrationId);
|
||||
if (userService == null) {
|
||||
userService = defaultUserService;
|
||||
}
|
||||
OAuth2User upstreamUser = userService.loadUser(request);
|
||||
|
||||
OAuthClaimsExtractor extractor = extractors.get(registrationId);
|
||||
if (extractor == null) {
|
||||
|
|
|
|||
|
|
@ -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<OAuth2UserRequest, OAuth2User> {
|
||||
String getProvider();
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ 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
|
||||
|
|
@ -14,6 +15,9 @@ import org.springframework.stereotype.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;
|
||||
|
||||
|
|
@ -30,13 +34,51 @@ public class SkillHubOAuth2AuthorizationRequestResolver
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -30,13 +30,26 @@ class OAuth2AuthorizationRequestResolverTest {
|
|||
.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 @@ class OAuth2AuthorizationRequestResolverTest {
|
|||
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=");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 @@ class OAuthLoginFlowServiceTest {
|
|||
@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 @@ class OAuthLoginFlowServiceTest {
|
|||
@Test
|
||||
void consumeReturnTo_clearsUnsafeSessionValue() {
|
||||
OAuthLoginFlowService service = new OAuthLoginFlowService(
|
||||
List.of(),
|
||||
List.of(),
|
||||
mock(AccessPolicy.class),
|
||||
mock(IdentityBindingService.class)
|
||||
|
|
|
|||
4
web/public/feishu-logo.svg
Normal file
4
web/public/feishu-logo.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none">
|
||||
<path fill="#3370FF" d="M21.2 7.9c-1.6-2.6-4.7-3.9-7.7-3.2L4.6 6.7c-.5.1-.6.7-.2 1l3.5 2.7-3 1.7c-.4.2-.4.8 0 1l4.4 2.4c.7.4 1.5.5 2.3.3l7.6-1.7c.8-.2 1.4-.8 1.6-1.6.2-1 .1-2.1.4-4.6z"/>
|
||||
<path fill="#7EA6FF" d="M13 13.2l8 1.4c-.5 2.6-2.6 4.6-5.2 4.9l-5 .5c-.4 0-.6-.5-.3-.8l2.5-6z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 397 B |
Loading…
Add table
Reference in a new issue