233 lines
18 KiB
Markdown
233 lines
18 KiB
Markdown
---
|
||
name: OAuth2 PKCE 单体设计
|
||
overview: 在单体部署中同时承担 AS 与 RS:逻辑分层 + **opaque access token**(随机串)存 PostgreSQL/Redis,RS 中间件按 token 查元数据完成校验;可选暴露 RFC 7662 introspection 供未来独立资源服务。前后端分离下后端不渲染 HTML,仅 JSON 登录与 302;OAuth 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/token(authorization_code + S256 PKCE + state)
|
||
status: completed
|
||
- id: opaque-token-mw
|
||
content: 签发 opaque access token(高熵随机串,元数据存库)、Gin Bearer 中间件内联查库/Redis 解析用户与 scope;可选 POST /oauth/introspect(RFC 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 或 Redis(Redis 更适合极短 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 场景)。
|