api-server/docs/ios登录流程.md
WangDL 387785bd1e
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 19s
ci: deploy workflow 加 Apple 登录环境变量
2026-05-13 15:54:59 +08:00

1004 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

对,现在后端登录要开始接了。你要先理解一个核心点:
```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 可选
```
这样现在简单能跑,后面也不用返工。