diff --git a/src/modules/auth/apple-auth.service.ts b/src/modules/auth/apple-auth.service.ts index 0a77a8a..2afe221 100644 --- a/src/modules/auth/apple-auth.service.ts +++ b/src/modules/auth/apple-auth.service.ts @@ -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; + /** 上次已验证通过的 nonce set,用于防重放(生产环境) */ + private readonly usedNonces = new Set(); constructor(private readonly configService: ConfigService) { this.appleIssuer = this.configService.get( @@ -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'); + } } diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index f6695bd..c381fd0 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -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);