feat: 优化web

This commit is contained in:
2026-04-23 18:58:13 +08:00
commit 544a2f3428
160 changed files with 27327 additions and 0 deletions
@@ -0,0 +1,232 @@
---
name: OAuth2 PKCE 单体设计
overview: 在单体部署中同时承担 AS 与 RS:逻辑分层 + **opaque access token**(随机串)存 PostgreSQL/RedisRS 中间件按 token 查元数据完成校验;可选暴露 RFC 7662 introspection 供未来独立资源服务。前后端分离下后端不渲染 HTML,仅 JSON 登录与 302OAuth 2.1 + 强制 PKCE,与现有 IAM 对齐。
todos:
- id: schema-oauth
content: 设计并迁移 oauth_client、opaque access_token / refresh_token 元数据表(或 Redis)、authorization code 存储策略
status: completed
- id: session-login
content: JSON 登录 API + HttpOnly Cookie 与 Redis 会话;未登录访问 /oauth/authorize 时 302 到配置的前端 URL(携带完整回跳 authorize 链接或 request_id),无后端 HTML
status: completed
- id: auth-code-pkce
content: 实现 /oauth/authorize 与 /oauth/tokenauthorization_code + S256 PKCE + state
status: completed
- id: opaque-token-mw
content: 签发 opaque access token(高熵随机串,元数据存库)、Gin Bearer 中间件内联查库/Redis 解析用户与 scope;可选 POST /oauth/introspectRFC 7662
status: completed
- id: refresh-scope
content: refresh_token 轮换、scope 与租户/权限映射、限流与审计
status: completed
isProject: false
---
# OAuth2 Authorization Code + PKCE(单体 AS+RS)设计
## 可行性结论
**可以这样做,而且是合理选择。** 单体同时作为 AS 与 RS 并不意味着违反 OAuth 模型:OAuth 描述的是**角色与端点**,不要求物理上分属不同进程。同一服务内:
- **授权服务器**:提供 `/oauth/authorize``/oauth/token`(以及可选的授权服务器元数据、**令牌自省**、撤销端点)。
- **资源服务器**:现有 [`/api/v1/...`](internal/server/http.go) 等业务 API,通过 `Authorization: Bearer` 校验访问令牌。
部署形态为**单体**时,AS 签发 **opaque** 令牌并写入存储,RS 与 AS **同进程**:中间件直接 **查库/Redis** 解析 `sub`(用户 id)、租户、`scope``exp`,**无需** JWT 验签,也**不必**为 RS 单独 HTTP 调 introspection(除非你愿意统一走自省接口以保持代码路径单一)。若未来 RS 独立部署,再启用 **RFC 7662 introspection** 或共享 Redis/DB 读副本。
**选型说明(已定)****Access token 采用 opaque**——撤销可即时生效(删行或标 `revoked`)、不暴露声明给客户端解析;代价是每次 API 请求多一次存储读取(可用 Redis 缓存 + 短 TTL 与 DB 一致)。
与仓库内 [docs/oauth-v2.md](docs/oauth-v2.md)(OAuth 2.1 草案)对齐的要点:
- **Authorization Code** 为主流程;**PKCE**`code_challenge` / `code_verifier`,推荐 S256)对各类客户端(含机密客户端)可防范授权码注入,草案要求除例外情况外**强制使用**(见文档中 4.1 与 7.5 节相关段落)。
- **Redirect URI** 须严格字符串匹配(2.1 比 2.0 更严)。
- **Implicit** 等 grant 在 2.1 中不再规定,**不要**实现隐式授权。
- 生产环境 **HTTPS**、**短期 access token**、**scope/audience 约束**为最佳实践。
---
## 前后端分离约束(后端不返回 HTML)
本方案与「后端只提供 API」**兼容**:OAuth 规范里授权端点本来就是 **HTTP 重定向** 与浏览器导航,不要求授权服务器返回登录表单 HTML。
- **登录页、同意页**:由**前端 SPA** 渲染(独立域名或 `/login` 路由均可)。
- **后端**提供:`POST /api/v1/.../login`(或 `auth/login`)等 **JSON** 接口校验 [`iam_user`](internal/iam/entity/user.go) 凭据,成功后通过 **`Set-Cookie`**HttpOnly、Secure、`SameSite` 按跨站需求选择)建立会话,**响应体不写 HTML**。
- **`GET /oauth/authorize`**:仅返回 **302**(已登录则带 `code` 重定向到 client;未登录则重定向到**配置的前端登录 URL**),或 **302 到错误 redirect_uri**(OAuth 标准错误响应)。禁止用 HTML 表单作为唯一交互方式。
**未登录时的典型串联**(浏览器内):
1. 用户浏览器访问 AS 的 `/oauth/authorize?...`(含 PKCE、`state`)。
2. AS 发现无会话:**302** 到 `FRONTEND_LOGIN_URL`,并在 query 中携带「回到 authorize 所需信息」——常见两种做法(二选一或组合):
- **回跳链接**`next` / `return_to` = `urlencode(完整 authorize URL)`,前端登录成功后执行 `location.href = decode(next)` 再次命中 authorize(此时带 Cookie)。
- **短期票据**`login_challenge` / `request_id` 指向 Redis 中暂存的 authorize 查询参数,前端登录成功后调用后端「完成登录并继续授权」或仍用回跳 authorize URL(实现简单优先前者需额外端点)。
3. 用户在 SPA 输入账号密码 → **`POST` JSON 登录** → Cookie 写入。
4. 浏览器再次 **`GET` 同一 `/oauth/authorize?...`**(PKCE 参数必须与首次一致,故回跳必须带齐原始 query),AS 有会话则签发 `code`**302** 到第三方/首方应用的 `redirect_uri`
**跨域 Cookie**:若 API 与 SPA **不同站点**,需 `SameSite=None; Secure` 且 CORS 对登录与 authorize 请求配置 `credentials`,并在配置中固定 `Cookie``Domain`/`Path`;若同站不同路径则可用 `Lax`。此项在 [`pkg/config`](pkg/config) 中显式配置并写入计划实施阶段。
**同意(consent**:若需要显式授权,同样 **302 到前端同意页**(带 `consent_challenge` 或回跳 authorize),用户提交后由前端再导航回 authorize 或调用完成端点——**仍无后端 HTML**。
---
## 目标架构(逻辑视图)
```mermaid
flowchart LR
subgraph client [OAuth客户端]
Browser[浏览器/SPA]
Native[原生应用]
end
subgraph monolith [单体后端]
AuthZ["/oauth/authorize"]
LoginAPI["POST JSON 登录"]
Token["/oauth/token"]
RS["/api/v1 资源API"]
Intro["可选 /oauth/introspect"]
end
subgraph spa [前端应用]
LoginUI[登录页UI]
end
subgraph store [持久化]
PG[(PostgreSQL)]
Redis[(Redis可选)]
end
Browser --> AuthZ
AuthZ -->|未登录302| LoginUI
LoginUI --> LoginAPI
LoginAPI -->|Set-Cookie| Browser
Browser --> AuthZ
AuthZ -->|302 code| Browser
Browser --> Token
Token -->|opaque| Browser
Browser --> RS
RS -->|查 token 元数据| PG
RS -.->|独立 RS 时| Intro
Token --> PG
LoginAPI --> Redis
```
---
## 核心组件设计
### 1. OAuth 客户端注册(Client Registry
需要能校验 `client_id`、允许的 **redirect_uri 列表**、客户端类型(public / confidential)。
- **首方应用**(自有前端):可用**配置 + 种子数据**或表 `oauth_client``client_id`、哈希后的 `client_secret`(仅 confidential)、`redirect_uris[]``grant_types``token_endpoint_auth_method`)。
- **第三方**:同样走表结构,由管理 API 维护(可后续迭代)。
OAuth 2.1 对 **public client** 不在 token 端点使用 secret**confidential client** 使用 `client_secret`(或 `private_key_jwt`,可二期)。
### 2. 授权端点 `GET /oauth/authorize`
**查询参数**RFC/2.1):`response_type=code``client_id``redirect_uri``scope``state`(**强烈建议必填**,防 CSRF)、`code_challenge``code_challenge_method=S256`
**服务端行为**
1. 校验 client、redirect_uri、PKCE 参数完整且 method 支持。
2. 若用户**未登录**:**302** 到配置项 **`FRONTEND_LOGIN_URL`**(或等价路径),并携带**完整回跳**到本端点所需的参数(见上文「前后端分离约束」:`next`/`return_to``request_id`)。**不返回 HTML**。登录成功依赖后续 **`POST` JSON 登录** + **HttpOnly Cookie** + **Redis 会话**(与 [`internal/server/http.go`](internal/server/http.go) 中间件顺序一致)。
3. 已登录用户:若需同意且未静默授权:**302** 到 **`FRONTEND_CONSENT_URL`**(同样仅重定向,无 HTML);首方可配置静默同意跳过。
4. 生成 **authorization code**(一次性、短 TTL,如 60–120s),**绑定**`client_id``redirect_uri``code_challenge`/`method`、**资源所有者 id**(即 [`iam_user`](internal/iam/entity/user.go) 主键)、**租户**`tenant_id`)、请求的 `scope`。存 PostgreSQL 或 RedisRedis 更适合极短 TTL)。
5. 302 到 `redirect_uri?code=...&state=...`
### 3. 令牌端点 `POST /oauth/token`
支持:
- `grant_type=authorization_code``code``redirect_uri``client_id`、**`code_verifier`**PKCE);confidential 客户端另加 `client_secret` 或 HTTP Basic。
- `grant_type=refresh_token`**refresh token 轮换**(发新废旧,旧 token 入库标记撤销)推荐。
**校验**
- 校验 code 未使用、未过期;`code_verifier` 与当时存的 `code_challenge` 按 S256 比对(与 [docs/oauth-v2.md](docs/oauth-v2.md) 一致)。
- 颁发 **access_token****opaque**,高熵随机串)与 **refresh_token**(opaque);**存库字段建议**:`token` 或仅存 **哈希**(防 DB 泄露即泄露明文)、`user_id``tenant_id``client_id``scope``expires_at``revoked_at` 等。
错误响应遵循 OAuth 2.1 的 `application/json` 错误体约定。
### 4. Opaque Access Token 与资源服务器校验(默认方案)
**Access token** 为不可解析的随机串(opaque),**不**使用 JWT 自包含声明。
**存储与撤销**:表(或 Redis)保存令牌元数据;撤销时删除或标记 `revoked`**立即生效**。
**RS 中间件**(挂在 [`apiGroup`](internal/server/http.go) 上需保护的子树):
-`Authorization: Bearer` 取出 token**查存储**(优先 Redis 缓存命中,miss 回源 PG)得到 `user_id``tenant_id``scope``exp`
- 校验未过期、未撤销;按需校验 `scope`
-`user_id``tenant_id``scope` 写入 `gin.Context`,替代当前仅靠 [`X-User-ID` / `X-Tenant-ID`](internal/iam/handler/helpers.go) 的占位方式;对内网遗留 header 仅过渡且需防伪造。
**可选 `POST /oauth/introspect`**[RFC 7662](https://www.rfc-editor.org/rfc/rfc7662)):与上述查表语义一致,供**未来独立 RS** 或统一审计;单体同进程内可直接复用同一套存储访问逻辑,不必强制每次 API 都走 HTTP 自省。
**与 JWT 对比**:本方案不暴露 `/.well-known/jwks.json`(除非日后为 **id_token** 引入 JWT);若将来要支持纯离线验签再另议。
### 5. Scope 与现有 IAM 的映射
- 最小集:`openid`(若上 OIDC)、`profile``email`(按需,OIDC 层再扩展)。
- API 资源:`api` 或细分 `user.read``admin` 等,与 [`iam_menu.perms`](migrations/postgres/001_iam.sql) / 角色体系映射:opaque 行内可存 **scope 字符串**;细粒度权限可在中间件加载后按 `user_id`+`tenant_id` **再查**角色/菜单(与「快照进 token」二选一,避免存储行过大)。
### 6. 安全与运维要点
- **HTTPS** 强制(开发可用本地证书或反向代理)。
- **Redirect URI** 精确匹配;禁止 open redirect。
- **Authorization code** 单次使用;建议**绑定 PKCE**(已实现则满足 2.1 核心要求)。
- **Refresh token**:哈希存储、轮换、可撤销。
- **限流**`/oauth/token`、登录接口按 IP/client 限流。
- **审计**:登录、发 token、撤销记日志。
---
## 与现有代码库的衔接点
| 区域 | 作用 |
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| [`internal/server/http.go`](internal/server/http.go) | 注册 `/oauth/*`、可选 `/.well-known/oauth-authorization-server``/oauth/introspect`,并为 `/api/v1` 挂 Bearer 中间件 |
| [`internal/iam`](internal/iam) | 复用用户校验(密码可用 [`pkg/utils/codec`](pkg/utils/codec/bcypt.go))、租户与用户查询 |
| [`migrations/postgres`](migrations/postgres) | `oauth_client``oauth_access_token`(或统一 `oauth_token`)、`oauth_refresh_token`、authorization code 表或 Redis |
| [`pkg/config`](pkg/config) | issuer URL、access/refresh TTL、`FRONTEND_LOGIN_URL` / `FRONTEND_CONSENT_URL`、跨域 Cookie 与 CORS |
依赖:OAuth 协议可自研或选用 **Fosite** / **go-oauth2**(需核对 opaque + 2.1 PKCE);**JWT 库仅在** 日后 OIDC `id_token` 时再引入。
---
## 建议代码落点(包结构)
与现有 [`internal/iam`](internal/iam)(租户/用户 CRUD)**并列**,新增独立边界 **`internal/auth`**:作为**各类认证方式的统一挂载点**(OAuth2、日后 SAML、LDAP、API Key 等),避免与 IAM 领域模型混包。**首期 OAuth2 Code + PKCE** 放在 **`internal/auth/oauth2/`** 子树(Go 子包 `oauth2`),与「账号密码会话」等可并列拆分,互不污染。
| 路径 | 内容 |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **`internal/auth/oauth2/handler`** | `authorize``token``introspect`、可选 `revocation` |
| **`internal/auth/oauth2/service`** | 授权码、PKCE、opaque 签发/校验、refresh 轮换 |
| **`internal/auth/oauth2/repository`** | `oauth_client`、token、authorization code 持久化 |
| **`internal/auth/oauth2/entity`** | OAuth 表对应模型(关联 `iam_user`,用户主数据仍在 IAM) |
| **`internal/auth/session/`**(可选子包) | JSON 登录、Cookie/Redis 会话(供 authorize 识别用户;命名避免与 `net/http` 概念混淆时可叫 `websession`) |
| **`internal/auth/middleware`** 或 **`internal/server`** | **Bearer 查库中间件**(协议无关:只要是 opaque 就查存储);或放 `internal/server/auth.go` |
| **`internal/auth/http_register.go`** | 聚合注册:实现 [`HttpRoutes`](internal/server/http.go),内部委托 `oauth2` 等子路由(与 [`internal/iam/http_register.go`](internal/iam/http_register.go) 同级注入) |
| **`internal/auth/wire_provider.go`** | `ProviderSet`,在 [`cmd/server/wire.go`](cmd/server/wire.go) 注入 |
**命名说明**:顶层用 **`auth`** 而非 **`oauth`**,便于扩展;**HTTP 路径**仍可保持标准 **`/oauth/authorize`**、**`/oauth/token`**(协议规定,与包名无关)。
**JSON 登录**:建议 **`internal/auth/session`**(或 `handler/login.go``auth` 根下),与 **`internal/auth/oauth2`** 并列;**密码校验**复用 [`internal/iam/service`](internal/iam/service) + [`pkg/utils/codec`](pkg/utils/codec/bcypt.go)。
**配置**[`pkg/config`](pkg/config) 增加 `Auth` 段(内含 `OAuth2``Session``CORS` 等子段)。
**迁移**[`migrations/postgres`](migrations/postgres) 新文件如 `005_oauth.sql`(编号按仓库顺延)。
**入口组装**[`internal/server/http.go`](internal/server/http.go) 的 `NewHttpRouteRegistrars` 增加 **`authRoutes`**;全局 Bearer 中间件在 `NewHttpEngine` 或路由组上按需挂载。
**纯工具**PKCE SHA256、高熵随机串可放 **`pkg/security/token`**(或 `pkg/authutil`),避免 `internal/auth` 根包臃肿。
---
## 可选扩展(非首期必选)
- **OpenID Connect**:在 OAuth2 之上增加 `openid` scope、`/oauth/userinfo``id_token` 一般为 **JWT**(与 opaque **access_token** 并存不冲突)。
- **RFC 7662**:独立资源服务时 RS 调 introspection;单体已用查表则可选暴露端点以保持对外契约一致。
- **RP-Initiated Logout**:前后端分离时的统一登出。
---
## 实施阶段建议
1. **数据模型 + 配置**:客户端表、**opaque token 表/索引**(按 token 哈希查找)、TTL、**前端回跳 URL**、迁移脚本。
2. **JSON 登录 + 会话**`POST` 登录接口、HttpOnly Cookie + Redis,供 `authorize` 识别用户;与前端约定 `next`/`return_to` 回跳完整 authorize URL。
3. **authorize + token + PKCE**:浏览器串联「authorize → 302 前端登录 → 再 authorize」;端到端打通 Code 换 **opaque** Token。
4. **Bearer 中间件(查库/缓存)**:保护 `/api/v1`,上下文注入 `user_id`/`tenant_id`;可选 `introspect` 端点。
5. **Refresh 轮换与撤销**、scope 与 IAM 权限对齐、集成测试(含跨域 Cookie 场景)。