All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 19s
1004 lines
18 KiB
Markdown
1004 lines
18 KiB
Markdown
对,现在后端登录要开始接了。你要先理解一个核心点:
|
||
|
||
```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 ID:cloud.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 可选
|
||
```
|
||
|
||
这样现在简单能跑,后面也不用返工。
|