Mục lục
- Mục Tiêu Bài Học
- TOTP Hoạt Động Như Thế Nào
- Vấn Đề Khi Test Login 2FA
- Setup Test Account 2FA
- Cài otplib
- Generate TOTP Code Tại Runtime
- Full 2FA Login Setup Flow
- Combine Với storageState
- Clock Skew — Nguyên Nhân OTP Invalid
- Real TOTP vs Mock 2FA
- Time Window Handling
- Backup Codes
- Common Pitfalls
- Tổng Kết
- Bài Tập Củng Cố
- Bài Tiếp Theo
Mục Tiêu Bài Học
Sau khi hoàn thành bài này, bạn sẽ:
- Hiểu cơ chế TOTP tại sao không thể hardcode code.
- Setup test account 2FA đúng cách để lấy được TOTP secret.
- Dùng
otplibđể generate 6-digit OTP code tại runtime. - Viết setup project đăng nhập qua 2FA screen đầy đủ.
- Kết hợp 2FA login với
storageStateđể chỉ 2FA một lần, test sau reuse state. - Hiểu clock skew ảnh hưởng đến OTP như thế nào và cách phòng tránh.
- Phân biệt real TOTP testing với mock 2FA — chọn đúng theo ngữ cảnh.
- Tránh 4 pitfall hay gặp khi test 2FA.
TOTP Hoạt Động Như Thế Nào
TOTP (Time-based One-Time Password) được định nghĩa trong RFC 6238, xây dựng trên HOTP (RFC 4226). Thuật toán:
- Client và server chia sẻ một shared secret — chuỗi base32 (ví dụ
JBSWY3DPEHPK3PXP). Secret này được thiết lập một lần khi user enable 2FA. - Code được generate từ
HMAC-SHA1(secret, floor(unix_time / 30)). - Kết quả rút gọn thành 6 chữ số.
- Mỗi 30 giây,
floor(unix_time / 30)tăng lên 1 → code thay đổi.
Điểm quan trọng: client (Google Authenticator, Authy) và server dùng cùng secret và cùng system time để tính code. Không cần liên lạc mạng khi generate — cả hai tính ra cùng kết quả nếu đồng bộ thời gian.
Secret được lưu phía server (thường encrypted trong database) và phía client (trong app authenticator). Khi user quét QR khi setup 2FA, thực chất là transfer secret từ server về app.
Vấn Đề Khi Test Login 2FA
Login flow thông thường (email + password) có thể automation bình thường — credentials cố định, điền vào form, nhấn submit. Nhưng khi app thêm 2FA screen sau login, xuất hiện 3 vấn đề:
Vấn đề 1: Code thay đổi mỗi 30 giây
Không thể hardcode "123456" vào test. Code có hiệu lực tối đa 30 giây (thực tế nhiều server cho phép window ±1 step = tổng cộng 90 giây). Test có thể chạy bất cứ lúc nào — code tại thời điểm chạy khác code tại thời điểm viết test.
Vấn đề 2: App authenticator không thể automation
Người dùng thường mở Google Authenticator hoặc Authy trên điện thoại để đọc code. Playwright không tương tác được với app mobile ngoài process browser.
Vấn đề 3: 2FA screen xuất hiện giữa flow
Sau khi submit email + password, app redirect sang 2FA screen. Nếu bỏ qua bước này, test fail tại bước navigate đến dashboard vì app chưa hoàn thành login.
Giải pháp: có TOTP secret của test account, generate code đúng tại runtime bằng thư viện otplib — cùng thuật toán mà app authenticator dùng.
Setup Test Account 2FA
Để generate code đúng, cần có TOTP secret của test account. Các bước setup:
Bước 1: Enable 2FA cho test account
Đăng nhập vào test account, vào phần Settings → Security → Enable 2FA. App sẽ hiển thị QR code để quét bằng authenticator app.
Bước 2: Lấy TOTP secret từ QR setup
QR code encode một URI theo chuẩn otpauth://:
otpauth://totp/AppName:test%40x.com?secret=JBSWY3DPEHPK3PXP&issuer=AppName&algorithm=SHA1&digits=6&period=30
Secret nằm trong parameter secret= — chuỗi base32 (chỉ gồm chữ hoa A-Z và số 2-7). Để lấy secret mà không cần quét QR bằng điện thoại:
- Nhiều app hiển thị "Enter manually" hoặc "Can't scan QR?" với secret dạng text bên cạnh QR.
- Nếu app không hiển thị: dùng QR decoder (như ZXing) để decode URI từ QR image.
- Nếu bạn kiểm soát backend test: query thẳng database hoặc expose endpoint
/api/test/totp-secret?userId=...chỉ dùng trong test env.
Bước 3: Lưu secret vào env var
# .env.test
TEST_TOTP_SECRET=JBSWY3DPEHPK3PXP
[email protected]
TEST_PASSWORD=TestPassword123!
File .env.test không commit lên git. Secret này chỉ cấp cho test account trong test environment — không liên quan đến production.
Cài otplib
otplib là thư viện Node.js implement HOTP/TOTP theo RFC 4226 và RFC 6238.
npm i -D otplib
Hai API chính:
import { authenticator } from 'otplib';
// Generate code tại thời điểm hiện tại
const code = authenticator.generate(secret); // trả về chuỗi 6 chữ số, vd "482917"
// Verify code (dùng để test chính otplib nếu cần)
const isValid = authenticator.verify({ token: code, secret }); // true/false
authenticator.generate() dùng Date.now() của process hiện tại để tính time step — cùng thuật toán mà Google Authenticator dùng, miễn là system clock đồng bộ.
Mặc định của otplib khớp với Google Authenticator defaults: algorithm SHA1, 6 digits, period 30 giây. Nếu app của bạn cấu hình khác (ví dụ 8 digits hoặc period 60 giây), có thể override:
authenticator.options = { digits: 8, step: 60 };
Generate TOTP Code Tại Runtime
Pattern cơ bản: generate code ngay trước khi fill vào field OTP:
import { authenticator } from 'otplib';
const TOTP_SECRET = process.env.TEST_TOTP_SECRET!;
// Kiểm tra secret hợp lệ khi load module
if (!TOTP_SECRET) {
throw new Error('TEST_TOTP_SECRET is not set. Check your .env.test file.');
}
Sau đó trong test:
// Generate code TẠI thời điểm cần điền — không generate sớm
const code = authenticator.generate(TOTP_SECRET);
await page.getByLabel('Authentication code').fill(code);
Không generate code sớm rồi lưu biến từ trước. Nếu có bất kỳ await nào giữa lúc generate và lúc fill, code có thể expire nếu vừa qua ranh giới 30 giây. Generate ngay trước fill() là cách an toàn nhất.
Full 2FA Login Setup Flow
File tests/auth.setup.ts:
import { test as setup } from '@playwright/test';
import { authenticator } from 'otplib';
const TOTP_SECRET = process.env.TEST_TOTP_SECRET!;
if (!TOTP_SECRET) {
throw new Error('TEST_TOTP_SECRET is not set.');
}
setup('login with 2FA', async ({ page }) => {
await page.goto('/login');
// Bước 1: điền credentials
await page.getByLabel('Email').fill(process.env.TEST_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
await page.getByRole('button', { name: 'Login' }).click();
// Bước 2: 2FA screen — generate code ngay tại thời điểm fill
await page.waitForURL('**/login/2fa');
const code = authenticator.generate(TOTP_SECRET);
await page.getByLabel('Authentication code').fill(code);
await page.getByRole('button', { name: 'Verify' }).click();
// Bước 3: verify login thành công
await page.waitForURL('/dashboard');
// Bước 4: lưu state để test sau không cần 2FA lại
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});
Setup này chạy một lần. Các test sau load state từ user.json — session đã authenticated, không qua 2FA screen nữa.
Nếu app không redirect sang URL riêng cho 2FA mà dùng modal hoặc inline form, thay waitForURL bằng waitForSelector:
// Chờ 2FA field xuất hiện thay vì chờ URL change
await page.waitForSelector('[aria-label="Authentication code"]');
const code = authenticator.generate(TOTP_SECRET);
await page.getByLabel('Authentication code').fill(code);
Combine Với storageState
Pattern chuẩn: 2FA chỉ chạy một lần trong setup project, kết quả lưu vào storageState, các test dùng state đó.
File playwright.config.ts:
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'authenticated',
use: {
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
});
Test trong project authenticated bắt đầu với session đã đăng nhập — không cần lặp lại 2FA flow:
import { test, expect } from '@playwright/test';
// Test này chạy trong project 'authenticated'
// storageState đã restore từ user.json — đã qua 2FA rồi
test('dashboard loads after 2FA login', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.locator('[data-testid="welcome-banner"]')).toBeVisible();
});
Điều kiện để pattern này hoạt động: session cookie / token trong storageState còn hiệu lực khi test chạy. Nếu session hết hạn ngắn, cần chạy lại setup hoặc dùng API-based login approach (không đề cập ở bài này).
Clock Skew — Nguyên Nhân OTP Invalid
TOTP phụ thuộc hoàn toàn vào system time. Code được tính từ floor(unix_time / 30) — nếu client và server tính ra hai giá trị khác nhau, code không match dù secret đúng.
Clock skew là gì
Sai lệch thời gian giữa test runner machine và server. Ví dụ: test runner ở T=1748400000, server ở T=1748400040 — chênh 40 giây → test runner đang ở time step N, server đã ở time step N+1 → code khác nhau.
Server tolerance
Hầu hết implementation (Google, Authy, standard library) chấp nhận code trong window T-1, T, T+1 (tức ±30 giây, tổng cộng 90 giây valid). Nếu clock skew nhỏ hơn 30 giây, server thường vẫn chấp nhận.
Nếu skew lớn hơn 30 giây: code bị reject với lỗi "Invalid authentication code" dù logic đúng. Triệu chứng: test pass trên máy local, fail trên CI server có NTP out of sync.
Kiểm tra và fix
# Kiểm tra system time của CI server (Linux)
date -u
# Đồng bộ NTP nếu có quyền
sudo ntpdate -s time.nist.gov
# hoặc
sudo timedatectl set-ntp true
Nếu không có quyền root trên CI, kiểm tra xem provider có option sync NTP trong pipeline config không (nhiều CI provider như GitHub Actions sync tự động — thường đủ chính xác).
Nếu app của bạn có thể cấu hình: tăng window tolerance lên ±2 step (90 giây mỗi chiều) trong test env để test ít bị ảnh hưởng bởi clock skew nhỏ:
// Phía test: tăng window của otplib nếu server cũng dùng window tương ứng
authenticator.options = { window: 2 }; // chấp nhận ±2 time steps
Tuy nhiên window phía client chỉ ảnh hưởng đến authenticator.verify(), không ảnh hưởng đến authenticator.generate(). Vẫn phải đảm bảo clock đồng bộ — đây chỉ là safety margin.
Real TOTP vs Mock 2FA
Hai hướng tiếp cận testing 2FA — mỗi cái có trade-off khác nhau:
| Tiêu chí | Real TOTP (otplib) | Mock 2FA |
|---|---|---|
| Cách hoạt động | Generate OTP đúng từ shared secret, submit qua form | Disable 2FA trong test env, hoặc server chấp nhận fixed code |
| Test gì được | Toàn bộ 2FA flow: UI, validation, session creation | Flow sau 2FA; 2FA screen không được test |
| Độ phức tạp setup | Cần TOTP secret của test account | Cần config server test env (disable/fixed code) |
| Clock dependency | Có — cần clock sync | Không |
| Realistic | Cao — test đúng flow người dùng thật | Thấp — 2FA screen không được cover |
| Phù hợp cho | E2E test 2FA login flow, regression 2FA | Test chức năng sau login khi 2FA không phải focus |
Bài này focus Real TOTP. Mock 2FA phù hợp khi team muốn test features sau login mà không muốn phụ thuộc vào 2FA infrastructure.
Time Window Handling
TOTP code có hiệu lực trong một time step 30 giây. Vấn đề xảy ra khi code được generate ngay sát cuối time step, sau đó network latency hoặc DOM interaction tốn vài giây — lúc submit thì code vừa expire.
Có thể kiểm tra thời gian còn lại của time step hiện tại bằng authenticator.timeRemaining():
import { authenticator } from 'otplib';
async function generateSafeCode(secret: string): Promise<string> {
// Nếu còn ít hơn 5 giây, đợi sang step mới
const remaining = authenticator.timeRemaining();
if (remaining < 5) {
await new Promise(resolve => setTimeout(resolve, (remaining + 1) * 1000));
}
return authenticator.generate(secret);
}
Trong flow setup 2FA, gọi helper này thay vì gọi generate() trực tiếp:
setup('login with 2FA', async ({ page }) => {
// ... fill email + password + click login ...
await page.waitForURL('**/login/2fa');
const code = await generateSafeCode(TOTP_SECRET);
await page.getByLabel('Authentication code').fill(code);
await page.getByRole('button', { name: 'Verify' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});
Helper này thêm tối đa ~5 giây delay trong trường hợp xấu nhất, bù lại tránh được flaky test do expire edge case.
Backup Codes
Nhiều app cung cấp backup codes khi setup 2FA — thường 8-10 codes, mỗi code dùng một lần. Backup codes có thể dùng thay TOTP khi không có authenticator app.
Về lý thuyết: có thể test login với backup code thay TOTP để tránh phụ thuộc clock sync. Tuy nhiên backup codes là one-time-use — sau khi dùng, code đó không còn hiệu lực. Test dùng backup code sẽ consume code đó vĩnh viễn.
Với TOTP approach (otplib), mỗi lần generate code mới dựa trên time — không consume gì hết, có thể chạy setup nhiều lần tùy ý. Vì vậy TOTP approach thích hợp hơn cho CI/CD so với backup codes.
Backup codes phù hợp hơn để test tính năng "đăng nhập bằng backup code" như một test case riêng, không phải để bypass 2FA trong E2E setup.
Deep dive SMS OTP và email OTP (cần webhook, mock SMTP) sẽ cover trong Series 3.
Common Pitfalls
Pitfall 1: Hardcode OTP code
Code OTP valid tối đa 30 giây. Hardcode vào test đảm bảo test luôn fail sau 30 giây.
// SAI: hardcode code — valid tối đa 30s từ lúc viết
await page.getByLabel('Authentication code').fill('482917');
// ĐÚNG: generate tại runtime
const code = authenticator.generate(TOTP_SECRET);
await page.getByLabel('Authentication code').fill(code);
Pitfall 2: Generate code quá sớm, điền quá muộn
Nếu có nhiều await giữa lúc generate và lúc điền, code có thể expire giữa chừng.
// SAI: generate sớm, sau đó vẫn còn nhiều bước async trước khi fill
const code = authenticator.generate(TOTP_SECRET);
await page.getByLabel('Email').fill(email); // await
await page.getByLabel('Password').fill(password); // await
await page.getByRole('button', { name: 'Login' }).click(); // await
await page.waitForURL('**/2fa'); // có thể chậm
await page.getByLabel('Authentication code').fill(code); // code có thể expire rồi
// ĐÚNG: generate ngay trước fill
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Login' }).click();
await page.waitForURL('**/2fa');
const code = authenticator.generate(TOTP_SECRET); // generate ở đây
await page.getByLabel('Authentication code').fill(code);
Pitfall 3: TOTP secret sai format
Secret phải là chuỗi base32 hợp lệ — chỉ gồm chữ hoa A-Z và số 2-7. Các lỗi thường gặp:
- Thêm khoảng trắng (copy từ app hiển thị "JBSW Y3DP" thay vì "JBSWY3DP").
- Secret có dấu
=padding ở cuối — một số app thêm, một số không.otplibthường xử lý được, nhưng nếu lỗi thì thử remove=. - Secret là hex string thay vì base32 — một số app cũ dùng hex. Nếu secret toàn 0-9 và a-f thì đây là hex, cần convert.
// Kiểm tra secret hợp lệ trước khi dùng
const isValidSecret = authenticator.check(TOTP_SECRET);
// check() trả về boolean kiểm tra format secret
Thực ra otplib không có check() API — cách verify là generate và verify ngay:
// Verify secret hoạt động
const testCode = authenticator.generate(TOTP_SECRET);
const valid = authenticator.verify({ token: testCode, secret: TOTP_SECRET });
if (!valid) {
throw new Error('TOTP_SECRET format invalid or time out of sync');
}
Pitfall 4: Clock skew dẫn đến code invalid trên CI
Đã đề cập ở mục 9. Triệu chứng điển hình: test pass 100% trên máy local, fail intermittently hoặc luôn fail trên CI server. Thêm log để diagnose:
setup('login with 2FA', async ({ page }) => {
// ... fill credentials ...
await page.waitForURL('**/2fa');
const now = Date.now();
const code = authenticator.generate(TOTP_SECRET);
console.log(`[2FA] Generated code at ${new Date(now).toISOString()}, remaining: ${authenticator.timeRemaining()}s`);
await page.getByLabel('Authentication code').fill(code);
await page.getByRole('button', { name: 'Verify' }).click();
// Nếu verify fail, log để debug
const url = page.url();
if (!url.includes('/dashboard')) {
console.error(`[2FA] Verify failed. Current URL: ${url}`);
console.error(`[2FA] Check clock sync between test runner and server.`);
}
await page.waitForURL('/dashboard');
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});
Tổng Kết
- TOTP code tính từ
HMAC-SHA1(secret, floor(unix_time / 30)), thay đổi mỗi 30 giây — không thể hardcode. - Cần TOTP secret (base32) của test account. Lấy từ QR setup, lưu vào
.env.test, không commit. npm i -D otplib→authenticator.generate(secret)để generate 6-digit code tại runtime.- Generate code ngay trước
fill()— không generate sớm rồi để qua nhiều await. - Dùng
authenticator.timeRemaining()để tránh generate code sát cuối time step. - 2FA setup chạy một lần →
storageState→ test sau load state, không cần 2FA lại. - Clock skew giữa test runner và server làm code invalid. Đảm bảo NTP sync. Server thường chấp nhận ±30 giây (window 1).
- Real TOTP test đúng flow; Mock 2FA bỏ qua 2FA screen — chọn theo nhu cầu.
- Backup codes là one-time-use — không dùng cho CI setup thường xuyên.
Bài Tập Củng Cố
Câu 1
TOTP code được tính như thế nào? Tại sao không thể hardcode một code cụ thể vào test?
Đáp án
TOTP code tính từ HMAC-SHA1(secret, floor(unix_time / 30)), rút gọn thành 6 chữ số. Mỗi 30 giây, floor(unix_time / 30) tăng 1, dẫn đến code khác. Code hardcode sẽ hết hiệu lực sau tối đa 30 giây (hoặc 90 giây nếu server có window ±1) kể từ thời điểm code đó được cấp — test chạy sau đó sẽ luôn fail.
Câu 2
Đoạn code sau có vấn đề gì?
setup('login with 2FA', async ({ page }) => {
const code = authenticator.generate(TOTP_SECRET);
await page.goto('/login');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('pass');
await page.getByRole('button', { name: 'Login' }).click();
await page.waitForURL('**/2fa');
await page.getByLabel('Authentication code').fill(code);
await page.getByRole('button', { name: 'Verify' }).click();
});
Đáp án
Code được generate trước tất cả các await. Nếu các bước goto, fill, click, waitForURL tốn hơn ~25-30 giây (ví dụ server chậm, mạng CI chậm), code có thể đã expire khi fill. Đặc biệt nếu code được generate sát cuối time step, chỉ cần vài giây là qua ranh giới. Sửa: di chuyển authenticator.generate() xuống ngay trước dòng fill(code).
Câu 3
Test pass 100% trên máy local nhưng fail intermittently trên CI với lỗi "Invalid authentication code". Nguyên nhân có thể là gì? Hướng điều tra như thế nào?
Đáp án
Nguyên nhân có khả năng cao nhất là clock skew giữa CI server và test server. TOTP dùng system time để tính time step — nếu CI machine lệch >30 giây so với server, code không khớp.
Hướng điều tra: (1) log Date.now() và authenticator.timeRemaining() trong setup để xem thời điểm generate. (2) Kiểm tra NTP sync của CI machine: date -u so với time server thật. (3) Kiểm tra CI provider có bảo đảm NTP sync không. (4) Thử tăng window tolerance phía server test env lên ±2 steps.
Câu 4
Tại sao không nên dùng backup codes để bypass 2FA trong CI setup thường xuyên?
Đáp án
Backup codes là one-time-use — mỗi lần dùng thì code đó bị invalidate. Số lượng backup codes hữu hạn (thường 8-10). Nếu CI chạy nhiều lần mỗi ngày, backup codes sẽ cạn kiệt và cần generate lại thủ công. TOTP approach với otplib không có hạn chế này — mỗi lần generate là code mới dựa trên time, không consume resource gì.
Câu 5
Team muốn test các tính năng của dashboard mà không muốn phụ thuộc vào 2FA infrastructure (clock sync, TOTP secret management). Họ nên dùng approach nào?
Đáp án
Mock 2FA approach: cấu hình server trong test environment để disable 2FA hoặc chấp nhận một fixed code (ví dụ "000000") khi nhận header đặc biệt hoặc trong test mode. Cách này bỏ qua 2FA screen hoàn toàn — phù hợp khi mục tiêu test là features sau login, không phải 2FA flow. Trade-off: 2FA screen không được test. Nên giữ ít nhất một test E2E dùng real TOTP để verify 2FA flow không bị broken.
Bài Tiếp Theo
Bài 112 tiếp tục nhóm Authentication nâng cao với vấn đề CAPTCHA trong test environment.
