diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index bdce710..517c3b5 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -131,6 +131,7 @@ import { createCidrMatcher, parseCidr } from '@/shared/utils/cidr'; import { buildAttachmentDisposition } from '@/shared/utils/content-disposition'; import { kstDateString, kstStartOfDate } from '@/shared/utils/kst-date'; import { + TOTP_PERIOD_SECONDS, buildOtpauthUrl, encodeBase32, totpCode, @@ -353,6 +354,46 @@ function otpCodeOf(secret: Buffer, offset = 0): string { return totpCode(secret, totpCounterAt(Date.now()) + offset); } +/** 인증 앱 코드 한 단계(30초)의 길이 */ +const OTP_STEP_MS = TOTP_PERIOD_SECONDS * 1000; +/** 앞 단계 코드를 고르지 않는 경계 앞 시간 — 그 코드는 요청이 가는 사이 경계를 넘으면 서버의 창(앞뒤 한 단계) 밖이다 */ +const OTP_BOUNDARY_GUARD_MS = 1_000; +/** 경계를 기다린 뒤 조금 더 기다리는 시간 — 넘긴 것을 확실히 하려고 */ +const OTP_STEP_SETTLE_MS = 50; +/** 고르기를 다시 해 보는 횟수 — 경계를 한 번 넘기면 빈 코드가 생기므로 셋이면 넉넉하다 */ +const OTP_PICK_ATTEMPTS = 3; + +/** + * 아직 쓰지 않은 인증 앱 코드 — 서버가 받는 앞뒤 한 단계 안에서 고른다(성공에 쓴 코드는 2분 동안 다시 받지 않는다). + * 지금 → 다음 → 앞 단계 차례다: 지금 · 다음 단계 코드는 요청이 가는 사이 30초 경계를 넘어도 서버의 창 안에 남고, 앞 단계는 밖으로 나간다. + * 앞 단계밖에 남지 않았는데 경계까지 1초가 안 남았으면 경계를 넘긴 뒤 다시 고른다(그때는 다음 단계가 빈다). + * 고정한 "앞 단계" 코드는 그사이 경계를 넘으면 이미 쓴 코드와 같아져 AUTH_006 으로 흔들렸다 (2026-11-06) + */ +async function unusedOtpCode( + secret: Buffer, + used: readonly string[], +): Promise { + for (let attempt = 0; attempt < OTP_PICK_ATTEMPTS; attempt += 1) { + const now = Date.now(); + const counter = totpCounterAt(now); + const free = (offset: number): string | null => { + const code = totpCode(secret, counter + offset); + return used.includes(code) ? null : code; + }; + const steady = free(0) ?? free(1); + if (steady !== null) return steady; + const untilNextStep = OTP_STEP_MS - (now % OTP_STEP_MS); + const previous = free(-1); + if (previous !== null && untilNextStep > OTP_BOUNDARY_GUARD_MS) { + return previous; + } + await new Promise((resolve) => + setTimeout(resolve, untilNextStep + OTP_STEP_SETTLE_MS), + ); + } + throw new Error('쓰지 않은 인증 앱 코드를 고르지 못했다'); +} + /** 문을 열 조건을 다시 보는 간격 */ const GATE_POLL_MS = 10; /** 문을 열 조건을 기다리는 최대 시간 — 회귀가 생기면 멈춰 있지 않고 이 시간 뒤에 분명히 실패한다 */ @@ -6969,12 +7010,17 @@ describe('응답 계약 (e2e)', () => { ).expect(200); expect(body(status).data).toMatchObject({ isEnabled: true }); + // 켤 때 · 로그인에 쓴 코드는 2분 동안 다시 받지 않는다 — 그 둘이 아닌 코드로 끈다 (그사이 30초 경계를 넘어도 흔들리지 않게) + const deactivationCode = await unusedOtpCode(secret, [ + activationCode, + loginCode, + ]); const off = await asMe( request(server).post('/api/two-factor/deactivation'), ) .send({ currentPassword: CORRECT_PASSWORD, - otpCode: otpCodeOf(secret, -1), + otpCode: deactivationCode, }) .expect(200); expect(body(off).data).toEqual({