전달 검증하기
`OpenEmail::verifyWebhookSignature`: 재전송 허용 창을 둔 상수 시간 비교, 그리고 파싱된 이벤트 반환.
요청 핸들러에서
웹훅 URL은 공개되어 있습니다. 인터넷에 있는 누구든 형태만 맞는 JSON을 그 주소로 POST할 수 있으므로, 서명을 확인하지 않고 이벤트의 type을 읽는 핸들러는 누구에게나 열린 쓰기 API입니다.
use OpenEmail\Exception\WebhookSignatureException;use OpenEmail\OpenEmail; $payload = (string) file_get_contents('php://input'); try { $event = OpenEmail::verifyWebhookSignature( $payload, $_SERVER, (string) getenv('OPENEMAIL_WEBHOOK_SECRET'), toleranceSeconds: 300, );} catch (WebhookSignatureException) { http_response_code(400); return;} error_log($event['type'] . ' ' . $event['id']);http_response_code(204);원시 본문을 문자열로 전달하세요. 디코딩한 뒤 다시 인코딩하면 키 순서와 공백이 바뀌어 서명이 맞지 않게 되므로, 페이로드의 타입이 string으로 지정되어 있습니다: json_decode()의 결과나 프레임워크가 파싱한 입력 같은 배열은 검사되지 않고 TypeError가 됩니다. 헤더는 getallheaders()가 반환하는 것처럼 헤더 이름의 대소문자를 가리지 않는 배열, 헤더가 HTTP_X_OPENEMAIL_SIGNATURE로 도착하는 $_SERVER, Symfony나 Laravel의 HeaderBag 같은 모든 iterable, 또는 PSR-7 요청일 수 있습니다. 값이 배열이면 첫 번째 요소를 읽습니다.
직접 구현한 검사에서 보통 빠지는 두 가지를 이 메서드가 처리합니다: hash_equals()로 MAC을 상수 시간에 비교하므로 타이밍으로 올바른 접두사를 알아낼 수 없고, toleranceSeconds:(따로 지정하지 않으면 5분)보다 앞뒤로 벗어난 오래된 전달을 거부하므로 가로챈 요청을 영원히 재전송할 수 없습니다. toleranceSeconds: 0으로 두면 재전송 검사가 꺼집니다. 두 결함 모두 조용합니다. 둘 중 하나를 안고 있는 핸들러도 여러분이 떠올릴 만한 테스트는 전부 통과합니다.
실패하면 언제나 OpenEmail\Exception\WebhookSignatureException을 던집니다: X-OpenEmail-Signature 헤더가 없거나, t=<seconds>,v1=<hex> 형식이 아니거나, 타임스탬프가 허용 창을 벗어났거나, 서명이 맞지 않거나, 서명된 본문이 JSON 객체가 아닌 경우입니다. 성공하면 본문을 id, type, createdAt, data를 가진 camelCase 키의 배열로 디코딩해 반환하므로, 두 번째 json_decode()를 잘못할 일이 없습니다.
빈 시크릿은 대신 OpenEmail\Exception\InvalidArgumentException을 던집니다. 잘못된 전달이 아니라 설정의 실수이기 때문이며, getenv() 앞의 (string)은 설정되지 않은 변수를 정확히 그런 빈 값으로 바꿉니다. WebhookSignatureException만 잡아서 400으로 응답하세요. 그러면 시크릿이 없을 때 스크립트는 잡히지 않은 예외로 끝나고, 프로덕션의 PHP는 이를 500으로 응답하므로, 모든 이벤트가 위조로 거절되는 대신 문제를 고친 뒤 전달이 다시 시도됩니다.
검사는 모든 PHP 빌드에 있는 hash_hmac()과 hash_equals()로 동작하므로 확장 모듈이 필요 없습니다. 예제는 exit을 호출하지 않고 return하므로, 같은 코드가 함수나 프런트 컨트롤러 안에서도 동작합니다.
PSR-7 앱에서
Slim과 Mezzio가 건네주는 것 같은 PSR-7 요청이라면, 요청 자체를 헤더로, 그 본문을 문자열로 전달하세요.
use OpenEmail\Exception\WebhookSignatureException;use OpenEmail\OpenEmail;use Psr\Http\Message\ResponseFactoryInterface;use Psr\Http\Message\ResponseInterface;use Psr\Http\Message\ServerRequestInterface; function receiveOpenEmail(ServerRequestInterface $request, ResponseFactoryInterface $responses): ResponseInterface{ try { $event = OpenEmail::verifyWebhookSignature( (string) $request->getBody(), $request, (string) getenv('OPENEMAIL_WEBHOOK_SECRET'), ); } catch (WebhookSignatureException) { return $responses->createResponse(400); } error_log($event['type'] . ' ' . $event['id']); return $responses->createResponse(204);}빨리 응답하세요: 5초 안에 응답을 받지 못한 전달은 실패로 간주되어 나중에 다시 전송되므로, 처리는 큐에 넘기고 2xx로 응답하세요. 큐로 넘기는 부분까지 포함한 Laravel과 Symfony 컨트롤러는 프레임워크 페이지에 있습니다.
OpenEmail\Constants\WebhookSignatureHeaders는 전달이 담고 오는 세 헤더를 정의합니다: X-OpenEmail-Signature, 이벤트의 유형을 담은 X-OpenEmail-Event, 그리고 본문의 id와 같은 id를 담은 X-OpenEmail-Delivery입니다. 이 id는 이벤트의 재시도와 재전송 때마다 같으므로, 이미 처리한 이벤트를 건너뛸 때 저장해야 할 값이 이것입니다.
시크릿을 교체하는 동안
webhooks->rotateSecret에는 겹침 구간이 없습니다: 전달은 호출이 반환되는 순간부터 새 시크릿으로 서명됩니다. 먼저 두 시크릿을 모두 받아들이는 수신 측을 배포하고, 교체한 뒤, 새 시크릿을 수신 측이 읽는 곳에 저장하고, 그다음 예전 시크릿을 빼세요.
use OpenEmail\Exception\WebhookSignatureException;use OpenEmail\OpenEmail; function verifyDelivery(string $payload, mixed $headers): array{ $next = getenv('OPENEMAIL_WEBHOOK_SECRET_NEXT'); try { return OpenEmail::verifyWebhookSignature($payload, $headers, (string) getenv('OPENEMAIL_WEBHOOK_SECRET')); } catch (WebhookSignatureException $error) { if (!is_string($next) || $next === '') { throw $error; } return OpenEmail::verifyWebhookSignature($payload, $headers, $next); }}webhooks->test는 서명된 합성 이벤트를 보내므로, 교체한 뒤 호출해 예전 시크릿을 빼기 전에 새 시크릿으로 검증이 통과하는지 확인하세요.