From 5d3a0187083fde3402629ba75581a0294b7b9e1d Mon Sep 17 00:00:00 2001 From: ttipo Date: Fri, 6 Nov 2026 16:40:53 +0900 Subject: [PATCH] =?UTF-8?q?test(backend):=202=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20e2e=20=EA=B0=80=2030=EC=B4=88=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=EC=97=90=EC=84=9C=20=ED=9D=94=EB=93=A4=EB=A6=AC?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B2=8C=20=EB=81=84=EA=B8=B0=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20=EA=B3=A0=EB=A5=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "등록 → 켜기 → … → 끄기" 시나리오는 끄기에 "그때 단계 −1" 코드를 썼다. 켜기와 끄기 사이에 30초 경계를 넘으면 그 코드가 켤 때 쓴 코드와 같아져, 서버의 다시 쓴 코드 막기가 AUTH_006 으로 거절해 가끔 실패했다(코드는 맞게 거절한 것이다). 이미 쓴 코드가 아닌 것을 0 → +1 → −1 단계에서 고르고(0 · +1 은 요청이 경계를 넘어도 서버의 ±1 창 안), −1 만 남았는데 경계가 1초 안이면 경계를 기다린 뒤 다시 고른다. 따로 다섯 번 돌려 모두 통과했다. Co-Authored-By: Claude Opus 5.5 --- backend/test/app.e2e-spec.ts | 48 +++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) 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({