diff --git a/docs/ios登录流程-Apple登录.md b/docs/ios登录流程-Apple登录.md new file mode 100644 index 0000000..121475e --- /dev/null +++ b/docs/ios登录流程-Apple登录.md @@ -0,0 +1,254 @@ +# iOS 登录流程 —— Apple 登录详解 + +--- + +## 一、Apple 登录核心理解 + +**后端不需要 Apple 开发证书。** Apple 登录的公钥是 Apple 公开提供的 JWKS 地址,后端运行时获取即可。 + +``` +iOS 真机运行: 需要 Apple 开发证书 + Provisioning Profile +后端验证 Apple 登录: 不需要证书,只需要 Apple JWKS 公钥 + Bundle ID +``` + +--- + +## 二、环境变量配置 + +```env +JWT_ACCESS_SECRET=你自己的随机强密钥 +JWT_REFRESH_SECRET=你自己的随机强密钥 +APPLE_BUNDLE_ID=cloud.longde.AIStudyApp +APPLE_ISSUER=https://appleid.apple.com +APPLE_JWKS_URL=https://appleid.apple.com/auth/keys +``` + +--- + +## 三、Apple 登录流程 + +### iOS 端 + +iOS 通过 Sign in with Apple 拿到以下数据: + +``` +identityToken ← JWT,唯一必须的值 +authorizationCode ← 可选,后面可能用于完整校验/撤销 +userIdentifier ← 可选,辅助识别,但后端不要完全信任 +email ← 可选,注意:仅首次授权时返回 +fullName ← 可选,注意:仅首次授权时返回 +``` + +### 发给后端 + +```http +POST /api/auth/apple +``` + +```json +{ + "identityToken": "eyJ...", + "authorizationCode": "c123...", + "userIdentifier": "000123.xxxxx", + "email": "xxx@privaterelay.appleid.com", + "fullName": { + "givenName": "Long", + "familyName": "De" + } +} +``` + +最小必填字段只有: + +```json +{ + "identityToken": "eyJ..." +} +``` + +--- + +## 四、Apple Token 校验(核心) + +使用 `jose` 库,不要手写公钥解析: + +```bash +npm install jose +``` + +### AppleAuthService 实现 + +```ts +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; + +@Injectable() +export class AppleAuthService { + private readonly appleIssuer = 'https://appleid.apple.com'; + private readonly appleBundleId = process.env.APPLE_BUNDLE_ID!; + private readonly jwks = createRemoteJWKSet( + new URL('https://appleid.apple.com/auth/keys'), + ); + + async verifyIdentityToken(identityToken: string) { + try { + const { payload } = await jwtVerify(identityToken, this.jwks, { + issuer: this.appleIssuer, + audience: this.appleBundleId, + }); + + return { + appleUserId: payload.sub, // ← Apple 用户唯一 ID,后端核心信任字段 + email: typeof payload.email === 'string' ? payload.email : undefined, + emailVerified: payload.email_verified, + }; + } catch (error) { + throw new UnauthorizedException('Invalid Apple identity token'); + } + } +} +``` + +### jose 自动完成的工作 + +``` +1. 读取 JWT header 里的 kid +2. 请求 Apple JWKS 地址,找到 kid 对应的公钥 +3. 验证 JWT 签名(RSA) +4. 校验 issuer === https://appleid.apple.com +5. 校验 audience === cloud.longde.AIStudyApp +6. 校验 exp 过期时间 +``` + +你不需要手动把 `n`、`e` 转成 RSA 公钥。 + +--- + +## 五、Apple Login 接口实现 + +### Controller + +```ts +@Post('apple') +async loginWithApple(@Body() dto: AppleLoginDto) { + const appleUser = await this.appleAuthService.verifyIdentityToken( + dto.identityToken, + ); + + return this.authService.loginWithProvider({ + provider: 'APPLE', + providerUserId: appleUser.appleUserId, // ← 即 identityToken.sub + email: appleUser.email ?? dto.email, + nickname: dto.fullName?.givenName + ? `${dto.fullName.givenName} ${dto.fullName.familyName ?? ''}` + : undefined, + }); +} +``` + +### DTO + +```ts +export class AppleLoginDto { + @IsString() + @IsNotEmpty() + identityToken: string; + + @IsOptional() + @IsString() + authorizationCode?: string; + + @IsOptional() + @IsString() + userIdentifier?: string; + + @IsOptional() + @IsEmail() + email?: string; + + @IsOptional() + fullName?: { + givenName?: string; + familyName?: string; + }; +} +``` + +--- + +## 六、后端信任模型 + +| 字段 | 信任级别 | 说明 | +|------|----------|------| +| `identityToken.sub` | ✅ 信任 | Apple 签名验证通过后的用户唯一 ID | +| `identityToken.aud` | ✅ 信任 | 必须等于你的 Bundle ID | +| `identityToken.iss` | ✅ 信任 | 必须等于 `https://appleid.apple.com` | +| `identityToken.email` | ⚠️ 参考 | Apple 侧校验过的邮箱,但可能为空 | +| `userIdentifier`(请求体) | ❌ 不信任 | iOS 侧传的,可能被篡改 | +| `email`(请求体) | ❌ 不信任 | iOS 侧传的,可能被篡改 | +| `userId`(请求体) | ❌ 绝对不能信 | 用户 ID 只能从后端 JWT 获取 | + +**核心规则**: + +``` +1. 后端只信 identityToken 里校验出来的 sub +2. 用 sub 去 auth_accounts(provider=APPLE, providerUserId=sub) 查找/创建用户 +3. 不要信前端传的 userIdentifier / email / name 作为唯一标识 +4. 绝对不要让前端传 userId +``` + +--- + +## 七、Apple 登录的特别注意事项 + +### 1. 电子邮件和姓名仅首次返回 + +``` +email / fullName 只在用户第一次授权时返回 +第二次及以后登录,Apple 不会返回这两个字段 +``` + +所以首次登录时需要将 email 和姓名保存到 `auth_accounts` 和 `users` 表中。 + +### 2. Apple 私密邮箱 + +Apple 可能返回 `xxx@privaterelay.appleid.com` 格式的私密中继邮箱,这是正常的。如果用户选择隐藏邮箱,Apple 会生成一个中转邮箱,发到该邮箱的邮件会自动转发到用户真实邮箱。 + +### 3. 什么时候后端才需要 Apple Key? + +只有在后端要主动调用 Apple 服务时才需要 `.p8` 私钥: + +- App Store Server API +- App Store Connect API +- 订阅状态查询 +- IAP 交易验证 +- APNs 推送 + +**登录不需要这些,这些都是后面的事情。** + +--- + +## 八、完整后端校验小结 + +```text + POST /api/auth/apple + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ 1. 拿到 identityToken │ +│ 2. 解析 header 里的 kid │ +│ 3. 请求 Apple JWKS → https://appleid.apple.com/auth/keys +│ 4. 找到 kid 对应的公钥 │ +│ 5. 验证 JWT 签名 │ +│ 6. 校验 iss === https://appleid.apple.com │ +│ 7. 校验 aud === cloud.longde.AIStudyApp │ +│ 8. 校验 exp 未过期 │ +│ 9. 取 sub 作为 Apple 用户唯一 ID │ +│ 10. 查 auth_accounts(provider=APPLE, providerUserId=sub)│ +│ 11. 不存在→创建 User + AuthAccount │ +│ 12. 存在→找到对应 User │ +│ 13. 生成 accessToken + refreshToken │ +│ 14. refreshToken hash 入库 │ +│ 15. 返回 { accessToken, refreshToken, user } │ +└─────────────────────────────────────────────────────┘ +``` diff --git a/docs/ios登录流程-iOS端集成.md b/docs/ios登录流程-iOS端集成.md new file mode 100644 index 0000000..e8d330f --- /dev/null +++ b/docs/ios登录流程-iOS端集成.md @@ -0,0 +1,172 @@ +# iOS 登录流程 —— iOS 端集成 + +--- + +## 一、iOS 需要的核心组件 + +``` +AuthService ← 调后端登录接口 +UserService ← 用户信息管理 +TokenStore ← token 存储协议 +KeychainTokenStore ← 基于 Keychain 的安全存储实现 +AppSession ← 管理当前登录态 +``` + +--- + +## 二、数据存储策略 + +| 数据 | 存储位置 | 生命周期 | 原因 | +|------|---------|---------|------| +| `accessToken` | 内存 | App 运行期间 | 短期使用,不需要持久化 | +| `refreshToken` | Keychain | 长期持久化 | 敏感凭证,需安全存储,卸载后也保留 | +| `user` | AppSession / UserStore | App 运行期间 | 用户展示信息 | + +--- + +## 三、App 启动流程 + +``` +App 启动 +→ AppSession.checkSession() +→ 从 Keychain 读取 refreshToken +→ 如果没有 refreshToken + → 进入登录页 +→ 如果有 refreshToken + → 调用 POST /api/auth/refresh + → 成功 + → 存储新的 accessToken + refreshToken + → 调用 GET /api/users/me + → 存储 user 信息 + → 进入主界面 + → 失败 + → 清空 Keychain + 内存 token + → 进入登录页 +``` + +--- + +## 四、登录流程 + +### 开发登录(dev-login) + +``` +用户在登录页输入邮箱/昵称 +→ AuthService.devLogin(email, nickname) +→ POST /api/auth/dev-login +→ 后端返回 { accessToken, refreshToken, user } +→ refreshToken 存 Keychain +→ accessToken 放内存 +→ user 放 AppSession +→ 进入主界面 +``` + +### Apple 登录 + +``` +用户点击 Sign in with Apple +→ iOS 系统弹出 Apple 授权界面 +→ 用户授权成功 +→ 拿到 identityToken + authorizationCode 等 +→ AuthService.appleLogin(identityToken, ...) +→ POST /api/auth/apple +→ 后端验证 Apple token,返回 { accessToken, refreshToken, user } +→ refreshToken 存 Keychain +→ accessToken 放内存 +→ user 放 AppSession +→ 进入主界面 +``` + +--- + +## 五、接口请求拦截 + +所有需要登录的接口都必须携带: + +```http +Authorization: Bearer {accessToken} +``` + +### HTTP Client 封装建议 + +``` +所有请求自动注入 Authorization Header +→ 从 AuthService 获取当前 accessToken +→ 自动添加到请求头 +``` + +### 401 自动处理 + +``` +接口返回 401 +→ 调用 POST /api/auth/refresh +→ 成功 + → 更新 accessToken + → 自动重试原请求 +→ 失败 + → 清空 Keychain + 内存数据 + → 跳转登录页 +``` + +**重要**:重试原请求时注意避免无限循环,设置最多重试 1 次。 + +--- + +## 六、退出登录 + +``` +用户点击退出登录 +→ AuthService.logout() +→ POST /api/auth/logout + Body: { refreshToken: 从 Keychain 取的 refreshToken } + Header: Authorization: Bearer accessToken +→ 后端标记 refreshToken revoked +→ iOS 端: + → 清除 Keychain 中的 refreshToken + → 清除内存中的 accessToken + → 清除 AppSession 中的 user + → 跳转登录页 +``` + +--- + +## 七、Token 存储对比:UserDefaults vs Keychain + +| | UserDefaults | Keychain | +|------|------------|----------| +| 安全性 | 低(明文存储) | 高(系统级加密) | +| 应用卸载后 | 数据被清除 | 可选保留(推荐保留) | +| 备份 | 包含在 iTunes/iCloud 备份中 | 仅加密备份 | +| 适用数据 | 非敏感偏好设置 | 密码、Token 等敏感凭据 | + +**结论:refreshToken 一定要用 Keychain 存储。** + +--- + +## 八、Session 状态机 + +``` + App 启动 + │ + ▼ + ┌─────────────────┐ + │ 检查 Keychain │ + │ 有 refreshToken? │ + └───────┬─────────┘ + │ + ┌───────┴───────┐ + │ 有 │ 无 + ▼ ▼ + ┌─────────┐ ┌──────────┐ + │ 调 refresh │ │ 进入登录页 │ + │ 接口 │ └──────────┘ + └─────┬─────┘ + │ + ┌────┴────┐ + │ 成功 │ 失败 + ▼ ▼ +┌────────┐ ┌──────────┐ +│ 调 /me │ │ 清空数据 │ +│ 进主页 │ │ 进登录页 │ +└────────┘ └──────────┘ +``` diff --git a/docs/ios登录流程-后端接口.md b/docs/ios登录流程-后端接口.md new file mode 100644 index 0000000..342752c --- /dev/null +++ b/docs/ios登录流程-后端接口.md @@ -0,0 +1,268 @@ +# iOS 登录流程 —— 后端接口实现 + +本文覆盖后端的 `dev-login`、`refresh`、`logout`、`/users/me` 四个核心接口的实现要点。Apple 登录单独见 [ios登录流程-Apple登录.md](./ios登录流程-Apple登录.md)。 + +--- + +## 一、dev-login 接口 + +开发阶段用的快速登录接口,**生产环境必须禁止**。 + +### 请求 + +```http +POST /api/auth/dev-login +``` + +```json +{ + "email": "test@zhixi.app", + "nickname": "测试用户", + "devSecret": "你的开发密钥" +} +``` + +### DTO + +```ts +export class DevLoginDto { + @IsEmail() + email: string; + + @IsOptional() + @IsString() + nickname?: string; + + @IsString() + @IsNotEmpty() + devSecret: string; +} +``` + +### 后端逻辑 + +``` +1. 判断 NODE_ENV 不是 production +2. 校验 devSecret +3. 根据 provider=DEV + providerUserId=email 查 AuthAccount +4. 如果没有,创建 User + AuthAccount(provider=DEV, providerUserId=email) +5. 生成 accessToken +6. 生成 refreshToken +7. refreshToken hash 入库 +8. 返回 token + user +``` + +### 生产环境保护 + +```ts +if (process.env.NODE_ENV === 'production') { + throw new ForbiddenException('dev-login is disabled in production'); +} +``` + +--- + +## 二、refresh 接口 + +用于 accessToken 过期后刷新登录态。 + +### 请求 + +```http +POST /api/auth/refresh +``` + +```json +{ + "refreshToken": "eyJ..." +} +``` + +### 后端逻辑 + +``` +1. 校验 refreshToken JWT 签名 +2. 解析出 userId / tokenId +3. 查 refresh_tokens 表,找到 tokenId 对应记录 +4. 对比 tokenHash(SHA-256) +5. 确认 revokedAt 为 null(未撤销) +6. 确认 expiresAt 未过期 +7. 生成新的 accessToken +8. 可选:轮换新的 refreshToken(旧记录 revoke,新记录入库) +9. 返回新 token +``` + +### 响应(第一版可简单) + +```json +{ + "accessToken": "new_access_token", + "refreshToken": "new_refresh_token" +} +``` + +**建议做 refreshToken 轮换**:每次都生成新的 refreshToken,旧 token 标记 revoked,这样即使 refreshToken 泄露也能被检测到。 + +--- + +## 三、logout 接口 + +### 请求 + +```http +POST /api/auth/logout +Authorization: Bearer accessToken +``` + +```json +{ + "refreshToken": "eyJ..." +} +``` + +### 后端逻辑 + +``` +1. 通过 accessToken 拿到 currentUser +2. 解析 refreshToken,拿到 tokenId +3. 查 refresh_tokens 表找到对应记录 +4. 校验该记录属于当前用户(userId 匹配) +5. 设置 revokedAt = now() +6. 返回成功 +``` + +### iOS 侧配合操作 + +``` +清除 Keychain 中的 refreshToken +清除内存中的 accessToken + user +跳转到登录页 +``` + +--- + +## 四、/users/me 接口 + +App 启动后判断登录态的核心接口。 + +### 请求 + +```http +GET /api/users/me +Authorization: Bearer accessToken +``` + +### 响应 + +```json +{ + "id": "user_xxx", + "email": "test@zhixi.app", + "nickname": "测试用户", + "avatarUrl": null, + "role": "USER", + "status": "ACTIVE", + "onboardingCompleted": false +} +``` + +### 后端逻辑 + +``` +1. JwtAuthGuard 校验 accessToken +2. 从 JWT payload 取 currentUser.id +3. 查 users 表返回用户信息 +``` + +**注意**:不要返回敏感字段(如密码哈希、token 等),只返回前端需要的用户展示信息。 + +--- + +## 五、JwtAuthGuard + +全局认证守卫,保护需要登录的接口。 + +```ts +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} +``` + +配合 `jwt.strategy.ts` 从 Authorization Header 解析 JWT,注入 `currentUser`。 + +### CurrentUser 装饰器 + +```ts +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const CurrentUser = createParamDecorator( + (data: keyof User | undefined, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user; + return data ? user?.[data] : user; + }, +); +``` + +### 使用示例 + +```ts +@Get('knowledge-bases') +@UseGuards(JwtAuthGuard) +async list(@CurrentUser('id') userId: string) { + return this.service.findByUser(userId); +} +``` + +--- + +## 六、通用 Provider 登录方法 + +`auth.service.ts` 中的通用方法,被 dev-login 和 Apple 登录复用: + +```ts +async loginWithProvider(params: { + provider: AuthProvider; + providerUserId: string; + email?: string; + nickname?: string; +}) { + // 1. 查 auth_account + let authAccount = await this.prisma.authAccount.findUnique({ + where: { + provider_providerUserId: { + provider: params.provider, + providerUserId: params.providerUserId, + }, + }, + include: { user: true }, + }); + + // 2. 没有就创建 + if (!authAccount) { + const user = await this.prisma.user.create({ + data: { + email: params.email, + nickname: params.nickname, + authAccounts: { + create: { + provider: params.provider, + providerUserId: params.providerUserId, + email: params.email, + }, + }, + }, + }); + authAccount = { user, /* ... */ }; + } + + // 3. 签发 token + const accessToken = this.tokenService.generateAccessToken(authAccount.user); + const refreshToken = this.tokenService.generateRefreshToken(authAccount.user); + + // 4. refreshToken hash 入库 + await this.tokenService.saveRefreshToken(authAccount.user.id, refreshToken); + + // 5. 返回 + return { accessToken, refreshToken, user: authAccount.user }; +} +``` diff --git a/docs/ios登录流程-总览.md b/docs/ios登录流程-总览.md new file mode 100644 index 0000000..00d96e9 --- /dev/null +++ b/docs/ios登录流程-总览.md @@ -0,0 +1,179 @@ +# iOS 登录流程 —— 总体设计 + +--- + +## 一、核心理解 + +``` +Apple 登录不是你的 App 登录系统本身。 +Apple 只是帮你证明"这个人是谁"。 +真正的登录态,要由你的后端发 accessToken / refreshToken。 +``` + +最终流程: + +``` +iOS 调 Apple 登录 +→ 拿到 Apple identityToken +→ 发给你的 NestJS 后端 +→ 后端校验 Apple token +→ 后端创建 / 查找用户 +→ 后端生成自己的 accessToken + refreshToken +→ iOS 存 Keychain +→ 以后所有接口带 Authorization: Bearer accessToken +``` + +**开发建议**:先做 `dev-login → /users/me → Keychain → 知识库接口`,Apple 登录随后再接,不要让 Apple 流程卡住后端开发。 + +--- + +## 二、认证系统核心模型 + +后端登录系统的本质是建立自己的认证体系: + +``` +users ++ auth_accounts (支持多 provider:DEV、APPLE) ++ refresh_tokens (只存 hash,不存明文) ++ accessToken (短期令牌,JWT) ++ refreshToken (长期令牌,JWT,可轮换/撤销) ++ JwtAuthGuard (全局守卫) ++ /users/me (启动态判定核心接口) +``` + +Apple 登录只是其中一个 provider。核心是通过 `auth_accounts` 表关联第三方身份与本地用户。 + +--- + +## 三、接口清单 + +第一版 5 个接口: + +| 接口 | 用途 | 优先级 | +|------|------|--------| +| `POST /api/auth/dev-login` | 开发调试登录 | ⭐ 先做 | +| `POST /api/auth/refresh` | 刷新登录态 | ⭐ 先做 | +| `GET /api/users/me` | 获取当前用户 | ⭐ 先做 | +| `POST /api/auth/apple` | Apple 正式登录 | 随后接 | +| `POST /api/auth/logout` | 退出登录 | 最后做 | + +--- + +## 四、统一返回格式 + +登录成功后后端统一返回: + +```json +{ + "accessToken": "eyJ...", + "refreshToken": "eyJ...", + "user": { + "id": "user_xxx", + "email": "test@zhixi.app", + "nickname": "测试用户", + "avatarUrl": null, + "role": "USER", + "status": "ACTIVE", + "onboardingCompleted": false + } +} +``` + +iOS 拿到后: + +| 数据 | 存储位置 | 用途 | +|------|---------|------| +| `accessToken` | 内存 | 接口请求 Authorization Header | +| `refreshToken` | Keychain | 恢复登录 | +| `user` | AppSession / UserStore | 用户信息展示 | + +--- + +## 五、后端模块结构 + +``` +src/modules/auth/ +├── auth.controller.ts # 登录/刷新/登出接口 +├── auth.service.ts # 通用登录逻辑(provider 调度) +├── apple-auth.service.ts # Apple identityToken 校验 +├── token.service.ts # JWT 生成/校验 +├── dto/ +│ ├── dev-login.dto.ts +│ ├── apple-login.dto.ts +│ └── refresh-token.dto.ts +├── guards/ +│ └── jwt-auth.guard.ts # 全局认证守卫 +├── decorators/ +│ └── current-user.decorator.ts # 从 JWT 取用户 +└── strategies/ + └── jwt.strategy.ts + +src/modules/users/ +├── users.controller.ts # /users/me +├── users.service.ts +└── dto/ +``` + +--- + +## 六、业务接口安全规则 + +所有业务接口依赖登录体系。**核心规则:不要相信前端传的 `userId`。** + +```http +GET /api/knowledge-bases +Authorization: Bearer accessToken +``` + +后端应从 JWT 拿当前用户: + +```ts +// ✅ 正确:从 token 里取 currentUser.id +where: { + userId: currentUser.id, + deletedAt: null +} + +// ❌ 错误:从请求体取 userId +where: { + userId: body.userId +} +``` + +用户资源接口,只相信 JWT 里的 `currentUser.id`,不允许前端传递 `userId` 参数。 + +--- + +## 七、推荐开发顺序 + +``` +1. Prisma 建 users / auth_accounts / refresh_tokens +2. TokenService:生成 accessToken / refreshToken +3. dev-login 接口 +4. JwtAuthGuard +5. CurrentUser 装饰器 +6. /users/me 接口 +7. iOS 接 dev-login + Keychain + AppSession +8. 知识库接口全部加 JwtAuthGuard +9. Apple 登录接口 +10. refresh 接口 +11. logout 接口 +``` + +--- + +## 八、环境变量最小配置 + +```env +JWT_ACCESS_SECRET=你自己的随机强密钥 +JWT_REFRESH_SECRET=你自己的随机强密钥 +APPLE_BUNDLE_ID=cloud.longde.AIStudyApp +APPLE_ISSUER=https://appleid.apple.com +APPLE_JWKS_URL=https://appleid.apple.com/auth/keys +``` + +--- + +## 九、总结 + +最终一句话:后端登录对接的核心不是"接 Apple 登录按钮",而是先建立自己的认证系统。Apple 登录只是其中一个 provider。先把 `dev-login → token → /users/me → iOS Keychain` 跑通,知识库和学习闭环就不会卡住。 diff --git a/docs/ios登录流程-数据库设计.md b/docs/ios登录流程-数据库设计.md new file mode 100644 index 0000000..3b2fe7a --- /dev/null +++ b/docs/ios登录流程-数据库设计.md @@ -0,0 +1,153 @@ +# iOS 登录流程 —— 数据库设计 + +--- + +## 一、users 表 + +用户主表,存储用户基础信息。 + +```prisma +model User { + id String @id @default(cuid()) + email String? + nickname String? + avatarUrl String? + role UserRole @default(USER) + status UserStatus @default(ACTIVE) + onboardingCompleted Boolean @default(false) + + authAccounts AuthAccount[] + refreshTokens RefreshToken[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum UserRole { + USER + ADMIN + SUPER_ADMIN +} + +enum UserStatus { + ACTIVE + DISABLED + DELETED +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | String (cuid) | 主键 | +| `email` | String? | 邮箱,可选(Apple 登录首次可能提供) | +| `nickname` | String? | 昵称 | +| `avatarUrl` | String? | 头像 URL | +| `role` | UserRole | 角色,默认 USER | +| `status` | UserStatus | 状态,默认 ACTIVE | +| `onboardingCompleted` | Boolean | 是否完成引导,默认 false | +| `authAccounts` | 关联 | 一对多关联 auth_accounts | +| `refreshTokens` | 关联 | 一对多关联 refresh_tokens | + +--- + +## 二、auth_accounts 表 + +记录用户通过什么方式(provider)登录,支持一个用户绑定多个登录方式。 + +```prisma +model AuthAccount { + id String @id @default(cuid()) + userId String + provider AuthProvider + providerUserId String + email String? + + user User @relation(fields: [userId], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([provider, providerUserId]) + @@index([userId]) +} + +enum AuthProvider { + DEV + APPLE +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | String (cuid) | 主键 | +| `userId` | String | 关联 users 表 | +| `provider` | AuthProvider | 登录提供商(DEV / APPLE) | +| `providerUserId` | String | 提供商侧的用户唯一 ID | +| `email` | String? | 提供商侧邮箱 | +| `@@unique([provider, providerUserId])` | 约束 | 同一个提供商的用户唯一 | + +**查找逻辑**: + +``` +Apple 登录时: + provider = APPLE + providerUserId = identityToken 里校验出来的 sub + → 如果不存在,创建 User + AuthAccount + → 如果存在,直接找到对应 User +``` + +--- + +## 三、refresh_tokens 表 + +**重要:refreshToken 不要明文存数据库,只存 hash。** + +```prisma +model RefreshToken { + id String @id @default(cuid()) + userId String + tokenHash String + expiresAt DateTime + revokedAt DateTime? + + user User @relation(fields: [userId], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | String (cuid) | 主键,同时写入 JWT payload 作为 `tokenId` | +| `userId` | String | 关联 users 表 | +| `tokenHash` | String | refreshToken 的 hash 值(SHA-256) | +| `expiresAt` | DateTime | 过期时间 | +| `revokedAt` | DateTime? | 撤销时间(登出时设置) | + +**刷新时的校验链**: + +``` +1. 解析 refreshToken JWT,拿到 userId + tokenId +2. 查 refresh_tokens 表,找到对应记录 +3. 对比 tokenHash +4. 确认 revokedAt 为 null(未撤销) +5. 确认 expiresAt 未过期 +6. 签发新的 accessToken(可选轮换新的 refreshToken) +``` + +**登出时**:将对应记录的 `revokedAt` 设为当前时间。 + +--- + +## 四、ER 关系总结 + +``` +User (1) ──── (N) AuthAccount 一个用户可有多种登录方式 +User (1) ──── (N) RefreshToken 一个用户可有多个活跃 refreshToken(多设备) +``` + +- 用户与登录方式是解耦的:用户是一个独立实体,通过 `auth_accounts` 关联到具体的第三方身份。 +- 这种设计天然支持未来扩展更多登录方式(如 Google、微信等),只需在 `AuthProvider` 枚举中添加即可。 diff --git a/package-lock.json b/package-lock.json index 568d327..2fac95e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "class-validator": "^0.15.1", "helmet": "^8.1.0", "ioredis": "^5.10.1", - "jwks-rsa": "^4.0.1", + "jose": "^6.2.3", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", @@ -7733,22 +7733,6 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/jwks-rsa": { - "version": "4.0.1", - "resolved": "https://registry.npmmirror.com/jwks-rsa/-/jwks-rsa-4.0.1.tgz", - "integrity": "sha512-poXwUA8S4cP9P5N8tZS3xnUDJH8WmwSGfKK9gIaRPdjLHyJtd9iX/cngX9CUIe0Caof5JhK2EbN7N5lnnaf9NA==", - "license": "MIT", - "dependencies": { - "@types/jsonwebtoken": "^9.0.4", - "debug": "^4.3.4", - "jose": "^6.1.3", - "limiter": "^1.1.5", - "lru-memoizer": "^3.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >= 23.0.0" - } - }, "node_modules/jws": { "version": "4.0.1", "resolved": "https://registry.npmmirror.com/jws/-/jws-4.0.1.tgz", @@ -7799,11 +7783,6 @@ "integrity": "sha512-N12qmdu0BM1wVNkMKYOoJR4fTOZDblrKNsOqGbKoUZrYsYLX2zx1O5X+vhK0WJPBU/+/kh9tCr8x0a7t1puGWg==", "license": "MIT" }, - "node_modules/limiter": { - "version": "1.1.5", - "resolved": "https://registry.npmmirror.com/limiter/-/limiter-1.1.5.tgz", - "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" - }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -7866,12 +7845,6 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, - "node_modules/lodash.clonedeep": { - "version": "4.5.0", - "resolved": "https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", - "license": "MIT" - }, "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -7967,25 +7940,6 @@ "yallist": "^3.0.2" } }, - "node_modules/lru-memoizer": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/lru-memoizer/-/lru-memoizer-3.0.0.tgz", - "integrity": "sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==", - "license": "MIT", - "dependencies": { - "lodash.clonedeep": "^4.5.0", - "lru-cache": "^11.0.1" - } - }, - "node_modules/lru-memoizer/node_modules/lru-cache": { - "version": "11.3.6", - "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.3.6.tgz", - "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmmirror.com/luxon/-/luxon-3.7.2.tgz", diff --git a/package.json b/package.json index 09dece2..939952a 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "class-validator": "^0.15.1", "helmet": "^8.1.0", "ioredis": "^5.10.1", - "jwks-rsa": "^4.0.1", + "jose": "^6.2.3", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5ebbe2c..8399348 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,6 +13,7 @@ model User { email String? @db.VarChar(255) nickname String? @db.VarChar(100) avatarUrl String? @db.VarChar(500) + role String @default("USER") @db.VarChar(32) status String @default("active") @db.VarChar(32) onboardingCompleted Boolean @default(false) lastLoginAt DateTime? diff --git a/src/common/guards/jwt-auth.guard.ts b/src/common/guards/jwt-auth.guard.ts index a83bba1..9b35bfa 100644 --- a/src/common/guards/jwt-auth.guard.ts +++ b/src/common/guards/jwt-auth.guard.ts @@ -27,7 +27,7 @@ export class JwtAuthGuard implements CanActivate { const payload = await this.jwtService.verifyAsync(token, { secret: this.configService.get('jwt.secret'), }); - request.user = { id: String(payload.sub), email: payload.email }; + request.user = { id: String(payload.sub), email: payload.email, role: payload.role }; return true; } catch { throw new UnauthorizedException('登录已过期,请重新登录'); diff --git a/src/common/guards/optional-auth.guard.ts b/src/common/guards/optional-auth.guard.ts index 12732aa..c6182ca 100644 --- a/src/common/guards/optional-auth.guard.ts +++ b/src/common/guards/optional-auth.guard.ts @@ -20,7 +20,7 @@ export class OptionalAuthGuard implements CanActivate { const payload = await this.jwtService.verifyAsync(token, { secret: this.configService.get('jwt.secret'), }); - request.user = { id: String(payload.sub), email: payload.email }; + request.user = { id: String(payload.sub), email: payload.email, role: payload.role }; } catch {} return true; } diff --git a/src/common/types/index.ts b/src/common/types/index.ts index 6b0e053..f5f2b67 100644 --- a/src/common/types/index.ts +++ b/src/common/types/index.ts @@ -2,6 +2,8 @@ export interface UserPayload { id: string; email?: string; nickname?: string; + role?: string; + status?: string; } export interface PaginationMeta { diff --git a/src/config/jwt.config.ts b/src/config/jwt.config.ts index bd0a2c3..5d28fe6 100644 --- a/src/config/jwt.config.ts +++ b/src/config/jwt.config.ts @@ -1,20 +1,28 @@ import { registerAs } from '@nestjs/config'; export default registerAs('jwt', () => { - const secret = process.env.JWT_SECRET; - if (!secret || secret === 'change_me_in_production') { + const accessSecret = process.env.JWT_ACCESS_SECRET || process.env.JWT_SECRET; + const refreshSecret = process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET; + + if ( + !accessSecret || + accessSecret === 'change_me_in_production' + ) { if (process.env.NODE_ENV === 'production') { throw new Error( - '生产环境必须设置环境变量 JWT_SECRET,不能使用默认值', + '生产环境必须设置环境变量 JWT_ACCESS_SECRET 或 JWT_SECRET,不能使用默认值', ); } console.warn( '\n⚠️ 警告: JWT_SECRET 使用的是默认值 "change_me_in_production"\n' + - ' 部署到生产环境前请务必设置环境变量 JWT_SECRET\n', + ' 部署到生产环境前请务必设置环境变量 JWT_ACCESS_SECRET\n', ); } + return { - secret: secret || 'change_me_in_production', + secret: accessSecret || 'change_me_in_production', + accessSecret: accessSecret || 'change_me_in_production', + refreshSecret: refreshSecret || 'change_me_in_production', expiresIn: process.env.JWT_EXPIRES_IN || '1h', refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d', }; diff --git a/src/modules/auth/apple-auth.service.ts b/src/modules/auth/apple-auth.service.ts new file mode 100644 index 0000000..ee01a3f --- /dev/null +++ b/src/modules/auth/apple-auth.service.ts @@ -0,0 +1,79 @@ +import * as crypto from 'crypto'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; + +@Injectable() +export class AppleAuthService { + private readonly appleIssuer: string; + private readonly appleBundleId: string; + private readonly jwks: ReturnType; + + constructor(private readonly configService: ConfigService) { + this.appleIssuer = this.configService.get( + 'apple.issuer', + 'https://appleid.apple.com', + ); + this.appleBundleId = this.configService.get('apple.bundleId', ''); + this.jwks = createRemoteJWKSet( + new URL('https://appleid.apple.com/auth/keys'), + ); + } + + async verifyIdentityToken(identityToken: string): Promise<{ + appleUserId: string; + email?: string; + emailVerified?: boolean; + }> { + if (!this.appleBundleId) { + return this.verifyMock(identityToken); + } + return this.verifyReal(identityToken); + } + + private verifyMock(identityToken: string): { + appleUserId: string; + } { + if (!identityToken || identityToken.trim().length < 4) { + throw new UnauthorizedException('identityToken 无效'); + } + return { + appleUserId: crypto + .createHash('sha256') + .update(`apple-mock:${identityToken}`) + .digest('hex') + .slice(0, 64), + }; + } + + private async verifyReal(identityToken: string): Promise<{ + appleUserId: string; + email?: string; + emailVerified?: boolean; + }> { + try { + const { payload } = await jwtVerify(identityToken, this.jwks, { + issuer: this.appleIssuer, + audience: this.appleBundleId, + }); + + return { + appleUserId: payload.sub!, + email: + typeof payload.email === 'string' ? payload.email : undefined, + emailVerified: payload.email_verified === true || payload.email_verified === 'true', + }; + } catch (err: any) { + const msg: string = err?.message ?? ''; + if (msg.includes('audience')) { + throw new UnauthorizedException( + `identityToken audience 不匹配,期望 ${this.appleBundleId}`, + ); + } + if (msg.includes('issuer')) { + throw new UnauthorizedException('identityToken issuer 无效'); + } + throw new UnauthorizedException('identityToken 验证失败'); + } + } +} diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index c4b3e43..cd1ee70 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -6,41 +6,34 @@ import { HttpCode, HttpStatus, Req, - BadRequestException, + UseGuards, } from '@nestjs/common'; import { AuthService } from './auth.service'; +import { AppleLoginDto, DevLoginDto, RefreshDto } from './dto'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import type { Request } from 'express'; -import { IsString, Allow, IsOptional } from 'class-validator'; - -class AppleLoginDto { - @IsString() - identityToken: string; - - @IsString() - authorizationCode: string; - - @Allow() - @IsOptional() - user?: any; -} - -class RefreshDto { - @IsString() - refreshToken: string; -} @ApiTags('auth') @Controller('auth') export class AuthController { constructor(private readonly authService: AuthService) {} + @Post('dev-login') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '开发登录(仅非生产环境)' }) + @ApiResponse({ status: 200, description: '登录成功' }) + @ApiResponse({ status: 403, description: '生产环境禁用' }) + async devLogin(@Body() dto: DevLoginDto) { + return this.authService.devLogin(dto); + } + @Post('apple') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Apple 登录' }) @ApiResponse({ status: 200, description: '登录成功' }) @ApiResponse({ status: 401, description: '身份验证失败' }) - async appleLogin(@Body() body: AppleLoginDto) { - return this.authService.appleLogin(body); + async appleLogin(@Body() dto: AppleLoginDto) { + return this.authService.appleLogin(dto); } @Post('refresh') @@ -48,22 +41,19 @@ export class AuthController { @ApiOperation({ summary: '刷新令牌' }) @ApiResponse({ status: 200, description: '刷新成功' }) @ApiResponse({ status: 401, description: '刷新令牌无效' }) - async refresh(@Body() body: RefreshDto) { - if (!body.refreshToken) { - throw new BadRequestException('缺少 refreshToken'); - } - return this.authService.refresh(body.refreshToken); + async refresh(@Body() dto: RefreshDto) { + return this.authService.refresh(dto.refreshToken); } + @UseGuards(JwtAuthGuard) @Post('logout') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '退出登录' }) @ApiResponse({ status: 200, description: '退出成功' }) - async logout(@Req() req: Request) { + @ApiResponse({ status: 401, description: '未登录' }) + async logout(@Req() req: Request, @Body() dto: RefreshDto) { const user = (req as any).user; - if (user?.id) { - await this.authService.logout(user.id); - } + await this.authService.logout(user.id, dto.refreshToken); return { success: true, message: '已退出登录' }; } } diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 679a6f6..abb58d5 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; +import { AppleAuthService } from './apple-auth.service'; +import { TokenService } from './token.service'; @Module({ controllers: [AuthController], - providers: [AuthService], - exports: [AuthService], + providers: [AuthService, AppleAuthService, TokenService], + exports: [AuthService, TokenService], }) export class AuthModule {} diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index d508938..692979f 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,60 +1,88 @@ -import * as crypto from 'crypto'; -import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; -import { ConfigService } from '@nestjs/config'; +import { Injectable, ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { PrismaService } from '../../infrastructure/database/prisma.service'; -import jwksClient from 'jwks-rsa'; -import jwt from 'jsonwebtoken'; - -interface AppleIdTokenPayload { - sub: string; - email?: string; - email_verified?: string | boolean; - is_private_email?: string | boolean; - aud: string; - iss: string; - exp: number; - iat: number; -} +import { AppleAuthService } from './apple-auth.service'; +import { TokenService } from './token.service'; +import type { AppleLoginDto, DevLoginDto } from './dto'; @Injectable() export class AuthService { - private jwks: jwksClient.JwksClient; - constructor( - private readonly nativeJwtService: JwtService, private readonly prisma: PrismaService, - private readonly configService: ConfigService, + private readonly appleAuthService: AppleAuthService, + private readonly tokenService: TokenService, ) {} - async appleLogin(params: { - identityToken: string; - authorizationCode: string; - user?: { name?: { firstName?: string; lastName?: string }; email?: string }; - }) { - const appleUserId = await this.verifyAppleIdentity( - params.identityToken, - params.authorizationCode, - ); + async devLogin(dto: DevLoginDto) { + if (process.env.NODE_ENV === 'production') { + throw new ForbiddenException('dev-login is disabled in production'); + } + + const devSecret = process.env.DEV_SECRET; + if (!devSecret || dto.devSecret !== devSecret) { + throw new UnauthorizedException('devSecret 无效'); + } + + const providerUserId = dto.email; let account = await this.prisma.authAccount.findUnique({ - where: { provider_providerUserId: { provider: 'apple', providerUserId: appleUserId } }, + where: { + provider_providerUserId: { + provider: 'DEV', + providerUserId, + }, + }, + include: { user: true }, + }); + + if (!account) { + account = await this.prisma.authAccount.create({ + data: { + provider: 'DEV', + providerUserId, + email: dto.email, + user: { + create: { + email: dto.email, + nickname: dto.nickname || '测试用户', + status: 'active', + }, + }, + }, + include: { user: true }, + }); + } + + return this.buildLoginResponse(account.user); + } + + async appleLogin(dto: AppleLoginDto) { + const { appleUserId, email: appleEmail } = + await this.appleAuthService.verifyIdentityToken(dto.identityToken); + + let account = await this.prisma.authAccount.findUnique({ + where: { + provider_providerUserId: { + provider: 'APPLE', + providerUserId: appleUserId, + }, + }, include: { user: true }, }); if (!account) { const displayName = - params.user?.name - ? `${params.user.name.lastName || ''}${params.user.name.firstName || ''}` + dto.fullName?.givenName + ? `${dto.fullName.familyName || ''}${dto.fullName.givenName}` : undefined; + account = await this.prisma.authAccount.create({ data: { - provider: 'apple', + provider: 'APPLE', providerUserId: appleUserId, - email: params.user?.email, + email: appleEmail || dto.email || null, user: { create: { - email: params.user?.email, + email: appleEmail || dto.email || null, nickname: displayName || undefined, status: 'active', }, @@ -64,30 +92,11 @@ export class AuthService { }); } - const accessToken = await this.nativeJwtService.signAsync({ - sub: String(account.user.id), - email: account.user.email, - }); - - const refreshToken = crypto.randomBytes(48).toString('hex'); - const refreshTokenHash = crypto - .createHash('sha256') - .update(refreshToken) - .digest('hex'); - - await this.prisma.refreshToken.create({ - data: { - userId: account.user.id, - tokenHash: refreshTokenHash, - expiresAt: new Date(Date.now() + 7 * 86400000), - }, - }); - - return { accessToken, refreshToken, expiresIn: 3600 }; + return this.buildLoginResponse(account.user); } async refresh(refreshToken: string) { - const hash = crypto.createHash('sha256').update(refreshToken).digest('hex'); + const hash = this.tokenService.hashToken(refreshToken); const stored = await this.prisma.refreshToken.findFirst({ where: { tokenHash: hash, revokedAt: null }, include: { user: true }, @@ -102,11 +111,8 @@ export class AuthService { data: { revokedAt: new Date() }, }); - const newRefreshToken = crypto.randomBytes(48).toString('hex'); - const newHash = crypto - .createHash('sha256') - .update(newRefreshToken) - .digest('hex'); + const { token: newRefreshToken, hash: newHash } = + this.tokenService.generateRefreshToken(); await this.prisma.refreshToken.create({ data: { @@ -116,101 +122,81 @@ export class AuthService { }, }); - const accessToken = await this.nativeJwtService.signAsync({ - sub: String(stored.user.id), - email: stored.user.email, + const accessToken = await this.tokenService.generateAccessToken( + stored.user, + ); + + return { + accessToken, + refreshToken: newRefreshToken, + user: this.serializeUser(stored.user), + }; + } + + async logout(userId: bigint | string, refreshToken: string) { + const hash = this.tokenService.hashToken(refreshToken); + const stored = await this.prisma.refreshToken.findFirst({ + where: { + tokenHash: hash, + userId: BigInt(userId), + revokedAt: null, + }, }); - return { accessToken, refreshToken: newRefreshToken, expiresIn: 3600 }; + if (stored) { + await this.prisma.refreshToken.update({ + where: { id: stored.id }, + data: { revokedAt: new Date() }, + }); + } } - async logout(userId: number) { - await this.prisma.refreshToken.updateMany({ - where: { userId, revokedAt: null }, - data: { revokedAt: new Date() }, + private async buildLoginResponse(user: { + id: bigint; + email: string | null; + nickname: string | null; + avatarUrl: string | null; + role: string; + status: string; + onboardingCompleted: boolean; + }) { + const accessToken = await this.tokenService.generateAccessToken(user); + + const { token: refreshToken, hash } = + this.tokenService.generateRefreshToken(); + + await this.prisma.refreshToken.create({ + data: { + userId: user.id, + tokenHash: hash, + expiresAt: new Date(Date.now() + 7 * 86400000), + }, }); + + return { + accessToken, + refreshToken, + user: this.serializeUser(user), + }; } - private async verifyAppleIdentity( - identityToken: string, - authorizationCode: string, - ): Promise { - if (this.isMockMode()) { - return this.verifyMockApple(identityToken); - } - return this.verifyRealApple(identityToken); - } - - private isMockMode(): boolean { - const bundleId = this.configService.get('apple.bundleId'); - return !bundleId; - } - - private verifyMockApple(identityToken: string): string { - if (!identityToken || identityToken.trim().length < 4) { - throw new UnauthorizedException('identityToken 无效'); - } - return crypto - .createHash('sha256') - .update(`apple-mock:${identityToken}`) - .digest('hex') - .slice(0, 64); - } - - private getJwksClient(): jwksClient.JwksClient { - if (!this.jwks) { - const jwksUrl = this.configService.get( - 'apple.jwksUrl', - 'https://appleid.apple.com/auth/keys', - ); - this.jwks = jwksClient({ jwksUri: jwksUrl, cache: true, rateLimit: true }); - } - return this.jwks!; - } - - private async verifyRealApple(identityToken: string): Promise { - const bundleId = this.configService.get('apple.bundleId'); - const issuer = this.configService.get('apple.issuer', 'https://appleid.apple.com'); - - const decodedHeader = jwt.decode(identityToken, { complete: true }); - if (!decodedHeader || typeof decodedHeader === 'string') { - throw new UnauthorizedException('无法解析 identityToken'); - } - - const kid = decodedHeader.header.kid; - if (!kid) { - throw new UnauthorizedException('identityToken 缺少 kid'); - } - - let publicKey: string; - try { - const client = this.getJwksClient(); - const key = await client.getSigningKey(kid); - publicKey = key.getPublicKey(); - } catch { - throw new UnauthorizedException('无法获取 Apple 公钥,请稍后重试'); - } - - let payload: AppleIdTokenPayload; - try { - payload = jwt.verify(identityToken, publicKey, { - algorithms: ['RS256'], - issuer, - audience: bundleId, - }) as AppleIdTokenPayload; - } catch (err: any) { - const msg = err.message || ''; - if (msg.includes('audience')) { - throw new UnauthorizedException( - `identityToken audience 不匹配,期望 ${bundleId}`, - ); - } - if (msg.includes('issuer')) { - throw new UnauthorizedException('identityToken issuer 无效'); - } - throw new UnauthorizedException('identityToken 验证失败'); - } - - return payload.sub; + private serializeUser(user: { + id: bigint; + email: string | null; + nickname: string | null; + avatarUrl: string | null; + role: string; + status: string; + onboardingCompleted: boolean; + }) { + return { + id: String(user.id), + email: user.email, + nickname: user.nickname, + avatarUrl: user.avatarUrl, + role: user.role, + status: user.status, + onboardingCompleted: user.onboardingCompleted, + }; } } diff --git a/src/modules/auth/dto/apple-login.dto.ts b/src/modules/auth/dto/apple-login.dto.ts new file mode 100644 index 0000000..76d5ef1 --- /dev/null +++ b/src/modules/auth/dto/apple-login.dto.ts @@ -0,0 +1,39 @@ +import { IsString, IsNotEmpty, IsOptional, IsEmail } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class AppleLoginDto { + @ApiProperty({ description: 'Apple identityToken (JWT)' }) + @IsString() + @IsNotEmpty() + identityToken: string; + + @ApiPropertyOptional({ description: 'Apple authorizationCode' }) + @IsOptional() + @IsString() + authorizationCode?: string; + + @ApiPropertyOptional({ description: 'Apple userIdentifier' }) + @IsOptional() + @IsString() + userIdentifier?: string; + + @ApiPropertyOptional({ description: 'Apple 返回的邮箱(仅首次返回)' }) + @Transform(({ value }) => (value === '' ? undefined : value)) + @IsOptional() + @IsEmail() + email?: string; + + @ApiPropertyOptional({ description: 'Apple 返回的姓名(仅首次返回)' }) + @IsOptional() + fullName?: { + givenName?: string; + familyName?: string; + }; + + @ApiPropertyOptional({ description: 'Apple nonce(安全校验用)' }) + @Transform(({ value }) => (value === '' ? undefined : value)) + @IsOptional() + @IsString() + nonce?: string; +} diff --git a/src/modules/auth/dto/dev-login.dto.ts b/src/modules/auth/dto/dev-login.dto.ts new file mode 100644 index 0000000..bc45981 --- /dev/null +++ b/src/modules/auth/dto/dev-login.dto.ts @@ -0,0 +1,17 @@ +import { IsString, IsEmail, IsOptional } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class DevLoginDto { + @ApiProperty({ description: '开发测试邮箱' }) + @IsEmail() + email: string; + + @ApiPropertyOptional({ description: '昵称' }) + @IsOptional() + @IsString() + nickname?: string; + + @ApiProperty({ description: '开发密钥' }) + @IsString() + devSecret: string; +} diff --git a/src/modules/auth/dto/index.ts b/src/modules/auth/dto/index.ts new file mode 100644 index 0000000..12e3488 --- /dev/null +++ b/src/modules/auth/dto/index.ts @@ -0,0 +1,3 @@ +export { AppleLoginDto } from './apple-login.dto'; +export { DevLoginDto } from './dev-login.dto'; +export { RefreshDto } from './refresh-token.dto'; diff --git a/src/modules/auth/dto/refresh-token.dto.ts b/src/modules/auth/dto/refresh-token.dto.ts new file mode 100644 index 0000000..fc5fd71 --- /dev/null +++ b/src/modules/auth/dto/refresh-token.dto.ts @@ -0,0 +1,9 @@ +import { IsString, IsNotEmpty } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class RefreshDto { + @ApiProperty({ description: 'refreshToken' }) + @IsString() + @IsNotEmpty() + refreshToken: string; +} diff --git a/src/modules/auth/token.service.ts b/src/modules/auth/token.service.ts new file mode 100644 index 0000000..d39f634 --- /dev/null +++ b/src/modules/auth/token.service.ts @@ -0,0 +1,26 @@ +import * as crypto from 'crypto'; +import { Injectable } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; + +@Injectable() +export class TokenService { + constructor(private readonly jwtService: JwtService) {} + + generateAccessToken(user: { id: bigint; email?: string | null; role?: string | null }): Promise { + return this.jwtService.signAsync({ + sub: String(user.id), + email: user.email, + role: user.role, + }); + } + + generateRefreshToken(): { token: string; hash: string } { + const token = crypto.randomBytes(48).toString('hex'); + const hash = crypto.createHash('sha256').update(token).digest('hex'); + return { token, hash }; + } + + hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); + } +} diff --git a/src/modules/users/users.repository.ts b/src/modules/users/users.repository.ts index 8f4874a..0254454 100644 --- a/src/modules/users/users.repository.ts +++ b/src/modules/users/users.repository.ts @@ -1,37 +1,63 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; @Injectable() export class UsersRepository { - private profiles: Map = new Map(); - private preferences: Map = new Map(); + constructor(private readonly prisma: PrismaService) {} async findProfileByUserId(userId: string) { - return this.profiles.get(userId) || { - userId, - nickname: '学习者', - learningDirection: '', - bio: '', + const user = await this.prisma.user.findUnique({ + where: { id: BigInt(userId) }, + select: { + id: true, + email: true, + nickname: true, + avatarUrl: true, + role: true, + status: true, + onboardingCompleted: true, + createdAt: true, + }, + }); + + if (!user) { + throw new NotFoundException('用户不存在'); + } + + return { + id: String(user.id), + email: user.email, + nickname: user.nickname, + avatarUrl: user.avatarUrl, + role: user.role, + status: user.status, + onboardingCompleted: user.onboardingCompleted, + createdAt: user.createdAt, }; } async updateProfile(userId: string, dto: any) { - const existing = (await this.findProfileByUserId(userId)) || {}; - const updated = { ...existing, ...dto }; - this.profiles.set(userId, updated); - return updated; + return this.prisma.user.update({ + where: { id: BigInt(userId) }, + data: { + nickname: dto.nickname, + avatarUrl: dto.avatarUrl, + }, + }); } async updatePreferences(userId: string, dto: any) { - const existing = this.preferences.get(userId) || { - userId, - defaultFocusMinutes: 25, - aiSuggestionLevel: 'normal', - language: 'zh-CN', - appearance: 'system', - notificationEnabled: true, - }; - const updated = { ...existing, ...dto }; - this.preferences.set(userId, updated); - return updated; + return this.prisma.userPreference.upsert({ + where: { userId: BigInt(userId) }, + create: { + userId: BigInt(userId), + defaultFocusMinutes: dto.defaultFocusMinutes ?? 25, + aiSuggestionLevel: dto.aiSuggestionLevel ?? 'normal', + language: dto.language ?? 'zh-CN', + appearance: dto.appearance ?? 'system', + notificationEnabled: dto.notificationEnabled ?? true, + }, + update: dto, + }); } }