feat: 初始化项目
This commit is contained in:
9
src/common/decorators/current-user.decorator.ts
Normal file
9
src/common/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { AccessTokenPayload } from '../../users/services/apple-auth.service';
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext): AccessTokenPayload => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
},
|
||||
);
|
||||
4
src/common/decorators/public.decorator.ts
Normal file
4
src/common/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
68
src/common/encryption.service.spec.ts
Normal file
68
src/common/encryption.service.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { EncryptionService } from './encryption.service';
|
||||
|
||||
describe('EncryptionService', () => {
|
||||
let service: EncryptionService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [EncryptionService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<EncryptionService>(EncryptionService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('should encrypt and decrypt successfully', () => {
|
||||
const originalText = 'Hello, World!';
|
||||
const encrypted = service.encrypt(originalText);
|
||||
const decrypted = service.decrypt(encrypted);
|
||||
|
||||
expect(decrypted).toBe(originalText);
|
||||
});
|
||||
|
||||
it('should encrypt JSON data successfully', () => {
|
||||
const userData = {
|
||||
id: '1234567890',
|
||||
name: '张三',
|
||||
mail: 'zhangsan@example.com'
|
||||
};
|
||||
|
||||
const jsonString = JSON.stringify(userData);
|
||||
const encrypted = service.encrypt(jsonString);
|
||||
const decrypted = service.decrypt(encrypted);
|
||||
const parsedData = JSON.parse(decrypted);
|
||||
|
||||
expect(parsedData).toEqual(userData);
|
||||
});
|
||||
|
||||
it('should generate different ciphertexts for same plaintext', () => {
|
||||
const plaintext = 'test message';
|
||||
const encrypted1 = service.encrypt(plaintext);
|
||||
const encrypted2 = service.encrypt(plaintext);
|
||||
|
||||
// 由于IV随机生成,每次加密结果应该不同
|
||||
expect(encrypted1).not.toBe(encrypted2);
|
||||
|
||||
// 但解密结果应该相同
|
||||
expect(service.decrypt(encrypted1)).toBe(plaintext);
|
||||
expect(service.decrypt(encrypted2)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('should throw error for invalid encrypted data', () => {
|
||||
expect(() => {
|
||||
service.decrypt('invalid-base64-data');
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should handle Chinese characters correctly', () => {
|
||||
const chineseText = '这是一个中文测试消息 🎉';
|
||||
const encrypted = service.encrypt(chineseText);
|
||||
const decrypted = service.decrypt(encrypted);
|
||||
|
||||
expect(decrypted).toBe(chineseText);
|
||||
});
|
||||
});
|
||||
105
src/common/encryption.service.ts
Normal file
105
src/common/encryption.service.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class EncryptionService {
|
||||
private readonly logger = new Logger(EncryptionService.name);
|
||||
private readonly algorithm = 'aes-256-cbc';
|
||||
private readonly keyLength = 32; // 256 bits for AES
|
||||
private readonly hmacKeyLength = 32; // 256 bits for HMAC
|
||||
private readonly ivLength = 16; // 128 bits for CBC
|
||||
private readonly hmacLength = 32; // 256 bits for SHA256
|
||||
|
||||
// 从环境变量获取密钥,或使用默认密钥(生产环境必须使用环境变量)
|
||||
private readonly encryptionKey: Buffer;
|
||||
private readonly hmacKey: Buffer;
|
||||
|
||||
constructor() {
|
||||
const encryptionKeyString = process.env.ENCRYPTION_KEY || 'your-default-32-char-aes-secret-key!';
|
||||
const hmacKeyString = process.env.HMAC_KEY || 'your-default-32-char-hmac-secret-key';
|
||||
this.logger.log(`EncryptionService constructor encryptionKeyString: ${encryptionKeyString}`);
|
||||
this.logger.log(`EncryptionService constructor hmacKeyString: ${hmacKeyString}`);
|
||||
|
||||
// 两个独立的密钥
|
||||
this.encryptionKey = Buffer.from(encryptionKeyString.slice(0, 32).padEnd(32, '0'), 'utf8');
|
||||
this.hmacKey = Buffer.from(hmacKeyString.slice(0, 32).padEnd(32, '0'), 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密数据
|
||||
* @param plaintext 要加密的明文
|
||||
* @returns 加密后的base64字符串,格式:iv.ciphertext.hmac
|
||||
*/
|
||||
encrypt(plaintext: string): string {
|
||||
try {
|
||||
const iv = crypto.randomBytes(this.ivLength);
|
||||
const cipher = crypto.createCipheriv(this.algorithm, this.encryptionKey, iv);
|
||||
|
||||
let ciphertext = cipher.update(plaintext, 'utf8');
|
||||
ciphertext = Buffer.concat([ciphertext, cipher.final()]);
|
||||
|
||||
// 计算 HMAC (对 iv + ciphertext 进行HMAC)
|
||||
const dataToHmac = Buffer.concat([iv, ciphertext]);
|
||||
const hmac = crypto.createHmac('sha256', this.hmacKey);
|
||||
hmac.update(dataToHmac);
|
||||
const hmacDigest = hmac.digest();
|
||||
|
||||
// 组合 iv + ciphertext + hmac 并编码为base64
|
||||
const combined = Buffer.concat([iv, ciphertext, hmacDigest]);
|
||||
return combined.toString('base64');
|
||||
} catch (error) {
|
||||
throw new Error(`加密失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密数据
|
||||
* @param encryptedData 加密的base64字符串
|
||||
* @returns 解密后的明文
|
||||
*/
|
||||
decrypt(encryptedData: string): string {
|
||||
try {
|
||||
const combined = Buffer.from(encryptedData, 'base64');
|
||||
|
||||
// 检查数据长度是否足够
|
||||
if (combined.length < this.ivLength + this.hmacLength) {
|
||||
throw new Error('加密数据格式无效');
|
||||
}
|
||||
|
||||
// 提取 iv, ciphertext, hmac
|
||||
const iv = combined.slice(0, this.ivLength);
|
||||
const hmacDigest = combined.slice(-this.hmacLength);
|
||||
const ciphertext = combined.slice(this.ivLength, -this.hmacLength);
|
||||
|
||||
// 验证 HMAC
|
||||
const dataToHmac = Buffer.concat([iv, ciphertext]);
|
||||
const hmac = crypto.createHmac('sha256', this.hmacKey);
|
||||
hmac.update(dataToHmac);
|
||||
const expectedHmac = hmac.digest();
|
||||
|
||||
if (!crypto.timingSafeEqual(hmacDigest, expectedHmac)) {
|
||||
throw new Error('HMAC验证失败,数据可能被篡改');
|
||||
}
|
||||
|
||||
// 解密
|
||||
const decipher = crypto.createDecipheriv(this.algorithm, this.encryptionKey, iv);
|
||||
let plaintext = decipher.update(ciphertext, undefined, 'utf8');
|
||||
plaintext += decipher.final('utf8');
|
||||
|
||||
return plaintext;
|
||||
} catch (error) {
|
||||
throw new Error(`解密失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成新的加密密钥(用于初始化)
|
||||
* 返回两个独立的密钥
|
||||
*/
|
||||
generateKey(): { encryptionKey: string; hmacKey: string } {
|
||||
return {
|
||||
encryptionKey: crypto.randomBytes(this.keyLength).toString('base64'),
|
||||
hmacKey: crypto.randomBytes(this.hmacKeyLength).toString('base64')
|
||||
};
|
||||
}
|
||||
}
|
||||
46
src/common/guards/jwt-auth.guard.ts
Normal file
46
src/common/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, Logger } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AppleAuthService } from '../../users/services/apple-auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
private readonly logger = new Logger(JwtAuthGuard.name);
|
||||
constructor(
|
||||
private readonly appleAuthService: AppleAuthService,
|
||||
private readonly reflector: Reflector,
|
||||
) { }
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
// 检查是否标记为公开接口
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const authHeader = request.headers.authorization;
|
||||
|
||||
this.logger.log(`authHeader: ${authHeader}`);
|
||||
|
||||
if (!authHeader) {
|
||||
throw new UnauthorizedException('缺少授权头');
|
||||
}
|
||||
|
||||
try {
|
||||
const token = this.appleAuthService.extractTokenFromHeader(authHeader);
|
||||
const payload = this.appleAuthService.verifyAccessToken(token);
|
||||
|
||||
this.logger.log(`鉴权成功: ${JSON.stringify(payload)}, token: ${token}`);
|
||||
// 将用户信息添加到请求对象中
|
||||
request.user = payload;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException('无效的访问令牌');
|
||||
}
|
||||
}
|
||||
}
|
||||
9
src/common/logger/logger.module.ts
Normal file
9
src/common/logger/logger.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WinstonModule } from 'nest-winston';
|
||||
import { winstonConfig } from './winston.config';
|
||||
|
||||
@Module({
|
||||
imports: [WinstonModule.forRoot(winstonConfig)],
|
||||
exports: [WinstonModule],
|
||||
})
|
||||
export class LoggerModule { }
|
||||
136
src/common/logger/winston.config.ts
Normal file
136
src/common/logger/winston.config.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import * as winston from 'winston';
|
||||
import * as DailyRotateFile from 'winston-daily-rotate-file';
|
||||
import { WinstonModule } from 'nest-winston';
|
||||
import * as path from 'path';
|
||||
|
||||
// 日志目录
|
||||
const LOG_DIR = path.join(process.cwd(), 'logs');
|
||||
|
||||
// 日志格式
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp({
|
||||
format: 'YYYY-MM-DD HH:mm:ss',
|
||||
}),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.printf((info) => {
|
||||
const { timestamp, level, message, context, stack, ...meta } = info;
|
||||
const contextStr = context ? `[${context}] ` : '';
|
||||
const stackStr = stack ? `\n${stack}` : '';
|
||||
|
||||
// 如果有额外的元数据,将其格式化为JSON字符串
|
||||
const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta, null, 2)}` : '';
|
||||
|
||||
return `${timestamp} [${level.toUpperCase()}] ${contextStr}${message}${metaStr}${stackStr}`;
|
||||
}),
|
||||
);
|
||||
|
||||
// 控制台格式(带颜色)
|
||||
const consoleFormat = winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.timestamp({
|
||||
format: 'YYYY-MM-DD HH:mm:ss',
|
||||
}),
|
||||
winston.format.printf((info) => {
|
||||
const { timestamp, level, message, context, stack, ...meta } = info;
|
||||
const contextStr = context ? `[${context}] ` : '';
|
||||
const stackStr = stack ? `\n${stack}` : '';
|
||||
|
||||
// 如果有额外的元数据,将其格式化为JSON字符串
|
||||
const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta, null, 2)}` : '';
|
||||
|
||||
return `${timestamp} ${level} ${contextStr}${message}${metaStr}${stackStr}`;
|
||||
}),
|
||||
);
|
||||
|
||||
// 创建日志传输器
|
||||
const createTransports = () => {
|
||||
const transports: winston.transport[] = [];
|
||||
|
||||
// 控制台输出
|
||||
transports.push(
|
||||
new winston.transports.Console({
|
||||
format: consoleFormat,
|
||||
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
}),
|
||||
);
|
||||
|
||||
// 错误日志文件(按日期滚动,保留7天)
|
||||
transports.push(
|
||||
new DailyRotateFile({
|
||||
filename: path.join(LOG_DIR, 'error-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
level: 'error',
|
||||
format: logFormat,
|
||||
maxFiles: '7d', // 保留7天
|
||||
maxSize: '20m', // 单个文件最大20MB
|
||||
auditFile: path.join(LOG_DIR, '.audit-error.json'),
|
||||
}),
|
||||
);
|
||||
|
||||
// 应用日志文件(按日期滚动,保留7天)
|
||||
transports.push(
|
||||
new DailyRotateFile({
|
||||
filename: path.join(LOG_DIR, 'app-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
level: 'info',
|
||||
format: logFormat,
|
||||
maxFiles: '7d', // 保留7天
|
||||
maxSize: '20m', // 单个文件最大20MB
|
||||
auditFile: path.join(LOG_DIR, '.audit-app.json'),
|
||||
}),
|
||||
);
|
||||
|
||||
// 调试日志文件(仅在开发环境)
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
transports.push(
|
||||
new DailyRotateFile({
|
||||
filename: path.join(LOG_DIR, 'debug-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
level: 'debug',
|
||||
format: logFormat,
|
||||
maxFiles: '7d', // 保留7天
|
||||
maxSize: '20m', // 单个文件最大20MB
|
||||
auditFile: path.join(LOG_DIR, '.audit-debug.json'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return transports;
|
||||
};
|
||||
|
||||
// Winston配置
|
||||
export const winstonConfig = {
|
||||
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
format: logFormat,
|
||||
transports: createTransports(),
|
||||
// 处理未捕获的异常
|
||||
exceptionHandlers: [
|
||||
new DailyRotateFile({
|
||||
filename: path.join(LOG_DIR, 'exceptions-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
format: logFormat,
|
||||
maxFiles: '7d',
|
||||
maxSize: '20m',
|
||||
auditFile: path.join(LOG_DIR, '.audit-exceptions.json'),
|
||||
}),
|
||||
],
|
||||
// 处理未处理的Promise拒绝
|
||||
rejectionHandlers: [
|
||||
new DailyRotateFile({
|
||||
filename: path.join(LOG_DIR, 'rejections-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
format: logFormat,
|
||||
maxFiles: '7d',
|
||||
maxSize: '20m',
|
||||
auditFile: path.join(LOG_DIR, '.audit-rejections.json'),
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
// 创建Winston Logger实例
|
||||
export const createWinstonLogger = () => {
|
||||
return WinstonModule.createLogger(winstonConfig);
|
||||
};
|
||||
|
||||
// 导出winston实例供直接使用
|
||||
export const logger = winston.createLogger(winstonConfig);
|
||||
Reference in New Issue
Block a user