Skip to content

Commit 9aa4fb8

Browse files
authored
[Feat/admin authorization] - 사용자 권한에 따른 시큐리티 설정 (#103)
* ✨ feat : add error-code for INSUFFICENT_PERMISSION * ✨ feat : add UnauthorizedAccessException * ✨ feat : add UnauthorizedAccessException-handler to GlobalExceptionHandler * ✨ feat : add role-based filtering to SecurityConfig * ✨ feat : add CustomAccessDeniedHandler to security config * ✨ feat : add security constants * ♻️ refactor : refactor security-config; extract common filtering * ✨ feat : ignore filter if security-context already allocated * ✨ feat : add NoResourceFoundException-handler * ✅ test : add tests for security-filtering * ♻️ refactor : add null check for logging user
1 parent 5ffc95b commit 9aa4fb8

File tree

10 files changed

+358
-28
lines changed

10 files changed

+358
-28
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package org.runimo.runimo.auth.exceptions;
2+
3+
import org.runimo.runimo.auth.filters.UserErrorCode;
4+
import org.runimo.runimo.exceptions.BusinessException;
5+
6+
public class UnauthorizedAccessException extends BusinessException {
7+
8+
public UnauthorizedAccessException(String message) {
9+
super(UserErrorCode.INSUFFICIENT_PERMISSIONS, message);
10+
}
11+
12+
public UnauthorizedAccessException(Throwable cause) {
13+
super(UserErrorCode.INSUFFICIENT_PERMISSIONS, cause.getMessage());
14+
}
15+
}

src/main/java/org/runimo/runimo/auth/filters/JwtAuthenticationFilter.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import org.runimo.runimo.auth.jwt.JwtResolver;
1616
import org.runimo.runimo.auth.jwt.UserDetail;
1717
import org.runimo.runimo.common.response.ErrorResponse;
18+
import org.springframework.security.authentication.AnonymousAuthenticationToken;
1819
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
1920
import org.springframework.security.core.Authentication;
2021
import org.springframework.security.core.authority.SimpleGrantedAuthority;
@@ -39,6 +40,14 @@ protected void doFilterInternal(@NonNull HttpServletRequest request,
3940
@NonNull HttpServletResponse response, @NonNull FilterChain filterChain)
4041
throws IOException {
4142
try {
43+
44+
var auth = SecurityContextHolder.getContext().getAuthentication();
45+
if (auth != null && auth.isAuthenticated() &&
46+
!(auth instanceof AnonymousAuthenticationToken)) {
47+
filterChain.doFilter(request, response);
48+
return;
49+
}
50+
4251
if (!hasValidAuthorizationHeader(request)) {
4352
setErrorResponse(UserErrorCode.JWT_NOT_FOUND, response);
4453
return;

src/main/java/org/runimo/runimo/auth/filters/UserErrorCode.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ public enum UserErrorCode implements CustomResponseCode {
1616
JWT_BROKEN("UEH4014", HttpStatus.UNAUTHORIZED, "JWT 토큰이 손상되었습니다", "JWT 토큰이 손상되었습니다"),
1717
REFRESH_FAILED("UEH4015", HttpStatus.UNAUTHORIZED, "리프레시 토큰이 만료되었습니다.", "리프레시 토큰이 만료되었습니다."),
1818

19+
// 403
20+
INSUFFICIENT_PERMISSIONS("UEH4031", HttpStatus.FORBIDDEN, "권한이 부족합니다.", "권한이 부족합니다."),
21+
1922
// 404
2023
USER_NOT_FOUND("UEH4041", HttpStatus.NOT_FOUND, "사용자를 찾을 수 없음", "사용자를 찾을 수 없음"),
2124
;
Lines changed: 40 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
package org.runimo.runimo.config;
22

3+
import static org.runimo.runimo.config.SecurityConstants.ADMIN_ENDPOINT_PATTERN;
4+
import static org.runimo.runimo.config.SecurityConstants.ADMIN_ROLE;
5+
import static org.runimo.runimo.config.SecurityConstants.COMMON_PUBLIC_ENDPOINTS;
6+
import static org.runimo.runimo.config.SecurityConstants.DEV_PUBLIC_ENDPOINTS;
7+
import static org.runimo.runimo.config.SecurityConstants.USER_ENDPOINT_PATTERN;
8+
import static org.runimo.runimo.config.SecurityConstants.USER_ROLE;
9+
310
import lombok.RequiredArgsConstructor;
411
import org.runimo.runimo.auth.filters.JwtAuthenticationFilter;
12+
import org.runimo.runimo.exceptions.CustomAccessDeniedHandler;
513
import org.springframework.context.annotation.Bean;
614
import org.springframework.context.annotation.Configuration;
715
import org.springframework.context.annotation.Profile;
816
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
917
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
1018
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
19+
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
1120
import org.springframework.security.config.http.SessionCreationPolicy;
1221
import org.springframework.security.web.SecurityFilterChain;
1322
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@@ -18,48 +27,52 @@
1827
public class SecurityConfig {
1928

2029
private final JwtAuthenticationFilter jwtAuthenticationFilter;
30+
private final CustomAccessDeniedHandler customAccessDeniedHandler;
2131

2232
@Bean
2333
@Profile({"prod", "test"})
2434
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
25-
http
26-
.csrf(AbstractHttpConfigurer::disable)
27-
.sessionManagement(session -> session
28-
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
29-
)
30-
.authorizeHttpRequests(authorize -> authorize
31-
.requestMatchers("/api/v1/auth/**").permitAll()
32-
.requestMatchers("/api/v1/users/**").hasAnyRole("USER", "ADMIN")
33-
.requestMatchers("/checker/**").permitAll()
34-
.requestMatchers("/actuator/**").permitAll()
35-
.requestMatchers(("/error")).permitAll()
36-
.anyRequest().authenticated()
37-
)
38-
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
39-
40-
return http.build();
35+
return buildSecurityFilterChain(http)
36+
.authorizeHttpRequests(this::configureProdAuthorization)
37+
.build();
4138
}
4239

4340
@Bean
4441
@Profile("dev")
4542
public SecurityFilterChain devSecurityFilterChain(HttpSecurity http) throws Exception {
46-
http
43+
return buildSecurityFilterChain(http)
44+
.authorizeHttpRequests(this::configureDevAuthorization)
45+
.build();
46+
}
47+
48+
private HttpSecurity buildSecurityFilterChain(HttpSecurity http) throws Exception {
49+
return http
4750
.csrf(AbstractHttpConfigurer::disable)
4851
.sessionManagement(session -> session
4952
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
5053
)
51-
.authorizeHttpRequests(authorize -> authorize
52-
.requestMatchers("/api/v1/auth/**").permitAll()
53-
.requestMatchers("/actuator/prometheus").permitAll()
54-
.requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**")
55-
.permitAll()
56-
.requestMatchers(("/error")).permitAll()
57-
.requestMatchers("/api/v1/users/**").hasAnyRole("USER", "ADMIN")
58-
.requestMatchers("/checker/**").permitAll()
59-
.anyRequest().authenticated()
54+
.exceptionHandling(exception -> exception
55+
.accessDeniedHandler(customAccessDeniedHandler)
6056
)
6157
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
58+
}
59+
60+
private void configureProdAuthorization(
61+
AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry authorize) {
62+
authorize
63+
.requestMatchers(COMMON_PUBLIC_ENDPOINTS).permitAll()
64+
.requestMatchers(ADMIN_ENDPOINT_PATTERN).hasRole(ADMIN_ROLE)
65+
.requestMatchers(USER_ENDPOINT_PATTERN).hasAnyRole(USER_ROLE, ADMIN_ROLE)
66+
.anyRequest().authenticated();
67+
}
6268

63-
return http.build();
69+
private void configureDevAuthorization(
70+
AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry authorize) {
71+
authorize
72+
.requestMatchers(COMMON_PUBLIC_ENDPOINTS).permitAll()
73+
.requestMatchers(DEV_PUBLIC_ENDPOINTS).permitAll()
74+
.requestMatchers(ADMIN_ENDPOINT_PATTERN).hasRole(ADMIN_ROLE)
75+
.requestMatchers(USER_ENDPOINT_PATTERN).hasAnyRole(USER_ROLE, ADMIN_ROLE)
76+
.anyRequest().authenticated();
6477
}
6578
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package org.runimo.runimo.config;
2+
3+
import lombok.AccessLevel;
4+
import lombok.NoArgsConstructor;
5+
6+
@NoArgsConstructor(access = AccessLevel.PRIVATE)
7+
final class SecurityConstants {
8+
9+
10+
static final String[] COMMON_PUBLIC_ENDPOINTS = {
11+
"/api/v1/auth/**",
12+
"/checker/**",
13+
"/actuator/**",
14+
"/error"
15+
};
16+
17+
static final String[] DEV_PUBLIC_ENDPOINTS = {
18+
"/swagger-ui/**",
19+
"/swagger-ui.html",
20+
"/v3/api-docs/**"
21+
};
22+
23+
static final String ADMIN_ENDPOINT_PATTERN = "/api/v1/admin/**";
24+
25+
static final String USER_ENDPOINT_PATTERN = "/api/v1/users/**";
26+
27+
static final String USER_ROLE = "USER";
28+
static final String ADMIN_ROLE = "ADMIN";
29+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package org.runimo.runimo.exceptions;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import jakarta.servlet.http.HttpServletRequest;
5+
import jakarta.servlet.http.HttpServletResponse;
6+
import java.io.IOException;
7+
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.runimo.runimo.auth.filters.UserErrorCode;
10+
import org.runimo.runimo.common.response.ErrorResponse;
11+
import org.springframework.http.MediaType;
12+
import org.springframework.security.access.AccessDeniedException;
13+
import org.springframework.security.core.context.SecurityContextHolder;
14+
import org.springframework.security.web.access.AccessDeniedHandler;
15+
import org.springframework.stereotype.Component;
16+
17+
@Slf4j
18+
@Component
19+
@RequiredArgsConstructor
20+
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
21+
22+
private final ObjectMapper objectMapper;
23+
24+
@Override
25+
public void handle(HttpServletRequest request, HttpServletResponse response,
26+
AccessDeniedException accessDeniedException) throws IOException {
27+
28+
log.warn("[ERROR]접근 거부됨 - URI: {}, 사용자: {}", request.getRequestURI(),
29+
SecurityContextHolder.getContext().getAuthentication() != null
30+
? SecurityContextHolder.getContext().getAuthentication().getName() : "Unknown");
31+
32+
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
33+
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
34+
response.setCharacterEncoding("UTF-8");
35+
36+
ErrorResponse errorResponse = ErrorResponse.of(UserErrorCode.INSUFFICIENT_PERMISSIONS);
37+
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
38+
}
39+
}

src/main/java/org/runimo/runimo/exceptions/GlobalExceptionHandler.java

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import java.util.NoSuchElementException;
55
import lombok.extern.slf4j.Slf4j;
66
import org.runimo.runimo.auth.exceptions.SignUpException;
7-
import org.runimo.runimo.auth.exceptions.UnRegisteredUserException;
7+
import org.runimo.runimo.auth.exceptions.UnauthorizedAccessException;
88
import org.runimo.runimo.auth.exceptions.UserJwtException;
99
import org.runimo.runimo.common.response.ErrorResponse;
1010
import org.runimo.runimo.external.ExternalServiceException;
@@ -19,6 +19,7 @@
1919
import org.springframework.web.bind.MissingServletRequestParameterException;
2020
import org.springframework.web.bind.annotation.ExceptionHandler;
2121
import org.springframework.web.bind.annotation.RestControllerAdvice;
22+
import org.springframework.web.servlet.resource.NoResourceFoundException;
2223

2324
@Slf4j
2425
@RestControllerAdvice
@@ -27,6 +28,22 @@ public class GlobalExceptionHandler {
2728
private static final String ERROR_LOG_HEADER = "ERROR: ";
2829

2930

31+
@ExceptionHandler(NoResourceFoundException.class)
32+
public ResponseEntity<ErrorResponse> handleNoResourceFoundException(
33+
NoResourceFoundException e) {
34+
log.warn("{} {}", ERROR_LOG_HEADER, e.getMessage(), e);
35+
return ResponseEntity.status(HttpStatus.NOT_FOUND)
36+
.body(ErrorResponse.of("요청한 리소스를 찾을 수 없습니다.", e.getMessage()));
37+
}
38+
39+
@ExceptionHandler(UnauthorizedAccessException.class)
40+
public ResponseEntity<ErrorResponse> handleUnauthorizedAccessException(
41+
UnauthorizedAccessException e) {
42+
log.debug("{} {}", ERROR_LOG_HEADER, e.getMessage(), e);
43+
return ResponseEntity.status(HttpStatus.FORBIDDEN)
44+
.body(ErrorResponse.of(e.getErrorCode()));
45+
}
46+
3047
@ExceptionHandler(RunimoException.class)
3148
public ResponseEntity<ErrorResponse> handleRunimoException(RunimoException e) {
3249
log.warn("{} {}", ERROR_LOG_HEADER, e.getMessage(), e);
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package org.runimo.runimo.security;
2+
3+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
4+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
5+
6+
import org.junit.jupiter.api.DisplayName;
7+
import org.junit.jupiter.api.Test;
8+
import org.springframework.beans.factory.annotation.Autowired;
9+
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
10+
import org.springframework.boot.test.context.SpringBootTest;
11+
import org.springframework.test.context.ActiveProfiles;
12+
import org.springframework.test.web.servlet.MockMvc;
13+
14+
@SpringBootTest
15+
@AutoConfigureMockMvc
16+
@ActiveProfiles("dev")
17+
class SecurityConfigDevTest {
18+
19+
@Autowired
20+
private MockMvc mockMvc;
21+
22+
@Test
23+
@DisplayName("개발 환경에서 Swagger 접근 가능")
24+
void swaggerAccessibleInDev() throws Exception {
25+
mockMvc.perform(get("/swagger-ui.html"))
26+
.andExpect(status().isFound()); // 302 redirect to swagger-ui/index.html
27+
28+
mockMvc.perform(get("/v3/api-docs"))
29+
.andExpect(status().isOk());
30+
}
31+
32+
@Test
33+
@DisplayName("개발 환경에서도 기본 보안 규칙 적용")
34+
void basicSecurityRulesStillApply() throws Exception {
35+
mockMvc.perform(get("/api/v1/users/me"))
36+
.andExpect(status().isUnauthorized());
37+
38+
mockMvc.perform(get("/api/v1/admin/users"))
39+
.andExpect(status().isUnauthorized());
40+
}
41+
}

0 commit comments

Comments
 (0)