[BE-47] 웹훅 HMAC 검증 기반 · nonce · 수신 이력 (FR-72)

작업 내용 (설계 의도)

근거 TDD: 20260808-지원관리-확장-tdd.md — “방안 2 웹훅 신뢰”, “webhook 컨텍스트 인터페이스 시그니처”

변경 사항

FR-89(연락 이벤트 수신)는 3단계지만, 검증 기반은 1단계에 세웁니다 — 터널이 열리는 시점(1-10)에 웹훅 경로가 인증 없이 공개되므로 서명 검증이 그때 이미 존재해야 합니다.

  1. Stripe 서명 규약을 그대로 채택합니다 — X-Recruitment-Signature: t={epochSeconds},v1={hex}, 서명 대상은 "{timestamp}.{rawBody}", 허용 오차 ±300초(FR-72·NFR-15와 수치 일치).
  2. 서명 대상은 UTF-8 raw body 문자열 그대로입니다. 공백·키 순서를 정규화하지 않아 발신·수신 측 직렬화 차이를 원천 차단합니다. 컨트롤러가 @RequestBody rawBody: String으로 받는 전제를 계약으로 남깁니다(BE-71이 사용).
  3. 검증은 도메인 로직입니다 — 허용 오차·nonce 보존 기간이 요구사항 수치이므로 WebhookVerificationDomainService에 캡슐화합니다. 서블릿 필터에 두면 body 되감기·Repository 호출이 레이어 위반을 부릅니다.
  4. 비교는 상수 시간(MessageDigest.isEqual) — 타이밍 공격 방어.
  5. nonce는 삽입이 곧 판정입니다 — webhook_request_nonces의 유니크 제약 위반을 재사용으로 흡수합니다(check-then-act 아님). 24시간 후 WebhookNonceCleanupScheduler(cron 0 20 3 * * *)가 삭제합니다.
  6. 수신 이력(webhook_receipt_logs)에 성공·실패를 모두 남깁니다 — PRD Operations “수신 건수·서명 검증 실패 건수·사유”를 만족합니다. 실패 사유는 응답 본문에 노출하지 않습니다(공격자에게 힌트 금지) — 이력에만 남깁니다.
  7. HMAC 시크릿은 WebhookSecretGateway(domain interface) 뒤로 숨기고 환경 변수로 주입합니다.
  8. 허용 오차·nonce 보존 기간은 WebhookPolicyGateway(domain interface) 뒤로 숨긴 정책값입니다 — 하드코딩 상수가 아니라 recruitment.webhook.timestamp-tolerance-seconds·nonce-retention-hours를 실제로 읽습니다(PR #100 리뷰 p2 반영).

이 티켓은 엔드포인트를 만들지 않습니다 — 검증 컴포넌트와 nonce 정리 스케줄러만 만듭니다. 실제 수신 API는 BE-71(3단계)입니다.

BE-71 계약 (PR #100 리뷰 p1·p2 반영 — 이 서비스를 호출할 때 지켜야 하는 것)

  • WebhookVerificationDomainService.verify()는 트랜잭션 경계를 스스로 열지 않습니다 — 호출하는 UseCase(예: ReceiveContactEventUseCase)가 엽니다.

  • 그 UseCase는 @Transactional(noRollbackFor = [WebhookVerificationFailedException::class])를 선언해야 합니다. reject()가 실패 이력을 저장한 직후 예외를 던지므로, 기본 롤백 규칙(RuntimeException → 전체 롤백)을 그대로 두면 방금 저장한 실패 이력까지 사라집니다(LoginUseCase.kt·BE-46과 동일 결함 패턴). 이 계약은 WebhookVerificationDomainServiceTransactionContractTest가 실 MySQL로 고정합니다.

  • putIfAbsent를 REQUIRES_NEW로 감싸지 마세요. WebhookNonceRepositoryImpl.putIfAbsentJdbcTemplate 직접 INSERT로 구현돼 있습니다(PR #100 재리뷰 p1 반영). 격리가 불필요하고, 감싸면 오히려 결함이 생깁니다.

    근거
    불필요한 이유JDBC 직접 INSERT는 Hibernate Session을 전혀 거치지 않아 flush 시점 rollback-only 마킹이 애초에 발생하지 않습니다. MySQL/InnoDB는 (PostgreSQL과 달리) 한 문장의 유니크 위반이 트랜잭션 전체를 무효화하지 않으므로, 위반 직후의 수신 이력 저장이 같은 트랜잭션에서 그대로 성공합니다. JDBC INSERT는 주변 트랜잭션에 참여하며 스스로 격리하지 않습니다(실 MySQL로 고정 확인).
    감싸면 안 되는 이유nonce는 삽입 자체가 판정입니다. REQUIRES_NEW로 감싸면 nonce가 별도 트랜잭션에서 먼저 커밋되어, 외부 트랜잭션이 다른 사유로 롤백된 뒤의 정상 재시도가 NONCE_REUSED로 거부됩니다. 이번에 없앤 결함(이력 유실)과 같은 계열이 반대 방향으로 재발합니다.

    이전 판 티켓의 “위험이 있다면 호출부 UseCase가 REQUIRES_NEW로 격리해야 합니다”는 JPA saveAndFlush 구현 시절의 안내이며 무효입니다. 상세는 WebhookNonceRepositoryImpl KDoc 참고.

  • 호출부 UseCase가 지켜야 할 것은 위의 noRollbackFor 하나뿐입니다.

롤백: 신규 테이블·스케줄러만 추가하므로 코드 되돌리기로 안전합니다.

의존

  • BE-45 (예외 클래스·설정)

다이어그램

처리 흐름

sequenceDiagram
    participant Caller as 발신 측
    participant C as WebhookController(BE-71)
    participant V as WebhookVerificationDomainService
    participant N as WebhookNonceRepository
    participant L as WebhookReceiptLogRepository
    Caller->>C: POST + 서명 헤더 + nonce
    C->>V: verify(rawBody, signatureHeader, nonce)
    V->>V: 헤더 파싱 → 시간 오차 ±300초 확인
    V->>V: HMAC-SHA256("{t}.{body}") 상수 시간 비교
    V->>N: putIfAbsent(nonce)
    alt 검증 실패
        V->>L: save(사유)
        V-->>C: WebhookVerificationFailedException
    else 성공
        V->>L: save(OK)
        V-->>C: Unit
    end

클래스 의존

flowchart LR
    subgraph Presentation["presentation/webhook"]
        Sched[WebhookNonceCleanupScheduler]
    end
    subgraph Application["application/webhook"]
        Cleanup[CleanupWebhookNoncesUseCase]
    end
    subgraph Domain["domain/webhook"]
        DS[WebhookVerificationDomainService]
        Sig[WebhookSignature]
        NonceRepo[WebhookNonceRepository]
        LogRepo[WebhookReceiptLogRepository]
        Secret[WebhookSecretGateway]
    end
    Sched --> Cleanup
    Cleanup --> DS
    DS --> Sig
    DS --> NonceRepo
    DS --> LogRepo
    DS --> Secret

테스트 케이스

  • 유효한 서명·타임스탬프·nonce면 검증을 통과하고 이력에 OK가 남는다
  • body를 1바이트 변조한 뒤 원 서명을 보내면 실패하고 이력에 SIGNATURE_MISMATCH가 남는다
  • 타임스탬프가 6분 전이면 실패하고 이력에 TIMESTAMP_OUT_OF_RANGE가 남는다
  • 타임스탬프가 정확히 300초 전이면 통과한다 (경계값)
  • 타임스탬프가 301초 전이면 실패한다 (경계값)
  • 미래 타임스탬프도 ±300초 안이면 통과한다
  • 같은 nonce를 두 번 쓰면 두 번째가 실패하고 이력에 NONCE_REUSED가 남는다
  • 헤더 형식이 t=..,v1=..이 아니면 실패하고 이력에 MALFORMED_HEADER가 남는다
  • 판정 순서 고정: 서명이 불일치하면 nonce를 소비하지 않는다 (같은 nonce로 올바른 서명을 보내면 통과한다)
  • 판정 순서 고정: 타임스탬프가 범위 밖이면 nonce를 소비하지 않는다 (범위 안 재전송 시 같은 nonce가 통과한다)
  • 판정 순서가 헤더 형식 → 시각 오차 → 서명 → nonce 재사용으로 고정되어 있다 (서명 검증이 nonce 소비보다 앞)
  • 검증 실패 예외의 메시지에 실패 사유 상세가 담기지 않는다 (정보 노출 방지)
  • 24시간이 지난 nonce는 정리 배치가 삭제하고, 같은 nonce를 다시 쓸 수 있다
  • nonce 정리 배치를 두 번 실행해도 결과가 같다 (멱등)
  • 서명 비교에 equals 대신 상수 시간 비교를 쓴다