mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-08 22:21:09 +00:00
Merge 1049a267fb into 53df1041f5
This commit is contained in:
commit
0d64ab15bc
24 changed files with 644 additions and 22 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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. 核心接口设计
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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=");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
2
web/public/feishu-logo.svg
Normal file
2
web/public/feishu-logo.svg
Normal 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 |
|
|
@ -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}"
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ vi.mock('lucide-react', () => ({
|
|||
|
||||
vi.mock('@/api/client', () => ({
|
||||
getDirectAuthRuntimeConfig: () => ({ enabled: false }),
|
||||
isLocalRegistrationEnabled: () => true,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/auth/login-button', () => ({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue