- 在 GET /challenges、GET /challenges/:id、GET /challenges/:id/rankings 添加 @Public() 装饰器,允许未登录用户访问 - 将 userId 改为可选参数,未登录时仍可返回基础数据 - 列表接口过滤掉 UPCOMING 状态挑战,仅展示进行中/已结束 - 返回 DTO 新增 unit 字段,用于前端展示进度单位 - 鉴权守卫优化:公开接口若携带 token 仍尝试解析并注入 user,方便后续业务逻辑
241 lines
7.1 KiB
TypeScript
241 lines
7.1 KiB
TypeScript
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;
|
||
}
|
||
}
|