다중 인증(MFA): SMS부터 패스키까지
SMS OTP, TOTP, FIDO2/WebAuthn 등 MFA 방식의 보안 강도를 비교하고, TOTP 완전 구현과 MFA 우회 공격(피싱, SIM Swap, MFA Fatigue) 방어 방법을 다룹니다.
지난 글에서 OIDC로 사용자 인증을 표준화하는 방법을 다뤘다. 비밀번호 하나만으로는 부족하다. 피싱, 크리덴셜 스터핑, 데이터 유출 등 다양한 경로로 비밀번호가 노출될 수 있다. **다중 인증(MFA)**은 두 번째 인증 요소를 추가해 계정 탈취를 대폭 어렵게 만든다.
MFA의 세 가지 요소
- 지식 (Know): 비밀번호, PIN
- 소지 (Have): 인증 앱, 하드웨어 키, 전화기
- 특성 (Are): 지문, 얼굴 인식
MFA는 서로 다른 카테고리의 요소를 두 가지 이상 조합한다. 같은 카테고리를 두 번 쓰는 것(비밀번호 + PIN)은 MFA가 아니다.
MFA 방식 비교
SMS OTP — 지양
SIM Swap 공격, SS7 프로토콜 취약점, 실시간 피싱에 취약하다. NIST SP 800-63B는 SMS OTP를 “제한된 인증자”로 분류한다. 기존 사용자 기반을 위해 제공할 수 있지만, 더 강한 방식으로 이전을 권장해야 한다.
TOTP(시간 기반 OTP) — 현재 표준
Google Authenticator, Microsoft Authenticator 등 앱에서 30초마다 6자리 코드를 생성한다. RFC 6238 표준이며 구현이 간단하다. 단, 실시간 피싱에는 여전히 취약하다(공격자가 OTP를 즉시 릴레이).
FIDO2/WebAuthn — 최강
공개키 암호화 기반. 서버는 공개키만 보관하고, 개인키는 하드웨어 기기나 생체 인증기에서 절대 벗어나지 않는다. 도메인 바인딩으로 피싱 사이트에서는 절대 인증할 수 없다. 현재 가장 강력한 MFA 방식이다.
TOTP 완전 구현
import pyotp
import secrets
import hashlib
# 등록
def setup_totp(user_id: str, db) -> dict:
secret = pyotp.random_base32()
totp = pyotp.TOTP(secret)
# QR 코드 URI — Authenticator 앱에 스캔
uri = totp.provisioning_uri(user_id, issuer_name="MyApp")
# 백업 코드 생성 (10개)
backup_codes = [secrets.token_hex(4).upper() for _ in range(10)]
hashed_codes = [hashlib.sha256(c.encode()).hexdigest() for c in backup_codes]
# 아직 DB 저장하지 말 것 — 사용자가 확인 후 저장
return {
"secret": secret, # 임시 보관 (확인 후 DB 저장)
"qr_uri": uri,
"backup_codes": backup_codes, # 사용자에게 한 번만 표시
"hashed_codes": hashed_codes,
}
# 등록 확인: 사용자가 OTP 입력으로 정상 등록 확인
def confirm_totp_setup(user_id: str, code: str, pending_secret: str, db) -> bool:
totp = pyotp.TOTP(pending_secret)
if not totp.verify(code, valid_window=1):
return False
# 검증 완료 후 DB에 저장
db.save_totp_secret(user_id, pending_secret)
return True
# 로그인 시 검증
def verify_totp(user_id: str, code: str, db) -> bool:
secret = db.get_totp_secret(user_id)
if not secret:
raise ValueError("TOTP 미등록")
totp = pyotp.TOTP(secret)
# valid_window=1: 이전/다음 30초 윈도우 허용 (시계 오차 대비)
if not totp.verify(code, valid_window=1):
# 백업 코드 시도
return verify_backup_code(user_id, code, db)
# 코드 재사용 방지: 이미 사용한 코드 기록
if db.is_totp_code_used(user_id, code):
raise ValueError("이미 사용된 OTP")
db.mark_totp_code_used(user_id, code, ttl_seconds=90)
return True
MFA 상태 관리
MFA 검증 후 세션에 단계를 기록한다.
class LoginStep(str, Enum):
PASSWORD_VERIFIED = "password_verified"
MFA_VERIFIED = "mfa_verified"
async def login_step2_mfa(code: str, request: Request):
# 1단계(비밀번호) 완료 확인
if request.session.get("login_step") != LoginStep.PASSWORD_VERIFIED:
raise HTTPException(401, "비밀번호 인증 먼저 필요")
user_id = request.session["pending_user_id"]
if not verify_totp(user_id, code, db):
raise HTTPException(401, "OTP 인증 실패")
# MFA 완료 — 완전한 세션 생성
request.session["user_id"] = user_id
request.session["login_step"] = LoginStep.MFA_VERIFIED
del request.session["pending_user_id"]
return {"message": "로그인 완료"}
MFA Fatigue 공격 방어
Push 알림 기반 MFA에서 공격자가 반복적으로 승인 요청을 보내 사용자가 지쳐 수락하는 공격이다.
MFA_REQUEST_LIMIT = 3 # 10분 내 최대 요청
MFA_LOCKOUT_SECONDS = 600
def check_mfa_rate_limit(user_id: str, redis_client):
key = f"mfa_attempts:{user_id}"
count = redis_client.incr(key)
if count == 1:
redis_client.expire(key, MFA_LOCKOUT_SECONDS)
if count > MFA_REQUEST_LIMIT:
raise RateLimitError("MFA 요청 횟수 초과 — 잠시 후 다시 시도")
보안 체크리스트
- TOTP: 백업 코드 10개 제공 및 안전한 보관 안내
- OTP 코드 재사용 방지 (30~90초 TTL로 사용 기록)
- MFA 실패 횟수 제한 (5회 실패 시 계정 잠금)
- MFA 설정 변경 시 재인증 요구
- 가능하면 FIDO2/WebAuthn 제공 (피싱 저항)
- SMS OTP: 더 강한 방식 이전 권유
지난 글: OpenID Connect
다음 글: WebAuthn과 패스키: 비밀번호 없는 미래
읽어주셔서 감사합니다. 😊