-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathAccessControlContractTest.java
More file actions
316 lines (282 loc) · 14.9 KB
/
Copy pathAccessControlContractTest.java
File metadata and controls
316 lines (282 loc) · 14.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
package com.carecode.integration;
import com.carecode.CareCodeApplication;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.MediaType;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
/**
* 어떤 경로가 로그인 없이 열려 있고 어떤 경로가 막혀 있어야 하는지를 코드로 고정한다.
*
* <p>SecurityConfig 는 선언 순서에 따라 앞선 규칙이 뒤를 덮는다. 실제로 병원 공개 규칙이
* 존재하지 않는 {@code /hospitals/**} 에 걸려 있고 앞선 {@code /health/**} 가 전부 잡아
* 병원 조회가 통째로 로그인 필수였는데, 규칙 자체는 멀쩡해 보여서 아무도 눈치채지 못했다.
* 클래스 레벨 {@code @PreAuthorize} 가 URL 규칙을 덮는 경우도 마찬가지다.
*
* <p>그래서 규칙을 읽는 대신 실제 응답 코드를 확인한다.
*/
@SpringBootTest(
classes = CareCodeApplication.class,
properties = {
"spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration,"
+ "org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration,"
+ "org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration",
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:carecode_acl;MODE=MySQL;DB_CLOSE_DELAY=-1",
"spring.datasource.driver-class-name=org.h2.Driver",
"spring.datasource.username=sa",
"spring.datasource.password=",
"spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
"spring.jpa.hibernate.ddl-auto=create-drop",
"spring.flyway.enabled=false",
"jwt.secret=testJwtSecretKeyForAccessControlTestMustBe256BitsLong0123456789",
"springdoc.api-docs.enabled=false",
"springdoc.swagger-ui.enabled=false",
"public.data.api.key=dummy",
"KAKAO_CLIENT_ID=dummy-kakao-client",
"KAKAO_CLIENT_SECRET=dummy-kakao-secret",
"MAIL_USERNAME=dummy",
"MAIL_PASSWORD=dummy"
}
)
@AutoConfigureMockMvc
@DisplayName("접근제어 계약")
class AccessControlContractTest {
@MockBean
RedisConnectionFactory redisConnectionFactory;
@MockBean
StringRedisTemplate stringRedisTemplate;
@MockBean
JavaMailSender javaMailSender;
@Autowired
MockMvc mockMvc;
@Autowired
com.carecode.domain.user.service.JwtService jwtService;
/**
* 로그인 전에도 보여야 하는 경로.
*
* <p>여기서 확인하는 건 인가지 응답 내용이 아니다. 데이터가 없어 404 가 나올 수는 있어도
* 인증을 요구해서는 안 된다.
*/
@ParameterizedTest(name = "{0} 은 로그인 없이 열려 있다")
@ValueSource(strings = {
"/actuator/health",
// 동의하기 전에 읽어야 하는 문서
"/legal/privacy-policy",
"/legal/terms",
"/legal/version",
// 둘러보기 단계에서 보여줘야 가입 전환이 생긴다
"/policies",
"/policies/categories",
"/policies/statistics",
"/facilities",
"/facilities/popular",
"/facilities/statistics",
"/health/hospitals",
"/health/hospitals/popular",
"/health/hospitals/statistics",
"/community/posts",
"/community/tags"
})
void publicPathsDoNotRequireLogin(String path) throws Exception {
MvcResult result = mockMvc.perform(get(path)).andReturn();
assertThat(result.getResponse().getStatus())
.as("%s 는 비로그인 접근이 가능해야 한다", path)
.isNotIn(401, 403);
}
/** 남의 개인정보가 걸린 경로. 뚫리면 그대로 사고다. */
@ParameterizedTest(name = "{0} 은 로그인이 필요하다")
@ValueSource(strings = {
"/policies/recommendations",
"/policies/missed-benefits",
"/policies/regional-comparison",
"/policies/bookmarks",
"/health/records/user/1",
"/health/statistics",
// 좋아요 "여부" 는 내 상태라 공개 조회와 구분해야 한다
"/health/hospitals/1/like-status",
// "내가 찜한 병원" 목록. 경로가 한 세그먼트라 /health/hospitals/* 와일드카드에
// 먼저 걸려 공개로 선언돼 있었다. 병원 상세와 모양이 같아 눈에 띄지 않는다.
"/health/hospitals/likes",
// 같은 모양의 문제. 게시글 상세(/community/posts/*)에 먹히고 있었다.
"/community/posts/liked",
"/community/posts/bookmarked",
"/notifications",
"/auth/user/profile",
// 본인 계정 API. 로그인 없이 열리면 남의 프로필이 그대로 노출된다
"/users/profile",
"/users/me",
// 관리 API 는 비로그인부터 막힌다
"/api/admin/users",
"/api/admin/users/statistics"
})
void protectedPathsRequireLogin(String path) throws Exception {
MvcResult result = mockMvc.perform(get(path)).andReturn();
assertThat(result.getResponse().getStatus())
.as("%s 는 인증을 요구해야 한다", path)
.isEqualTo(401);
}
/**
* 가입 흐름에서 로그인 전에 호출되는 POST 경로.
*
* <p>이 규칙은 오랫동안 존재하지 않는 {@code /users/send-code} 를 가리키고 있었다.
* 실제 매핑인 {@code /auth/send-code} 는 화이트리스트에 없어 {@code anyRequest().authenticated()}
* 에 걸렸고, 그 결과 "가입하려면 먼저 로그인해야 하는" 상태였다.
* 메일로 받은 인증 링크({@code GET /auth/verify}) 역시 같은 이유로 401 이었다.
*/
@ParameterizedTest(name = "{0} 은 가입 전에 호출할 수 있어야 한다")
@ValueSource(strings = {"/auth/send-code", "/auth/verify-code"})
void signupFlowPostPathsDoNotRequireLogin(String path) throws Exception {
MvcResult result = mockMvc.perform(post(path)).andReturn();
// 파라미터가 없어 400 이 날 수는 있어도, 인증을 요구해서는 안 된다.
assertThat(result.getResponse().getStatus())
.as("%s 는 비로그인 접근이 가능해야 한다", path)
.isNotIn(401, 403);
}
@Test
@DisplayName("이메일 인증 링크는 로그인 없이 열린다")
void emailVerificationLinkIsPublic() throws Exception {
MvcResult result = mockMvc.perform(get("/auth/verify").param("token", "dummy-token")).andReturn();
assertThat(result.getResponse().getStatus()).isNotIn(401, 403);
}
/**
* 권한 상승 회귀 방지.
*
* <p>{@code PUT /users/{id}/role} 은 클래스 제약이 {@code isAuthenticated()} 뿐이어서,
* 가입만 하면 누구나 자신을 ADMIN 으로 올리고 {@code /api/admin/**} 전체를 열 수 있었다.
* 해당 매핑은 삭제했고, 역할 변경은 관리자 전용 경로로만 남겼다.
*/
@ParameterizedTest(name = "{0} 매핑은 더 이상 존재하지 않는다")
@ValueSource(strings = {
"/users/1/role",
"/users/1/activate",
"/users/1/reactivate"
})
@WithMockUser(username = "attacker@example.com", roles = "PARENT")
void privilegedMappingsRemovedFromUserApi(String path) throws Exception {
MvcResult result = mockMvc.perform(
put(path).contentType(MediaType.APPLICATION_JSON).content("{\"role\":\"ADMIN\"}")).andReturn();
assertThat(result.getResponse().getStatus())
.as("%s 는 매핑이 없어야 한다 (404/405)", path)
.isIn(404, 405);
}
/**
* 공공데이터 동기화는 외부 API 한도를 태우고 DB 에 쓴다. 공개로 두면 누구나 실행할 수 있다.
*
* <p>{@code /api/public/care-facilities/**} 가 통째로 permitAll 이라, 그 아래 있는
* 동기화 트리거까지 열려 있었다. {@code swagger/sync} 는 GET 이라 브라우저 접속이나
* 크롤러만으로도 실행된다.
*/
@Test
@DisplayName("공공데이터 동기화는 비로그인으로 실행할 수 없다")
void publicDataSyncIsNotOpen() throws Exception {
assertThat(mockMvc.perform(post("/api/public/care-facilities/sync-all")).andReturn()
.getResponse().getStatus())
.as("POST 동기화가 열려 있으면 안 된다")
.isIn(401, 403);
assertThat(mockMvc.perform(get("/api/public/care-facilities/swagger/sync")).andReturn()
.getResponse().getStatus())
.as("GET 동기화는 브라우저 접속만으로도 실행된다")
.isIn(401, 403);
}
@Test
@DisplayName("일반 회원도 공공데이터 동기화를 실행할 수 없다")
@WithMockUser(username = "member@example.com", roles = "PARENT")
void publicDataSyncRequiresAdmin() throws Exception {
assertThat(mockMvc.perform(post("/api/public/care-facilities/sync-all")).andReturn()
.getResponse().getStatus()).isEqualTo(403);
}
/** 시설·정책 조회는 계속 공개여야 한다. 위 제한이 조회까지 막으면 안 된다. */
@ParameterizedTest(name = "{0} 은 여전히 공개다")
@ValueSource(strings = {
"/api/public/care-facilities/swagger/stats",
"/api/public/care-facilities/swagger/db-facilities"
})
void publicDataReadStaysOpen(String path) throws Exception {
assertThat(mockMvc.perform(get(path)).andReturn().getResponse().getStatus())
.isNotIn(401, 403);
}
/** 버전 필터가 실제 필터 체인에 걸려 있는지. 단위 테스트만으로는 등록 누락을 모른다. */
@Test
@DisplayName("응답에 API 버전 헤더가 붙고, 모르는 버전은 400 이다")
void apiVersionHeader() throws Exception {
MvcResult ok = mockMvc.perform(get("/facilities")).andReturn();
assertThat(ok.getResponse().getHeader("X-API-Version")).isEqualTo("1");
MvcResult unsupported = mockMvc.perform(get("/facilities").header("X-API-Version", "9")).andReturn();
assertThat(unsupported.getResponse().getStatus()).isEqualTo(400);
}
/** 사용자 목록·검색은 전체 회원 개인정보다. 로그인만 했다고 열리면 안 된다. */
@ParameterizedTest(name = "{0} 은 일반 회원에게 403 이다")
@ValueSource(strings = {
"/api/admin/users",
"/api/admin/users/statistics",
"/api/admin/users/search?keyword=a",
"/api/admin/users/active",
"/api/admin/users/verified",
"/api/admin/users/by-region/서울"
})
@WithMockUser(username = "member@example.com", roles = "PARENT")
void adminQueriesRejectNonAdmin(String path) throws Exception {
MvcResult result = mockMvc.perform(get(path)).andReturn();
assertThat(result.getResponse().getStatus())
.as("%s 는 ADMIN 이 아니면 막혀야 한다", path)
.isEqualTo(403);
}
/**
* 토큰 종류 혼동 회귀 방지.
*
* <p>{@code /auth/refresh} 는 서명·만료만 보는 {@code validateToken} 을 쓰고 있었다.
* 그 검증은 Access Token 도 통과시키고, 서버 세션 저장소를 쓰지 않는 기본 설정
* ({@code jwt.refresh-token.store=none}) 에서는 뒤따르는 등록 여부 확인도 항상 참이라,
* 탈취한 1시간짜리 Access Token 을 30일짜리 Refresh Token 으로 바꿀 수 있었다.
*/
@Test
@DisplayName("Access Token 으로는 토큰을 갱신할 수 없다")
void accessTokenCannotBeUsedToRefresh() throws Exception {
String accessToken = jwtService.generateAccessToken("u-1", "victim@example.com", "PARENT");
MvcResult result = mockMvc.perform(post("/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"refreshToken\":\"" + accessToken + "\"}"))
.andReturn();
assertThat(result.getResponse().getStatus()).isEqualTo(401);
}
@Test
@DisplayName("Refresh Token 은 갱신 경로에서 토큰 종류 검증을 통과한다")
void refreshTokenPassesTypeCheck() throws Exception {
String refreshToken = jwtService.generateRefreshToken("u-1", "victim@example.com");
MvcResult result = mockMvc.perform(post("/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"refreshToken\":\"" + refreshToken + "\"}"))
.andReturn();
// 해당 사용자가 DB 에 없으므로 최종 응답은 404 다. 중요한 건 "토큰 종류 때문에 401" 이
// 아니라는 점이다. 여기까지 왔다는 것은 종류 검증을 통과했다는 뜻이다.
assertThat(result.getResponse().getStatus())
.as("정상 Refresh Token 이 종류 검증에서 막히면 안 된다")
.isNotEqualTo(401);
}
@Test
@DisplayName("일반 회원은 관리자 경로로도 역할을 바꿀 수 없다")
void nonAdminCannotEscalateThroughAdminPath() throws Exception {
MvcResult result = mockMvc.perform(put("/api/admin/users/1/role")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"role\":\"ADMIN\"}")
.with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
.user("member@example.com").roles("PARENT")))
.andReturn();
assertThat(result.getResponse().getStatus()).isEqualTo(403);
}
}