Skip to content

[TASK-220 / 25.07.04] Refactor: 시그니처 인증 과정 추가 #39

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jul 6, 2025
Merged
3 changes: 2 additions & 1 deletion .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ POSTGRES_HOST=localhost
POSTGRES_PORT=5432

# ETC
SLACK_WEBHOOK_URL=https://hooks.slack.com/services
SLACK_WEBHOOK_URL=https://hooks.slack.com/services
SLACK_SENTRY_SECRET=374708bedd34ae70f814471ff24db7dedc4b9bee06a7e8ef9255a4f6c8bd9049 # 실제 키를 사용하세요
1 change: 1 addition & 0 deletions .github/workflows/test-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ jobs:
echo "POSTGRES_HOST=${{ secrets.POSTGRES_HOST }}" >> .env
echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env
echo "POSTGRES_PORT=${{ secrets.POSTGRES_PORT }}" >> .env
echo "SENTRY_CLIENT_SECRET=${{ secrets.SENTRY_CLIENT_SECRET }}" >> .env
# AES 키들 추가 (테스트용 더미 키)
echo "AES_KEY_0=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" >> .env
echo "AES_KEY_1=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" >> .env
Expand Down
56 changes: 31 additions & 25 deletions src/controllers/__test__/webhook.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,12 +147,13 @@ describe('WebhookController', () => {
);

expect(mockResponse.status).toHaveBeenCalledWith(400);
expect(mockResponse.json).toHaveBeenCalledWith({
success: true,
message: 'Sentry 웹훅 처리에 실패했습니다',
data: {},
error: null
});
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Sentry 웹훅 처리에 실패했습니다',
statusCode: 400,
code: 'INVALID_SYNTAX'
})
);
expect(nextFunction).not.toHaveBeenCalled();
});

Expand All @@ -166,12 +167,13 @@ describe('WebhookController', () => {
);

expect(mockResponse.status).toHaveBeenCalledWith(400);
expect(mockResponse.json).toHaveBeenCalledWith({
success: true,
message: 'Sentry 웹훅 처리에 실패했습니다',
data: {},
error: null
});
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Sentry 웹훅 처리에 실패했습니다',
statusCode: 400,
code: 'INVALID_SYNTAX'
})
);
});

it('action이 없는 경우 400 에러를 반환해야 한다', async () => {
Expand All @@ -184,12 +186,13 @@ describe('WebhookController', () => {
);

expect(mockResponse.status).toHaveBeenCalledWith(400);
expect(mockResponse.json).toHaveBeenCalledWith({
success: true,
message: 'Sentry 웹훅 처리에 실패했습니다',
data: {},
error: null
});
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Sentry 웹훅 처리에 실패했습니다',
statusCode: 400,
code: 'INVALID_SYNTAX'
})
);
});

it('전혀 다른 형태의 객체인 경우 400 에러를 반환해야 한다', async () => {
Expand All @@ -206,12 +209,13 @@ describe('WebhookController', () => {
);

expect(mockResponse.status).toHaveBeenCalledWith(400);
expect(mockResponse.json).toHaveBeenCalledWith({
success: true,
message: 'Sentry 웹훅 처리에 실패했습니다',
data: {},
error: null
});
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Sentry 웹훅 처리에 실패했습니다',
statusCode: 400,
code: 'INVALID_SYNTAX'
})
);
});

it('action은 created이지만 필수 필드가 없는 경우 에러를 전달해야 한다', async () => {
Expand All @@ -232,7 +236,9 @@ describe('WebhookController', () => {

expect(nextFunction).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Sentry 웹훅 데이터가 올바르지 않습니다'
message: 'Sentry 웹훅 처리에 실패했습니다',
statusCode: 400,
code: 'INVALID_SYNTAX'
})
);
expect(mockResponse.json).not.toHaveBeenCalled();
Expand Down
6 changes: 3 additions & 3 deletions src/controllers/webhook.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextFunction, Request, RequestHandler, Response } from 'express';
import { EmptyResponseDto, SentryWebhookData } from '@/types';
import logger from '@/configs/logger.config';
import { sendSlackMessage } from '@/modules/slack/slack.notifier';
import { BadRequestError } from '@/exception';

export class WebhookController {
private readonly STATUS_EMOJI = {
Expand All @@ -16,9 +17,8 @@ export class WebhookController {
next: NextFunction,
): Promise<void> => {
try {

if (req.body?.action !== "created") {
const response = new EmptyResponseDto(true, 'Sentry 웹훅 처리에 실패했습니다', {}, null);
const response = new BadRequestError('Sentry 웹훅 처리에 실패했습니다');
res.status(400).json(response);
return;
}
Expand All @@ -39,7 +39,7 @@ export class WebhookController {
private formatSentryMessage(sentryData: SentryWebhookData): string {
const { data: { issue } } = sentryData;

if(!issue.status || !issue.title || !issue.culprit || !issue.id) throw new Error('Sentry 웹훅 데이터가 올바르지 않습니다');
if(!issue.status || !issue.title || !issue.culprit || !issue.id) throw new BadRequestError('Sentry 웹훅 처리에 실패했습니다');

const { status, title: issueTitle, culprit, permalink, id } = issue;
const statusEmoji = this.STATUS_EMOJI[status as keyof typeof this.STATUS_EMOJI];
Expand Down
39 changes: 38 additions & 1 deletion src/middlewares/auth.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { NextFunction, Request, Response } from 'express';
import { isUUID } from 'class-validator';
import logger from '@/configs/logger.config';
import pool from '@/configs/db.config';
import { DBError, InvalidTokenError } from '@/exception';
import { CustomError, DBError, InvalidTokenError } from '@/exception';
import { VelogJWTPayload, User } from '@/types';
import crypto from "crypto";

/**
* 요청에서 토큰을 추출하는 함수
Expand Down Expand Up @@ -66,10 +67,46 @@ const verifyBearerTokens = () => {
};
};

/**
* Sentry 웹훅 요청의 시그니처 헤더를 검증합니다.
* HMAC SHA256과 Sentry의 Client Secret를 사용하여 요청 본문을 해시화하고,
* Sentry에서 제공하는 시그니처 헤더와 비교하여 요청의 무결성을 확인합니다.
*/
function verifySentrySignature() {
return (req: Request, res: Response, next: NextFunction) => {
try {
if (!process.env.SENTRY_CLIENT_SECRET) throw new Error("SENTRY_CLIENT_SECRET가 env에 없습니다");

const hmac = crypto.createHmac("sha256", process.env.SENTRY_CLIENT_SECRET);

// Raw body 사용 - Express에서 파싱되기 전의 원본 데이터 필요
// req.rawBody가 없다면 fallback으로 JSON.stringify 사용 (완벽하지 않음)
// @ts-expect-error - rawBody는 커스텀 미들웨어에서 추가되는 속성
const bodyToVerify = req.rawBody || JSON.stringify(req.body);
const sentrySignature = req.headers["sentry-hook-signature"];

if (!bodyToVerify) throw new Error("요청 본문이 없습니다.");
if (!sentrySignature) throw new Error("시그니처 헤더가 없습니다.");

hmac.update(bodyToVerify, "utf8");
const digest = hmac.digest("hex");

if (digest !== sentrySignature) throw new CustomError("유효하지 않은 시그니처 헤더입니다.", "INVALID_SIGNATURE", 400);

next();
} catch (error) {
logger.error('시그니처 검증 중 오류가 발생하였습니다. : ', error);
next(error);
}
}
}

/**
* 사용자 인증을 위한 미들웨어 모음
* @property {Function} verify
* * @property {Function} verifySignature
*/
export const authMiddleware = {
verify: verifyBearerTokens(),
verifySignature: verifySentrySignature(),
};
3 changes: 2 additions & 1 deletion src/routes/webhook.router.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import express, { Router } from 'express';
import { WebhookController } from '@/controllers/webhook.controller';
import { authMiddleware } from '@/middlewares/auth.middleware';

const router: Router = express.Router();

Expand Down Expand Up @@ -47,6 +48,6 @@ const webhookController = new WebhookController();
* 500:
* description: 서버 오류
*/
router.post('/webhook/sentry', webhookController.handleSentryWebhook);
router.post('/webhook/sentry', authMiddleware.verifySignature, webhookController.handleSentryWebhook);

export default router;
Loading