docs(auth): 完善三方登录模块注释 — 含申请步骤与算法说明
AlipayProvider: - callGateway: 补充完整请求参数结构说明 - sign: 详细说明 RSA2 签名5步骤(字典序→拼接→SHA256WithRSA→Base64) - chunkBase64: 说明 PEM 格式每64字符换行要求 AppleProvider: - verifyIdentityToken: 详细说明 claims 验证逻辑和签名验证原理 - getAppleJWKS: 说明 JWKS 缓存策略和 Apple 密钥轮换机制 - jwkToPublicKeyPem: 说明 JWK→SPKI PEM 转换过程 - base64urlDecode: 说明 Base64URL 与标准 Base64 的区别 GoogleProvider: - verifyIdToken: 说明 tokeninfo vs JWKS 本地验证的权衡,补充响应字段说明 welcome_page.dart: - 类头注释: 补充平台配置要求和各登录方式申请前提 - _onAlipayTap: 补充 tobias v3 API 结构、result 格式解析、scope 含义、iOS/Android 配置要求 - _parseParam: 说明 URI query 解析的原因 - _onGoogleTap: 补充 signIn 返回 null 的含义、idToken 说明、Android 配置 - _onAppleTap: 补充 Apple 3 项重要限制(姓名/邮箱只在首次授权时返回) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e8e2a44fef
commit
91f06890a5
|
|
@ -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<string, string>,
|
||||
authToken?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
// 时间格式: "2024-01-15 10:30:00"(北京时间,去掉 T 和毫秒)
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace('T', ' ')
|
||||
.substring(0, 19);
|
||||
|
||||
// 公共参数
|
||||
// 公共参数(每次请求都必须携带)
|
||||
const params: Record<string, string> = {
|
||||
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, string>): 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AppleJWK[]> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<GoogleTokenInfo> {
|
||||
// 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 不属于此应用');
|
||||
|
|
|
|||
|
|
@ -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<WelcomePage> {
|
|||
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<WelcomePage> {
|
|||
}
|
||||
|
||||
// ── 支付宝 ─────────────────────────────────────────────────────────────────
|
||||
// tobias 包使用方法:
|
||||
// isAliPayInstalled() → Future<bool>
|
||||
// aliPay(String orderStr) — 用于支付
|
||||
// 但 OAuth 授权用的是 Alipay.authCode(appId, scope) 返回 auth_code
|
||||
//
|
||||
// tobias v3.x API: aliPayAuth(appId, scope) → Future<Map>
|
||||
// 返回 {'resultStatus': '9000', 'memo': '', 'result': '...(含 auth_code)...'}
|
||||
// resultStatus: 9000=成功, 6001=取消, 4000=错误
|
||||
// tobias 包(https://pub.dev/packages/tobias)封装了支付宝 SDK。
|
||||
//
|
||||
// 关键 API:
|
||||
// isAliPayInstalled → Future<bool> 检查支付宝是否安装
|
||||
// aliPayAuth(appId, scope) → Future<Map> 发起 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 配置要求:
|
||||
// <queries><package android:name="com.eg.android.AlipayGphone" /></queries>
|
||||
|
||||
Future<void> _onAlipayTap() async {
|
||||
final installed = await isAliPayInstalled;
|
||||
|
|
@ -97,24 +131,28 @@ class _WelcomePageState extends State<WelcomePage> {
|
|||
|
||||
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<WelcomePage> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 从 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<void> _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<WelcomePage> {
|
|||
}
|
||||
|
||||
// ── 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<void> _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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue