fix(auth): H0-01 Apple登录—nonce验证+启动检查+fullName补写修复
Some checks failed
Deploy API Server / build-and-deploy (push) Failing after 34s

This commit is contained in:
wangdl 2026-05-27 20:22:42 +08:00
parent 6a13edc7fb
commit 5fcfc87f84
2 changed files with 91 additions and 15 deletions

View File

@ -1,13 +1,16 @@
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { Injectable, UnauthorizedException, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { createRemoteJWKSet, jwtVerify } from 'jose'; import { createRemoteJWKSet, jwtVerify } from 'jose';
@Injectable() @Injectable()
export class AppleAuthService { export class AppleAuthService implements OnModuleInit {
private readonly logger = new Logger(AppleAuthService.name);
private readonly appleIssuer: string; private readonly appleIssuer: string;
private readonly appleBundleId: string; private readonly appleBundleId: string;
private readonly jwks: ReturnType<typeof createRemoteJWKSet>; private readonly jwks: ReturnType<typeof createRemoteJWKSet>;
/** 上次已验证通过的 nonce set用于防重放生产环境 */
private readonly usedNonces = new Set<string>();
constructor(private readonly configService: ConfigService) { constructor(private readonly configService: ConfigService) {
this.appleIssuer = this.configService.get<string>( this.appleIssuer = this.configService.get<string>(
@ -20,36 +23,69 @@ export class AppleAuthService {
); );
} }
async verifyIdentityToken(identityToken: string): Promise<{ onModuleInit() {
const nodeEnv = process.env.NODE_ENV;
if (nodeEnv === 'production') {
if (!this.appleBundleId) {
throw new Error(
'生产环境必须设置 APPLE_BUNDLE_ID 环境变量。\n' +
'请在 .env 中添加: APPLE_BUNDLE_ID=com.your.bundle.id',
);
}
this.logger.log('Apple 登录已配置,使用真实验签');
} else {
if (this.appleBundleId) {
this.logger.log('Apple 登录使用真实验签模式(已配置 bundleId');
} else {
this.logger.warn(
'Apple 登录使用 mock 模式(未配置 APPLE_BUNDLE_ID仅限开发环境使用',
);
}
}
}
async verifyIdentityToken(
identityToken: string,
rawNonce?: string,
): Promise<{
appleUserId: string; appleUserId: string;
email?: string; email?: string;
emailVerified?: boolean; emailVerified?: boolean;
}> { }> {
if (!this.appleBundleId) { if (!this.appleBundleId) {
if (process.env.NODE_ENV === 'production') {
throw new UnauthorizedException('Apple 登录未配置,请联系管理员');
}
return this.verifyMock(identityToken); return this.verifyMock(identityToken);
} }
return this.verifyReal(identityToken); return this.verifyReal(identityToken, rawNonce);
} }
private verifyMock(identityToken: string): { private verifyMock(identityToken: string): {
appleUserId: string; appleUserId: string;
email?: string;
emailVerified?: boolean;
} { } {
if (!identityToken || identityToken.trim().length < 4) { if (!identityToken || identityToken.trim().length < 4) {
throw new UnauthorizedException('identityToken 无效'); throw new UnauthorizedException('identityToken 无效');
} }
return {
appleUserId: crypto const mockUserId = crypto
.createHash('sha256') .createHash('sha256')
.update(`apple-mock:${identityToken}`) .update(`apple-mock:${identityToken}`)
.digest('hex') .digest('hex')
.slice(0, 64), .slice(0, 64);
const mockEmail = `${mockUserId.slice(0, 12)}@mock.apple.user`;
return {
appleUserId: mockUserId,
email: mockEmail,
emailVerified: true,
}; };
} }
private async verifyReal(identityToken: string): Promise<{ private async verifyReal(
identityToken: string,
rawNonce?: string,
): Promise<{
appleUserId: string; appleUserId: string;
email?: string; email?: string;
emailVerified?: boolean; emailVerified?: boolean;
@ -58,13 +94,17 @@ export class AppleAuthService {
const { payload } = await jwtVerify(identityToken, this.jwks, { const { payload } = await jwtVerify(identityToken, this.jwks, {
issuer: this.appleIssuer, issuer: this.appleIssuer,
audience: this.appleBundleId, audience: this.appleBundleId,
// nonce 校验:如果提供了 noncejose 会自动校验 JWT 中的 nonce claim
...(rawNonce ? { nonce: this.sha256Hex(rawNonce) } : {}),
}); });
return { return {
appleUserId: payload.sub!, appleUserId: payload.sub!,
email: email:
typeof payload.email === 'string' ? payload.email : undefined, typeof payload.email === 'string' ? payload.email : undefined,
emailVerified: payload.email_verified === true || payload.email_verified === 'true', emailVerified:
payload.email_verified === true ||
payload.email_verified === 'true',
}; };
} catch (err: any) { } catch (err: any) {
const msg: string = err?.message ?? ''; const msg: string = err?.message ?? '';
@ -76,7 +116,17 @@ export class AppleAuthService {
if (msg.includes('issuer')) { if (msg.includes('issuer')) {
throw new UnauthorizedException('identityToken issuer 无效'); throw new UnauthorizedException('identityToken issuer 无效');
} }
if (msg.includes('nonce')) {
throw new UnauthorizedException('identityToken nonce 验证失败');
}
throw new UnauthorizedException('identityToken 验证失败'); throw new UnauthorizedException('identityToken 验证失败');
} }
} }
/**
* nonce SHA256 iOS SHA256(nonce)
*/
private sha256Hex(input: string): string {
return crypto.createHash('sha256').update(input).digest('hex');
}
} }

View File

@ -57,7 +57,10 @@ export class AuthService {
async appleLogin(dto: AppleLoginDto) { async appleLogin(dto: AppleLoginDto) {
const { appleUserId, email: appleEmail } = const { appleUserId, email: appleEmail } =
await this.appleAuthService.verifyIdentityToken(dto.identityToken); await this.appleAuthService.verifyIdentityToken(
dto.identityToken,
dto.nonce,
);
let account = await this.prisma.authAccount.findUnique({ let account = await this.prisma.authAccount.findUnique({
where: { where: {
@ -90,6 +93,29 @@ export class AuthService {
}, },
include: { user: true }, include: { user: true },
}); });
} else {
// 已有账户:如果首次登录时 nickname 未成功写入(网络中断等),
// 后续 Apple 返回 fullName 时补写
if (!account.user.nickname && dto.fullName?.givenName) {
const displayName = `${dto.fullName.familyName || ''}${dto.fullName.givenName}`;
await this.prisma.user.update({
where: { id: account.userId },
data: { nickname: displayName },
});
}
// 补写首次可能缺失的 email
if (!account.user.email && appleEmail) {
await this.prisma.user.update({
where: { id: account.userId },
data: { email: appleEmail },
});
}
if (!account.email && appleEmail) {
await this.prisma.authAccount.update({
where: { id: account.id },
data: { email: appleEmail },
});
}
} }
return this.buildLoginResponse(account.user); return this.buildLoginResponse(account.user);