개발 slecs

관리자 로그인에 TOTP 2단계 인증 추가

목차

관리자 로그인에 TOTP 기반 2FA를 얹었다. credentials 인증 위에 OTP 레이어를 추가하는 작업이라 미들웨어 흐름까지 손을 댔고, 결과적으로 파일 수는 많지 않았지만 인증 흐름 전체를 다시 그려야 하는 작업이었음.

왜 지금 2FA였나

관리자 계정은 일반 사용자보다 권한 범위가 넓다. ID/PW 조합만으로 보호하는 구조는 credential stuffing이나 단순 브루트포스에 취약한데, 사내 서비스라도 관리자 패널이 외부에서 접근 가능한 엔드포인트에 있다면 리스크는 동일하다. 팀 내에서 "운영자 계정 탈취 시 피해 반경"을 다시 논의했고, 2FA를 빠르게 붙이는 쪽으로 우선순위를 당겼다.

TOTP(Time-based One-Time Password)를 선택한 이유는 단순하다. SMS OTP는 SIM 스와핑 공격에 노출되고, 이메일 OTP는 이메일 계정이 함께 털릴 경우 의미가 없다. TOTP는 서버-클라이언트 간 공유 시크릿 기반으로 타임스텝마다 코드를 생성하기 때문에 네트워크 전송 없이도 검증이 가능하다. Google Authenticator나 Authy 같은 범용 앱과 호환되는 것도 운영 부담을 줄이는 포인트였음.

변경 파일별 역할

파일 역할
src/lib/two-factor.ts TOTP 시크릿 생성, QR 코드 URI 생성, 코드 검증 로직
src/middleware.ts 세션 내 2FA 완료 여부 체크 + 미완료 시 /admin/2fa 리다이렉트
src/app/admin/2fa/page.tsx OTP 코드 입력 화면
src/app/admin/settings/2fa/page.tsx 2FA 설정 진입 페이지
src/app/admin/settings/2fa/_client.tsx QR 코드 표시 + 초기 등록 플로우 클라이언트 컴포넌트

핵심은 two-factor.tsmiddleware.ts 두 파일이다. 라이브러리 레이어에서 시크릿 생성과 검증을 캡슐화하고, 미들웨어에서 세션 상태를 보고 분기하는 구조로 갔음.

인증 흐름 설계

credentials 로그인 성공 이후 세션에 twoFactorVerified: false 플래그를 심어두고, 미들웨어가 관리자 라우트 접근 시 이 플래그를 확인하는 구조다.

// middleware.ts (패턴 예시)
const session = await getSession(req);

if (session?.user?.role === 'admin' && !session?.twoFactorVerified) {
  return NextResponse.redirect(new URL('/admin/2fa', req.url));
}

로그인 자체는 기존 credentials 흐름을 그대로 쓰고, 2FA 검증을 별도 스텝으로 분리한 점이 포인트다. 세션에 플래그를 두는 방식은 "로그인은 됐지만 아직 승인되지 않은 상태"를 표현하는 흔한 패턴인데, 이 플래그가 세션 탈취 시 우회 가능하다는 한계는 있음. 그래서 플래그 검증을 서버 미들웨어에서 하는 게 중요하고, 클라이언트 사이드에서만 처리하면 안 된다.

// two-factor.ts (패턴 예시)
import { authenticator } from 'otplib';

export function generateSecret() {
  return authenticator.generateSecret();
}

export function verifyToken(token: string, secret: string): boolean {
  return authenticator.verify({ token, secret });
}

export function getOtpAuthUrl(email: string, secret: string) {
  return authenticator.keyuri(email, 'AdminPanel', secret);
}

otplib 기준으로 이 정도 인터페이스면 충분하다. 검증 함수를 얇게 래핑해두면 나중에 라이브러리 교체가 필요할 때 수정 범위를 좁힐 수 있다.

코드리뷰에서 나온 포인트

팀원이 리뷰에서 짚은 부분이 두 가지였음.

  • 리커버리 코드 없음: TOTP 앱을 잃어버리거나 기기를 교체하면 잠길 수 있다. 초기 등록 시 일회용 리커버리 코드를 발급하는 로직이 없다는 지적이었고, 이건 다음 이터레이션으로 넘겼다.
  • 시크릿 저장 위치: DB에 평문으로 저장하면 DB가 털릴 때 시크릿도 노출된다. 암호화 저장 또는 환경변수 기반 키로 encrypt 후 저장하는 방식을 검토하기로 했음.

둘 다 맞는 지적이고, 빠르게 기본 레이어를 올리는 것과 완전한 보안 커버리지 사이의 트레이드오프였다. 현재 구조가 "없는 것보다 낫다"는 건 확실하지만, 리커버리 플로우 없이 운영 투입하면 실제로 잠기는 운영자가 나올 수 있어서 배포 전에 리커버리 코드 발급은 반드시 붙여야 한다.

회고

인증 흐름에 레이어를 추가할 때 가장 신경 쓰이는 건 "기존 세션과의 정합성"이다. 이미 로그인된 사용자가 있는 상태에서 미들웨어 조건을 바꾸면 의도치 않은 강제 로그아웃이 발생할 수 있다. 이번엔 twoFactorVerified 플래그가 없는 기존 세션을 어떻게 처리할지 명시적으로 정의해야 했고, 플래그 없음 = 미검증으로 처리해서 재인증을 강제하는 쪽으로 결정했다. 사용자 경험보다 보안을 우선한 결정이었고, 관리자 패널이라는 맥락에서는 옳은 방향이라고 본다.

끝.


🛒 이 글과 어울리는 추천 상품

*위 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.

댓글 0

첫 댓글 달아줘.