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

7.0 KiB
Raw Permalink Blame History

iOS 登录流程 —— Apple 登录详解


一、Apple 登录核心理解

后端不需要 Apple 开发证书。 Apple 登录的公钥是 Apple 公开提供的 JWKS 地址,后端运行时获取即可。

iOS 真机运行:    需要 Apple 开发证书 + Provisioning Profile
后端验证 Apple 登录:  不需要证书,只需要 Apple JWKS 公钥 + Bundle ID

二、环境变量配置

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               ← 可选,注意:仅首次授权时返回

发给后端

POST /api/auth/apple
{
  "identityToken": "eyJ...",
  "authorizationCode": "c123...",
  "userIdentifier": "000123.xxxxx",
  "email": "xxx@privaterelay.appleid.com",
  "fullName": {
    "givenName": "Long",
    "familyName": "De"
  }
}

最小必填字段只有:

{
  "identityToken": "eyJ..."
}

四、Apple Token 校验(核心)

使用 jose 库,不要手写公钥解析:

npm install jose

AppleAuthService 实现

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 过期时间

你不需要手动把 ne 转成 RSA 公钥。


五、Apple Login 接口实现

Controller

@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

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_accountsusers 表中。

2. Apple 私密邮箱

Apple 可能返回 xxx@privaterelay.appleid.com 格式的私密中继邮箱这是正常的。如果用户选择隐藏邮箱Apple 会生成一个中转邮箱,发到该邮箱的邮件会自动转发到用户真实邮箱。

3. 什么时候后端才需要 Apple Key

只有在后端要主动调用 Apple 服务时才需要 .p8 私钥:

  • App Store Server API
  • App Store Connect API
  • 订阅状态查询
  • IAP 交易验证
  • APNs 推送

登录不需要这些,这些都是后面的事情。


八、完整后端校验小结

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