[BE-47] 웹훅 HMAC 검증 기반 · nonce · 수신 이력 (FR-72)
작업 내용 (설계 의도)
근거 TDD: 20260808-지원관리-확장-tdd.md — “방안 2 웹훅 신뢰”, “webhook 컨텍스트 인터페이스 시그니처”
변경 사항
FR-89(연락 이벤트 수신)는 3단계지만, 검증 기반은 1단계에 세웁니다 — 터널이 열리는 시점(1-10)에 웹훅 경로가 인증 없이 공개되므로 서명 검증이 그때 이미 존재해야 합니다.
- Stripe 서명 규약을 그대로 채택합니다 —
X-Recruitment-Signature: t={epochSeconds},v1={hex}, 서명 대상은"{timestamp}.{rawBody}", 허용 오차 ±300초(FR-72·NFR-15와 수치 일치). - 서명 대상은 UTF-8 raw body 문자열 그대로입니다. 공백·키 순서를 정규화하지 않아 발신·수신 측 직렬화 차이를 원천 차단합니다. 컨트롤러가
@RequestBody rawBody: String으로 받는 전제를 계약으로 남깁니다(BE-71이 사용). - 검증은 도메인 로직입니다 — 허용 오차·nonce 보존 기간이 요구사항 수치이므로
WebhookVerificationDomainService에 캡슐화합니다. 서블릿 필터에 두면 body 되감기·Repository 호출이 레이어 위반을 부릅니다. - 비교는 상수 시간(
MessageDigest.isEqual) — 타이밍 공격 방어. - nonce는 삽입이 곧 판정입니다 —
webhook_request_nonces의 유니크 제약 위반을 재사용으로 흡수합니다(check-then-act 아님). 24시간 후WebhookNonceCleanupScheduler(cron0 20 3 * * *)가 삭제합니다. - 수신 이력(
webhook_receipt_logs)에 성공·실패를 모두 남깁니다 — PRD Operations “수신 건수·서명 검증 실패 건수·사유”를 만족합니다. 실패 사유는 응답 본문에 노출하지 않습니다(공격자에게 힌트 금지) — 이력에만 남깁니다. - HMAC 시크릿은
WebhookSecretGateway(domain interface) 뒤로 숨기고 환경 변수로 주입합니다. - 허용 오차·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.putIfAbsent는JdbcTemplate직접 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구현 시절의 안내이며 무효입니다. 상세는WebhookNonceRepositoryImplKDoc 참고. -
호출부 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대신 상수 시간 비교를 쓴다