0

OAuth2 Account Takeovers: Xây dựng Kiến trúc Social Login Bất Khả Xâm Phạm

Chào anh em, hôm nay tôi sẽ chia sẻ với anh em chủ đề security.

Bài viết này dành cho ai? Dành cho các Software Engineer, Backend Developer và Architect đang tìm cách chuẩn hóa hệ thống xác thực của mình, tránh việc phụ thuộc quá nhiều vào các thư viện "hộp đen" (black-box) và đảm bảo an toàn tuyệt đối trước các đợt tấn công Account Takeover.


Sự An Toàn Ảo Tưởng Của Social Login

Chúng ta thường nghĩ rằng: "Chỉ cần dùng Google/GitHub Login là xong phần bảo mật, Google lo hết rồi". Thực tế, lỗ hổng hiếm khi nằm ở Google, mà nằm ở cách chúng ta tích hợp (integration) hệ thống của mình với Provider.

Trong kiến trúc bảo mật gần đây, chúng tôi quyết định đập bỏ hoàn toàn các thư viện trung gian như Passport.js để tự xây dựng một luồng (flow) OAuth2 tùy chỉnh, dựa trên nguyên tắc Zero-Trust.

Hãy cùng phân tích tại sao.

1. Cạm bẫy Dependency: Tại sao tôi từ bỏ Passport.js?

Passport.js là một thư viện tuyệt vời để bắt đầu, nhưng trong kiến trúc Enterprise, nó đóng gói toàn bộ luồng OAuth thành một "hộp đen". Bạn không kiểm soát được chính xác request nào được gọi, xử lý lỗi ra sao ở tầng Domain, và quan trọng nhất là tăng diện mạo tấn công (Attack Surface) vì phụ thuộc vào một chuỗi các package nhỏ lẻ.

Thay vào đó, chúng tôi sử dụng Axios để tự thực hiện luồng trao đổi Token (Token Exchange):

// https://github.com/paudang/nodejs-social-auth/blob/main/src/infrastructure/auth/socialAuthService.ts
export class GoogleProvider implements ISocialProvider {
  name = 'Google';
  async getProfile(code: string, redirectUri: string): Promise<ISocialProfile> {
    try {
      const params = new URLSearchParams();
      params.append('code', code);
      params.append('client_id', process.env.GOOGLE_CLIENT_ID!);
      params.append('client_secret', process.env.GOOGLE_CLIENT_SECRET!);
      params.append('redirect_uri', redirectUri);
      params.append('grant_type', 'authorization_code');

      // Tự chủ động exchange token thay vì dùng black-box lib
      const tokenResponse = await axios.post('https://oauth2.googleapis.com/token', params.toString(), {
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
      });

      const { access_token } = tokenResponse.data;
      const profileResponse = await axios.get('https://www.googleapis.com/oauth2/v2/userinfo', {
        headers: { Authorization: `Bearer ${access_token}` },
      });

      return {
        id: profileResponse.data.id,
        email: profileResponse.data.email,
        name: profileResponse.data.name,
      };
    } catch (error) {
      // Bắt lỗi và kiểm soát hoàn toàn ở tầng Infrastructure
      throw new Error('Failed to authenticate with Google');
    }
  }
}

2. Vấn đề Blind Linking & Account Takeover (ATO)

Một trong những lỗ hổng chết người của Social Login là Blind Linking (Liên kết tài khoản mù quáng). Nếu một kẻ tấn công tạo một tài khoản GitHub với email của bạn (dù chưa verify), và hệ thống tự động gộp (merge) tài khoản GitHub đó vào user có sẵn dựa trên email -> Kẻ tấn công vừa chiếm được tài khoản của bạn.

Để giải quyết vấn đề này, luồng kiểm tra của chúng ta tách bạch googleIdgithubId, đồng thời vô hiệu hóa (disabled) mật khẩu nếu user được sinh ra từ mạng xã hội:

// https://github.com/paudang/nodejs-social-auth/blob/main/src/usecases/auth/socialLoginUseCase.ts
// 1. Find or create user
let user = await this.userRepository.findByEmail(profile.email);

if (!user) {
  // Tạo user mới, trường Password là null để vô hiệu hóa đăng nhập truyền thống
  user = new User(
    null,
    profile.name,
    profile.email,
    null, // Password = null
    this.provider.name === 'Google' ? profile.id : null,
    this.provider.name === 'GitHub' ? profile.id : null,
  );
  user = await this.userRepository.save(user);
} else {
  // Link social ID một cách có kiểm soát
  let updated = false;
  if (this.provider.name === 'Google' && !user.googleId) {
    user.googleId = profile.id;
    updated = true;
  }
  // ... Update user 
}

3. Đồng bộ hóa với "Nuclear Revoke"

Trong bài viết trước về "The Illusion of Stateless Security", tôi đã đề cập đến Nuclear Revoke - cơ chế vô hiệu hóa phiên bản làm việc diện rộng bằng Redis.

Khi user đăng nhập bằng Google, chúng ta không dùng session của Google để duy trì đăng nhập. Chúng ta lập tức chuyển đổi (exchange) nó thành JWT nội bộ của chúng ta, được bảo vệ bằng Refresh Token Rotation và JTI Tracking:

// https://github.com/paudang/nodejs-social-auth/blob/main/src/interfaces/controllers/auth/authController.ts
// Sau khi xác thực Social thành công
const { user, accessToken, refreshToken } = await useCase.execute(code as string, redirectUri);
const refreshJti = JwtService.decodeToken(refreshToken)?.jti;

// Store refresh token vào Redis List (Nuclear Revoke System)
const cacheKey = `refresh_tokens:${userId}`;
const activeTokens = await cacheService.get<string[]>(cacheKey) || [];
activeTokens.push(refreshJti!);
await cacheService.set(cacheKey, activeTokens, 7 * 24 * 60 * 60);

// Trả về qua Cookie thay vì body để tránh XSS
res.cookie('accessToken', accessToken, { httpOnly: true, secure: true, sameSite: 'lax' });
res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: true, sameSite: 'lax' });
res.redirect('/');

4. Luồng tương tác hoàn chỉnh (Sequence Diagram)

Dưới đây là sơ đồ kiến trúc thể hiện toàn bộ quy trình từ lúc User click vào nút Login, quá trình Verify State (chống CSRF), đến khi phát hành Internal JWT:

Note: Quá trình Verify 'state' (chống CSRF) trong sơ đồ trên là kiến trúc lý tưởng. Tính năng sinh mã ngẫu nhiên bằng Cryptography cho state hiện đang được phát triển và sẽ được tự động hóa trong phiên bản cập nhật tiếp theo của tool.

Tổng kết

Nếu bạn muốn trải nghiệm luồng bảo mật hoàn chỉnh này (MVC hoặc Clean Architecture) mà không phải tự tay viết lại, bạn có thể chạy dòng lệnh sau từ dự án open-source của tôi:

npx nodejs-quickstart-structure@latest init -n "my-secure-app" -l "TypeScript" -a "Clean Architecture" -d "PostgreSQL" --db-name "demo" -c "REST APIs" --caching "Redis" --ci-provider "GitHub Actions" --auth JWT --social-auth Google GitHub --no-include-security --advanced-options

Tài nguyên cho Architect


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí