251123:2300 Update T1

This commit is contained in:
2025-11-24 08:15:15 +07:00
parent 23006898d9
commit 9360d78ea6
81 changed files with 4232 additions and 347 deletions

View File

@@ -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();
});
});

View File

@@ -1,4 +0,0 @@
import { Controller } from '@nestjs/common';
@Controller('aut')
export class AutController {}

View File

@@ -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;
}
}

View File

@@ -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],
})

View File

@@ -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' };
}
}

View 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,
};
}
}

View 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;
}
}

View File

@@ -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 {}

View 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
}));

View 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 });

View File

@@ -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);

View 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);

View 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;
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtRefreshGuard extends AuthGuard('jwt-refresh') {}

View File

@@ -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;
}
}

View 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;
}
}

View 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}`,
);
}
}),
);
}
}

View 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,
);
}
}),
);
}
}

View 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;
}
}

View 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;
}
}