82 lines
3.2 KiB
JavaScript
82 lines
3.2 KiB
JavaScript
// Journey: TOTP full lifecycle (enroll → verify → backup-codes → disable)
|
|
//
|
|
// Endpoints exercised:
|
|
// GET /api/v1/members/me/totp (before)
|
|
// POST /api/v1/members/me/totp/enroll-start
|
|
// POST /api/v1/members/me/totp/enroll-confirm
|
|
// POST /api/v1/members/me/totp/verify
|
|
// POST /api/v1/members/me/totp/backup-codes (regenerate)
|
|
// GET /api/v1/members/me/totp (mid; enrolled=true)
|
|
// DELETE /api/v1/members/me/totp
|
|
// GET /api/v1/members/me/totp (after; enrolled=false)
|
|
//
|
|
// TOTP code generation is local (lib/totp.js) — no external authenticator.
|
|
import { sleep } from 'k6';
|
|
import { get, post, del, checkEnvelope } from '../lib/http.js';
|
|
import { registerAndConfirm } from '../lib/auth.js';
|
|
import { generateTOTP, parseOTPAuth } from '../lib/totp.js';
|
|
|
|
export const options = {
|
|
vus: 1,
|
|
iterations: 1,
|
|
thresholds: { checks: ['rate==1.0'] },
|
|
};
|
|
|
|
export default function () {
|
|
const { tokens } = registerAndConfirm();
|
|
const bearer = { Authorization: `Bearer ${tokens.access_token}` };
|
|
|
|
// 1. status before enroll
|
|
const before = checkEnvelope(get('/api/v1/members/me/totp', bearer), 'GET /me/totp (before)').data;
|
|
if (before.enrolled !== false) throw new Error('totp should be disabled initially');
|
|
|
|
// 2. enroll-start → get otpauth URL
|
|
const enroll = checkEnvelope(post('/api/v1/members/me/totp/enroll-start', null, bearer), 'POST /me/totp/enroll-start').data;
|
|
parseOTPAuth(enroll.otpauth_url); // validate it parses
|
|
const code = generateTOTP(enroll.otpauth_url);
|
|
|
|
// 3. enroll-confirm
|
|
const confirm = checkEnvelope(
|
|
post('/api/v1/members/me/totp/enroll-confirm', { code }, bearer),
|
|
'POST /me/totp/enroll-confirm',
|
|
).data;
|
|
if (!Array.isArray(confirm.backup_codes) || confirm.backup_codes.length === 0) {
|
|
throw new Error('enroll-confirm: backup_codes missing');
|
|
}
|
|
|
|
// 4. status mid (enrolled=true)
|
|
const mid = checkEnvelope(get('/api/v1/members/me/totp', bearer), 'GET /me/totp (mid)').data;
|
|
if (mid.enrolled !== true) throw new Error('totp should be enrolled after confirm');
|
|
|
|
// 5. verify — wait until current TOTP differs from the one just consumed
|
|
// so the replay guard (Member.TOTP.ReplayTTLSeconds) does not reject it.
|
|
let verifyCode = generateTOTP(enroll.otpauth_url);
|
|
for (let i = 0; verifyCode === code && i < 30; i++) {
|
|
sleep(1.1);
|
|
verifyCode = generateTOTP(enroll.otpauth_url);
|
|
}
|
|
if (verifyCode === code) {
|
|
throw new Error('TOTP window did not advance; cannot avoid replay');
|
|
}
|
|
checkEnvelope(
|
|
post('/api/v1/members/me/totp/verify', { code: verifyCode }, bearer),
|
|
'POST /me/totp/verify',
|
|
);
|
|
|
|
// 6. backup-codes (regenerate)
|
|
const regen = checkEnvelope(
|
|
post('/api/v1/members/me/totp/backup-codes', null, bearer),
|
|
'POST /me/totp/backup-codes',
|
|
).data;
|
|
if (!Array.isArray(regen.backup_codes) || regen.backup_codes.length === 0) {
|
|
throw new Error('backup-codes: missing codes');
|
|
}
|
|
|
|
// 7. disable
|
|
checkEnvelope(del('/api/v1/members/me/totp', null, bearer), 'DELETE /me/totp');
|
|
|
|
// 8. status after disable
|
|
const after = checkEnvelope(get('/api/v1/members/me/totp', bearer), 'GET /me/totp (after)').data;
|
|
if (after.enrolled !== false) throw new Error('totp should be disabled after DELETE');
|
|
}
|