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) {