Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env.release.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docker-compose.staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 22 additions & 3 deletions docs/03-authentication-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 核心接口设计

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@
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;
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;
Expand All @@ -40,27 +42,33 @@ 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,
SkillHubMetrics skillHubMetrics,
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;
this.platformSessionService = platformSessionService;
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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
14 changes: 14 additions & 0 deletions server/skillhub-app/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions server/skillhub-app/src/main/resources/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
);
}
}
Loading