api-server/src/modules/auth/auth.service.ts

217 lines
6.2 KiB
TypeScript
Raw Normal View History

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<string> {
if (this.isMockMode()) {
return this.verifyMockApple(identityToken);
}
return this.verifyRealApple(identityToken);
}
private isMockMode(): boolean {
const bundleId = this.configService.get<string>('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<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;
}
}