Files
plates-server/src/users/services/apple-auth.service.ts
richarjiang 999fc7f793 feat(challenges): 支持公开访问挑战列表与详情接口
- 在 GET /challenges、GET /challenges/:id、GET /challenges/:id/rankings 添加 @Public() 装饰器,允许未登录用户访问
- 将 userId 改为可选参数,未登录时仍可返回基础数据
- 列表接口过滤掉 UPCOMING 状态挑战,仅展示进行中/已结束
- 返回 DTO 新增 unit 字段,用于前端展示进度单位
- 鉴权守卫优化:公开接口若携带 token 仍尝试解析并注入 user,方便后续业务逻辑
2025-09-30 16:43:46 +08:00

241 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 ')) {
return ''
}
return authHeader.substring(7);
}
/**
* 获取访问令牌的过期时间(秒)
*/
getAccessTokenExpiresIn(): number {
return this.accessTokenExpiresIn;
}
/**
* 获取刷新令牌的过期时间(秒)
*/
getRefreshTokenExpiresIn(): number {
return this.refreshTokenExpiresIn;
}
}