TOTP 2FA 구현 (otpauth)

언제 — 앱 기반 2FA(TOTP, RFC 6238)를 직접 붙일 때. 먼저 직접 구현할 일인지부터 가른다.

  • TOTP 직접 구현이 디폴트다. 위탁할 “OTP 서비스”라는 게 개념적으로 없다.
  • 매니지드 인증(Auth0·Cognito·Firebase·Clerk·Supabase)을 쓰면 MFA 가 기능으로 내장돼 만질 일이 없다. 단 이건 “인증 전체 위탁”이지 “OTP 만 위탁”이 아니다.
  • SMS/Email OTP 는 별개다. 생성은 랜덤 + Redis TTL 로 trivial 하고, 서드파티를 쓰는 이유는 전달 인프라 때문이다.
  • 금융 OTP·본인인증은 규제 영역이라 외부 인증기관이 강제된다. 직접 구현 대상이 아니다.

멘탈 모델을 먼저 잡는다. TOTP 는 서버와 Authenticator 앱이 secret 을 공유한 뒤 각자 독립적으로 같은 코드를 계산하는 구조다. 등록 시점(QR 스캔) 이후로는 둘 사이에 통신이 없다.

[등록 시 단 한 번]  서버 ──(secret 을 QR 로)──> 사용자 폰 앱
[그 이후 통신 0]    서버: secret + 현재시각 → HMAC → 코드 A
                    앱:   secret + 현재시각 → HMAC → 코드 B
                    사용자가 B 입력 → 서버가 A == B 비교
  • ❌ “OTP 검증 서버에 요청” → 그런 서버 없다. 라이브러리 안에 네트워크 호출 0.
  • ❌ “서버가 사용자에게 코드 발송” → 그건 SMS/Email OTP, 별개 물건.

라이브러리는 토큰 생성·검증(순수 알고리즘)만 담당한다. secret 암호화 저장·복구 코드·재사용 방지·throttling 은 전부 직접 구현이다.


R1. 등록 — secret 생성 & QR 용 URI (서버)

import * as OTPAuth from "otpauth"

const secret = new OTPAuth.Secret({ size: 20 })    // 최소 128비트, 160비트(20B) 권장

const totp = new OTPAuth.TOTP({
  issuer: "Acme",                  // 서비스명 (앱에 그룹명으로 표시)
  label: "user@example.com",       // 계정 식별자
  algorithm: "SHA1", digits: 6, period: 30,
  secret,
})

const uri = totp.toString()
// otpauth://totp/Acme:user@example.com?issuer=Acme&secret=…&algorithm=SHA1&digits=6&period=30
const secretBase32 = secret.base32  // 저장할 값 — 아직 pending, R2 통과 전까지 활성화 X

QR 이미지 생성은 라이브러리 범위 밖이라 qrcode 같은 별도 패키지로 uri 를 인코딩한다.

함정algorithm·digits·period 는 특별한 이유 없으면 기본값을 고정한다. 대부분의 Authenticator 앱이 SHA1/6/30 만 안정적으로 지원한다.


R2. 등록 확정 — 초기 검증

QR 스캔 직후 앱에 뜬 코드를 입력하게 해서 secret 이 제대로 공유됐는지 확인한다. 여기 통과해야만 2FA 를 활성화한다.

const delta = totp.validate({ token: userInput, window: 1 })
if (delta !== null) {
  // secret 을 암호화해 저장 + 2FA 활성 플래그 on + 복구 코드 발급
} else {
  // 재입력 요청 (등록 미완료 유지)
}

validate()delta(정수) 또는 null 을 반환한다. null 이면 window 안에서 못 찾은 것 = 무효다.


R3. 로그인 시 검증 (매 로그인)

ID/PW 1차 인증 통과 후, DB 의 secret 으로 TOTP 를 재구성한다.

const totp = new OTPAuth.TOTP({
  issuer: "Acme", label: "user@example.com",
  algorithm: "SHA1", digits: 6, period: 30,
  secret: OTPAuth.Secret.fromBase32(savedSecretBase32),   // 복호화해 로드
})
const delta = totp.validate({ token: userInput, window: 1 })

보조 메서드 — totp.generate() 는 지금 이 순간의 코드(앱이 하는 계산과 동일, 디버깅용), totp.counter() 는 경과 구간 수(재사용 방지 키), totp.remaining() 은 코드가 바뀌기까지 남은 ms(UI 카운트다운).

URI ↔ 객체totp.toString() / OTPAuth.URI.parse(uri).


R4. window 와 clock drift

서버 시계와 폰 시계는 몇 초씩 어긋난다. window: N 은 현재 구간 기준 앞뒤 N개 구간(±N×period 초)까지 허용한다는 뜻이다.

  • window: 1, period: 30±30초 허용 (실무 표준)
  • 키우면 유효 코드 개수가 늘어 브루트포스에 취약하다 → 최소화 원칙

라이브러리가 안 해주는 것 (전부 직접)

항목 처리
secret 저장 평문 금지. 앱 레벨 암호화(KMS/envelope) 후 DB
secret 노출 서버에만 보관. 클라이언트엔 등록 시 QR 로 1회만
window 크기 기본 1 유지
재사용 방지 성공한 counter() 값을 저장 → 같은 구간 코드 재검증 차단
throttling OTP 입력 시도 횟수 제한 (필수)
복구 수단 복구 코드 별도 발급·해시 저장

보안 고려사항은 RFC 4226 §7, RFC 6238 §5.


#592