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:
parent
387785bd1e
commit
fa69749884
254
docs/ios登录流程-Apple登录.md
Normal file
254
docs/ios登录流程-Apple登录.md
Normal 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 } │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
172
docs/ios登录流程-iOS端集成.md
Normal file
172
docs/ios登录流程-iOS端集成.md
Normal 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 │ │ 清空数据 │
|
||||||
|
│ 进主页 │ │ 进登录页 │
|
||||||
|
└────────┘ └──────────┘
|
||||||
|
```
|
||||||
268
docs/ios登录流程-后端接口.md
Normal file
268
docs/ios登录流程-后端接口.md
Normal 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 + 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 };
|
||||||
|
}
|
||||||
|
```
|
||||||
179
docs/ios登录流程-总览.md
Normal file
179
docs/ios登录流程-总览.md
Normal 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 (支持多 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` 跑通,知识库和学习闭环就不会卡住。
|
||||||
153
docs/ios登录流程-数据库设计.md
Normal file
153
docs/ios登录流程-数据库设计.md
Normal 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
48
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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?
|
||||||
|
|||||||
@ -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('登录已过期,请重新登录');
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
79
src/modules/auth/apple-auth.service.ts
Normal file
79
src/modules/auth/apple-auth.service.ts
Normal 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 验证失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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: '已退出登录' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {}
|
||||||
|
|||||||
@ -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,
|
||||||
|
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) {
|
private async buildLoginResponse(user: {
|
||||||
await this.prisma.refreshToken.updateMany({
|
id: bigint;
|
||||||
where: { userId, revokedAt: null },
|
email: string | null;
|
||||||
data: { revokedAt: new Date() },
|
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(
|
private serializeUser(user: {
|
||||||
identityToken: string,
|
id: bigint;
|
||||||
authorizationCode: string,
|
email: string | null;
|
||||||
): Promise<string> {
|
nickname: string | null;
|
||||||
if (this.isMockMode()) {
|
avatarUrl: string | null;
|
||||||
return this.verifyMockApple(identityToken);
|
role: string;
|
||||||
}
|
status: string;
|
||||||
return this.verifyRealApple(identityToken);
|
onboardingCompleted: boolean;
|
||||||
}
|
}) {
|
||||||
|
return {
|
||||||
private isMockMode(): boolean {
|
id: String(user.id),
|
||||||
const bundleId = this.configService.get<string>('apple.bundleId');
|
email: user.email,
|
||||||
return !bundleId;
|
nickname: user.nickname,
|
||||||
}
|
avatarUrl: user.avatarUrl,
|
||||||
|
role: user.role,
|
||||||
private verifyMockApple(identityToken: string): string {
|
status: user.status,
|
||||||
if (!identityToken || identityToken.trim().length < 4) {
|
onboardingCompleted: user.onboardingCompleted,
|
||||||
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<string>(
|
|
||||||
'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<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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/modules/auth/dto/apple-login.dto.ts
Normal file
39
src/modules/auth/dto/apple-login.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
17
src/modules/auth/dto/dev-login.dto.ts
Normal file
17
src/modules/auth/dto/dev-login.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
3
src/modules/auth/dto/index.ts
Normal file
3
src/modules/auth/dto/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { AppleLoginDto } from './apple-login.dto';
|
||||||
|
export { DevLoginDto } from './dev-login.dto';
|
||||||
|
export { RefreshDto } from './refresh-token.dto';
|
||||||
9
src/modules/auth/dto/refresh-token.dto.ts
Normal file
9
src/modules/auth/dto/refresh-token.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
26
src/modules/auth/token.service.ts
Normal file
26
src/modules/auth/token.service.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user