# iOS 登录流程 —— Apple 登录详解 --- ## 一、Apple 登录核心理解 **后端不需要 Apple 开发证书。** Apple 登录的公钥是 Apple 公开提供的 JWKS 地址,后端运行时获取即可。 ``` iOS 真机运行: 需要 Apple 开发证书 + Provisioning Profile 后端验证 Apple 登录: 不需要证书,只需要 Apple JWKS 公钥 + Bundle ID ``` --- ## 二、环境变量配置 ```env JWT_ACCESS_SECRET=你自己的随机强密钥 JWT_REFRESH_SECRET=你自己的随机强密钥 APPLE_BUNDLE_ID=cloud.longde.AIStudyApp APPLE_ISSUER=https://appleid.apple.com APPLE_JWKS_URL=https://appleid.apple.com/auth/keys ``` --- ## 三、Apple 登录流程 ### iOS 端 iOS 通过 Sign in with Apple 拿到以下数据: ``` identityToken ← JWT,唯一必须的值 authorizationCode ← 可选,后面可能用于完整校验/撤销 userIdentifier ← 可选,辅助识别,但后端不要完全信任 email ← 可选,注意:仅首次授权时返回 fullName ← 可选,注意:仅首次授权时返回 ``` ### 发给后端 ```http POST /api/auth/apple ``` ```json { "identityToken": "eyJ...", "authorizationCode": "c123...", "userIdentifier": "000123.xxxxx", "email": "xxx@privaterelay.appleid.com", "fullName": { "givenName": "Long", "familyName": "De" } } ``` 最小必填字段只有: ```json { "identityToken": "eyJ..." } ``` --- ## 四、Apple Token 校验(核心) 使用 `jose` 库,不要手写公钥解析: ```bash npm install jose ``` ### AppleAuthService 实现 ```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, // ← Apple 用户唯一 ID,后端核心信任字段 email: typeof payload.email === 'string' ? payload.email : undefined, emailVerified: payload.email_verified, }; } catch (error) { throw new UnauthorizedException('Invalid Apple identity token'); } } } ``` ### jose 自动完成的工作 ``` 1. 读取 JWT header 里的 kid 2. 请求 Apple JWKS 地址,找到 kid 对应的公钥 3. 验证 JWT 签名(RSA) 4. 校验 issuer === https://appleid.apple.com 5. 校验 audience === cloud.longde.AIStudyApp 6. 校验 exp 过期时间 ``` 你不需要手动把 `n`、`e` 转成 RSA 公钥。 --- ## 五、Apple Login 接口实现 ### Controller ```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, // ← 即 identityToken.sub email: appleUser.email ?? dto.email, nickname: dto.fullName?.givenName ? `${dto.fullName.givenName} ${dto.fullName.familyName ?? ''}` : undefined, }); } ``` ### 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; }; } ``` --- ## 六、后端信任模型 | 字段 | 信任级别 | 说明 | |------|----------|------| | `identityToken.sub` | ✅ 信任 | Apple 签名验证通过后的用户唯一 ID | | `identityToken.aud` | ✅ 信任 | 必须等于你的 Bundle ID | | `identityToken.iss` | ✅ 信任 | 必须等于 `https://appleid.apple.com` | | `identityToken.email` | ⚠️ 参考 | Apple 侧校验过的邮箱,但可能为空 | | `userIdentifier`(请求体) | ❌ 不信任 | iOS 侧传的,可能被篡改 | | `email`(请求体) | ❌ 不信任 | iOS 侧传的,可能被篡改 | | `userId`(请求体) | ❌ 绝对不能信 | 用户 ID 只能从后端 JWT 获取 | **核心规则**: ``` 1. 后端只信 identityToken 里校验出来的 sub 2. 用 sub 去 auth_accounts(provider=APPLE, providerUserId=sub) 查找/创建用户 3. 不要信前端传的 userIdentifier / email / name 作为唯一标识 4. 绝对不要让前端传 userId ``` --- ## 七、Apple 登录的特别注意事项 ### 1. 电子邮件和姓名仅首次返回 ``` email / fullName 只在用户第一次授权时返回 第二次及以后登录,Apple 不会返回这两个字段 ``` 所以首次登录时需要将 email 和姓名保存到 `auth_accounts` 和 `users` 表中。 ### 2. Apple 私密邮箱 Apple 可能返回 `xxx@privaterelay.appleid.com` 格式的私密中继邮箱,这是正常的。如果用户选择隐藏邮箱,Apple 会生成一个中转邮箱,发到该邮箱的邮件会自动转发到用户真实邮箱。 ### 3. 什么时候后端才需要 Apple Key? 只有在后端要主动调用 Apple 服务时才需要 `.p8` 私钥: - App Store Server API - App Store Connect API - 订阅状态查询 - IAP 交易验证 - APNs 推送 **登录不需要这些,这些都是后面的事情。** --- ## 八、完整后端校验小结 ```text POST /api/auth/apple │ ▼ ┌─────────────────────────────────────────────────────┐ │ 1. 拿到 identityToken │ │ 2. 解析 header 里的 kid │ │ 3. 请求 Apple JWKS → https://appleid.apple.com/auth/keys │ 4. 找到 kid 对应的公钥 │ │ 5. 验证 JWT 签名 │ │ 6. 校验 iss === https://appleid.apple.com │ │ 7. 校验 aud === cloud.longde.AIStudyApp │ │ 8. 校验 exp 未过期 │ │ 9. 取 sub 作为 Apple 用户唯一 ID │ │ 10. 查 auth_accounts(provider=APPLE, providerUserId=sub)│ │ 11. 不存在→创建 User + AuthAccount │ │ 12. 存在→找到对应 User │ │ 13. 生成 accessToken + refreshToken │ │ 14. refreshToken hash 入库 │ │ 15. 返回 { accessToken, refreshToken, user } │ └─────────────────────────────────────────────────────┘ ```