Skip to content

Commit cc354e0

Browse files
author
EpicFn
committed
feat : 서명 검증 로직 구현
1 parent 63db747 commit cc354e0

File tree

7 files changed

+184
-29
lines changed

7 files changed

+184
-29
lines changed

build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ dependencies {
9898
// Playwright for Java
9999
implementation 'com.microsoft.playwright:playwright:1.54.0'
100100

101+
// Apache Commons Codec
102+
implementation"commons-codec:commons-codec:1.19.0"
101103
}
102104

103105
dependencyManagement {

src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/ApiV1DashboardController.java

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,20 @@ public class ApiV1DashboardController {
2525
private final DashboardService dashboardService;
2626

2727
/**
28-
* LiveBlocks를 위한 React-flow 데이터 저장 API (LiveBlocks Webhook 타겟)
29-
* @param bodyForReactFlow React-flow 데이터를 가지고 있는 Dto
28+
* React-flow 데이터 저장(갱신) API
29+
* @param dashboardId React-flow 데이터의 dashboard 식별 id
30+
* @param requestBody React-flow 에서 보내주는 body 전체
31+
* @param signature Liveblocks-Signature 헤더 값
32+
* @return ResponseEntity<RsData<Void>>
3033
*/
3134
@PutMapping("/{dashboardId}/graph")
3235
@Operation(summary = "React-flow 데이터 저장(갱신)")
3336
public ResponseEntity<RsData<Void>> updateGraph(
3437
@PathVariable Integer dashboardId,
35-
@RequestBody BodyForReactFlow bodyForReactFlow
38+
@RequestBody String requestBody,
39+
@RequestHeader("Liveblocks-Signature") String signature
3640
) {
37-
// TODO : signature 검증 로직 추가
38-
39-
dashboardService.updateGraph(dashboardId, bodyForReactFlow);
41+
dashboardService.verifyAndUpdateGraph(dashboardId, requestBody, signature);
4042

4143
return ResponseEntity
4244
.status(HttpStatus.OK)

src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/DashboardService.java

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package org.tuna.zoopzoop.backend.domain.dashboard.service;
22

3+
import com.fasterxml.jackson.databind.ObjectMapper;
34
import jakarta.persistence.NoResultException;
45
import lombok.RequiredArgsConstructor;
6+
import org.apache.commons.codec.binary.Hex;
7+
import org.springframework.beans.factory.annotation.Value;
58
import org.springframework.stereotype.Service;
69
import org.springframework.transaction.annotation.Transactional;
710
import org.tuna.zoopzoop.backend.domain.dashboard.dto.BodyForReactFlow;
@@ -13,7 +16,11 @@
1316
import org.tuna.zoopzoop.backend.domain.member.entity.Member;
1417
import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService;
1518

19+
import javax.crypto.Mac;
20+
import javax.crypto.spec.SecretKeySpec;
21+
import java.nio.charset.StandardCharsets;
1622
import java.nio.file.AccessDeniedException;
23+
import java.security.MessageDigest;
1724
import java.util.List;
1825

1926
@Service
@@ -22,6 +29,17 @@
2229
public class DashboardService {
2330
private final DashboardRepository dashboardRepository;
2431
private final MembershipService membershipService;
32+
private final ObjectMapper objectMapper;
33+
34+
@Value("${liveblocks.secret-key}")
35+
private String liveblocksSecretKey;
36+
37+
// 5분 (밀리초 단위)
38+
private static final long TOLERANCE_IN_MILLIS = 5 * 60 * 1000;
39+
40+
41+
// =========================== Graph 관련 메서드 ===========================
42+
2543
/**
2644
* 대시보드 ID를 통해 Graph 데이터를 조회하는 메서드
2745
*/
@@ -53,6 +71,32 @@ public void updateGraph(Integer dashboardId, BodyForReactFlow dto) {
5371

5472
}
5573

74+
/**
75+
* 서명 검증 후 Graph 업데이트를 수행하는 메서드
76+
* @param dashboardId 대시보드 ID
77+
* @param requestBody 요청 바디
78+
* @param signatureHeader 서명 헤더
79+
*/
80+
public void verifyAndUpdateGraph(Integer dashboardId, String requestBody, String signatureHeader) {
81+
// 1. 서명 검증
82+
if (!isValidSignature(requestBody, signatureHeader)) {
83+
throw new SecurityException("Invalid webhook signature.");
84+
}
85+
86+
// 2. 검증 통과 후, 기존 업데이트 로직 실행
87+
try {
88+
BodyForReactFlow dto = objectMapper.readValue(requestBody, BodyForReactFlow.class);
89+
updateGraph(dashboardId, dto);
90+
} catch (NoResultException e) {
91+
throw new NoResultException(dashboardId + " ID를 가진 대시보드를 찾을 수 없습니다.");
92+
}
93+
catch (Exception e) {
94+
throw new RuntimeException("Failed to process request body.", e);
95+
}
96+
}
97+
98+
// =========================== 권한 관련 메서드 ===========================
99+
56100
/**
57101
* 대시보드 접근 권한을 검증하는 메서드
58102
* @param member 접근을 시도하는 멤버
@@ -68,4 +112,58 @@ public void verifyAccessPermission(Member member, Integer dashboardId) throws Ac
68112
throw new AccessDeniedException("대시보드의 접근 권한이 없습니다.");
69113
}
70114
}
115+
116+
117+
/**
118+
* LiveBlocks Webhook 요청의 유효성을 검증하는 메서드
119+
* @param requestBody 요청 바디
120+
* @param signatureHeader LiveBlocks가 제공하는 서명 헤더
121+
* @return 서명이 유효하면 true, 그렇지 않으면 false
122+
*/
123+
private boolean isValidSignature(String requestBody, String signatureHeader) {
124+
try {
125+
// 1. 헤더 파싱
126+
String[] parts = signatureHeader.split(",");
127+
long timestamp = -1;
128+
String signatureHashFromHeader = null;
129+
130+
for (String part : parts) {
131+
String[] pair = part.split("=", 2);
132+
if (pair.length == 2) {
133+
if ("t".equals(pair[0])) {
134+
timestamp = Long.parseLong(pair[1]);
135+
} else if ("v1".equals(pair[0])) {
136+
signatureHashFromHeader = pair[1];
137+
}
138+
}
139+
}
140+
141+
if (timestamp == -1 || signatureHashFromHeader == null) {
142+
return false; // 헤더 형식이 잘못됨
143+
}
144+
145+
// 2. 리플레이 공격 방지를 위한 타임스탬프 검증 (선택사항)
146+
long now = System.currentTimeMillis();
147+
if (now - timestamp > TOLERANCE_IN_MILLIS) {
148+
return false; // 너무 오래된 요청
149+
}
150+
151+
// 3. 서명 재생성
152+
String payload = timestamp + "." + requestBody;
153+
Mac mac = Mac.getInstance("HmacSHA256");
154+
SecretKeySpec secretKeySpec = new SecretKeySpec(liveblocksSecretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
155+
mac.init(secretKeySpec);
156+
byte[] expectedHashBytes = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
157+
158+
// 4. 서명 비교 (타이밍 공격 방지를 위해 MessageDigest.isEqual 사용)
159+
byte[] signatureHashBytesFromHeader = Hex.decodeHex(signatureHashFromHeader);
160+
return MessageDigest.isEqual(expectedHashBytes, signatureHashBytesFromHeader);
161+
162+
} catch (Exception e) {
163+
// 파싱 실패, 디코딩 실패 등 모든 예외는 검증 실패로 간주
164+
return false;
165+
}
166+
}
167+
168+
71169
}

src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,17 @@ public ResponseEntity<RsData<Void>> handle(MissingServletRequestPartException e)
178178
);
179179
}
180180

181+
@ExceptionHandler(SecurityException.class)
182+
public ResponseEntity<RsData<Void>> handleSecurityException(SecurityException e) {
183+
return new ResponseEntity<>(
184+
new RsData<>(
185+
"403", // 또는 "401"
186+
e.getMessage()
187+
),
188+
FORBIDDEN // 또는 UNAUTHORIZED
189+
);
190+
}
191+
181192
@ExceptionHandler(Exception.class) // 내부 서버 에러(= 따로 Exception을 지정하지 않은 경우.)
182193
public ResponseEntity<RsData<Void>> handleException(Exception e) {
183194
return new ResponseEntity<>(

src/main/resources/application-secrets.yml.template

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,7 @@ jwt:
4949
access-token-validity: {ACCESSTOKEN_VALIDITY}
5050
refresh-token-validity: {REFRESHTOKEN_VALIDITY}
5151

52-
OPENAI_API_KEY: {OPENAI_API_KEY}
52+
OPENAI_API_KEY: {OPENAI_API_KEY}
53+
54+
liveblocks:
55+
secret-key: {LIVEBLOCKS_SECRET_KEY}

src/main/resources/application-test.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,6 @@ spring:
1616
sql:
1717
init:
1818
mode: never
19+
20+
liveblocks:
21+
secret-key: test_dummy_liveblocks_secret_key

src/test/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/DashboardControllerTest.java

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package org.tuna.zoopzoop.backend.domain.dashboard.controller;
22

3+
import org.apache.commons.codec.binary.Hex;
34
import org.junit.jupiter.api.*;
45
import org.springframework.beans.factory.annotation.Autowired;
6+
import org.springframework.beans.factory.annotation.Value;
57
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
68
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
79
import org.springframework.boot.test.context.SpringBootTest;
@@ -22,10 +24,15 @@
2224
import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService;
2325
import org.tuna.zoopzoop.backend.testSupport.ControllerTestSupport;
2426

27+
import javax.crypto.Mac;
28+
import javax.crypto.spec.SecretKeySpec;
29+
import java.nio.charset.StandardCharsets;
30+
import java.security.InvalidKeyException;
31+
import java.security.NoSuchAlgorithmException;
32+
2533
import static org.hamcrest.Matchers.hasSize;
2634
import static org.junit.jupiter.api.Assertions.assertEquals;
27-
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
28-
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
35+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
2936
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
3037
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
3138

@@ -48,7 +55,9 @@ class DashboardControllerTest extends ControllerTestSupport {
4855
private Integer authorizedDashboardId;
4956
private Integer unauthorizedDashboardId;
5057

51-
// 테스트에 필요한 유저, 스페이스, 멤버십 데이터를 미리 설정합니다.
58+
@Value("${liveblocks.secret-key}")
59+
private String testSecretKey;
60+
5261
@BeforeAll
5362
void setUp() {
5463
// 1. 유저 생성
@@ -145,11 +154,15 @@ void updateGraph_Success() throws Exception {
145154
// Given
146155
String url = String.format("/api/v1/dashboard/%d/graph", authorizedDashboardId);
147156
String requestBody = createReactFlowJsonBody();
157+
String validSignature = generateLiveblocksSignature(requestBody);
148158

149-
// When: 데이터 수정
150-
ResultActions updateResult = performPut(url, requestBody);
159+
// When: 데이터 저장
160+
ResultActions updateResult = mvc.perform(put(url)
161+
.contentType(MediaType.APPLICATION_JSON)
162+
.header("Liveblocks-Signature", validSignature) // ★ 서명 헤더 추가
163+
.content(requestBody));
151164

152-
// Then: 수정 성공 응답 확인
165+
// Then: 저장 성공 응답 확인
153166
expectOk(
154167
updateResult,
155168
"React-flow 데이터를 저장했습니다."
@@ -174,9 +187,13 @@ void updateGraph_Fail_NotFound() throws Exception {
174187
Integer nonExistentDashboardId = 9999;
175188
String url = String.format("/api/v1/dashboard/%d/graph", nonExistentDashboardId);
176189
String requestBody = createReactFlowJsonBody();
190+
String validSignature = generateLiveblocksSignature(requestBody);
177191

178-
// When
179-
ResultActions resultActions = performPut(url, requestBody);
192+
// When: 데이터 저장
193+
ResultActions resultActions = mvc.perform(put(url)
194+
.contentType(MediaType.APPLICATION_JSON)
195+
.header("Liveblocks-Signature", validSignature) // ★ 서명 헤더 추가
196+
.content(requestBody));;
180197

181198
// Then
182199
expectNotFound(
@@ -185,20 +202,23 @@ void updateGraph_Fail_NotFound() throws Exception {
185202
);
186203
}
187204

188-
// @Test
189-
// @DisplayName("대시보드 그래프 데이터 저장 - 실패: 서명 검증 실패")
190-
// void updateGraph_Fail_Forbidden() throws Exception {
191-
// // Given
192-
// String url = String.format("/api/v1/dashboard/%d/graph", authorizedDashboardId);
193-
// String requestBody = createReactFlowJsonBody();
194-
//
195-
// // When
196-
// ResultActions resultActions = performPut(url, requestBody);
197-
//
198-
// // Then
199-
// // TODO: 실제 구현된 권한 체크 로직의 예외 메시지에 따라 "권한이 없습니다." 부분을 수정해야 합니다.
200-
// expectForbidden(resultActions, "액세스가 거부되었습니다.");
201-
// }
205+
@Test
206+
@DisplayName("대시보드 그래프 데이터 저장 - 실패: 서명 검증 실패")
207+
void updateGraph_Fail_Forbidden() throws Exception {
208+
// Given
209+
String url = String.format("/api/v1/dashboard/%d/graph", authorizedDashboardId);
210+
String requestBody = createReactFlowJsonBody();
211+
String invalidSignature = "t=123,v1=invalid_signature"; // 유효하지 않은 서명
212+
213+
// When
214+
ResultActions resultActions = mvc.perform(put(url)
215+
.contentType(MediaType.APPLICATION_JSON)
216+
.header("Liveblocks-Signature", invalidSignature) // ★ 잘못된 서명 헤더 추가
217+
.content(requestBody));
218+
219+
// Then
220+
expectForbidden(resultActions, "Invalid webhook signature.");
221+
}
202222

203223
// ======================= TEST DATA FACTORIES ======================== //
204224

@@ -233,4 +253,20 @@ private String createReactFlowJsonBody() {
233253
""";
234254
}
235255

256+
// ======================= HELPER METHODS ======================== //
257+
private String generateLiveblocksSignature(String requestBody) throws NoSuchAlgorithmException, InvalidKeyException, InvalidKeyException {
258+
long timestamp = System.currentTimeMillis();
259+
String payload = timestamp + "." + requestBody;
260+
261+
Mac mac = Mac.getInstance("HmacSHA256");
262+
SecretKeySpec secretKeySpec = new SecretKeySpec(testSecretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
263+
mac.init(secretKeySpec);
264+
byte[] hash = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
265+
266+
// v1 서명은 해시값의 Hex 인코딩 문자열입니다.
267+
String signatureHash = Hex.encodeHexString(hash);
268+
269+
return String.format("t=%d,v1=%s", timestamp, signatureHash);
270+
}
271+
236272
}

0 commit comments

Comments
 (0)