fix(auth): H0-01 Apple登录—nonce验证+启动检查+fullName补写修复
Some checks failed
Deploy API Server / build-and-deploy (push) Failing after 34s
Some checks failed
Deploy API Server / build-and-deploy (push) Failing after 34s
This commit is contained in:
parent
6a13edc7fb
commit
5fcfc87f84
@ -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 校验:如果提供了 nonce,jose 会自动校验 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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user