255 lines
7.0 KiB
Markdown
Raw Permalink Normal View History

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