mirror of
https://github.com/won-ktds/smarketing-backend.git
synced 2026-06-13 04:49:10 +00:00
add : init project
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
package com.won.smarketing.common.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
/**
|
||||
* Redis 설정 클래스
|
||||
* Redis 연결 및 템플릿 설정
|
||||
*/
|
||||
@Configuration
|
||||
public class RedisConfig {
|
||||
|
||||
@Value("${spring.data.redis.host}")
|
||||
private String redisHost;
|
||||
|
||||
@Value("${spring.data.redis.port}")
|
||||
private int redisPort;
|
||||
|
||||
/**
|
||||
* Redis 연결 팩토리 설정
|
||||
*
|
||||
* @return Redis 연결 팩토리
|
||||
*/
|
||||
@Bean
|
||||
public RedisConnectionFactory redisConnectionFactory() {
|
||||
return new LettuceConnectionFactory(redisHost, redisPort);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis 템플릿 설정
|
||||
*
|
||||
* @return Redis 템플릿
|
||||
*/
|
||||
@Bean
|
||||
public RedisTemplate<String, String> redisTemplate() {
|
||||
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
|
||||
redisTemplate.setConnectionFactory(redisConnectionFactory());
|
||||
redisTemplate.setKeySerializer(new StringRedisSerializer());
|
||||
redisTemplate.setValueSerializer(new StringRedisSerializer());
|
||||
return redisTemplate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.won.smarketing.common.config;
|
||||
|
||||
import com.won.smarketing.common.security.JwtAuthenticationFilter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
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.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Spring Security 설정 클래스
|
||||
* 인증, 인가, CORS 등 보안 관련 설정
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
|
||||
/**
|
||||
* 패스워드 인코더 Bean 설정
|
||||
*
|
||||
* @return BCrypt 패스워드 인코더
|
||||
*/
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Security Filter Chain 설정
|
||||
*
|
||||
* @param http HttpSecurity 객체
|
||||
* @return SecurityFilterChain
|
||||
* @throws Exception 예외
|
||||
*/
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.sessionManagement(session ->
|
||||
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(
|
||||
"/api/member/register",
|
||||
"/api/member/check-duplicate",
|
||||
"/api/member/validate-password",
|
||||
"/api/auth/login",
|
||||
"/swagger-ui/**",
|
||||
"/swagger-ui.html",
|
||||
"/api-docs/**",
|
||||
"/actuator/**"
|
||||
).permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* CORS 설정
|
||||
*
|
||||
* @return CORS 설정 소스
|
||||
*/
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOriginPatterns(Arrays.asList("*"));
|
||||
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||
configuration.setAllowedHeaders(Arrays.asList("authorization", "content-type", "x-auth-token"));
|
||||
configuration.setExposedHeaders(Arrays.asList("x-auth-token"));
|
||||
configuration.setAllowCredentials(true);
|
||||
configuration.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.won.smarketing.common.config;
|
||||
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.models.security.SecurityScheme;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Swagger 설정 클래스
|
||||
* API 문서화를 위한 OpenAPI 설정
|
||||
*/
|
||||
@Configuration
|
||||
public class SwaggerConfig {
|
||||
|
||||
/**
|
||||
* OpenAPI 설정
|
||||
*
|
||||
* @return OpenAPI 인스턴스
|
||||
*/
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
String securitySchemeName = "bearerAuth";
|
||||
|
||||
return new OpenAPI()
|
||||
.info(new Info()
|
||||
.title("AI 마케팅 서비스 API")
|
||||
.description("소상공인을 위한 맞춤형 AI 마케팅 솔루션 API 문서")
|
||||
.version("v1.0.0"))
|
||||
.addSecurityItem(new SecurityRequirement().addList(securitySchemeName))
|
||||
.components(new Components()
|
||||
.addSecuritySchemes(securitySchemeName,
|
||||
new SecurityScheme()
|
||||
.name(securitySchemeName)
|
||||
.type(SecurityScheme.Type.HTTP)
|
||||
.scheme("bearer")
|
||||
.bearerFormat("JWT")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.won.smarketing.common.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 표준 API 응답 DTO
|
||||
* 모든 API 응답에 사용되는 공통 형식
|
||||
*
|
||||
* @param <T> 응답 데이터 타입
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Schema(description = "API 응답")
|
||||
public class ApiResponse<T> {
|
||||
|
||||
@Schema(description = "응답 상태 코드", example = "200")
|
||||
private int status;
|
||||
|
||||
@Schema(description = "응답 메시지", example = "요청이 성공적으로 처리되었습니다.")
|
||||
private String message;
|
||||
|
||||
@Schema(description = "응답 데이터")
|
||||
private T data;
|
||||
|
||||
/**
|
||||
* 성공 응답 생성 (데이터 포함)
|
||||
*
|
||||
* @param data 응답 데이터
|
||||
* @param <T> 데이터 타입
|
||||
* @return 성공 응답
|
||||
*/
|
||||
public static <T> ApiResponse<T> success(T data) {
|
||||
return ApiResponse.<T>builder()
|
||||
.status(200)
|
||||
.message("요청이 성공적으로 처리되었습니다.")
|
||||
.data(data)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공 응답 생성 (데이터 및 메시지 포함)
|
||||
*
|
||||
* @param data 응답 데이터
|
||||
* @param message 응답 메시지
|
||||
* @param <T> 데이터 타입
|
||||
* @return 성공 응답
|
||||
*/
|
||||
public static <T> ApiResponse<T> success(T data, String message) {
|
||||
return ApiResponse.<T>builder()
|
||||
.status(200)
|
||||
.message(message)
|
||||
.data(data)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 오류 응답 생성
|
||||
*
|
||||
* @param status 오류 상태 코드
|
||||
* @param message 오류 메시지
|
||||
* @param <T> 데이터 타입
|
||||
* @return 오류 응답
|
||||
*/
|
||||
public static <T> ApiResponse<T> error(int status, String message) {
|
||||
return ApiResponse.<T>builder()
|
||||
.status(status)
|
||||
.message(message)
|
||||
.data(null)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.won.smarketing.common.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 페이지네이션 응답 DTO
|
||||
* 페이지 단위 조회 결과를 담는 공통 형식
|
||||
*
|
||||
* @param <T> 페이지 내용의 데이터 타입
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Schema(description = "페이지네이션 응답")
|
||||
public class PageResponse<T> {
|
||||
|
||||
@Schema(description = "페이지 내용")
|
||||
private List<T> content;
|
||||
|
||||
@Schema(description = "현재 페이지 번호", example = "0")
|
||||
private int pageNumber;
|
||||
|
||||
@Schema(description = "페이지 크기", example = "20")
|
||||
private int pageSize;
|
||||
|
||||
@Schema(description = "전체 요소 수", example = "100")
|
||||
private long totalElements;
|
||||
|
||||
@Schema(description = "전체 페이지 수", example = "5")
|
||||
private int totalPages;
|
||||
|
||||
@Schema(description = "첫 번째 페이지 여부", example = "true")
|
||||
private boolean first;
|
||||
|
||||
@Schema(description = "마지막 페이지 여부", example = "false")
|
||||
private boolean last;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.won.smarketing.common.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 비즈니스 로직 예외
|
||||
* 애플리케이션 내 비즈니스 규칙 위반 시 발생하는 예외
|
||||
*/
|
||||
@Getter
|
||||
public class BusinessException extends RuntimeException {
|
||||
|
||||
private final ErrorCode errorCode;
|
||||
|
||||
/**
|
||||
* 비즈니스 예외 생성자
|
||||
*
|
||||
* @param errorCode 오류 코드
|
||||
*/
|
||||
public BusinessException(ErrorCode errorCode) {
|
||||
super(errorCode.getMessage());
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 비즈니스 예외 생성자 (추가 메시지 포함)
|
||||
*
|
||||
* @param errorCode 오류 코드
|
||||
* @param message 추가 메시지
|
||||
*/
|
||||
public BusinessException(ErrorCode errorCode, String message) {
|
||||
super(message);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.won.smarketing.common.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
/**
|
||||
* 애플리케이션 오류 코드 정의
|
||||
* 각 오류 상황에 대한 코드, HTTP 상태, 메시지 정의
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public enum ErrorCode {
|
||||
|
||||
// 회원 관련 오류
|
||||
MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "M001", "회원을 찾을 수 없습니다."),
|
||||
DUPLICATE_MEMBER_ID(HttpStatus.BAD_REQUEST, "M002", "이미 사용 중인 사용자 ID입니다."),
|
||||
DUPLICATE_EMAIL(HttpStatus.BAD_REQUEST, "M003", "이미 사용 중인 이메일입니다."),
|
||||
DUPLICATE_BUSINESS_NUMBER(HttpStatus.BAD_REQUEST, "M004", "이미 등록된 사업자 번호입니다."),
|
||||
INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "M005", "잘못된 패스워드입니다."),
|
||||
INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "M006", "유효하지 않은 토큰입니다."),
|
||||
TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "M007", "만료된 토큰입니다."),
|
||||
|
||||
// 매장 관련 오류
|
||||
STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "S001", "매장을 찾을 수 없습니다."),
|
||||
STORE_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "S002", "이미 등록된 매장이 있습니다."),
|
||||
MENU_NOT_FOUND(HttpStatus.NOT_FOUND, "S003", "메뉴를 찾을 수 없습니다."),
|
||||
|
||||
// 마케팅 콘텐츠 관련 오류
|
||||
CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "C001", "콘텐츠를 찾을 수 없습니다."),
|
||||
CONTENT_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "C002", "콘텐츠 생성에 실패했습니다."),
|
||||
AI_SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "C003", "AI 서비스를 사용할 수 없습니다."),
|
||||
|
||||
// AI 추천 관련 오류
|
||||
RECOMMENDATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "R001", "추천 생성에 실패했습니다."),
|
||||
EXTERNAL_API_ERROR(HttpStatus.SERVICE_UNAVAILABLE, "R002", "외부 API 호출에 실패했습니다."),
|
||||
|
||||
// 공통 오류
|
||||
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "G001", "서버 내부 오류가 발생했습니다."),
|
||||
INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "G002", "잘못된 입력값입니다."),
|
||||
INVALID_TYPE_VALUE(HttpStatus.BAD_REQUEST, "G003", "잘못된 타입의 값입니다."),
|
||||
MISSING_REQUEST_PARAMETER(HttpStatus.BAD_REQUEST, "G004", "필수 요청 파라미터가 누락되었습니다."),
|
||||
ACCESS_DENIED(HttpStatus.FORBIDDEN, "G005", "접근이 거부되었습니다."),
|
||||
METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "G006", "허용되지 않은 HTTP 메서드입니다.");
|
||||
|
||||
private final HttpStatus httpStatus;
|
||||
private final String code;
|
||||
private final String message;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.won.smarketing.common.exception;
|
||||
|
||||
import com.won.smarketing.common.dto.ApiResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
|
||||
import java.nio.file.AccessDeniedException;
|
||||
|
||||
/**
|
||||
* 전역 예외 처리 핸들러
|
||||
* 애플리케이션에서 발생하는 모든 예외를 처리하여 일관된 응답 형식 제공
|
||||
*/
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
/**
|
||||
* 비즈니스 예외 처리
|
||||
*
|
||||
* @param ex 비즈니스 예외
|
||||
* @return 오류 응답
|
||||
*/
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException ex) {
|
||||
log.warn("Business exception occurred: {}", ex.getMessage());
|
||||
ErrorCode errorCode = ex.getErrorCode();
|
||||
return ResponseEntity
|
||||
.status(errorCode.getHttpStatus())
|
||||
.body(ApiResponse.error(errorCode.getHttpStatus().value(), ex.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 유효성 검증 예외 처리 (@Valid 애노테이션)
|
||||
*
|
||||
* @param ex 유효성 검증 예외
|
||||
* @return 오류 응답
|
||||
*/
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleValidationException(MethodArgumentNotValidException ex) {
|
||||
log.warn("Validation exception occurred: {}", ex.getMessage());
|
||||
String errorMessage = ex.getBindingResult()
|
||||
.getFieldErrors()
|
||||
.stream()
|
||||
.findFirst()
|
||||
.map(error -> error.getDefaultMessage())
|
||||
.orElse("유효성 검증에 실패했습니다.");
|
||||
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), errorMessage));
|
||||
}
|
||||
|
||||
/**
|
||||
* 바인딩 예외 처리
|
||||
*
|
||||
* @param ex 바인딩 예외
|
||||
* @return 오류 응답
|
||||
*/
|
||||
@ExceptionHandler(BindException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleBindException(BindException ex) {
|
||||
log.warn("Bind exception occurred: {}", ex.getMessage());
|
||||
String errorMessage = ex.getBindingResult()
|
||||
.getFieldErrors()
|
||||
.stream()
|
||||
.findFirst()
|
||||
.map(error -> error.getDefaultMessage())
|
||||
.orElse("요청 데이터 바인딩에 실패했습니다.");
|
||||
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), errorMessage));
|
||||
}
|
||||
|
||||
/**
|
||||
* 타입 불일치 예외 처리
|
||||
*
|
||||
* @param ex 타입 불일치 예외
|
||||
* @return 오류 응답
|
||||
*/
|
||||
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleTypeMismatchException(MethodArgumentTypeMismatchException ex) {
|
||||
log.warn("Type mismatch exception occurred: {}", ex.getMessage());
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), "잘못된 타입의 값입니다."));
|
||||
}
|
||||
|
||||
/**
|
||||
* 필수 파라미터 누락 예외 처리
|
||||
*
|
||||
* @param ex 필수 파라미터 누락 예외
|
||||
* @return 오류 응답
|
||||
*/
|
||||
@ExceptionHandler(MissingServletRequestParameterException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleMissingParameterException(MissingServletRequestParameterException ex) {
|
||||
log.warn("Missing parameter exception occurred: {}", ex.getMessage());
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), "필수 요청 파라미터가 누락되었습니다: " + ex.getParameterName()));
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 메서드 불일치 예외 처리
|
||||
*
|
||||
* @param ex HTTP 메서드 불일치 예외
|
||||
* @return 오류 응답
|
||||
*/
|
||||
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleMethodNotSupportedException(HttpRequestMethodNotSupportedException ex) {
|
||||
log.warn("Method not supported exception occurred: {}", ex.getMessage());
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.METHOD_NOT_ALLOWED)
|
||||
.body(ApiResponse.error(HttpStatus.METHOD_NOT_ALLOWED.value(), "허용되지 않은 HTTP 메서드입니다."));
|
||||
}
|
||||
|
||||
/**
|
||||
* 접근 거부 예외 처리
|
||||
*
|
||||
* @param ex 접근 거부 예외
|
||||
* @return 오류 응답
|
||||
*/
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleAccessDeniedException(AccessDeniedException ex) {
|
||||
log.warn("Access denied exception occurred: {}", ex.getMessage());
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(HttpStatus.FORBIDDEN.value(), "접근이 거부되었습니다."));
|
||||
}
|
||||
|
||||
/**
|
||||
* 기타 모든 예외 처리
|
||||
*
|
||||
* @param ex 예외
|
||||
* @return 오류 응답
|
||||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleGenericException(Exception ex) {
|
||||
log.error("Unexpected exception occurred", ex);
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부 오류가 발생했습니다."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.won.smarketing.common.security;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
|
||||
/**
|
||||
* JWT 인증 필터
|
||||
* 요청 헤더에서 JWT 토큰을 추출하여 인증 처리
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private static final String AUTHORIZATION_HEADER = "Authorization";
|
||||
private static final String BEARER_PREFIX = "Bearer ";
|
||||
|
||||
/**
|
||||
* JWT 토큰 인증 처리
|
||||
*
|
||||
* @param request HTTP 요청
|
||||
* @param response HTTP 응답
|
||||
* @param filterChain 필터 체인
|
||||
* @throws ServletException 서블릿 예외
|
||||
* @throws IOException I/O 예외
|
||||
*/
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
// 요청 헤더에서 JWT 토큰 추출
|
||||
String token = resolveToken(request);
|
||||
|
||||
// 토큰이 있고 유효한 경우 인증 정보 설정
|
||||
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
|
||||
String userId = jwtTokenProvider.getUserIdFromToken(token);
|
||||
Authentication authentication = new UsernamePasswordAuthenticationToken(
|
||||
userId, null, Collections.emptyList());
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
log.debug("Security context에 '{}' 인증 정보를 저장했습니다.", userId);
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 헤더에서 JWT 토큰 추출
|
||||
*
|
||||
* @param request HTTP 요청
|
||||
* @return JWT 토큰 (Bearer 접두사 제거)
|
||||
*/
|
||||
private String resolveToken(HttpServletRequest request) {
|
||||
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
|
||||
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
|
||||
return bearerToken.substring(BEARER_PREFIX.length());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.won.smarketing.common.security;
|
||||
|
||||
import com.won.smarketing.common.exception.BusinessException;
|
||||
import com.won.smarketing.common.exception.ErrorCode;
|
||||
import io.jsonwebtoken.*;
|
||||
import io.jsonwebtoken.io.Decoders;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* JWT 토큰 생성 및 검증 유틸리티
|
||||
* Access Token과 Refresh Token 관리
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class JwtTokenProvider {
|
||||
|
||||
private final SecretKey key;
|
||||
private final long accessTokenValidityTime;
|
||||
private final long refreshTokenValidityTime;
|
||||
|
||||
/**
|
||||
* JWT 토큰 프로바이더 생성자
|
||||
*
|
||||
* @param secretKey JWT 서명에 사용할 비밀키
|
||||
* @param accessTokenValidityTime Access Token 유효 시간 (밀리초)
|
||||
* @param refreshTokenValidityTime Refresh Token 유효 시간 (밀리초)
|
||||
*/
|
||||
public JwtTokenProvider(
|
||||
@Value("${jwt.secret-key}") String secretKey,
|
||||
@Value("${jwt.access-token-validity}") long accessTokenValidityTime,
|
||||
@Value("${jwt.refresh-token-validity}") long refreshTokenValidityTime) {
|
||||
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
|
||||
this.key = Keys.hmacShaKeyFor(keyBytes);
|
||||
this.accessTokenValidityTime = accessTokenValidityTime;
|
||||
this.refreshTokenValidityTime = refreshTokenValidityTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Access Token 생성
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @return Access Token
|
||||
*/
|
||||
public String generateAccessToken(String userId) {
|
||||
long now = System.currentTimeMillis();
|
||||
Date validity = new Date(now + accessTokenValidityTime);
|
||||
|
||||
return Jwts.builder()
|
||||
.setSubject(userId)
|
||||
.setIssuedAt(new Date(now))
|
||||
.setExpiration(validity)
|
||||
.signWith(key, SignatureAlgorithm.HS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Token 생성
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @return Refresh Token
|
||||
*/
|
||||
public String generateRefreshToken(String userId) {
|
||||
long now = System.currentTimeMillis();
|
||||
Date validity = new Date(now + refreshTokenValidityTime);
|
||||
|
||||
return Jwts.builder()
|
||||
.setSubject(userId)
|
||||
.setIssuedAt(new Date(now))
|
||||
.setExpiration(validity)
|
||||
.signWith(key, SignatureAlgorithm.HS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰에서 사용자 ID 추출
|
||||
*
|
||||
* @param token JWT 토큰
|
||||
* @return 사용자 ID
|
||||
*/
|
||||
public String getUserIdFromToken(String token) {
|
||||
Claims claims = parseClaims(token);
|
||||
return claims.getSubject();
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 유효성 검증
|
||||
*
|
||||
* @param token JWT 토큰
|
||||
* @return 유효성 여부
|
||||
*/
|
||||
public boolean validateToken(String token) {
|
||||
try {
|
||||
parseClaims(token);
|
||||
return true;
|
||||
} catch (ExpiredJwtException e) {
|
||||
log.warn("Expired JWT token: {}", e.getMessage());
|
||||
throw new BusinessException(ErrorCode.TOKEN_EXPIRED);
|
||||
} catch (UnsupportedJwtException e) {
|
||||
log.warn("Unsupported JWT token: {}", e.getMessage());
|
||||
throw new BusinessException(ErrorCode.INVALID_TOKEN);
|
||||
} catch (MalformedJwtException e) {
|
||||
log.warn("Malformed JWT token: {}", e.getMessage());
|
||||
throw new BusinessException(ErrorCode.INVALID_TOKEN);
|
||||
} catch (SecurityException e) {
|
||||
log.warn("Invalid JWT signature: {}", e.getMessage());
|
||||
throw new BusinessException(ErrorCode.INVALID_TOKEN);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("JWT token compact of handler are invalid: {}", e.getMessage());
|
||||
throw new BusinessException(ErrorCode.INVALID_TOKEN);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰에서 Claims 추출
|
||||
*
|
||||
* @param token JWT 토큰
|
||||
* @return Claims
|
||||
*/
|
||||
private Claims parseClaims(String token) {
|
||||
return Jwts.parserBuilder()
|
||||
.setSigningKey(key)
|
||||
.build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
}
|
||||
|
||||
/**
|
||||
* Access Token 유효 시간 반환
|
||||
*
|
||||
* @return Access Token 유효 시간 (밀리초)
|
||||
*/
|
||||
public long getAccessTokenValidityTime() {
|
||||
return accessTokenValidityTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Token 유효 시간 반환
|
||||
*
|
||||
* @return Refresh Token 유효 시간 (밀리초)
|
||||
*/
|
||||
public long getRefreshTokenValidityTime() {
|
||||
return refreshTokenValidityTime;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user