이중 인증(2FA)은 계정을 보호하기 위한 표준 방법이 되었습니다. 가장 일반적인 2FA 메커니즘, 즉 인증 앱에 표시되는 여섯 자리 코드는 TOTP(시간 기반 일회용 비밀번호)로 정의됩니다. 이 가이드는 TOTP의 내부 동작, 애플리케이션 구현 방법, 인증 앱 없이 테스트 코드를 생성하는 방법을 설명합니다.
TOTP란
TOTP(Time-Based One-Time Password)는 RFC 6238에 정의되어 있습니다. 짧은 숫자 코드(일반적으로 6자리)를 생성합니다:
- 30초마다 변경됨
- 한 번만 사용 가능
- 서버와 인증 앱 모두가 알고 있는 공유 시크릿이 필요
알고리즘은 결정론적입니다. 같은 시크릿과 같은 타임 윈도우라면 항상 같은 코드가 생성됩니다. “일회용”이란 각 코드가 30초 윈도우 내에서만 유효하다는 의미로, 재전송 공격을 방지합니다.
TOTP의 작동 원리: 수학적 원리
TOTP는 HOTP(HMAC 기반 일회용 비밀번호, RFC 4226)를 기반으로 구축됩니다. 핵심 알고리즘:
T = floor(current_unix_time / 30) # 타임 카운터
TOTP = HOTP(secret, T)
HOTP = Truncate(HMAC-SHA1(secret, T))
단계별 설명:
-
타임 카운터: 현재 Unix 타임스탬프를 타임스텝(기본 30초)으로 나눕니다.
floor(1712500000 / 30) = 57083333 -
HMAC: 시크릿 키와 타임 카운터로 HMAC-SHA1을 계산합니다. 20바이트 해시가 생성됩니다.
-
동적 잘라내기: 해시의 마지막 바이트를 가져옵니다. 하위 4비트가 오프셋을 결정합니다. 해당 오프셋에서 4바이트를 추출하고 최상위 비트를 마스킹하여 31비트 정수를 얻습니다.
-
나머지 연산:
정수 mod 10^6(6자리의 경우)을 계산하여 최종 OTP를 얻습니다.
결과는 6자리 코드입니다. 서버는 같은 계산을 수행하고 코드가 일치하면 수락합니다(클록 드리프트를 고려하여 보통 앞뒤 한 타임스텝도 허용합니다).
공유 시크릿
시크릿은 base32로 인코딩된 랜덤 값으로, 일반적으로 160비트(20바이트)입니다. 인증 앱에서 QR 코드를 스캔하면 QR 코드에는 다음과 같은 URI가 포함됩니다:
otpauth://totp/Service:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Service&algorithm=SHA1&digits=6&period=30
secret 파라미터가 base32 인코딩된 공유 시크릿입니다. 앱은 2FA 등록 시 한 번 생성하여 (암호화하여) 데이터베이스에 저장합니다. 사용자는 인증 앱(Google Authenticator, Authy, 1Password 등)에 저장합니다.
시크릿은 기밀 정보입니다. 시크릿을 가진 사람은 누구든지 해당 계정의 유효한 코드를 생성할 수 있습니다.
온라인에서 TOTP 테스트 코드 생성하기
base32 시크릿을 입력하면 현재 유효한 TOTP 코드를 즉시 얻을 수 있습니다. 다음 용도에 유용합니다:
- 개발 중 2FA 구현 테스트
- 시크릿이 올바르게 설정되었는지 확인
- 시간 동기화 문제 디버깅
데이터는 서버로 전송되지 않으며 계산은 브라우저에서 실행됩니다.
애플리케이션에 TOTP 구현하기
등록 플로우
- 랜덤 20바이트 시크릿 생성
- base32로 인코딩
- 서비스 이름과 사용자 식별자가 포함된
otpauth://URI 구성 - QR 코드로 표시하여 사용자가 스캔할 수 있게 함
- 현재 코드를 입력받아 등록 확인
- 시크릿을 (암호화하여) 데이터베이스에 저장
Node.js 구현
import { authenticator } from 'otplib';
// 등록
const secret = authenticator.generateSecret(); // 예: "JBSWY3DPEHPK3PXP"
const otpauthUrl = authenticator.keyuri('user@example.com', 'MyApp', secret);
// QR 코드 생성(qrcode 라이브러리 사용)
import qrcode from 'qrcode';
const qrDataUrl = await qrcode.toDataURL(otpauthUrl);
// 검증(로그인 시)
const isValid = authenticator.verify({ token: userCode, secret });
Python 구현
import pyotp
import qrcode
# 등록
secret = pyotp.random_base32()
totp = pyotp.TOTP(secret)
# QR 코드용 OTPAuth URI
uri = totp.provisioning_uri(name="user@example.com", issuer_name="MyApp")
img = qrcode.make(uri)
img.save("qr.png")
# 검증
is_valid = totp.verify(user_code) # 현재 ±1 타임스텝 허용
Go 구현
import "github.com/pquerna/otp/totp"
// 등록
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "MyApp",
AccountName: "user@example.com",
})
secret := key.Secret()
// key.URL()을 QR 코드로 표시
// 검증
valid := totp.Validate(userCode, secret)
TOTP 파라미터
RFC 6238은 기본값을 변경할 수 있습니다. 대부분의 인증 앱은 표준 변형을 모두 지원합니다:
| 파라미터 | 기본값 | 일반적인 대안 |
|---|---|---|
| 알고리즘 | SHA-1 | SHA-256, SHA-512 |
| 자릿수 | 6 | 8 |
| 기간 | 30초 | 60초 |
특별한 요구 사항이 없다면 기본값(SHA-1, 6자리, 30초)을 사용하세요. 비표준 설정은 일부 인증 앱과 호환성 문제를 일으킬 수 있습니다. SHA-1은 여기서 보안 문제가 아닙니다. 160비트 시크릿 키를 사용한 HMAC-SHA1은 80비트 보안을 제공하며, 30초 내에 6자리를 브루트 포스하는 것은 이미 불가능합니다.
클록 동기화와 드리프트
TOTP는 서버와 클라이언트의 클록이 적절히 동기화되어 있어야 합니다. 대부분의 구현은 클록 드리프트를 고려하여 T-1, T, T+1(현재 타임스텝 전후 ±30초)의 코드를 허용합니다.
사용자 기기의 클록이 크게 벗어나면(수 분) TOTP가 실패합니다. 모던 스마트폰에서는 드물지만 임베디드 기기나 커스텀 구현에서는 주의가 필요합니다.
서버 클록은 NTP를 사용하세요. Linux에서:
timedatectl status # 현재 NTP 동기화 상태 확인
timedatectl set-ntp true # NTP 동기화 활성화
TOTP vs 다른 2FA 방식
| 방식 | 보안성 | 피싱 저항성 | 외부 의존 없음 |
|---|---|---|---|
| TOTP | 높음 | 없음 | 있음 |
| SMS OTP | 낮음 | 없음 | 없음(통신사) |
| 푸시 알림 | 중간 | 없음 | 없음(앱 서버) |
| FIDO2 / 패스키 | 매우 높음 | 있음 | 있음 |
| 하드웨어 토큰(TOTP) | 높음 | 없음 | 있음 |
TOTP는 실시간 피싱에 취약합니다(공격자가 사용자를 속여 가짜 사이트에서 코드를 입력하게 하고 즉시 중계하는 방식). 최고 보안을 위해서는 FIDO2/WebAuthn 하드웨어 키가 피싱 저항성을 제공합니다. TOTP는 비밀번호만 사용하는 것보다 보안을 크게 향상시키며, 대부분의 애플리케이션에서 실용적인 2FA 표준입니다.
복구 코드
TOTP와 함께 반드시 복구 코드를 구현하세요. 사용자가 인증 기기를 잃어버리면 접근을 복구할 수 있는 방법이 필요합니다:
- 등록 시 8~10개의 일회용 복구 코드 생성
- bcrypt 또는 Argon2로 해시화하여 저장
- 한 번만 표시하고 오프라인에 저장하도록 안내
- 각 코드는 사용 후 무효화
import secrets
def generate_recovery_codes(count=10):
return [secrets.token_hex(10) for _ in range(count)]