diff --git a/apps/api/prisma/seeds/.gitkeep b/apps/api/prisma/seeds/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/backend/oidc-provider.md b/docs/backend/oidc-provider.md new file mode 100644 index 0000000..d162f40 --- /dev/null +++ b/docs/backend/oidc-provider.md @@ -0,0 +1,958 @@ +# OIDC Provider 实施方案 + +本文档描述如何将系统扩展为 OIDC Provider,对外提供中央鉴权业务。 + +## 一、方案概述 + +基于 [panva/node-oidc-provider](https://github.com/panva/node-oidc-provider) 库实现 OIDC Provider 功能,该库是 OpenID Certified 的成熟实现,支持完整的 OAuth 2.0 和 OpenID Connect 规范。 + +## 二、数据存储设计 + +### 2.1 存储策略 + +根据数据特点采用混合存储策略: + +| 数据类型 | 存储位置 | 原因 | +|----------|----------|------| +| OidcClient | PostgreSQL | 客户端配置,需要持久化和管理 | +| OidcGrant | PostgreSQL | 用户授权记录,需要审计和长期保存 | +| OidcRefreshToken | PostgreSQL | 长期令牌(30天),需要可靠撤销 | +| AuthorizationCode | Redis | 短期(10分钟),一次性使用 | +| AccessToken | Redis | 短期(1小时),高频访问 | +| Session | Redis | 会话数据,可重建 | +| Interaction | Redis | 临时交互流程(1小时) | + +### 2.2 Prisma 模型(PostgreSQL) + +需要在 `apps/api/prisma/schema.prisma` 中添加以下模型: + +```prisma +// ============ OIDC Provider 模块 ============ + +// OIDC 客户端应用 +model OidcClient { + id String @id @default(cuid(2)) + clientId String @unique // 客户端 ID + clientSecret String? // 客户端密钥(公开客户端可为空) + clientName String // 客户端名称 + clientUri String? // 客户端主页 + logoUri String? // Logo URL + redirectUris String[] // 回调地址列表 + postLogoutRedirectUris String[] // 登出后回调地址 + grantTypes String[] // 授权类型: authorization_code, refresh_token, client_credentials + responseTypes String[] // 响应类型: code, token, id_token + scopes String[] // 允许的 scope: openid, profile, email, etc. + tokenEndpointAuthMethod String @default("client_secret_basic") // 认证方式 + applicationType String @default("web") // web / native + isEnabled Boolean @default(true) + isFirstParty Boolean @default(false) // 是否为第一方应用(跳过授权确认) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + grants OidcGrant[] + refreshTokens OidcRefreshToken[] + + @@map("oidc_clients") +} + +// OIDC 刷新令牌(长期,需要持久化) +model OidcRefreshToken { + id String @id @default(cuid(2)) + jti String @unique // JWT ID + grantId String // 关联的授权 ID + clientId String // 客户端 ID + userId String // 用户 ID + scope String // 授权范围 + data Json // 完整令牌数据 + expiresAt DateTime + consumedAt DateTime? + createdAt DateTime @default(now()) + + client OidcClient @relation(fields: [clientId], references: [clientId]) + + @@index([grantId]) + @@index([clientId]) + @@index([userId]) + @@map("oidc_refresh_tokens") +} + +// OIDC 授权记录(用户对客户端的授权) +model OidcGrant { + id String @id @default(cuid(2)) + grantId String @unique // oidc-provider 生成的 grant ID + clientId String // 客户端 ID + userId String // 用户 ID + scope String // 已授权的 scope + data Json // 完整授权数据 + expiresAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + client OidcClient @relation(fields: [clientId], references: [clientId]) + user User @relation(fields: [userId], references: [id]) + + @@unique([clientId, userId]) + @@index([userId]) + @@map("oidc_grants") +} +``` + +### 2.3 Redis 数据结构 + +短期/临时数据存储在 Redis 中,使用以下 Key 格式: + +``` +oidc:authorization_code:{code} # 授权码,TTL: 600s +oidc:access_token:{jti} # 访问令牌,TTL: 3600s +oidc:session:{uid} # OIDC 会话,TTL: 14d +oidc:interaction:{uid} # 交互会话,TTL: 3600s +``` + +Value 为 JSON 序列化的完整数据。 + +### 2.4 User 模型关联 + +在现有 `User` 模型中添加关联: + +```prisma +model User { + // ... 现有字段 ... + + // OIDC 关联 + oidcGrants OidcGrant[] +} +``` + +## 三、后端模块结构 + +### 3.1 目录结构 + +``` +apps/api/src/oidc/ +├── oidc.module.ts # OIDC 模块定义 +├── oidc.controller.ts # OIDC 端点控制器 +├── oidc.service.ts # OIDC 核心服务 +├── adapters/ +│ ├── prisma.adapter.ts # Prisma 存储适配器(Client、Grant、RefreshToken) +│ └── redis.adapter.ts # Redis 存储适配器(AuthorizationCode、AccessToken、Session、Interaction) +├── services/ +│ ├── account.service.ts # 账户查找服务 +│ ├── client.service.ts # 客户端管理服务 +│ └── interaction.service.ts # 交互处理服务 +├── dto/ +│ ├── client.dto.ts # 客户端 DTO +│ ├── consent.dto.ts # 授权确认 DTO +│ └── interaction.dto.ts # 交互 DTO +├── guards/ +│ └── oidc-interaction.guard.ts # 交互会话守卫 +└── config/ + └── oidc.config.ts # OIDC Provider 配置 +``` + +### 3.2 核心文件实现 + +#### 3.2.1 混合适配器工厂 (`adapters/index.ts`) + +```typescript +import { PrismaService } from '@/prisma/prisma.service'; +import { RedisService } from '@/common/redis/redis.service'; +import { PrismaAdapter } from './prisma.adapter'; +import { RedisAdapter } from './redis.adapter'; + +// Prisma 存储的模型 +const PRISMA_MODELS = ['Client', 'Grant', 'RefreshToken']; + +// Redis 存储的模型 +const REDIS_MODELS = ['AuthorizationCode', 'AccessToken', 'Session', 'Interaction']; + +export function createAdapterFactory( + prisma: PrismaService, + redis: RedisService +) { + return (model: string) => { + if (PRISMA_MODELS.includes(model)) { + return new PrismaAdapter(prisma, model); + } + if (REDIS_MODELS.includes(model)) { + return new RedisAdapter(redis, model); + } + throw new Error(`Unknown model: ${model}`); + }; +} +``` + +#### 3.2.2 Prisma 适配器 (`adapters/prisma.adapter.ts`) + +```typescript +import { PrismaService } from '@/prisma/prisma.service'; + +// oidc-provider 要求的适配器接口(用于 Client、Grant、RefreshToken) +export class PrismaAdapter { + private model: string; + + constructor( + private prisma: PrismaService, + model: string + ) { + this.model = model; + } + + // 根据 model 名称映射到对应的 Prisma 模型 + private getModelDelegate() { + const modelMap: Record = { + Client: 'oidcClient', + RefreshToken: 'oidcRefreshToken', + Grant: 'oidcGrant', + }; + return this.prisma[modelMap[this.model]]; + } + + async upsert(id: string, payload: unknown, expiresIn: number) { /* ... */ } + async find(id: string) { /* ... */ } + async findByUserCode(userCode: string) { /* ... */ } + async findByUid(uid: string) { /* ... */ } + async consume(id: string) { /* ... */ } + async destroy(id: string) { /* ... */ } + async revokeByGrantId(grantId: string) { /* ... */ } +} +``` + +#### 3.2.3 Redis 适配器 (`adapters/redis.adapter.ts`) + +```typescript +import { RedisService } from '@/common/redis/redis.service'; + +// Redis Key 前缀 +const KEY_PREFIX = 'oidc'; + +// 各模型的 TTL(秒) +const MODEL_TTL: Record = { + AuthorizationCode: 600, // 10 分钟 + AccessToken: 3600, // 1 小时 + Session: 14 * 24 * 3600, // 14 天 + Interaction: 3600, // 1 小时 +}; + +// oidc-provider 要求的适配器接口(用于短期数据) +export class RedisAdapter { + private model: string; + private keyPrefix: string; + + constructor( + private redis: RedisService, + model: string + ) { + this.model = model; + this.keyPrefix = `${KEY_PREFIX}:${model.toLowerCase()}`; + } + + private key(id: string): string { + return `${this.keyPrefix}:${id}`; + } + + async upsert(id: string, payload: unknown, expiresIn: number) { + const ttl = expiresIn || MODEL_TTL[this.model] || 3600; + await this.redis.setJson(this.key(id), payload, ttl); + } + + async find(id: string) { + return this.redis.getJson(this.key(id)); + } + + async findByUid(uid: string) { + return this.find(uid); + } + + async consume(id: string) { + const data = await this.find(id); + if (data) { + (data as Record).consumed = Math.floor(Date.now() / 1000); + const ttl = await this.redis.ttl(this.key(id)); + if (ttl > 0) { + await this.redis.setJson(this.key(id), data, ttl); + } + } + } + + async destroy(id: string) { + await this.redis.del(this.key(id)); + } + + async revokeByGrantId(grantId: string) { + // Redis 适配器不支持按 grantId 批量撤销 + // 需要在业务层处理或使用 Redis SCAN + } +} +``` + +#### 3.2.4 账户服务 (`services/account.service.ts`) + +```typescript +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@/prisma/prisma.service'; + +@Injectable() +export class OidcAccountService { + constructor(private prisma: PrismaService) {} + + // oidc-provider 要求的 findAccount 方法 + async findAccount(ctx: unknown, id: string) { + const user = await this.prisma.user.findUnique({ + where: { id }, + select: { id: true, email: true, name: true, avatarId: true }, + }); + + if (!user) return undefined; + + return { + accountId: user.id, + async claims(use: string, scope: string) { + const claims: Record = { sub: user.id }; + + if (scope.includes('profile')) { + claims.name = user.name; + claims.picture = user.avatarId; // 可转换为完整 URL + } + if (scope.includes('email')) { + claims.email = user.email; + claims.email_verified = true; + } + + return claims; + }, + }; + } +} +``` + +#### 3.2.5 OIDC 配置 (`config/oidc.config.ts`) + +```typescript +import type { Configuration } from 'oidc-provider'; + +export function createOidcConfiguration( + issuer: string, + accountService: OidcAccountService, + adapterFactory: (model: string) => PrismaAdapter +): Configuration { + return { + // 适配器工厂 + adapter: adapterFactory, + + // 账户查找 + findAccount: accountService.findAccount.bind(accountService), + + // 支持的功能 + features: { + devInteractions: { enabled: false }, // 禁用开发模式交互 + rpInitiatedLogout: { enabled: true }, + resourceIndicators: { enabled: true }, + revocation: { enabled: true }, + introspection: { enabled: true }, + }, + + // 支持的 claims + claims: { + openid: ['sub'], + profile: ['name', 'picture'], + email: ['email', 'email_verified'], + }, + + // 令牌有效期 + ttl: { + AccessToken: 3600, // 1 小时 + AuthorizationCode: 600, // 10 分钟 + RefreshToken: 30 * 24 * 3600, // 30 天 + IdToken: 3600, // 1 小时 + Interaction: 3600, // 1 小时 + Session: 14 * 24 * 3600, // 14 天 + Grant: 14 * 24 * 3600, // 14 天 + }, + + // Cookie 配置 + cookies: { + keys: [process.env.OIDC_COOKIE_SECRET], + long: { signed: true, maxAge: 14 * 24 * 3600 * 1000 }, + short: { signed: true }, + }, + + // 交互 URL(重定向到前端) + interactions: { + url: (ctx, interaction) => `/oidc/interaction/${interaction.uid}`, + }, + + // 路由前缀 + routes: { + authorization: '/authorize', + token: '/token', + userinfo: '/userinfo', + jwks: '/jwks', + revocation: '/revoke', + introspection: '/introspect', + end_session: '/logout', + }, + + // PKCE 配置 + pkce: { + methods: ['S256'], + required: () => false, // 公开客户端强制要求 + }, + + // 响应类型 + responseTypes: ['code', 'code id_token', 'id_token', 'none'], + + // 授权类型 + grantTypes: ['authorization_code', 'refresh_token', 'client_credentials'], + }; +} +``` + +#### 3.2.6 OIDC 控制器 (`oidc.controller.ts`) + +```typescript +import { All, Controller, Get, Post, Req, Res, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiExcludeEndpoint } from '@nestjs/swagger'; +import type { Request, Response } from 'express'; + +import { Public } from '@/auth/decorators/public.decorator'; +import { OidcService } from './oidc.service'; + +@ApiTags('OIDC Provider') +@Controller('oidc') +export class OidcController { + constructor(private readonly oidcService: OidcService) {} + + // oidc-provider 处理所有标准端点 + @All('*') + @Public() + @ApiExcludeEndpoint() + async handleOidc(@Req() req: Request, @Res() res: Response) { + return this.oidcService.callback(req, res); + } + + // 交互端点 - 获取交互详情 + @Get('interaction/:uid') + @Public() + @ApiOperation({ summary: '获取 OIDC 交互详情' }) + async getInteraction(@Req() req: Request, @Res() res: Response) { + const details = await this.oidcService.interactionDetails(req, res); + // 返回交互详情供前端渲染 + return res.json(details); + } + + // 交互端点 - 提交登录 + @Post('interaction/:uid/login') + @Public() + @ApiOperation({ summary: '提交 OIDC 登录' }) + async submitLogin(@Req() req: Request, @Res() res: Response) { + return this.oidcService.handleLogin(req, res); + } + + // 交互端点 - 提交授权确认 + @Post('interaction/:uid/confirm') + @Public() + @ApiOperation({ summary: '提交 OIDC 授权确认' }) + async submitConsent(@Req() req: Request, @Res() res: Response) { + return this.oidcService.handleConsent(req, res); + } + + // 交互端点 - 中止授权 + @Post('interaction/:uid/abort') + @Public() + @ApiOperation({ summary: '中止 OIDC 授权' }) + async abortInteraction(@Req() req: Request, @Res() res: Response) { + return this.oidcService.handleAbort(req, res); + } +} +``` + +#### 3.2.7 客户端管理控制器 (`client.controller.ts`) + +```typescript +@ApiTags('OIDC 客户端管理') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, PermissionGuard) +@Controller('oidc-clients') +export class OidcClientController { + constructor(private readonly clientService: OidcClientService) {} + + @Get() + @RequirePermission('oidc-client:read') + @ApiOperation({ summary: '获取客户端列表' }) + findAll(@Query() query: PaginationQueryDto) { /* ... */ } + + @Post() + @RequirePermission('oidc-client:create') + @ApiOperation({ summary: '创建客户端' }) + create(@Body() dto: CreateOidcClientDto) { /* ... */ } + + @Patch(':id') + @RequirePermission('oidc-client:update') + @ApiOperation({ summary: '更新客户端' }) + update(@Param('id') id: string, @Body() dto: UpdateOidcClientDto) { /* ... */ } + + @Delete(':id') + @RequirePermission('oidc-client:delete') + @ApiOperation({ summary: '删除客户端' }) + remove(@Param('id') id: string) { /* ... */ } + + @Post(':id/regenerate-secret') + @RequirePermission('oidc-client:update') + @ApiOperation({ summary: '重新生成客户端密钥' }) + regenerateSecret(@Param('id') id: string) { /* ... */ } +} +``` + +## 四、OIDC 端点说明 + +| 端点 | 方法 | 说明 | +|------|------|------| +| `/.well-known/openid-configuration` | GET | OIDC 发现文档 | +| `/oidc/authorize` | GET | 授权端点 | +| `/oidc/token` | POST | 令牌端点 | +| `/oidc/userinfo` | GET/POST | 用户信息端点 | +| `/oidc/jwks` | GET | JSON Web Key Set | +| `/oidc/revoke` | POST | 令牌撤销 | +| `/oidc/introspect` | POST | 令牌内省 | +| `/oidc/logout` | GET/POST | 登出端点 | +| `/oidc/interaction/:uid` | GET | 获取交互详情 | +| `/oidc/interaction/:uid/login` | POST | 提交登录 | +| `/oidc/interaction/:uid/confirm` | POST | 提交授权确认 | + +## 五、前端改动 + +### 5.1 设计原则 + +OIDC 交互页面采用 SSR(服务端渲染)以优化性能,且独立于现有登录系统: + +- **独立路由**:使用 `/oidc/` 路由,不复用现有 `(auth)` 路由组 +- **独立登录页面**:OIDC 登录页面独立实现,不复用现有登录组件(避免状态冲突) +- **授权确认页面**:SSR + Server Actions,首屏直出 +- **错误页面**:纯 SSR,无客户端 JS + +### 5.2 新增页面 + +OIDC 交互页面使用独立路由组 `(oidc)`,与现有登录系统完全隔离: + +``` +apps/web/src/app/(oidc)/ +├── oidc/ +│ ├── interaction/[uid]/ +│ │ ├── page.tsx # SSR 交互页面(根据 prompt 类型渲染登录或授权确认) +│ │ └── actions.ts # Server Actions(登录、授权确认、中止) +│ └── error/page.tsx # OIDC 错误页面(SSR) +└── layout.tsx # OIDC 专用布局(独立于主站布局) +``` + +OIDC 客户端管理页面放在 dashboard 路由组中(需要登录后访问): + +``` +apps/web/src/app/(dashboard)/oidc-clients/page.tsx # 客户端列表 +apps/web/src/components/oidc-clients/ +├── OidcClientsTable.tsx # 客户端表格 +├── OidcClientEditDialog.tsx # 编辑对话框 +└── index.ts +``` + +### 5.3 交互页面(SSR + Server Actions) + +```tsx +// apps/web/src/app/(oidc)/oidc/interaction/[uid]/page.tsx +import { redirect, notFound } from 'next/navigation'; +import { cookies } from 'next/headers'; + +import { ConsentForm } from './ConsentForm'; +import { LoginForm } from './LoginForm'; + +interface InteractionDetails { + uid: string; + prompt: { name: 'login' | 'consent'; details?: Record }; + params: { + client_id: string; + redirect_uri: string; + scope: string; + }; + client: { + clientId: string; + clientName: string; + logoUri?: string; + clientUri?: string; + }; + session?: { + accountId: string; + }; +} + +// 服务端获取交互详情 +async function getInteractionDetails(uid: string): Promise { + const res = await fetch(`${process.env.API_URL}/oidc/interaction/${uid}`, { + headers: { Cookie: cookies().toString() }, + cache: 'no-store', + }); + + if (!res.ok) return null; + return res.json(); +} + +interface Props { + params: { uid: string }; +} + +export default async function OidcInteractionPage({ params }: Props) { + const details = await getInteractionDetails(params.uid); + + if (!details) { + notFound(); + } + + // 根据 prompt 类型渲染不同组件 + if (details.prompt.name === 'login') { + return ( + + ); + } + + if (details.prompt.name === 'consent') { + return ( + + ); + } + + // 未知 prompt 类型 + redirect(`/oidc/error?error=unknown_prompt`); +} +``` + +### 5.4 Server Actions + +```typescript +// apps/web/src/app/(oidc)/oidc/interaction/[uid]/actions.ts +'use server'; + +import { cookies } from 'next/headers'; +import { redirect } from 'next/navigation'; + +// 提交登录 +export async function submitLogin(uid: string, formData: FormData) { + const email = formData.get('email') as string; + const password = formData.get('password') as string; + + const res = await fetch(`${process.env.API_URL}/oidc/interaction/${uid}/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: cookies().toString(), + }, + body: JSON.stringify({ email, password }), + }); + + if (!res.ok) { + const error = await res.json(); + return { error: error.message || '登录失败' }; + } + + // 后端返回重定向 URL + const { redirectTo } = await res.json(); + redirect(redirectTo); +} + +// 提交授权确认 +export async function submitConsent(uid: string, scopes: string[]) { + const res = await fetch(`${process.env.API_URL}/oidc/interaction/${uid}/confirm`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: cookies().toString(), + }, + body: JSON.stringify({ scopes }), + }); + + if (!res.ok) { + const error = await res.json(); + return { error: error.message || '授权失败' }; + } + + const { redirectTo } = await res.json(); + redirect(redirectTo); +} + +// 中止授权 +export async function abortInteraction(uid: string) { + const res = await fetch(`${process.env.API_URL}/oidc/interaction/${uid}/abort`, { + method: 'POST', + headers: { Cookie: cookies().toString() }, + }); + + const { redirectTo } = await res.json(); + redirect(redirectTo); +} +``` + +### 5.5 授权确认表单组件 + +```tsx +// apps/web/src/app/(oidc)/oidc/interaction/[uid]/ConsentForm.tsx +'use client'; + +import { useTransition } from 'react'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; + +import { submitConsent, abortInteraction } from './actions'; + +const SCOPE_DESCRIPTIONS: Record = { + openid: '基本身份信息', + profile: '个人资料(姓名、头像)', + email: '邮箱地址', + offline_access: '离线访问(保持登录状态)', +}; + +interface Props { + uid: string; + client: { clientName: string; logoUri?: string; clientUri?: string }; + params: { scope: string }; + session?: { accountId: string }; +} + +export function ConsentForm({ uid, client, params }: Props) { + const [isPending, startTransition] = useTransition(); + const requestedScopes = params.scope.split(' '); + + const handleSubmit = (formData: FormData) => { + const selectedScopes = requestedScopes.filter( + (scope) => formData.get(`scope_${scope}`) === 'on' + ); + startTransition(() => submitConsent(uid, selectedScopes)); + }; + + const handleDeny = () => { + startTransition(() => abortInteraction(uid)); + }; + + return ( + + +
+ {client.logoUri && ( + + )} +
+ {client.clientName} + 请求访问您的账户 +
+
+
+
+ +

该应用请求以下权限:

+
    + {requestedScopes.map((scope) => ( +
  • + + +
  • + ))} +
+
+ + + + +
+
+ ); +} +``` + +## 六、共享类型定义 + +在 `packages/shared/src/types/oidc.ts` 中添加: + +```typescript +// OIDC 客户端类型 +export interface OidcClientResponse { + id: string; + clientId: string; + clientName: string; + clientUri: string | null; + logoUri: string | null; + redirectUris: string[]; + postLogoutRedirectUris: string[]; + grantTypes: string[]; + responseTypes: string[]; + scopes: string[]; + tokenEndpointAuthMethod: string; + applicationType: string; + isEnabled: boolean; + isFirstParty: boolean; + createdAt: string; + updatedAt: string; +} + +export interface CreateOidcClientDto { + clientName: string; + clientUri?: string; + logoUri?: string; + redirectUris: string[]; + postLogoutRedirectUris?: string[]; + grantTypes?: string[]; + responseTypes?: string[]; + scopes?: string[]; + tokenEndpointAuthMethod?: string; + applicationType?: string; + isFirstParty?: boolean; +} + +export interface UpdateOidcClientDto { + clientName?: string; + clientUri?: string; + logoUri?: string; + redirectUris?: string[]; + postLogoutRedirectUris?: string[]; + grantTypes?: string[]; + responseTypes?: string[]; + scopes?: string[]; + isEnabled?: boolean; + isFirstParty?: boolean; +} + +// OIDC 交互类型 +export interface OidcInteractionDetails { + uid: string; + prompt: { + name: 'login' | 'consent'; + details?: Record; + }; + params: { + client_id: string; + redirect_uri: string; + scope: string; + response_type: string; + state?: string; + nonce?: string; + }; + client: { + clientId: string; + clientName: string; + logoUri?: string; + clientUri?: string; + }; + session?: { + accountId: string; + }; +} + +// Scope 描述 +export const OidcScopeDescriptions: Record = { + openid: '基本身份信息', + profile: '个人资料(姓名、头像)', + email: '邮箱地址', + offline_access: '离线访问(刷新令牌)', +}; +``` + +## 七、环境变量配置 + +在 `apps/api/.env.example` 中添加: + +```bash +# ----- OIDC Provider 配置 ----- +# OIDC 签发者 URL(必须是可公开访问的 HTTPS URL) +OIDC_ISSUER=http://localhost:4000 +# OIDC Cookie 签名密钥(生产环境必须修改) +OIDC_COOKIE_SECRET=your-oidc-cookie-secret-change-in-production +# OIDC JWKS 私钥(RS256,PEM 格式,Base64 编码) +# 生成方式: openssl genrsa 2048 | base64 -w 0 +OIDC_JWKS_PRIVATE_KEY= +``` + +## 八、安全考虑 + +1. **PKCE 支持**:公开客户端(SPA、移动应用)强制要求 PKCE +2. **令牌安全**: + - Access Token 使用 JWT 格式,支持签名验证 + - Refresh Token 存储在数据库,支持撤销 + - 短期 Access Token(1小时)+ 长期 Refresh Token(30天) +3. **客户端认证**: + - 机密客户端使用 client_secret_basic 或 client_secret_post + - 支持 private_key_jwt 高安全认证方式 +4. **授权确认**: + - 第三方应用必须经过用户授权确认 + - 第一方应用可配置跳过确认 + - 记住用户授权决定,避免重复确认 +5. **令牌撤销**: + - 支持主动撤销 Access Token 和 Refresh Token + - 用户可在设置页面管理已授权的应用 +6. **CORS 配置**: + - Token 端点需要支持跨域请求 + - 配置允许的 Origin 列表 + +## 九、实施步骤 + +### 阶段一:基础设施(1-2天) + +1. 添加 Prisma 数据模型 +2. 实现 Prisma 适配器 +3. 配置 oidc-provider +4. 集成到 NestJS 应用 + +### 阶段二:核心功能(2-3天) + +1. 实现账户服务 +2. 实现交互处理(登录、授权确认) +3. 实现客户端管理 CRUD +4. 添加权限控制 + +### 阶段三:前端页面(1-2天) + +1. 授权确认页面 +2. OIDC 客户端管理页面 +3. 用户已授权应用管理 + +### 阶段四:测试与文档(1天) + +1. 单元测试 +2. 集成测试 +3. API 文档完善 +4. 使用文档 + +## 十、关键文件清单 + +| 文件 | 说明 | +|------|------| +| `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 客户端管理页面 | +| `packages/shared/src/types/oidc.ts` | OIDC 共享类型定义 | + +## 十一、参考资料 + +- [panva/node-oidc-provider](https://github.com/panva/node-oidc-provider) +- [node-oidc-provider Documentation](https://github.com/panva/node-oidc-provider/blob/main/docs/README.md) +- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) +- [OAuth 2.0 RFC 6749](https://tools.ietf.org/html/rfc6749) diff --git a/docs/index.md b/docs/index.md index bb19ea8..3b06758 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,6 +8,7 @@ | ---------------------------------------------------- | -------------------- | -------------------------------- | | [design.md](./design.md) | 项目设计文档 | 了解整体架构、技术选型、模块设计 | | [backend/soft-delete.md](./backend/soft-delete.md) | 软删除设计文档 | 了解软删除机制、扩展新模型 | +| [backend/oidc-provider.md](./backend/oidc-provider.md) | OIDC Provider 实施方案 | 实现中央鉴权、OAuth2.0/OIDC 集成 | | [api/crud-service.md](./api/crud-service.md) | CrudService 分层架构 | CRUD 服务开发、关联查询、多对多关系 | | [../plop/README.md](../plop/README.md) | CRUD 代码生成器 | 一键生成全栈 CRUD 模块 |