실행 예제는 java-src/auth/03_two_factor 에 있습니다. 순수 JDK 이며 1·2단계의 Jwt·MiniJson·PasswordHasher·TokenService·Http 를 그대로 가져왔습니다(TokenService 에 amr 만 추가). 30초를 기다리지 않도록 Totp.OFFSET 으로 서버·앱 시계를 함께 돌립니다.
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종 |
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글자가 됩니다. 실행 결과:
Base32(secret) = GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ → decode 일치: truepublic 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 벡터로 확인한 결과입니다.
시각(초) 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 일치 3531306자리는 8자리의 뒤 6자리입니다. 같은 bin 을 10^6 으로 나눈 나머지이기 때문입니다.
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단계 서명 비교와 같은 이유(타이밍 공격) 입니다. 시계를 돌려 가며 실행한 결과입니다.
현재 step 59631060 의 코드 502737 (남은 시간 3초)
같은 시각 검증 → step 59631060
이미 사용(last=59631060) 후 재검증 → -1 (재생 거부)
서버 시계 30초 느림 → step 59631060 (윈도우 안, 허용)
서버 시계 30초 빠름 → step 59631060 (윈도우 안, 허용)
서버 시계 60초 느림 → -1 (2 스텝 차이, 거부)
OFFSET=+30 상태에서 다음 코드: 868057 ← 30초마다 완전히 다른 값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 으로 실패 카운터를 올리는 장난을 막습니다.
등록 → 확인 → 로그인 → 코드 → API → 회전까지 한 번에 돌린 출력입니다.
[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 로 열렸다는 사실이 유지됨)같은 서버에 공격을 시도한 결과입니다. 전부 거부됩니다.
(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 재등록을 강제하거나 고위험 기능을 막는 근거로 씁니다.