Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package org.runimo.runimo.auth.exceptions;

import org.runimo.runimo.auth.filters.UserErrorCode;
import org.runimo.runimo.exceptions.BusinessException;

public class UnauthorizedAccessException extends BusinessException {

public UnauthorizedAccessException(String message) {
super(UserErrorCode.INSUFFICIENT_PERMISSIONS, message);
}

public UnauthorizedAccessException(Throwable cause) {
super(UserErrorCode.INSUFFICIENT_PERMISSIONS, cause.getMessage());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import org.runimo.runimo.auth.jwt.JwtResolver;
import org.runimo.runimo.auth.jwt.UserDetail;
import org.runimo.runimo.common.response.ErrorResponse;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
Expand All @@ -39,6 +40,14 @@ protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response, @NonNull FilterChain filterChain)
throws IOException {
try {

var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() &&
!(auth instanceof AnonymousAuthenticationToken)) {
filterChain.doFilter(request, response);
return;
}

if (!hasValidAuthorizationHeader(request)) {
setErrorResponse(UserErrorCode.JWT_NOT_FOUND, response);
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ public enum UserErrorCode implements CustomResponseCode {
JWT_BROKEN("UEH4014", HttpStatus.UNAUTHORIZED, "JWT 토큰이 손상되었습니다", "JWT 토큰이 손상되었습니다"),
REFRESH_FAILED("UEH4015", HttpStatus.UNAUTHORIZED, "리프레시 토큰이 만료되었습니다.", "리프레시 토큰이 만료되었습니다."),

// 403
INSUFFICIENT_PERMISSIONS("UEH4031", HttpStatus.FORBIDDEN, "권한이 부족합니다.", "권한이 부족합니다."),

// 404
USER_NOT_FOUND("UEH4041", HttpStatus.NOT_FOUND, "사용자를 찾을 수 없음", "사용자를 찾을 수 없음"),
;
Expand Down
67 changes: 40 additions & 27 deletions src/main/java/org/runimo/runimo/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
package org.runimo.runimo.config;

import static org.runimo.runimo.config.SecurityConstants.ADMIN_ENDPOINT_PATTERN;
import static org.runimo.runimo.config.SecurityConstants.ADMIN_ROLE;
import static org.runimo.runimo.config.SecurityConstants.COMMON_PUBLIC_ENDPOINTS;
import static org.runimo.runimo.config.SecurityConstants.DEV_PUBLIC_ENDPOINTS;
import static org.runimo.runimo.config.SecurityConstants.USER_ENDPOINT_PATTERN;
import static org.runimo.runimo.config.SecurityConstants.USER_ROLE;

import lombok.RequiredArgsConstructor;
import org.runimo.runimo.auth.filters.JwtAuthenticationFilter;
import org.runimo.runimo.exceptions.CustomAccessDeniedHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
Expand All @@ -18,48 +27,52 @@
public class SecurityConfig {

private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final CustomAccessDeniedHandler customAccessDeniedHandler;

@Bean
@Profile({"prod", "test"})
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/api/v1/users/**").hasAnyRole("USER", "ADMIN")
.requestMatchers("/checker/**").permitAll()
.requestMatchers("/actuator/**").permitAll()
.requestMatchers(("/error")).permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

return http.build();
return buildSecurityFilterChain(http)
.authorizeHttpRequests(this::configureProdAuthorization)
.build();
}

@Bean
@Profile("dev")
public SecurityFilterChain devSecurityFilterChain(HttpSecurity http) throws Exception {
http
return buildSecurityFilterChain(http)
.authorizeHttpRequests(this::configureDevAuthorization)
.build();
}

private HttpSecurity buildSecurityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/actuator/prometheus").permitAll()
.requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**")
.permitAll()
.requestMatchers(("/error")).permitAll()
.requestMatchers("/api/v1/users/**").hasAnyRole("USER", "ADMIN")
.requestMatchers("/checker/**").permitAll()
.anyRequest().authenticated()
.exceptionHandling(exception -> exception
.accessDeniedHandler(customAccessDeniedHandler)
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}

private void configureProdAuthorization(
AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry authorize) {
authorize
.requestMatchers(COMMON_PUBLIC_ENDPOINTS).permitAll()
.requestMatchers(ADMIN_ENDPOINT_PATTERN).hasRole(ADMIN_ROLE)
.requestMatchers(USER_ENDPOINT_PATTERN).hasAnyRole(USER_ROLE, ADMIN_ROLE)
.anyRequest().authenticated();
}

return http.build();
private void configureDevAuthorization(
AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry authorize) {
authorize
.requestMatchers(COMMON_PUBLIC_ENDPOINTS).permitAll()
.requestMatchers(DEV_PUBLIC_ENDPOINTS).permitAll()
.requestMatchers(ADMIN_ENDPOINT_PATTERN).hasRole(ADMIN_ROLE)
.requestMatchers(USER_ENDPOINT_PATTERN).hasAnyRole(USER_ROLE, ADMIN_ROLE)
.anyRequest().authenticated();
}
}
29 changes: 29 additions & 0 deletions src/main/java/org/runimo/runimo/config/SecurityConstants.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package org.runimo.runimo.config;

import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
final class SecurityConstants {


static final String[] COMMON_PUBLIC_ENDPOINTS = {
"/api/v1/auth/**",
"/checker/**",
"/actuator/**",
"/error"
};

static final String[] DEV_PUBLIC_ENDPOINTS = {
"/swagger-ui/**",
"/swagger-ui.html",
"/v3/api-docs/**"
};

static final String ADMIN_ENDPOINT_PATTERN = "/api/v1/admin/**";

static final String USER_ENDPOINT_PATTERN = "/api/v1/users/**";

static final String USER_ROLE = "USER";
static final String ADMIN_ROLE = "ADMIN";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package org.runimo.runimo.exceptions;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.runimo.runimo.auth.filters.UserErrorCode;
import org.runimo.runimo.common.response.ErrorResponse;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

private final ObjectMapper objectMapper;

@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {

log.warn("[ERROR]접근 거부됨 - URI: {}, 사용자: {}", request.getRequestURI(),
SecurityContextHolder.getContext().getAuthentication() != null
? SecurityContextHolder.getContext().getAuthentication().getName() : "Unknown");

response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");

ErrorResponse errorResponse = ErrorResponse.of(UserErrorCode.INSUFFICIENT_PERMISSIONS);
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import java.util.NoSuchElementException;
import lombok.extern.slf4j.Slf4j;
import org.runimo.runimo.auth.exceptions.SignUpException;
import org.runimo.runimo.auth.exceptions.UnRegisteredUserException;
import org.runimo.runimo.auth.exceptions.UnauthorizedAccessException;
import org.runimo.runimo.auth.exceptions.UserJwtException;
import org.runimo.runimo.common.response.ErrorResponse;
import org.runimo.runimo.external.ExternalServiceException;
Expand All @@ -19,6 +19,7 @@
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;

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


@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<ErrorResponse> handleNoResourceFoundException(
NoResourceFoundException e) {
log.warn("{} {}", ERROR_LOG_HEADER, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse.of("요청한 리소스를 찾을 수 없습니다.", e.getMessage()));
}

@ExceptionHandler(UnauthorizedAccessException.class)
public ResponseEntity<ErrorResponse> handleUnauthorizedAccessException(
UnauthorizedAccessException e) {
log.debug("{} {}", ERROR_LOG_HEADER, e.getMessage(), e);
Copy link

Copilot AI Jun 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Consider using a higher log level (e.g., warn) for UnauthorizedAccessException to ensure that potentially critical security issues are adequately logged in production.

Suggested change
log.debug("{} {}", ERROR_LOG_HEADER, e.getMessage(), e);
log.warn("{} {}", ERROR_LOG_HEADER, e.getMessage(), e);

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jeeheaG 권한 없는 사용자에 대한 로그를 warn으로 두는게 좋을까요?

return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ErrorResponse.of(e.getErrorCode()));
}
Comment on lines +39 to +45
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Increase log level for security violations.

Unauthorized access attempts should be logged at a higher level than debug for security monitoring. Consider using log.warn() or log.info() for better visibility in production logs.

-        log.debug("{} {}", ERROR_LOG_HEADER, e.getMessage(), e);
+        log.warn("{} {}", ERROR_LOG_HEADER, e.getMessage(), e);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@ExceptionHandler(UnauthorizedAccessException.class)
public ResponseEntity<ErrorResponse> handleUnauthorizedAccessException(
UnauthorizedAccessException e) {
log.debug("{} {}", ERROR_LOG_HEADER, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ErrorResponse.of(e.getErrorCode()));
}
@ExceptionHandler(UnauthorizedAccessException.class)
public ResponseEntity<ErrorResponse> handleUnauthorizedAccessException(
UnauthorizedAccessException e) {
log.warn("{} {}", ERROR_LOG_HEADER, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ErrorResponse.of(e.getErrorCode()));
}
🤖 Prompt for AI Agents
In src/main/java/org/runimo/runimo/exceptions/GlobalExceptionHandler.java around
lines 39 to 45, the log level for unauthorized access exceptions is set to
debug, which is too low for security-related events. Change the log method from
log.debug() to log.warn() or log.info() to increase visibility of these security
violations in production logs.


@ExceptionHandler(RunimoException.class)
public ResponseEntity<ErrorResponse> handleRunimoException(RunimoException e) {
log.warn("{} {}", ERROR_LOG_HEADER, e.getMessage(), e);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package org.runimo.runimo.security;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
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.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("dev")
class SecurityConfigDevTest {

@Autowired
private MockMvc mockMvc;

@Test
@DisplayName("개발 환경에서 Swagger 접근 가능")
void swaggerAccessibleInDev() throws Exception {
mockMvc.perform(get("/swagger-ui.html"))
.andExpect(status().isFound()); // 302 redirect to swagger-ui/index.html

mockMvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk());
}

@Test
@DisplayName("개발 환경에서도 기본 보안 규칙 적용")
void basicSecurityRulesStillApply() throws Exception {
mockMvc.perform(get("/api/v1/users/me"))
.andExpect(status().isUnauthorized());

mockMvc.perform(get("/api/v1/admin/users"))
.andExpect(status().isUnauthorized());
}
}
Loading