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
11 changes: 11 additions & 0 deletions .env.release.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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.
Expand Down
1 change: 1 addition & 0 deletions docker-compose.staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 17 additions & 3 deletions docs/03-authentication-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 核心接口设计

Expand Down
14 changes: 14 additions & 0 deletions server/skillhub-app/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
);
}
}
Original file line number Diff line number Diff line change
@@ -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
) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,23 +30,31 @@
@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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;

Expand All @@ -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();
}
}
Loading