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 { Injectable, UnauthorizedException } from '@nestjs/common';
import { Injectable, UnauthorizedException, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createRemoteJWKSet, jwtVerify } from 'jose';
@Injectable()
export class AppleAuthService {
export class AppleAuthService implements OnModuleInit {
private readonly logger = new Logger(AppleAuthService.name);
private readonly appleIssuer: string;
private readonly appleBundleId: string;
private readonly jwks: ReturnType<typeof createRemoteJWKSet>;
/** 上次已验证通过的 nonce set用于防重放生产环境 */
private readonly usedNonces = new Set<string>();
constructor(private readonly configService: ConfigService) {
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;
email?: string;
emailVerified?: boolean;
}> {
if (!this.appleBundleId) {
if (process.env.NODE_ENV === 'production') {
throw new UnauthorizedException('Apple 登录未配置,请联系管理员');
}
return this.verifyMock(identityToken);
}
return this.verifyReal(identityToken);
return this.verifyReal(identityToken, rawNonce);
}
private verifyMock(identityToken: string): {
appleUserId: string;
email?: string;
emailVerified?: boolean;
} {
if (!identityToken || identityToken.trim().length < 4) {
throw new UnauthorizedException('identityToken 无效');
}
const mockUserId = crypto
.createHash('sha256')
.update(`apple-mock:${identityToken}`)
.digest('hex')
.slice(0, 64);
const mockEmail = `${mockUserId.slice(0, 12)}@mock.apple.user`;
return {
appleUserId: crypto
.createHash('sha256')
.update(`apple-mock:${identityToken}`)
.digest('hex')
.slice(0, 64),
appleUserId: mockUserId,
email: mockEmail,
emailVerified: true,
};
}
private async verifyReal(identityToken: string): Promise<{
private async verifyReal(
identityToken: string,
rawNonce?: string,
): Promise<{
appleUserId: string;
email?: string;
emailVerified?: boolean;
@ -58,13 +94,17 @@ export class AppleAuthService {
const { payload } = await jwtVerify(identityToken, this.jwks, {
issuer: this.appleIssuer,
audience: this.appleBundleId,
// nonce 校验:如果提供了 noncejose 会自动校验 JWT 中的 nonce claim
...(rawNonce ? { nonce: this.sha256Hex(rawNonce) } : {}),
});
return {
appleUserId: payload.sub!,
email:
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) {
const msg: string = err?.message ?? '';
@ -76,7 +116,17 @@ export class AppleAuthService {
if (msg.includes('issuer')) {
throw new UnauthorizedException('identityToken issuer 无效');
}
if (msg.includes('nonce')) {
throw new UnauthorizedException('identityToken nonce 验证失败');
}
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) {
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({
where: {
@ -90,6 +93,29 @@ export class AuthService {
},
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);