2026-05-09 18:57:33 +08:00
|
|
|
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';
|
2026-05-13 15:35:41 +08:00
|
|
|
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;
|
|
|
|
|
}
|
2026-05-09 18:25:04 +08:00
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class AuthService {
|
2026-05-13 15:35:41 +08:00
|
|
|
private jwks: jwksClient.JwksClient;
|
|
|
|
|
|
2026-05-09 18:57:33 +08:00
|
|
|
constructor(
|
2026-05-13 15:35:41 +08:00
|
|
|
private readonly nativeJwtService: JwtService,
|
2026-05-09 18:57:33 +08:00
|
|
|
private readonly prisma: PrismaService,
|
|
|
|
|
private readonly configService: ConfigService,
|
|
|
|
|
) {}
|
2026-05-09 18:25:04 +08:00
|
|
|
|
|
|
|
|
async appleLogin(params: {
|
|
|
|
|
identityToken: string;
|
|
|
|
|
authorizationCode: string;
|
|
|
|
|
user?: { name?: { firstName?: string; lastName?: string }; email?: string };
|
|
|
|
|
}) {
|
2026-05-09 18:57:33 +08:00
|
|
|
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) {
|
2026-05-09 18:25:04 +08:00
|
|
|
const displayName =
|
|
|
|
|
params.user?.name
|
|
|
|
|
? `${params.user.name.lastName || ''}${params.user.name.firstName || ''}`
|
|
|
|
|
: undefined;
|
2026-05-09 18:57:33 +08:00
|
|
|
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 },
|
2026-05-09 18:25:04 +08:00
|
|
|
});
|
|
|
|
|
}
|
2026-05-09 18:57:33 +08:00
|
|
|
|
2026-05-13 15:35:41 +08:00
|
|
|
const accessToken = await this.nativeJwtService.signAsync({
|
|
|
|
|
sub: String(account.user.id),
|
2026-05-09 18:57:33 +08:00
|
|
|
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),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-09 18:25:04 +08:00
|
|
|
return { accessToken, refreshToken, expiresIn: 3600 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async refresh(refreshToken: string) {
|
2026-05-09 18:57:33 +08:00
|
|
|
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),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-13 15:35:41 +08:00
|
|
|
const accessToken = await this.nativeJwtService.signAsync({
|
2026-05-09 18:57:33 +08:00
|
|
|
sub: String(stored.user.id),
|
|
|
|
|
email: stored.user.email,
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-13 15:35:41 +08:00
|
|
|
return { accessToken, refreshToken: newRefreshToken, expiresIn: 3600 };
|
2026-05-09 18:57:33 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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<string> {
|
|
|
|
|
if (this.isMockMode()) {
|
2026-05-13 15:35:41 +08:00
|
|
|
return this.verifyMockApple(identityToken);
|
2026-05-09 18:57:33 +08:00
|
|
|
}
|
2026-05-13 15:35:41 +08:00
|
|
|
return this.verifyRealApple(identityToken);
|
2026-05-09 18:57:33 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private isMockMode(): boolean {
|
2026-05-13 15:35:41 +08:00
|
|
|
const bundleId = this.configService.get<string>('apple.bundleId');
|
|
|
|
|
return !bundleId;
|
2026-05-09 18:57:33 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-13 15:35:41 +08:00
|
|
|
private verifyMockApple(identityToken: string): string {
|
2026-05-09 18:57:33 +08:00
|
|
|
if (!identityToken || identityToken.trim().length < 4) {
|
|
|
|
|
throw new UnauthorizedException('identityToken 无效');
|
|
|
|
|
}
|
|
|
|
|
return crypto
|
|
|
|
|
.createHash('sha256')
|
2026-05-13 15:35:41 +08:00
|
|
|
.update(`apple-mock:${identityToken}`)
|
2026-05-09 18:57:33 +08:00
|
|
|
.digest('hex')
|
|
|
|
|
.slice(0, 64);
|
2026-05-09 18:25:04 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-13 15:35:41 +08:00
|
|
|
private getJwksClient(): jwksClient.JwksClient {
|
|
|
|
|
if (!this.jwks) {
|
|
|
|
|
const jwksUrl = this.configService.get<string>(
|
|
|
|
|
'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<string> {
|
|
|
|
|
const bundleId = this.configService.get<string>('apple.bundleId');
|
|
|
|
|
const issuer = this.configService.get<string>('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;
|
2026-05-09 18:25:04 +08:00
|
|
|
}
|
|
|
|
|
}
|