Skip to content

5.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 (ลงทะเบียนใน 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 ที่ลงทะเบียนแต่ไม่ทำงาน

12:54:centralize-api/libs/jwt/jwt-auth.guard.ts
@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) {
    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()

typescript
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 อ่าน metadata isPublicKey จาก method handler แล้วจาก class — ถ้ามีใน method จะ override class
  • เนื่องจากทุก method มี @Public()isPublic จะเป็น true เสมอ
  • if (isPublic) return true → ข้าม super.canActivate()JWT ไม่ถูกตรวจ

handleRequest()

typescript
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 หลายตัว

6:24:centralize-api/libs/jwt/jwt.strategy.ts
@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 ผิดประเภทconstructorconfigService.get('jwtSecretKey') คืนค่าจาก .env:SECRET_KEY ซึ่งเป็น SSH pubkey fragment (AAAAB3NzaC1yc2E...) ไม่ใช่ JWT secret → ถ้า guard ทำงานจริงจะ verify ผ่านเพราะ key ยาวพอ แต่เป็น broken crypto design
console.log(' --->>>>>', payload):20debug statement ที่ลืมลบ → log JWT payload ออก stdout ทุกครั้ง (ถ้า guard ทำงาน) — รั่ว claims
this.data = payload ใน singleton:21JwtStrategy เป็น 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)

15:15:centralize-api/.env
SECRET_KEY=AAAAB3NzaC1yc2EAAAADAQABAAABAQCMH5xOB6PH3INC7ScxkR2b3IChoJJDpCcQqgwwtzKxqkXBTUNqb+qE20Z2WJcnca9rPTjiuahlOChjRo8mELybKZt65NrWHYbj2hv4Yliybz8xPZJC9KIT3hlcwDv5jqqUmeDRNk1MUEn1SNXJDquaerv1vQU5EZYw0/hXYNc2Y+hqltWcdPCJ12cpBO9VXnOvtGWLzufMLOHTsupaPqcjRpid5xI7tx/cQZvSKw8gUA/9UNlX/XhSToU72NEQLpxBFyGhRqPYJQprvFpqdGOmhIzjsSTut5cKdxqYmsWUbGZgVVgEzLBdA/JXrCLWXoiLaohNciqvG/xdnzyKldJb

เดาได้ว่าเกิดจากการ 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 — ใช้ทุกที่

typescript
// jwt-auth.guard.ts
export const isPublicKey = 'isPublic';
export const Public = () => SetMetadata(isPublicKey, true);

@Public() ใช้ decorator SetMetadata ของ NestJS เพื่อติด metadata isPublic: true ลงใน method หรือ class — JwtAuthGuard.canActivate จะอ่าน metadata นี้แล้วข้ามกระบวนตรวจสอบสิทธิ์ทันที

การใช้งานจริงใน controller — ทุก method ทุกตัว

Controllerendpoint countใช้ @Public()
AppController1ทั้งหมด
EmployeeController14ทั้งหมด
HrPaySlipController1ทั้งหมด
HrTimeTempController1ทั้งหมด
รวม1717/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/login route
  • passport-local อยู่ใน package.json แต่ไม่ถูก import ที่ไหน (dead dependency)
  • ไม่มี user table ที่จะใช้ verify (table auth_user ใน UserEntity เป็น dead code — บท 5.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_DEFAULT APP_PORT=8080) ไม่ใช่ NestJS centralize-api ตัวนี้ (port 3000)

→ centralize-api ตัวนี้ไม่มีกลไก auth ใด ๆ ที่ใช้งานจริง ทุก request ผ่านหมด


ผลกระทบต่อ security

Endpointข้อมูลที่รั่วSeverity
/api/employeeempCode, fullName, identityCard, passportNo🔴 Critical
/api/employee/bankAccountbank account number, branch, bank name🔴 Critical
/api/employee/getEmployeeDisasterwork history, disaster date🟠 High
/api/hrPaySlippayslip detail (income, deduction)🔴 Critical
/api/hrTimeTemp/importtime attendance, check-in/out🟠 High

ถ้า service นี้ expose สู่ internet โดยไม่มี reverse proxy + auth layer ด้านหน้า → data breach ทันที แต่ถ้าอยู่ใน private network ที่เข้าถึงได้แค่ vtrc-api และ backoffice เท่านั้น → ความเสี่ยงลดลง (แต่ก็ยังไม่ดี)


การแก้ — ทำให้ auth ทำงานจริง

Option A — เอา @Public() ออก

เอา @Public() ออกจาก endpoint ที่ sensitive (ทุกตัวที่ expose PII) → guard จะเริ่มทำงาน ต้อง

  1. แก้ secretOrKey ให้เป็น secret จริง (ไม่ใช่ SSH pubkey)
  2. ลบ console.log debug statement
  3. เอา this.data = payload ออก เพราะเป็น race condition
  4. ประกาศ interface JwtPayload แทน any
  5. caller (vtrc-api หรือ client อื่น) ต้องขอ JWT จาก login endpoint ที่ต้องสร้างเพิ่ม

Option B — ใช้ apiKey middleware

เพิ่ม middleware ที่ตรวจ x-api-key header ทุก request — ง่ายกว่า JWT และเหมาะกับ service-to-service auth

  1. สร้าง ApiKeyMiddleware ที่เช็ค header กับ env var
  2. register เป็น global middleware
  3. แจก apiKey ให้ caller แต่ละตัว

Option C — ใช้ mTLS

ถ้า service อยู่ในระบบที่ทุก client เป็น internal service → ใช้ mTLS (mutual TLS) ที่ client cert เป็นตัวยืนยัน — เป็นวิธีที่ปลอดภัยสุด แต่ต้องการ CA infrastructure


สรุปประเด็นสำคัญ

  • JwtAuthGuard ลงทะเบียนเป็น global guard แต่ถูก bypass ทุก route เพราะทุก method ใช้ @Public()
  • JwtStrategy มี bug 3 ตัวconsole.log รั่ว payload, this.data = payload race 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 (บท 5.8)