Files
smart-go/.cursor/plans/oauth2_pkce_单体设计_62bcf65b.plan.md
T
2026-04-23 18:58:13 +08:00

233 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 场景)。