공공부하자개발 · 영어 학습 노트
자바
인증·로그인JWT · OAuth2 · 2FA 로 로그인 구현0/3 완료
  • 011단계: 비밀번호 해시와 JWT 로그인
  • 022단계: Access/Refresh 토큰과 OAuth2 인가 코드
  • 033단계: 2FA(TOTP) 와 로그인 흐름 통합
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 인증·로그인 › 03 / 3

3단계: 2FA(TOTP) 와 로그인 흐름 통합

섹션 7진행 0 / 3
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

2. 핵심 원리

2.1 HOTP → TOTP: HMAC 한 번으로 6자리가 나온다

RFC 4226 의 HOTP(HMAC-based OTP) 는 공유 시크릿 K 와 카운터 C 로 HMAC-SHA1 을 계산하고, 그 20바이트에서 4바이트를 골라 10^6 으로 나눈 나머지를 코드로 씁니다. RFC 6238 의 TOTP 는 카운터 자리에 현재 시각을 30초로 나눈 몫(step) 을 넣은 것뿐입니다.

text
step = floor(현재 유닉스 초 / 30)
H    = HMAC-SHA1(K, step 을 8바이트 빅엔디안으로)          → 20바이트
off  = H[19] & 0x0F                                        → 0~15 (동적 절단: 시작 위치를 해시 자신이 정한다)
bin  = (H[off]&0x7F)<<24 | H[off+1]<<16 | H[off+2]<<8 | H[off+3]   → 31비트 양수
code = bin mod 10^6, 앞자리 0 포함 6자리

서버와 휴대폰이 같은 K 와 같은 시계를 가지면 같은 숫자가 나옵니다. 통신이 필요 없는 이유입니다. K 는 등록할 때 딱 한 번 QR 로 넘어가고, 그 뒤로는 양쪽이 시간만 봅니다.

RFC 6238 부록 B 는 시크릿 12345678901234567890(ASCII 20바이트) 에 대한 기대값을 실어 두었습니다. 구현이 맞는지는 이 표와 대조하면 됩니다.

유닉스 초 step 8자리 기대값
59 1 94287082
1111111109 37037036 07081804
1234567890 41152263 89005924
20000000000 666666666 65353130

동적 절단이 필요한 이유는 해시의 특정 위치 4바이트만 쓰면 그 위치의 편향이 코드에 그대로 드러나기 때문입니다. 마지막 바이트로 시작 위치를 고르면 시크릿마다 다른 자리를 쓰게 됩니다. 최상위 비트를 0x7F 로 지우는 것은 부호 있는 정수 언어에서 음수를 피하기 위한 규칙이고, 자바가 바로 그 경우입니다.

2.2 시크릿을 어떻게 넘기는가: Base32 와 otpauth URI

시크릿은 160비트 난수입니다. 이것을 사람이 읽고 앱이 찍을 수 있는 형태로 바꾼 것이 Base32(대문자 AZ 와 27, 32글자) 와 otpauth URI 입니다.

text
otpauth://totp/auth-lesson:kim?secret=EOHSVSDW33YMRWDX7DUVUWEGAPSHX3RD&issuer=auth-lesson&algorithm=SHA1&digits=6&period=30

QR 코드는 이 문자열을 그림으로 그린 것에 불과합니다. 앱은 secret 을 Base32 디코딩해 K 로 저장하고, issuer:account 를 목록에 표시합니다. Base64 대신 Base32 를 쓰는 이유는 0/O, 1/l 같은 혼동 글자가 없고 대소문자를 안 가려서 QR 을 못 찍을 때 손으로 32글자를 옮겨 적을 수 있기 때문입니다.

등록에는 반드시 확인(confirm) 단계가 있어야 합니다. 시크릿을 보여주자마자 2FA 를 켜면, 사용자가 QR 을 안 찍고 창을 닫았을 때 다음 로그인부터 계정이 잠깁니다. 그래서 시크릿은 pending 으로 두고, 앱이 만든 코드 하나를 서버가 검증해 통과할 때 비로소 활성화합니다. 이 레슨의 서버가 /2fa/setup 과 /2fa/confirm 을 나눈 이유입니다.

2.3 시간 윈도우와 재생 방지

휴대폰 시계와 서버 시계는 몇 초씩 어긋나고, 사용자가 코드를 보고 타이핑하는 사이에 30초 경계를 넘기도 합니다. 그래서 서버는 현재 step 앞뒤 1스텝(±30초) 을 함께 검사합니다. 총 90초 창이며, RFC 도 이 정도를 권합니다. 창을 더 넓히면 편해지지만 유효한 코드 개수가 늘어 추측 확률이 그만큼 오릅니다.

윈도우 때문에 생기는 구멍이 하나 있습니다. 같은 코드가 90초 동안 유효하니, 어깨너머로 본 코드를 사용자 직후에 다시 넣으면 통과합니다. RFC 6238 5.2 는 "한 번 성공한 코드는 다시 받지 말라" 고 못 박습니다.

구현은 간단합니다. 마지막으로 성공한 step 을 저장하고, 그 이하 step 은 거부합니다. 이 레슨의 Totp.verify 가 lastUsedStep 을 받는 이유이고, 등록 확인에 쓴 코드도 사용됨으로 기록합니다.

이 규칙의 부작용으로 정상 사용자도 30초 안에 두 번 로그인할 수 없습니다. 실무에서 문제가 되지 않고, 오히려 "같은 코드 두 번" 은 공격 신호입니다.

2.4 백업 코드: 휴대폰을 잃어버렸을 때의 마지막 문

휴대폰을 바꾸거나 잃어버리면 시크릿도 사라집니다. 고객센터가 본인 확인을 대신하는 것은 비용이 크고 사회공학 공격의 통로가 되므로, 등록할 때 1회용 백업 코드 10개를 함께 발급해 사용자가 안전한 곳에 적어 두게 합니다.

백업 코드는 비밀번호와 같은 취급을 받습니다. 서버에는 해시만 저장하고, 평문은 발급 응답에서 딱 한 번 보여줍니다. 40비트 난수처럼 엔트로피가 충분하므로 PBKDF2 대신 SHA-256 으로 충분합니다(2단계의 refresh token_hash 와 같은 논리). 쓰면 즉시 지워서 1회용을 보장하고, 재발급하면 이전 것을 전부 무효화합니다. 남은 개수가 2~3개로 줄면 재발급을 권하는 알림이 실무의 마무리입니다.

2.5 1~3단계 통합: mfa_token 과 amr

2FA 를 켠 사용자의 로그인은 두 번의 요청으로 나뉩니다. 문제는 첫 번째 요청(비밀번호) 이 성공한 뒤 두 번째 요청(코드) 이 오기까지의 상태를 어떻게 표현하느냐입니다.

text
브라우저                          인증 서버
  │ POST /login {id, pw}            │
  │────────────────────────────────▶│ 비밀번호 검증 (1단계)
  │ 200 {mfa_required:true,         │
  │      mfa_token: JWT(aud=mfa,3분)}│ ← Access 아님. 절반만 통과했다는 증표
  │◀────────────────────────────────│
  │ POST /login/2fa {mfa_token,code}│
  │────────────────────────────────▶│ mfa_token 서명·exp·aud·1회성 검증
  │                                 │ TOTP 검증 (3단계)
  │ 200 {access_token, amr:[pwd,otp]}│ + Set-Cookie refresh_token (2단계)
  │◀────────────────────────────────│

mfa_token 을 만드는 세 가지 규칙이 이 설계의 전부입니다.

규칙 이유
aud 를 "mfa" 로 API 는 aud=resource-api 만 받으므로 이 토큰으로는 401
짧게(3분) 비밀번호 통과 상태가 오래 살면 그 자체가 공격 대상
jti 1회용 성공하면 jti 를 사용됨에 넣어 두 번째 세션을 막는다

세션 대신 별도 audience 로 "반쪽 상태" 를 격리하면, 기존 aud 검사 하나가 그대로 방어가 됩니다. 사용자는 몇 초면 코드를 넣으므로 3분이면 충분합니다.

발급된 Access 에는 amr(Authentication Methods References, RFC 8176) 클레임으로 통과한 요소를 기록합니다. ["pwd"] 는 비밀번호만, ["pwd","otp"] 는 2FA 통과입니다. 이 값을 Refresh 레코드에 함께 저장해 두면 회전 후에도 유지되고, "정산 메뉴는 amr 에 otp 가 있는 세션만" 같은 인가 규칙을 리소스 서버가 토큰만 보고 내릴 수 있습니다.

2.6 실패 횟수 제한: 6자리는 100만 가지뿐

TOTP 코드는 100만 가지이고 윈도우 3개면 그중 3개가 유효합니다. 시도를 제한하지 않으면 초당 수백 번 보내는 스크립트가 평균 수십만 번 만에 뚫습니다. 반드시 사용자별로 실패를 세어 5회면 몇 분 잠그고, 성공하면 0 으로 되돌립니다. 잠긴 동안은 맞는 코드도 거부합니다. 1단계의 비밀번호 5회 잠금과 같은 원리이며, 백업 코드 시도도 같은 카운터에 합칩니다.

2.7 Spring Security 에서는: 필터 체인 위치와 SAML SSO 공존

Spring Security 에는 TOTP 구현이 없습니다. 계산은 이 레슨의 60줄이면 되고, 문제는 다단계 상태를 필터 체인 어디에 두느냐입니다.

이 레슨 Spring Security 6.x (Boot 3.5) 비고
비밀번호 검증 DaoAuthenticationProvider + PasswordEncoder 1단계와 동일
mfa_token (aud=mfa) 권한이 ROLE_PRE_MFA 뿐인 Authentication 을 저장 인증됐지만 요소가 모자란 상태
/api/* 에서 mfa_token 거부 /2fa/** 외에는 hasRole("USER") 요구 AuthorizationFilter(맨 뒤) 가 403
코드 검증 성공 Authentication 을 정식 권한으로 교체 Spring Security 7 은 FactorGrantedAuthority 내장
amr 발급 시 커스텀 클레임, 검증 시 JwtAuthenticationConverter 2단계 대응표와 동일
실패 횟수·잠금 AuthenticationFailureEvent 리스너 또는 서비스 계층 카운터 라이브러리 없음, 직접

핵심은 2FA 가 인증 필터가 아니라 인가 단계에 끼어든다는 점입니다. 필터 체인은 UsernamePasswordAuthenticationFilter(또는 BearerTokenAuthenticationFilter) 가 사용자를 식별한 뒤 맨 끝의 AuthorizationFilter 가 URL 별 규칙을 적용합니다.

비밀번호만 통과한 사용자는 "인증됨 + 권한 부족" 이므로 코드 입력 화면 외 모든 곳에서 403 이 나고, 코드까지 통과하면 권한이 채워집니다. 이 레슨의 aud 분리를 Spring 식으로 옮긴 것입니다.

SAML SSO 와의 공존은 "누가 2FA 를 하느냐" 를 정하는 문제입니다.

사용자 인증 주체 2FA 담당 우리 서버가 할 일
임직원 회사 IdP (SAML) IdP 의 MFA 정책 (ADFS/Keycloak) SAML 응답의 AuthnContextClassRef 를 amr 로 옮긴다
협력사·운영 계정 우리 로컬 DB 우리 TOTP (이 레슨의 방식) 비밀번호 → mfa_token → 코드 흐름 그대로

IdP 가 이미 MFA 를 했는데 우리가 또 TOTP 를 요구하면 사용자는 하루에 코드를 두 번 넣습니다. 반대로 IdP 정책이 약하면 AuthnRequest 의 RequestedAuthnContext 로 MFA 를 요구할 수 있습니다.

Spring 의 saml2Login() 은 응답을 Saml2AuthenticatedPrincipal 로 줍니다. 여기서 컨텍스트 클래스를 읽어 MultiFactor 나 TimeSyncToken 이면 otp 통과로 인정하고 우리 토큰의 amr 을 채웁니다. 그러면 리소스 서버는 두 경로를 구분할 필요가 없습니다.

핵심 원리
  • 2.1 HOTP → TOTP: HMAC 한 번으로 6자리가 나온다
  • 2.2 시크릿을 어떻게 넘기는가: Base32 와 otpauth URI
  • 2.3 시간 윈도우와 재생 방지
  • 2.4 백업 코드: 휴대폰을 잃어버렸을 때의 마지막 문
  • 2.5 1~3단계 통합: mfa_token 과 amr
  • 2.6 실패 횟수 제한: 6자리는 100만 가지뿐
  • 2.7 Spring Security 에서는: 필터 체인 위치와 SAML SSO 공존
이전 섹션1 왜 배우는가2 / 7다음 섹션3 코드 예제