255 lines
7.0 KiB
Markdown
255 lines
7.0 KiB
Markdown
# 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 } │
|
||
└─────────────────────────────────────────────────────┘
|
||
```
|