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 可选
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
这样现在简单能跑,后面也不用返工。
|