[보안 설정 개선] JwtTokenIssuanceFilter 및 JwtTokenValidationFilter 추가로 JWT 인증 로직을 강화하고, SecurityPathConfig를 통해 허용 경로 설정을 외부화하여 보안 유연성 향상. JwtAccessDeniedHandler 및 JwtAuthenticationEntryPoint 삭제로 코드 정리.

This commit is contained in:
sohot8653
2025-08-22 00:58:15 +09:00
parent 09e06cd10b
commit 1850227d24
10 changed files with 100 additions and 138 deletions

View File

@@ -2,10 +2,12 @@ package com.bio.bio_backend;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@SpringBootApplication
@EnableJpaAuditing
@EnableConfigurationProperties
public class BioBackendApplication {
public static void main(String[] args) {

View File

@@ -0,0 +1,45 @@
package com.bio.bio_backend.global.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
@ConfigurationProperties(prefix = "security")
public class SecurityPathConfig {
private List<String> permitAllPaths;
public List<String> getPermitAllPaths() {
return permitAllPaths;
}
public void setPermitAllPaths(List<String> permitAllPaths) {
this.permitAllPaths = permitAllPaths;
}
/**
* 주어진 경로가 허용된 경로인지 확인
* @param path 확인할 경로
* @param contextPath 컨텍스트 경로
* @return 허용 여부
*/
public boolean isPermittedPath(String path, String contextPath) {
if (permitAllPaths == null) {
return false;
}
return permitAllPaths.stream()
.anyMatch(permittedPath -> {
String fullPath = contextPath + permittedPath;
if (permittedPath.endsWith("/**")) {
// /** 패턴 처리
String basePath = fullPath.substring(0, fullPath.length() - 2);
return path.startsWith(basePath);
} else {
return path.equals(fullPath);
}
});
}
}

View File

@@ -1,41 +0,0 @@
package com.bio.bio_backend.global.exception;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerExceptionResolver;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import com.bio.bio_backend.global.dto.ApiResponseDto;
import com.bio.bio_backend.global.constants.ApiResponseCode;
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
private final HandlerExceptionResolver resolver;
public JwtAccessDeniedHandler(@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
this.resolver = resolver;
}
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
Exception exception = (Exception) request.getAttribute("exception");
if (exception != null) {
resolver.resolveException(request, response, null, exception);
} else {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
new ObjectMapper().writeValue(response.getWriter(), ApiResponseDto.fail(ApiResponseCode.COMMON_FORBIDDEN));
}
}
}

View File

@@ -1,35 +0,0 @@
package com.bio.bio_backend.global.exception;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerExceptionResolver;
import java.io.IOException;
@Component
@Slf4j
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final HandlerExceptionResolver resolver;
public JwtAuthenticationEntryPoint(@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
this.resolver = resolver;
}
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
Exception exception = (Exception) request.getAttribute("exception");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
if (exception == null) {
return;
}
resolver.resolveException(request, response, null, exception);
}
}

View File

@@ -1,4 +1,4 @@
package com.bio.bio_backend.global.security;
package com.bio.bio_backend.global.filter;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
@@ -9,13 +9,11 @@ import com.bio.bio_backend.global.dto.ApiResponseDto;
import com.bio.bio_backend.domain.base.member.dto.LoginRequestDto;
import com.bio.bio_backend.domain.base.member.dto.LoginResponseDto;
import com.bio.bio_backend.domain.base.member.dto.MemberDto;
import com.bio.bio_backend.domain.base.member.service.MemberService;
import com.bio.bio_backend.global.constants.ApiResponseCode;
import com.bio.bio_backend.global.utils.JwtUtils;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.AuthenticationManager;
@@ -30,36 +28,22 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Objects;
@RequiredArgsConstructor
@Slf4j
public class JwtTokenIssuanceFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final MemberService memberService;
private final JwtUtils jwtUtils;
private final Environment env;
private final ObjectMapper objectMapper;
// 사용자 login 인증 처리
@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
LoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class);
// UsernamePasswordAuthenticationToken authToken;
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(requestDto.getUserId(), requestDto.getPassword());
/*
if (req.getLoginLogFlag() == 1) {
authToken = new UsernamePasswordAuthenticationToken("admin2", "test123!"); // 비밀번호는 실제 비밀번호로 설정해야
} else {
throw new AuthenticationServiceException("login fail");
}
*/
return authenticationManager.authenticate(authToken);
}
@@ -69,23 +53,22 @@ public class JwtTokenIssuanceFilter extends UsernamePasswordAuthenticationFilter
FilterChain chain, Authentication authResult) throws IOException, ServletException {
UserDetails userDetails = (UserDetails) authResult.getPrincipal();
MemberDto member = (MemberDto) userDetails;
String accessToken = jwtUtils.generateToken(member.getUserId(), member.getRole().getValue(),
Long.parseLong(Objects.requireNonNull(env.getProperty("token.expiration_time_access"))));
// 모듈화된 메서드 사용
String refreshToken = jwtUtils.createAndSaveRefreshToken(member.getUserId(), member.getRole().getValue());
// 토큰 생성
String accessToken = jwtUtils.createAccessToken(member.getUserId(), member.getRole().getValue());
String refreshToken = jwtUtils.createRefreshToken(member.getUserId(), member.getRole().getValue());
// Refresh 토큰을 DB에 저장
jwtUtils.saveRefreshToken(member.getUserId(), refreshToken);
member.setRefreshToken(refreshToken);
member.setLastLoginAt(LocalDateTime.now());
// Refresh 토큰 쿠키 저장 - 모듈화된 메서드 사용
// Refresh 토큰 쿠키 저장
jwtUtils.setRefreshTokenCookie(response, refreshToken);
// JWT 토큰 전달
// Access 토큰 전달
response.setHeader("Authorization", "Bearer " + accessToken);
SecurityContextHolderStrategy contextHolder = SecurityContextHolder.getContextHolderStrategy();

View File

@@ -1,4 +1,4 @@
package com.bio.bio_backend.global.security;
package com.bio.bio_backend.global.filter;
import java.io.IOException;
import java.util.Objects;
@@ -21,6 +21,7 @@ import com.bio.bio_backend.global.dto.ApiResponseDto;
import com.bio.bio_backend.domain.base.member.service.MemberService;
import com.bio.bio_backend.global.constants.ApiResponseCode;
import com.bio.bio_backend.global.utils.JwtUtils;
import com.bio.bio_backend.global.config.SecurityPathConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -31,16 +32,14 @@ public class JwtTokenValidationFilter extends OncePerRequestFilter {
private final JwtUtils jwtUtils;
private final MemberService memberService;
private final Environment env;
private final SecurityPathConfig securityPathConfig;
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String path = request.getRequestURI();
// permitAll 경로는 JWT 검증을 건너뜀 (WebSecurity.java의 설정과 동기화)
return path.equals("/login") ||
path.startsWith("/members/register") ||
path.startsWith("/swagger-ui/") ||
path.startsWith("/api-docs/") ||
path.startsWith("/ws/");
String contextPath = env.getProperty("server.servlet.context-path", "");
return securityPathConfig.isPermittedPath(path, contextPath);
}
@Override

View File

@@ -1,11 +1,9 @@
package com.bio.bio_backend.global.security;
package com.bio.bio_backend.global.filter;
import java.util.Arrays;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import jakarta.servlet.http.HttpServletRequest;

View File

@@ -1,5 +1,7 @@
package com.bio.bio_backend.global.security;
import com.bio.bio_backend.global.filter.JwtTokenIssuanceFilter;
import com.bio.bio_backend.global.filter.JwtTokenValidationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
@@ -18,9 +20,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.bio.bio_backend.domain.base.member.service.MemberService;
import com.bio.bio_backend.global.exception.CustomAuthenticationFailureHandler;
import com.bio.bio_backend.global.exception.JwtAccessDeniedHandler;
import com.bio.bio_backend.global.exception.JwtAuthenticationEntryPoint;
import com.bio.bio_backend.global.utils.JwtUtils;
import com.bio.bio_backend.global.config.SecurityPathConfig;
import lombok.RequiredArgsConstructor;
@Configuration
@@ -33,18 +34,17 @@ public class WebSecurity {
private final JwtUtils jwtUtils;
private final ObjectMapper objectMapper;
private final Environment env;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final SecurityPathConfig securityPathConfig;
private JwtTokenIssuanceFilter getJwtTokenIssuanceFilter(AuthenticationManager authenticationManager) throws Exception {
JwtTokenIssuanceFilter filter = new JwtTokenIssuanceFilter(authenticationManager, memberService, jwtUtils, env, objectMapper);
filter.setFilterProcessesUrl("/login"); // 로그인 EndPoint
JwtTokenIssuanceFilter filter = new JwtTokenIssuanceFilter(authenticationManager, jwtUtils, objectMapper);
filter.setFilterProcessesUrl("/login");
filter.setAuthenticationFailureHandler(new CustomAuthenticationFailureHandler(objectMapper));
return filter;
}
private JwtTokenValidationFilter getJwtTokenValidationFilter() {
return new JwtTokenValidationFilter(jwtUtils, memberService, env);
return new JwtTokenValidationFilter(jwtUtils, memberService, env, securityPathConfig);
}
@@ -59,12 +59,13 @@ public class WebSecurity {
AuthenticationManager authenticationManager = authenticationManagerBuilder.build();
// 설정 파일에서 허용할 경로 가져오기
String[] permitAllPaths = securityPathConfig.getPermitAllPaths().toArray(new String[0]);
http.csrf(AbstractHttpConfigurer::disable) //csrf 비활성화
.authorizeHttpRequests(request -> //request 허용 설정
request
.requestMatchers("/members/register").permitAll()
.requestMatchers("/swagger-ui/**", "/api-docs/**").permitAll() // Swagger 허용
.requestMatchers("/ws/**").permitAll() // WebSocket 허용
.requestMatchers(permitAllPaths).permitAll() // 설정 파일에서 허용할 경로
.anyRequest().authenticated() // 나머지 요청은 인증 필요
)
.authenticationManager(authenticationManager)
@@ -72,14 +73,6 @@ public class WebSecurity {
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션 사용 안함
)
.logout(AbstractHttpConfigurer::disable);
// 예외 처리 핸들링
http.exceptionHandling((exceptionConfig) ->
exceptionConfig
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
);
http
// 1단계: JWT 토큰 발급 필터 (로그인 요청 처리 및 토큰 발급)
.addFilterBefore(getJwtTokenIssuanceFilter(authenticationManager), UsernamePasswordAuthenticationFilter.class)

View File

@@ -93,17 +93,30 @@ public class JwtUtils {
return null;
}
// Refresh Token 생성 및 DB 저장
public String createAndSaveRefreshToken(String username, String role) {
String refreshToken = generateToken(username, role,
Long.parseLong(Objects.requireNonNull(env.getProperty("token.expiration_time_refresh"))));
// Access Token 생성
public String createAccessToken(String username, String role) {
return generateToken(username, role,
Long.parseLong(Objects.requireNonNull(env.getProperty("token.expiration_time_access"))));
}
// MemberDto 객체 생성하여 updateRefreshToken 호출
// Refresh Token 생성
public String createRefreshToken(String username, String role) {
return generateToken(username, role,
Long.parseLong(Objects.requireNonNull(env.getProperty("token.expiration_time_refresh"))));
}
// Refresh Token을 DB에 저장
public void saveRefreshToken(String username, String refreshToken) {
MemberDto member = new MemberDto();
member.setUserId(username);
member.setRefreshToken(refreshToken);
memberService.updateRefreshToken(member);
}
// Refresh Token 생성 및 DB 저장 (기존 호환성을 위한 메서드)
public String createAndSaveRefreshToken(String username, String role) {
String refreshToken = createRefreshToken(username, role);
saveRefreshToken(username, refreshToken);
return refreshToken;
}

View File

@@ -95,3 +95,8 @@ springdoc.swagger-ui.doc-expansion=none
springdoc.swagger-ui.disable-swagger-default-url=true
springdoc.default-produces-media-type=application/json
springdoc.default-consumes-media-type=application/json
# ========================================
# 보안 설정 - 허용할 경로
# ========================================
security.permit-all-paths=/login,/members/register,/swagger-ui/**,/api-docs/**,/ws/**