비밀번호 없는 인증, Spring Boot 서버 측 검증 로직 적용하기

궁금하면 500원·2025년 11월 10일

미생의 스프링

목록 보기
44/48

Spring Security & WebAuthn4J 기반 Passkeys 인증 시스템 구현

이 게시글은 Spring SecurityWebAuthn4J 라이브러리를 활용하여 Passkeys(암호 없는 인증)를 구현한 내용을 정리합니다.
Challenge-Response 및 Sign Counter 검증 등 WebAuthn 표준의 핵심 보안 메커니즘을 충실히 반영한 엔터프라이즈 레벨의 인증 시스템 구조를 제시합니다.

1. 프로젝트 개요 및 핵심 기술

  • 목표: FIDO2/WebAuthn 표준을 준수하는 강력한 Passkeys 기반 인증 시스템 구현.
  • 프레임워크: Spring Boot 3.x, Spring Security
  • 핵심 라이브러리: WebAuthn4J (WebAuthn 데이터 구조 및 서버 측 검증 로직 처리)
  • 주요 기능:
    • 사용자별 Passkey 등록
    • Passkey를 활용한 사용자 인증
    • Challenge-Response 메커니즘을 통한 리플레이 공격 방지
    • 인증 카운터 검증을 통한 인증기 복제 공격 방어
  • 아키텍처: RESTful API 기반의 3-Tier 아키텍처

2. 핵심 보안 메커니즘 (WebAuthn)

메커니즘설명구현 내역 (코드 참고)중요도
Challenge-Response서버가 임의의 값(Challenge)을 생성하여 클라이언트에 전달하고, 클라이언트는 이 값에 서명하여 응답. 리플레이 공격 방지를 위해 일회용으로 사용 후 폐기.ChallengeService에서 세션별 Challenge 생성/저장/폐기. WebAuthnServiceverify() 메소드에서 검증.매우 높음
Sign Count 검증인증기가 서명할 때마다 증가시키는 카운터 값을 서버가 저장하고, 매 인증 시 이전 값보다 큰지 확인.WebAuthnCredential 엔티티에 counter 저장. WebAuthnService.authenticate()에서 DB 값과 응답의 카운터 값을 비교 후 업데이트.높음
RP ID & OriginRelying Party ID와 Origin을 검증하여 피싱 및 교차 출처 공격 방지.WebAuthnService에서 $ {webauthn.rp.id}$ {webauthn.allowed.origins} 값을 사용해 ServerProperty 객체 생성 및 검증에 활용.높음
COSE Key 저장Passkey 등록 시 생성된 공개키를 COSE 형식으로 저장. 개인키는 절대 서버에 저장되지 않음.WebAuthnService.registerCredential()에서 공개키를 추출하여 WebAuthnCredential.publicKeyCose에 저장.높음

3. 핵심 코드 분석

Passkey 인증의 핵심 로직을 담당하며, WebAuthn4J 라이브러리의 검증 기능을 사용해 안전성을 확보합니다.

3.1. Passkey 등록 옵션 생성

등록 과정의 첫 단계로, 클라이언트(브라우저)에 전달하여 새로운 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
    );
}

3.2. Passkey 등록 응답 검증 및 저장

클라이언트로부터 받은 응답을 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);
}

3.3. Passkey 인증 처리

클라이언트의 서명(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();
}

4. 아키텍처 및 인증 플로우

제시된 아키텍처 다이어그램은 시스템 구성 요소와 데이터 흐름을 명확하게 보여줍니다. 특히 ChallengeService중앙 캐시 또는 세션 스토어 역할을 하여 Challenge의 일회용성을 보장하는 구조가 인상적입니다.

보완 포인트: ChallengeService는 인메모리 ConcurrentHashMap을 사용하고 있으나, 분산 환경에서는 Redis 등의 외부 캐시 시스템으로 대체되어야 세션 불일치 문제를 방지할 수 있습니다.

Passkey 등록 플로우

StepSubjectActionKey Security Feature
1Client → Server등록 옵션 요청 (/options)WebAuthnService에서 Challenge 생성/저장
2Server → ClientPublicKeyCredentialCreationOptions 응답Challenge, RP ID, User Handle 포함
3Clientnavigator.credentials.create(options) 호출생체 인식/PIN 등을 통한 Passkey (Key-Pair) 생성
4Client → Server등록 응답 전송 (/register)Attestation Object & Client Data 포함
5Server등록 응답 검증 및 저장Challenge 폐기, WebAuthn4J를 통한 FIDO2 검증, 공개키 저장

Passkey 인증 플로우

StepSubjectActionKey Security Feature
1Client → Server인증 옵션 요청 (/options)WebAuthnService에서 Challenge 생성/저장
2Server → ClientPublicKeyCredentialRequestOptions 응답Challenge, 허용 Credential ID 목록 포함
3Clientnavigator.credentials.get(options) 호출Passkey를 사용한 Signature(서명) 생성
4Client → Server인증 응답 전송 (/authenticate)Authenticator Data & Signature 포함
5Server인증 응답 검증 및 인증Challenge 폐기, WebAuthn4J를 통한 Signature 검증, Sign Count 비교 및 업데이트
6Server사용자 인증 완료Spring Security ContextAuthentication 객체 설정

5. 결론

Spring Boot와 WebAuthn4J를 사용하여 Passkey를 안전하게 구현하는 모범적인 사례를 보여줍니다.
특히 ChallengeService를 분리하여 보안 로직의 핵심인 Challenge 관리를 전담하게 한 점과, WebAuthnService.authenticate()에서 Sign Count를 엄격하게 검증하는 부분이 훌륭합니다.
이 구조를 기반으로 사용자 관리 및 클라이언트 UI만 보강하면 실제 서비스에 바로 적용할 수 있는 강력한 무암호 인증 시스템이 완성됩니다.

느낀점

Passkeys 표준의 핵심 보안 요구사항들이 서버 로직에 안정적으로 녹아든 점이 인상적입니다.

특히 Challenge 관리 서비스를 별도로 분리하고, 인증 과정에서 Sign Count 검증을 누락 없이 구현하여 리플레이 공격과 인증기 복제 공격에 대해 방어적인 태세를 갖춘 점이 돋보입니다.
이는 단순한 PoC 수준을 넘어 실제 서비스 환경을 고려한 완성도 높은 설계입니다.

WebAuthn4J 라이브러리를 활용하여 복잡한 FIDO 검증 과정을 깔끔하게 캡슐화한 방식 역시 모범적이며, Spring Security 컨텍스트와 매끄럽게 연동하여 인증 흐름을 완성한 구조는 재사용성과 유지보수 측면에서 대단하다 생각듭니다.

profile
에러가 나도 괜찮아 — 그건 내가 배우고 있다는 증거야.

0개의 댓글