Admin CMS MFA(2단계 인증) 설계 문서

개요

관리자 콘솔(admin) 로그인에 TOTP 기반 MFA를 추가한다. Cognito MFA 기능을 쓰지 않고 account-api 코드에서 직접 검증하며, 토큰 발급은 기존대로 Cognito를 사용한다.

작성일: 2026-10-01

현재 로그인 구조

항목 내용 위치
로그인 핸들러 account-api admin-account-api /login account-api/admin-account-api/service/index.js:58
비번 검증 DB admin_account.pwd (AES_ENCRYPT) 복호화 비교, 10회 실패 시 NonACTIVE service/index.js:15-56, repository/adminAccount.js:4-16
토큰 발급 복호화한 비번으로 Cognito USER_PASSWORD_AUTH 호출 → JWT service/index.js:74-75, cognito/cognito.js:19-25
응답 형식 Authorization 헤더 = idToken:accessToken:refreshToken, 바디 {id, date} service/index.js:89-101
admin-api 토큰 검증 idToken을 aws-jwt-verify로 로컬 검증, 30분 캐시 admin-api/admin-api/service/admin/index.js:38-91
프론트 React 18, Login.jsx → /admin/customer/accountinfo/list admin/src/components/Login.jsx
메일/SMS alarm-api → NHN Notification Hub (SES 없음) alarm-api/alarm-api/tools/NotificationHub.js

Cognito는 토큰 발급기로만 쓰이고 인증 판단은 account-api 코드가 한다. MFA도 같은 자리(비번 검증 뒤, 토큰 발급 전)에 끼워 넣는다.

방식 선정

비교

방식 장점 단점 판단
TOTP 직접 구현 (otplib) Cognito 설정 변경 없음, admin_type별 정책 자유, 리셋은 컬럼 초기화 시드 암호화 저장·재사용 방지 직접 구현 채택
Cognito TOTP MFA 시드를 Cognito가 보관, passkey 확장 용이 User Pool이 템플릿에 없어 두 AWS 계정 Pool 수동 설정, 챌린지 응답으로 로그인 플로우 구조 변경 보류
이메일 OTP (NHN) 앱 불필요 NHN 의존, 지연, 메일 탈취 시 무력화 비추
SMS OTP (NHN) 익숙함 건당 비용, SIM 스와핑, 발송 장애 시 로그인 불가 비추
WebAuthn/Passkey 가장 안전 구현 복잡, 하드웨어 키 배포 2차 고려

비용

Cognito TOTP MFA 자체는 추가 요금 없음 (Lite/Essentials 모두 MAU 단가에 포함, SMS MFA만 SNS 비용). 관리자 Pool은 월 10,000 MAU 무료 구간 안이라 어느 방식이든 비용은 0원. 비용은 결정 요인 아님.

인증 앱

TOTP는 RFC 6238 표준이라 Google Authenticator, Microsoft Authenticator, Authy, 1Password, Bitwarden 등 TOTP 지원 앱 모두 사용 가능.

시크릿 저장 위치

시드 암호화 방식

항목 값
알고리즘 AES-256-GCM (Node crypto 내장, 인증 태그로 변조 감지)
키 32바이트 랜덤, Secrets Manager admin-mfa-key. 비번용 AES_KEY와 분리
키 로딩 Lambda 콜드스타트 시 1회 조회 후 모듈 스코프 캐시
IV 저장 건마다 12바이트 랜덤
저장 형식 v1:<iv b64>:<tag b64>:<ciphertext b64> (접두어는 키 교체·알고리즘 변경 대비)
구현 위치 account-api/admin-account-api/utils/mfaCrypto.js (encrypt / decrypt)

MySQL AES_ENCRYPT를 쓰지 않는 이유: 키가 쿼리에 섞여 slow query 로그·binlog·에러 메시지에 남을 수 있음. Node에서 암호화하면 DB는 암호문만 다룸.

대안으로 KMS envelope 암호화 가능 (키 원문이 Lambda 메모리에도 없음, 자동 로테이션, 복호화마다 CloudTrail). 로그인마다 Decrypt 호출 1회. 현재 프로젝트들이 Secrets Manager만 쓰고 있어 1차는 Secrets Manager, 필요 시 v2: 접두어로 KMS 전환.

로그인 흐름

[admin 프론트]                 [account-api admin-account-api]              [DB admin_account]
    |                                   |                                        |
    |-- POST /login {account,pwd} ----->|                                        |
    |                                   |-- checkLogin (기존 그대로) ------------>|
    |                                   |<-- member (mfa_enabled 포함) -----------|
    |                                   |
    |      ┌─ mfa_enabled=0, MFA 강제 대상 아님  → 기존대로 Cognito 토큰 발급 (끝)
    |      ├─ mfa_enabled=0, MFA 강제 대상(내부관리자) → {step:"SETUP", mfaSession} 반환
    |      └─ mfa_enabled=1                        → {step:"VERIFY", mfaSession} 반환
    |<-- 200 {step, mfaSession} ---------|   (Authorization 헤더 없음, mfaSession은 5분 JWT)
    |
    |  [step = SETUP]
    |-- POST /mfa/setup {mfaSession} -->|  시드 생성 → 암호화 저장 (mfa_enabled 아직 0)
    |<-- {otpauthUri} ------------------|  프론트가 QR 렌더링
    |-- POST /mfa/confirm {mfaSession, code} -->|  코드 검증 성공 → mfa_enabled=1, 백업코드 발급
    |<-- Authorization 헤더 + {id,date,backupCodes} --|  이후 Cognito 토큰 발급
    |
    |  [step = VERIFY]
    |-- POST /mfa/verify {mfaSession, code} -->|  mfaSession 검증 → 시드 복호화 → TOTP 검증
    |                                   |      실패: mfa_fail_count+1, 10회 시 NonACTIVE
    |                                   |      성공: mfa_last_step 저장 → cognito.login(account, pwd)
    |<-- Authorization: id:access:refresh 헤더 + {id,date} --|  (현재 로그인 응답과 동일)

mfaSession

비번 검증은 통과했지만 OTP는 아직인 상태를 나타내는 임시 JWT.

항목 값
payload { adminId, purpose: "mfa" }
만료 5분
서명 키 env (INTERNAL_SECRET과 같은 방식)
용도 /mfa/setup, /mfa/confirm, /mfa/verify 호출에만 사용. Cognito 토큰과 형식이 달라 admin-api 호출 불가

DB 변경 (database-schema / account / admin_account)

컬럼 타입 용도
mfa_secret VARCHAR(255) NULL TOTP 시드, AES-256-GCM 암호문 (v1:iv:tag:ct)
mfa_enabled TINYINT(1) DEFAULT 0 등록 완료 여부
mfa_last_step BIGINT NULL 마지막 성공 TOTP 타임스텝, 같은 코드 재사용 차단
mfa_fail_count INT DEFAULT 0 연속 실패 횟수, 성공 시 0
mfa_backup_codes TEXT NULL 백업 코드 bcrypt 해시 배열(JSON), 사용 시 삭제
mfa_udate DATETIME NULL 등록/해제 시각 (KST)

서버 구조 (account-api / admin-account-api)

admin-account-api/
├── route.js                    /admin/mfa/setup, /mfa/confirm, /mfa/verify 추가
├── service/
│   ├── index.js                login(): checkLogin 이후, cognito.login 직전에 MFA 분기
│   └── mfa.js                  setup / confirm / verify / 공통 issueTokens()
├── repository/
│   └── adminAccount.js         findMfa, saveMfaSecret, enableMfa, updateMfaResult, resetMfa
├── utils/
│   ├── totp.js                 otplib 래퍼 (generateSecret, keyuri, verify + step 반환)
│   ├── mfaCrypto.js            AES-256-GCM encrypt / decrypt, Secrets Manager 키 캐시
│   └── mfaSession.js           sign / verify (5분 JWT)
└── static/account.js           AES_KEY (비번용, 기존 그대로. MFA에는 사용 안 함)

관리 기능 (admin-api)

기능 위치 내용
resetMfa admin-api/admin-api/service/admin/auth/index.js 대상 admin_id의 mfa_* 컬럼 초기화 → 다음 로그인 시 SETUP 유도. 호출자 admin_type === 1만 허용
목록/상세 같은 파일 list / detail 응답에 mfa_enabled 포함

프론트 구조 (admin / React)

src/
├── components/
│   ├── Login.jsx               응답 step 분기 (토큰 수신 시 기존대로, 아니면 MFA 화면)
│   ├── mfa/
│   │   ├── MfaSetup.jsx        QR(qrcode.react) + 첫 코드 입력 → /mfa/confirm → 백업코드 1회 표시
│   │   └── MfaVerify.jsx       6자리 입력 → /mfa/verify
│   └── admin/auth/...          관리자 상세에 "MFA 초기화" 버튼
├── routes/App.jsx              /admin/login/mfa 라우트 추가
└── redux/reducers/mfa.js       mfaSession, step 임시 보관 (persist 제외)

운영 정책

항목 정책
대상 내부관리자(admin_type=1) 강제. 제휴사(admin_type=2)는 1차 제외, mfa_enabled=1이면 검증만
분실 백업 코드 로그인 또는 다른 내부관리자가 콘솔에서 초기화
잠금 OTP 10회 연속 실패 시 비번과 동일하게 NonACTIVE
시계 오차 ±1 스텝(30초) 허용

배포 순서

  1. database-schema: admin_account 컬럼 추가 (기존 로그인 영향 없음)
  2. account-api admin-account-api: MFA 엔드포인트 + login 분기 (mfa_enabled=0이면 기존 동작 유지)
  3. admin-api: resetMfa, 목록/상세 mfa_enabled 노출
  4. admin 프론트: MfaSetup / MfaVerify / 초기화 버튼
  5. 내부관리자 강제 플래그 활성화

작업 분할

구분 범위 의존
A database-schema admin_account 컬럼 추가 없음
B account-api admin-account-api: mfa 서비스/repository/utils, login 분기 A 컬럼명
C admin-api: resetMfa, mfa_enabled 노출 A 컬럼명
D admin 프론트: MfaSetup/MfaVerify, Login 분기, 초기화 버튼 B API 스펙

B·C·D는 독립적이라 컬럼명과 API 응답 형식만 확정하면 병렬 진행 가능.

향후 확장