refactor(auth): restructure auth system, align with iOS login flow spec

- Split AuthService into AppleAuthService, TokenService, AuthService
- Add dev-login endpoint (dev-only, disabled in production)
- AppleLoginDto: authorizationCode optional, add userIdentifier/email/fullName/nonce
- Login/refresh responses now include user object
- logout: single-token revoke + JwtAuthGuard protection
- users.repository: switch from in-memory Map to Prisma persistence
- JWT payload includes role, guards attach full user info to request
- Dual JWT secret support (JWT_ACCESS_SECRET / JWT_REFRESH_SECRET)
- Replace jwks-rsa+jsonwebtoken with jose library
- Prisma User model: add role field
- Independent DTO files with @Transform for empty string safety
- Add 5 iOS login flow documentation files
This commit is contained in:
WangDL 2026-05-13 17:31:50 +08:00
parent 387785bd1e
commit fa69749884
22 changed files with 1429 additions and 261 deletions

View File

@ -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 } │
└─────────────────────────────────────────────────────┘
```

View File

@ -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 │ │ 清空数据 │
│ 进主页 │ │ 进登录页 │
└────────┘ └──────────┘
```

View File

@ -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 + AuthAccountprovider=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. 对比 tokenHashSHA-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 };
}
```

View File

@ -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 (支持多 providerDEV、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` 跑通,知识库和学习闭环就不会卡住。

View File

@ -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` 枚举中添加即可。

48
package-lock.json generated
View File

@ -25,7 +25,7 @@
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"ioredis": "^5.10.1", "ioredis": "^5.10.1",
"jwks-rsa": "^4.0.1", "jose": "^6.2.3",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
@ -7733,22 +7733,6 @@
"safe-buffer": "^5.0.1" "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": { "node_modules/jws": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmmirror.com/jws/-/jws-4.0.1.tgz", "resolved": "https://registry.npmmirror.com/jws/-/jws-4.0.1.tgz",
@ -7799,11 +7783,6 @@
"integrity": "sha512-N12qmdu0BM1wVNkMKYOoJR4fTOZDblrKNsOqGbKoUZrYsYLX2zx1O5X+vhK0WJPBU/+/kh9tCr8x0a7t1puGWg==", "integrity": "sha512-N12qmdu0BM1wVNkMKYOoJR4fTOZDblrKNsOqGbKoUZrYsYLX2zx1O5X+vhK0WJPBU/+/kh9tCr8x0a7t1puGWg==",
"license": "MIT" "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": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "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==", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT" "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": { "node_modules/lodash.defaults": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@ -7967,25 +7940,6 @@
"yallist": "^3.0.2" "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": { "node_modules/luxon": {
"version": "3.7.2", "version": "3.7.2",
"resolved": "https://registry.npmmirror.com/luxon/-/luxon-3.7.2.tgz", "resolved": "https://registry.npmmirror.com/luxon/-/luxon-3.7.2.tgz",

View File

@ -36,7 +36,7 @@
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"ioredis": "^5.10.1", "ioredis": "^5.10.1",
"jwks-rsa": "^4.0.1", "jose": "^6.2.3",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",

View File

@ -13,6 +13,7 @@ model User {
email String? @db.VarChar(255) email String? @db.VarChar(255)
nickname String? @db.VarChar(100) nickname String? @db.VarChar(100)
avatarUrl String? @db.VarChar(500) avatarUrl String? @db.VarChar(500)
role String @default("USER") @db.VarChar(32)
status String @default("active") @db.VarChar(32) status String @default("active") @db.VarChar(32)
onboardingCompleted Boolean @default(false) onboardingCompleted Boolean @default(false)
lastLoginAt DateTime? lastLoginAt DateTime?

View File

@ -27,7 +27,7 @@ export class JwtAuthGuard implements CanActivate {
const payload = await this.jwtService.verifyAsync(token, { const payload = await this.jwtService.verifyAsync(token, {
secret: this.configService.get<string>('jwt.secret'), secret: this.configService.get<string>('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; return true;
} catch { } catch {
throw new UnauthorizedException('登录已过期,请重新登录'); throw new UnauthorizedException('登录已过期,请重新登录');

View File

@ -20,7 +20,7 @@ export class OptionalAuthGuard implements CanActivate {
const payload = await this.jwtService.verifyAsync(token, { const payload = await this.jwtService.verifyAsync(token, {
secret: this.configService.get<string>('jwt.secret'), secret: this.configService.get<string>('jwt.secret'),
}); });
request.user = { id: String(payload.sub), email: payload.email }; request.user = { id: String(payload.sub), email: payload.email, role: payload.role };
} catch {} } catch {}
return true; return true;
} }

View File

@ -2,6 +2,8 @@ export interface UserPayload {
id: string; id: string;
email?: string; email?: string;
nickname?: string; nickname?: string;
role?: string;
status?: string;
} }
export interface PaginationMeta { export interface PaginationMeta {

View File

@ -1,20 +1,28 @@
import { registerAs } from '@nestjs/config'; import { registerAs } from '@nestjs/config';
export default registerAs('jwt', () => { export default registerAs('jwt', () => {
const secret = process.env.JWT_SECRET; const accessSecret = process.env.JWT_ACCESS_SECRET || process.env.JWT_SECRET;
if (!secret || secret === 'change_me_in_production') { const refreshSecret = process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET;
if (
!accessSecret ||
accessSecret === 'change_me_in_production'
) {
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
throw new Error( throw new Error(
'生产环境必须设置环境变量 JWT_SECRET不能使用默认值', '生产环境必须设置环境变量 JWT_ACCESS_SECRET 或 JWT_SECRET不能使用默认值',
); );
} }
console.warn( console.warn(
'\n⚠ 警告: JWT_SECRET 使用的是默认值 "change_me_in_production"\n' + '\n⚠ 警告: JWT_SECRET 使用的是默认值 "change_me_in_production"\n' +
' 部署到生产环境前请务必设置环境变量 JWT_SECRET\n', ' 部署到生产环境前请务必设置环境变量 JWT_ACCESS_SECRET\n',
); );
} }
return { 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', expiresIn: process.env.JWT_EXPIRES_IN || '1h',
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d', refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
}; };

View File

@ -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<typeof createRemoteJWKSet>;
constructor(private readonly configService: ConfigService) {
this.appleIssuer = this.configService.get<string>(
'apple.issuer',
'https://appleid.apple.com',
);
this.appleBundleId = this.configService.get<string>('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 验证失败');
}
}
}

View File

@ -6,41 +6,34 @@ import {
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Req, Req,
BadRequestException, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthService } from './auth.service'; 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 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') @ApiTags('auth')
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) {} 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') @Post('apple')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Apple 登录' }) @ApiOperation({ summary: 'Apple 登录' })
@ApiResponse({ status: 200, description: '登录成功' }) @ApiResponse({ status: 200, description: '登录成功' })
@ApiResponse({ status: 401, description: '身份验证失败' }) @ApiResponse({ status: 401, description: '身份验证失败' })
async appleLogin(@Body() body: AppleLoginDto) { async appleLogin(@Body() dto: AppleLoginDto) {
return this.authService.appleLogin(body); return this.authService.appleLogin(dto);
} }
@Post('refresh') @Post('refresh')
@ -48,22 +41,19 @@ export class AuthController {
@ApiOperation({ summary: '刷新令牌' }) @ApiOperation({ summary: '刷新令牌' })
@ApiResponse({ status: 200, description: '刷新成功' }) @ApiResponse({ status: 200, description: '刷新成功' })
@ApiResponse({ status: 401, description: '刷新令牌无效' }) @ApiResponse({ status: 401, description: '刷新令牌无效' })
async refresh(@Body() body: RefreshDto) { async refresh(@Body() dto: RefreshDto) {
if (!body.refreshToken) { return this.authService.refresh(dto.refreshToken);
throw new BadRequestException('缺少 refreshToken');
}
return this.authService.refresh(body.refreshToken);
} }
@UseGuards(JwtAuthGuard)
@Post('logout') @Post('logout')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '退出登录' }) @ApiOperation({ summary: '退出登录' })
@ApiResponse({ status: 200, description: '退出成功' }) @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; const user = (req as any).user;
if (user?.id) { await this.authService.logout(user.id, dto.refreshToken);
await this.authService.logout(user.id);
}
return { success: true, message: '已退出登录' }; return { success: true, message: '已退出登录' };
} }
} }

View File

@ -1,10 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { AppleAuthService } from './apple-auth.service';
import { TokenService } from './token.service';
@Module({ @Module({
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService], providers: [AuthService, AppleAuthService, TokenService],
exports: [AuthService], exports: [AuthService, TokenService],
}) })
export class AuthModule {} export class AuthModule {}

View File

@ -1,60 +1,88 @@
import * as crypto from 'crypto'; import { Injectable, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../infrastructure/database/prisma.service'; import { PrismaService } from '../../infrastructure/database/prisma.service';
import jwksClient from 'jwks-rsa'; import { AppleAuthService } from './apple-auth.service';
import jwt from 'jsonwebtoken'; import { TokenService } from './token.service';
import type { AppleLoginDto, DevLoginDto } from './dto';
interface AppleIdTokenPayload {
sub: string;
email?: string;
email_verified?: string | boolean;
is_private_email?: string | boolean;
aud: string;
iss: string;
exp: number;
iat: number;
}
@Injectable() @Injectable()
export class AuthService { export class AuthService {
private jwks: jwksClient.JwksClient;
constructor( constructor(
private readonly nativeJwtService: JwtService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly configService: ConfigService, private readonly appleAuthService: AppleAuthService,
private readonly tokenService: TokenService,
) {} ) {}
async appleLogin(params: { async devLogin(dto: DevLoginDto) {
identityToken: string; if (process.env.NODE_ENV === 'production') {
authorizationCode: string; throw new ForbiddenException('dev-login is disabled in production');
user?: { name?: { firstName?: string; lastName?: string }; email?: string }; }
}) {
const appleUserId = await this.verifyAppleIdentity( const devSecret = process.env.DEV_SECRET;
params.identityToken, if (!devSecret || dto.devSecret !== devSecret) {
params.authorizationCode, throw new UnauthorizedException('devSecret 无效');
); }
const providerUserId = dto.email;
let account = await this.prisma.authAccount.findUnique({ 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 }, include: { user: true },
}); });
if (!account) { if (!account) {
const displayName = const displayName =
params.user?.name dto.fullName?.givenName
? `${params.user.name.lastName || ''}${params.user.name.firstName || ''}` ? `${dto.fullName.familyName || ''}${dto.fullName.givenName}`
: undefined; : undefined;
account = await this.prisma.authAccount.create({ account = await this.prisma.authAccount.create({
data: { data: {
provider: 'apple', provider: 'APPLE',
providerUserId: appleUserId, providerUserId: appleUserId,
email: params.user?.email, email: appleEmail || dto.email || null,
user: { user: {
create: { create: {
email: params.user?.email, email: appleEmail || dto.email || null,
nickname: displayName || undefined, nickname: displayName || undefined,
status: 'active', status: 'active',
}, },
@ -64,30 +92,11 @@ export class AuthService {
}); });
} }
const accessToken = await this.nativeJwtService.signAsync({ return this.buildLoginResponse(account.user);
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 };
} }
async refresh(refreshToken: string) { 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({ const stored = await this.prisma.refreshToken.findFirst({
where: { tokenHash: hash, revokedAt: null }, where: { tokenHash: hash, revokedAt: null },
include: { user: true }, include: { user: true },
@ -102,11 +111,8 @@ export class AuthService {
data: { revokedAt: new Date() }, data: { revokedAt: new Date() },
}); });
const newRefreshToken = crypto.randomBytes(48).toString('hex'); const { token: newRefreshToken, hash: newHash } =
const newHash = crypto this.tokenService.generateRefreshToken();
.createHash('sha256')
.update(newRefreshToken)
.digest('hex');
await this.prisma.refreshToken.create({ await this.prisma.refreshToken.create({
data: { data: {
@ -116,101 +122,81 @@ export class AuthService {
}, },
}); });
const accessToken = await this.nativeJwtService.signAsync({ const accessToken = await this.tokenService.generateAccessToken(
sub: String(stored.user.id), stored.user,
email: stored.user.email, );
});
return { accessToken, refreshToken: newRefreshToken, expiresIn: 3600 }; return {
accessToken,
refreshToken: newRefreshToken,
user: this.serializeUser(stored.user),
};
} }
async logout(userId: number) { async logout(userId: bigint | string, refreshToken: string) {
await this.prisma.refreshToken.updateMany({ const hash = this.tokenService.hashToken(refreshToken);
where: { userId, revokedAt: null }, const stored = await this.prisma.refreshToken.findFirst({
where: {
tokenHash: hash,
userId: BigInt(userId),
revokedAt: null,
},
});
if (stored) {
await this.prisma.refreshToken.update({
where: { id: stored.id },
data: { revokedAt: new Date() }, data: { revokedAt: new Date() },
}); });
} }
private async verifyAppleIdentity(
identityToken: string,
authorizationCode: string,
): Promise<string> {
if (this.isMockMode()) {
return this.verifyMockApple(identityToken);
}
return this.verifyRealApple(identityToken);
} }
private isMockMode(): boolean { private async buildLoginResponse(user: {
const bundleId = this.configService.get<string>('apple.bundleId'); id: bigint;
return !bundleId; 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 verifyMockApple(identityToken: string): string { private serializeUser(user: {
if (!identityToken || identityToken.trim().length < 4) { id: bigint;
throw new UnauthorizedException('identityToken 无效'); email: string | null;
} nickname: string | null;
return crypto avatarUrl: string | null;
.createHash('sha256') role: string;
.update(`apple-mock:${identityToken}`) status: string;
.digest('hex') onboardingCompleted: boolean;
.slice(0, 64); }) {
} return {
id: String(user.id),
private getJwksClient(): jwksClient.JwksClient { email: user.email,
if (!this.jwks) { nickname: user.nickname,
const jwksUrl = this.configService.get<string>( avatarUrl: user.avatarUrl,
'apple.jwksUrl', role: user.role,
'https://appleid.apple.com/auth/keys', status: user.status,
); onboardingCompleted: user.onboardingCompleted,
this.jwks = jwksClient({ jwksUri: jwksUrl, cache: true, rateLimit: true }); };
}
return this.jwks!;
}
private async verifyRealApple(identityToken: string): Promise<string> {
const bundleId = this.configService.get<string>('apple.bundleId');
const issuer = this.configService.get<string>('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;
} }
} }

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,3 @@
export { AppleLoginDto } from './apple-login.dto';
export { DevLoginDto } from './dev-login.dto';
export { RefreshDto } from './refresh-token.dto';

View File

@ -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;
}

View File

@ -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<string> {
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');
}
}

View File

@ -1,37 +1,63 @@
import { Injectable } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
@Injectable() @Injectable()
export class UsersRepository { export class UsersRepository {
private profiles: Map<string, any> = new Map(); constructor(private readonly prisma: PrismaService) {}
private preferences: Map<string, any> = new Map();
async findProfileByUserId(userId: string) { async findProfileByUserId(userId: string) {
return this.profiles.get(userId) || { const user = await this.prisma.user.findUnique({
userId, where: { id: BigInt(userId) },
nickname: '学习者', select: {
learningDirection: '', id: true,
bio: '', 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) { async updateProfile(userId: string, dto: any) {
const existing = (await this.findProfileByUserId(userId)) || {}; return this.prisma.user.update({
const updated = { ...existing, ...dto }; where: { id: BigInt(userId) },
this.profiles.set(userId, updated); data: {
return updated; nickname: dto.nickname,
avatarUrl: dto.avatarUrl,
},
});
} }
async updatePreferences(userId: string, dto: any) { async updatePreferences(userId: string, dto: any) {
const existing = this.preferences.get(userId) || { return this.prisma.userPreference.upsert({
userId, where: { userId: BigInt(userId) },
defaultFocusMinutes: 25, create: {
aiSuggestionLevel: 'normal', userId: BigInt(userId),
language: 'zh-CN', defaultFocusMinutes: dto.defaultFocusMinutes ?? 25,
appearance: 'system', aiSuggestionLevel: dto.aiSuggestionLevel ?? 'normal',
notificationEnabled: true, language: dto.language ?? 'zh-CN',
}; appearance: dto.appearance ?? 'system',
const updated = { ...existing, ...dto }; notificationEnabled: dto.notificationEnabled ?? true,
this.preferences.set(userId, updated); },
return updated; update: dto,
});
} }
} }