POST /2fa/backup-codes 를 추가해 로그인된 사용자가 백업 코드를 재발급받을 수 있게 하세요. 조건: (1) 재발급하면 이전 코드는 전부 무효, (2) 재발급 요청 본문에 현재 TOTP 코드를 요구해 세션 탈취자가 백업 코드를 뽑아가지 못하게, (3) /login/2fa 성공 응답에 backup_codes_left 를 넣어 2개 이하면 클라이언트가 경고할 수 있게.
private void backupCodes(HttpExchange ex) throws IOException {
UserStore.User u = requireUser(ex);
if (!u.mfaEnabled()) { Http.json(ex, 400, Map.of("error", "mfa_not_enabled")); return; }
long step = Totp.verify(u.totpSecret, String.valueOf(Http.body(ex).get("code")), u.lastUsedStep); // (2) 재인증
if (step < 0) { fail(ex, u); return; } // 실패 카운터 공유
u.lastUsedStep = step;
Http.json(ex, 200, Map.of("backup_codes", users.newBackupCodes(u))); // (1) newBackupCodes 가 clear() 로 이전 것 무효화
}
// /login/2fa 성공 시 issue() 대신
Http.json(ex, 200, Map.of("access_token", …, "amr", amr, "backup_codes_left", users.backupCodesLeft(u))); // (3)재발급에 TOTP 코드를 요구하는 이유: Access 토큰만 탈취한 공격자가 백업 코드를 발급받아 두면, 나중에 비밀번호만 알아내도 2FA 를 우회할 수 있습니다. "2FA 설정을 바꾸는 작업은 2FA 로 재인증" 이 원칙입니다.
변형 2 의 "이 기기 기억" 을 구현하되, 다음 조건을 지키세요. (1) 기기 토큰은 사용자당 최대 5개, 오래된 것부터 밀어냄, (2) 비밀번호가 바뀌면 전부 무효, (3) 기억된 기기로 로그인해도 amr 에는 otp 대신 remembered 를 넣어 step-up 규칙(변형 3) 이 구분할 수 있게.
// UserStore.User
final LinkedHashMap<String, Long> trustedDevices = new LinkedHashMap<>(); // hash → expiresAt, 삽입 순서 유지
void trustDevice(String hash, long expiresAt) {
trustedDevices.put(hash, expiresAt);
while (trustedDevices.size() > 5) trustedDevices.remove(trustedDevices.keySet().iterator().next()); // (1) 가장 오래된 것 제거
}
void changePassword(String newHash) { passwordHash = newHash; trustedDevices.clear(); } // (2)
// AuthServer.login, 비밀번호 통과 후
String d = Http.cookies(ex).get("mfa_device");
if (u.mfaEnabled() && d != null && u.trustedDevices.getOrDefault(UserStore.sha256(d), 0L) > TokenService.now()) {
issue(ex, u, List.of("pwd", "remembered")); return; // (3)
}변형 3 의 step-up 검사는 amr.contains("otp") 이므로 remembered 세션은 정산 승인 때 코드를 다시 넣게 됩니다. "편의는 로그인까지, 위험한 순간은 진짜 요소로" 가 이 설계의 요지입니다. passwordHash 를 final 에서 풀어야 하는 것도 잊지 마세요.
하드웨어 토큰(버튼을 누를 때마다 카운터가 1 오르는 HOTP 기기) 을 지원하려면 시간 대신 서버가 기억하는 카운터로 검증해야 합니다. 사용자가 버튼을 몇 번 헛눌러 기기 카운터가 서버보다 앞서 있을 수 있습니다. verifyHotp(secret, code, serverCounter) 를 구현하세요. 조건은 셋입니다.
public static long verifyHotp(byte[] secret, String code, long serverCounter) {
for (long c = serverCounter; c < serverCounter + 10; c++) { // (1) 앞으로만, (3) 뒤는 재생이므로 금지
if (MessageDigest.isEqual(generate(secret, c).getBytes(US_ASCII), code.getBytes(US_ASCII))) return c + 1; // (2)
}
return -1;
}
// 호출: long next = verifyHotp(u.hotpSecret, code, u.hotpCounter); if (next > 0) u.hotpCounter = next;generate 는 TOTP 와 완전히 같은 함수입니다. TOTP 가 HOTP 의 특수한 경우라는 것이 코드로 드러납니다. look-ahead 를 10 으로 두면 유효 코드가 10개라 실패 횟수 제한이 더 중요해지고, 기기가 10번 넘게 헛눌렸으면 관리자가 재동기화(연속 코드 2개 입력) 해야 합니다. RFC 4226 7.4 가 이 절차입니다.