
이 게시글은 Spring Security와 WebAuthn4J 라이브러리를 활용하여 Passkeys(암호 없는 인증)를 구현한 내용을 정리합니다.
Challenge-Response 및 Sign Counter 검증 등 WebAuthn 표준의 핵심 보안 메커니즘을 충실히 반영한 엔터프라이즈 레벨의 인증 시스템 구조를 제시합니다.
| 메커니즘 | 설명 | 구현 내역 (코드 참고) | 중요도 |
|---|---|---|---|
| Challenge-Response | 서버가 임의의 값(Challenge)을 생성하여 클라이언트에 전달하고, 클라이언트는 이 값에 서명하여 응답. 리플레이 공격 방지를 위해 일회용으로 사용 후 폐기. | ChallengeService에서 세션별 Challenge 생성/저장/폐기. WebAuthnService의 verify() 메소드에서 검증. | 매우 높음 |
| Sign Count 검증 | 인증기가 서명할 때마다 증가시키는 카운터 값을 서버가 저장하고, 매 인증 시 이전 값보다 큰지 확인. | WebAuthnCredential 엔티티에 counter 저장. WebAuthnService.authenticate()에서 DB 값과 응답의 카운터 값을 비교 후 업데이트. | 높음 |
| RP ID & Origin | Relying Party ID와 Origin을 검증하여 피싱 및 교차 출처 공격 방지. | WebAuthnService에서 $ {webauthn.rp.id} 및 $ {webauthn.allowed.origins} 값을 사용해 ServerProperty 객체 생성 및 검증에 활용. | 높음 |
| COSE Key 저장 | Passkey 등록 시 생성된 공개키를 COSE 형식으로 저장. 개인키는 절대 서버에 저장되지 않음. | WebAuthnService.registerCredential()에서 공개키를 추출하여 WebAuthnCredential.publicKeyCose에 저장. | 높음 |
Passkey 인증의 핵심 로직을 담당하며, WebAuthn4J 라이브러리의 검증 기능을 사용해 안전성을 확보합니다.
등록 과정의 첫 단계로, 클라이언트(브라우저)에 전달하여 새로운 Passkey 생성을 유도합니다.
public PublicKeyCredentialCreationOptions createRegistrationOptions(User user, HttpSession session) {
// 1. Challenge 생성: 리플레이 방지용 일회용 값
Challenge challenge = challengeService.generateAndStoreChallenge(session.getId(), "registration");
// 2. Relying Party 및 User 정보 설정
PublicKeyCredentialRpEntity rpEntity = new PublicKeyCredentialRpEntity(rpId, rpName);
PublicKeyCredentialUserEntity userEntity = new PublicKeyCredentialUserEntity(
Base64UrlUtil.decode(user.getUserHandle()), // FIDO User ID
user.getUsername(),
user.getDisplayName()
);
// 3. Authenticator Selection Criteria 설정: Passkey 사용 권장
AuthenticatorSelectionCriteria authenticatorSelection = new AuthenticatorSelectionCriteria(
AuthenticatorAttachment.PLATFORM, // 플랫폼 인증기 (e.g., Face ID, Windows Hello)
true, // Require Resident Key (rk=true, Discoverable Credential) -> Passkey의 필수 요소
UserVerificationRequirement.PREFERRED
);
// 최종 옵션 반환
return new PublicKeyCredentialCreationOptions(
rpEntity, userEntity, challenge, pubKeyCredParams, /* ... 생략 ... */
authenticatorSelection, AttestationConveyancePreference.NONE, null
);
}
클라이언트로부터 받은 응답을 WebAuthn 표준에 따라 엄격하게 검증하고, 공개키를 DB에 저장합니다.
@Transactional
public void registerCredential(/* ... */, HttpSession session) {
// 1. Challenge 검증 및 폐기 (일회용 보장)
Challenge challenge = challengeService.getChallenge(sessionId, "registration");
if (challenge == null) { throw new RuntimeException("Challenge not found or expired"); }
challengeService.removeChallenge(sessionId, "registration"); // 사용 즉시 폐기
// 2. Server Property 생성 (RP ID, Origin, Challenge 포함)
ServerProperty serverProperty = new ServerProperty(new Origin(allowedOrigins), rpId, challenge, null, false);
// 3. WebAuthnRegistrationContextVerifier를 통한 등록 검증
com.webauthn4j.verifier.WebAuthnRegistrationContext registrationContext = new com.webauthn4j.verifier.WebAuthnRegistrationContext(/* ... */);
registrationVerifier.verify(registrationContext); // WebAuthn4J가 모든 필수 보안 검증 수행
// 4. 검증 완료 후 Credential 정보 추출 및 저장
// * 공개키 추출 (publicKeyCose)
// * 초기 카운터는 0L로 저장
WebAuthnCredential credential = new WebAuthnCredential(/* ... */);
credentialRepository.save(credential);
}
클라이언트의 서명(Assertion)을 검증하고, 인증 카운터(Sign Count)를 확인하여 리플레이 공격을 방지합니다.
@Transactional
public User authenticate(/* ... */, HttpSession session) {
// 1. Credential 조회 및 공개키 추출 (DB에서 조회)
WebAuthnCredential credential = credentialRepository.findByCredentialId(credentialId)
.orElseThrow(() -> new RuntimeException("Credential not found"));
byte[] publicKeyCoseBytes = Base64UrlUtil.decode(credential.getPublicKeyCose());
// 2. Challenge 검증 및 폐기
Challenge challenge = challengeService.getChallenge(sessionId, "authentication");
challengeService.removeChallenge(sessionId, "authentication"); // 사용 즉시 폐기
// 3. WebAuthnAuthenticationContextVerifier를 통한 인증 검증
RegisteredCredential registeredCredential = new RegisteredCredential(
Base64UrlUtil.decode(credentialId),
publicKeyCoseBytes,
credential.getCounter(), // DB에 저장된 이전 카운터 값 사용
null
);
com.webauthn4j.verifier.WebAuthnAuthenticationContext authenticationContext = new com.webauthn4j.verifier.WebAuthnAuthenticationContext(/* ... */);
authenticationVerifier.verify(authenticationContext); // 서명 유효성 등 검증
// 4. **Sign Count (인증 카운터) 검증 및 업데이트**
AuthenticatorData<?> authenticatorData = /* ... 파싱 ... */;
long newCounter = authenticatorData.getSignCount();
if (newCounter <= credential.getCounter()) {
throw new RuntimeException("Counter value is invalid (Possible replay attack)");
}
credential.setCounter(newCounter);
credentialRepository.save(credential);
// 5. Spring Security Context에 인증 정보 설정
return credential.getUser();
}
제시된 아키텍처 다이어그램은 시스템 구성 요소와 데이터 흐름을 명확하게 보여줍니다. 특히 ChallengeService가 중앙 캐시 또는 세션 스토어 역할을 하여 Challenge의 일회용성을 보장하는 구조가 인상적입니다.
보완 포인트:
ChallengeService는 인메모리ConcurrentHashMap을 사용하고 있으나, 분산 환경에서는 Redis 등의 외부 캐시 시스템으로 대체되어야 세션 불일치 문제를 방지할 수 있습니다.
| Step | Subject | Action | Key Security Feature |
|---|---|---|---|
| 1 | Client → Server | 등록 옵션 요청 (/options) | WebAuthnService에서 Challenge 생성/저장 |
| 2 | Server → Client | PublicKeyCredentialCreationOptions 응답 | Challenge, RP ID, User Handle 포함 |
| 3 | Client | navigator.credentials.create(options) 호출 | 생체 인식/PIN 등을 통한 Passkey (Key-Pair) 생성 |
| 4 | Client → Server | 등록 응답 전송 (/register) | Attestation Object & Client Data 포함 |
| 5 | Server | 등록 응답 검증 및 저장 | Challenge 폐기, WebAuthn4J를 통한 FIDO2 검증, 공개키 저장 |
| Step | Subject | Action | Key Security Feature |
|---|---|---|---|
| 1 | Client → Server | 인증 옵션 요청 (/options) | WebAuthnService에서 Challenge 생성/저장 |
| 2 | Server → Client | PublicKeyCredentialRequestOptions 응답 | Challenge, 허용 Credential ID 목록 포함 |
| 3 | Client | navigator.credentials.get(options) 호출 | Passkey를 사용한 Signature(서명) 생성 |
| 4 | Client → Server | 인증 응답 전송 (/authenticate) | Authenticator Data & Signature 포함 |
| 5 | Server | 인증 응답 검증 및 인증 | Challenge 폐기, WebAuthn4J를 통한 Signature 검증, Sign Count 비교 및 업데이트 |
| 6 | Server | 사용자 인증 완료 | Spring Security Context에 Authentication 객체 설정 |
Spring Boot와 WebAuthn4J를 사용하여 Passkey를 안전하게 구현하는 모범적인 사례를 보여줍니다.
특히 ChallengeService를 분리하여 보안 로직의 핵심인 Challenge 관리를 전담하게 한 점과, WebAuthnService.authenticate()에서 Sign Count를 엄격하게 검증하는 부분이 훌륭합니다.
이 구조를 기반으로 사용자 관리 및 클라이언트 UI만 보강하면 실제 서비스에 바로 적용할 수 있는 강력한 무암호 인증 시스템이 완성됩니다.
Passkeys 표준의 핵심 보안 요구사항들이 서버 로직에 안정적으로 녹아든 점이 인상적입니다.
특히 Challenge 관리 서비스를 별도로 분리하고, 인증 과정에서 Sign Count 검증을 누락 없이 구현하여 리플레이 공격과 인증기 복제 공격에 대해 방어적인 태세를 갖춘 점이 돋보입니다.
이는 단순한 PoC 수준을 넘어 실제 서비스 환경을 고려한 완성도 높은 설계입니다.
WebAuthn4J 라이브러리를 활용하여 복잡한 FIDO 검증 과정을 깔끔하게 캡슐화한 방식 역시 모범적이며, Spring Security 컨텍스트와 매끄럽게 연동하여 인증 흐름을 완성한 구조는 재사용성과 유지보수 측면에서 대단하다 생각듭니다.