Skip to content
Merged
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
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: CI

on:
pull_request:
push:
branches: [master]

permissions:
contents: read

jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- run: corepack enable
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm test
- run: pnpm build
- run: docker build .
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ WORKDIR /app

COPY package.json pnpm-lock.yaml tsconfig.json tsconfig.build.json ./
COPY src ./src
COPY web ./web
COPY scripts ./scripts

RUN corepack enable && pnpm install --frozen-lockfile
Expand Down
89 changes: 61 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

- **🏫 无缝对接校园认证**:将学校 UIS / CAS 登录链路安全包装为标准 OIDC 登录入口。
- **🔐 标准协议支持**:完整支持 Authorization Code + PKCE 流程,签发高可靠 ID / Access Token。
- **🎛️ 受控白名单接入**:非开放生态 OP,需在通过 JSON 文件预注册客户端域与凭证,实现严格的安全边界
- **🎛️ 受控客户端管理**:客户端由 PostgreSQL 持久化,通过登录保护的管理台创建、审核、编辑和停用
- **🛡️ 生产级安全防护**:内置交互页 CSRF 校验、端点及登录限流、Refresh Token Rotation、Artifact 自动清理。
- **📧 邮箱验证引擎**:原生内置 Resend 邮件服务支持,保障用户的实名绑定链路。
- **📦 现代化技术栈**:搭配 PostgreSQL 持久化与 Redis 高缓存,基于 Node.js 24 无缝构建。
Expand Down Expand Up @@ -78,16 +78,16 @@ sequenceDiagram
```bash
# 初始化测试环境,自动配置内置 demo 客户端
pnpm init-env --force --profile test

# 启动后端中间件集群
docker compose -f deploy/docker-compose.yml up -d --build

# 等待启动并检测健康状态
curl http://127.0.0.1:3003/health/ready
curl http://127.0.0.1:3003/.well-known/openid-configuration
```

*注意:使用 `--force` 将抹除先前的加密轮数并覆写预置信息。如若数据库中保留了早期密码可能会发生鉴权拒绝,推荐执行 `docker compose -f deploy/docker-compose.yml down -v` 彻底洗卷。*
_注意:使用 `--force` 将抹除先前的加密轮数并覆写预置信息。如若数据库中保留了早期密码可能会发生鉴权拒绝,推荐执行 `docker compose -f deploy/docker-compose.yml down -v` 彻底洗卷。_

3. **本地开发联调网络 (HTTPS)**
适用于由宿主机或网关代理终止 TLS 的场景:
Expand All @@ -104,7 +104,7 @@ sequenceDiagram
```bash
# 生成供正式使用的 env 安全模板
pnpm init-env --force --profile production --issuer https://auth.example.com

# 以后台常驻唤起
docker compose -f deploy/docker-compose.prod.yml up -d --build
```
Expand All @@ -115,7 +115,8 @@ docker compose -f deploy/docker-compose.prod.yml up -d --build
- [ ] `OIDC_COOKIE_SECURE=true`、`TRUST_PROXY_HOPS=1` 与 `TRUSTED_PROXY_CIDRS` 配置完毕,反向代理必须覆盖 `X-Forwarded-For`。
- [ ] 项目中涉及的各套秘钥组(Cookie / 加密 / Redis 等)均已更改为高熵值。
- [ ] `RESEND_API_KEY` 及 `OIDC_EMAIL_FROM` 已正确就绪以实现邮箱鉴权下行。
- [ ] OIDC 终端客户端已在 `deploy/oidc-clients.json` 中配置完毕并映射。
- [ ] 如需预置客户端,已写入 `deploy/oidc-clients.json`;缺失或空文件允许从零客户端启动。
- [ ] 至少一名管理员的 Subject ID 已写入 `OIDC_ADMIN_SUBJECT_IDS`。
- [ ] 初次启动可通过设定 `OIDC_AUTO_SEED_SIGNING_KEY=true` (或命令执行)完成签名私钥分发。
- [ ] 确保 `APP_ENV=production` 环境下正确连接到了非易失形态的 PostgreSQL 与 Redis 实例。

Expand All @@ -126,45 +127,75 @@ docker compose -f deploy/docker-compose.prod.yml up -d --build
- 当前环境下拒绝 Implicit 以及部分混合模式,强校验 **Authorization Code + PKCE (`S256`)** 协议流。
- 正式环境下回调及回溯域必须通过 `https://` 约束,严防劫持。

### 客户端注册示范
### 客户端初始化与管理

数据库是客户端配置的唯一运行时数据源。`oidc-clients.json` 只在 `oidc_clients` 表为空时进行一次性、事务化导入;只要表中已有任意记录,后续启动不会读取、校验或覆盖该文件。JSON 导入的客户端没有所有者,只有管理员能够维护。

暂未开放动态注册(Dynamic Register)能力,支持对本地 JSON 做增列并随着容器下发。默认路径侦听位置在 `/app/config/oidc-clients.json` 处。
打开 `/manage`,使用校园统一身份认证账号登录即可创建和管理自己的客户端。首次部署管理员可按以下流程配置:

1. 普通登录 `/manage`,在页面顶部复制自己的 `Subject ID`;
2. 将该值加入 `OIDC_ADMIN_SUBJECT_IDS`(多个值以逗号分隔);
3. 重启服务,再次登录后即可看到“全部客户端”和“待审核”。

API 或管理台创建的客户端统一进入 `pending`。Web 客户端的 `client_id` 与高熵 `client_secret` 由服务端生成,Secret 仅在创建响应中显示一次;SPA 是公开客户端,不生成 Secret。客户端类型创建后不可修改;已启用客户端第一轮只允许修改名称和描述,Redirect URI 或 scopes 变更请创建新客户端,避免审核期间中断生产流量。被拒绝客户端修改后进入草稿,需显式重新提交审核;停用后第一轮不能恢复。

普通主体默认最多拥有 10 个非停用客户端,其中最多 5 个处于待审核状态;管理员默认豁免配额。创建操作还按主体(默认每小时 5 次)和来源 IP(默认每小时 20 次)限流。以上值可通过 `OIDC_MANAGEMENT_CLIENT_*` 环境变量调整。

<details>
<summary><code>oidc-clients.json</code> 范例</summary>

```json
{
"clients": [
{
"clientId": "demo-site",
"clientSecretDigest": "scrypt$N=16384,r=8,p=1,keylen=32$<base64url-salt>$<base64url-digest>",
"grantTypes": ["authorization_code", "refresh_token"],
"scopeWhitelist": ["openid", "profile", "email", "student"],
"redirectUris": ["https://demo.example.com/callback"],
"postLogoutRedirectUris": ["https://demo.example.com/logout-complete"],
"autoConsent": false
{
"clientId": "demo-site",
"displayName": "Demo Site",
"description": "首次部署演示客户端",
"clientSecretDigest": "scrypt$N=16384,r=8,p=1,keylen=32$<base64url-salt>$<base64url-digest>",
"grantTypes": ["authorization_code", "refresh_token"],
"scopeWhitelist": ["openid", "profile", "email", "student"],
"redirectUris": ["https://demo.example.com/callback"],
"postLogoutRedirectUris": ["https://demo.example.com/logout-complete"],
"autoConsent": false
}
]
}
```

</details>

`offline_access` 是显式 opt-in scope,不在默认 `scopeWhitelist` 内。`tokenEndpointAuthMethod="none"` 的 public client 默认只允许 `authorization_code`;如确需向 public client 签发 refresh token,必须同时显式配置 `grantTypes` 包含 `refresh_token`、`scopeWhitelist` 包含 `offline_access`,并设置 `allowRefreshTokenForPublicClient: true`。
</details>

`offline_access` 是 Web 客户端的显式 opt-in scope,不在默认 `scopeWhitelist` 内。第一轮 SPA 固定使用 Authorization Code + PKCE,不允许 refresh token 或 `offline_access`。Native、M2M 和包含通配符或 fragment 的 Redirect URI 均不接受。

`student` scope 只增加 `status` claim。当前 `status=active` 表示该账号已通过学校 UIS/CAS 认证且可在本 OP 中使用,不代表“当前在读学生”身份;RP 不应据此推断学籍状态。

### OIDC 核心端点映射表

| 功能区 | 端点 URI | 操作详述 |
| :--- | :--- | :--- |
| **Discovery** | `GET /.well-known/openid-configuration` | 获取服务支持的签名算法与节点映射表。 |
| **Authorize** | `GET /auth` | 重定向登入,允许附带客户端白名单内的 `openid profile email student offline_access` 域。 |
| **Token** | `POST /token` | basic auth/form 模式签发/转结令牌;Public Client 默认不签发 Refresh Token。 |
| **UserInfo** | `GET /userinfo` | 校验 Access 以查询 User 字段。注意 `邮箱` 相关数据仅过审可返回。|
| **Logout** | `GET /session/end` | 注销全域登录状态(应附 `id_token_hint`及回溯)。 |
| **JWKS** | `GET /jwks` | 提供用于客户端对端强验证的 RSA-256 (RS256) 公钥串。 |
| 功能区 | 端点 URI | 操作详述 |
| :------------ | :-------------------------------------- | :-------------------------------------------------------------------------------------- |
| **Discovery** | `GET /.well-known/openid-configuration` | 获取服务支持的签名算法与节点映射表。 |
| **Authorize** | `GET /auth` | 重定向登入,允许附带客户端白名单内的 `openid profile email student offline_access` 域。 |
| **Token** | `POST /token` | basic auth/form 模式签发/转结令牌;Public Client 默认不签发 Refresh Token。 |
| **UserInfo** | `GET /userinfo` | 校验 Access 以查询 User 字段。注意 `邮箱` 相关数据仅过审可返回。 |
| **Logout** | `GET /session/end` | 注销全域登录状态(应附 `id_token_hint`及回溯)。 |
| **JWKS** | `GET /jwks` | 提供用于客户端对端强验证的 RSA-256 (RS256) 公钥串。 |

### 客户端管理 API

管理 API 全部位于 `/api/management`,使用独立的 HttpOnly 数据库会话;所有修改请求还必须携带管理上下文返回的 `X-CSRF-Token`。

| 路由 | 说明 |
| :---------------------------------------------------- | :------------------------------------------------------------- |
| `GET /auth/context` | 获取登录状态、Subject ID、管理员标记和 CSRF token。 |
| `POST /auth/login` / `POST /auth/logout` | 建立或撤销管理会话。 |
| `GET /clients` / `POST /clients` | 查询自己的客户端或创建待审核客户端;管理员可使用 `?view=all`。 |
| `GET /clients/:clientId` / `PATCH /clients/:clientId` | 查看或按 `version` 乐观锁修改客户端。 |
| `POST /clients/:clientId/disable` | 永久停用客户端。 |
| `POST /clients/:clientId/submit` | 将修改后的草稿客户端重新提交审核。 |
| `GET /admin/reviews` | 管理员获取待审核列表。 |
| `POST /admin/reviews/:clientId/approve` | 管理员批准待审核客户端。 |
| `POST /admin/reviews/:clientId/reject` | 管理员拒绝待审核客户端,可附原因。 |

客户端响应不会返回 Secret 摘要。创建 Web 客户端时的 `clientSecret` 只存在于该次 `201` 响应,不可再次查询。

## 🧑‍💻 常用指令 (Scripts)

Expand All @@ -178,7 +209,7 @@ pnpm build

# 服务数据辅助操作
pnpm seed:key # 为 OIDC 补种 RSA 签名池
pnpm seed:client # 将文件上的 Clients 信息下灌进入持久化表
pnpm seed:client # 仅在客户端表为空时导入 JSON;不会覆盖已有记录
```

## 🛡️ 能力边界 (Limitations)
Expand All @@ -198,6 +229,8 @@ pnpm seed:client # 将文件上的 Clients 信息下灌进入持久化表
- 设备层授权流 (Device Auth Flow)
- 隐式流与杂凑流 (Implicit / Hybrid OIDC)
- 复杂的 Pairwise ID 等隐私隔离模型
- Native、M2M、域名验证、使用统计和复杂组织模型
- Secret 重置、双 Secret 轮换或已停用客户端恢复

## 📄 许可证 (License)

Expand Down
12 changes: 11 additions & 1 deletion deploy/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ OIDC_KEY_ENCRYPTION_SECRET=<set-in-deploy-env>
OIDC_ARTIFACT_ENCRYPTION_SECRET=<set-in-deploy-env>
OIDC_COOKIE_KEYS=<set-in-deploy-env>,<set-in-deploy-env>
OIDC_CSRF_SIGNING_SECRET=<set-in-deploy-env>
# 客户端管理管理员 subjectId 白名单,逗号分隔;首次登录后可在 /manage 查看自己的 subjectId。
OIDC_ADMIN_SUBJECT_IDS=
# 每个主体最多 10 个非停用客户端、其中最多 5 个待审核客户端;管理员默认豁免配额。
OIDC_MANAGEMENT_CLIENT_MAX_PER_SUBJECT=10
OIDC_MANAGEMENT_CLIENT_MAX_PENDING_PER_SUBJECT=5
OIDC_MANAGEMENT_CLIENT_QUOTA_ADMIN_EXEMPT=true
# 创建接口同时按主体和来源 IP 限流,窗口单位为秒。
OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_SUBJECT_MAX=5
OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_IP_MAX=20
OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_WINDOW_SECONDS=3600

# ==============================
# 会话、交互与令牌时效(单位:秒)
Expand Down Expand Up @@ -76,7 +86,7 @@ OIDC_ARTIFACT_OPPORTUNISTIC_CLEANUP_BATCH_SIZE=200
OIDC_ARTIFACT_OPPORTUNISTIC_CLEANUP_INTERVAL_SECONDS=30

# ==============================
# OIDC 客户端配置(JSON 文件
# OIDC 客户端首次初始化(数据库非空后不再读取该文件
# ==============================
OIDC_CLIENTS_CONFIG_PATH=/app/config/oidc-clients.json
# 是否在启动时为首次部署自动补种签名密钥。生产默认建议保持 false,手工执行 seed:key。
Expand Down
19 changes: 17 additions & 2 deletions deploy/docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ services:
TRUST_PROXY_HOPS: ${TRUST_PROXY_HOPS:-1}
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7}
OIDC_CLIENTS_CONFIG_PATH: ${OIDC_CLIENTS_CONFIG_PATH:-/app/config/oidc-clients.json}
OIDC_ADMIN_SUBJECT_IDS: ${OIDC_ADMIN_SUBJECT_IDS:-}
OIDC_MANAGEMENT_CLIENT_MAX_PER_SUBJECT: ${OIDC_MANAGEMENT_CLIENT_MAX_PER_SUBJECT:-10}
OIDC_MANAGEMENT_CLIENT_MAX_PENDING_PER_SUBJECT: ${OIDC_MANAGEMENT_CLIENT_MAX_PENDING_PER_SUBJECT:-5}
OIDC_MANAGEMENT_CLIENT_QUOTA_ADMIN_EXEMPT: ${OIDC_MANAGEMENT_CLIENT_QUOTA_ADMIN_EXEMPT:-true}
OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_SUBJECT_MAX: ${OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_SUBJECT_MAX:-5}
OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_IP_MAX: ${OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_IP_MAX:-20}
OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_WINDOW_SECONDS: ${OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_WINDOW_SECONDS:-3600}
OIDC_ARTIFACT_CLEANUP_ENABLED: ${OIDC_ARTIFACT_CLEANUP_ENABLED:-true}
OIDC_ARTIFACT_CLEANUP_CRON: "${OIDC_ARTIFACT_CLEANUP_CRON:-*/5 * * * *}"
ports:
Expand All @@ -28,7 +35,11 @@ services:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3003/health/ready >/dev/null || exit 1"]
test:
[
"CMD-SHELL",
"wget -qO- http://127.0.0.1:3003/health/ready >/dev/null || exit 1",
]
interval: 10s
timeout: 5s
retries: 6
Expand Down Expand Up @@ -71,7 +82,11 @@ services:
- postgres_data:/var/lib/postgresql/data
- ../scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-postgres}"]
test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-postgres}",
]
interval: 10s
timeout: 5s
retries: 6
Expand Down
19 changes: 17 additions & 2 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ services:
OIDC_COOKIE_SECURE: ${OIDC_COOKIE_SECURE:-false}
TRUST_PROXY_HOPS: ${TRUST_PROXY_HOPS:-0}
OIDC_CLIENTS_CONFIG_PATH: ${OIDC_CLIENTS_CONFIG_PATH:-/app/config/oidc-clients.json}
OIDC_ADMIN_SUBJECT_IDS: ${OIDC_ADMIN_SUBJECT_IDS:-}
OIDC_MANAGEMENT_CLIENT_MAX_PER_SUBJECT: ${OIDC_MANAGEMENT_CLIENT_MAX_PER_SUBJECT:-10}
OIDC_MANAGEMENT_CLIENT_MAX_PENDING_PER_SUBJECT: ${OIDC_MANAGEMENT_CLIENT_MAX_PENDING_PER_SUBJECT:-5}
OIDC_MANAGEMENT_CLIENT_QUOTA_ADMIN_EXEMPT: ${OIDC_MANAGEMENT_CLIENT_QUOTA_ADMIN_EXEMPT:-true}
OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_SUBJECT_MAX: ${OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_SUBJECT_MAX:-5}
OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_IP_MAX: ${OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_IP_MAX:-20}
OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_WINDOW_SECONDS: ${OIDC_MANAGEMENT_CLIENT_CREATE_RATE_LIMIT_WINDOW_SECONDS:-3600}
OIDC_ARTIFACT_CLEANUP_ENABLED: ${OIDC_ARTIFACT_CLEANUP_ENABLED:-true}
OIDC_ARTIFACT_CLEANUP_CRON: "${OIDC_ARTIFACT_CLEANUP_CRON:-*/5 * * * *}"
ports:
Expand All @@ -27,7 +34,11 @@ services:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3003/health/ready >/dev/null || exit 1"]
test:
[
"CMD-SHELL",
"wget -qO- http://127.0.0.1:3003/health/ready >/dev/null || exit 1",
]
interval: 10s
timeout: 5s
retries: 6
Expand Down Expand Up @@ -70,7 +81,11 @@ services:
- postgres_data:/var/lib/postgresql/data
- ../scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-postgres}"]
test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-postgres}",
]
interval: 10s
timeout: 5s
retries: 6
Expand Down
22 changes: 6 additions & 16 deletions deploy/oidc-clients.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,13 @@
"clients": [
{
"clientId": "demo-site",
"displayName": "Demo Site",
"description": "首次部署演示客户端",
"clientSecretDigest": "scrypt$N=16384,r=8,p=1,keylen=32$<base64url-salt>$<base64url-digest>",
"grantTypes": [
"authorization_code",
"refresh_token"
],
"scopeWhitelist": [
"openid",
"profile",
"email",
"student"
],
"redirectUris": [
"https://demo.xxx.com/callback"
],
"postLogoutRedirectUris": [
"https://demo.xxx.com/logout-complete"
],
"grantTypes": ["authorization_code", "refresh_token"],
"scopeWhitelist": ["openid", "profile", "email", "student"],
"redirectUris": ["https://demo.xxx.com/callback"],
"postLogoutRedirectUris": ["https://demo.xxx.com/logout-complete"],
"autoConsent": false
}
]
Expand Down
Loading
Loading