251123:2300 Update T1
This commit is contained in:
@@ -1,18 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AutController } from './aut.controller';
|
||||
|
||||
describe('AutController', () => {
|
||||
let controller: AutController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AutController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<AutController>(AutController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
import { Controller } from '@nestjs/common';
|
||||
|
||||
@Controller('aut')
|
||||
export class AutController {}
|
||||
@@ -1,17 +1,34 @@
|
||||
import { Controller, Post, Body, UnauthorizedException } from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler'; // <--- ✅ เพิ่มบรรทัดนี้ครับ
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { LoginDto } from './dto/login.dto.js'; // <--- Import DTO
|
||||
import { RegisterDto } from './dto/register.dto.js'; // <--- Import DTO
|
||||
// File: src/common/auth/auth.controller.ts
|
||||
// บันทึกการแก้ไข: เพิ่ม Endpoints ให้ครบตามแผน T1.2 (Refresh, Logout, Profile)
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
Get,
|
||||
UseGuards,
|
||||
UnauthorizedException,
|
||||
Request,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { LoginDto } from './dto/login.dto.js';
|
||||
import { RegisterDto } from './dto/register.dto.js';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard.js';
|
||||
import { JwtRefreshGuard } from './guards/jwt-refresh.guard.js'; // ต้องสร้าง Guard นี้
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; // (ถ้าใช้ Swagger)
|
||||
|
||||
@ApiTags('Authentication')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
// เพิ่มความเข้มงวดให้ Login (กัน Brute Force)
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } }) // 🔒 ให้ลองได้แค่ 5 ครั้ง ใน 1 นาที
|
||||
// เปลี่ยน @Body() req เป็น @Body() loginDto: LoginDto
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } }) // เข้มงวด: 5 ครั้ง/นาที
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'เข้าสู่ระบบเพื่อรับ Access & Refresh Token' })
|
||||
async login(@Body() loginDto: LoginDto) {
|
||||
const user = await this.authService.validateUser(
|
||||
loginDto.username,
|
||||
@@ -26,15 +43,38 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Post('register-admin')
|
||||
// เปลี่ยน @Body() req เป็น @Body() registerDto: RegisterDto
|
||||
@UseGuards(JwtAuthGuard) // ควรป้องกัน Route นี้ให้เฉพาะ Superadmin
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'สร้างบัญชีผู้ใช้ใหม่ (Admin Only)' })
|
||||
async register(@Body() registerDto: RegisterDto) {
|
||||
return this.authService.register(registerDto);
|
||||
}
|
||||
/*ตัวอย่าง: ยกเว้นการนับ (เช่น Health Check)
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
|
||||
@SkipThrottle()
|
||||
@Get('health')
|
||||
check() { ... }
|
||||
*/
|
||||
@UseGuards(JwtRefreshGuard)
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'ขอ Access Token ใหม่ด้วย Refresh Token' })
|
||||
async refresh(@Request() req) {
|
||||
// req.user จะมาจาก JwtRefreshStrategy
|
||||
return this.authService.refreshToken(req.user.sub, req.user.refreshToken);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'ออกจากระบบ (Revoke Token)' })
|
||||
async logout(@Request() req) {
|
||||
// ดึง Token จาก Header Authorization: Bearer <token>
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
return this.authService.logout(req.user.sub, token);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('profile')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'ดึงข้อมูลผู้ใช้ปัจจุบัน' })
|
||||
getProfile(@Request() req) {
|
||||
return req.user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// File: src/common/auth/auth.module.ts
|
||||
// บันทึกการแก้ไข: ลงทะเบียน Refresh Strategy และแก้ไข Config
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
@@ -5,7 +8,8 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { AuthController } from './auth.controller.js';
|
||||
import { UserModule } from '../../modules/user/user.module.js';
|
||||
import { JwtStrategy } from '../guards/jwt.strategy.js';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy.js';
|
||||
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -17,14 +21,17 @@ import { JwtStrategy } from '../guards/jwt.strategy.js';
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
// Cast เป็น any เพื่อแก้ปัญหา Type ไม่ตรงกับ Library
|
||||
expiresIn: (configService.get<string>('JWT_EXPIRATION') ||
|
||||
'8h') as any,
|
||||
// ใช้ Template String หรือค่า Default ที่ปลอดภัย
|
||||
expiresIn: configService.get<string>('JWT_EXPIRATION') || '15m',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
providers: [
|
||||
AuthService,
|
||||
JwtStrategy,
|
||||
JwtRefreshStrategy, // ✅ เพิ่ม Strategy สำหรับ Refresh Token
|
||||
],
|
||||
controllers: [AuthController],
|
||||
exports: [AuthService],
|
||||
})
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
// File: src/common/auth/auth.service.ts
|
||||
// บันทึกการแก้ไข: เพิ่ม Refresh Token, Logout (Redis Blacklist) และ Profile ตาม T1.2
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
Inject,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Cache } from 'cache-manager';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { UserService } from '../../modules/user/user.service.js';
|
||||
import { RegisterDto } from './dto/register.dto.js'; // Import DTO
|
||||
import { RegisterDto } from './dto/register.dto.js';
|
||||
import { User } from '../../modules/user/entities/user.entity.js';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private userService: UserService,
|
||||
private jwtService: JwtService,
|
||||
private configService: ConfigService,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache, // ใช้ Redis สำหรับ Blacklist
|
||||
) {}
|
||||
|
||||
// 1. ตรวจสอบ Username/Password
|
||||
async validateUser(username: string, pass: string): Promise<any> {
|
||||
const user = await this.userService.findOneByUsername(username);
|
||||
if (user && (await bcrypt.compare(pass, user.password))) {
|
||||
@@ -21,21 +36,91 @@ export class AuthService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Login: สร้าง Access & Refresh Token
|
||||
async login(user: any) {
|
||||
const payload = { username: user.username, sub: user.user_id };
|
||||
const payload = {
|
||||
username: user.username,
|
||||
sub: user.user_id,
|
||||
scope: 'Global', // ตัวอย่าง: ใส่ Scope เริ่มต้น หรือดึงจาก Role
|
||||
};
|
||||
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
this.jwtService.signAsync(payload, {
|
||||
secret: this.configService.get<string>('JWT_SECRET'),
|
||||
expiresIn: this.configService.get<string>('JWT_EXPIRATION') || '15m',
|
||||
}),
|
||||
this.jwtService.signAsync(payload, {
|
||||
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
|
||||
expiresIn:
|
||||
this.configService.get<string>('JWT_REFRESH_EXPIRATION') || '7d',
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
access_token: this.jwtService.sign(payload),
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
user: user, // ส่งข้อมูล user กลับไปให้ Frontend ใช้แสดงผลเบื้องต้น
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Register (สำหรับ Admin)
|
||||
async register(userDto: RegisterDto) {
|
||||
// ตรวจสอบว่ามี user อยู่แล้วหรือไม่
|
||||
const existingUser = await this.userService.findOneByUsername(
|
||||
userDto.username,
|
||||
);
|
||||
if (existingUser) {
|
||||
throw new BadRequestException('Username already exists');
|
||||
}
|
||||
|
||||
const salt = await bcrypt.genSalt();
|
||||
const hashedPassword = await bcrypt.hash(userDto.password, salt);
|
||||
|
||||
// ใช้ค่าจาก DTO ที่ Validate มาแล้ว
|
||||
return this.userService.create({
|
||||
...userDto,
|
||||
password: hashedPassword,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Refresh Token: ออก Token ใหม่
|
||||
async refreshToken(userId: number, refreshToken: string) {
|
||||
// ตรวจสอบความถูกต้องของ Refresh Token (ถ้าใช้ DB เก็บ Refresh Token ก็เช็คตรงนี้)
|
||||
// ในที่นี้เราเชื่อใจ Signature ของ JWT Refresh Secret
|
||||
const user = await this.userService.findOne(userId);
|
||||
if (!user) throw new UnauthorizedException('User not found');
|
||||
|
||||
// สร้าง Access Token ใหม่
|
||||
const payload = { username: user.username, sub: user.user_id };
|
||||
const accessToken = await this.jwtService.signAsync(payload, {
|
||||
secret: this.configService.get<string>('JWT_SECRET'),
|
||||
expiresIn: this.configService.get<string>('JWT_EXPIRATION') || '15m',
|
||||
});
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
// refresh_token: refreshToken, // จะส่งเดิมกลับ หรือ Rotate ใหม่ก็ได้ (แนะนำ Rotate เพื่อความปลอดภัยสูงสุด)
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Logout: นำ Token เข้า Blacklist ใน Redis
|
||||
async logout(userId: number, accessToken: string) {
|
||||
// หาเวลาที่เหลือของ Token เพื่อตั้ง TTL ใน Redis
|
||||
try {
|
||||
const decoded = this.jwtService.decode(accessToken);
|
||||
if (decoded && decoded.exp) {
|
||||
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
|
||||
if (ttl > 0) {
|
||||
// Key pattern: blacklist:token:{token_string}
|
||||
await this.cacheManager.set(
|
||||
`blacklist:token:${accessToken}`,
|
||||
true,
|
||||
ttl * 1000,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore decoding error
|
||||
}
|
||||
return { message: 'Logged out successfully' };
|
||||
}
|
||||
}
|
||||
|
||||
32
backend/src/common/auth/strategies/jwt-refresh.strategy.ts
Normal file
32
backend/src/common/auth/strategies/jwt-refresh.strategy.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// File: src/common/auth/strategies/jwt-refresh.strategy.ts
|
||||
// บันทึกการแก้ไข: Strategy สำหรับ Refresh Token (T1.2)
|
||||
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class JwtRefreshStrategy extends PassportStrategy(
|
||||
Strategy,
|
||||
'jwt-refresh',
|
||||
) {
|
||||
constructor(configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
// ใช้ Secret แยกต่างหากสำหรับ Refresh Token
|
||||
secretOrKey: configService.get<string>('JWT_REFRESH_SECRET'),
|
||||
passReqToCallback: true,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(req: Request, payload: any) {
|
||||
const refreshToken = ExtractJwt.fromAuthHeaderAsBearerToken()(req);
|
||||
return {
|
||||
...payload,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
63
backend/src/common/auth/strategies/jwt.strategy.ts
Normal file
63
backend/src/common/auth/strategies/jwt.strategy.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// File: src/common/auth/strategies/jwt.strategy.ts
|
||||
// บันทึกการแก้ไข: ปรับปรุง JwtStrategy ให้ตรวจสอบ Blacklist (Redis) และสถานะ User (T1.2)
|
||||
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable, UnauthorizedException, Inject } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager'; // ✅ ใช้สำหรับ Blacklist
|
||||
import { Cache } from 'cache-manager';
|
||||
import { Request } from 'express';
|
||||
import { UserService } from '../../../modules/user/user.service.js';
|
||||
|
||||
// Interface สำหรับ Payload ใน Token
|
||||
export interface JwtPayload {
|
||||
sub: number;
|
||||
username: string;
|
||||
scope?: string; // เพิ่ม Scope ถ้ามีการใช้
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
configService: ConfigService,
|
||||
private userService: UserService,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache, // ✅ Inject Redis Cache
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('JWT_SECRET'),
|
||||
passReqToCallback: true, // ✅ จำเป็นต้องใช้ เพื่อดึง Raw Token มาเช็ค Blacklist
|
||||
});
|
||||
}
|
||||
|
||||
async validate(req: Request, payload: JwtPayload) {
|
||||
// 1. ดึง Token ออกมาเพื่อตรวจสอบใน Blacklist
|
||||
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(req);
|
||||
|
||||
// 2. ตรวจสอบว่า Token นี้อยู่ใน Redis Blacklist หรือไม่ (กรณี Logout ไปแล้ว)
|
||||
const isBlacklisted = await this.cacheManager.get(
|
||||
`blacklist:token:${token}`,
|
||||
);
|
||||
if (isBlacklisted) {
|
||||
throw new UnauthorizedException('Token has been revoked (Logged out)');
|
||||
}
|
||||
|
||||
// 3. ค้นหา User จาก Database
|
||||
const user = await this.userService.findOne(payload.sub);
|
||||
|
||||
// 4. ตรวจสอบความถูกต้องของ User
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
// 5. (Optional) ตรวจสอบว่า User ยัง Active อยู่หรือไม่
|
||||
if (user.is_active === false || user.is_active === 0) {
|
||||
throw new UnauthorizedException('User account is inactive');
|
||||
}
|
||||
|
||||
// คืนค่า User เพื่อนำไปใส่ใน req.user
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,31 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { AutController } from './aut/aut.controller';
|
||||
// File: src/common/common.module.ts
|
||||
// บันทึกการแก้ไข: Module รวม Infrastructure พื้นฐาน (T1.1)
|
||||
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { CryptoService } from './services/crypto.service';
|
||||
import { RequestContextService } from './services/request-context.service';
|
||||
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { HttpExceptionFilter } from './exceptions/http-exception.filter';
|
||||
import { TransformInterceptor } from './interceptors/transform.interceptor';
|
||||
// import { IdempotencyInterceptor } from './interceptors/idempotency.interceptor'; // นำเข้าถ้าต้องการใช้ Global
|
||||
|
||||
@Global() // ทำให้ Module นี้ใช้ได้ทั่วทั้งแอปโดยไม่ต้อง Import ซ้ำ
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [AutController]
|
||||
imports: [ConfigModule],
|
||||
providers: [
|
||||
CryptoService,
|
||||
RequestContextService,
|
||||
// Register Global Filter & Interceptor ที่นี่ หรือใน AppModule ก็ได้
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
useClass: HttpExceptionFilter,
|
||||
},
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: TransformInterceptor,
|
||||
},
|
||||
],
|
||||
exports: [CryptoService, RequestContextService],
|
||||
})
|
||||
export class CommonModule {}
|
||||
|
||||
11
backend/src/common/config/redis.config.ts
Normal file
11
backend/src/common/config/redis.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// File: src/common/config/redis.config.ts
|
||||
// บันทึกการแก้ไข: สร้าง Config สำหรับ Redis (T0.2)
|
||||
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('redis', () => ({
|
||||
host: process.env.REDIS_HOST || 'cache', // Default เป็นชื่อ Service ใน Docker
|
||||
port: parseInt(process.env.REDIS_PORT, 10) || 6379,
|
||||
ttl: parseInt(process.env.REDIS_TTL, 10) || 3600, // Default TTL 1 ชั่วโมง
|
||||
// password: process.env.REDIS_PASSWORD, // เปิดใช้ถ้ามี Password
|
||||
}));
|
||||
11
backend/src/common/decorators/audit.decorator.ts
Normal file
11
backend/src/common/decorators/audit.decorator.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const AUDIT_KEY = 'audit';
|
||||
|
||||
export interface AuditMetadata {
|
||||
action: string; // ชื่อการกระทำ (เช่น 'rfa.create', 'user.login')
|
||||
entityType?: string; // ชื่อ Entity (เช่น 'rfa', 'user') - ถ้าไม่ระบุอาจจะพยายามเดา
|
||||
}
|
||||
|
||||
export const Audit = (action: string, entityType?: string) =>
|
||||
SetMetadata(AUDIT_KEY, { action, entityType });
|
||||
@@ -0,0 +1,10 @@
|
||||
// File: src/common/decorators/bypass-maintenance.decorator.ts
|
||||
// บันทึกการแก้ไข: ใช้สำหรับยกเว้นการตรวจสอบ Maintenance Mode (T1.1)
|
||||
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const BYPASS_MAINTENANCE_KEY = 'bypass_maintenance';
|
||||
|
||||
// ใช้ @BypassMaintenance() บน Controller หรือ Method ที่ต้องการให้ทำงานได้แม้ปิดระบบ
|
||||
export const BypassMaintenance = () =>
|
||||
SetMetadata(BYPASS_MAINTENANCE_KEY, true);
|
||||
7
backend/src/common/decorators/idempotency.decorator.ts
Normal file
7
backend/src/common/decorators/idempotency.decorator.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// File: src/common/decorators/idempotency.decorator.ts
|
||||
// ใช้สำหรับบังคับว่า Controller นี้ต้องมี Idempotency Key (Optional Enhancement)
|
||||
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IDEMPOTENCY_KEY = 'idempotency_required';
|
||||
export const RequireIdempotency = () => SetMetadata(IDEMPOTENCY_KEY, true);
|
||||
56
backend/src/common/entities/audit-log.entity.ts
Normal file
56
backend/src/common/entities/audit-log.entity.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// File: src/common/entities/audit-log.entity.ts
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../modules/user/entities/user.entity';
|
||||
|
||||
@Entity('audit_logs')
|
||||
export class AuditLog {
|
||||
@PrimaryGeneratedColumn({ name: 'audit_id', type: 'bigint' })
|
||||
auditId!: string;
|
||||
|
||||
@Column({ name: 'request_id', nullable: true })
|
||||
requestId?: string;
|
||||
|
||||
// ✅ ต้องมีบรรทัดนี้ (TypeORM ต้องการเพื่อ Map Column)
|
||||
@Column({ name: 'user_id', nullable: true })
|
||||
userId?: number | null; // ✅ เพิ่ม | null เพื่อรองรับค่า null
|
||||
|
||||
@Column({ length: 100 })
|
||||
action!: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['INFO', 'WARN', 'ERROR', 'CRITICAL'],
|
||||
default: 'INFO',
|
||||
})
|
||||
severity!: string;
|
||||
|
||||
@Column({ name: 'entity_type', length: 50, nullable: true })
|
||||
entityType?: string;
|
||||
|
||||
@Column({ name: 'entity_id', length: 50, nullable: true })
|
||||
entityId?: string;
|
||||
|
||||
@Column({ name: 'details_json', type: 'json', nullable: true })
|
||||
detailsJson?: any;
|
||||
|
||||
@Column({ name: 'ip_address', length: 45, nullable: true })
|
||||
ipAddress?: string;
|
||||
|
||||
@Column({ name: 'user_agent', length: 255, nullable: true })
|
||||
userAgent?: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user?: User;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// File: src/common/exceptions/http-exception.filter.ts
|
||||
// บันทึกการแก้ไข: ปรับปรุง Global Filter ให้จัดการ Error ปลอดภัยสำหรับ Production และ Log ละเอียดใน Dev (T1.1)
|
||||
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
@@ -17,34 +20,65 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
// 1. หา Status Code
|
||||
const status =
|
||||
exception instanceof HttpException
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
// 2. หา Error Response Body ต้นฉบับ
|
||||
const exceptionResponse =
|
||||
exception instanceof HttpException
|
||||
? exception.getResponse()
|
||||
: 'Internal server error';
|
||||
: { message: 'Internal server error' };
|
||||
|
||||
// จัดรูปแบบ Error Message
|
||||
const message =
|
||||
// จัดรูปแบบ Error Message ให้เป็น Object เสมอ
|
||||
let errorBody: any =
|
||||
typeof exceptionResponse === 'string'
|
||||
? exceptionResponse
|
||||
: (exceptionResponse as any).message || exceptionResponse;
|
||||
// 👇👇 เพิ่มบรรทัดนี้ครับ (สำคัญมาก!) 👇👇
|
||||
console.error('💥 REAL ERROR:', exception);
|
||||
? { message: exceptionResponse }
|
||||
: exceptionResponse;
|
||||
|
||||
// Log Error (สำคัญมากสำหรับการ Debug แต่ไม่ส่งให้ Client เห็นทั้งหมด)
|
||||
this.logger.error(
|
||||
`Http Status: ${status} Error Message: ${JSON.stringify(message)}`,
|
||||
);
|
||||
// 3. 📝 Logging Strategy (แยกตามความรุนแรง)
|
||||
if (status >= 500) {
|
||||
// 💥 Critical Error: Log stack trace เต็มๆ
|
||||
this.logger.error(
|
||||
`💥 HTTP ${status} Error on ${request.method} ${request.url}`,
|
||||
exception instanceof Error
|
||||
? exception.stack
|
||||
: JSON.stringify(exception),
|
||||
);
|
||||
|
||||
response.status(status).json({
|
||||
// 👇👇 สิ่งที่คุณต้องการ: Log ดิบๆ ให้เห็นชัดใน Docker Console 👇👇
|
||||
console.error('💥 REAL CRITICAL ERROR:', exception);
|
||||
} else {
|
||||
// ⚠️ Client Error (400, 401, 403, 404): Log แค่ Warning พอ ไม่ต้อง Stack Trace
|
||||
this.logger.warn(
|
||||
`⚠️ HTTP ${status} Error on ${request.method} ${request.url}: ${JSON.stringify(errorBody.message || errorBody)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 🔒 Security & Response Formatting
|
||||
// กรณี Production และเป็น Error 500 -> ต้องซ่อนรายละเอียดความผิดพลาดของ Server
|
||||
if (status === 500 && process.env.NODE_ENV === 'production') {
|
||||
errorBody = {
|
||||
message: 'Internal server error',
|
||||
// อาจเพิ่ม reference code เพื่อให้ user แจ้ง support ได้ เช่น code: 'ERR-500'
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Construct Final Response
|
||||
const responseBody = {
|
||||
statusCode: status,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
message: status === 500 ? 'Internal server error' : message, // ซ่อน Detail กรณี 500
|
||||
});
|
||||
...errorBody, // Spread message, error, validation details
|
||||
};
|
||||
|
||||
// 🛠️ Development Mode: แถม Stack Trace ไปให้ Frontend Debug ง่ายขึ้น
|
||||
if (process.env.NODE_ENV !== 'production' && exception instanceof Error) {
|
||||
responseBody.stack = exception.stack;
|
||||
}
|
||||
|
||||
response.status(status).json(responseBody);
|
||||
}
|
||||
}
|
||||
|
||||
5
backend/src/common/guards/jwt-refresh.guard.ts
Normal file
5
backend/src/common/guards/jwt-refresh.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtRefreshGuard extends AuthGuard('jwt-refresh') {}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
// Interface สำหรับ Payload ใน Token
|
||||
interface JwtPayload {
|
||||
sub: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
import { UserService } from '../../modules/user/user.service.js';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
configService: ConfigService,
|
||||
private userService: UserService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('JWT_SECRET')!,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload) {
|
||||
const user = await this.userService.findOne(payload.sub);
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
71
backend/src/common/guards/maintenance-mode.guard.ts
Normal file
71
backend/src/common/guards/maintenance-mode.guard.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// File: src/common/guards/maintenance-mode.guard.ts
|
||||
// บันทึกการแก้ไข: ตรวจสอบ Flag ใน Redis เพื่อ Block API ระหว่างปรับปรุงระบบ (T1.1)
|
||||
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Inject,
|
||||
Injectable,
|
||||
ServiceUnavailableException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Cache } from 'cache-manager';
|
||||
import { BYPASS_MAINTENANCE_KEY } from '../decorators/bypass-maintenance.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class MaintenanceModeGuard implements CanActivate {
|
||||
private readonly logger = new Logger(MaintenanceModeGuard.name);
|
||||
// Key ที่ใช้เก็บสถานะใน Redis (Admin จะเป็นคน Toggle ค่านี้)
|
||||
private readonly MAINTENANCE_KEY = 'system:maintenance_mode';
|
||||
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
// 1. ตรวจสอบว่า Route นี้ได้รับการยกเว้นหรือไม่ (Bypass)
|
||||
const isBypassed = this.reflector.getAllAndOverride<boolean>(
|
||||
BYPASS_MAINTENANCE_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (isBypassed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. ตรวจสอบสถานะจาก Redis
|
||||
try {
|
||||
const isMaintenanceOn = await this.cacheManager.get(this.MAINTENANCE_KEY);
|
||||
|
||||
// ถ้า Redis มีค่าเป็น true หรือ string "true" ให้ Block
|
||||
if (isMaintenanceOn === true || isMaintenanceOn === 'true') {
|
||||
// (Optional) 3. ตรวจสอบ Backdoor Header สำหรับ Admin (ถ้าต้องการ Bypass ฉุกเฉิน)
|
||||
const request = context.switchToHttp().getRequest();
|
||||
// const bypassToken = request.headers['x-maintenance-bypass'];
|
||||
// if (bypassToken === process.env.ADMIN_SECRET) return true;
|
||||
|
||||
this.logger.warn(
|
||||
`Blocked request to ${request.url} due to Maintenance Mode`,
|
||||
);
|
||||
|
||||
throw new ServiceUnavailableException({
|
||||
statusCode: 503,
|
||||
message: 'ระบบกำลังปิดปรับปรุงชั่วคราว กรุณาลองใหม่ในภายหลัง',
|
||||
error: 'Service Unavailable',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// กรณี Redis ล่ม หรือ Error อื่นๆ ให้ยอมให้ผ่านไปก่อน (Fail Open) หรือ Block (Fail Closed) ตามนโยบาย
|
||||
// ในที่นี้เลือก Fail Open เพื่อไม่ให้ระบบล่มตาม Redis
|
||||
if (error instanceof ServiceUnavailableException) {
|
||||
throw error;
|
||||
}
|
||||
this.logger.error('Error checking maintenance mode', error);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
80
backend/src/common/interceptors/audit-log.interceptor.ts
Normal file
80
backend/src/common/interceptors/audit-log.interceptor.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Request } from 'express';
|
||||
|
||||
import { AuditLog } from '../entities/audit-log.entity';
|
||||
import { AUDIT_KEY, AuditMetadata } from '../decorators/audit.decorator';
|
||||
import { User } from '../../modules/user/entities/user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AuditLogInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger(AuditLogInterceptor.name);
|
||||
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
@InjectRepository(AuditLog)
|
||||
private auditLogRepo: Repository<AuditLog>,
|
||||
) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const auditMetadata = this.reflector.getAllAndOverride<AuditMetadata>(
|
||||
AUDIT_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!auditMetadata) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const user = (request as any).user as User;
|
||||
const rawIp = request.ip || request.socket.remoteAddress;
|
||||
const ip = Array.isArray(rawIp) ? rawIp[0] : rawIp;
|
||||
const userAgent = request.get('user-agent');
|
||||
|
||||
return next.handle().pipe(
|
||||
tap(async (data) => {
|
||||
try {
|
||||
let entityId = null;
|
||||
|
||||
if (data && typeof data === 'object') {
|
||||
if ('id' in data) entityId = String(data.id);
|
||||
else if ('audit_id' in data) entityId = String(data.audit_id);
|
||||
else if ('user_id' in data) entityId = String(data.user_id);
|
||||
}
|
||||
|
||||
if (!entityId && request.params.id) {
|
||||
entityId = String(request.params.id);
|
||||
}
|
||||
|
||||
// ✅ FIX: ใช้ user?.user_id || null
|
||||
const auditLog = this.auditLogRepo.create({
|
||||
userId: user ? user.user_id : null,
|
||||
action: auditMetadata.action,
|
||||
entityType: auditMetadata.entityType,
|
||||
entityId: entityId,
|
||||
ipAddress: ip,
|
||||
userAgent: userAgent,
|
||||
severity: 'INFO',
|
||||
} as unknown as AuditLog); // ✨ Trick: Cast ผ่าน unknown เพื่อล้าง Error ถ้า TS ยังไม่อัปเดต
|
||||
|
||||
await this.auditLogRepo.save(auditLog);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to create audit log for ${auditMetadata.action}: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
74
backend/src/common/interceptors/idempotency.interceptor.ts
Normal file
74
backend/src/common/interceptors/idempotency.interceptor.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
// File: src/common/interceptors/idempotency.interceptor.ts
|
||||
// บันทึกการแก้ไข: สร้าง IdempotencyInterceptor เพื่อป้องกันการทำรายการซ้ำ (T1.1)
|
||||
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Inject,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ConflictException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Cache } from 'cache-manager';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { Request } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class IdempotencyInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger(IdempotencyInterceptor.name);
|
||||
|
||||
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
|
||||
|
||||
async intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Promise<Observable<any>> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const method = request.method;
|
||||
|
||||
// 1. ตรวจสอบว่าควรใช้ Idempotency หรือไม่ (เฉพาะ POST, PUT, DELETE)
|
||||
if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
// 2. ดึง Idempotency-Key จาก Header
|
||||
const idempotencyKey = request.headers['idempotency-key'] as string;
|
||||
|
||||
// ถ้าไม่มี Key ส่งมา ให้ทำงานปกติ (หรือจะบังคับให้ Error ก็ได้ ตาม Policy)
|
||||
if (!idempotencyKey) {
|
||||
// หมายเหตุ: ในระบบที่ Strict อาจจะ throw BadRequestException ถ้าไม่มี Key สำหรับ Transaction สำคัญ
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const cacheKey = `idempotency:${idempotencyKey}`;
|
||||
|
||||
// 3. ตรวจสอบใน Redis ว่า Key นี้เคยถูกประมวลผลหรือยัง
|
||||
const cachedResponse = await this.cacheManager.get(cacheKey);
|
||||
|
||||
if (cachedResponse) {
|
||||
this.logger.warn(
|
||||
`Idempotency key detected: ${idempotencyKey}. Returning cached response.`,
|
||||
);
|
||||
// ถ้ามี ให้คืนค่าเดิมกลับไปเลย (เสมือนว่าทำรายการสำเร็จแล้ว)
|
||||
return of(cachedResponse);
|
||||
}
|
||||
|
||||
// 4. ถ้ายังไม่มี ให้ประมวลผลต่อ และบันทึกผลลัพธ์ลง Redis
|
||||
return next.handle().pipe(
|
||||
tap(async (response) => {
|
||||
try {
|
||||
// บันทึก Response ลง Cache (TTL 24 ชั่วโมง หรือตามความเหมาะสม)
|
||||
await this.cacheManager.set(cacheKey, response, 86400 * 1000);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to cache idempotency key ${idempotencyKey}`,
|
||||
err.stack,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
40
backend/src/common/services/crypto.service.ts
Normal file
40
backend/src/common/services/crypto.service.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// File: src/common/services/crypto.service.ts
|
||||
// บันทึกการแก้ไข: Encryption/Decryption Utility (T1.1)
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class CryptoService {
|
||||
private readonly algorithm = 'aes-256-cbc';
|
||||
private readonly key: Buffer;
|
||||
private readonly ivLength = 16;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
// Key ต้องมีขนาด 32 bytes (256 bits)
|
||||
const secret =
|
||||
this.configService.get<string>('APP_SECRET_KEY') ||
|
||||
'default-secret-key-32-chars-long!';
|
||||
this.key = crypto.scryptSync(secret, 'salt', 32);
|
||||
}
|
||||
|
||||
encrypt(text: string): string {
|
||||
const iv = crypto.randomBytes(this.ivLength);
|
||||
const cipher = crypto.createCipheriv(this.algorithm, this.key, iv);
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
return `${iv.toString('hex')}:${encrypted}`;
|
||||
}
|
||||
|
||||
decrypt(text: string): string {
|
||||
const [ivHex, encryptedHex] = text.split(':');
|
||||
if (!ivHex || !encryptedHex) return text;
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const decipher = crypto.createDecipheriv(this.algorithm, this.key, iv);
|
||||
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
}
|
||||
}
|
||||
35
backend/src/common/services/request-context.service.ts
Normal file
35
backend/src/common/services/request-context.service.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// File: src/common/services/request-context.service.ts
|
||||
// บันทึกการแก้ไข: เก็บ Context ระหว่าง Request (User, TraceID) (T1.1)
|
||||
|
||||
import { Injectable, Scope } from '@nestjs/common';
|
||||
import { AsyncLocalStorage } from 'async_hooks';
|
||||
|
||||
@Injectable({ scope: Scope.DEFAULT })
|
||||
export class RequestContextService {
|
||||
private static readonly cls = new AsyncLocalStorage<Map<string, any>>();
|
||||
|
||||
static run(fn: () => void) {
|
||||
this.cls.run(new Map(), fn);
|
||||
}
|
||||
|
||||
static set(key: string, value: any) {
|
||||
const store = this.cls.getStore();
|
||||
if (store) {
|
||||
store.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
static get<T>(key: string): T | undefined {
|
||||
const store = this.cls.getStore();
|
||||
return store?.get(key);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
static get currentUserId(): number | null {
|
||||
return this.get('user_id') || null;
|
||||
}
|
||||
|
||||
static get requestId(): string | null {
|
||||
return this.get('request_id') || null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user