This commit is contained in:
袁浩东 2026-09-06 02:49:34 +08:00 committed by GitHub
commit 0d64ab15bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 644 additions and 22 deletions

View file

@ -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 <base-url>/login/oauth2/code/feishu to the app's
# redirect URLs (安全设置 -> 重定向 URL).
# Note: users without an email are denied when EMAIL_DOMAIN access policy is enabled;
# SUBJECT_WHITELIST entries must use the Feishu open_id (ou_...).
OAUTH2_FEISHU_CLIENT_ID=
OAUTH2_FEISHU_CLIENT_SECRET=
OAUTH2_FEISHU_BASE_URI=https://open.feishu.cn
# 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.

View file

@ -280,9 +280,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. 核心接口设计

View file

@ -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<AuthMeResponse> 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);

View file

@ -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;
}
}
}

View file

@ -70,6 +70,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:
api-base-url: ${OAUTH2_GITHUB_API_BASE_URL:https://api.github.com}
@ -79,6 +88,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

View file

@ -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}

View file

@ -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}

View file

@ -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<String, Object> attrs = oAuth2User.getAttributes();
// open_id is unique within the Feishu app; union_id is kept in extra for potential
// cross-app identity migration later.
String subject = String.valueOf(attrs.get("open_id"));
String email = (String) attrs.get("enterprise_email");
if (email == null) {
email = (String) attrs.get("email");
}
// 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
);
}
}

View file

@ -0,0 +1,127 @@
package com.iflytek.skillhub.auth.oauth;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
/**
* Loads Feishu (Lark) user info, which deviates from the standard OAuth format: the response is
* wrapped in a {@code {code, msg, data}} envelope and errors are reported with HTTP 200.
*/
@Component
public class FeishuOAuth2UserService implements ProviderOAuth2UserService {
static final String PROVIDER = "feishu";
private final RestClient restClient;
/**
* Uses an external-service client that is intentionally not customized with application
* tracing. Trace context must not be propagated to the external Feishu service.
*/
@Autowired
public FeishuOAuth2UserService() {
this(RestClient.builder());
}
public FeishuOAuth2UserService(RestClient.Builder restClientBuilder) {
this.restClient = restClientBuilder
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build();
}
@Override
public String getProvider() {
return PROVIDER;
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
String userInfoUri = userRequest.getClientRegistration().getProviderDetails()
.getUserInfoEndpoint().getUri();
FeishuUserResponse response;
try {
response = restClient.get()
.uri(userInfoUri)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userRequest.getAccessToken().getTokenValue())
.retrieve()
.body(new ParameterizedTypeReference<FeishuUserResponse>() {});
} catch (Exception e) {
throw new OAuth2AuthenticationException(
new OAuth2Error("feishu_userinfo_error", "Failed to load Feishu user info: " + e.getMessage(), null),
e
);
}
if (response == null || response.code() != 0 || response.data() == null) {
String msg = response != null ? response.msg() : "empty response";
throw new OAuth2AuthenticationException(
new OAuth2Error("feishu_userinfo_error", "Feishu user info error: " + msg, null)
);
}
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
.getUserInfoEndpoint().getUserNameAttributeName();
Map<String, Object> attributes = flatten(response.data(), userNameAttributeName);
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
attributes,
userNameAttributeName
);
}
private Map<String, Object> flatten(FeishuUserData data, String userNameAttributeName) {
Map<String, Object> attributes = new LinkedHashMap<>();
putIfPresent(attributes, "open_id", data.openId());
putIfPresent(attributes, "union_id", data.unionId());
putIfPresent(attributes, "name", data.name());
putIfPresent(attributes, "en_name", data.enName());
putIfPresent(attributes, "avatar_url", data.avatarUrl());
putIfPresent(attributes, "email", data.email());
putIfPresent(attributes, "enterprise_email", data.enterpriseEmail());
putIfPresent(attributes, "mobile", data.mobile());
if (!attributes.containsKey(userNameAttributeName)) {
throw new OAuth2AuthenticationException(
new OAuth2Error("feishu_userinfo_error", "Feishu user info missing " + userNameAttributeName, null)
);
}
return attributes;
}
private void putIfPresent(Map<String, Object> attributes, String key, String value) {
if (value != null && !value.isBlank()) {
attributes.put(key, value);
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
record FeishuUserResponse(int code, String msg, @JsonProperty("data") FeishuUserData data) {}
@JsonIgnoreProperties(ignoreUnknown = true)
record FeishuUserData(
@JsonProperty("open_id") String openId,
@JsonProperty("union_id") String unionId,
@JsonProperty("name") String name,
@JsonProperty("en_name") String enName,
@JsonProperty("avatar_url") String avatarUrl,
@JsonProperty("email") String email,
@JsonProperty("enterprise_email") String enterpriseEmail,
@JsonProperty("mobile") String mobile
) {}
}

View file

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

View file

@ -16,6 +16,7 @@ import java.util.stream.Collectors;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.user.OAuth2User;
@ -29,23 +30,31 @@ import org.springframework.stereotype.Service;
@Service
public class OAuthLoginFlowService {
private final DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();
private final OAuth2UserService<OAuth2UserRequest, OAuth2User> defaultUserService = new DefaultOAuth2UserService();
private final Map<String, OAuthClaimsExtractor> extractors;
private final Map<String, ProviderOAuth2UserService> userServiceOverrides;
private final AccessPolicy accessPolicy;
private final IdentityBindingService identityBindingService;
public OAuthLoginFlowService(List<OAuthClaimsExtractor> extractorList,
List<ProviderOAuth2UserService> userServiceList,
AccessPolicy accessPolicy,
IdentityBindingService identityBindingService) {
this.extractors = extractorList.stream()
.collect(Collectors.toMap(OAuthClaimsExtractor::getProvider, Function.identity()));
this.userServiceOverrides = userServiceList.stream()
.collect(Collectors.toMap(ProviderOAuth2UserService::getProvider, Function.identity()));
this.accessPolicy = accessPolicy;
this.identityBindingService = identityBindingService;
}
public AuthenticatedLoginContext loadLoginContext(OAuth2UserRequest request) {
OAuth2User upstreamUser = delegate.loadUser(request);
String registrationId = request.getClientRegistration().getRegistrationId();
OAuth2UserService<OAuth2UserRequest, OAuth2User> userService = userServiceOverrides.get(registrationId);
if (userService == null) {
userService = defaultUserService;
}
OAuth2User upstreamUser = userService.loadUser(request);
OAuthClaimsExtractor extractor = extractors.get(registrationId);
if (extractor == null) {

View file

@ -0,0 +1,14 @@
package com.iflytek.skillhub.auth.oauth;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.user.OAuth2User;
/**
* Strategy interface for provider-specific OAuth user loading. Implementations override the
* default user info loading for providers whose endpoints deviate from the standard
* flat-attribute response format.
*/
public interface ProviderOAuth2UserService extends OAuth2UserService<OAuth2UserRequest, OAuth2User> {
String getProvider();
}

View file

@ -8,7 +8,8 @@ import org.springframework.stereotype.Component;
/**
* 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

View file

@ -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<String, Object> attrs = new HashMap<>(Map.of(
"open_id", "ou_123",
"name", "张三",
"email", "zhangsan@personal.example",
"enterprise_email", "zhangsan@corp.example"
));
OAuthClaims claims = extractor.extract(userRequest(), user(attrs));
assertThat(claims.provider()).isEqualTo("feishu");
assertThat(claims.subject()).isEqualTo("ou_123");
assertThat(claims.email()).isEqualTo("zhangsan@corp.example");
// Feishu emails are admin-imported; the extractor must not claim verification.
assertThat(claims.emailVerified()).isFalse();
assertThat(claims.providerLogin()).isEqualTo("张三");
}
@Test
void extract_allowsNullEmailAndFallsBackUsername() {
Map<String, Object> attrs = new HashMap<>(Map.of("open_id", "ou_456"));
OAuthClaims claims = extractor.extract(userRequest(), user(attrs));
assertThat(claims.subject()).isEqualTo("ou_456");
assertThat(claims.email()).isNull();
assertThat(claims.emailVerified()).isFalse();
assertThat(claims.providerLogin()).isEqualTo("feishu-ou_456");
}
@Test
void extract_fallsBackToEnglishNameWhenChineseNameBlank() {
Map<String, Object> attrs = new HashMap<>(Map.of(
"open_id", "ou_789",
"en_name", "Alice"
));
OAuthClaims claims = extractor.extract(userRequest(), user(attrs));
assertThat(claims.providerLogin()).isEqualTo("Alice");
}
private DefaultOAuth2User user(Map<String, Object> attrs) {
return new DefaultOAuth2User(java.util.List.of(), attrs, "open_id");
}
private OAuth2UserRequest userRequest() {
ClientRegistration registration = ClientRegistration.withRegistrationId("feishu")
.clientId("cli_test123")
.clientSecret("client-secret")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.authorizationUri("https://open.feishu.cn/open-apis/authen/v1/authorize")
.tokenUri("https://open.feishu.cn/open-apis/authen/v2/oauth/token")
.userInfoUri("https://open.feishu.cn/open-apis/authen/v1/user_info")
.userNameAttributeName("open_id")
.clientName("飞书")
.build();
OAuth2AccessToken accessToken = new OAuth2AccessToken(
OAuth2AccessToken.TokenType.BEARER,
"token-123",
Instant.now(),
Instant.now().plusSeconds(3600)
);
return new OAuth2UserRequest(registration, accessToken);
}
}

View file

@ -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);
}
}

View file

@ -30,13 +30,26 @@ class OAuth2AuthorizationRequestResolverTest {
.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
);
}
@ -84,4 +97,32 @@ class OAuth2AuthorizationRequestResolverTest {
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=");
}
}

View file

@ -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)
@ -75,6 +77,7 @@ class OAuthLoginFlowServiceTest {
@Test
void consumeReturnTo_clearsUnsafeSessionValue() {
OAuthLoginFlowService service = new OAuthLoginFlowService(
List.of(),
List.of(),
mock(AccessPolicy.class),
mock(IdentityBindingService.class)

View file

@ -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

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Official Feishu/Lark logo, source: homarr-labs/dashboard-icons -->
<svg xmlns="http://www.w3.org/2000/svg" width="800px" height="800px" viewBox="62.16 94.5 407.87 324.19"><path d="M274.18 264.785q.515-.517 1.03-1.027c.685-.688 1.372-1.258 2.056-1.945l1.37-1.372 4.118-4.113 5.598-5.601 4.8-4.797 4.575-4.457 4.796-4.688 4.344-4.344 6.059-6.054c1.14-1.145 2.285-2.29 3.543-3.317 2.168-2.054 4.457-4 6.855-5.828 2.172-1.715 4.344-3.312 6.516-4.914 3.082-2.172 6.398-4.344 9.71-6.285 3.204-1.941 6.63-3.656 10.06-5.371 3.199-1.602 6.515-2.973 9.827-4.23 1.829-.684 3.774-1.372 5.602-2.055.914-.344 1.941-.688 2.856-.914-8.57-33.715-24.227-64.575-45.258-90.86-4.114-5.14-10.399-8.113-17.028-8.113H130.754c-3.203 0-4.457 4-1.945 5.941 59.543 43.66 109.144 99.887 145.03 164.801 0-.226.227-.34.34-.457m0 0" style="stroke:none;fill-rule:nonzero;fill:#00d6b9;fill-opacity:1"/><path d="M204.79 418.691c90.288 0 169.03-49.828 210.058-123.543 1.488-2.628 2.859-5.257 4.23-7.882q-3.087 6-6.86 11.312l-2.741 3.77c-1.141 1.488-2.399 2.972-3.657 4.457-1.03 1.144-2.058 2.285-3.086 3.316-2.058 2.172-4.343 4.227-6.629 6.172a53 53 0 0 1-3.886 3.2c-1.598 1.144-3.086 2.284-4.684 3.429-1.031.683-2.058 1.371-3.086 1.941-1.144.684-2.172 1.258-3.316 1.942a131 131 0 0 1-6.969 3.543c-2.059.918-4.117 1.828-6.289 2.515-2.285.801-4.57 1.602-6.969 2.285-3.543.914-7.086 1.715-10.742 2.286-2.629.457-5.258.687-8 .914-2.86.23-5.601.23-8.457.23-3.086 0-6.289-.23-9.488-.57a83 83 0 0 1-7.086-1.031c-2.055-.34-4.113-.801-6.168-1.258-1.031-.227-2.176-.57-3.203-.797-2.973-.8-6.055-1.602-9.028-2.516-1.488-.457-2.972-.914-4.457-1.258-2.172-.683-4.457-1.37-6.629-2.058-1.828-.57-3.656-1.14-5.37-1.711q-2.573-.86-5.145-1.715c-1.14-.344-2.285-.8-3.543-1.144-1.371-.457-2.856-1.028-4.227-1.485-1.027-.344-2.058-.687-2.972-1.027-1.942-.688-4-1.488-5.942-2.172-1.144-.457-2.285-.914-3.43-1.258-1.484-.57-3.085-1.144-4.57-1.828-1.601-.687-3.203-1.258-4.8-1.945-1.028-.457-2.06-.797-3.087-1.258-1.257-.57-2.628-1.027-3.886-1.598-1.028-.457-1.942-.8-2.969-1.258l-3.086-1.37c-.914-.344-1.832-.801-2.746-1.145a44 44 0 0 1-2.512-1.14c-.8-.345-1.715-.802-2.515-1.145-.914-.344-1.715-.801-2.512-1.141-1.031-.457-2.172-1.031-3.203-1.484-1.14-.575-2.285-1.032-3.426-1.602-1.258-.574-2.402-1.144-3.66-1.715-1.027-.457-2.055-1.027-3.082-1.484-54.172-26.973-102.172-63.086-143.09-106.746-2.055-2.172-5.71-.684-5.71 2.289l.112 154.398v12.57c0 7.317 3.543 14.06 9.598 18.172 38.172 24.801 83.773 39.543 132.914 39.543m0 0" style="stroke:none;fill-rule:nonzero;fill:#3370ff;fill-opacity:1"/><path d="M414.84 295.188c0 .113-.113.113-.113.226zl.8-1.489c-.343.457-.574 1.028-.8 1.488m3.793-7.05.226-.457.114-.23q-.17.513-.34.687m0 0" style="stroke:none;fill-rule:nonzero;fill:#133c9a;fill-opacity:1"/><path d="M470.035 201.121c-18.285-9.031-38.86-14.059-60.687-14.059-12.914 0-25.485 1.829-37.371 5.141-1.372.344-2.743.8-4.114 1.258-.914.344-1.941.574-2.855.914-1.945.688-3.774 1.375-5.602 2.059-3.316 1.257-6.629 2.742-9.828 4.23-3.43 1.598-6.742 3.426-10.058 5.371a128 128 0 0 0-9.715 6.285c-2.285 1.602-4.457 3.2-6.512 4.914a154 154 0 0 0-6.86 5.828c-1.14 1.141-2.398 2.172-3.542 3.313l-6.055 6.059-4.344 4.343-4.8 4.684-4.57 4.46-4.802 4.798-11.086 11.086c-.687.687-1.37 1.37-2.058 1.945l-1.028 1.027c-.457.457-1.027 1.028-1.601 1.485-.57.57-1.14 1.031-1.711 1.601a244.4 244.4 0 0 1-49.828 35.313c1.027.457 2.168 1.027 3.199 1.488.8.34 1.715.797 2.512 1.14.8.344 1.715.801 2.515 1.145.801.344 1.602.684 2.516 1.14.914.345 1.828.802 2.742 1.145l3.086 1.371c1.027.457 1.942.801 2.969 1.258 1.258.57 2.629 1.028 3.887 1.598 1.03.46 2.058.8 3.086 1.258 1.601.687 3.199 1.258 4.8 1.945 1.485.57 3.086 1.14 4.57 1.828 1.145.457 2.286.914 3.43 1.258 1.946.684 4 1.484 5.946 2.172a81 81 0 0 1 2.968 1.027c1.371.457 2.856 1.028 4.23 1.485 1.141.343 2.286.8 3.544 1.14q2.567.86 5.14 1.719c1.829.57 3.657 1.14 5.372 1.71 2.171.688 4.457 1.376 6.628 2.06 1.489.457 2.973.914 4.457 1.257 2.973.914 5.942 1.715 9.032 2.512 1.027.344 2.168.574 3.199.8 2.055.458 4.113.915 6.172 1.259 2.398.457 4.683.8 7.082 1.03 3.203.34 6.402.571 9.488.571 2.856 0 5.715 0 8.457-.23 2.63-.227 5.371-.457 8-.914 3.656-.57 7.2-1.371 10.742-2.286 2.399-.683 4.688-1.37 6.973-2.285 2.172-.8 4.227-1.601 6.285-2.515 2.399-1.028 4.684-2.285 6.973-3.543 1.14-.57 2.168-1.258 3.312-1.942 1.028-.687 2.059-1.257 3.086-1.945 1.602-1.027 3.2-2.168 4.684-3.426a52 52 0 0 0 3.887-3.203c2.289-1.941 4.457-4 6.628-6.168 1.032-1.031 2.06-2.172 3.086-3.316 1.258-1.485 2.516-2.969 3.657-4.457.918-1.258 1.828-2.512 2.742-3.77 2.515-3.543 4.8-7.316 6.86-11.199l2.284-4.688 21.145-42.171v.113c6.742-14.742 16.226-28.113 27.656-39.426m0 0" style="stroke:none;fill-rule:nonzero;fill:#133c9a;fill-opacity:1"/></svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View file

@ -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}"
};

View file

@ -70,6 +70,7 @@ type RuntimeConfig = {
authSessionBootstrapEnabled?: string
authSessionBootstrapProvider?: string
authSessionBootstrapAuto?: string
registrationEnabled?: string
}
declare global {
@ -183,6 +184,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<T> = {
code: number
msg: string

View file

@ -24,6 +24,7 @@ vi.mock('lucide-react', () => ({
vi.mock('@/api/client', () => ({
getDirectAuthRuntimeConfig: () => ({ enabled: false }),
isLocalRegistrationEnabled: () => true,
}))
vi.mock('@/features/auth/login-button', () => ({

View file

@ -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() {
</Link>
</p>
<p className="text-center text-sm text-muted-foreground">
{t('login.noAccount')}
{' '}
<Link
to="/register"
search={{ returnTo }}
className="font-medium text-primary hover:underline"
>
{t('login.register')}
</Link>
{isLocalRegistrationEnabled() ? (
<>
{t('login.noAccount')}
{' '}
<Link
to="/register"
search={{ returnTo }}
className="font-medium text-primary hover:underline"
>
{t('login.register')}
</Link>
</>
) : null}
</p>
</form>
</TabsContent>

View file

@ -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 <Navigate to="/login" search={{ returnTo }} />
}
function validateUsername(value: string) {
const trimmed = value.trim()
if (!trimmed) {