feat: 앱 기반과 앱 서버 — 앱 전용 로그인 · 새 메일 알림(FCM) · 앱 설정 #20

Merged
ttipo merged 20 commits from feat/app-push-server into main 2026-11-08 15:47:30 +09:00
Showing only changes of commit 5d3a018708 - Show all commits
+47 -1
View File
@@ -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({