1. 문제 상황
금융 시스템에서 보안 강화를 위해 2단계 인증(2FA)이 필요했습니다. 외부 라이브러리에 의존하지 않고, RFC 6238 표준을 완벽히 이해하면서 구현하고 싶었습니다.
요구사항
- Google Authenticator, Microsoft Authenticator 등 표준 앱과 호환
- QR 코드로 간편한 등록
- 백업 코드로 기기 분실 시 복구
- 미들웨어 레벨에서 2FA 강제
2. TOTP 알고리즘 이해하기
TOTP(Time-based One-Time Password)는 시간 기반 일회용 비밀번호입니다. RFC 6238에 정의되어 있으며, 핵심은 의외로 간단합니다.
2.1 동작 원리
현재 시간 (Unix timestamp)
│
▼
÷ 30초 (period)
│
▼
카운터 값 (counter)
│
▼
HMAC-SHA1(secret, counter)
│
▼
Dynamic Truncation
│
▼
6자리 OTP 코드
2.2 핵심 수식
counter = floor(timestamp / 30)
hash = HMAC-SHA1(secret, counter)
code = DynamicTruncation(hash) mod 10^6
3. 구현
3.1 Base32 인코딩/디코딩
TOTP 비밀키는 Base32로 인코딩됩니다. Google Authenticator가 이 형식을 사용합니다.
// src/lib/core/2fa.ts
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
/**
* Base32 인코딩 (RFC 4648)
*/
function base32Encode(buffer: Buffer): string {
let result = '';
let bits = 0;
let value = 0;
for (let i = 0; i < buffer.length; i++) {
value = (value << 8) | buffer[i]!;
bits += 8;
while (bits >= 5) {
result += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) {
result += BASE32_ALPHABET[(value << (5 - bits)) & 31];
}
return result;
}
/**
* Base32 디코딩
*/
function base32Decode(input: string): Buffer {
const cleanInput = input.toUpperCase().replace(/=+$/, '');
const bytes: number[] = [];
let bits = 0;
let value = 0;
for (let i = 0; i < cleanInput.length; i++) {
const char = cleanInput[i]!;
const index = BASE32_ALPHABET.indexOf(char);
if (index === -1) continue;
value = (value << 5) | index;
bits += 5;
if (bits >= 8) {
bytes.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
return Buffer.from(bytes);
}
3.2 TOTP 비밀키 생성
import crypto from 'crypto';
const SECRET_LENGTH = 20; // 160비트 (Google Authenticator 호환)
/**
* TOTP 비밀키 생성
* @returns Base32 인코딩된 비밀키
*/
export function generateTotpSecret(): string {
const buffer = crypto.randomBytes(SECRET_LENGTH);
return base32Encode(buffer);
}
// 예: "JBSWY3DPEHPK3PXP"
3.3 TOTP 코드 생성 (핵심 알고리즘)
const TOTP_DIGITS = 6; // OTP 자릿수
const TOTP_PERIOD = 30; // 유효 기간 (초)
const TOTP_ALGORITHM = 'sha1'; // Google Authenticator 호환
/**
* TOTP 코드 생성
*
* @param secret - Base32 인코딩된 비밀키
* @param timestamp - 타임스탬프 (기본: 현재 시간)
* @returns 6자리 TOTP 코드
*/
export function generateTotpCode(secret: string, timestamp = Date.now()): string {
// 1. 카운터 계산 (30초 단위)
const counter = Math.floor(timestamp / 1000 / TOTP_PERIOD);
// 2. 카운터를 8바이트 빅엔디안 버퍼로 변환
const counterBuffer = Buffer.alloc(8);
counterBuffer.writeBigUInt64BE(BigInt(counter));
// 3. HMAC-SHA1 계산
const key = base32Decode(secret);
const hmac = crypto.createHmac(TOTP_ALGORITHM, key);
hmac.update(counterBuffer);
const hash = hmac.digest();
// 4. Dynamic Truncation (RFC 6238 핵심)
const offset = hash[hash.length - 1]! & 0x0f; // ← 마지막 4비트가 오프셋
const code =
((hash[offset]! & 0x7f) << 24) | // ← 최상위 비트 제거 (양수 보장)
((hash[offset + 1]! & 0xff) << 16) |
((hash[offset + 2]! & 0xff) << 8) |
(hash[offset + 3]! & 0xff);
// 5. 6자리로 변환
return String(code % 10 ** TOTP_DIGITS).padStart(TOTP_DIGITS, '0');
}
Dynamic Truncation 상세 설명:
HMAC 결과 (20바이트):
┌──────────────────────────────────────────────────────────────────────┐
│ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 │
│ 1f 86 98 69 0e 02 ca 16 61 85 50 ef 7f 19 da 8e 94 5b 55 5a │
└──────────────────────────────────────────────────────────────────────┘
│
offset = 0x5a & 0x0f = 10
│
▼
┌────────────────────┐
│ 50 ef 7f 19 │ offset 10~13
└────────────────────┘
│
▼
(0x50 & 0x7f) << 24 = 0x50000000
(0xef) << 16 = 0x00ef0000
(0x7f) << 8 = 0x00007f00
(0x19) = 0x00000019
───────────────────────────────
합계 = 0x50ef7f19 = 1357938457
│
▼
1357938457 % 1000000 = 938457
│
▼
OTP: "938457"
3.4 TOTP 코드 검증
시간 차이를 고려하여 앞뒤 윈도우까지 허용합니다.
/**
* 타이밍 안전한 문자열 비교
* 타이밍 공격 방지
*/
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
return crypto.timingSafeEqual(bufA, bufB);
}
/**
* TOTP 코드 검증
*
* 시간 차이를 고려하여 앞뒤 1개 윈도우까지 허용 (±30초)
*/
export function verifyTotpCode(secret: string, code: string): boolean {
if (!code || code.length !== TOTP_DIGITS) {
return false;
}
const now = Date.now();
// 현재 + 앞뒤 1개 윈도우 확인 (총 3개)
for (let i = -1; i <= 1; i++) {
const timestamp = now + i * TOTP_PERIOD * 1000;
const expectedCode = generateTotpCode(secret, timestamp);
if (timingSafeEqual(code, expectedCode)) {
return true;
}
}
return false;
}
3.5 QR 코드용 URI 생성
/**
* TOTP URI 생성 (QR 코드용)
*
* 형식: otpauth://totp/Issuer:account?secret=...&issuer=...
*/
export function generateTotpUri(
secret: string,
email: string,
issuer = 'MyApp'
): string {
const encodedEmail = encodeURIComponent(email);
const encodedIssuer = encodeURIComponent(issuer);
return `otpauth://totp/${encodedIssuer}:${encodedEmail}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=${TOTP_DIGITS}&period=${TOTP_PERIOD}`;
}
// 예: otpauth://totp/MyApp:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=MyApp&algorithm=SHA1&digits=6&period=30
3.6 백업 코드 구현
기기 분실 시 복구를 위한 일회용 백업 코드입니다.
import { encrypt, decrypt, encryptArray, decryptArray } from './encryption';
const BACKUP_CODE_COUNT = 10;
const BACKUP_CODE_LENGTH = 8;
/**
* 백업 코드 생성
* @returns 암호화되지 않은 백업 코드 배열
*/
export function generateBackupCodes(): string[] {
const codes: string[] = [];
for (let i = 0; i < BACKUP_CODE_COUNT; i++) {
const code = crypto
.randomBytes(BACKUP_CODE_LENGTH)
.toString('hex')
.toUpperCase()
.slice(0, BACKUP_CODE_LENGTH);
codes.push(code);
}
return codes;
}
// 예: ["A1B2C3D4", "E5F6G7H8", ...]
/**
* 백업 코드 검증 및 소비
*/
export function verifyBackupCode(
encryptedCodes: string[],
inputCode: string
): { valid: boolean; remainingCodes: string[] } {
// 복호화
const codes = decryptArray(encryptedCodes);
const normalizedInput = inputCode.toUpperCase().replace(/\s+/g, '');
// 코드 찾기
const index = codes.findIndex((code) =>
timingSafeEqual(code, normalizedInput)
);
if (index === -1) {
return { valid: false, remainingCodes: encryptedCodes };
}
// 사용된 코드 제거 후 다시 암호화
codes.splice(index, 1);
const remainingCodes = encryptArray(codes);
return { valid: true, remainingCodes };
}
/**
* 백업 코드 포맷팅 (표시용)
*/
export function formatBackupCodes(codes: string[]): string[] {
return codes.map((code) => {
if (code.length === 8) {
return `${code.slice(0, 4)}-${code.slice(4)}`; // "A1B2-C3D4" 형식
}
return code;
});
}
3.7 비밀키 암호화 저장
/**
* 2FA 비밀키 암호화 저장
*/
export function encryptTotpSecret(secret: string): string {
return encrypt(secret);
}
/**
* 2FA 비밀키 복호화
*/
export function decryptTotpSecret(encryptedSecret: string): string {
return decrypt(encryptedSecret);
}
4. Next.js 통합
4.1 데이터베이스 스키마
model User {
id String @id @default(cuid())
email String
// ...
// 2FA 필드
twoFactorEnabled Boolean @default(false)
twoFactorSecret String? // TOTP 비밀키 (암호화됨)
twoFactorBackupCodes String[] // 백업 코드 배열 (암호화됨)
lastTwoFactorAt DateTime? // 마지막 2FA 인증 시간
}
4.2 2FA 설정 페이지
// src/app/(auth)/auth/2fa-setup/page.tsx
'use client';
import { useState } from 'react';
import QRCode from 'qrcode'; // npm install qrcode
export default function TwoFactorSetupPage() {
const [secret, setSecret] = useState('');
const [qrCodeUrl, setQrCodeUrl] = useState('');
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [verificationCode, setVerificationCode] = useState('');
// 1. 비밀키 생성 요청
async function handleGenerateSecret() {
const res = await fetch('/api/auth/2fa/generate', { method: 'POST' });
const data = await res.json();
setSecret(data.secret);
setBackupCodes(data.backupCodes);
// QR 코드 생성
const qr = await QRCode.toDataURL(data.uri);
setQrCodeUrl(qr);
}
// 2. 코드 검증 및 2FA 활성화
async function handleVerify() {
const res = await fetch('/api/auth/2fa/verify', {
method: 'POST',
body: JSON.stringify({ code: verificationCode }),
});
if (res.ok) {
// 성공 - 대시보드로 이동
window.location.href = '/dashboard';
} else {
alert('코드가 올바르지 않습니다.');
}
}
return (
<div>
<h1>2단계 인증 설정</h1>
{!qrCodeUrl ? (
<button onClick={handleGenerateSecret}>시작하기</button>
) : (
<>
{/* QR 코드 표시 */}
<img src={qrCodeUrl} alt="QR Code" />
{/* 수동 입력용 비밀키 */}
<p>수동 입력: {secret}</p>
{/* 백업 코드 표시 */}
<div>
<h2>백업 코드 (안전하게 보관하세요)</h2>
<ul>
{backupCodes.map((code, i) => (
<li key={i}>{code}</li>
))}
</ul>
</div>
{/* 검증 */}
<input
type="text"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
placeholder="6자리 코드 입력"
maxLength={6}
/>
<button onClick={handleVerify}>확인</button>
</>
)}
</div>
);
}
4.3 API 라우트
// src/app/api/auth/2fa/generate/route.ts
import { NextResponse } from 'next/server';
import { auth } from '@/lib/core/auth';
import { prisma } from '@/lib/core/db';
import {
generateTotpSecret,
generateTotpUri,
generateBackupCodes,
encryptTotpSecret,
} from '@/lib/core/2fa';
import { encryptArray } from '@/lib/core/encryption';
export async function POST() {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: '인증 필요' }, { status: 401 });
}
// 비밀키 생성
const secret = generateTotpSecret();
const uri = generateTotpUri(secret, session.user.email);
const backupCodes = generateBackupCodes();
// 임시 저장 (아직 활성화 X)
await prisma.user.update({
where: { id: session.user.id },
data: {
twoFactorSecret: encryptTotpSecret(secret),
twoFactorBackupCodes: encryptArray(backupCodes),
// twoFactorEnabled는 아직 false
},
});
return NextResponse.json({
secret, // 표시용 (클라이언트에서 QR 생성)
uri,
backupCodes: backupCodes.map(
(c) => `${c.slice(0, 4)}-${c.slice(4)}` // 포맷팅
),
});
}
// src/app/api/auth/2fa/verify/route.ts
import { NextResponse } from 'next/server';
import { auth } from '@/lib/core/auth';
import { prisma } from '@/lib/core/db';
import { verifyTotpCode, decryptTotpSecret } from '@/lib/core/2fa';
export async function POST(request: Request) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: '인증 필요' }, { status: 401 });
}
const { code } = await request.json();
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { twoFactorSecret: true },
});
if (!user?.twoFactorSecret) {
return NextResponse.json({ error: '2FA 설정 필요' }, { status: 400 });
}
const secret = decryptTotpSecret(user.twoFactorSecret);
const isValid = verifyTotpCode(secret, code);
if (!isValid) {
return NextResponse.json({ error: '코드 불일치' }, { status: 400 });
}
// 2FA 활성화
await prisma.user.update({
where: { id: session.user.id },
data: {
twoFactorEnabled: true,
lastTwoFactorAt: new Date(),
},
});
return NextResponse.json({ success: true });
}
4.4 미들웨어에서 2FA 강제
// src/middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { auth } from '@/lib/core/auth';
// 2FA 검증이 필요한 경로
const PROTECTED_PATHS = ['/dashboard', '/settings', '/admin'];
// 2FA 관련 경로 (예외)
const TWO_FACTOR_PATHS = ['/auth/2fa-setup', '/auth/2fa-verify'];
export async function middleware(request: NextRequest) {
const session = await auth();
const { pathname } = request.nextUrl;
// 보호된 경로 접근 시
if (PROTECTED_PATHS.some((p) => pathname.startsWith(p))) {
// 로그인 확인
if (!session?.user) {
return NextResponse.redirect(new URL('/login', request.url));
}
// 2FA 활성화됐지만 검증 안 된 경우
if (session.user.twoFactorEnabled && !session.user.twoFactorVerified) {
// 2FA 페이지는 허용
if (TWO_FACTOR_PATHS.some((p) => pathname.startsWith(p))) {
return NextResponse.next();
}
// 그 외는 2FA 검증 페이지로 리다이렉트
return NextResponse.redirect(new URL('/auth/2fa-verify', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
5. 핵심 개념 정리
| 개념 | 설명 | 값 |
|---|---|---|
| Period | OTP 유효 기간 | 30초 |
| Digits | OTP 자릿수 | 6자리 |
| Algorithm | HMAC 알고리즘 | SHA1 |
| Secret Length | 비밀키 길이 | 20바이트 (160비트) |
| Window | 시간 허용 오차 | ±1 (30초) |
6. 베스트 프랙티스
보안 체크리스트
- [ ] 비밀키는 암호화하여 저장
- [ ] 백업 코드는 일회용 (사용 시 삭제)
- [ ] 타이밍 안전한 비교 사용 (
crypto.timingSafeEqual) - [ ] 시간 윈도우는 ±1만 허용 (너무 넓으면 보안 약화)
- [ ] Rate Limiting 적용 (무차별 대입 방지)
사용자 경험
- [ ] QR 코드 + 수동 입력 옵션 제공
- [ ] 백업 코드 복사/다운로드 기능
- [ ] 2FA 비활성화 시 확인 절차
- [ ] 마지막 인증 시간 표시
7. FAQ
Q: 왜 SHA1을 사용하나요? SHA256이 더 안전하지 않나요?
A: TOTP 표준(RFC 6238)은 SHA1, SHA256, SHA512를 모두 지원합니다. 하지만 Google Authenticator 등 대부분의 앱이 SHA1만 지원합니다. HMAC-SHA1은 SHA1의 collision 취약점과 무관하며, TOTP 용도로는 충분히 안전합니다.
Q: 서버와 클라이언트 시간이 다르면 어떻게 되나요?
A: ±1 윈도우(±30초)를 허용하므로 약간의 시간차는 괜찮습니다. 서버는 NTP로 시간 동기화를 해야 하고, 모바일 앱도 대부분 네트워크 시간을 사용합니다.
Q: 비밀키를 평문으로 저장하면 안 되나요?
A: 절대 안 됩니다. 비밀키가 유출되면 공격자가 OTP를 생성할 수 있습니다. 반드시 암호화하여 저장하세요.
Q: 백업 코드는 몇 개가 적당한가요?
A: 일반적으로 10개를 제공합니다. 너무 적으면 기기 분실 시 복구 어렵고, 너무 많으면 보안 위험이 증가합니다.
Q: 2FA 없이 로그인한 세션도 보호해야 하나요?
A: 네. NextAuth JWT에 twoFactorVerified 플래그를 추가하고, 미들웨어에서 체크해야 합니다.
8. 참고 자료
9. 다음 단계
2FA를 구현했다면, API 전체에 대한 에러 처리 표준화도 중요합니다.