import * as crypto from 'crypto'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import jwksClient from 'jwks-rsa'; import jwt from 'jsonwebtoken'; interface AppleIdTokenPayload { sub: string; email?: string; email_verified?: string | boolean; is_private_email?: string | boolean; aud: string; iss: string; exp: number; iat: number; } @Injectable() export class AuthService { private jwks: jwksClient.JwksClient; constructor( private readonly nativeJwtService: JwtService, private readonly prisma: PrismaService, private readonly configService: ConfigService, ) {} async appleLogin(params: { identityToken: string; authorizationCode: string; user?: { name?: { firstName?: string; lastName?: string }; email?: string }; }) { const appleUserId = await this.verifyAppleIdentity( params.identityToken, params.authorizationCode, ); let account = await this.prisma.authAccount.findUnique({ where: { provider_providerUserId: { provider: 'apple', providerUserId: appleUserId } }, include: { user: true }, }); if (!account) { const displayName = params.user?.name ? `${params.user.name.lastName || ''}${params.user.name.firstName || ''}` : undefined; account = await this.prisma.authAccount.create({ data: { provider: 'apple', providerUserId: appleUserId, email: params.user?.email, user: { create: { email: params.user?.email, nickname: displayName || undefined, status: 'active', }, }, }, include: { user: true }, }); } const accessToken = await this.nativeJwtService.signAsync({ sub: String(account.user.id), email: account.user.email, }); const refreshToken = crypto.randomBytes(48).toString('hex'); const refreshTokenHash = crypto .createHash('sha256') .update(refreshToken) .digest('hex'); await this.prisma.refreshToken.create({ data: { userId: account.user.id, tokenHash: refreshTokenHash, expiresAt: new Date(Date.now() + 7 * 86400000), }, }); return { accessToken, refreshToken, expiresIn: 3600 }; } async refresh(refreshToken: string) { const hash = crypto.createHash('sha256').update(refreshToken).digest('hex'); const stored = await this.prisma.refreshToken.findFirst({ where: { tokenHash: hash, revokedAt: null }, include: { user: true }, }); if (!stored || stored.expiresAt < new Date()) { throw new UnauthorizedException('刷新令牌无效或已过期'); } await this.prisma.refreshToken.update({ where: { id: stored.id }, data: { revokedAt: new Date() }, }); const newRefreshToken = crypto.randomBytes(48).toString('hex'); const newHash = crypto .createHash('sha256') .update(newRefreshToken) .digest('hex'); await this.prisma.refreshToken.create({ data: { userId: stored.userId, tokenHash: newHash, expiresAt: new Date(Date.now() + 7 * 86400000), }, }); const accessToken = await this.nativeJwtService.signAsync({ sub: String(stored.user.id), email: stored.user.email, }); return { accessToken, refreshToken: newRefreshToken, expiresIn: 3600 }; } async logout(userId: number) { await this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: new Date() }, }); } private async verifyAppleIdentity( identityToken: string, authorizationCode: string, ): Promise { if (this.isMockMode()) { return this.verifyMockApple(identityToken); } return this.verifyRealApple(identityToken); } private isMockMode(): boolean { const bundleId = this.configService.get('apple.bundleId'); return !bundleId; } private verifyMockApple(identityToken: string): string { if (!identityToken || identityToken.trim().length < 4) { throw new UnauthorizedException('identityToken 无效'); } return crypto .createHash('sha256') .update(`apple-mock:${identityToken}`) .digest('hex') .slice(0, 64); } private getJwksClient(): jwksClient.JwksClient { if (!this.jwks) { const jwksUrl = this.configService.get( 'apple.jwksUrl', 'https://appleid.apple.com/auth/keys', ); this.jwks = jwksClient({ jwksUri: jwksUrl, cache: true, rateLimit: true }); } return this.jwks!; } private async verifyRealApple(identityToken: string): Promise { const bundleId = this.configService.get('apple.bundleId'); const issuer = this.configService.get('apple.issuer', 'https://appleid.apple.com'); const decodedHeader = jwt.decode(identityToken, { complete: true }); if (!decodedHeader || typeof decodedHeader === 'string') { throw new UnauthorizedException('无法解析 identityToken'); } const kid = decodedHeader.header.kid; if (!kid) { throw new UnauthorizedException('identityToken 缺少 kid'); } let publicKey: string; try { const client = this.getJwksClient(); const key = await client.getSigningKey(kid); publicKey = key.getPublicKey(); } catch { throw new UnauthorizedException('无法获取 Apple 公钥,请稍后重试'); } let payload: AppleIdTokenPayload; try { payload = jwt.verify(identityToken, publicKey, { algorithms: ['RS256'], issuer, audience: bundleId, }) as AppleIdTokenPayload; } catch (err: any) { const msg = err.message || ''; if (msg.includes('audience')) { throw new UnauthorizedException( `identityToken audience 不匹配,期望 ${bundleId}`, ); } if (msg.includes('issuer')) { throw new UnauthorizedException('identityToken issuer 无效'); } throw new UnauthorizedException('identityToken 验证失败'); } return payload.sub; } }