generated from ttipo/project-base
test(backend): 2단계 인증 e2e 가 30초 경계에서 흔들리지 않게 끄기 코드를 고른다
"등록 → 켜기 → … → 끄기" 시나리오는 끄기에 "그때 단계 −1" 코드를 썼다. 켜기와 끄기 사이에 30초 경계를 넘으면 그 코드가 켤 때 쓴 코드와 같아져, 서버의 다시 쓴 코드 막기가 AUTH_006 으로 거절해 가끔 실패했다(코드는 맞게 거절한 것이다). 이미 쓴 코드가 아닌 것을 0 → +1 → −1 단계에서 고르고(0 · +1 은 요청이 경계를 넘어도 서버의 ±1 창 안), −1 만 남았는데 경계가 1초 안이면 경계를 기다린 뒤 다시 고른다. 따로 다섯 번 돌려 모두 통과했다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string> {
|
||||
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({
|
||||
|
||||
Reference in New Issue
Block a user