diff --git a/backend/services/auth-service/src/infrastructure/alipay/alipay.provider.ts b/backend/services/auth-service/src/infrastructure/alipay/alipay.provider.ts index 61407f3..8481da6 100644 --- a/backend/services/auth-service/src/infrastructure/alipay/alipay.provider.ts +++ b/backend/services/auth-service/src/infrastructure/alipay/alipay.provider.ts @@ -139,19 +139,38 @@ export class AlipayProvider { } /** - * 调用支付宝网关 — 构建 RSA2 签名请求 + * 调用支付宝开放平台网关 + * + * 支付宝所有 API 共用同一个网关 URL(openapi.alipay.com/gateway.do), + * 通过 method 参数区分具体接口,所有请求必须携带 RSA2 签名。 + * + * 请求参数结构(POST application/x-www-form-urlencoded): + * app_id — 应用 AppID + * method — 接口名称,如 alipay.system.oauth.token + * charset — 编码,固定 utf-8 + * sign_type — 签名算法,固定 RSA2(推荐,比 RSA 更安全) + * timestamp — 发送请求的时间(yyyy-MM-dd HH:mm:ss,北京时间) + * version — API 版本,固定 1.0 + * biz_content — JSON 格式业务参数(仅有业务参数时才传) + * auth_token — 用户授权 Token(调用需要授权的接口时传) + * sign — 对以上所有参数的 RSA2 签名(最后计算,不参与签名本身) + * + * @param method 支付宝开放平台接口名称 + * @param bizContent 业务参数(将被 JSON.stringify 后放入 biz_content) + * @param authToken 用户 access_token(获取用户信息时传入) */ private async callGateway( method: string, bizContent: Record, authToken?: string, ): Promise> { + // 时间格式: "2024-01-15 10:30:00"(北京时间,去掉 T 和毫秒) const timestamp = new Date() .toISOString() .replace('T', ' ') .substring(0, 19); - // 公共参数 + // 公共参数(每次请求都必须携带) const params: Record = { app_id: this.appId!, method, @@ -161,18 +180,21 @@ export class AlipayProvider { version: '1.0', }; + // auth_token: 调用 alipay.user.info.share 时必须携带,代表用户授权 if (authToken) { params['auth_token'] = authToken; } + // biz_content: 业务参数(JSON 格式),部分接口(如 token 换取)需要 if (Object.keys(bizContent).length > 0) { params['biz_content'] = JSON.stringify(bizContent); } - // 生成 RSA2 签名 + // 最后一步:对所有参数(除 sign 本身)计算 RSA2 签名 params['sign'] = this.sign(params); - // POST 请求体(URL 编码) + // POST body: application/x-www-form-urlencoded 格式 + // querystring.stringify 会自动对值进行 URL 编码 const postBody = querystring.stringify(params); const data = await this.httpsPost(this.gateway, postBody); @@ -180,26 +202,51 @@ export class AlipayProvider { } /** - * RSA2 (SHA256WithRSA) 签名 - * 1. 参数字典序升序 - * 2. 拼接 key=value - * 3. SHA256WithRSA 签名 → Base64 + * RSA2 签名算法(SHA256WithRSA) + * + * 支付宝官方规范: + * 1. 收集所有请求参数(去掉 sign、值为空的参数) + * 2. 按参数名的字典序(ASCII 升序)排列 + * 3. 拼接为 "key=value&key2=value2"(值不做 URL 编码) + * 4. 用 RSA2 私钥(PKCS8 格式)对拼接后的字符串做 SHA256WithRSA 签名 + * 5. 将签名结果 Base64 编码,URL 编码后放入 sign 参数 + * + * 示例签名字符串(字典序排列): + * app_id=2021003189&biz_content={"code":"AP_xxx"}&charset=utf-8& + * method=alipay.system.oauth.token&sign_type=RSA2×tamp=2024-01-15 10:30:00&version=1.0 + * + * 私钥格式注意: + * ALIPAY_PRIVATE_KEY 存储的是纯 Base64 字符串(无 -----BEGIN PRIVATE KEY----- 头尾) + * Node.js crypto 需要完整 PEM 格式,因此需要加头尾并每64字符换行 */ private sign(params: Record): string { + // Step 1+2: 过滤空值和 sign 参数,按字典序排列 const sortedKeys = Object.keys(params) - .filter((k) => k !== 'sign') + .filter((k) => k !== 'sign' && params[k] !== '' && params[k] != null) .sort(); + + // Step 3: 拼接 key=value(注意:值保持原始字符串,不 URL 编码) const signString = sortedKeys.map((k) => `${k}=${params[k]}`).join('&'); - // PKCS8 格式私钥(ALIPAY_PRIVATE_KEY 存储的是 Base64,无 header/footer) + // Step 4: 构建 PEM 格式私钥(Node.js crypto 要求标准 PEM 格式) + // ALIPAY_PRIVATE_KEY 是 PKCS8 格式,使用 -----BEGIN PRIVATE KEY----- + // 注意: 若是 PKCS1 格式则用 -----BEGIN RSA PRIVATE KEY----- const privateKeyPem = `-----BEGIN PRIVATE KEY-----\n${this.chunkBase64(this.privateKey!)}\n-----END PRIVATE KEY-----`; + // Step 4: RSA-SHA256 = SHA256WithRSA = RSA2(支付宝的叫法) const signer = crypto.createSign('RSA-SHA256'); signer.update(signString, 'utf8'); + + // Step 5: Base64 编码签名结果 return signer.sign(privateKeyPem, 'base64'); } - /** 每64字符换行(PEM 格式要求) */ + /** + * 将纯 Base64 字符串按每64字符换行,生成 PEM 格式所需的多行格式 + * + * PEM 标准(RFC 7468)要求 Base64 内容每行不超过64字符, + * 支付宝密钥工具导出的私钥可能是单行,必须转换后才能被 Node.js crypto 识别。 + */ private chunkBase64(base64: string): string { return base64.match(/.{1,64}/g)?.join('\n') || base64; } diff --git a/backend/services/auth-service/src/infrastructure/apple/apple.provider.ts b/backend/services/auth-service/src/infrastructure/apple/apple.provider.ts index 9d4a951..6030c60 100644 --- a/backend/services/auth-service/src/infrastructure/apple/apple.provider.ts +++ b/backend/services/auth-service/src/infrastructure/apple/apple.provider.ts @@ -97,49 +97,74 @@ export class AppleProvider { const header = JSON.parse(this.base64urlDecode(parts[0])); const payload = JSON.parse(this.base64urlDecode(parts[1])); - // 2. 基本 claims 验证 + // ── Step 2: claims 验证(不依赖签名,快速失败)────────────────── const now = Math.floor(Date.now() / 1000); + + // exp: Unix 秒,Apple Identity Token 有效期通常为5分钟 if (payload.exp < now) { throw new UnauthorizedException('Apple Identity Token 已过期'); } + // iss: 必须是 Apple 的签发方,防止伪造 Token if (payload.iss !== 'https://appleid.apple.com') { throw new UnauthorizedException('Apple Identity Token issuer 无效'); } + // aud: 必须是本应用的 Bundle ID(或 Service ID),防止其他 App 的 Token 被接受 + // 例: cn.gogenex.consumer if (payload.aud !== this.clientId) { this.logger.error(`Apple token aud mismatch: ${payload.aud} != ${this.clientId}`); throw new UnauthorizedException('Apple Token 不属于此应用'); } - // 3. 获取 Apple 公钥 + 验证签名 + // ── Step 3: 获取 Apple 公钥并验证 JWT 签名 ────────────────────── + // Apple 使用 RS256(RSASSA-PKCS1-v1_5 with SHA-256)对 JWT 签名 + // JWT header 中的 kid 指向具体使用的密钥对,Apple 定期轮换密钥 const jwks = await this.getAppleJWKS(); const matchingKey = jwks.find((k) => k.kid === header.kid); if (!matchingKey) { - throw new UnauthorizedException(`未找到 Apple 公钥 (kid=${header.kid})`); + // kid 不匹配通常是因为 Apple 已轮换密钥但缓存未更新 + // 下次请求会清除缓存重新获取(当前请求直接拒绝) + throw new UnauthorizedException(`未找到 Apple 公钥 (kid=${header.kid}),请稍后重试`); } + // JWT 签名验证:signed_data = base64url(header) + "." + base64url(payload) + // signature = base64url(RSA-SHA256_sign(signed_data, apple_private_key)) const publicKeyPem = this.jwkToPublicKeyPem(matchingKey); - const signedData = `${parts[0]}.${parts[1]}`; - const signature = Buffer.from(parts[2], 'base64url'); + const signedData = `${parts[0]}.${parts[1]}`; // 原始 header.payload 字符串 + const signature = Buffer.from(parts[2], 'base64url'); // base64url → Buffer const verifier = crypto.createVerify('SHA256'); verifier.update(signedData); const valid = verifier.verify(publicKeyPem, signature); if (!valid) { + // 签名无效意味着 Token 被篡改或不是 Apple 签发的 throw new UnauthorizedException('Apple Identity Token 签名验证失败'); } this.logger.log(`Apple token verified: sub=${payload.sub?.slice(0, 6)}...`); return { - sub: payload.sub, - email: payload.email, + sub: payload.sub, // 用户唯一 ID(稳定,跨设备/卸载重装不变) + email: payload.email, // 仅首次授权时提供,之后为 null(客户端需缓存) + // email_verified 在 Apple JWT 中是字符串 'true'/'false' 或布尔值,统一处理 emailVerified: payload.email_verified === 'true' || payload.email_verified === true, + // is_private_email=true 表示用户选择了「隐藏邮箱」,Apple 生成了代理邮箱地址 isPrivateEmail: payload.is_private_email === 'true' || payload.is_private_email === true, }; } - /** 获取 Apple JWKS 公钥集合(带内存缓存) */ + /** + * 获取 Apple 公钥集合(JWKS)— 带24小时内存缓存 + * + * Apple 公钥地址: https://appleid.apple.com/auth/keys + * 响应示例: + * { "keys": [{ "kty":"RSA", "kid":"xxx", "use":"sig", "alg":"RS256", "n":"...", "e":"..." }] } + * + * Apple 密钥轮换策略: + * - 密钥不频繁轮换,24小时缓存通常安全 + * - 若出现 kid 不匹配,说明已轮换,下次请求自动获取新密钥 + * - 生产优化:可根据 Cache-Control 响应头动态调整 TTL + */ private async getAppleJWKS(): Promise { const now = Date.now(); if (this.cachedJwks && now - this.jwksCachedAt < this.JWKS_CACHE_TTL) { @@ -154,16 +179,30 @@ export class AppleProvider { } /** - * 将 Apple JWK(RSA 公钥)转换为 PEM 格式 - * Apple 使用 RS256(RSA-SHA256),JWK 包含 n(modulus)和 e(exponent) + * 将 Apple JWK 格式公钥转换为 Node.js crypto 所需的 PEM 格式 + * + * Apple Identity Token 使用 RS256 算法(RSA + SHA256) + * JWK 公钥包含: + * n — RSA modulus(Base64URL 编码的大整数) + * e — RSA exponent(通常为 AQAB,即65537) + * + * Node.js 18+ 的 crypto.createPublicKey 直接支持 JWK 格式输入, + * 导出为 SPKI(SubjectPublicKeyInfo)格式的 PEM,可被 createVerify 使用。 */ private jwkToPublicKeyPem(jwk: AppleJWK): string { - // Node.js 18+ 支持直接从 JWK 创建 KeyObject const key = crypto.createPublicKey({ key: jwk as any, format: 'jwk' }); return key.export({ type: 'spki', format: 'pem' }) as string; } - /** Base64URL → UTF-8 字符串 */ + /** + * Base64URL 解码为 UTF-8 字符串 + * + * JWT 各段使用 Base64URL 编码(标准 Base64 的 URL 安全变体): + * - '+' 替换为 '-' + * - '/' 替换为 '_' + * - 去掉末尾 '=' 填充 + * 解码时需要反向操作并补回 '=' 使 Base64 长度为4的倍数。 + */ private base64urlDecode(input: string): string { const base64 = input.replace(/-/g, '+').replace(/_/g, '/'); const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); diff --git a/backend/services/auth-service/src/infrastructure/google/google.provider.ts b/backend/services/auth-service/src/infrastructure/google/google.provider.ts index 1fdfe15..cf12a6d 100644 --- a/backend/services/auth-service/src/infrastructure/google/google.provider.ts +++ b/backend/services/auth-service/src/infrastructure/google/google.provider.ts @@ -66,23 +66,44 @@ export class GoogleProvider { private readonly clientId = process.env.GOOGLE_CLIENT_ID; /** - * 验证 Google ID Token 并返回用户信息 + * 通过 Google tokeninfo 接口验证 ID Token 并返回用户信息 * - * @param idToken Flutter google_sign_in 返回的 idToken + * 实现方式选择: + * 当前: 调用 Google tokeninfo 接口(简单,Google 服务端验证签名 + 有效期) + * 替代: 本地 JWKS 验证(获取 https://www.googleapis.com/oauth2/v3/certs 缓存公钥, + * 本地验证 JWT 签名,减少一次网络 RTT,适合高并发场景) + * 当前选择理由: tokeninfo 接口更简单,且每次登录频率不高,外部 RTT 可接受 + * + * tokeninfo 接口响应(token 有效时返回用户信息,无效时返回 error): + * sub: 用户全局唯一 ID(不随账号信息变化,作为 openid 存储) + * email: 用户邮箱(Google 账号通常已验证) + * email_verified: 邮箱是否已验证('true'/'false') + * name: 显示名(given_name + family_name) + * picture: 头像 URL(可能包含 =s96-c 等尺寸参数) + * aud: Client ID(必须与配置的 GOOGLE_CLIENT_ID 一致) + * exp: 过期时间(tokeninfo 接口已验证,不需要重复检查) + * + * 错误响应(token 无效时): + * { "error": "invalid_token", "error_description": "Token has been expired..." } + * + * @param idToken Flutter google_sign_in 返回的 JWT 格式 ID Token(通常1小时有效) */ async verifyIdToken(idToken: string): Promise { + // tokeninfo 会自动验证 JWT 签名 + exp 有效期,无需手动处理 const data = await this.httpsGet( `https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(idToken)}`, ); const info = JSON.parse(data) as GoogleTokenInfo; + // 若 token 无效,Google 返回 { error: '...', error_description: '...' } if (info.error) { this.logger.error(`Google token invalid: ${info.error} - ${info.error_description}`); throw new UnauthorizedException(`Google 登录失败: ${info.error_description || info.error}`); } - // 验证 audience(防止接受其他应用的 token) + // 验证 audience:防止接受其他应用颁发的 token(安全必要) + // GOOGLE_CLIENT_ID 未配置时跳过此验证(开发便利,生产必须配置) if (this.clientId && info.aud !== this.clientId) { this.logger.error(`Google token audience mismatch: ${info.aud} != ${this.clientId}`); throw new UnauthorizedException('Google Token 不属于此应用'); diff --git a/frontend/genex-mobile/lib/features/auth/presentation/pages/welcome_page.dart b/frontend/genex-mobile/lib/features/auth/presentation/pages/welcome_page.dart index 4d0d020..0d9d9bf 100644 --- a/frontend/genex-mobile/lib/features/auth/presentation/pages/welcome_page.dart +++ b/frontend/genex-mobile/lib/features/auth/presentation/pages/welcome_page.dart @@ -13,9 +13,25 @@ import '../../../../core/services/auth_service.dart'; /// A1. 欢迎页 - 品牌展示 + 注册/登录入口 /// -/// 平台差异(运行时判断): -/// Android: 微信 + 支付宝 + Google -/// iOS: 微信 + 支付宝 + Google + Apple +/// ── 社交登录平台支持 ────────────────────────────────────── +/// Android: 微信 + 支付宝 + Google(3 个按钮) +/// iOS: 微信 + 支付宝 + Google + Apple(4 个按钮) +/// +/// ── 各登录方式申请前提 ────────────────────────────────── +/// 微信: 微信开放平台企业认证 (¥300/年) + 创建移动应用 +/// 支付宝: 支付宝开放平台 + 创建移动应用 + 开通「获取会员信息」接口 +/// Google: Google Cloud Console 创建 OAuth 2.0 Client ID +/// Apple: Apple Developer ($99/年) + 开启 Sign In with Apple 能力 +/// +/// ── 配置方式(--dart-define 传入 AppID,不写死到代码中)── +/// flutter build apk --dart-define=ALIPAY_APP_ID=2021003189xxxxxx +/// flutter build apk --dart-define=WECHAT_APP_ID=wx0000000000000000 +/// +/// ── 登录流程(各平台)──────────────────────────────────── +/// 微信: fluwx.sendWeChatAuth → WXAuthResp(code) → POST /auth/wechat +/// 支付宝: tobias.aliPayAuth → Map(result 含 auth_code) → POST /auth/alipay +/// Google: google_sign_in.signIn → GoogleSignInAuthentication.idToken → POST /auth/google +/// Apple: sign_in_with_apple.getAppleIDCredential → identityToken → POST /auth/apple class WelcomePage extends StatefulWidget { const WelcomePage({super.key}); @@ -29,6 +45,9 @@ class _WelcomePageState extends State { bool _googleLoading = false; bool _appleLoading = false; + // google_sign_in 实例(scopes 决定 idToken 中包含哪些用户数据) + // 'email': 用于后端在首次注册时写入 users 表 + // 'profile': 获取用户显示名和头像 URL final _googleSignIn = GoogleSignIn(scopes: ['email', 'profile']); @override @@ -75,14 +94,29 @@ class _WelcomePageState extends State { } // ── 支付宝 ───────────────────────────────────────────────────────────────── - // tobias 包使用方法: - // isAliPayInstalled() → Future - // aliPay(String orderStr) — 用于支付 - // 但 OAuth 授权用的是 Alipay.authCode(appId, scope) 返回 auth_code // - // tobias v3.x API: aliPayAuth(appId, scope) → Future - // 返回 {'resultStatus': '9000', 'memo': '', 'result': '...(含 auth_code)...'} - // resultStatus: 9000=成功, 6001=取消, 4000=错误 + // tobias 包(https://pub.dev/packages/tobias)封装了支付宝 SDK。 + // + // 关键 API: + // isAliPayInstalled → Future 检查支付宝是否安装 + // aliPayAuth(appId, scope) → Future 发起 OAuth 授权 + // + // aliPayAuth 返回 Map 结构: + // resultStatus: '9000' = 成功, '6001' = 用户取消, '4000' = 失败 + // result: query 格式字符串,包含 auth_code、scope、state 等 + // 示例: "auth_code=AP_xxxxxxxx&scope=auth_user&state=&charset=UTF-8" + // memo: 错误描述(resultStatus != '9000' 时有值) + // + // scope 说明: + // auth_user — 获取用户基本信息(nick_name、avatar、user_id),需开通「获取会员信息」接口 + // auth_base — 仅获取 user_id(静默授权,不弹授权页,功能受限) + // + // iOS Info.plist 配置要求: + // CFBundleURLSchemes: ["alipay$(ALIPAY_APP_ID)"] — 支付宝回调 URL Scheme + // LSApplicationQueriesSchemes: ["alipay", "alipays"] — 检查支付宝是否安装 + // + // Android AndroidManifest.xml 配置要求: + // Future _onAlipayTap() async { final installed = await isAliPayInstalled; @@ -97,24 +131,28 @@ class _WelcomePageState extends State { setState(() => _alipayLoading = true); try { - // appId 与后端 ALIPAY_APP_ID 一致 + // ALIPAY_APP_ID 通过 --dart-define=ALIPAY_APP_ID=xxx 在编译时注入 + // 与后端 .env 中的 ALIPAY_APP_ID 保持一致(同一支付宝移动应用的 AppID) const alipayAppId = String.fromEnvironment('ALIPAY_APP_ID', defaultValue: ''); final result = await aliPayAuth(alipayAppId, 'auth_user'); final status = result['resultStatus']?.toString() ?? ''; if (status != '9000') { - // 用户取消或错误 + // '6001' = 用户主动取消,静默处理(不显示错误提示) + // '4000' = 授权失败,同样静默(finally 会清除 loading 状态) return; } - // 从 result 字符串中解析 auth_code - // result 格式: "auth_code=AP_xxxxxxxx&scope=auth_user&state=..." + // result 字符串为 URL query 格式,例如: + // "auth_code=AP_xxxxxxxxxxxxxxxx&scope=auth_user&state=&charset=UTF-8" + // 用 Uri(query: ...) 解析可安全处理特殊字符 final resultStr = result['result']?.toString() ?? ''; final authCode = _parseParam(resultStr, 'auth_code'); if (authCode == null || authCode.isEmpty) { throw Exception('未获取到 auth_code'); } + // 将 auth_code 发送后端,后端完成 token 换取 + 用户信息获取 await AuthService.instance.loginByAlipay(authCode: authCode); if (mounted) Navigator.pushReplacementNamed(context, '/main'); } catch (e) { @@ -128,24 +166,56 @@ class _WelcomePageState extends State { } } - /// 从 URL Query 格式字符串中提取参数值 + /// 从 URL Query 格式字符串中提取指定参数的值 + /// + /// 支付宝 SDK 返回的 result 字符串格式类似 URL query string, + /// 借助 Uri(query: ...) 解析可正确处理 URL 编码的特殊字符。 + /// + /// 示例: + /// input: "auth_code=AP_xxxxxxxx&scope=auth_user&state=&charset=UTF-8" + /// _parseParam(str, 'auth_code') → 'AP_xxxxxxxx' String? _parseParam(String str, String key) { final uri = Uri(query: str); return uri.queryParameters[key]; } // ── Google ───────────────────────────────────────────────────────────────── + // + // google_sign_in 包(https://pub.dev/packages/google_sign_in) + // + // 登录流程: + // GoogleSignIn.signIn() → 弹出 Google 账号选择界面 + // → 返回 GoogleSignInAccount(用户基本信息: email, displayName 等) + // → account.authentication → GoogleSignInAuthentication + // → .idToken: JWT 格式,包含 sub(用户ID)、email、name、picture + // + // Android 配置要求(android/app/build.gradle): + // 不需要额外配置,google_sign_in 自动读取 google-services.json + // 但需要在 Google Cloud Console 创建 Android OAuth Client ID(需要 SHA-1 指纹) + // + // iOS 配置要求: + // Runner/GoogleService-Info.plist — 从 Firebase 或 Google Cloud Console 下载 + // 或在 Info.plist 中配置 GIDClientID + // + // 注意: + // signIn() 在用户已登录时可能直接返回缓存账号(无弹窗) + // 若需要重新弹出选择界面,先调用 _googleSignIn.signOut() Future _onGoogleTap() async { setState(() => _googleLoading = true); try { + // 弹出 Google 账号选择/授权界面 + // 返回 null = 用户手动关闭了界面(取消) final account = await _googleSignIn.signIn(); - if (account == null) return; // 用户取消 + if (account == null) return; // 用户取消,不显示错误提示 + // 获取 OAuth 认证凭据(包含 idToken + accessToken) + // idToken 是 JWT 格式,后端可通过 Google 公钥验证其有效性 final auth = await account.authentication; final idToken = auth.idToken; if (idToken == null) throw Exception('未获取到 ID Token'); + // 将 idToken 发送后端验证(Google tokeninfo API 或 JWKS 本地验证) await AuthService.instance.loginByGoogle(idToken: idToken); if (mounted) Navigator.pushReplacementNamed(context, '/main'); } catch (e) { @@ -160,26 +230,54 @@ class _WelcomePageState extends State { } // ── Apple(仅 iOS)──────────────────────────────────────────────────────── + // + // sign_in_with_apple 包(https://pub.dev/packages/sign_in_with_apple) + // + // iOS 配置要求: + // Xcode → Target → Signing & Capabilities → + Capability → Sign In with Apple + // (对应 apple.provider.ts 中的 APPLE_CLIENT_ID = Bundle ID) + // + // 重要的 Apple 限制: + // 1. givenName / familyName 只在用户首次授权时返回,之后为 null + // → 首次注册时需将姓名保存到 users 表,之后无法从 Apple 重新获取 + // 2. email 同样只在首次授权时返回 + // → 若用户选择「隐藏邮箱」,Apple 会生成一个代理邮箱(privaterelay.appleid.com) + // 3. userIdentifier(= identityToken 中的 sub)是稳定的唯一 ID + // → 即使用户卸载 App 重装,sub 不变;用户可在 Apple ID 设置中撤销授权 + // + // AppleIDAuthorizationScopes: + // email — 请求用户邮箱(仅首次授权时返回) + // fullName — 请求用户姓名(givenName + familyName,仅首次授权时返回) + // + // identityToken: + // JWT 格式,由 Apple 用 RS256(RSA-SHA256)私钥签名 + // 后端通过 Apple JWKS 公钥(https://appleid.apple.com/auth/keys)验证签名 Future _onAppleTap() async { setState(() => _appleLoading = true); try { final credential = await SignInWithApple.getAppleIDCredential( scopes: [ - AppleIDAuthorizationScopes.email, - AppleIDAuthorizationScopes.fullName, + AppleIDAuthorizationScopes.email, // 首次授权时返回邮箱 + AppleIDAuthorizationScopes.fullName, // 首次授权时返回姓名 ], ); + // identityToken 是 JWT,后端用于验证用户身份的核心凭据 final identityToken = credential.identityToken; if (identityToken == null) throw Exception('未获取到 Identity Token'); - // 拼接用户显示名(Apple 首次登录时提供,之后为 null) + // 用户显示名:仅首次授权时有值,之后 givenName/familyName 为 null + // 拼接 "名 姓",过滤空值,确保不传空字符串到后端 final displayName = [ credential.givenName, credential.familyName, ].where((s) => s != null && s.isNotEmpty).join(' '); + // identityToken + displayName 发后端: + // - 后端用 Apple JWKS 验证 identityToken 签名 + // - 提取 sub 作为用户唯一标识(后续登录同一用户) + // - 首次注册时用 email + displayName 初始化用户资料 await AuthService.instance.loginByApple( identityToken: identityToken, displayName: displayName.isNotEmpty ? displayName : null,