Skip to content

Commit

Permalink
웹훅 검증 모듈 추가 (#1)
Browse files Browse the repository at this point in the history
* 웹훅 검증 모듈 추가
* 예제 업데이트
* style: apply review
---------
Co-authored-by: Xiniha <[email protected]>
  • Loading branch information
finalchild committed Jun 28, 2024
1 parent 9eeda06 commit ea1623f
Show file tree
Hide file tree
Showing 8 changed files with 559 additions and 1 deletion.
5 changes: 5 additions & 0 deletions .changeset/wild-kings-whisper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@portone/server-sdk": minor
---

웹훅 검증 모듈 추가
33 changes: 33 additions & 0 deletions examples/express/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,44 @@ const PortOne = require("@portone/server-sdk");

const app = express();

const webhookSecret = "pzQGE83cSIRKM4/WH5QY+g==";

function rawBody(req, res, next) {
req.setEncoding("utf8");
req.rawBody = "";
req.on("data", (chunk) => {
req.rawBody += chunk;
});
req.on("end", () => {
next();
});
}

app.get("/", (_, res) => {
if (PortOne) res.end("PortOne Server SDK is available");
else res.end("PortOne Server SDK is not available");
});

app.get("/webhook", rawBody, async (req, res, next) => {
try {
try {
await PortOne.Webhook.verify(webhookSecret, req.rawBody, req.headers);
} catch (err) {
if (err instanceof PortOne.Webhook.WebhookVerificationError) {
console.log("Invalid webhook payload received:", err.message);
res.status(400).end();
return;
}
throw e;
}

console.log("Valid webhook payload received:", JSON.parse(req.rawBody));
res.status(200).end();
} catch (err) {
next(err);
}
});

app.listen(8080, () => {
console.log("Server is running on http://localhost:8080");
});
56 changes: 56 additions & 0 deletions packages/server-sdk/src/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* 포트원 SDK에서 발생하는 모든 에러의 기반 타입입니다.
*
* PortOneError를 상속하는 모든 에러는 `_tag` 필드를 가지며,
* 해당 필드의 값을 통해 손쉽게 타입 검사를 수행할 수 있습니다.
*/
export abstract class PortOneError extends Error {
/**
* 에러 타입을 구분하기 위한 필드입니다.
*
* Effect 등의 라이브러리를 사용하실 때, 해당 필드를 통해 각 타입에 대한 에러를 구분하여 처리하실 수 있습니다.
*/
abstract readonly _tag: string;

constructor(message: string, options?: ErrorOptions) {
super(message, options);
Object.setPrototypeOf(this, PortOneError.prototype);
this.name = "PortOneError";
this.stack = new Error(message).stack;
}
}

/**
* SDK에 전달한 사용자 입력이 잘못되었을 때 발생하는 에러입니다.
*
* 해당 에러는 대부분 사용자의 실수로 발생합니다.
* 에러가 발생하는 경우, 에러가 발생한 함수의 문서를 참고하여
* 문제를 수정해주시기 바랍니다.
*/
export class InvalidInputError extends PortOneError {
readonly _tag = "PortOneInvalidInputError";

constructor(message: string) {
super(message);
Object.setPrototypeOf(this, InvalidInputError.prototype);
this.name = "InvalidInputError";
}
}

/**
* SDK 내에서 알 수 없는 오류가 일어났을 때 발생하는 에러입니다.
*
* 해당 에러는 주로 포트원 SDK 혹은 서버 내부 오류로 인해 발생합니다.
* 에러가 발생하는 경우, 포트원 고객센터에 문의하시기 바랍니다.
*
* `cause` 필드에 담긴 에러를 통해 오류가 발생한 원인을 확인할 수 있습니다.
*/
export class UnknownError extends PortOneError {
readonly _tag = "PortOneUnknownError";

constructor(cause: unknown, options?: Omit<ErrorOptions, "cause">) {
super("알 수 없는 에러가 발생했습니다.", { ...options, cause });
Object.setPrototypeOf(this, UnknownError.prototype);
this.name = "UnknownError";
}
}
8 changes: 7 additions & 1 deletion packages/server-sdk/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
export type {};
export {
PortOneError,
InvalidInputError,
UnknownError,
} from "./error";

export * as Webhook from "./webhook";
28 changes: 28 additions & 0 deletions packages/server-sdk/src/utils/timingSafeEqual.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export function timingSafeEqual(
a: ArrayBufferView | ArrayBufferLike | DataView,
b: ArrayBufferView | ArrayBufferLike | DataView,
): boolean {
if (a.byteLength !== b.byteLength) return false;

const aDataView =
a instanceof DataView
? a
: ArrayBuffer.isView(a)
? new DataView(a.buffer, a.byteOffset, a.byteLength)
: new DataView(a);
const bDataView =
b instanceof DataView
? b
: ArrayBuffer.isView(b)
? new DataView(b.buffer, b.byteOffset, b.byteLength)
: new DataView(b);

const length = aDataView.byteLength;
let out = 0;
let i = -1;
while (++i < length) {
out |= aDataView.getUint8(i) ^ bDataView.getUint8(i);
}

return out === 0;
}
7 changes: 7 additions & 0 deletions packages/server-sdk/src/utils/try.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function tryCatch<T, E>(fn: () => T, onError: (e: unknown) => E): T | E {
try {
return fn();
} catch (e) {
return onError(e);
}
}
230 changes: 230 additions & 0 deletions packages/server-sdk/src/webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { InvalidInputError, PortOneError } from "./error";
import { timingSafeEqual } from "./utils/timingSafeEqual";
import { tryCatch } from "./utils/try";

const WEBHOOK_TOLERANCE_IN_SECONDS = 5 * 60; // 5분

/**
* 웹훅 검증이 실패했을 때 발생하는 에러입니다.
*
* `reason` 필드를 통해 상세한 실패 원인을 확인할 수 있습니다.
*/
export class WebhookVerificationError extends PortOneError {
readonly _tag = "WebhookVerificationError";

/**
* 웹훅 검증이 실패한 상세 사유을 나타냅니다.
*/
readonly reason: WebhookVerificationFailureReason;

/**
* 웹훅 검증 실패 사유로부터 에러 메시지를 생성합니다.
*
* @param reason 에러 메시지를 생성할 실패 사유
* @returns 에러 메시지
*/
static getMessage(reason: WebhookVerificationFailureReason): string {
switch (reason) {
case "MISSING_REQUIRED_HEADERS":
return "필수 헤더가 누락되었습니다.";
case "NO_MATCHING_SIGNATURE":
return "올바른 웹훅 시그니처를 찾을 수 없습니다.";
case "INVALID_SIGNATURE":
return "웹훅 시그니처가 유효하지 않습니다.";
case "TIMESTAMP_TOO_OLD":
return "웹훅 시그니처의 타임스탬프가 만료 기한을 초과했습니다.";
case "TIMESTAMP_TOO_NEW":
return "웹훅 시그니처의 타임스탬프가 미래 시간으로 설정되어 있습니다.";
}
}

constructor(
reason: WebhookVerificationFailureReason,
options?: ErrorOptions,
) {
super(WebhookVerificationError.getMessage(reason), options);
Object.setPrototypeOf(this, WebhookVerificationError.prototype);
this.name = "WebhookVerificationError";
this.reason = reason;
}
}

/**
* 웹훅 검증 실패 사유입니다.
*
* `WebhookVerificationError.getMessage()`에 전달하여 에러 메시지를 얻을 수 있습니다.
*/
export type WebhookVerificationFailureReason =
| "MISSING_REQUIRED_HEADERS"
| "NO_MATCHING_SIGNATURE"
| "INVALID_SIGNATURE"
| "TIMESTAMP_TOO_OLD"
| "TIMESTAMP_TOO_NEW";

/**
* 웹훅 요청에 필수적으로 포함되는 헤더들입니다.
*/
export interface WebhookUnbrandedRequiredHeaders {
"webhook-id": string;
"webhook-timestamp": string;
"webhook-signature": string;
}

/**
* 웹훅 인스턴스에서 사용할 옵션입니다.
*/
export interface WebhookOptions {
/**
* 웹훅 시크릿의 포맷입니다.
*
* - `"raw"`인 경우, `secret` 파라미터의 값을 그대로 사용합니다.
* - 지정하지 않을 경우, `secret` 파라미터의 값을 base64 문자열로 간주합니다.
*/
format?: "raw";
}

const prefix = "whsec_";

/**
* 웹훅 페이로드를 검증합니다.
*
* @param secret 웹훅 시크릿
* @param payload 웹훅 페이로드
* @param headers 웹훅 요청 시 포함된 헤더
* @returns 검증 후 디코딩된 웹훅 페이로드를 반환하는 Promise
* @throws {InvalidInputError} 입력받은 시크릿이 유효하지 않을 때 발생합니다.
* @throws {WebhookVerificationError} 웹훅 검증에 실패했을 때 발생합니다.
*/
export async function verify(
secret: string | Uint8Array,
payload: string,
headers: WebhookUnbrandedRequiredHeaders | Record<string, string>,
): Promise<void> {
const mappedHeaders: Record<string, string> = Object.fromEntries(
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
);

const msgId = mappedHeaders["webhook-id"];
const msgSignature = mappedHeaders["webhook-signature"];
const msgTimestamp = mappedHeaders["webhook-timestamp"];

if (
typeof msgId !== "string" ||
typeof msgSignature !== "string" ||
typeof msgTimestamp !== "string" ||
!msgId ||
!msgSignature ||
!msgTimestamp
) {
throw new WebhookVerificationError("MISSING_REQUIRED_HEADERS");
}

verifyTimestamp(msgTimestamp);

const expectedSignature = await sign(secret, msgId, msgTimestamp, payload);

for (const versionedSignature of msgSignature.split(" ")) {
const split = versionedSignature.split(",", 3);
if (split.length < 2) continue;
const [version, signature] = split;

if (version !== "v1") continue;

const signatureDecoded = tryCatch(
() => Uint8Array.from(atob(signature), (c) => c.charCodeAt(0)),
() => undefined,
);
if (signatureDecoded === undefined) continue;

if (timingSafeEqual(signatureDecoded, expectedSignature)) return;
}
throw new WebhookVerificationError("NO_MATCHING_SIGNATURE");
}

/**
* 웹훅 페이로드를 서명하여 웹훅 본문을 생성합니다.
*
* @param msgId 웹훅 본문에 지정할 고유 ID
* @param msgTimestamp 웹훅 생성 시도 시각
* @param payload 웹훅 페이로드
* @returns 서명된 웹훅 본문을 반환하는 Promise
* @throws {InvalidInputError} 입력받은 웹훅 페이로드가 유효하지 않을 때 발생합니다.
*/
async function sign(
secret: string | Uint8Array,
msgId: string,
msgTimestamp: string,
payload: string,
): Promise<ArrayBuffer> {
const cryptoKey = await getCryptoKeyFromSecret(secret);
const encoder = new TextEncoder();
const toSign = encoder.encode(`${msgId}.${msgTimestamp}.${payload}`);

return await crypto.subtle.sign("HMAC", cryptoKey, toSign);
}

const secrets = new Map<string | Uint8Array, CryptoKey>();

/**
* 웹훅 시크릿 입력으로부터 CryptoKey를 가져옵니다.
*
* @throws {InvalidInputError} 입력받은 웹훅 시크릿이 유효하지 않을 때 발생합니다.
*/
async function getCryptoKeyFromSecret(secret: string | Uint8Array) {
const cryptoKeyCached = secrets.get(secret); // cache based on argument
if (cryptoKeyCached !== undefined) return cryptoKeyCached;

let rawSecret: Uint8Array;
if (secret instanceof Uint8Array) {
rawSecret = secret;
} else if (typeof secret === "string") {
const secretBase64 = secret.startsWith(prefix)
? secret.substring(prefix.length)
: secret;
rawSecret = tryCatch(
() => Uint8Array.from(atob(secretBase64), (c) => c.charCodeAt(0)),
() => {
throw new InvalidInputError(
"`secret` 파라미터가 올바른 Base64 문자열이 아닙니다.",
);
},
);
} else {
throw new InvalidInputError("`secret` 파라미터의 타입이 잘못되었습니다.");
}

if (rawSecret.length === 0)
throw new InvalidInputError("시크릿은 비어 있을 수 없습니다.");

const cryptoKey = await crypto.subtle.importKey(
"raw",
rawSecret,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);

secrets.set(secret, cryptoKey);

return cryptoKey;
}

/**
* 웹훅의 타임스탬프 정보를 검증합니다.
*
* @throws {WebhookVerificationError} 타임스탬프가 유효하지 않을 때 발생합니다.
*/
function verifyTimestamp(timestampHeader: string): void {
const now = Math.floor(Date.now() / 1000);
const timestamp = Number.parseInt(timestampHeader, 10);
if (Number.isNaN(timestamp)) {
throw new WebhookVerificationError("INVALID_SIGNATURE");
}

if (now - timestamp > WEBHOOK_TOLERANCE_IN_SECONDS) {
throw new WebhookVerificationError("TIMESTAMP_TOO_OLD");
}
if (timestamp > now + WEBHOOK_TOLERANCE_IN_SECONDS) {
throw new WebhookVerificationError("TIMESTAMP_TOO_NEW");
}
}
Loading

0 comments on commit ea1623f

Please sign in to comment.