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