2026-05-15 17:29:57 +08:00

255 lines
7.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 } │
└─────────────────────────────────────────────────────┘
```