diff --git a/.env.release.example b/.env.release.example
index a77518823..902b63b2b 100644
--- a/.env.release.example
+++ b/.env.release.example
@@ -112,6 +112,19 @@ 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
+# 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).
# Replace "OIDC" in variable names with your registration id (uppercase).
# The registration id becomes identity_binding.provider_code — keep it stable.
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
diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md
index 2f4b77052..303358b6a 100644
--- a/docs/03-authentication-design.md
+++ b/docs/03-authentication-design.md
@@ -279,9 +279,28 @@ 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. **授权端点**:使用官方当前文档的标准 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`。
+3. **token 端点认证**:使用 `client_secret_post`(表单传 client_id/client_secret)。
+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/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.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.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 LocalAuthController(ApiResponseFactory responseFactory,
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 LocalAuthController(ApiResponseFactory responseFactory,
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/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;
+ }
+ }
+}
diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml
index 10e9638eb..e6138d070 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_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
servlet:
multipart:
max-file-size: 100MB
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/FeishuClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractor.java
new file mode 100644
index 000000000..25a6ecc82
--- /dev/null
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractor.java
@@ -0,0 +1,60 @@
+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");
+ }
+ // 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()) {
+ 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/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 @@
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 @@
@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 OAuth2LoginFailureHandler(OAuthLoginFlowService oauthLoginFlowService) {
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/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 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 @@
@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..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
@@ -8,7 +8,8 @@
/**
* 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
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..53adad50c
--- /dev/null
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractorTest.java
@@ -0,0 +1,88 @@
+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");
+ // Feishu emails are admin-imported; the extractor must not claim verification.
+ assertThat(claims.emailVerified()).isFalse();
+ 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..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
@@ -30,13 +30,26 @@ void setUp() {
.scope("read:user")
.clientName("GitHub")
.build();
+ ClientRegistration feishu = ClientRegistration.withRegistrationId("feishu")
+ .clientId("cli_test123")
+ .clientSecret("secret")
+ .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")
+ .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 @@ void resolve_ignoresUnsafeReturnTo() {
assertThat(session).isNotNull();
assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull();
}
+
+ @Test
+ 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).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("app_id=");
+ }
+
+ @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 @@ void rememberReturnTo_stores_sanitized_return_target() {
@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 @@ void resolveFailureRedirect_maps_access_denied_to_user_facing_page() {
@Test
void consumeReturnTo_clearsUnsafeSessionValue() {
OAuthLoginFlowService service = new OAuthLoginFlowService(
+ List.of(),
List.of(),
mock(AccessPolicy.class),
mock(IdentityBindingService.class)
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/public/feishu-logo.svg b/web/public/feishu-logo.svg
new file mode 100644
index 000000000..0cb86de7d
--- /dev/null
+++ b/web/public/feishu-logo.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
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) {