diff --git a/apps/api/.env b/apps/api/.env index b0e64db..2317884 100644 --- a/apps/api/.env +++ b/apps/api/.env @@ -8,6 +8,7 @@ JWT_EXPIRES_IN="7d" PORT=4000 NODE_ENV=development +FRONTEND_URL=http://localhost:3000 # ----- 加密配置 ----- # 是否启用通信加密 @@ -38,3 +39,8 @@ MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin MINIO_BUCKET=seclusion MINIO_PUBLIC_URL= + +# ----- OIDC Provider 配置 ----- +OIDC_ISSUER=http://localhost:4000/oidc +OIDC_COOKIE_SECRET=oidc-cookie-secret-change-in-production +OIDC_JWKS_PRIVATE_KEY=LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRRFhGYkJ5cDVKc2Y1UWsKS213UnhObkxOMHE2Rzk1djZjQ1ZENW5EZmpzeDE0cDZpS2NRaG5jZU9JMmF2bDdGUUJGZzcyN1QvMlFqRExYegpHd05rcUhpcmo0ZlNHeERzN2x3c1RqSkRNUE51akF0NnlIMVVMdDBrWUxJL2pwRlhUUWNKdTFKQlc5OWZwTnU1CitoQkZpMHd4cnljQmRpWjlEeVg2K0I2YjhzRDRlRzdZbXJOSzlhd3NuQjVmQTUxUG4rWHRrNUM1YktvRjI4N0sKb1owRGxPZU1jY003S0I4elc4L1ZyZnE4Mk9pNDd3T3ZScmIwQ2I5aG5Ia0dUQWhyT0xzNjE5R1oyM1FnZzg1cQpVL2FXMGZwSG56WHpETWV5ak5scXJoRlgvb1hQak5EaHMvUGE2MHozZWxtS1dOZ1ByQ0dtb08ySnd6NDlmZkVFCjBDU2xNTzZiQWdNQkFBRUNnZ0VBRmQzL3BYaUIrNFB4Qk1oSFduc2dCWGdtb2N0Smp5azl5aW5lNFRCSlJtVDYKa0VDcWM1U29NYXRnUWpaT25sRklNd25FdzhyNFhGUGpmOGJrVG15T2NDclVqVGp4UEpWelM1SGJyRmNpdUwrRwpQMEo0ODRFY1BLR1VIY0FaNkwxTkZPRTFtSzJGaFV6V2hnNzFib3lkLzRNbVBSRE5FdlBpVWFTK1ArNnJUZGVJCi9UbVdwTnVic25ockJ0RE1pSDZ2RDBSMEJBK1NzTkI2Nkw5elI2UUVqSXhCOW12aGpJaXJyR3hkREprcVRldFEKRnVZZUNMVVpSMXVYU1dYdXFzSWZQSmNpbjJtblVpdUhFa3Z0bUVlaG9mL1g1ZVJNYWsvTmc0UmtUWHF5Y2FudwpDN3NOdUFmai9rNU51QTlWazJVVi9qanVmUWowSktKb2drQUhWVGRtMlFLQmdRRHVZZGpkeXNkSjlkTXJJN3BFCnRZNTJPaU0vaWkyQ2dsanZvTXZFejljTEFiOEVNbVpiRlUrTmhWRUVVbDBkK3dJYTNmUFFBKzRVZ2t6U09oUHcKT1E1NVk0SUp5Rm9yOFdLZ0hBcUI4ZHo3dnlTZWVSNmZUM1R5TFdSN3RJKzV6ZVZEQU1pNFEwUnRKRDFMeFVzMApBQnlzK0lIc2hmYUd2N1NxN1lUMytsMkZGd0tCZ1FEbSt3NnVhTVhsTUVtUW9remVFTWYwTGNwVHMxcTJPN3ZtCk9XSFB5TTNnTlY5UWVIUUF1dC9mNmU1VWVPdnliWHg5SjAxcEU3Z29RdWRhRWpiSkl4eXgyWXlaYkVzS28rV3kKR05mWk9RL1F5YWxHL29PYXFxQUpPdFVGa0dvUzlOaDV1R0Y1eXY5YmRQRU9TeTN0dVhpNzVxNE9sQ3RYVnM1WApBbWtMTVd6ZEhRS0JnQllsM3lsVU1zbnJYaEJQQkhwbngvR3lHeDVITDAxRjRROTZpQlFrSDEyMWJ0THIvOWlNCmxWU1h3MXc4YnN4ZlN1WEdJMlg3UjM1K1VMYmprSUNzUEcwSTBzY241MERYNzRyaXNCTThyb1J4VU95c1lpejUKQyt1SVRpSzBOdnBUWis2ZXZ3ZG5zSTdYWkI2TEdSNmV1QXRXRjNRclNpbGczRjlaTEJhQ0czaEhBb0dBR3VRegp5MTVyVzhtSlp3dGVRNlJVZ3pzcGlTRWllSUR2MlZmbzZWWUprZ2JrdCt1dUpiK2IvT2V4VmFoV1gvMGJOejd5CkpqK2pleHgrN3QrYi9VTFhQbVdEbHdFaW8zUjljNFNzN0o5V0ZnckVhSDJOT042UWowS0lOb09mdGVGSHFyUXEKdFJGTE5ZeWgyL1lvdkxxUk1kOGplSk1Ma0xtTWdGakpmZ0lkR0lrQ2dZRUFoL3BMRlVnNFNueGRxWm51bG13cQpVaEgvcVY0ck9zY1hRVkJidHArOTNSWVJ6THQ2c0VRT2NNWExLVGtEcU5UZzBLamdub2wyM2NJbnVSSWNRcE9rClcvTUlqZEsrZnEvaXJUZnJ4bzM3cUhRNXo4cFdtOWY3L29TeHIvS1RlaWFTYkFrZ0ptSVdQeCt5bnhvQmZHV00Kcnp5dmlvUjV4Uk10YTBCaS8rTkxtM0U9Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K diff --git a/docs/backend/oidc-provider.md b/docs/backend/oidc-provider.md index 8fdbdc7..5a1e8d8 100644 --- a/docs/backend/oidc-provider.md +++ b/docs/backend/oidc-provider.md @@ -1,11 +1,90 @@ -# OIDC Provider 实施方案 +# OIDC Provider 实施文档 -本文档描述如何将系统扩展为 OIDC Provider,对外提供中央鉴权业务。 +本文档描述系统作为 OIDC Provider 对外提供中央鉴权业务的实现方案。 + +> **实施状态**:✅ 已完成(2025年1月) ## 一、方案概述 基于 [panva/node-oidc-provider](https://github.com/panva/node-oidc-provider) 库实现 OIDC Provider 功能,该库是 OpenID Certified 的成熟实现,支持完整的 OAuth 2.0 和 OpenID Connect 规范。 +### 1.1 核心特性 + +- **混合存储策略**:PostgreSQL 存储持久化数据,Redis 存储短期/会话数据 +- **第一方应用自动授权**:配置 `isFirstParty: true` 的客户端自动跳过授权确认页 +- **完整的客户端管理**:支持创建、编辑、删除客户端,以及密钥重新生成 +- **标准 OIDC 端点**:支持授权、令牌、用户信息、JWKS、令牌撤销等标准端点 + +### 1.2 快速开始 + +**1. 环境配置** + +确保 `.env` 文件包含以下配置: + +```bash +OIDC_ISSUER=http://localhost:4000/oidc +OIDC_COOKIE_SECRET=your-oidc-cookie-secret +OIDC_JWKS_PRIVATE_KEY= +``` + +生成 JWKS 私钥: +```bash +openssl genrsa 2048 | base64 -w 0 +``` + +**2. 创建客户端** + +通过管理界面(/oidc-clients)或 API 创建 OIDC 客户端: + +```bash +curl -X POST http://localhost:4000/oidc-clients \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "clientName": "我的应用", + "redirectUris": ["http://localhost:3001/callback"], + "grantTypes": ["authorization_code", "refresh_token"], + "scopes": ["openid", "profile", "email"], + "isFirstParty": false + }' +``` + +响应会包含 `clientId` 和 `clientSecret`(仅显示一次)。 + +**3. 发起授权** + +引导用户访问授权端点: + +``` +http://localhost:4000/oidc/authorize + ?client_id= + &redirect_uri=http://localhost:3001/callback + &response_type=code + &scope=openid profile email + &state= +``` + +**4. 获取令牌** + +用户授权后,使用授权码换取令牌: + +```bash +curl -X POST http://localhost:4000/oidc/token \ + -u ":" \ + -d "grant_type=authorization_code" \ + -d "code=" \ + -d "redirect_uri=http://localhost:3001/callback" +``` + +**5. 获取用户信息** + +使用 Access Token 获取用户信息: + +```bash +curl http://localhost:4000/oidc/userinfo \ + -H "Authorization: Bearer " +``` + ## 二、数据存储设计 ### 2.1 存储策略 @@ -131,21 +210,21 @@ model User { ``` apps/api/src/oidc/ ├── oidc.module.ts # OIDC 模块定义 -├── oidc.controller.ts # OIDC 端点控制器 -├── oidc.service.ts # OIDC 核心服务 +├── oidc.controller.ts # 交互端点控制器(/oidc-interaction) +├── oidc.service.ts # OIDC 核心服务(Provider 初始化与管理) ├── adapters/ +│ ├── index.ts # 混合适配器工厂 │ ├── prisma.adapter.ts # Prisma 存储适配器(Client、Grant、RefreshToken) │ └── redis.adapter.ts # Redis 存储适配器(AuthorizationCode、AccessToken、Session、Interaction) +├── controllers/ +│ └── client.controller.ts # 客户端管理控制器(/oidc-clients) ├── services/ -│ ├── account.service.ts # 账户查找服务 -│ ├── client.service.ts # 客户端管理服务 -│ └── interaction.service.ts # 交互处理服务 +│ ├── account.service.ts # 账户查找服务(findAccount 实现) +│ ├── client.service.ts # 客户端管理服务(CRUD + 密钥生成) +│ └── interaction.service.ts # 交互处理服务(登录、授权确认) ├── dto/ -│ ├── client.dto.ts # 客户端 DTO -│ ├── consent.dto.ts # 授权确认 DTO -│ └── interaction.dto.ts # 交互 DTO -├── guards/ -│ └── oidc-interaction.guard.ts # 交互会话守卫 +│ ├── client.dto.ts # 客户端 DTO(创建、更新、响应) +│ └── interaction.dto.ts # 交互 DTO(登录、授权确认) └── config/ └── oidc.config.ts # OIDC Provider 配置 ``` @@ -326,7 +405,41 @@ export class OidcAccountService { } ``` -#### 3.2.5 OIDC 配置 (`config/oidc.config.ts`) +#### 3.2.5 客户端服务 (`services/client.service.ts`) + +客户端 ID 使用 cuid2 生成,客户端密钥使用 64 字符随机十六进制: + +```typescript +import { randomBytes } from 'crypto'; +import { Injectable } from '@nestjs/common'; +import { createId } from '@paralleldrive/cuid2'; + +@Injectable() +export class OidcClientService extends CrudService<...> { + // 生成客户端 ID(使用 cuid2,格式如 clxxx...) + private generateClientId(): string { + return createId(); + } + + // 生成客户端密钥(64 字符随机十六进制,无前缀) + private generateClientSecret(): string { + return randomBytes(32).toString('hex'); + } + + async createClient(dto: CreateOidcClientDto) { + const clientId = this.generateClientId(); + const clientSecret = this.generateClientSecret(); + // ...创建客户端逻辑 + } + + async regenerateSecret(id: string) { + const clientSecret = this.generateClientSecret(); + // ...更新密钥逻辑 + } +} +``` + +#### 3.2.6 OIDC 配置 (`config/oidc.config.ts`) ```typescript import type { Configuration } from 'oidc-provider'; @@ -408,7 +521,7 @@ export function createOidcConfiguration( } ``` -#### 3.2.6 OIDC 控制器 (`oidc.controller.ts`) +#### 3.2.7 OIDC 控制器 (`oidc.controller.ts`) ```typescript import { All, Controller, Get, Post, Req, Res, UseGuards } from '@nestjs/common'; @@ -467,7 +580,7 @@ export class OidcController { } ``` -#### 3.2.7 客户端管理控制器 (`client.controller.ts`) +#### 3.2.8 客户端管理控制器 (`client.controller.ts`) ```typescript @ApiTags('OIDC 客户端管理') @@ -909,7 +1022,7 @@ OIDC_JWKS_PRIVATE_KEY= - 支持 private_key_jwt 高安全认证方式 4. **授权确认**: - 第三方应用必须经过用户授权确认 - - 第一方应用可配置跳过确认 + - **第一方应用自动授权**(见下文详细说明) - 记住用户授权决定,避免重复确认 5. **令牌撤销**: - 支持主动撤销 Access Token 和 Refresh Token @@ -918,34 +1031,138 @@ OIDC_JWKS_PRIVATE_KEY= - Token 端点需要支持跨域请求 - 配置允许的 Origin 列表 +### 8.1 第一方应用自动授权实现 + +第一方应用(`isFirstParty: true`)在两种场景下自动跳过授权确认页: + +**场景 1:登录时自动授权** + +用户在 OIDC 登录页面输入凭据后,`finishLogin` 方法检测第一方应用并自动完成授权: + +```typescript +// interaction.service.ts +async finishLogin(provider, ctx, accountId, options) { + // 检查是否为第一方应用 + const client = await provider.Client.find(options.clientId); + const isFirstParty = client?.['urn:custom:first_party']; + + if (isFirstParty) { + // 第一方应用:登录后自动完成授权 + const grant = new provider.Grant({ accountId, clientId: options.clientId }); + options.scopes.forEach(scope => grant.addOIDCScope(scope)); + const grantId = await grant.save(); + + return provider.interactionFinished(ctx.req, ctx.res, { + login: { accountId }, + consent: { grantId }, + }, { mergeWithLastSubmission: true }); + } + + // 第三方应用:仅完成登录,等待授权确认 + return provider.interactionFinished(ctx.req, ctx.res, { + login: { accountId }, + }, { mergeWithLastSubmission: false }); +} +``` + +**场景 2:已登录用户访问时自动授权** + +用户已在系统登录,访问第一方应用的授权 URL 时,`getInteraction` 接口检测并自动完成授权: + +```typescript +// oidc.controller.ts +async getInteraction(req, res, uid) { + const details = await this.oidcService.interactionDetails(req, res); + const client = await provider.Client.find(details.params.client_id); + + // 检查:consent prompt + 已登录 + 第一方应用 + if ( + details.prompt.name === 'consent' && + details.session?.accountId && + client?.['urn:custom:first_party'] + ) { + const scopes = details.params.scope.split(' '); + const redirectTo = await this.interactionService.finishConsent(...); + + // 返回自动授权标记,前端直接重定向 + return res.json({ autoConsent: true, redirectTo }); + } + + // 返回交互详情,前端渲染登录或授权页面 + return res.json({ uid, prompt, params, client, session }); +} +``` + +**前端处理自动授权**: + +```tsx +// page.tsx +const details = await getInteractionDetails(uid); + +// 第一方应用自动授权,直接重定向 +if (details.autoConsent && details.redirectTo) { + redirect(details.redirectTo); +} +``` + +### 8.2 Grant Scope 存储 + +oidc-provider 的 Grant payload 中,scope 存储在嵌套结构中: + +```typescript +// prisma.adapter.ts - Grant 模型存储逻辑 +case 'Grant': { + // payload 结构: { openid: { scope: "openid profile email" } } + const openid = payload.openid as { scope?: string } | undefined; + const scopeString = openid?.scope || ''; + + await this.prisma.oidcGrant.upsert({ + where: { grantId: id }, + update: { data: payload, scope: scopeString, expiresAt }, + create: { + grantId: id, + clientId: payload.clientId, + userId: payload.accountId, + scope: scopeString, + data: payload, + expiresAt, + }, + }); +} +``` + ## 九、实施步骤 -### 阶段一:基础设施(1-2天) +> **状态**:全部阶段已完成 ✅ -1. 添加 Prisma 数据模型 -2. 实现 Prisma 适配器 -3. 配置 oidc-provider -4. 集成到 NestJS 应用 +### 阶段一:基础设施 ✅ -### 阶段二:核心功能(2-3天) +1. ✅ 添加 Prisma 数据模型(OidcClient、OidcGrant、OidcRefreshToken) +2. ✅ 实现混合适配器(Prisma + Redis) +3. ✅ 配置 oidc-provider +4. ✅ 集成到 NestJS 应用(Express 中间件挂载) -1. 实现账户服务 -2. 实现交互处理(登录、授权确认) -3. 实现客户端管理 CRUD -4. 添加权限控制 +### 阶段二:核心功能 ✅ -### 阶段三:前端页面(1-2天) +1. ✅ 实现账户服务(findAccount) +2. ✅ 实现交互处理(登录、授权确认、中止) +3. ✅ 实现客户端管理 CRUD +4. ✅ 添加权限控制(oidc-client:read/create/update/delete) +5. ✅ 实现第一方应用自动授权 -1. 授权确认页面 -2. OIDC 客户端管理页面 -3. 用户已授权应用管理 +### 阶段三:前端页面 ✅ -### 阶段四:测试与文档(1天) +1. ✅ OIDC 登录页面(独立于现有登录系统) +2. ✅ 授权确认页面(scope 选择) +3. ✅ OIDC 客户端管理页面(表格 + 创建/编辑弹窗) +4. ✅ 客户端密钥重新生成功能 -1. 单元测试 -2. 集成测试 -3. API 文档完善 -4. 使用文档 +### 阶段四:测试与完善 ✅ + +1. ✅ 端到端授权流程测试 +2. ✅ 第一方应用自动授权测试 +3. ✅ API 文档完善(Swagger) +4. ✅ 实施文档更新 ## 十、NestJS 集成要点 @@ -1025,15 +1242,48 @@ oidc-provider 中间件挂载在 `/oidc` 路径下,会先执行。自定义交 ## 十一、关键文件清单 +### 后端文件 + | 文件 | 说明 | |------|------| | `apps/api/src/main.ts` | 应用入口,挂载 oidc-provider 中间件 | -| `apps/api/prisma/schema.prisma` | 添加 OIDC 相关数据模型 | -| `apps/api/src/oidc/` | OIDC 模块目录 | -| `apps/api/src/auth/auth.service.ts` | 参考现有认证逻辑 | -| `apps/web/src/app/(oidc)/` | OIDC 交互页面(登录、授权确认) | -| `apps/web/src/app/(dashboard)/oidc-clients/` | OIDC 客户端管理页面 | +| `apps/api/prisma/schema.prisma` | OIDC 数据模型(OidcClient、OidcGrant、OidcRefreshToken) | +| `apps/api/src/oidc/oidc.module.ts` | OIDC 模块定义 | +| `apps/api/src/oidc/oidc.service.ts` | Provider 初始化与管理 | +| `apps/api/src/oidc/oidc.controller.ts` | 交互端点(获取详情、登录、授权、中止) | +| `apps/api/src/oidc/adapters/index.ts` | 混合适配器工厂 | +| `apps/api/src/oidc/adapters/prisma.adapter.ts` | PostgreSQL 存储适配器 | +| `apps/api/src/oidc/adapters/redis.adapter.ts` | Redis 存储适配器 | +| `apps/api/src/oidc/services/account.service.ts` | 账户查找服务 | +| `apps/api/src/oidc/services/client.service.ts` | 客户端管理(cuid2 ID + 随机密钥) | +| `apps/api/src/oidc/services/interaction.service.ts` | 交互处理(含第一方自动授权) | +| `apps/api/src/oidc/controllers/client.controller.ts` | 客户端管理 API | +| `apps/api/src/oidc/config/oidc.config.ts` | OIDC Provider 配置 | +| `apps/api/src/oidc/dto/client.dto.ts` | 客户端 DTO | +| `apps/api/src/oidc/dto/interaction.dto.ts` | 交互 DTO | + +### 前端文件 + +| 文件 | 说明 | +|------|------| +| `apps/web/src/app/(oidc)/layout.tsx` | OIDC 专用布局 | +| `apps/web/src/app/(oidc)/oidc/interaction/[uid]/page.tsx` | 交互页面(SSR) | +| `apps/web/src/app/(oidc)/oidc/interaction/[uid]/OidcLoginForm.tsx` | 登录表单 | +| `apps/web/src/app/(oidc)/oidc/interaction/[uid]/OidcConsentForm.tsx` | 授权确认表单 | +| `apps/web/src/app/(oidc)/oidc/error/page.tsx` | OIDC 错误页面 | +| `apps/web/src/app/(dashboard)/oidc-clients/page.tsx` | 客户端管理页面 | +| `apps/web/src/components/oidc-clients/OidcClientsTable.tsx` | 客户端列表表格 | +| `apps/web/src/components/oidc-clients/OidcClientCreateDialog.tsx` | 创建客户端弹窗 | +| `apps/web/src/components/oidc-clients/OidcClientEditDialog.tsx` | 编辑客户端弹窗 | +| `apps/web/src/hooks/useOidcClients.ts` | 客户端管理 hooks | +| `apps/web/src/services/oidc-client.service.ts` | 客户端 API 服务 | + +### 共享类型 + +| 文件 | 说明 | +|------|------| | `packages/shared/src/types/oidc.ts` | OIDC 共享类型定义 | +| `packages/shared/src/types/index.ts` | 类型导出(包含 OIDC 类型) | ## 十二、参考资料