Next.js에서 2FA/TOTP 인증 구현하기: 라이브러리 없이 RFC 6238 표준 준수

외부 라이브러리 없이 RFC 6238 TOTP 표준을 직접 구현했습니다. Dynamic Truncation 알고리즘 동작 원리와 Next.js 미들웨어에서 2FA를 강제하는 방법을 공유합니다.

1. 문제 상황

금융 시스템에서 보안 강화를 위해 2단계 인증(2FA)이 필요했습니다. 외부 라이브러리에 의존하지 않고, RFC 6238 표준을 완벽히 이해하면서 구현하고 싶었습니다.

요구사항

  1. Google Authenticator, Microsoft Authenticator 등 표준 앱과 호환
  2. QR 코드로 간편한 등록
  3. 백업 코드로 기기 분실 시 복구
  4. 미들웨어 레벨에서 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 전체에 대한 에러 처리 표준화도 중요합니다.

시리즈 목차:

  1. Prisma Extension으로 민감 데이터 암호화 자동화하기
  2. Next.js에서 2FA/TOTP 인증 구현하기 ← 현재 글
  3. API 에러 처리 표준화: withErrorHandler 패턴