feat: 初始化项目
This commit is contained in:
241
src/users/services/apple-auth.service.ts
Normal file
241
src/users/services/apple-auth.service.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { Injectable, Logger, UnauthorizedException, BadRequestException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import * as jwksClient from 'jwks-rsa';
|
||||
import axios from 'axios';
|
||||
|
||||
export interface AppleTokenPayload {
|
||||
iss: string; // 发行者,应该是 https://appleid.apple.com
|
||||
aud: string; // 受众,应该是你的 bundle ID
|
||||
exp: number; // 过期时间
|
||||
iat: number; // 签发时间
|
||||
sub: string; // 用户的唯一标识符
|
||||
email?: string; // 用户邮箱(可选)
|
||||
email_verified?: boolean; // 邮箱是否验证(可选)
|
||||
is_private_email?: boolean; // 是否是私有邮箱(可选)
|
||||
real_user_status?: number; // 真实用户状态
|
||||
transfer_sub?: string; // 转移的用户标识符(可选)
|
||||
}
|
||||
|
||||
export interface AccessTokenPayload {
|
||||
sub: string; // 用户ID
|
||||
email?: string; // 用户邮箱
|
||||
iat: number; // 签发时间
|
||||
exp: number; // 过期时间
|
||||
type: 'access'; // token类型
|
||||
}
|
||||
|
||||
export interface RefreshTokenPayload {
|
||||
sub: string; // 用户ID
|
||||
iat: number; // 签发时间
|
||||
exp: number; // 过期时间
|
||||
type: 'refresh'; // token类型
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AppleAuthService {
|
||||
private readonly logger = new Logger(AppleAuthService.name);
|
||||
private readonly bundleId: string;
|
||||
private readonly jwksClient: jwksClient.JwksClient;
|
||||
private readonly accessTokenSecret: string;
|
||||
private readonly refreshTokenSecret: string;
|
||||
private readonly accessTokenExpiresIn = 30 * 24 * 60 * 60; // 30天(秒)
|
||||
private readonly refreshTokenExpiresIn = 90 * 24 * 60 * 60; // 90天(秒)
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly jwtService: JwtService,
|
||||
) {
|
||||
this.bundleId = this.configService.get<string>('APPLE_BUNDLE_ID') || '';
|
||||
this.accessTokenSecret = this.configService.get<string>('JWT_ACCESS_SECRET') || 'your-access-token-secret-key';
|
||||
this.refreshTokenSecret = this.configService.get<string>('JWT_REFRESH_SECRET') || 'your-refresh-token-secret-key';
|
||||
|
||||
// 初始化 JWKS 客户端,用于获取 Apple 的公钥
|
||||
this.jwksClient = jwksClient({
|
||||
jwksUri: 'https://appleid.apple.com/auth/keys',
|
||||
cache: true,
|
||||
cacheMaxAge: 24 * 60 * 60 * 1000, // 24小时缓存
|
||||
rateLimit: true,
|
||||
jwksRequestsPerMinute: 10,
|
||||
});
|
||||
|
||||
if (!this.bundleId) {
|
||||
this.logger.warn('APPLE_BUNDLE_ID 环境变量未设置,Apple登录验证可能失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Apple 的公钥
|
||||
*/
|
||||
private async getApplePublicKey(kid: string): Promise<string> {
|
||||
try {
|
||||
const key = await this.jwksClient.getSigningKey(kid);
|
||||
return key.getPublicKey();
|
||||
} catch (error) {
|
||||
this.logger.error(`获取Apple公钥失败: ${error.message}`);
|
||||
throw new UnauthorizedException('无法验证Apple身份令牌');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Apple Identity Token
|
||||
*/
|
||||
async verifyAppleToken(identityToken: string): Promise<AppleTokenPayload> {
|
||||
try {
|
||||
// 解码 token header 获取 kid
|
||||
const decodedHeader = jwt.decode(identityToken, { complete: true });
|
||||
if (!decodedHeader || !decodedHeader.header.kid) {
|
||||
throw new BadRequestException('无效的身份令牌格式');
|
||||
}
|
||||
|
||||
const kid = decodedHeader.header.kid;
|
||||
|
||||
// 获取 Apple 公钥
|
||||
const publicKey = await this.getApplePublicKey(kid);
|
||||
|
||||
this.logger.log(`Apple 公钥: ${publicKey}`);
|
||||
this.logger.log(`this.bundleId: ${this.bundleId}`);
|
||||
// 验证 token
|
||||
const payload = jwt.verify(identityToken, publicKey, {
|
||||
algorithms: ['RS256'],
|
||||
audience: this.bundleId,
|
||||
issuer: 'https://appleid.apple.com',
|
||||
}) as AppleTokenPayload;
|
||||
|
||||
// 验证必要字段
|
||||
if (!payload.sub) {
|
||||
throw new BadRequestException('身份令牌缺少用户标识符');
|
||||
}
|
||||
|
||||
// 验证 token 是否过期
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp < now) {
|
||||
throw new UnauthorizedException('身份令牌已过期');
|
||||
}
|
||||
|
||||
this.logger.log(`Apple身份令牌验证成功,用户ID: ${payload.sub}`);
|
||||
return payload;
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedException || error instanceof BadRequestException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.error(`验证Apple身份令牌失败: ${error.message}`);
|
||||
throw new UnauthorizedException('身份令牌验证失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成访问令牌
|
||||
*/
|
||||
generateAccessToken(userId: string, email?: string): string {
|
||||
const payload: AccessTokenPayload = {
|
||||
sub: userId,
|
||||
email,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + this.accessTokenExpiresIn,
|
||||
type: 'access',
|
||||
};
|
||||
|
||||
return jwt.sign(payload, this.accessTokenSecret, {
|
||||
algorithm: 'HS256',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成刷新令牌
|
||||
*/
|
||||
generateRefreshToken(userId: string): string {
|
||||
const payload: RefreshTokenPayload = {
|
||||
sub: userId,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + this.refreshTokenExpiresIn,
|
||||
type: 'refresh',
|
||||
};
|
||||
|
||||
return jwt.sign(payload, this.refreshTokenSecret, {
|
||||
algorithm: 'HS256',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证访问令牌
|
||||
*/
|
||||
verifyAccessToken(token: string): AccessTokenPayload {
|
||||
try {
|
||||
const payload = jwt.verify(token, this.accessTokenSecret, {
|
||||
algorithms: ['HS256'],
|
||||
}) as AccessTokenPayload;
|
||||
|
||||
if (payload.type !== 'access') {
|
||||
throw new UnauthorizedException('无效的令牌类型');
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
this.logger.error(`验证访问令牌失败: ${error.message}`);
|
||||
throw new UnauthorizedException('访问令牌无效或已过期');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证刷新令牌
|
||||
*/
|
||||
verifyRefreshToken(token: string): RefreshTokenPayload {
|
||||
try {
|
||||
const payload = jwt.verify(token, this.refreshTokenSecret, {
|
||||
algorithms: ['HS256'],
|
||||
}) as RefreshTokenPayload;
|
||||
|
||||
if (payload.type !== 'refresh') {
|
||||
throw new UnauthorizedException('无效的令牌类型');
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
this.logger.error(`验证刷新令牌失败: ${error.message}`);
|
||||
throw new UnauthorizedException('刷新令牌无效或已过期');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新访问令牌
|
||||
*/
|
||||
async refreshAccessToken(refreshToken: string, userEmail?: string): Promise<{ accessToken: string; expiresIn: number }> {
|
||||
const payload = this.verifyRefreshToken(refreshToken);
|
||||
|
||||
const newAccessToken = this.generateAccessToken(payload.sub, userEmail);
|
||||
|
||||
return {
|
||||
accessToken: newAccessToken,
|
||||
expiresIn: this.accessTokenExpiresIn,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Bearer token 中提取 token
|
||||
*/
|
||||
extractTokenFromHeader(authHeader: string): string {
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('无效的授权头格式');
|
||||
}
|
||||
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取访问令牌的过期时间(秒)
|
||||
*/
|
||||
getAccessTokenExpiresIn(): number {
|
||||
return this.accessTokenExpiresIn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取刷新令牌的过期时间(秒)
|
||||
*/
|
||||
getRefreshTokenExpiresIn(): number {
|
||||
return this.refreshTokenExpiresIn;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user