api-server/docs/ios登录流程.md

1004 lines
18 KiB
Markdown
Raw Normal View History

对,现在后端登录要开始接了。你要先理解一个核心点:
```text
Apple 登录不是你的 App 登录系统本身。
Apple 只是帮你证明“这个人是谁”。
真正的登录态,要由你的后端发 accessToken / refreshToken。
```
也就是说最终流程是:
```text
iOS 调 Apple 登录
→ 拿到 Apple identityToken
→ 发给你的 NestJS 后端
→ 后端校验 Apple token
→ 后端创建 / 查找用户
→ 后端生成自己的 accessToken + refreshToken
→ iOS 存 Keychain
→ 以后所有接口带 Authorization: Bearer accessToken
```
不过你现在后端开发阶段,我建议先做:
```text
dev-login
→ /users/me
→ Keychain
→ 知识库接口
```
Apple 登录随后再接,不要让 Apple 流程卡住后端开发。
---
# 一、后端登录模块要做哪些接口
第一版只需要这 5 个:
```http
POST /api/auth/dev-login
POST /api/auth/apple
POST /api/auth/refresh
POST /api/auth/logout
GET /api/users/me
```
其中现在最先实现:
```http
POST /api/auth/dev-login
POST /api/auth/refresh
GET /api/users/me
```
等这些通了,再接:
```http
POST /api/auth/apple
```
---
# 二、数据库先建这 3 张表
## 1. 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
}
```
## 2. auth_accounts
这个表用来记录用户是通过什么方式登录的。
```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
}
```
## 3. refresh_tokens
```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])
}
```
注意:`refreshToken` 不要明文存数据库,只存 hash。
---
# 三、接口返回格式
登录成功后,后端统一返回:
```json
{
"accessToken": "eyJ...",
"refreshToken": "eyJ...",
"user": {
"id": "user_xxx",
"email": "test@zhixi.app",
"nickname": "测试用户",
"avatarUrl": null,
"role": "USER",
"status": "ACTIVE",
"onboardingCompleted": false
}
}
```
iOS 拿到以后:
```text
accessToken内存里用接口请求带上
refreshToken存 Keychain用来恢复登录
user存 AppSession / UserStore
```
---
# 四、dev-login 怎么做
dev-login 是开发阶段用的。
请求:
```http
POST /api/auth/dev-login
```
```json
{
"email": "test@zhixi.app",
"nickname": "测试用户",
"devSecret": "你的开发密钥"
}
```
后端逻辑:
```text
1. 判断 NODE_ENV 不是 production
2. 校验 devSecret
3. 根据 email 查 AuthAccount
4. 如果没有,创建 User + AuthAccount
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')
}
```
---
# 五、Apple 登录怎么接
iOS 通过 Sign in with Apple 拿到:
```text
identityToken
authorizationCode
userIdentifier
email
fullName
```
然后发给后端:
```http
POST /api/auth/apple
```
```json
{
"identityToken": "eyJ...",
"authorizationCode": "...",
"userIdentifier": "000123.xxx",
"email": "xxx@privaterelay.appleid.com",
"fullName": {
"givenName": "Long",
"familyName": "De"
}
}
```
后端逻辑:
```text
1. 校验 identityToken
2. 确认 token 的 aud 是你的 Bundle ID
3. 确认 token 的 issuer 是 Apple
4. 拿 sub 作为 Apple providerUserId
5. 查 auth_accounts(provider=APPLE, providerUserId=sub)
6. 没有就创建 user + auth_account
7. 有就找到 user
8. 生成自己的 accessToken / refreshToken
9. 返回 token + user
```
你的 Bundle ID 现在是:
```text
cloud.longde.AIStudyApp
```
所以后端环境变量可以先配:
```env
APPLE_BUNDLE_ID=cloud.longde.AIStudyApp
```
Apple token 里的 `aud` 必须等于这个。
---
# 六、refresh 怎么做
请求:
```http
POST /api/auth/refresh
```
```json
{
"refreshToken": "eyJ..."
}
```
后端逻辑:
```text
1. 校验 refreshToken 签名
2. 解析 userId / tokenId
3. 查 refresh_tokens 表
4. 对比 hash
5. 确认未过期、未 revoked
6. 生成新的 accessToken
7. 可选:轮换新的 refreshToken
8. 返回新 token
```
第一版可以先简单:
```json
{
"accessToken": "new_access_token",
"refreshToken": "new_refresh_token"
}
```
建议做 refreshToken 轮换,这样更安全。
---
# 七、logout 怎么做
请求:
```http
POST /api/auth/logout
```
Header
```http
Authorization: Bearer accessToken
```
Body
```json
{
"refreshToken": "eyJ..."
}
```
后端逻辑:
```text
1. 找到 refresh_tokens 记录
2. 设置 revokedAt
3. 返回成功
```
iOS 侧:
```text
清除 Keychain
清除 AppSession
回到登录页
```
---
# 八、/users/me 怎么做
请求:
```http
GET /api/users/me
```
Header
```http
Authorization: Bearer accessToken
```
返回:
```json
{
"id": "user_xxx",
"email": "test@zhixi.app",
"nickname": "测试用户",
"avatarUrl": null,
"role": "USER",
"status": "ACTIVE",
"onboardingCompleted": false
}
```
这个接口是 App 启动后判断登录态的核心接口。
---
# 九、iOS 侧怎么接
iOS 需要这几个东西:
```text
AuthService
UserService
TokenStore / KeychainTokenStore
AppSession
```
启动流程:
```text
App 启动
→ AppSession.checkSession()
→ 从 Keychain 读 refreshToken
→ 如果没有,进入登录页
→ 如果有,调用 /auth/refresh
→ 成功后调用 /users/me
→ 进入主界面
→ 失败则清空 token进入登录页
```
登录流程:
```text
点击开发登录 / Apple 登录
→ 调后端登录接口
→ 后端返回 accessToken + refreshToken + user
→ refreshToken 存 Keychain
→ accessToken 放内存
→ user 放 AppSession
→ 进入主界面
```
以后所有接口都带:
```http
Authorization: Bearer accessToken
```
如果接口返回 401
```text
调用 /auth/refresh
→ 成功后重试原请求
→ 失败则退出登录
```
---
# 十、后端模块结构建议
NestJS 可以这样拆:
```text
src/modules/auth/
├── auth.controller.ts
├── auth.service.ts
├── apple-auth.service.ts
├── token.service.ts
├── dto/
│ ├── dev-login.dto.ts
│ ├── apple-login.dto.ts
│ └── refresh-token.dto.ts
├── guards/
│ └── jwt-auth.guard.ts
├── decorators/
│ └── current-user.decorator.ts
└── strategies/
└── jwt.strategy.ts
src/modules/users/
├── users.controller.ts
├── users.service.ts
└── dto/
```
---
# 十一、后面所有业务接口都依赖这个
登录做完后,你的知识库接口应该这样写:
```http
GET /api/knowledge-bases
Authorization: Bearer accessToken
```
后端不要让前端传 `userId`
后端应该从 token 里拿:
```ts
currentUser.id
```
然后查:
```ts
where: {
userId: currentUser.id,
deletedAt: null
}
```
这个规则非常重要:
```text
用户资源接口,不要相信前端传 userId。
只相信 JWT 里的 currentUser.id。
```
---
# 十二、你现在的开发顺序
建议你现在按这个顺序写:
```text
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 登录
```
---
# 最终一句话
你后端登录对接的核心不是“接 Apple 登录按钮”,而是先建立自己的认证系统:
```text
users
+ auth_accounts
+ refresh_tokens
+ accessToken
+ refreshToken
+ JwtAuthGuard
+ /users/me
```
然后 Apple 登录只是其中一个 provider。
现在先把 `dev-login → token → /users/me → iOS Keychain` 跑通,后面知识库和学习闭环就不会卡住。
不需要你刚才在 Xcode 里弄的那种 **iOS 开发证书 / Provisioning Profile**
你后端接 Apple 登录,主要分两种情况:
## 1. 只做 iOS App 的 Sign in with Apple 登录
**后端不需要 Apple 开发证书。**
你需要的是:
```text
Bundle ID
Apple 公钥地址
后端自己的 JWT Secret
```
你的 Bundle ID 现在是:
```text
cloud.longde.AIStudyApp
```
iOS 登录成功后,会给你一个:
```text
identityToken
```
你的后端要做的是:
```text
1. 拿 identityToken
2. 用 Apple 公钥验证这个 JWT 签名
3. 校验 iss 是 Apple
4. 校验 aud 等于 cloud.longde.AIStudyApp
5. 校验 exp 没过期
6. 取 sub 作为 Apple 用户唯一 ID
7. 创建 / 查找你的 users
8. 发你自己的 accessToken / refreshToken
```
Apple 官方文档也是这个流程App 完成 Sign in with Apple 后,会返回 identity token服务端应验证该 token确认它没有过期、没有被篡改或重放。([Apple Developer][1])
所以这里**不需要 p12 证书、开发证书、发布证书、Provisioning Profile**。
## 2. 什么时候后端才需要 Apple 的 Key
如果你后端要主动调用 Apple 的一些服务,才可能需要 Apple 后台生成的私钥 `.p8`
比如:
```text
App Store Server API
App Store Connect API
订阅状态查询
订阅通知验证
Apple IAP 交易相关接口
APNs 推送
```
这些通常需要在 Apple Developer / App Store Connect 里创建 API Key然后后端用私钥生成 JWT。Apple 官方 App Store Connect API 和 App Store Server API 都是通过 JWT 授权请求。([Apple Developer][2])
但这是后面的事情。
**你现在做登录,不需要。**
## 你现在后端登录最小需要配置
`.env` 里先放这些就够:
```env
JWT_ACCESS_SECRET=你自己的随机强密钥
JWT_REFRESH_SECRET=你自己的随机强密钥
APPLE_BUNDLE_ID=cloud.longde.AIStudyApp
APPLE_ISSUER=https://appleid.apple.com
```
如果你要验证 Apple identityToken后端会去 Apple 的 JWKS 公钥地址取公钥。你不需要自己申请证书。
## 一句话
```text
iOS 打包运行需要证书和 Provisioning Profile
后端验证 Apple 登录不需要这些证书,只需要验证 identityToken并校验 Bundle ID。
```
所以你后端现在可以直接继续做:
```text
POST /api/auth/dev-login
POST /api/auth/apple
POST /api/auth/refresh
GET /api/users/me
```
Apple 证书这块不会卡你的后端登录接口。
[1]: https://developer.apple.com/documentation/signinwithapple/authenticating-users-with-sign-in-with-apple?utm_source=chatgpt.com "Authenticating users with Sign in with Apple"
[2]: https://developer.apple.com/documentation/appstoreconnectapi/creating-api-keys-for-app-store-connect-api?utm_source=chatgpt.com "Creating API Keys for App Store Connect API"
公钥不在 Xcode也不在 App Store Connect 里面。
**Apple 登录用的公钥是 Apple 公开提供的 JWKS 地址,后端运行时去 Apple 获取。**你不用自己申请也不用下载证书。Apple 官方文档里这个接口就是用来获取验证 identity token 签名的公钥。([Apple Developer][1])
后端用这个地址:
```text
https://appleid.apple.com/auth/keys
```
## 你后端要做的事
你的后端收到 iOS 发来的:
```text
identityToken
```
然后后端做:
```text
1. 解析 identityToken 的 header拿到 kid
2. 请求 Apple 公钥地址
3. 从 keys 里找到 kid 对应的公钥
4. 验证 identityToken 签名
5. 校验 iss、aud、exp
6. aud 必须等于你的 Bundle ID
```
你的 Bundle ID 是:
```text
cloud.longde.AIStudyApp
```
所以后端 `.env` 里可以配:
```env
APPLE_BUNDLE_ID=cloud.longde.AIStudyApp
APPLE_ISSUER=https://appleid.apple.com
APPLE_JWKS_URL=https://appleid.apple.com/auth/keys
```
## 推荐用 `jose` 库,不要自己手写公钥解析
NestJS / Node 后端建议装:
```bash
npm install jose
```
然后写一个 Apple token 校验服务:
```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,
email: typeof payload.email === 'string' ? payload.email : undefined,
emailVerified: payload.email_verified,
};
} catch (error) {
throw new UnauthorizedException('Invalid Apple identity token');
}
}
}
```
这里 `jose` 会自动做:
```text
读取 token header 里的 kid
去 Apple JWKS 里找对应 key
验证 JWT 签名
校验 issuer
校验 audience
校验过期时间
```
你不用手动把 `n``e` 转成 RSA 公钥。
## 后端 Apple 登录接口大概这样
```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,
email: appleUser.email,
nickname: dto.nickname,
});
}
```
DTO
```ts
export class AppleLoginDto {
identityToken: string;
authorizationCode?: string;
userIdentifier?: string;
email?: string;
nickname?: string;
}
```
## 你要记住的区别
```text
iOS 真机运行:
需要 Apple Development 证书、Provisioning Profile。
后端验证 Apple 登录:
不需要证书。
只需要 Apple JWKS 公钥地址 + Bundle ID。
```
所以现在你已经有:
```text
Bundle IDcloud.longde.AIStudyApp
Apple 公钥地址https://appleid.apple.com/auth/keys
```
后端就可以开始写 `/api/auth/apple` 了。
[1]: https://developer.apple.com/documentation/signinwithapplerestapi/fetch-apple%27s-public-key-for-verifying-token-signature?utm_source=chatgpt.com "Fetch Apple's public key to verify token signatures"
对,**这里就应该先设计后端接口契约**。
你理解得基本对Apple 登录的核心参数确实就是:
```text
identityToken
```
但我不建议接口只设计成一个裸 token。更合理的是
```text
identityToken 必填
其他 Apple 返回的信息作为可选字段
```
因为 Apple 登录有几个坑:
```text
email / fullName 只会在用户第一次授权时返回
后续登录可能拿不到
authorizationCode 以后可能用于更完整的 Apple 账号校验 / 撤销
userIdentifier 可以辅助客户端识别,但后端不要完全信它
```
## 推荐接口
```http
POST /api/auth/apple
```
请求体:
```json
{
"identityToken": "eyJ...",
"authorizationCode": "c123...",
"userIdentifier": "000123.xxxxx",
"email": "xxx@privaterelay.appleid.com",
"fullName": {
"givenName": "Long",
"familyName": "Wang"
},
"nonce": "optional_nonce"
}
```
## 最小必填字段
真正必须的只有:
```json
{
"identityToken": "eyJ..."
}
```
所以 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;
};
@IsOptional()
@IsString()
nonce?: string;
}
```
## 后端真正信什么?
后端真正应该信的是:
```text
identityToken 里校验出来的 sub
```
流程是:
```text
1. iOS 把 identityToken 发给后端
2. 后端用 Apple 公钥验证 token
3. 校验 aud === cloud.longde.AIStudyApp
4. 校验 iss === https://appleid.apple.com
5. 校验 exp 没过期
6. 从 payload.sub 拿 Apple 用户唯一 ID
7. 用 sub 去 auth_accounts 里查找或创建用户
8. 后端生成自己的 accessToken / refreshToken
```
不要让前端传:
```text
userId
```
也不要以后端完全信任:
```text
userIdentifier
email
nickname
```
这些都只能作为辅助信息。
## 返回值设计
登录成功后,后端返回你自己的登录态:
```json
{
"accessToken": "your_access_token",
"refreshToken": "your_refresh_token",
"user": {
"id": "user_xxx",
"email": "xxx@privaterelay.appleid.com",
"nickname": "Wang",
"avatarUrl": null,
"role": "USER",
"status": "ACTIVE",
"onboardingCompleted": false
}
}
```
iOS 拿到以后:
```text
accessToken后续请求放 Authorization Header
refreshToken存 Keychain
user存 AppSession
```
## 同时保留 dev-login
你现在开发阶段还应该保留:
```http
POST /api/auth/dev-login
```
用于本地联调:
```json
{
"email": "test@zhixi.app",
"nickname": "测试用户",
"devSecret": "xxx"
}
```
这样你后端接口可以先跑通:
```text
dev-login
→ /users/me
→ knowledge-bases
→ knowledge-items
```
Apple 登录后面接上来,只是多一个 provider。
## 最终登录接口清单
第一版设计成这样:
```text
POST /api/auth/dev-login
POST /api/auth/apple
POST /api/auth/refresh
POST /api/auth/logout
GET /api/users/me
```
其中:
```text
/auth/apple正式 Apple 登录
/auth/dev-login开发调试
/auth/refresh刷新登录态
/auth/logout退出登录
/users/me获取当前用户
```
## 一句话
是的,**后端 Apple 登录接口的核心参数就是 identityToken**。
但接口最好设计成:
```text
identityToken 必填
authorizationCode / userIdentifier / email / fullName / nonce 可选
```
这样现在简单能跑,后面也不用返工。