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 @@ -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 <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
201 changes: 201 additions & 0 deletions deploy/portainer/README.md
Original file line number Diff line number Diff line change
@@ -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(当前线上) |
112 changes: 112 additions & 0 deletions deploy/portainer/skillhub-stack.yml
Original file line number Diff line number Diff line change
@@ -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:
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
Loading