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:
hailin 2026-03-04 04:56:11 -08:00
parent e8e2a44fef
commit 91f06890a5
4 changed files with 250 additions and 45 deletions

View File

@ -139,19 +139,38 @@ export class AlipayProvider {
}
/**
* RSA2
*
*
* API URLopenapi.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&timestamp=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;
}

View File

@ -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 使用 RS256RSASSA-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 JWKRSA PEM
* Apple 使 RS256RSA-SHA256JWK nmodulus eexponent
* Apple JWK Node.js crypto PEM
*
* Apple Identity Token 使 RS256 RSA + SHA256
* JWK :
* n RSA modulusBase64URL
* e RSA exponent AQAB65537
*
* Node.js 18+ crypto.createPublicKey JWK
* SPKISubjectPublicKeyInfo 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);

View File

@ -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 Token1
*/
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 不属于此应用');

View File

@ -13,9 +13,25 @@ import '../../../../core/services/auth_service.dart';
/// A1. - + /
///
///
/// Android: + + Google
/// iOS: + + Google + Apple
///
/// Android: + + Google3
/// iOS: + + Google + Apple4
///
///
/// : (¥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_codescopestate
// : "auth_code=AP_xxxxxxxx&scope=auth_user&state=&charset=UTF-8"
// memo: resultStatus != '9000'
//
// scope :
// auth_user nick_nameavataruser_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 subIDemailnamepicture
//
// 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 RS256RSA-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,