3.3.5 · Authentication ที่ไม่ทำงาน
บทนี้เจาะลึกระบบ authentication ของ centralize-api ซึ่ง ลงทะเบียนเป็น global guard แต่ถูก bypass ทุก route เพราะทุก controller ใช้ decorator @Public() ทำให้ auth machinery ทั้งหมดกลายเป็น dead code ใน production
ภาพรวม — สถาปัตยกรรม auth
centralize-api ใช้ Passport.js + JWT strategy ผ่าน @nestjs/passport ซึ่งเป็น pattern มาตรฐานของ NestJS
HTTP request (พร้อม Authorization: Bearer {jwt})
│
▼
JwtAuthGuard.canActivate() ← global guard (ลงทะเบียนใน centralize-api/src/main.ts:27)
│
├─ อ่าน @Public() metadata จาก reflector
│
├─ ถ้า isPublic === true → return true (SKIP auth)
│
└─ ถ้า isPublic === false → super.canActivate()
│
▼
JwtStrategy.validate(payload)
│
├─ ตรวจ JWT signature ด้วย secretOrKey
├─ ตรวจ exp (ignoreExpiration: false)
└─ return payload ให้ controllerปัญหา — ทุก controller method มี @Public() ทำให้ branch isPublic === true ทำงานเสมอ → JwtStrategy.validate() ไม่เคยถูกเรียก
JwtAuthGuard — guard ที่ลงทะเบียนแต่ไม่ทำงาน
centralize-api/libs/jwt/jwt-auth.guard.ts:12-54
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') implements CanActivate {
constructor(private reflector: Reflector) {
super();
}
private logger = new Logger(JwtAuthGuard.name);
private serviceName = '';
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(isPublicKey, [
context.getHandler(),
context.getClass(),
]);
this.logger.log(`isPublic -> ${JSON.stringify(isPublic)}`);
const request = context.switchToHttp().getRequest();
this.serviceName = request.url;
if (isPublic) {
return true;
}
return super.canActivate(context);
}
handleRequest(err, user, info) {
// You can throw an exception based on either "info" or "err" arguments
this.logger.log(`user -> ${JSON.stringify(user)}`);
this.logger.log(`serviceName -> ${this.serviceName}`);
// we can protect guard here
if (err || !user) {
throw (
err ||
new UnauthorizedException({ statusCode: 401, error: info.message })
);
}
return user;
}
}
export const isPublicKey = 'isPublic';
export const Public = () => SetMetadata(isPublicKey, true);วิเคราะห์ทีละบรรทัด
canActivate()
const isPublic = this.reflector.getAllAndOverride<boolean>(isPublicKey, [
context.getHandler(),
context.getClass(),
]);
this.logger.log(`isPublic -> ${JSON.stringify(isPublic)}`);
const request = context.switchToHttp().getRequest();
this.serviceName = request.url;
if (isPublic) {
return true;
}
return super.canActivate(context);reflector.getAllAndOverrideอ่าน metadataisPublicKeyจาก method handler แล้วจาก class — ถ้ามีใน method จะ override class- เนื่องจากทุก method มี
@Public()→isPublicจะเป็นtrueเสมอ if (isPublic) return true→ ข้ามsuper.canActivate()→ JWT ไม่ถูกตรวจthis.logger.log(isPublic -> ${...})ทำงานทุก request → log บรรทัดนี้ปรากฏใน log file ทุกครั้ง (HIGH-05)this.serviceName = request.urlเก็บ URL เป็น field ของ instance —JwtAuthGuardเป็น singleton (default scope ของ NestJS provider) ดังนั้น field นี้ถูก share ระหว่าง concurrent requests → race condition เล็กน้อย (ค่าserviceNameอาจเปลี่ยนกลางคัน)
handleRequest()
handleRequest(err, user, info) {
this.logger.log(`user -> ${JSON.stringify(user)}`);
this.logger.log(`serviceName -> ${this.serviceName}`);
// we can protect guard here
...
}- method นี้เรียกโดย
super.canActivate()ของ Passport เมื่อ strategy ทำงานเสร็จ - เนื่องจาก
super.canActivate()ไม่เคยถูกเรียก →handleRequest()ก็ไม่เคยทำงานเช่นกัน → dead code - comment
// we can protect guard hereบ่งบอกว่า author ทราบว่ายังไม่ complete
ปัญหาด้าน security
| ปัญหา | รายละเอียด |
|---|---|
| ทุก route ไม่มี auth | ใครก็เรียก endpoint ของ centralize-api ได้โดยไม่ต้องมี token ทำให้ PII (empCode, fullName, bankAccount) รั่ว |
| ไม่มี rate limit | ไม่มี @nestjs/throttler หรือ proxy rate limit → brute force enumeration ได้ |
| Swagger UI หลอก | @ApiBearerAuth() แสดงใน Swagger ทำให้ client เข้าใจผิดว่าต้องใส่ token จริง |
JwtStrategy — strategy ที่มี bug หลายตัว
centralize-api/libs/jwt/jwt.strategy.ts:6-24
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
private logger = new Logger(JwtStrategy.name);
data: any;
constructor(private configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get('jwtSecretKey'),
});
}
async validate(payload: any) {
this.logger.log(`JwtStrategy payload -> ${JSON.stringify(payload)} `);
console.log(' --->>>>>', payload);
this.data = payload;
return payload;
}
}Bug ทั้งหมดใน strategy นี้
| Bug | บรรทัด | ผลกระทบ |
|---|---|---|
secretOrKey ผิดประเภท | constructor | configService.get('jwtSecretKey') คืนค่าจาก .env:SECRET_KEY ซึ่งเป็น SSH pubkey fragment (AAAAB3NzaC1yc2E...) ไม่ใช่ JWT secret → ถ้า guard ทำงานจริงจะ verify ผ่านเพราะ key ยาวพอ แต่เป็น broken crypto design |
console.log(' --->>>>>', payload) | :20 | debug statement ที่ลืมลบ → log JWT payload ออก stdout ทุกครั้ง (ถ้า guard ทำงาน) — รั่ว claims |
this.data = payload ใน singleton | :21 | JwtStrategy เป็น singleton (default scope ของ NestJS provider) → field data ถูก share ระหว่าง request ทั้งหมด → race condition ถ้ามี concurrent requests |
data: any ประกาศ type | :9 | ใช้ any ทำให้ TypeScript ไม่ตรวจ type → เสี่ยง bug |
payload: any parameter | :18 | เช่นเดียวกัน — ไม่มี interface ที่กำหนด claims |
secretOrKey — SSH pubkey fragment
.env:SECRET_KEY คือ string ที่ขึ้นต้นด้วย AAAAB3NzaC1yc2E ซึ่งเป็น base64 encoding ของ ssh-rsa (Magic bytes ของ SSH public key format) — centralize-api/.env:15
เดาได้ว่าเกิดจากการ copy-paste ผิด — คนตั้งใจจะใช้ random string 64+ characters แต่ไป copy จาก SSH pubkey แทน ผลลัพธ์คือ key ยาวพอที่จะทำให้ jsonwebtoken.verify() ไม่ throw แต่
- key นี้เป็น public key ไม่ใช่ secret → ถ้า guard ทำงานจริง ใครก็รู้ key สามารถปลอมแปลง JWT ได้
- key ถูก committed ใน repo → ทุกคนที่เข้าถึง git history รู้ "secret"
การแก้ — สุ่ม secret ใหม่ด้วย openssl rand -hex 64 และเก็บใน secret manager (Vault, AWS Secrets Manager) ไม่ใช่ .env
@Public() decorator — ใช้ทุกที่
// centralize-api/libs/jwt/jwt-auth.guard.ts:53-54
export const isPublicKey = 'isPublic';
export const Public = () => SetMetadata(isPublicKey, true);@Public() ใช้ decorator SetMetadata ของ NestJS เพื่อติด metadata isPublic: true ลงใน method หรือ class — JwtAuthGuard.canActivate จะอ่าน metadata นี้แล้วข้ามกระบวนตรวจสอบสิทธิ์ทันที
การใช้งานจริงใน controller — ทุก method ทุกตัว
| Controller | endpoint count | ใช้ @Public() |
|---|---|---|
AppController | 1 | ทั้งหมด |
EmployeeController | 14 | ทั้งหมด |
HrPaySlipController | 1 | ทั้งหมด |
HrTimeTempController | 1 | ทั้งหมด |
| รวม | 17 | 17/17 = 100% |
ผล — JwtAuthGuard ไม่เคยเรียก super.canActivate() ใน request จริง → auth machinery ทั้งหมดเป็น dead code
ไม่มี login endpoint
ตามปกติ service ที่ใช้ JWT จะต้องมี login endpoint สำหรับแลก username/password เป็น JWT แต่ centralize-api ไม่มี login endpoint ใด ๆ
- ไม่มี
AuthController - ไม่มี
/auth/loginroute passport-localอยู่ในpackage.json(centralize-api/package.json:40) แต่ไม่ถูก import ที่ไหน (dead dependency)- ไม่มี user table ที่จะใช้ verify (table
auth_userในUserEntityเป็น dead code — บท 3.3.1)
เดาได้ว่าการออกแบบเดิมคือให้ caller (เช่น vtrc-api) มี JWT ของตัวเองแล้วส่งมา แต่เนื่องจาก @Public() ทุกที่ → JWT ที่ส่งมาไม่ถูกตรวจ → caller ไม่จำเป็นต้องส่ง JWT จริง
ไม่มี apiKey header
ตาม workspace rule (.cursor/rules/workspace-structure.mdc) บอกว่า vtrc-api เรียก centralize-api ผ่าน END_POINT_CENTRALIZE env และ API_CENTRALIZE_KEY แต่
- ค้นหา
apiKey,API_CENTRALIZE_KEY,x-api-key, header checks ทั้ง centralize-api source → ไม่พบการตรวจ apiKey ใด ๆ API_CENTRALIZE_KEY/END_POINT_CENTRALIZEของ vtrc-api ชี้ไปcu-central-api(CLIENT_TARGET ตัวอย่าง port 8082; CODE_DEFAULTAPP_PORT=8080) ไม่ใช่ NestJS centralize-api ตัวนี้ (port 3000)
→ centralize-api ตัวนี้ไม่มีกลไก auth ใด ๆ ที่ใช้งานจริง ทุก request ผ่านหมด
ผลกระทบต่อ security
| Endpoint | ข้อมูลที่รั่ว | Severity |
|---|---|---|
/api/employee | empCode, fullName, identityCard, passportNo | Critical |
/api/employee/bankAccount | bank account number, branch, bank name | Critical |
/api/employee/getEmployeeDisaster | work history, disaster date | High |
/api/hrPaySlip | payslip detail (income, deduction) | Critical |
/api/hrTimeTemp/import | time attendance, check-in/out | High |
ถ้า service นี้ expose สู่ internet โดยไม่มี reverse proxy + auth layer ด้านหน้า → data breach ทันที แต่ถ้าอยู่ใน private network ที่เข้าถึงได้แค่ vtrc-api และ backoffice เท่านั้น → ความเสี่ยงลดลง (แต่ก็ยังไม่ดี)
PDPA compliance — การเปิดเผยข้อมูลพนักงาน (รหัสบัตรประชาชน, เลขบัญชี, สลิปเงินเดือน) โดยไม่มีการควบคุมการเข้าถึงเป็นการละเมิดพระราชบัญญัติคุ้มครองข้อมูลส่วนบุคคล พ.ศ. 2562 (PDPA) มาตรา 26 (การเก็บรวบรวมโดยไม่ได้รับความยินยอม) — บริษัทและ สภากาชาด อาจถูกฟ้อง + ปรับสูงสุด 5 ล้านบาท
การแก้ — ทำให้ auth ทำงานจริง
Option A — เอา @Public() ออก
เอา @Public() ออกจาก endpoint ที่ sensitive (ทุกตัวที่ expose PII) → guard จะเริ่มทำงาน ต้อง
- แก้
secretOrKeyให้เป็น secret จริง (ไม่ใช่ SSH pubkey) - ลบ
console.logdebug statement - เอา
this.data = payloadออก เพราะเป็น race condition - ประกาศ interface
JwtPayloadแทนany - caller (vtrc-api หรือ client อื่น) ต้องขอ JWT จาก login endpoint ที่ต้องสร้างเพิ่ม
Option B — ใช้ apiKey middleware
เพิ่ม middleware ที่ตรวจ x-api-key header ทุก request — ง่ายกว่า JWT และเหมาะกับ service-to-service auth
- สร้าง
ApiKeyMiddlewareที่เช็ค header กับ env var - register เป็น global middleware
- แจก apiKey ให้ caller แต่ละตัว
Option C — ใช้ mTLS
ถ้า service อยู่ในระบบที่ทุก client เป็น internal service → ใช้ mTLS (mutual TLS) ที่ client cert เป็นตัวยืนยัน — เป็นวิธีที่ปลอดภัยสุด แต่ต้องการ CA infrastructure
Request trace — เส้นทางของ request ที่ไม่ถูก auth
เพื่อให้เห็นภาพ มา trace request จริงตั้งแต่เข้าจนถึง controller
1. Client ยิง GET /api/employee/bankAccount?empCode=X&empDeptCode=Y&empWorkingStatus=Working
(ไม่ต้องใส่ Authorization header เพราะ @Public() bypass หมด)
2. Express adapter รับ request → NestJS framework
3. JwtAuthGuard.canActivate():
- reflector.getAllAndOverride(isPublicKey, [handler, class]) → true
- logger.log('isPublic -> true') ← log บรรทัดนี้
- this.serviceName = '/api/employee/bankAccount?...'
- if (true) return true ← ข้าม super.canActivate()
4. ValidationPipe.transform(query, GetBankAccountDto):
- whitelist: ตัด field ที่ไม่มีใน DTO
- forbidNonWhitelisted: throw 400 ถ้ามี field แปลก
- query → GetBankAccountDto instance (empCode, empDeptCode, empWorkingStatus)
5. EmployeeController.getBankAccount(query):
- เรียก service.getBankAccount(query)
6. EmployeeService.getBankAccount(query):
- รัน raw SQL ที่ join 3 tables cross-database
- return rows
7. NestJS serialize → JSON response
8. HttpExceptionFilter ไม่ถูกเรียก (ไม่มี exception)จุดสังเกต — Authorization header ไม่จำเป็น ถ้า client ใส่มาก็ตาม JwtAuthGuard จะไม่ตรวจเพราะ isPublic === true ทำให้ super.canActivate() (ที่จะไปเรียก JwtStrategy.validate()) ไม่ทำงาน
สรุปประเด็นสำคัญ
- JwtAuthGuard ลงทะเบียนเป็น global guard แต่ถูก bypass ทุก route เพราะทุก method ใช้
@Public()(17/17 = 100%) - JwtStrategy มี bug 3 ตัว —
console.logรั่ว payload,this.data = payloadrace condition,secretOrKeyเป็น SSH pubkey fragment - ไม่มี login endpoint → ไม่มีทางขอ JWT ที่ถูกต้องแม้แต่จะทดสอบ
- ไม่มี apiKey header แม้ workspace rule บอกว่ามี — config
API_CENTRALIZE_KEYของ vtrc-api ชี้ไป cu-central-api ไม่ใช่ service ตัวนี้ - ผลคือ centralize-api เปิดให้ใครก็เรียกได้โดยไม่มี auth — ข้อมูล PII (รหัสบัตรประชาชน, เลขบัญชี, สลิปเงินเดือน) รั่วได้ถ้า service ถูก expose ออก internet
- การแก้มี 3 ทางเลือก — ใช้ JWT จริงจัง, ใช้ apiKey middleware, หรือใช้ mTLS (บท 3.3.8)