diff --git a/.env.release.example b/.env.release.example index 049d887ff..c7b5819d8 100644 --- a/.env.release.example +++ b/.env.release.example @@ -105,6 +105,19 @@ OAUTH2_GITLAB_CLIENT_SECRET= OAUTH2_GITLAB_BASE_URI=https://gitlab.com OAUTH2_GITLAB_DISPLAY_NAME=GitLab +# Optional: configure Feishu (Lark) OAuth. Create a self-built app (企业自建应用) on the +# Feishu Open Platform, grant the contact:user.base:readonly and contact:user.email:readonly +# scopes, publish a version, and add /login/oauth2/code/feishu to the app's +# redirect URLs (安全设置 -> 重定向 URL). +# Note: users without an email are denied when EMAIL_DOMAIN access policy is enabled; +# SUBJECT_WHITELIST entries must use the Feishu open_id (ou_...). +OAUTH2_FEISHU_CLIENT_ID= +OAUTH2_FEISHU_CLIENT_SECRET= +OAUTH2_FEISHU_BASE_URI=https://open.feishu.cn +# Host of the OAuth authorize (consent) page; override for Lark/international deployments. +OAUTH2_FEISHU_AUTHORIZE_URI=https://accounts.feishu.cn +OAUTH2_FEISHU_DISPLAY_NAME=飞书 + # Optional: OIDC login (e.g. Keycloak, Okta, Azure AD). # Replace "OIDC" in variable names with your registration id (uppercase). # The registration id becomes identity_binding.provider_code — keep it stable. diff --git a/deploy/portainer/README.md b/deploy/portainer/README.md new file mode 100644 index 000000000..123763cba --- /dev/null +++ b/deploy/portainer/README.md @@ -0,0 +1,201 @@ +# SkillHub 公司内网部署指南(Portainer) + +本指南覆盖 SkillHub 在公司内网 2TB 机器上的**首次部署**和**日常更新**, +基于 Portainer Stack + Higress 网关 + zot 私有镜像仓库。 + +- 线上地址:https://skillhub.inner.vicoo.ai +- 镜像仓库:`zot.inner.vicoo.ai/skillhub-{server,web,scanner}` +- Stack 文件:`deploy/portainer/skillhub-stack.yml`(本目录) + +## 架构概览 + +``` +浏览器 ──► Higress(*.inner.vicoo.ai, HTTPS) + └─► 127.0.0.1:2080 (web: Nginx) + └─► server:8080 (Spring Boot,容器内直连) +server ──► 共享 PostgreSQL 172.17.0.1:5432(库名 skillhub) + ──► 共享 Redis 172.17.0.1:6379(database 3) + ──► skill-scanner:8000(安全扫描) + ──► open.feishu.cn(飞书 OAuth,需出网) +``` + +要点: +- Higress 是 Host 网络,**容器必须暴露端口映射**,Higress 固定地址服务指向 `127.0.0.1:<宿主端口>` +- web 容器端口 `2080:80`(Higress 路由目标),server 端口 `2081:8080`(仅调试用) +- Higress 不透传 `X-Forwarded-Proto`,协议问题已由代码解决(后端 `PublicBaseUrlSchemeFilter`、 + Nginx `SKILLHUB_TRUST_FORWARDED_PROTO`),无需在网关侧配置 + +## 一、首次部署 + +### 1. 前置条件 + +| 项目 | 说明 | +|------|------| +| zot 仓库可登录 | `docker login zot.inner.vicoo.ai` | +| 飞书应用 | 已创建企业自建应用,重定向 URL 已加 `https://skillhub.inner.vicoo.ai/login/oauth2/code/feishu` | +| Higress | 已创建 `*.inner.vicoo.ai` 域名(自动 Let's Encrypt 证书) | +| 出网 | 2TB 宿主机可访问 `open.feishu.cn`(飞书登录依赖) | + +出网自检(在宿主机执行): + +```bash +curl -sS -m 10 -o /dev/null -w '%{http_code}\n' \ + https://open.feishu.cn/open-apis/authen/v2/oauth/token +``` + +返回非超时即可(4xx 也算通)。 + +### 2. 构建并推送镜像 + +在开发机(仓库根目录)执行。首次部署构建全部三个镜像: + +```bash +TAG=$(date +%Y%m%d-%H%M); echo "TAG=$TAG" +docker build --platform linux/amd64 -t zot.inner.vicoo.ai/skillhub-server:$TAG -f server/Dockerfile server +docker build --platform linux/amd64 -t zot.inner.vicoo.ai/skillhub-web:$TAG -f web/Dockerfile web +docker build --platform linux/amd64 -t zot.inner.vicoo.ai/skillhub-scanner:$TAG -f scanner/Dockerfile scanner +docker push zot.inner.vicoo.ai/skillhub-server:$TAG +docker push zot.inner.vicoo.ai/skillhub-web:$TAG +docker push zot.inner.vicoo.ai/skillhub-scanner:$TAG +``` + +注意:本机是 arm64 Mac,必须带 `--platform linux/amd64`(QEMU 跨平台构建,耗时较长)。 + +### 3. Higress 配置 + +1. 域名:`*.inner.vicoo.ai`(已有则跳过) +2. 固定地址服务:名称随意(不含空格),地址 `127.0.0.1`,端口 `2080` +3. 路由:域名 `skillhub.inner.vicoo.ai` → 上述服务,路径前缀 `/` + +### 4. 创建 Stack + +Portainer → Stacks → Add stack: + +1. 粘贴 `deploy/portainer/skillhub-stack.yml` 全文 +2. 在 **Environment variables** 中填写(缺一个都会出问题,见"常见故障"): + +| 变量 | 说明 | 示例 | +|------|------|------| +| `SKILLHUB_VERSION` | 镜像 tag | `20260807-2121` | +| `SHARED_POSTGRES_PASSWORD` | 共享 PG postgres 用户密码 | — | +| `OAUTH2_FEISHU_CLIENT_ID` | 飞书应用 App ID | `cli_xxxxx` | +| `OAUTH2_FEISHU_CLIENT_SECRET` | 飞书应用 App Secret | — | +| `COOKIE_SECRET` | 随机 hex | `openssl rand -hex 32` | + +3. Deploy the stack + +`db-init` 一次性容器会自动在共享 PG 上创建 `skillhub` 库(幂等)。 + +### 5. 验证 + +```bash +# runtime-config 应包含 registrationEnabled: "false" +curl -sk https://skillhub.inner.vicoo.ai/runtime-config.js + +# 健康检查 +curl -sk https://skillhub.inner.vicoo.ai/api/v1/auth/providers + +# 注册接口应返回 403 "Local registration is disabled"(需带 CSRF,浏览器里验证即可) +``` + +浏览器验证:飞书登录 → 首次登录自动建号;登录页无注册链接;`/register` 重定向回登录页。 + +### 6. 管理员 + +- bootstrap 管理员:用户名 `admin`,密码 `ChangeMe!2026`(密码登录页签),**登录后立即改密** +- 给飞书用户授管理员:admin 登录 → 用户管理 → 授予 `SUPER_ADMIN` / `SKILL_ADMIN` + +## 二、日常更新 + +代码改动后发布新版本: + +### 1. 构建推送 + +只重建有变化的镜像,其余复用旧镜像打同一 tag(Stack 只有一个 `SKILLHUB_VERSION`): + +```bash +TAG=$(date +%Y%m%d-%H%M); echo "TAG=$TAG" +PREV=<上一个 tag> # 例如 20260807-2121 + +# 示例:只有 web 有代码变化 +docker build --platform linux/amd64 -t zot.inner.vicoo.ai/skillhub-web:$TAG -f web/Dockerfile web +docker tag zot.inner.vicoo.ai/skillhub-server:$PREV zot.inner.vicoo.ai/skillhub-server:$TAG +docker tag zot.inner.vicoo.ai/skillhub-scanner:$PREV zot.inner.vicoo.ai/skillhub-scanner:$TAG +docker push zot.inner.vicoo.ai/skillhub-web:$TAG +docker push zot.inner.vicoo.ai/skillhub-server:$TAG +docker push zot.inner.vicoo.ai/skillhub-scanner:$TAG +``` + +改了后端就重建 server,改了 `web/` 就重建 web,改了 `scanner/` 就重建 scanner。 + +### 2. 更新 Stack + +Portainer → Stacks → skillhub: + +1. 如果 `skillhub-stack.yml` 有改动(新增环境变量、改配置),先把最新内容粘贴到 Editor +2. Environment variables 中把 `SKILLHUB_VERSION` 改为新 tag +3. Update the stack + +### 3. 更新后验证 + +- Portainer 中三个容器均 `running` / `healthy`(server 启动约需 1 分钟) +- 浏览器强刷(Cmd+Shift+R)验证本次改动——`runtime-config.js` 和 JS 资源可能被缓存 + +## 三、关键配置说明 + +### 环境变量(stack yml 内已固化,一般不用改) + +| 变量 | 值 | 作用 | +|------|-----|------| +| `SKILLHUB_PUBLIC_BASE_URL` | `https://skillhub.inner.vicoo.ai` | OAuth redirect_uri、Cookie Secure 的依据 | +| `SKILLHUB_AUTH_LOCAL_REGISTRATION_ENABLED` | `false` | 关闭本地注册 API(403) | +| `SKILLHUB_WEB_REGISTRATION_ENABLED` | `false` | 前端隐藏注册入口 | +| `SKILLHUB_TRUST_FORWARDED_PROTO` | `true` | Nginx 信任上游协议头 | +| `SESSION_COOKIE_SECURE` | `true` | Session Cookie 仅 HTTPS | +| `BOOTSTRAP_ADMIN_ENABLED` | `true` | 内置 admin 账号;改密后可置 `false` | +| `SPRING_DATA_REDIS_DATABASE` | `3` | 避免与共享 Redis 上其他应用冲突 | + +### 飞书凭据 + +`OAUTH2_FEISHU_CLIENT_ID` / `OAUTH2_FEISHU_CLIENT_SECRET` 从 Portainer Stack 环境变量注入。 +**两者都必须非空**:空 client-id 会导致 Spring Security 启动失败(容器 unhealthy)。 +飞书后台重置 Secret 后,记得同步更新 Stack 环境变量并重新部署。 + +### 只允许飞书注册 + +当前部署策略:登录保留密码页签(供 admin 使用),注册仅飞书一条路。 +如需完全禁用密码登录,将 `BOOTSTRAP_ADMIN_ENABLED` 置 `false` 前确认已有飞书 SUPER_ADMIN。 + +## 四、常见故障 + +| 现象 | 原因 | 处理 | +|------|------|------| +| `container skillhub-server-1 is unhealthy` | Stack 环境变量漏填,`${VAR}` 被插值为空串(常见于飞书 client-id 为空导致启动失败) | 检查 Environment variables 五项是否齐全;看 server 容器日志确认启动报错 | +| 配置改了但线上没生效 | runtime-config/静态资源浏览器缓存 | 强刷;确认 `curl runtime-config.js` 输出已是新值 | +| web 配置项显示为空字符串 | web 镜像旧于 entrypoint 修复版,或 Stack 里对应变量未设置 | 升级到 ≥ `20260807-2121` 的镜像并确认 Stack 环境变量 | +| 飞书 OAuth 回调 401/跳回登录页 | App Secret 不一致、宿主机无法出网到 open.feishu.cn | server 容器日志中现在有 `OAuth2 login failed` ERROR 行,直接看异常原因 | +| redirect_uri 是 http:// | 旧版镜像未含协议修复 | 升级到含 `PublicBaseUrlSchemeFilter` 的镜像(≥ `20260807-1700`) | +| db-init 拉取 postgres:16-alpine 失败 | 宿主机无法访问 docker.io | 改用本地已有或 zot 上的 postgres 镜像 | + +排查命令: + +```bash +# 线上配置自检 +curl -sk https://skillhub.inner.vicoo.ai/runtime-config.js + +# 宿主机上看容器日志 +docker logs --tail 100 skillhub-server-1 +docker logs --tail 50 skillhub-web-1 + +# 直连后端调试端口(宿主机上) +curl -s http://127.0.0.1:2081/actuator/health +``` + +## 五、历史镜像 tag 参考 + +| Tag | 内容 | +|-----|------| +| `20260807-1602` | 首个内网版本 | +| `20260807-1700` | 修复 redirect_uri http 问题(PublicBaseUrlSchemeFilter) | +| `20260807-2111` | 注册开关(前后端)+ OAuth 失败日志 | +| `20260807-2121` | 修复 web entrypoint 环境变量导出 bug(当前线上) | diff --git a/deploy/portainer/skillhub-stack.yml b/deploy/portainer/skillhub-stack.yml new file mode 100644 index 000000000..1d1c4200d --- /dev/null +++ b/deploy/portainer/skillhub-stack.yml @@ -0,0 +1,112 @@ +# Portainer stack for SkillHub on the company intranet (2TB machine). +# +# Routes through Higress: domain skillhub.inner.vicoo.ai -> 127.0.0.1:2080 (web). +# Uses shared infrastructure: PostgreSQL 172.17.0.1:5432, Redis 172.17.0.1:6379. +# +# Stack environment variables (enter in Portainer when creating the stack): +# SKILLHUB_VERSION image tag, e.g. 20260806-1430 +# SHARED_POSTGRES_PASSWORD password of the shared postgres superuser +# OAUTH2_FEISHU_CLIENT_ID Feishu app id, e.g. cli_xxxxx +# OAUTH2_FEISHU_CLIENT_SECRET Feishu app client secret +# COOKIE_SECRET random hex, e.g. `openssl rand -hex 32` + +services: + db-init: + # One-shot: creates the skillhub database on the shared PostgreSQL. + # If docker.io is unreachable on the host, switch to the shared PG's image. + image: postgres:16-alpine + restart: "no" + environment: + PGPASSWORD: ${SHARED_POSTGRES_PASSWORD} + entrypoint: ["/bin/sh", "-c"] + command: + - | + until pg_isready -h 172.17.0.1 -p 5432 -U postgres; do sleep 2; done + psql -h 172.17.0.1 -U postgres -tAc "SELECT 1 FROM pg_database WHERE datname='skillhub'" | grep -q 1 \ + || psql -h 172.17.0.1 -U postgres -c "CREATE DATABASE skillhub;" + + skill-scanner: + image: zot.inner.vicoo.ai/skillhub-scanner:${SKILLHUB_VERSION} + restart: always + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8000/health"] + interval: 10s + timeout: 5s + retries: 10 + + server: + image: zot.inner.vicoo.ai/skillhub-server:${SKILLHUB_VERSION} + restart: always + ports: + # Host port for direct debugging; Higress only routes through web (2080). + - "2081:8080" + environment: + SPRING_PROFILES_ACTIVE: docker + SPRING_DATASOURCE_URL: jdbc:postgresql://172.17.0.1:5432/skillhub + SPRING_DATASOURCE_USERNAME: postgres + SPRING_DATASOURCE_PASSWORD: ${SHARED_POSTGRES_PASSWORD} + # Redis database 3 to avoid key collisions with other apps on the shared instance. + SPRING_DATA_REDIS_HOST: 172.17.0.1 + SPRING_DATA_REDIS_PORT: "6379" + SPRING_DATA_REDIS_DATABASE: "3" + SKILLHUB_PUBLIC_BASE_URL: https://skillhub.inner.vicoo.ai + SESSION_COOKIE_SECURE: "true" + SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET: ${COOKIE_SECRET} + SKILLHUB_STORAGE_PROVIDER: local + STORAGE_BASE_PATH: /var/lib/skillhub/storage + SKILLHUB_SECURITY_SCANNER_ENABLED: "true" + SKILLHUB_SECURITY_SCANNER_URL: http://skill-scanner:8000 + SKILLHUB_SECURITY_SCANNER_MODE: upload + SKILLHUB_BUILTIN_SKILLS_ENABLED: "true" + SKILLHUB_AUTH_DIRECT_ENABLED: "true" + SKILLHUB_TRACING_MODE: none + SKILLHUB_LOG_FORMAT: json + SKILLHUB_SERVICE_VERSION: ${SKILLHUB_VERSION} + SKILLHUB_SERVICE_ENVIRONMENT: production + BOOTSTRAP_ADMIN_ENABLED: "true" + SKILLHUB_AUTH_LOCAL_REGISTRATION_ENABLED: "false" + BOOTSTRAP_ADMIN_USER_ID: portainer-admin + BOOTSTRAP_ADMIN_USERNAME: admin + BOOTSTRAP_ADMIN_PASSWORD: ChangeMe!2026 + BOOTSTRAP_ADMIN_DISPLAY_NAME: Platform Admin + BOOTSTRAP_ADMIN_EMAIL: admin@skillhub.local + OAUTH2_FEISHU_CLIENT_ID: ${OAUTH2_FEISHU_CLIENT_ID} + OAUTH2_FEISHU_CLIENT_SECRET: ${OAUTH2_FEISHU_CLIENT_SECRET} + volumes: + - skillhub_storage:/var/lib/skillhub/storage + depends_on: + db-init: + condition: service_completed_successfully + skill-scanner: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 60s + + web: + image: zot.inner.vicoo.ai/skillhub-web:${SKILLHUB_VERSION} + restart: always + ports: + # Higress service must point to 127.0.0.1:2080. + - "2080:80" + environment: + SKILLHUB_API_UPSTREAM: http://server:8080 + SKILLHUB_TRUST_FORWARDED_PROTO: "true" + SKILLHUB_PUBLIC_BASE_URL: https://skillhub.inner.vicoo.ai + SKILLHUB_WEB_API_BASE_URL: "" + SKILLHUB_WEB_REGISTRATION_ENABLED: "false" + depends_on: + server: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1/nginx-health"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 10s + +volumes: + skillhub_storage: diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index 48d1fd1ad..81a5bc6d8 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -71,6 +71,7 @@ services: SKILLHUB_API_UPSTREAM: http://server:8080 SKILLHUB_WEB_API_BASE_URL: "" SKILLHUB_PUBLIC_BASE_URL: "" + SKILLHUB_TRUST_FORWARDED_PROTO: "false" depends_on: server: condition: service_healthy diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index 2f4b77052..303358b6a 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -279,9 +279,28 @@ spring: ``` Spring Security OAuth2 Client 原生支持多 Provider 并存,新增 Provider 只需: -1. `application.yml` 添加 registration 配置 -2. `CustomOAuth2UserService` 中按 `registrationId` 分支处理用户属性映射 -3. 前端登录页增加对应按钮(通过 `/api/v1/auth/providers` 自动发现) +1. `application.yml` 添加 registration 配置(client-id 默认 `placeholder` 时登录页自动隐藏该入口) +2. 新增一个 `OAuthClaimsExtractor` 实现(`@Component`,按 `registrationId` 自动注册),完成用户属性到标准 claims 的映射 +3. 前端无需改动:登录按钮通过 `/api/v1/auth/methods` 自动发现,图标约定 `web/public/{provider}-logo.svg` + +### 非标准 Provider 接入样板:飞书(Feishu) + +飞书 OAuth 与标准 OAuth2 存在偏差,接入时做了以下定制,可作为后续非标准 Provider 的参考: + +1. **授权端点**:使用官方当前文档的标准 OAuth2 授权端点 + `https://accounts.feishu.cn/open-apis/authen/v1/authorize`(`client_id` + 可选 `scope`, + 权限在开放平台应用内配置),授权请求由 Spring Security 默认 resolver 构建, + host 可用 `OAUTH2_FEISHU_AUTHORIZE_URI` 覆盖;token / userinfo 端点仍在 `open.feishu.cn` + (`OAUTH2_FEISHU_BASE_URI` 覆盖)。 +2. **userinfo 响应包裹**:响应为 `{code, msg, data}` 结构且错误以 HTTP 200 返回。 + 通过 `ProviderOAuth2UserService` 扩展点实现 `FeishuOAuth2UserService`,覆盖默认的 user info 加载并解包 `data`; + `OAuthLoginFlowService` 按 registrationId 选择 loader,其余 Provider 仍走 `DefaultOAuth2UserService`。 +3. **token 端点认证**:使用 `client_secret_post`(表单传 client_id/client_secret)。 +4. **subject 选择**:绑定主体使用 `open_id`(应用内唯一);`union_id` 保留在 extra 中, + 未来若同一部署接入多个飞书应用可基于它做身份归并。 +5. **准入策略注意**:邮箱域名策略(EMAIL_DOMAIN)模式下,未绑定邮箱的飞书用户会被拒绝。 +6. **email_verified 语义**:飞书 user-info 返回的邮箱由组织管理员导入,无实时验证信号, + `FeishuClaimsExtractor` 恒置 `emailVerified=false`;EMAIL_DOMAIN 策略仅匹配邮箱域名,不依赖该标志。 ## 4. 核心接口设计 diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 10e9638eb..e6138d070 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -69,6 +69,15 @@ spring: authorization-grant-type: authorization_code redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" client-name: ${OAUTH2_GITLAB_DISPLAY_NAME:GitLab} + feishu: + client-id: ${OAUTH2_FEISHU_CLIENT_ID:placeholder} + client-secret: ${OAUTH2_FEISHU_CLIENT_SECRET:placeholder} + # Feishu scopes are configured on the open platform app itself + # (contact:user.base:readonly, contact:user.email:readonly). + authorization-grant-type: authorization_code + client-authentication-method: client_secret_post + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + client-name: ${OAUTH2_FEISHU_DISPLAY_NAME:飞书} provider: github: user-info-uri: https://api.github.com/user @@ -77,6 +86,11 @@ spring: token-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/oauth/token user-info-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/api/v4/user user-name-attribute: username + feishu: + authorization-uri: ${OAUTH2_FEISHU_AUTHORIZE_URI:https://accounts.feishu.cn}/open-apis/authen/v1/authorize + token-uri: ${OAUTH2_FEISHU_BASE_URI:https://open.feishu.cn}/open-apis/authen/v2/oauth/token + user-info-uri: ${OAUTH2_FEISHU_BASE_URI:https://open.feishu.cn}/open-apis/authen/v1/user_info + user-name-attribute: open_id servlet: multipart: max-file-size: 100MB diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractor.java new file mode 100644 index 000000000..25a6ecc82 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractor.java @@ -0,0 +1,60 @@ +package com.iflytek.skillhub.auth.oauth; + +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Component; + +/** + * Provider-specific claims extractor for Feishu (Lark) OAuth users. Attributes are already + * unwrapped from the Feishu response envelope by {@link FeishuOAuth2UserService}. + */ +@Component +public class FeishuClaimsExtractor implements OAuthClaimsExtractor { + + private static final Logger log = LoggerFactory.getLogger(FeishuClaimsExtractor.class); + + @Override + public String getProvider() { + return FeishuOAuth2UserService.PROVIDER; + } + + @Override + public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) { + Map attrs = oAuth2User.getAttributes(); + + // open_id is unique within the Feishu app; union_id is kept in extra for potential + // cross-app identity migration later. + String subject = String.valueOf(attrs.get("open_id")); + + String email = (String) attrs.get("enterprise_email"); + if (email == null) { + email = (String) attrs.get("email"); + } + // Feishu emails are imported by the organization admin and not verified with the user + // in real time, so they carry no verification signal; keep emailVerified false. + boolean emailVerified = false; + + String username = (String) attrs.get("name"); + if (username == null || username.isBlank()) { + username = (String) attrs.get("en_name"); + } + if (username == null || username.isBlank()) { + username = "feishu-" + subject; + } + + log.info("Feishu OAuth claims extracted - subject: {}, username: {}, email present: {}", + subject, username, email != null); + + return new OAuthClaims( + FeishuOAuth2UserService.PROVIDER, + subject, + email, + emailVerified, + username, + attrs + ); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserService.java new file mode 100644 index 000000000..3c44bb698 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserService.java @@ -0,0 +1,127 @@ +package com.iflytek.skillhub.auth.oauth; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +/** + * Loads Feishu (Lark) user info, which deviates from the standard OAuth format: the response is + * wrapped in a {@code {code, msg, data}} envelope and errors are reported with HTTP 200. + */ +@Component +public class FeishuOAuth2UserService implements ProviderOAuth2UserService { + + static final String PROVIDER = "feishu"; + + private final RestClient restClient; + + /** + * Uses an external-service client that is intentionally not customized with application + * tracing. Trace context must not be propagated to the external Feishu service. + */ + @Autowired + public FeishuOAuth2UserService() { + this(RestClient.builder()); + } + + public FeishuOAuth2UserService(RestClient.Builder restClientBuilder) { + this.restClient = restClientBuilder + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + } + + @Override + public String getProvider() { + return PROVIDER; + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + String userInfoUri = userRequest.getClientRegistration().getProviderDetails() + .getUserInfoEndpoint().getUri(); + + FeishuUserResponse response; + try { + response = restClient.get() + .uri(userInfoUri) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + userRequest.getAccessToken().getTokenValue()) + .retrieve() + .body(new ParameterizedTypeReference() {}); + } catch (Exception e) { + throw new OAuth2AuthenticationException( + new OAuth2Error("feishu_userinfo_error", "Failed to load Feishu user info: " + e.getMessage(), null), + e + ); + } + + if (response == null || response.code() != 0 || response.data() == null) { + String msg = response != null ? response.msg() : "empty response"; + throw new OAuth2AuthenticationException( + new OAuth2Error("feishu_userinfo_error", "Feishu user info error: " + msg, null) + ); + } + + String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails() + .getUserInfoEndpoint().getUserNameAttributeName(); + + Map attributes = flatten(response.data(), userNameAttributeName); + return new DefaultOAuth2User( + Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")), + attributes, + userNameAttributeName + ); + } + + private Map flatten(FeishuUserData data, String userNameAttributeName) { + Map attributes = new LinkedHashMap<>(); + putIfPresent(attributes, "open_id", data.openId()); + putIfPresent(attributes, "union_id", data.unionId()); + putIfPresent(attributes, "name", data.name()); + putIfPresent(attributes, "en_name", data.enName()); + putIfPresent(attributes, "avatar_url", data.avatarUrl()); + putIfPresent(attributes, "email", data.email()); + putIfPresent(attributes, "enterprise_email", data.enterpriseEmail()); + putIfPresent(attributes, "mobile", data.mobile()); + if (!attributes.containsKey(userNameAttributeName)) { + throw new OAuth2AuthenticationException( + new OAuth2Error("feishu_userinfo_error", "Feishu user info missing " + userNameAttributeName, null) + ); + } + return attributes; + } + + private void putIfPresent(Map attributes, String key, String value) { + if (value != null && !value.isBlank()) { + attributes.put(key, value); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + record FeishuUserResponse(int code, String msg, @JsonProperty("data") FeishuUserData data) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + record FeishuUserData( + @JsonProperty("open_id") String openId, + @JsonProperty("union_id") String unionId, + @JsonProperty("name") String name, + @JsonProperty("en_name") String enName, + @JsonProperty("avatar_url") String avatarUrl, + @JsonProperty("email") String email, + @JsonProperty("enterprise_email") String enterpriseEmail, + @JsonProperty("mobile") String mobile + ) {} +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java index e5c7dc3de..43985f4f9 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java @@ -16,6 +16,7 @@ import org.springframework.security.core.AuthenticationException; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.user.OAuth2User; @@ -29,23 +30,31 @@ @Service public class OAuthLoginFlowService { - private final DefaultOAuth2UserService delegate = new DefaultOAuth2UserService(); + private final OAuth2UserService defaultUserService = new DefaultOAuth2UserService(); private final Map extractors; + private final Map userServiceOverrides; private final AccessPolicy accessPolicy; private final IdentityBindingService identityBindingService; public OAuthLoginFlowService(List extractorList, + List userServiceList, AccessPolicy accessPolicy, IdentityBindingService identityBindingService) { this.extractors = extractorList.stream() .collect(Collectors.toMap(OAuthClaimsExtractor::getProvider, Function.identity())); + this.userServiceOverrides = userServiceList.stream() + .collect(Collectors.toMap(ProviderOAuth2UserService::getProvider, Function.identity())); this.accessPolicy = accessPolicy; this.identityBindingService = identityBindingService; } public AuthenticatedLoginContext loadLoginContext(OAuth2UserRequest request) { - OAuth2User upstreamUser = delegate.loadUser(request); String registrationId = request.getClientRegistration().getRegistrationId(); + OAuth2UserService userService = userServiceOverrides.get(registrationId); + if (userService == null) { + userService = defaultUserService; + } + OAuth2User upstreamUser = userService.loadUser(request); OAuthClaimsExtractor extractor = extractors.get(registrationId); if (extractor == null) { diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderOAuth2UserService.java new file mode 100644 index 000000000..bf587e812 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/ProviderOAuth2UserService.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.auth.oauth; + +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.user.OAuth2User; + +/** + * Strategy interface for provider-specific OAuth user loading. Implementations override the + * default user info loading for providers whose endpoints deviate from the standard + * flat-attribute response format. + */ +public interface ProviderOAuth2UserService extends OAuth2UserService { + String getProvider(); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java index c72b1d9d6..311de7573 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java @@ -8,7 +8,8 @@ /** * OAuth2 authorization request resolver that preserves a sanitized post-login redirect target in - * the HTTP session. + * the HTTP session. Authorization URIs are taken verbatim from the client registration; Feishu's + * current authorize endpoint accepts standard OAuth2 parameters (client_id, optional scope). */ @Component public class SkillHubOAuth2AuthorizationRequestResolver diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractorTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractorTest.java new file mode 100644 index 000000000..53adad50c --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuClaimsExtractorTest.java @@ -0,0 +1,88 @@ +package com.iflytek.skillhub.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; + +class FeishuClaimsExtractorTest { + + private final FeishuClaimsExtractor extractor = new FeishuClaimsExtractor(); + + @Test + void extract_prefersEnterpriseEmailOverPersonalEmail() { + Map attrs = new HashMap<>(Map.of( + "open_id", "ou_123", + "name", "张三", + "email", "zhangsan@personal.example", + "enterprise_email", "zhangsan@corp.example" + )); + + OAuthClaims claims = extractor.extract(userRequest(), user(attrs)); + + assertThat(claims.provider()).isEqualTo("feishu"); + assertThat(claims.subject()).isEqualTo("ou_123"); + assertThat(claims.email()).isEqualTo("zhangsan@corp.example"); + // Feishu emails are admin-imported; the extractor must not claim verification. + assertThat(claims.emailVerified()).isFalse(); + assertThat(claims.providerLogin()).isEqualTo("张三"); + } + + @Test + void extract_allowsNullEmailAndFallsBackUsername() { + Map attrs = new HashMap<>(Map.of("open_id", "ou_456")); + + OAuthClaims claims = extractor.extract(userRequest(), user(attrs)); + + assertThat(claims.subject()).isEqualTo("ou_456"); + assertThat(claims.email()).isNull(); + assertThat(claims.emailVerified()).isFalse(); + assertThat(claims.providerLogin()).isEqualTo("feishu-ou_456"); + } + + @Test + void extract_fallsBackToEnglishNameWhenChineseNameBlank() { + Map attrs = new HashMap<>(Map.of( + "open_id", "ou_789", + "en_name", "Alice" + )); + + OAuthClaims claims = extractor.extract(userRequest(), user(attrs)); + + assertThat(claims.providerLogin()).isEqualTo("Alice"); + } + + private DefaultOAuth2User user(Map attrs) { + return new DefaultOAuth2User(java.util.List.of(), attrs, "open_id"); + } + + private OAuth2UserRequest userRequest() { + ClientRegistration registration = ClientRegistration.withRegistrationId("feishu") + .clientId("cli_test123") + .clientSecret("client-secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .authorizationUri("https://open.feishu.cn/open-apis/authen/v1/authorize") + .tokenUri("https://open.feishu.cn/open-apis/authen/v2/oauth/token") + .userInfoUri("https://open.feishu.cn/open-apis/authen/v1/user_info") + .userNameAttributeName("open_id") + .clientName("飞书") + .build(); + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + "token-123", + Instant.now(), + Instant.now().plusSeconds(3600) + ); + return new OAuth2UserRequest(registration, accessToken); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserServiceTest.java new file mode 100644 index 000000000..4151d1981 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/FeishuOAuth2UserServiceTest.java @@ -0,0 +1,105 @@ +package com.iflytek.skillhub.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +class FeishuOAuth2UserServiceTest { + + @Test + void loadUser_unwrapsFeishuEnvelopeIntoFlatAttributes() { + RestClient.Builder restClientBuilder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build(); + server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v1/user_info")) + .andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer token-123")) + .andRespond(withSuccess( + """ + { + "code": 0, + "msg": "success", + "data": { + "open_id": "ou_123", + "union_id": "on_456", + "name": "张三", + "avatar_url": "https://avatar.example/zhangsan.png", + "enterprise_email": "zhangsan@corp.example", + "email": "zhangsan@personal.example" + } + } + """, + MediaType.APPLICATION_JSON + )); + FeishuOAuth2UserService service = new FeishuOAuth2UserService(restClientBuilder); + + OAuth2User user = service.loadUser(userRequest()); + + assertThat(user.getName()).isEqualTo("ou_123"); + assertThat(user.getAttributes()) + .containsEntry("open_id", "ou_123") + .containsEntry("union_id", "on_456") + .containsEntry("name", "张三") + .containsEntry("avatar_url", "https://avatar.example/zhangsan.png") + .containsEntry("enterprise_email", "zhangsan@corp.example") + .doesNotContainKey("code") + .doesNotContainKey("data"); + server.verify(); + } + + @Test + void loadUser_throwsWhenFeishuReportsErrorCode() { + RestClient.Builder restClientBuilder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build(); + server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v1/user_info")) + .andRespond(withSuccess( + """ + {"code": 99991663, "msg": "invalid access token"} + """, + MediaType.APPLICATION_JSON + )); + FeishuOAuth2UserService service = new FeishuOAuth2UserService(restClientBuilder); + + assertThatThrownBy(() -> service.loadUser(userRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode()) + .isEqualTo("feishu_userinfo_error")); + server.verify(); + } + + private OAuth2UserRequest userRequest() { + ClientRegistration registration = ClientRegistration.withRegistrationId("feishu") + .clientId("cli_test123") + .clientSecret("client-secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .authorizationUri("https://open.feishu.cn/open-apis/authen/v1/authorize") + .tokenUri("https://open.feishu.cn/open-apis/authen/v2/oauth/token") + .userInfoUri("https://open.feishu.cn/open-apis/authen/v1/user_info") + .userNameAttributeName("open_id") + .clientName("飞书") + .build(); + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + "token-123", + Instant.now(), + Instant.now().plusSeconds(3600) + ); + return new OAuth2UserRequest(registration, accessToken); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java index 357ada331..61ebe9dc2 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java @@ -30,13 +30,26 @@ void setUp() { .scope("read:user") .clientName("GitHub") .build(); + ClientRegistration feishu = ClientRegistration.withRegistrationId("feishu") + .clientId("cli_test123") + .clientSecret("secret") + .authorizationUri("https://accounts.feishu.cn/open-apis/authen/v1/authorize") + .tokenUri("https://open.feishu.cn/open-apis/authen/v2/oauth/token") + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .userInfoUri("https://open.feishu.cn/open-apis/authen/v1/user_info") + .userNameAttributeName("open_id") + .authorizationGrantType(org.springframework.security.oauth2.core.AuthorizationGrantType.AUTHORIZATION_CODE) + .clientAuthenticationMethod(org.springframework.security.oauth2.core.ClientAuthenticationMethod.CLIENT_SECRET_POST) + .clientName("飞书") + .build(); OAuthLoginFlowService oauthLoginFlowService = new OAuthLoginFlowService( + java.util.List.of(), java.util.List.of(), mock(AccessPolicy.class), mock(IdentityBindingService.class) ); resolver = new SkillHubOAuth2AuthorizationRequestResolver( - new InMemoryClientRegistrationRepository(github), + new InMemoryClientRegistrationRepository(github, feishu), oauthLoginFlowService ); } @@ -65,4 +78,32 @@ void resolve_ignoresUnsafeReturnTo() { assertThat(session).isNotNull(); assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull(); } + + @Test + void resolve_feishu_usesStandardOAuth2Parameters() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/feishu"); + + var authorizationRequest = resolver.resolve(request, "feishu"); + + assertThat(authorizationRequest).isNotNull(); + String uri = authorizationRequest.getAuthorizationRequestUri(); + assertThat(uri).startsWith("https://accounts.feishu.cn/open-apis/authen/v1/authorize"); + assertThat(uri).contains("client_id=cli_test123"); + assertThat(uri).contains("response_type=code"); + assertThat(uri).contains("state="); + assertThat(uri).doesNotContain("app_id="); + } + + @Test + void resolve_github_keepsStandardParameters() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/github"); + + var authorizationRequest = resolver.resolve(request, "github"); + + assertThat(authorizationRequest).isNotNull(); + String uri = authorizationRequest.getAuthorizationRequestUri(); + assertThat(uri).contains("client_id=client"); + assertThat(uri).contains("scope=read:user"); + assertThat(uri).doesNotContain("app_id="); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java index 029ec2944..f42064bad 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java @@ -17,6 +17,7 @@ class OAuthLoginFlowServiceTest { @Test void rememberReturnTo_stores_sanitized_return_target() { OAuthLoginFlowService service = new OAuthLoginFlowService( + List.of(), List.of(), mock(AccessPolicy.class), mock(IdentityBindingService.class) @@ -35,6 +36,7 @@ void rememberReturnTo_stores_sanitized_return_target() { @Test void resolveFailureRedirect_maps_access_denied_to_user_facing_page() { OAuthLoginFlowService service = new OAuthLoginFlowService( + List.of(), List.of(), mock(AccessPolicy.class), mock(IdentityBindingService.class) @@ -51,6 +53,7 @@ void resolveFailureRedirect_maps_access_denied_to_user_facing_page() { @Test void consumeReturnTo_clearsUnsafeSessionValue() { OAuthLoginFlowService service = new OAuthLoginFlowService( + List.of(), List.of(), mock(AccessPolicy.class), mock(IdentityBindingService.class) diff --git a/web/public/feishu-logo.svg b/web/public/feishu-logo.svg new file mode 100644 index 000000000..0cb86de7d --- /dev/null +++ b/web/public/feishu-logo.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file