공공부하자개발 · 영어 학습 노트
자바
인증·로그인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정리‹ 이전다음 ›

3. 코드 예제

실행 예제는 java-src/auth/03_two_factor 에 있습니다. 순수 JDK 이며 1·2단계의 Jwt·MiniJson·PasswordHasher·TokenService·Http 를 그대로 가져왔습니다(TokenService 에 amr 만 추가). 30초를 기다리지 않도록 Totp.OFFSET 으로 서버·앱 시계를 함께 돌립니다.

bash
cd java-src/auth/03_two_factor
javac -encoding UTF-8 *.java && java -Dstdout.encoding=UTF-8 Main
# java Main --serve  → 9000 포트에 띄워 두고 curl 로 직접 실험
파일 역할
Base32.java RFC 4648 인코딩·디코딩 (패딩 없음, 공백 허용)
Totp.java 시크릿 생성, HMAC-SHA1 코드 계산, ±1 윈도우 검증 + 재생 방지, otpauth URI
UserStore.java 사용자 + 2FA 상태(pending/활성 시크릿, lastUsedStep, 백업 코드 해시, 실패·잠금)
AuthServer.java /login → /login/2fa → 토큰, /2fa/setup·confirm, /api/profile, /refresh
Main.java RFC 벡터 검증, 윈도우 시연, 통합 흐름, 공격 7종

예제 1: Base32 — 5비트씩 자른다

java
private static final String ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";

public static String encode(byte[] data) {
    StringBuilder sb = new StringBuilder();
    int buffer = 0, bits = 0;
    for (byte b : data) {
        buffer = (buffer << 8) | (b & 0xFF); bits += 8;                    // 8비트씩 넣고
        while (bits >= 5) { bits -= 5; sb.append(ALPHABET.charAt((buffer >> bits) & 31)); }   // 5비트씩 꺼낸다
    }
    if (bits > 0) sb.append(ALPHABET.charAt((buffer << (5 - bits)) & 31)); // 남은 비트는 0 으로 채움
    return sb.toString();
}

20바이트 시크릿은 160비트, 5로 나누어떨어지므로 정확히 32글자가 됩니다. 실행 결과:

text
Base32(secret) = GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ  → decode 일치: true

예제 2: TOTP 계산과 RFC 6238 테스트 벡터

java
public static String generate(byte[] secret, long step, int digits) {
    Mac mac = Mac.getInstance("HmacSHA1");
    mac.init(new SecretKeySpec(secret, "RAW"));
    byte[] h = mac.doFinal(ByteBuffer.allocate(8).putLong(step).array());          // 카운터는 8바이트 빅엔디안
    int off = h[h.length - 1] & 0x0F;                                               // 동적 절단 시작 위치
    int bin = ((h[off] & 0x7F) << 24) | ((h[off + 1] & 0xFF) << 16) | ((h[off + 2] & 0xFF) << 8) | (h[off + 3] & 0xFF);
    return String.format("%0" + digits + "d", bin % (int) Math.pow(10, digits));  // 앞자리 0 도 자릿수에 포함
}

& 0xFF 를 빼먹으면 자바의 byte 가 음수로 부호 확장되어 값이 틀어집니다. RFC 벡터로 확인한 결과입니다.

text
  시각(초)        step        8자리     기대값     6자리
  59             1           94287082  일치  287082
  1111111109     37037036    07081804  일치  081804
  1111111111     37037037    14050471  일치  050471
  1234567890     41152263    89005924  일치  005924
  2000000000     66666666    69279037  일치  279037
  20000000000    666666666   65353130  일치  353130

6자리는 8자리의 뒤 6자리입니다. 같은 bin 을 10^6 으로 나눈 나머지이기 때문입니다.

예제 3: 윈도우와 재생 방지

java
public static long verify(byte[] secret, String code, long lastUsedStep) {
    long cur = step(now());
    for (long d = -WINDOW; d <= WINDOW; d++) {
        long s = cur + d;
        if (s > lastUsedStep && MessageDigest.isEqual(generate(secret, s).getBytes(US_ASCII), code.getBytes(US_ASCII))) return s;
    }
    return -1;   // 일치하는 스텝을 돌려주고, 호출자가 그것을 lastUsedStep 으로 저장한다
}

비교에 MessageDigest.isEqual 을 쓰는 것은 1단계 서명 비교와 같은 이유(타이밍 공격) 입니다. 시계를 돌려 가며 실행한 결과입니다.

text
현재 step 59631060 의 코드 502737 (남은 시간 3초)
  같은 시각 검증           → step 59631060
  이미 사용(last=59631060) 후 재검증 → -1  (재생 거부)
  서버 시계 30초 느림      → step 59631060  (윈도우 안, 허용)
  서버 시계 30초 빠름      → step 59631060  (윈도우 안, 허용)
  서버 시계 60초 느림      → -1  (2 스텝 차이, 거부)
  OFFSET=+30 상태에서 다음 코드: 868057  ← 30초마다 완전히 다른 값

예제 4: 통합 로그인 — 비밀번호 핸들러와 코드 핸들러

java
private void login(HttpExchange ex) throws IOException {
    Map<String, String> b = Http.body(ex);
    UserStore.User u = users.login(b.get("username"), b.get("password"));            // 1단계
    if (u == null) { Http.json(ex, 401, Map.of("error", "invalid_credentials")); return; }
    if (!u.mfaEnabled()) { issue(ex, u, List.of("pwd")); return; }                    // 2FA 안 켠 사용자는 바로 토큰
    String mfaToken = Jwt.hs256(Jwt.claims(TokenService.ISSUER, u.username, MFA_TOKEN_TTL,
            Map.of("aud", MFA_AUDIENCE, "amr", List.of("pwd"), "jti", tokens.randomToken())), secret);
    Http.json(ex, 200, Map.of("mfa_required", true, "mfa_token", mfaToken, "expires_in", MFA_TOKEN_TTL));
}

private void login2fa(HttpExchange ex) throws IOException {
    Map<String, String> b = Http.body(ex);
    Map<String, Object> c = Jwt.verifyHs256(b.get("mfa_token"), secret);              // 서명·exp
    if (!MFA_AUDIENCE.equals(c.get("aud"))) throw new Jwt.JwtException("aud 불일치: " + c.get("aud") + " 용 토큰");
    String jti = String.valueOf(c.get("jti"));
    if (usedMfaJti.contains(jti)) throw new Jwt.JwtException("이미 사용한 mfa_token");
    UserStore.User u = users.find((String) c.get("sub"));
    long now = TokenService.now();
    if (u.mfaLockedUntil > now) { Http.json(ex, 429, Map.of("error", "too_many_attempts", "retry_after", u.mfaLockedUntil - now)); return; }

    String amr2;
    if (b.containsKey("backup_code")) {
        if (!users.consumeBackupCode(u, b.get("backup_code"))) { fail(ex, u); return; }
        amr2 = "backup";
    } else {
        long step = Totp.verify(u.totpSecret, b.get("code"), u.lastUsedStep);      // 3단계
        if (step < 0) { fail(ex, u); return; }
        u.lastUsedStep = step;                                                       // 이 코드는 이제 못 쓴다
        amr2 = "otp";
    }
    u.mfaFailures = 0;
    usedMfaJti.add(jti);                                                             // mfa_token 1회용
    issue(ex, u, List.of("pwd", amr2));                                              // 2단계: Access 본문 + Refresh 쿠키
}

검사 순서에 주목하세요. 토큰 자체(서명·exp·aud·1회성) → 잠금 여부 → 코드 순입니다. 잠금을 코드 검사보다 먼저 두어야 잠긴 동안 맞는 코드도 거부되고, 토큰 검사를 맨 앞에 두어야 남의 mfa_token 으로 실패 카운터를 올리는 장난을 막습니다.

예제 5: 전체 흐름과 공격 7종

등록 → 확인 → 로그인 → 코드 → API → 회전까지 한 번에 돌린 출력입니다.

text
[2FA 미등록] /login → 200 amr=[pwd] (바로 토큰)
[등록] /2fa/setup → 200
  secret      : EOHSVSDW33YMRWDX7DUVUWEGAPSHX3RD
  otpauth URI : otpauth://totp/auth-lesson:kim?secret=EOHSVSDW33YMRWDX7DUVUWEGAPSHX3RD&issuer=auth-lesson&algorithm=SHA1&digits=6&period=30
  backup codes: [dr5z-o4il, iot6-udwm, 4hww-7uqy, ctqy-f3n3, bupw-7o4r, do4j-mz6p, 4i42-2yog, yjo6-6pjd, vt2v-jmt6, taw6-l7tp]
[확인 전 로그인] /login → 200 amr=[pwd] (pending 상태는 로그인에 영향 없음, 아직 비밀번호만으로 통과)
[확인] /2fa/confirm code=659613 → 200 {enabled=true, backup_codes_left=10}
[비밀번호] /login → 200 mfa_required=true expires_in=180 (토큰 없음, mfa_token 만)
  mfa_token payload: {iss=auth-server, sub=kim, iat=1788931828, exp=1788932008, aud=mfa, jti=8H-HfMjIj9Ac…, amr=[pwd]}
[코드] /login/2fa code=884210 → 200 amr=[pwd, otp] refresh 쿠키=true
[API] /api/profile → 200 {amr=[pwd, otp], sub=kim, roles=[USER]}
[회전] /refresh → 200 새 Access 의 amr=[pwd, otp] (세션이 2FA 로 열렸다는 사실이 유지됨)

같은 서버에 공격을 시도한 결과입니다. 전부 거부됩니다.

text
(a) 틀린 코드                → 401 {attempts_left=4, error=invalid_code}
(b) 방금 쓴 코드 재사용      → 401 {attempts_left=3, error=invalid_code}  ← 윈도우 안이라 값은 맞지만 lastUsedStep 이 막는다
(c) mfa_token 으로 API 호출  → 401 {error_description=aud 불일치: mfa 용 토큰, error=invalid_token}
(d) 성공에 쓴 mfa_token 재사용 → 401 {error_description=이미 사용한 mfa_token, error=invalid_token}
(e) 백업 코드 dr5z-o4il 로 로그인 → 200 amr=[pwd, backup]
    같은 백업 코드 재사용    → 401 {attempts_left=4, error=invalid_code}  ← 1회용
(f) mfa_token 만료(1초) 후    → 401 {error_description=만료됨 (exp=1788931830, now=1788931831), error=invalid_token}
(g) 틀린 코드 5회 누적        → 429 {retry_after=300, error=too_many_attempts}
    잠긴 동안 맞는 코드       → 429 {retry_after=300, error=too_many_attempts}  ← 6자리 100만 가지, 무제한 시도는 막아야 한다

(e) 의 amr 이 backup 인 점을 보세요. 백업 코드로 연 세션은 "휴대폰 없이 들어왔다" 는 뜻이므로, 실무에서는 이 세션에 2FA 재등록을 강제하거나 고위험 기능을 막는 근거로 씁니다.

예제 직접 실행

아래 폴더를 JDK 21 로 컴파일하고 실행합니다.

cd java-src\auth\03_two_factor
javac -encoding UTF-8 *.java && java Main
코드 예제
  • 예제 1: Base32 — 5비트씩 자른다
  • 예제 2: TOTP 계산과 RFC 6238 테스트 벡터
  • 예제 3: 윈도우와 재생 방지
  • 예제 4: 통합 로그인 — 비밀번호 핸들러와 코드 핸들러
  • 예제 5: 전체 흐름과 공격 7종
이전 섹션2 핵심 원리3 / 7다음 섹션4 응용 변형 예제