Bài viết

JWT rotation, IDOR và những quyết định bảo mật trong NovaShop

Độ khó: Nâng cao12 phút đọc
Bảo mậtAuthNestJS

Auth trong NovaShop trải dài qua 3 tầng: NestJS phát hành token, Next.js giữ cookie hộ, middleware âm thầm refresh. Nhiều quyết định nhỏ ở đây là những thứ hay bị bỏ qua khi build auth cho nhanh - và cũng là những chỗ hay bị khai thác nhất trong thực tế.

1. Access token và refresh token dùng secret khác nhau, và tự khai báo type

private async generateAccessToken(username: string, userId: number) {
  return this.jwtService.signAsync(
    { username, sub: userId, type: 'access' satisfies JwtTokenType },
    { secret: getJwtAccessSecret(this.configService), expiresIn: '15m' },
  );
}

private async generateRefreshToken(username: string, userId: number) {
  return this.jwtService.signAsync(
    { username, sub: userId, type: 'refresh' satisfies JwtTokenType },
    { secret: getJwtRefreshSecret(this.configService), expiresIn: '7d' },
  );
}

Hai secret riêng biệt (JWT_ACCESS_SECRET và JWT_REFRESH_SECRET) nghĩa là lộ một secret không tự động lộ luôn secret kia. Và mỗi token tự mang field type. JwtStrategy.validate() kiểm tra chặt:

if (payload.type !== 'access') {
  throw new UnauthorizedException('Invalid access token');
}

Không có check này, một refresh token sống 7 ngày mà bị lấy trộm có thể bị dùng thẳng như access token - vì về mặt chữ ký, cả hai đều hợp lệ nếu verify sai secret hoặc không phân biệt mục đích. Hai secret riêng là lớp khóa thứ nhất, field type là lớp thứ hai. Cái bẫy bên dưới thì rất phổ biến: một JWT ký hợp lệ không có nghĩa là đúng loại token được phép dùng ở chỗ này.

2. Refresh token không lưu plaintext trong DB

async login(username: string, userId: number) {
  const accessToken = await this.generateAccessToken(username, userId);
  const refreshToken = await this.generateRefreshToken(username, userId);
  const hashedRefreshToken = await bcrypt.hash(refreshToken, 10);
  await this.userService.updateUser(userId, { refreshToken: hashedRefreshToken });
  return { userId, accessToken, refreshToken };
}

Refresh token được hash bằng bcrypt trước khi ghi vào DB, giống hệt cách xử lý password. Nếu database bị dump - dính SQL injection, lộ backup, hay người trong nội bộ tuồn ra - thì kẻ tấn công cũng không có sẵn refresh token để dùng, mà phải brute-force ngược bcrypt, coi như bất khả thi. So với cách phổ biến là lưu thẳng refresh token, đây là một lớp phòng thủ thêm cho đúng cái tình huống xấu nhất: DB đã nằm trong tay người khác.

3. Refresh token rotation - token cũ chết ngay khi dùng

async refreshToken(refreshToken: string) {
  const payload = this.jwtService.verify(refreshToken, {
    secret: getJwtRefreshSecret(this.configService),
  });
  if (payload.type !== 'refresh') throw new UnauthorizedException(...);

  const user = await this.userService.findUserById(payload.sub);
  const isMatch = await bcrypt.compare(refreshToken, user.refreshToken);
  if (!isMatch) throw new UnauthorizedException('Invalid refresh token');

  // Refresh token rotation
  return this.login(user.username, user.id);
}

Mỗi lần refresh, login() được gọi lại, tức là một refresh token mới được sinh ra và hash mới ghi đè lên DB. Refresh token cũ lập tức không còn khớp bcrypt.compare nữa. Đây đúng là kỹ thuật refresh token rotation theo khuyến nghị của OWASP: nếu một refresh token bị đánh cắp và kẻ tấn công dùng nó, thì lần refresh tiếp theo của chủ sở hữu hợp pháp sẽ thất bại - một tín hiệu cho thấy token đã bị lộ.

Chỗ còn thiếu, tôi nói thẳng: hiện chưa có bước revoke toàn bộ session khi phát hiện token bị dùng lại. Thêm cơ chế refresh-token reuse detection để tự động logout mọi phiên của user đó là nâng cấp hợp lý tiếp theo.

4. Google OAuth: verify ở server, không tin token client gửi lên

async googleLogin(googleToken: string) {
  const ticket = await this.googleClient.verifyIdToken({
    idToken: googleToken,
    audience: this.configService.get<string>('GOOGLE_CLIENT_ID'),
  });
  const payload = ticket.getPayload();
  if (!payload || !payload.email || !payload.name) {
    throw new UnauthorizedException('Invalid Google token');
  }
}

verifyIdToken bắt buộc có audience, chặn kiểu tấn công cổ điển: dùng một ID token Google hoàn toàn hợp lệ nhưng được cấp cho ứng dụng OAuth khác để đăng nhập vào NovaShop. Thiếu kiểm tra audience thì bất kỳ ai có ID token Google hợp lệ từ app bất kỳ đều login được.

Với user Google mới, backend sinh mật khẩu ngẫu nhiên:

const randomSecurePassword = crypto.randomBytes(32).toString('hex');

Dùng crypto.randomBytes (CSPRNG) thay vì Math.random(). Điều này quan trọng vì tài khoản đó về lý thuyết vẫn login được bằng password nếu route credentials không chặn theo provider; một mật khẩu đoán được ở đây coi như mở cửa hậu cho toàn bộ tài khoản Google-only.

5. OwnsResourceGuard - chặn IDOR ở tầng framework

IDOR (Insecure Direct Object Reference) là lỗ hổng nằm top đầu OWASP: user A đổi id trên URL để đọc hoặc sửa dữ liệu của user B. NovaShop xử lý bằng một guard tái sử dụng:

canActivate(context: ExecutionContext): boolean {
  const paramName =
    this.reflector.getAllAndOverride<string>(OWNS_RESOURCE_KEY, [...]) ?? 'id';
  const request = context.switchToHttp().getRequest();
  const userId = Number(request.user?.id);
  const resourceId = Number(request.params[paramName]);

  if (!userId || userId !== resourceId) {
    throw new UnauthorizedException('You can only access your own resources');
  }
  return true;
}

Cách này tốt hơn hẳn việc nhét if (req.user.id !== params.id) vào từng controller - dễ quên, dễ mỗi chỗ viết một kiểu. Ở đây chỉ cần gắn decorator, guard tự so khớp userId lấy từ JWT đã verify (chứ không phải từ body hay query mà client giả mạo được) với resource id trên URL.

6. Webhook secret - biên giới tin cậy giữa Next.js và NestJS

Đơn hàng được xác nhận qua một endpoint nội bộ riêng, chứ không phải để Stripe gọi thẳng vào NestJS:

canActivate(context: ExecutionContext): boolean {
  const request = context.switchToHttp().getRequest<Request>();
  const expectedSecret = this.configService.get<string>('INTERNAL_WEBHOOK_SECRET');
  if (!expectedSecret) {
    throw new UnauthorizedException('Webhook secret is not configured');
  }
  const providedSecret = request.headers['x-webhook-secret'];
  if (typeof providedSecret !== 'string' || providedSecret !== expectedSecret) {
    throw new UnauthorizedException('Invalid webhook secret');
  }
  return true;
}

Chuỗi tin cậy thực tế là: Stripe → Next.js route handler (verify bằng chữ ký HMAC của Stripe) → NestJS (verify bằng INTERNAL_WEBHOOK_SECRET riêng). Hai lớp verify độc lập cho hai biên giới khác nhau. Nếu secret nội bộ lộ, kẻ tấn công vẫn không giả được webhook Stripe gốc; ngược lại, verify được chữ ký Stripe cũng không tự động có quyền gọi thẳng API nội bộ của NestJS.

Một chi tiết đáng học: guard fail-closed khi thiếu cấu hình, tức throw luôn nếu secret rỗng thay vì mặc định cho qua. Nhỏ thôi, nhưng nó quyết định giữa an toàn khi deploy thiếu env và toang khi deploy thiếu env.

7. Cookie luôn httpOnly, token không bao giờ chạm tới localStorage

cookieStore.set({
  name: ACCESS_TOKEN_COOKIE,
  value: tokens.accessToken,
  httpOnly: true,
  secure: isProd,
  sameSite: "lax",
});

Toàn bộ access và refresh token nằm trong cookie httpOnly, nên JavaScript phía client - kể cả script bị chèn qua XSS - không đọc được giá trị token. Đây là khác biệt sống còn so với pattern hay gặp: quăng JWT vào localStorage cho tiện gọi API. Làm vậy thì bất kỳ lỗ hổng XSS nào cũng đồng nghĩa với chiếm tài khoản toàn diện.

Đổi lại, những đoạn code sát phía client muốn biết userId thì phải nhờ server tự decode phần payload base64 của JWT, trong resolveUserId(). Tốn thêm vài dòng code, nhưng giữ được nguyên tắc: token không bao giờ rời khỏi server.

8. Race condition mà rotation tạo ra

Điểm này tôi nêu ra như một rủi ro cần biết, chứ không phải lỗi: cả middleware.ts lẫn resolveAccessToken() trong authFetch đều có thể cùng lúc gọi /token khi access token hết hạn. Mà rotation thì giết refresh token cũ ngay sau lần dùng đầu tiên, nên hai request song song - ví dụ một tab đang prefetch mấy route liền - sẽ tranh nhau. Một request thắng, request còn lại nhận lỗi Invalid refresh token dù user hoàn toàn hợp lệ.

Đây là cái giá thường gặp của rotation nghiêm ngặt. Thường người ta giải bằng cách cho refresh token cũ còn hiệu lực thêm một khoảng ngắn (grace period), hoặc đặt mutex ở tầng middleware.

Một token ký hợp lệ không đồng nghĩa với đúng token, đúng người, và đúng chỗ được dùng.

Phần lớn auth stack ở đây đi đúng khuyến nghị OWASP: token có type riêng, rotation, hash refresh token, cookie httpOnly, verify audience cho OAuth, webhook guard fail-closed. Hai thứ tôi muốn đào tiếp là cơ chế phát hiện refresh-token reuse để tự revoke toàn bộ session, và rate limiting cho /token với /login - hiện chưa có guard chống brute-force ở hai route này.