3단계: 2FA(TOTP) 와 로그인 흐름 통합
5. 자주 하는 실수 (Tip)
❌ 실수 1: 시크릿을 평문으로 저장한다
totp_secret VARCHAR2(32) 에 Base32 문자열 그대로. DB 백업 파일 하나가 유출되면 모든 사용자의 2FA 가 무력화됩니다. 변형 4 처럼 암호화하고, 로그에도 시크릿·otpauth URI 를 남기지 마세요. 이 레슨의 /2fa/setup 응답이 서버 로그에 찍히면 같은 문제입니다.
❌ 실수 2: 확인 없이 활성화한다
/2fa/setup 응답을 주는 순간 totpSecret 을 켜면, 사용자가 QR 을 안 찍고 나갔을 때 다음 로그인부터 잠깁니다. 고객센터 문의 1위입니다. 반드시 pending → 코드 1회 검증 → 활성화 순서를 지키세요.
❌ 실수 3: 재생 방지가 없다
윈도우 검사만 하고 lastUsedStep 을 저장하지 않으면 같은 코드가 90초간 재사용됩니다. 공격 (b) 가 통과합니다. 등록 확인에 쓴 코드도 사용됨으로 기록해야 합니다.
❌ 실수 4: 윈도우를 넓혀서 시계 문제를 덮는다
"가끔 안 된다" 는 문의에 WINDOW = 5 로 올리면 유효 코드가 11개가 되어 추측 확률이 4배 가까이 오릅니다. 원인은 대부분 서버 시계입니다. NTP 동기화를 확인하고, 사용자 쪽은 앱의 "시간 보정" 기능을 안내하세요. 이 레슨의 Totp.OFFSET 은 시연용이며 실서버에는 없어야 합니다.
❌ 실수 5: 시도 횟수를 제한하지 않는다
비밀번호는 잠그면서 코드는 무제한. 6자리 100만 가지는 브루트포스 대상입니다. 실패 카운터는 TOTP 와 백업 코드를 합쳐 세고, 잠긴 동안은 맞는 코드도 거부합니다(공격 (g)).
❌ 실수 6: 비밀번호 통과 상태를 정식 Access 로 준다
"권한 없는 Access 를 먼저 주고 코드를 넣으면 권한을 올린다" 는 설계는 리소스 서버마다 "권한이 비어 있는지" 를 따로 검사해야 하고 하나만 빠뜨리면 2FA 우회입니다. aud 를 mfa 로 분리하면 기존 aud 검사 하나로 전부 막힙니다(공격 (c)). Spring 에서는 ROLE_PRE_MFA 만 가진 Authentication 과 authorizeHttpRequests 규칙이 같은 역할을 합니다.
❌ 실수 7: 초 단위 exp 를 밀리초 감각으로 테스트한다
이 레슨을 만들며 겪은 함정입니다. mfa_token TTL 을 1초로 두고 1.5초 기다린 뒤 검증했더니 통과했습니다. exp 와 now 가 모두 초 단위 정수라 발급이 x.9초에 되면 1.5초 뒤도 같은 초로 잘려 now == exp 가 됩니다.
만료 테스트는 TTL 보다 최소 1초 이상 더 기다려야 하고, 실무 코드에서 exp <= now 와 exp < now 중 무엇을 쓰는지(이 레슨의 Jwt 는 exp < now 면 만료) 도 이런 경계에서 드러납니다.