18 KiB
对,现在后端登录要开始接了。你要先理解一个核心点:
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 流程卡住后端开发。
一、后端登录模块要做哪些接口
第一版只需要这 5 个:
POST /api/auth/dev-login
POST /api/auth/apple
POST /api/auth/refresh
POST /api/auth/logout
GET /api/users/me
其中现在最先实现:
POST /api/auth/dev-login
POST /api/auth/refresh
GET /api/users/me
等这些通了,再接:
POST /api/auth/apple
二、数据库先建这 3 张表
1. users
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
这个表用来记录用户是通过什么方式登录的。
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
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。
三、接口返回格式
登录成功后,后端统一返回:
{
"accessToken": "eyJ...",
"refreshToken": "eyJ...",
"user": {
"id": "user_xxx",
"email": "test@zhixi.app",
"nickname": "测试用户",
"avatarUrl": null,
"role": "USER",
"status": "ACTIVE",
"onboardingCompleted": false
}
}
iOS 拿到以后:
accessToken:内存里用,接口请求带上
refreshToken:存 Keychain,用来恢复登录
user:存 AppSession / UserStore
四、dev-login 怎么做
dev-login 是开发阶段用的。
请求:
POST /api/auth/dev-login
{
"email": "test@zhixi.app",
"nickname": "测试用户",
"devSecret": "你的开发密钥"
}
后端逻辑:
1. 判断 NODE_ENV 不是 production
2. 校验 devSecret
3. 根据 email 查 AuthAccount
4. 如果没有,创建 User + AuthAccount
5. 生成 accessToken
6. 生成 refreshToken
7. refreshToken hash 入库
8. 返回 token + user
这个接口必须禁止生产环境使用:
if (process.env.NODE_ENV === 'production') {
throw new ForbiddenException('dev-login is disabled in production')
}
五、Apple 登录怎么接
iOS 通过 Sign in with Apple 拿到:
identityToken
authorizationCode
userIdentifier
email
fullName
然后发给后端:
POST /api/auth/apple
{
"identityToken": "eyJ...",
"authorizationCode": "...",
"userIdentifier": "000123.xxx",
"email": "xxx@privaterelay.appleid.com",
"fullName": {
"givenName": "Long",
"familyName": "De"
}
}
后端逻辑:
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 现在是:
cloud.longde.AIStudyApp
所以后端环境变量可以先配:
APPLE_BUNDLE_ID=cloud.longde.AIStudyApp
Apple token 里的 aud 必须等于这个。
六、refresh 怎么做
请求:
POST /api/auth/refresh
{
"refreshToken": "eyJ..."
}
后端逻辑:
1. 校验 refreshToken 签名
2. 解析 userId / tokenId
3. 查 refresh_tokens 表
4. 对比 hash
5. 确认未过期、未 revoked
6. 生成新的 accessToken
7. 可选:轮换新的 refreshToken
8. 返回新 token
第一版可以先简单:
{
"accessToken": "new_access_token",
"refreshToken": "new_refresh_token"
}
建议做 refreshToken 轮换,这样更安全。
七、logout 怎么做
请求:
POST /api/auth/logout
Header:
Authorization: Bearer accessToken
Body:
{
"refreshToken": "eyJ..."
}
后端逻辑:
1. 找到 refresh_tokens 记录
2. 设置 revokedAt
3. 返回成功
iOS 侧:
清除 Keychain
清除 AppSession
回到登录页
八、/users/me 怎么做
请求:
GET /api/users/me
Header:
Authorization: Bearer accessToken
返回:
{
"id": "user_xxx",
"email": "test@zhixi.app",
"nickname": "测试用户",
"avatarUrl": null,
"role": "USER",
"status": "ACTIVE",
"onboardingCompleted": false
}
这个接口是 App 启动后判断登录态的核心接口。
九、iOS 侧怎么接
iOS 需要这几个东西:
AuthService
UserService
TokenStore / KeychainTokenStore
AppSession
启动流程:
App 启动
→ AppSession.checkSession()
→ 从 Keychain 读 refreshToken
→ 如果没有,进入登录页
→ 如果有,调用 /auth/refresh
→ 成功后调用 /users/me
→ 进入主界面
→ 失败则清空 token,进入登录页
登录流程:
点击开发登录 / Apple 登录
→ 调后端登录接口
→ 后端返回 accessToken + refreshToken + user
→ refreshToken 存 Keychain
→ accessToken 放内存
→ user 放 AppSession
→ 进入主界面
以后所有接口都带:
Authorization: Bearer accessToken
如果接口返回 401:
调用 /auth/refresh
→ 成功后重试原请求
→ 失败则退出登录
十、后端模块结构建议
NestJS 可以这样拆:
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/
十一、后面所有业务接口都依赖这个
登录做完后,你的知识库接口应该这样写:
GET /api/knowledge-bases
Authorization: Bearer accessToken
后端不要让前端传 userId。
后端应该从 token 里拿:
currentUser.id
然后查:
where: {
userId: currentUser.id,
deletedAt: null
}
这个规则非常重要:
用户资源接口,不要相信前端传 userId。
只相信 JWT 里的 currentUser.id。
十二、你现在的开发顺序
建议你现在按这个顺序写:
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 登录按钮”,而是先建立自己的认证系统:
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 开发证书。
你需要的是:
Bundle ID
Apple 公钥地址
后端自己的 JWT Secret
你的 Bundle ID 现在是:
cloud.longde.AIStudyApp
iOS 登录成功后,会给你一个:
identityToken
你的后端要做的是:
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)
所以这里不需要 p12 证书、开发证书、发布证书、Provisioning Profile。
2. 什么时候后端才需要 Apple 的 Key?
如果你后端要主动调用 Apple 的一些服务,才可能需要 Apple 后台生成的私钥 .p8。
比如:
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)
但这是后面的事情。
你现在做登录,不需要。
你现在后端登录最小需要配置
.env 里先放这些就够:
JWT_ACCESS_SECRET=你自己的随机强密钥
JWT_REFRESH_SECRET=你自己的随机强密钥
APPLE_BUNDLE_ID=cloud.longde.AIStudyApp
APPLE_ISSUER=https://appleid.apple.com
如果你要验证 Apple identityToken,后端会去 Apple 的 JWKS 公钥地址取公钥。你不需要自己申请证书。
一句话
iOS 打包运行需要证书和 Provisioning Profile;
后端验证 Apple 登录不需要这些证书,只需要验证 identityToken,并校验 Bundle ID。
所以你后端现在可以直接继续做:
POST /api/auth/dev-login
POST /api/auth/apple
POST /api/auth/refresh
GET /api/users/me
Apple 证书这块不会卡你的后端登录接口。
公钥不在 Xcode,也不在 App Store Connect 里面。
**Apple 登录用的公钥是 Apple 公开提供的 JWKS 地址,后端运行时去 Apple 获取。**你不用自己申请,也不用下载证书。Apple 官方文档里这个接口就是用来获取验证 identity token 签名的公钥。(Apple Developer)
后端用这个地址:
https://appleid.apple.com/auth/keys
你后端要做的事
你的后端收到 iOS 发来的:
identityToken
然后后端做:
1. 解析 identityToken 的 header,拿到 kid
2. 请求 Apple 公钥地址
3. 从 keys 里找到 kid 对应的公钥
4. 验证 identityToken 签名
5. 校验 iss、aud、exp
6. aud 必须等于你的 Bundle ID
你的 Bundle ID 是:
cloud.longde.AIStudyApp
所以后端 .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 后端建议装:
npm install jose
然后写一个 Apple token 校验服务:
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 会自动做:
读取 token header 里的 kid
去 Apple JWKS 里找对应 key
验证 JWT 签名
校验 issuer
校验 audience
校验过期时间
你不用手动把 n、e 转成 RSA 公钥。
后端 Apple 登录接口大概这样
@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:
export class AppleLoginDto {
identityToken: string;
authorizationCode?: string;
userIdentifier?: string;
email?: string;
nickname?: string;
}
你要记住的区别
iOS 真机运行:
需要 Apple Development 证书、Provisioning Profile。
后端验证 Apple 登录:
不需要证书。
只需要 Apple JWKS 公钥地址 + Bundle ID。
所以现在你已经有:
Bundle ID:cloud.longde.AIStudyApp
Apple 公钥地址:https://appleid.apple.com/auth/keys
后端就可以开始写 /api/auth/apple 了。
对,这里就应该先设计后端接口契约。
你理解得基本对:Apple 登录的核心参数确实就是:
identityToken
但我不建议接口只设计成一个裸 token。更合理的是:
identityToken 必填
其他 Apple 返回的信息作为可选字段
因为 Apple 登录有几个坑:
email / fullName 只会在用户第一次授权时返回
后续登录可能拿不到
authorizationCode 以后可能用于更完整的 Apple 账号校验 / 撤销
userIdentifier 可以辅助客户端识别,但后端不要完全信它
推荐接口
POST /api/auth/apple
请求体:
{
"identityToken": "eyJ...",
"authorizationCode": "c123...",
"userIdentifier": "000123.xxxxx",
"email": "xxx@privaterelay.appleid.com",
"fullName": {
"givenName": "Long",
"familyName": "Wang"
},
"nonce": "optional_nonce"
}
最小必填字段
真正必须的只有:
{
"identityToken": "eyJ..."
}
所以 DTO 可以这样设计:
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;
}
后端真正信什么?
后端真正应该信的是:
identityToken 里校验出来的 sub
流程是:
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
不要让前端传:
userId
也不要以后端完全信任:
userIdentifier
email
nickname
这些都只能作为辅助信息。
返回值设计
登录成功后,后端返回你自己的登录态:
{
"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 拿到以后:
accessToken:后续请求放 Authorization Header
refreshToken:存 Keychain
user:存 AppSession
同时保留 dev-login
你现在开发阶段还应该保留:
POST /api/auth/dev-login
用于本地联调:
{
"email": "test@zhixi.app",
"nickname": "测试用户",
"devSecret": "xxx"
}
这样你后端接口可以先跑通:
dev-login
→ /users/me
→ knowledge-bases
→ knowledge-items
Apple 登录后面接上来,只是多一个 provider。
最终登录接口清单
第一版设计成这样:
POST /api/auth/dev-login
POST /api/auth/apple
POST /api/auth/refresh
POST /api/auth/logout
GET /api/users/me
其中:
/auth/apple:正式 Apple 登录
/auth/dev-login:开发调试
/auth/refresh:刷新登录态
/auth/logout:退出登录
/users/me:获取当前用户
一句话
是的,后端 Apple 登录接口的核心参数就是 identityToken。
但接口最好设计成:
identityToken 必填
authorizationCode / userIdentifier / email / fullName / nonce 可选
这样现在简单能跑,后面也不用返工。