From b4388ee2a58151ac228e66a5fad47e829fae5533 Mon Sep 17 00:00:00 2001
From: yhd
Date: Fri, 7 Aug 2026 14:19:04 +0800
Subject: [PATCH 1/7] 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
---
.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 a77518823..098c3b03f 100644
--- a/.env.release.example
+++ b/.env.release.example
@@ -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 /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 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 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.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();
}
}
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 @@ 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=");
+ }
}
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 @@ 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)
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 cc9d73718256afabefd2a9119e4524d37614d0ea Mon Sep 17 00:00:00 2001
From: yhd
Date: Fri, 7 Aug 2026 14:32:36 +0800
Subject: [PATCH 2/7] 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.
Signed-off-by: yhd
---
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
From 7164822485f31d411118ce06a57e423ef81de0f9 Mon Sep 17 00:00:00 2001
From: yhd
Date: Tue, 11 Aug 2026 16:27:53 +0800
Subject: [PATCH 3/7] fix(web): use official Feishu logo on login button
Replace the hand-drawn approximation with the official Feishu/Lark
tri-color brand mark (teal/blue/navy), matching the feishu.cn logo.
Source: homarr-labs/dashboard-icons.
Signed-off-by: yhd
---
web/public/feishu-logo.svg | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/web/public/feishu-logo.svg b/web/public/feishu-logo.svg
index c5250f7d8..0cb86de7d 100644
--- a/web/public/feishu-logo.svg
+++ b/web/public/feishu-logo.svg
@@ -1,4 +1,2 @@
-
+
+
\ No newline at end of file
From e192b94d195f1e8ca9e52bee5ea12db550002019 Mon Sep 17 00:00:00 2001
From: yhd
Date: Tue, 11 Aug 2026 20:26:51 +0800
Subject: [PATCH 4/7] 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
---
.env.release.example | 2 +
docs/03-authentication-design.md | 9 +++-
.../src/main/resources/application.yml | 2 +-
.../auth/oauth/FeishuClaimsExtractor.java | 4 +-
...HubOAuth2AuthorizationRequestResolver.java | 49 ++-----------------
.../auth/oauth/FeishuClaimsExtractorTest.java | 3 +-
...Auth2AuthorizationRequestResolverTest.java | 10 ++--
7 files changed, 24 insertions(+), 55 deletions(-)
diff --git a/.env.release.example b/.env.release.example
index 098c3b03f..902b63b2b 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 9c43c43aa..303358b6a 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 ac5c1da46..e6138d070 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 73ba7b3b5..25a6ecc82 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 acccb3d15..311de7573 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 36f9f993a..53adad50c 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 d8887a4af..61ebe9dc2 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
From 94baeb5544e0913e40d3e8a22692321288bcfeba Mon Sep 17 00:00:00 2001
From: yhd
Date: Fri, 14 Aug 2026 22:13:01 +0800
Subject: [PATCH 5/7] fix(auth): force https scheme for requests matching the
public base URL
Higress terminates TLS and forwards plain HTTP without a usable
X-Forwarded-Proto, so {baseUrl} expansion produced http:// OAuth2
redirect URIs and Secure session cookies were dropped. Rewrite the
scheme to https when the request host matches skillhub.public.base-url.
Signed-off-by: yhd
---
.../filter/PublicBaseUrlSchemeFilter.java | 88 +++++++++++++++++++
1 file changed, 88 insertions(+)
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/PublicBaseUrlSchemeFilter.java
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/PublicBaseUrlSchemeFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/PublicBaseUrlSchemeFilter.java
new file mode 100644
index 000000000..530680dfd
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/PublicBaseUrlSchemeFilter.java
@@ -0,0 +1,88 @@
+package com.iflytek.skillhub.filter;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletRequestWrapper;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.net.URI;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+/**
+ * Some TLS-terminating gateways (e.g. Higress) forward requests over plain HTTP without a
+ * usable X-Forwarded-Proto, so the container reports scheme http. When the public base URL is
+ * https, force the scheme back to https for requests matching the public host; otherwise
+ * {baseUrl} expansion (OAuth2 redirect URIs) and Secure session cookies break.
+ */
+@Component
+@Order(Ordered.HIGHEST_PRECEDENCE + 10)
+public class PublicBaseUrlSchemeFilter extends OncePerRequestFilter {
+
+ private final String publicHost;
+
+ public PublicBaseUrlSchemeFilter(@Value("${skillhub.public.base-url:}") String publicBaseUrl) {
+ this.publicHost = resolveHttpsHost(publicBaseUrl);
+ }
+
+ private static String resolveHttpsHost(String publicBaseUrl) {
+ if (publicBaseUrl == null || publicBaseUrl.isBlank()) {
+ return null;
+ }
+ URI uri = URI.create(publicBaseUrl.trim());
+ if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null) {
+ return null;
+ }
+ return uri.getHost().toLowerCase();
+ }
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ throws ServletException, IOException {
+ if (publicHost == null
+ || !"http".equals(request.getScheme())
+ || !publicHost.equals(request.getServerName().toLowerCase())) {
+ filterChain.doFilter(request, response);
+ return;
+ }
+ filterChain.doFilter(new HttpsSchemeRequest(request), response);
+ }
+
+ private static final class HttpsSchemeRequest extends HttpServletRequestWrapper {
+
+ private HttpsSchemeRequest(HttpServletRequest request) {
+ super(request);
+ }
+
+ @Override
+ public String getScheme() {
+ return "https";
+ }
+
+ @Override
+ public boolean isSecure() {
+ return true;
+ }
+
+ @Override
+ public int getServerPort() {
+ int port = super.getServerPort();
+ return port == 80 ? 443 : port;
+ }
+
+ @Override
+ public StringBuffer getRequestURL() {
+ HttpServletRequest request = (HttpServletRequest) getRequest();
+ StringBuffer url = new StringBuffer("https://").append(request.getServerName());
+ String uri = request.getRequestURI();
+ if (uri != null) {
+ url.append(uri);
+ }
+ return url;
+ }
+ }
+}
From 1049a267fbd3bfdbdddfc3bda539499ade409f60 Mon Sep 17 00:00:00 2001
From: yhd
Date: Fri, 14 Aug 2026 22:13:13 +0800
Subject: [PATCH 6/7] feat(auth): add local registration toggle and OAuth
failure logging
Deployments that only allow third-party sign-up can now disable local
registration end to end: the register endpoint returns 403 when
skillhub.auth.local.registration-enabled is false, and the web UI hides
the entry when SKILLHUB_WEB_REGISTRATION_ENABLED is false. Also log
OAuth2 login failures so callback errors are diagnosable from server
logs. Fixes the runtime-config entrypoint so registration defaults are
actually exported to envsubst.
Signed-off-by: yhd
---
.../controller/LocalAuthController.java | 10 +++++++-
.../src/main/resources/messages.properties | 1 +
.../src/main/resources/messages_zh.properties | 1 +
.../auth/oauth/OAuth2LoginFailureHandler.java | 5 ++++
web/docker-entrypoint.d/30-runtime-config.sh | 6 ++++-
web/runtime-config.js.template | 3 ++-
web/src/api/client.ts | 9 +++++++
web/src/pages/login.test.tsx | 1 +
web/src/pages/login.tsx | 24 +++++++++++--------
web/src/pages/register.tsx | 8 +++++--
10 files changed, 53 insertions(+), 15 deletions(-)
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java
index 17e54fbee..fdf31f2b1 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java
@@ -13,6 +13,7 @@ import com.iflytek.skillhub.dto.LocalLoginRequest;
import com.iflytek.skillhub.dto.LocalRegisterRequest;
import com.iflytek.skillhub.dto.PasswordResetConfirmRequest;
import com.iflytek.skillhub.dto.PasswordResetRequestDto;
+import com.iflytek.skillhub.exception.ForbiddenException;
import com.iflytek.skillhub.exception.UnauthorizedException;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
import com.iflytek.skillhub.ratelimit.RateLimit;
@@ -20,6 +21,7 @@ import com.iflytek.skillhub.security.AuthFailureThrottleService;
import com.iflytek.skillhub.service.AuthMeResponseAssembler;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
@@ -40,6 +42,7 @@ public class LocalAuthController extends BaseApiController {
private final AuthFailureThrottleService authFailureThrottleService;
private final PasswordResetService passwordResetService;
private final AuthMeResponseAssembler authMeResponseAssembler;
+ private final boolean registrationEnabled;
public LocalAuthController(ApiResponseFactory responseFactory,
LocalAuthService localAuthService,
@@ -47,7 +50,8 @@ public class LocalAuthController extends BaseApiController {
PlatformSessionService platformSessionService,
AuthFailureThrottleService authFailureThrottleService,
PasswordResetService passwordResetService,
- AuthMeResponseAssembler authMeResponseAssembler) {
+ AuthMeResponseAssembler authMeResponseAssembler,
+ @Value("${skillhub.auth.local.registration-enabled:true}") boolean registrationEnabled) {
super(responseFactory);
this.localAuthService = localAuthService;
this.skillHubMetrics = skillHubMetrics;
@@ -55,12 +59,16 @@ public class LocalAuthController extends BaseApiController {
this.authFailureThrottleService = authFailureThrottleService;
this.passwordResetService = passwordResetService;
this.authMeResponseAssembler = authMeResponseAssembler;
+ this.registrationEnabled = registrationEnabled;
}
@PostMapping("/register")
@RateLimit(category = "auth-register", authenticated = 10, anonymous = 5, windowSeconds = 300)
public ApiResponse register(@Valid @RequestBody LocalRegisterRequest request,
HttpServletRequest httpRequest) {
+ if (!registrationEnabled) {
+ throw new ForbiddenException("error.auth.local.registration.disabled");
+ }
PlatformPrincipal principal = localAuthService.register(request.username(), request.password(), request.email());
skillHubMetrics.incrementUserRegister();
platformSessionService.establishSession(principal, httpRequest);
diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties
index 8791e6be3..c919c863e 100644
--- a/server/skillhub-app/src/main/resources/messages.properties
+++ b/server/skillhub-app/src/main/resources/messages.properties
@@ -40,6 +40,7 @@ error.auth.local.accountDisabled=This account has been disabled
error.auth.local.accountPending=This account is pending activation
error.auth.local.accountMerged=This account has been merged and can no longer be used to log in
error.auth.local.locked=Too many failed attempts. Please try again in {0} minute(s)
+error.auth.local.registration.disabled=Local registration is disabled. Please sign in with an authorized third-party account.
error.auth.login.throttled=Too many login attempts. Please try again in {0} minute(s)
error.auth.direct.disabled=Direct authentication compatibility is disabled
error.auth.direct.providerUnsupported=Unsupported direct authentication provider: {0}
diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties
index 0e1b3fc33..544007638 100644
--- a/server/skillhub-app/src/main/resources/messages_zh.properties
+++ b/server/skillhub-app/src/main/resources/messages_zh.properties
@@ -40,6 +40,7 @@ error.auth.local.accountDisabled=该账号已被禁用
error.auth.local.accountPending=该账号尚未激活
error.auth.local.accountMerged=该账号已合并,不能再用于登录
error.auth.local.locked=连续失败次数过多,请在 {0} 分钟后重试
+error.auth.local.registration.disabled=本地注册已关闭,请使用授权的第三方账号登录
error.auth.login.throttled=登录尝试过于频繁,请在 {0} 分钟后重试
error.auth.direct.disabled=直连认证兼容层未启用
error.auth.direct.providerUnsupported=不支持的直连认证提供方:{0}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java
index 14beac753..66c589ad3 100644
--- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java
@@ -3,6 +3,8 @@ package com.iflytek.skillhub.auth.oauth;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
@@ -16,6 +18,8 @@ import java.io.IOException;
@Component
public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
+ private static final Logger log = LoggerFactory.getLogger(OAuth2LoginFailureHandler.class);
+
private final OAuthLoginFlowService oauthLoginFlowService;
public OAuth2LoginFailureHandler(OAuthLoginFlowService oauthLoginFlowService) {
@@ -26,6 +30,7 @@ public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHan
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception)
throws IOException, ServletException {
+ log.error("OAuth2 login failed [uri={}]", request.getRequestURI(), exception);
String returnTo = oauthLoginFlowService.consumeReturnTo(request.getSession(false));
String redirectTarget = oauthLoginFlowService.resolveFailureRedirect(exception, returnTo);
if (redirectTarget != null) {
diff --git a/web/docker-entrypoint.d/30-runtime-config.sh b/web/docker-entrypoint.d/30-runtime-config.sh
index a8bf88a45..ccbbf903b 100644
--- a/web/docker-entrypoint.d/30-runtime-config.sh
+++ b/web/docker-entrypoint.d/30-runtime-config.sh
@@ -15,9 +15,13 @@ set -eu
: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED:=false}"
: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER:=}"
: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO:=false}"
+# NB: `${VAR:=default}` only sets a shell variable, not an exported one, so
+# envsubst would still substitute an empty string. Assign and export explicitly.
+SKILLHUB_WEB_REGISTRATION_ENABLED="${SKILLHUB_WEB_REGISTRATION_ENABLED:-true}"
+export SKILLHUB_WEB_REGISTRATION_ENABLED
# Generate runtime-config.js
-envsubst '${SKILLHUB_WEB_API_BASE_URL} ${SKILLHUB_PUBLIC_BASE_URL} ${SKILLHUB_WEB_AUTH_DIRECT_ENABLED} ${SKILLHUB_WEB_AUTH_DIRECT_PROVIDER} ${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED} ${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER} ${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO}' \
+envsubst '${SKILLHUB_WEB_API_BASE_URL} ${SKILLHUB_PUBLIC_BASE_URL} ${SKILLHUB_WEB_AUTH_DIRECT_ENABLED} ${SKILLHUB_WEB_AUTH_DIRECT_PROVIDER} ${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED} ${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER} ${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO} ${SKILLHUB_WEB_REGISTRATION_ENABLED}' \
< /usr/share/nginx/html/runtime-config.js.template \
> /usr/share/nginx/html/runtime-config.js
diff --git a/web/runtime-config.js.template b/web/runtime-config.js.template
index 1375a3805..f7d45322a 100644
--- a/web/runtime-config.js.template
+++ b/web/runtime-config.js.template
@@ -5,5 +5,6 @@ window.__SKILLHUB_RUNTIME_CONFIG__ = {
authDirectProvider: "${SKILLHUB_WEB_AUTH_DIRECT_PROVIDER}",
authSessionBootstrapEnabled: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED}",
authSessionBootstrapProvider: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER}",
- authSessionBootstrapAuto: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO}"
+ authSessionBootstrapAuto: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO}",
+ registrationEnabled: "${SKILLHUB_WEB_REGISTRATION_ENABLED}"
};
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index 5e4c77567..1819423ef 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -69,6 +69,7 @@ type RuntimeConfig = {
authSessionBootstrapEnabled?: string
authSessionBootstrapProvider?: string
authSessionBootstrapAuto?: string
+ registrationEnabled?: string
}
declare global {
@@ -182,6 +183,14 @@ export function getSessionBootstrapRuntimeConfig(): SessionBootstrapRuntimeConfi
}
}
+export function isLocalRegistrationEnabled(): boolean {
+ const value = getRuntimeConfig().registrationEnabled
+ if (value === undefined || value.trim() === '') {
+ return true
+ }
+ return parseBooleanFlag(value)
+}
+
type ApiEnvelope = {
code: number
msg: string
diff --git a/web/src/pages/login.test.tsx b/web/src/pages/login.test.tsx
index dacfa4779..839144774 100644
--- a/web/src/pages/login.test.tsx
+++ b/web/src/pages/login.test.tsx
@@ -24,6 +24,7 @@ vi.mock('lucide-react', () => ({
vi.mock('@/api/client', () => ({
getDirectAuthRuntimeConfig: () => ({ enabled: false }),
+ isLocalRegistrationEnabled: () => true,
}))
vi.mock('@/features/auth/login-button', () => ({
diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx
index 1aab99c48..5e424a758 100644
--- a/web/src/pages/login.tsx
+++ b/web/src/pages/login.tsx
@@ -2,7 +2,7 @@ import { Link, useNavigate, useSearch } from '@tanstack/react-router'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Eye, EyeOff } from 'lucide-react'
-import { getDirectAuthRuntimeConfig } from '@/api/client'
+import { getDirectAuthRuntimeConfig, isLocalRegistrationEnabled } from '@/api/client'
import { LoginButton } from '@/features/auth/login-button'
import { SessionBootstrapEntry } from '@/features/auth/session-bootstrap-entry'
import { useAuthMethods } from '@/features/auth/use-auth-methods'
@@ -166,15 +166,19 @@ export function LoginPage() {
- {t('login.noAccount')}
- {' '}
-
- {t('login.register')}
-
+ {isLocalRegistrationEnabled() ? (
+ <>
+ {t('login.noAccount')}
+ {' '}
+
+ {t('login.register')}
+
+ >
+ ) : null}
diff --git a/web/src/pages/register.tsx b/web/src/pages/register.tsx
index a3a0aa6aa..7d2637314 100644
--- a/web/src/pages/register.tsx
+++ b/web/src/pages/register.tsx
@@ -1,7 +1,7 @@
-import { Link, useNavigate, useSearch } from '@tanstack/react-router'
+import { Link, Navigate, useNavigate, useSearch } from '@tanstack/react-router'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { ApiError } from '@/api/client'
+import { ApiError, isLocalRegistrationEnabled } from '@/api/client'
import { LoginButton } from '@/features/auth/login-button'
import { useLocalRegister } from '@/features/auth/use-local-auth'
import { Button } from '@/shared/ui/button'
@@ -63,6 +63,10 @@ export function RegisterPage() {
const returnTo = search.returnTo && search.returnTo.startsWith('/') ? search.returnTo : '/dashboard'
+ if (!isLocalRegistrationEnabled()) {
+ return
+ }
+
function validateUsername(value: string) {
const trimmed = value.trim()
if (!trimmed) {
From d3f1d5e65af8139a6021eca52a5d7a114156f3c3 Mon Sep 17 00:00:00 2001
From: yhd
Date: Tue, 8 Sep 2026 10:57:16 +0800
Subject: [PATCH 7/7] test(auth): align Feishu fixture authorize host with
production config
FeishuOAuth2UserServiceTest and FeishuClaimsExtractorTest still built
their ClientRegistration with authorizationUri on open.feishu.cn, while
the production registration points at accounts.feishu.cn. Align the
fixtures so they stay representative; token/user_info stay on
open.feishu.cn, matching application.yml.
Signed-off-by: yhd
---
.../iflytek/skillhub/auth/oauth/FeishuClaimsExtractorTest.java | 2 +-
.../skillhub/auth/oauth/FeishuOAuth2UserServiceTest.java | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
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 53adad50c..b996f63b7 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
@@ -71,7 +71,7 @@ class FeishuClaimsExtractorTest {
.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")
+ .authorizationUri("https://accounts.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")
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
index 4151d1981..b2b47eb6f 100644
--- 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
@@ -88,7 +88,7 @@ class FeishuOAuth2UserServiceTest {
.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")
+ .authorizationUri("https://accounts.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")