first commit
This commit is contained in:
13
src/main/java/kr/tscc/base/BootstrapApplication.java
Normal file
13
src/main/java/kr/tscc/base/BootstrapApplication.java
Normal file
@@ -0,0 +1,13 @@
|
||||
package kr.tscc.base;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class BootstrapApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(BootstrapApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package kr.tscc.base.api.auth.controller;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import kr.tscc.base.api.auth.dto.LoginRequest;
|
||||
import kr.tscc.base.api.auth.dto.MeResponse;
|
||||
import kr.tscc.base.api.auth.service.AuthService;
|
||||
import kr.tscc.base.common.exception.ErrorCode;
|
||||
import kr.tscc.base.common.response.ApiError;
|
||||
import kr.tscc.base.common.response.ApiResponse;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* 인증 컨트롤러
|
||||
*
|
||||
* 책임:
|
||||
* - HTTP 요청/응답 처리
|
||||
* - 세션 직접 제어 금지
|
||||
* - 인증 처리 위임만 수행
|
||||
*
|
||||
* 금지 사항:
|
||||
* - 비즈니스 로직 포함
|
||||
* - Service 호출만 수행
|
||||
* - 인증/세션 직접 접근 금지
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
public AuthController(AuthService authService) {
|
||||
this.authService = authService;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSRF 쿠키 발급
|
||||
* 목적: CSRF 쿠키 발급, 세션 초기화 트리거
|
||||
* 특징: 인증 불필요, 응답 body 없음, Cookie만 내려줌
|
||||
*/
|
||||
@GetMapping("/csrf")
|
||||
public ApiResponse<Void> csrf() {
|
||||
return ApiResponse.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인
|
||||
* 목적: 사용자 인증, 세션 생성
|
||||
* 동작: 인증 성공 시 HttpSession 생성, JSESSIONID 쿠키 발급, CSRF 토큰 재발급
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<Void> login(@Valid @RequestBody LoginRequest request) {
|
||||
authService.login(request);
|
||||
return ApiResponse.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃
|
||||
* 목적: 세션 종료
|
||||
* 동작: HttpSession invalidate, 쿠키 만료
|
||||
*/
|
||||
@PostMapping("/logout")
|
||||
public ApiResponse<Void> logout() {
|
||||
authService.logout();
|
||||
return ApiResponse.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 내 인증 정보 조회
|
||||
*
|
||||
* 보안 규칙:
|
||||
* - 인증되지 않은 사용자는 401 응답
|
||||
* - null 체크 필수 (인증 실패 시 null 반환 가능)
|
||||
*/
|
||||
@GetMapping("/me")
|
||||
public ApiResponse<MeResponse> me() {
|
||||
MeResponse me = authService.me();
|
||||
if (me == null) {
|
||||
return ApiResponse.<MeResponse>errorWithType(
|
||||
new ApiError(
|
||||
ErrorCode.UNAUTHORIZED.code(),
|
||||
ErrorCode.UNAUTHORIZED.message()
|
||||
)
|
||||
);
|
||||
}
|
||||
return ApiResponse.success(me);
|
||||
}
|
||||
}
|
||||
39
src/main/java/kr/tscc/base/api/auth/dto/LoginRequest.java
Normal file
39
src/main/java/kr/tscc/base/api/auth/dto/LoginRequest.java
Normal file
@@ -0,0 +1,39 @@
|
||||
package kr.tscc.base.api.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 로그인 요청 DTO
|
||||
*
|
||||
* 역할:
|
||||
* - 로그인 요청 입력 모델
|
||||
* - Validation은 DTO에서 수행
|
||||
*
|
||||
* 보안 규칙:
|
||||
* - @Valid 필수
|
||||
* - NotBlank 검증
|
||||
*/
|
||||
public class LoginRequest {
|
||||
|
||||
@NotBlank(message = "Username is required")
|
||||
private String username;
|
||||
|
||||
@NotBlank(message = "Password is required")
|
||||
private String password;
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
}
|
||||
39
src/main/java/kr/tscc/base/api/auth/dto/MeResponse.java
Normal file
39
src/main/java/kr/tscc/base/api/auth/dto/MeResponse.java
Normal file
@@ -0,0 +1,39 @@
|
||||
package kr.tscc.base.api.auth.dto;
|
||||
|
||||
/**
|
||||
* 현재 사용자 정보 응답 DTO
|
||||
*
|
||||
* 보안 규칙:
|
||||
* - password, token 등 민감정보 절대 포함 금지
|
||||
* - 최소 정보만 반환
|
||||
*/
|
||||
public class MeResponse {
|
||||
|
||||
private final Long userId;
|
||||
private final String email;
|
||||
private final String displayName;
|
||||
private final String role;
|
||||
|
||||
public MeResponse(Long userId, String email, String displayName, String role) {
|
||||
this.userId = userId;
|
||||
this.email = email;
|
||||
this.displayName = displayName;
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public String getRole() {
|
||||
return role;
|
||||
}
|
||||
}
|
||||
74
src/main/java/kr/tscc/base/api/auth/service/AuthService.java
Normal file
74
src/main/java/kr/tscc/base/api/auth/service/AuthService.java
Normal file
@@ -0,0 +1,74 @@
|
||||
package kr.tscc.base.api.auth.service;
|
||||
|
||||
import kr.tscc.base.api.auth.dto.LoginRequest;
|
||||
import kr.tscc.base.api.auth.dto.MeResponse;
|
||||
import kr.tscc.base.security.principal.LoginUserPrincipal;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 인증 서비스
|
||||
*
|
||||
* 책임:
|
||||
* - Spring Security 인증 처리 위임
|
||||
* - 인증 성공 시 세션 생성
|
||||
* - 인증 실패 시 예외 처리
|
||||
*
|
||||
* 금지 사항:
|
||||
* - 사용자 상세 비즈니스 로직 처리
|
||||
* - 권한 판단 로직 포함
|
||||
* - 사용자 관리(User 도메인 영역 침범)
|
||||
*/
|
||||
@Service
|
||||
public class AuthService {
|
||||
|
||||
private final AuthenticationManager authenticationManager;
|
||||
|
||||
public AuthService(AuthenticationManager authenticationManager) {
|
||||
this.authenticationManager = authenticationManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 처리
|
||||
*/
|
||||
public void login(LoginRequest request) {
|
||||
UsernamePasswordAuthenticationToken token =
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
request.getUsername(),
|
||||
request.getPassword()
|
||||
);
|
||||
|
||||
Authentication auth = authenticationManager.authenticate(token);
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃 처리
|
||||
*/
|
||||
public void logout() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 사용자 정보 조회
|
||||
*/
|
||||
public MeResponse me() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !(auth.getPrincipal() instanceof LoginUserPrincipal)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
LoginUserPrincipal principal = (LoginUserPrincipal) auth.getPrincipal();
|
||||
var sessionUser = principal.getSessionUser();
|
||||
|
||||
return new MeResponse(
|
||||
sessionUser.getUserId(),
|
||||
sessionUser.getEmail(),
|
||||
sessionUser.getDisplayName(),
|
||||
sessionUser.getRole()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package kr.tscc.base.common.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import kr.tscc.base.common.util.Utils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import org.springframework.web.util.ContentCachingRequestWrapper;
|
||||
import org.springframework.web.util.ContentCachingResponseWrapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Enumeration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 요청/응답 로깅 필터
|
||||
*
|
||||
* 설계 목적:
|
||||
* - 요청/응답 로깅 중앙화
|
||||
* - 민감정보 마스킹 강제 (시큐어 코딩 규칙)
|
||||
* - 로그 포맷 통일
|
||||
*
|
||||
* 보안 규칙:
|
||||
* - password/token/sessionId 로그 금지
|
||||
* - Authorization/Cookie 전체 로그 금지
|
||||
* - 요청/응답 raw dump 금지
|
||||
* - 민감정보는 Utils.Masking으로 마스킹
|
||||
*/
|
||||
@Component
|
||||
public class RequestResponseLoggingFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RequestResponseLoggingFilter.class);
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public RequestResponseLoggingFilter(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String uri = request.getRequestURI();
|
||||
// health check, actuator는 로깅 제외
|
||||
return uri.startsWith("/health") || uri.startsWith("/actuator");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
|
||||
ContentCachingRequestWrapper req = new ContentCachingRequestWrapper(request);
|
||||
ContentCachingResponseWrapper res = new ContentCachingResponseWrapper(response);
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
try {
|
||||
filterChain.doFilter(req, res);
|
||||
} finally {
|
||||
long tookMs = System.currentTimeMillis() - start;
|
||||
|
||||
// 헤더 수집
|
||||
Map<String, String> headers = new LinkedHashMap<>();
|
||||
Enumeration<String> names = req.getHeaderNames();
|
||||
while (names.hasMoreElements()) {
|
||||
String n = names.nextElement();
|
||||
headers.put(n, req.getHeader(n));
|
||||
}
|
||||
|
||||
// 헤더 마스킹 (민감정보 제거)
|
||||
Map<String, String> safeHeaders = Utils.Masking.maskHeaders(headers);
|
||||
|
||||
// Body 읽기
|
||||
String reqBody = readBody(req.getContentAsByteArray(), req.getContentType());
|
||||
String resBody = readBody(res.getContentAsByteArray(), res.getContentType());
|
||||
|
||||
// 로깅 (민감정보 마스킹)
|
||||
log.info("[HTTP] {} {} ({}ms) status={} headers={} reqBody={} resBody={}",
|
||||
req.getMethod(),
|
||||
req.getRequestURI(),
|
||||
tookMs,
|
||||
res.getStatus(),
|
||||
safeHeaders,
|
||||
sanitizeBodyForLog(reqBody),
|
||||
sanitizeBodyForLog(resBody)
|
||||
);
|
||||
|
||||
res.copyBodyToResponse();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Body 읽기 (JSON만 처리)
|
||||
*/
|
||||
private String readBody(byte[] bytes, String contentType) {
|
||||
if (bytes == null || bytes.length == 0) return "";
|
||||
if (contentType == null) return "[non-json]";
|
||||
if (!contentType.contains(MediaType.APPLICATION_JSON_VALUE)) {
|
||||
return "[non-json]";
|
||||
}
|
||||
return new String(bytes, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Body 마스킹 처리
|
||||
* - JSON 파싱 후 깊은 마스킹
|
||||
* - 파싱 실패 시 키워드 기반 마스킹
|
||||
*/
|
||||
private String sanitizeBodyForLog(String json) {
|
||||
if (json == null || json.isEmpty()) return json;
|
||||
|
||||
try {
|
||||
// JSON 파싱 후 깊은 마스킹
|
||||
Object parsed = objectMapper.readValue(json, Object.class);
|
||||
Object masked = Utils.Masking.maskDeep(parsed);
|
||||
String maskedJson = objectMapper.writeValueAsString(masked);
|
||||
// 너무 긴 경우 truncate
|
||||
return Utils.Masking.truncateForLog(maskedJson, 1000);
|
||||
} catch (Exception e) {
|
||||
// 파싱 실패 시 키워드 기반 마스킹
|
||||
String lower = json.toLowerCase(Locale.ROOT);
|
||||
if (containsSensitiveKeyword(lower)) {
|
||||
return "[masked]";
|
||||
}
|
||||
// 길이 제한
|
||||
return Utils.Masking.truncateForLog(json, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 민감 키워드 포함 여부 확인
|
||||
*/
|
||||
private boolean containsSensitiveKeyword(String text) {
|
||||
String[] keywords = {
|
||||
"password", "passwd", "pwd",
|
||||
"token", "accesstoken", "refreshtoken",
|
||||
"authorization", "cookie", "session", "sessionid"
|
||||
};
|
||||
for (String keyword : keywords) {
|
||||
if (text.contains(keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
36
src/main/java/kr/tscc/base/common/config/WebMvcConfig.java
Normal file
36
src/main/java/kr/tscc/base/common/config/WebMvcConfig.java
Normal file
@@ -0,0 +1,36 @@
|
||||
package kr.tscc.base.common.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import kr.tscc.base.common.util.Utils;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* Web MVC 설정
|
||||
*
|
||||
* - CORS 설정
|
||||
* - Jackson ObjectMapper 초기화 (Utils.Json 사용)
|
||||
*/
|
||||
@Configuration
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public WebMvcConfig(Jackson2ObjectMapperBuilder builder) {
|
||||
this.objectMapper = builder.build();
|
||||
// Utils.Json 초기화
|
||||
Utils.Json.init(objectMapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/api/**")
|
||||
.allowedOrigins("http://localhost:5173", "http://localhost:3000") // 개발 환경
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package kr.tscc.base.common.exception;
|
||||
|
||||
/**
|
||||
* 비즈니스 예외
|
||||
*
|
||||
* 비즈니스 로직에서 발생하는 예외를 명시적으로 처리하기 위한 예외 클래스
|
||||
*/
|
||||
public class BizException extends RuntimeException {
|
||||
|
||||
private final ErrorCode errorCode;
|
||||
|
||||
public BizException(ErrorCode errorCode) {
|
||||
super(errorCode.message());
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
public BizException(ErrorCode errorCode, String message) {
|
||||
super(message);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
public ErrorCode getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
}
|
||||
34
src/main/java/kr/tscc/base/common/exception/ErrorCode.java
Normal file
34
src/main/java/kr/tscc/base/common/exception/ErrorCode.java
Normal file
@@ -0,0 +1,34 @@
|
||||
package kr.tscc.base.common.exception;
|
||||
|
||||
/**
|
||||
* 에러 코드 정의
|
||||
*
|
||||
* 설계 목적:
|
||||
* - 예외를 외부로 그대로 노출하지 않음
|
||||
* - 예외 → ErrorCode → ApiError 변환
|
||||
* - 컨트롤러별 try/catch 제거
|
||||
*/
|
||||
public enum ErrorCode {
|
||||
|
||||
INVALID_REQUEST("C001", "Invalid request"),
|
||||
UNAUTHORIZED("C002", "Unauthorized"),
|
||||
FORBIDDEN("C003", "Forbidden"),
|
||||
NOT_FOUND("C004", "Resource not found"),
|
||||
INTERNAL_ERROR("C999", "Internal server error");
|
||||
|
||||
private final String code;
|
||||
private final String message;
|
||||
|
||||
ErrorCode(String code, String message) {
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public String message() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package kr.tscc.base.common.exception;
|
||||
|
||||
import kr.tscc.base.common.response.ApiError;
|
||||
import kr.tscc.base.common.response.ApiResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
/**
|
||||
* 전역 예외 처리 핸들러
|
||||
*
|
||||
* 설계 목적:
|
||||
* - 예외를 외부로 그대로 노출하지 않음
|
||||
* - 예외 → ErrorCode → ApiError 변환
|
||||
* - 컨트롤러별 try/catch 제거
|
||||
* - 계단식 예외 처리 (구체 → 포괄)
|
||||
* - StackTrace 응답 금지 (시큐어 코딩 규칙)
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
/**
|
||||
* 비즈니스 예외 처리
|
||||
*/
|
||||
@ExceptionHandler(BizException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleBizException(BizException e) {
|
||||
ErrorCode errorCode = e.getErrorCode();
|
||||
log.warn("Business exception: {} - {}", errorCode.code(), e.getMessage());
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error(new ApiError(errorCode.code(), e.getMessage())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 예외 처리
|
||||
*/
|
||||
@ExceptionHandler(AuthenticationException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleAuthenticationException(AuthenticationException e) {
|
||||
log.warn("Authentication exception: {}", e.getMessage());
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(ApiResponse.error(new ApiError(
|
||||
ErrorCode.UNAUTHORIZED.code(),
|
||||
ErrorCode.UNAUTHORIZED.message()
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 인가 예외 처리
|
||||
*/
|
||||
@ExceptionHandler({AccessDeniedException.class, SecurityException.class})
|
||||
public ResponseEntity<ApiResponse<Void>> handleAccessDeniedException(Exception e) {
|
||||
log.warn("Access denied exception: {}", e.getMessage());
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(new ApiError(
|
||||
ErrorCode.FORBIDDEN.code(),
|
||||
ErrorCode.FORBIDDEN.message()
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 검증 예외 처리 (MethodArgumentNotValidException)
|
||||
*/
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleMethodArgumentNotValidException(
|
||||
MethodArgumentNotValidException e
|
||||
) {
|
||||
log.warn("Validation exception: {}", e.getMessage());
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error(new ApiError(
|
||||
ErrorCode.INVALID_REQUEST.code(),
|
||||
ErrorCode.INVALID_REQUEST.message()
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 검증 예외 처리 (BindException)
|
||||
*/
|
||||
@ExceptionHandler(BindException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleBindException(BindException e) {
|
||||
log.warn("Bind exception: {}", e.getMessage());
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error(new ApiError(
|
||||
ErrorCode.INVALID_REQUEST.code(),
|
||||
ErrorCode.INVALID_REQUEST.message()
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* IllegalArgumentException 처리
|
||||
*/
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleIllegalArgumentException(IllegalArgumentException e) {
|
||||
log.warn("Illegal argument exception: {}", e.getMessage());
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error(new ApiError(
|
||||
ErrorCode.INVALID_REQUEST.code(),
|
||||
ErrorCode.INVALID_REQUEST.message()
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 일반 예외 처리 (최후의 수단)
|
||||
* StackTrace는 로그에만 기록하고 응답에는 포함하지 않음 (시큐어 코딩 규칙)
|
||||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleGeneralException(Exception e) {
|
||||
log.error("Unexpected exception", e);
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error(new ApiError(
|
||||
ErrorCode.INTERNAL_ERROR.code(),
|
||||
ErrorCode.INTERNAL_ERROR.message()
|
||||
)));
|
||||
}
|
||||
}
|
||||
23
src/main/java/kr/tscc/base/common/response/ApiError.java
Normal file
23
src/main/java/kr/tscc/base/common/response/ApiError.java
Normal file
@@ -0,0 +1,23 @@
|
||||
package kr.tscc.base.common.response;
|
||||
|
||||
/**
|
||||
* API 에러 응답 모델
|
||||
*/
|
||||
public class ApiError {
|
||||
|
||||
private final String code;
|
||||
private final String message;
|
||||
|
||||
public ApiError(String code, String message) {
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
66
src/main/java/kr/tscc/base/common/response/ApiResponse.java
Normal file
66
src/main/java/kr/tscc/base/common/response/ApiResponse.java
Normal file
@@ -0,0 +1,66 @@
|
||||
package kr.tscc.base.common.response;
|
||||
|
||||
/**
|
||||
* 공통 API 응답 구조
|
||||
*
|
||||
* 설계 목적:
|
||||
* - 모든 API 응답 포맷 통일
|
||||
* - 성공/실패 구분 명확화
|
||||
* - 프론트엔드 처리 단순화
|
||||
*
|
||||
* @param <T> 응답 데이터 타입
|
||||
*/
|
||||
public class ApiResponse<T> {
|
||||
|
||||
private final boolean success;
|
||||
private final T data;
|
||||
private final ApiError error;
|
||||
|
||||
private ApiResponse(boolean success, T data, ApiError error) {
|
||||
this.success = success;
|
||||
this.data = data;
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공 응답 생성 (데이터 포함)
|
||||
*/
|
||||
public static <T> ApiResponse<T> success(T data) {
|
||||
return new ApiResponse<>(true, data, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공 응답 생성 (데이터 없음)
|
||||
*/
|
||||
public static ApiResponse<Void> success() {
|
||||
return new ApiResponse<>(true, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 응답 생성 (Void 타입)
|
||||
*/
|
||||
public static ApiResponse<Void> error(ApiError error) {
|
||||
return new ApiResponse<>(false, null, error);
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 응답 생성 (제네릭 타입)
|
||||
*
|
||||
* @param <T> 응답 데이터 타입 (에러 시 null)
|
||||
*/
|
||||
public static <T> ApiResponse<T> errorWithType(ApiError error) {
|
||||
return new ApiResponse<>(false, null, error);
|
||||
}
|
||||
|
||||
public boolean isSuccess() {
|
||||
return success;
|
||||
}
|
||||
|
||||
public T getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
public ApiError getError() {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
89
src/main/java/kr/tscc/base/common/response/PageQuery.java
Normal file
89
src/main/java/kr/tscc/base/common/response/PageQuery.java
Normal file
@@ -0,0 +1,89 @@
|
||||
package kr.tscc.base.common.response;
|
||||
|
||||
/**
|
||||
* 페이징 조회 조건 베이스 객체
|
||||
*
|
||||
* 역할:
|
||||
* - 요청 파라미터 기반 페이징 조건 캡슐화
|
||||
* - offset/limit 계산 책임
|
||||
* - count 결과 주입 시 전체 페이지 계산
|
||||
*
|
||||
* 설계 목적:
|
||||
* - Controller/Service/Mapper 전반에서 일관된 페이징 처리
|
||||
* - page 계산 로직 중복 제거
|
||||
* - MyBatis limit/offset 계산의 중앙화
|
||||
* - total count 기반 페이지 메타 정보 제공
|
||||
*/
|
||||
public class PageQuery {
|
||||
|
||||
private final int pageIndex;
|
||||
private final int pageSize;
|
||||
|
||||
private int totalCount;
|
||||
private int totalPages;
|
||||
|
||||
/**
|
||||
* 페이징 쿼리 생성
|
||||
*
|
||||
* @param pageIndex 페이지 번호 (1-based, null이거나 1 미만이면 1로 설정)
|
||||
* @param pageSize 페이지 크기 (null이거나 1 미만이면 20으로 설정)
|
||||
*/
|
||||
public PageQuery(Integer pageIndex, Integer pageSize) {
|
||||
this.pageIndex = (pageIndex == null || pageIndex < 1) ? 1 : pageIndex;
|
||||
this.pageSize = (pageSize == null || pageSize < 1) ? 20 : pageSize;
|
||||
}
|
||||
|
||||
public int getPageIndex() {
|
||||
return pageIndex;
|
||||
}
|
||||
|
||||
public int getPageSize() {
|
||||
return pageSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* MyBatis offset 계산
|
||||
*/
|
||||
public int getOffset() {
|
||||
return (pageIndex - 1) * pageSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* MyBatis limit 계산
|
||||
*/
|
||||
public int getLimit() {
|
||||
return pageSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 개수 적용 및 전체 페이지 수 계산
|
||||
*
|
||||
* @param totalCount 전체 개수
|
||||
*/
|
||||
public void applyTotalCount(int totalCount) {
|
||||
this.totalCount = totalCount;
|
||||
this.totalPages = (int) Math.ceil((double) totalCount / pageSize);
|
||||
}
|
||||
|
||||
public int getTotalCount() {
|
||||
return totalCount;
|
||||
}
|
||||
|
||||
public int getTotalPages() {
|
||||
return totalPages;
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 페이지 존재 여부
|
||||
*/
|
||||
public boolean hasNext() {
|
||||
return pageIndex < totalPages;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이전 페이지 존재 여부
|
||||
*/
|
||||
public boolean hasPrevious() {
|
||||
return pageIndex > 1;
|
||||
}
|
||||
}
|
||||
31
src/main/java/kr/tscc/base/common/response/PageResult.java
Normal file
31
src/main/java/kr/tscc/base/common/response/PageResult.java
Normal file
@@ -0,0 +1,31 @@
|
||||
package kr.tscc.base.common.response;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 페이징 응답 래퍼
|
||||
*
|
||||
* 역할:
|
||||
* - 실제 조회 결과와 페이징 메타 정보를 함께 반환
|
||||
* - API 응답 구조 표준화
|
||||
*
|
||||
* @param <T> 아이템 타입
|
||||
*/
|
||||
public class PageResult<T> {
|
||||
|
||||
private final List<T> items;
|
||||
private final PageQuery page;
|
||||
|
||||
public PageResult(List<T> items, PageQuery page) {
|
||||
this.items = items;
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
public List<T> getItems() {
|
||||
return items;
|
||||
}
|
||||
|
||||
public PageQuery getPage() {
|
||||
return page;
|
||||
}
|
||||
}
|
||||
104
src/main/java/kr/tscc/base/common/util/FileUtils.java
Normal file
104
src/main/java/kr/tscc/base/common/util/FileUtils.java
Normal file
@@ -0,0 +1,104 @@
|
||||
package kr.tscc.base.common.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 파일/경로 보안 전용 유틸리티
|
||||
*
|
||||
* 설계 목적:
|
||||
* - 파일/경로 관련 취약점(Path Traversal) 방지
|
||||
* - 보안 리뷰, 시큐어코딩 점검 시 독립적으로 확인 가능
|
||||
* - 문자열 유틸과 절대 섞지 않음
|
||||
* - 반드시 baseDir 하위 경로만 허용
|
||||
*
|
||||
* 설계 원칙:
|
||||
* - user input 경로는 절대 그대로 사용하지 않음
|
||||
* - Path.normalize() + startsWith(baseDir) 검증 필수
|
||||
* - 파일명은 별도로 sanitize 처리
|
||||
* - 확장자는 whitelist 방식으로만 허용
|
||||
*/
|
||||
public final class FileUtils {
|
||||
|
||||
private static final Set<String> DEFAULT_ALLOWED_EXTENSIONS =
|
||||
Set.of("txt", "pdf", "png", "jpg", "jpeg", "gif", "csv", "docx", "xlsx", "pptx");
|
||||
|
||||
private FileUtils() {}
|
||||
|
||||
/**
|
||||
* 안전한 경로 해석 (Path Traversal 방지)
|
||||
*
|
||||
* @param baseDir 기준 디렉터리 (절대 경로)
|
||||
* @param userPath 사용자 입력 경로
|
||||
* @return baseDir 하위의 정규화된 경로
|
||||
* @throws SecurityException baseDir 밖으로 나가는 경우
|
||||
*/
|
||||
public static Path safeResolve(Path baseDir, String userPath) {
|
||||
try {
|
||||
Path resolved = baseDir.resolve(userPath).normalize();
|
||||
if (!resolved.startsWith(baseDir)) {
|
||||
throw new SecurityException("Path traversal attempt blocked");
|
||||
}
|
||||
return resolved;
|
||||
} catch (InvalidPathException e) {
|
||||
throw new SecurityException("Invalid path", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일명 sanitize (특수문자 제거)
|
||||
*
|
||||
* @param filename 원본 파일명
|
||||
* @return sanitize된 파일명
|
||||
*/
|
||||
public static String sanitizeFilename(String filename) {
|
||||
if (filename == null) {
|
||||
return "unknown";
|
||||
}
|
||||
return filename
|
||||
.replaceAll("[\\\\/]", "")
|
||||
.replaceAll("\\.\\.", "")
|
||||
.replaceAll("[^a-zA-Z0-9._-]", "_");
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 확장자 추출
|
||||
*
|
||||
* @param filename 파일명
|
||||
* @return 확장자 (소문자, 점 제외)
|
||||
*/
|
||||
public static String getExtension(String filename) {
|
||||
if (filename == null) return "";
|
||||
int idx = filename.lastIndexOf('.');
|
||||
return (idx > -1) ? filename.substring(idx + 1).toLowerCase() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용된 확장자인지 확인 (화이트리스트)
|
||||
*
|
||||
* @param filename 파일명
|
||||
* @return 허용 여부
|
||||
*/
|
||||
public static boolean isAllowedExtension(String filename) {
|
||||
return DEFAULT_ALLOWED_EXTENSIONS.contains(getExtension(filename));
|
||||
}
|
||||
|
||||
/**
|
||||
* 디렉터리 생성 (존재하지 않으면 생성)
|
||||
*
|
||||
* @param dir 디렉터리 경로
|
||||
* @throws IllegalStateException 생성 실패 시
|
||||
*/
|
||||
public static void ensureDirectory(Path dir) {
|
||||
try {
|
||||
if (Files.notExists(dir)) {
|
||||
Files.createDirectories(dir);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Failed to create directory", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
101
src/main/java/kr/tscc/base/common/util/ServletUtils.java
Normal file
101
src/main/java/kr/tscc/base/common/util/ServletUtils.java
Normal file
@@ -0,0 +1,101 @@
|
||||
package kr.tscc.base.common.util;
|
||||
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* HttpServletRequest 보조 유틸리티
|
||||
*
|
||||
* 설계 목적:
|
||||
* - HttpServletRequest 직접 접근 로직을 흩뿌리지 않음
|
||||
* - IP / Header / Token / Cookie 접근 로직을 중앙화
|
||||
* - Controller / Filter / Handler 어디서든 동일한 방식으로 사용
|
||||
*
|
||||
* 설계 원칙:
|
||||
* - X-Forwarded-For 우선
|
||||
* - Trust Chain 명확히 정의
|
||||
* - Authorization 파싱은 Bearer 한정
|
||||
* - null-safe 접근만 허용
|
||||
*/
|
||||
public final class ServletUtils {
|
||||
|
||||
private ServletUtils() {}
|
||||
|
||||
/**
|
||||
* 클라이언트 IP 주소 추출
|
||||
* 프록시 환경을 고려하여 X-Forwarded-For 헤더 우선 확인
|
||||
*
|
||||
* @param request HttpServletRequest
|
||||
* @return 클라이언트 IP 주소
|
||||
*/
|
||||
public static String getClientIp(HttpServletRequest request) {
|
||||
String[] headers = {
|
||||
"X-Forwarded-For",
|
||||
"X-Real-IP",
|
||||
"Proxy-Client-IP",
|
||||
"WL-Proxy-Client-IP"
|
||||
};
|
||||
|
||||
for (String h : headers) {
|
||||
String ip = request.getHeader(h);
|
||||
if (ip != null && !ip.isBlank() && !"unknown".equalsIgnoreCase(ip)) {
|
||||
// 여러 IP가 있을 경우 첫 번째 IP 반환
|
||||
return ip.split(",")[0].trim();
|
||||
}
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearer 토큰 추출
|
||||
* Authorization 헤더에서 Bearer 토큰만 추출
|
||||
*
|
||||
* @param request HttpServletRequest
|
||||
* @return Bearer 토큰 또는 null
|
||||
*/
|
||||
public static String getBearerToken(HttpServletRequest request) {
|
||||
String auth = request.getHeader("Authorization");
|
||||
if (auth == null) return null;
|
||||
if (!auth.startsWith("Bearer ")) return null;
|
||||
return auth.substring(7);
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿠키 값 추출
|
||||
*
|
||||
* @param request HttpServletRequest
|
||||
* @param name 쿠키 이름
|
||||
* @return 쿠키 값 또는 null
|
||||
*/
|
||||
public static String getCookieValue(HttpServletRequest request, String name) {
|
||||
if (request.getCookies() == null) return null;
|
||||
for (Cookie c : request.getCookies()) {
|
||||
if (name.equals(c.getName())) {
|
||||
return c.getValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX 요청 여부 확인
|
||||
*
|
||||
* @param request HttpServletRequest
|
||||
* @return AJAX 요청 여부
|
||||
*/
|
||||
public static boolean isAjax(HttpServletRequest request) {
|
||||
String header = request.getHeader("X-Requested-With");
|
||||
return "XMLHttpRequest".equalsIgnoreCase(header);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 요청 여부 확인
|
||||
*
|
||||
* @param request HttpServletRequest
|
||||
* @return JSON 요청 여부
|
||||
*/
|
||||
public static boolean isJson(HttpServletRequest request) {
|
||||
String ct = request.getContentType();
|
||||
return ct != null && ct.contains("application/json");
|
||||
}
|
||||
}
|
||||
211
src/main/java/kr/tscc/base/common/util/Utils.java
Normal file
211
src/main/java/kr/tscc/base/common/util/Utils.java
Normal file
@@ -0,0 +1,211 @@
|
||||
package kr.tscc.base.common.util;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 공통 유틸리티 클래스 (CFGH: Cookie, Crypto, DateTime, Json, Masking)
|
||||
* 프로젝트 전반 정책을 한 파일에서 통제
|
||||
*/
|
||||
public final class Utils {
|
||||
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
private static volatile ObjectMapper OBJECT_MAPPER;
|
||||
public static final ZoneId KST = ZoneId.of("Asia/Seoul");
|
||||
|
||||
private Utils() {}
|
||||
|
||||
/**
|
||||
* JSON 처리 유틸리티
|
||||
*/
|
||||
public static final class Json {
|
||||
public static void init(ObjectMapper mapper) {
|
||||
OBJECT_MAPPER = mapper;
|
||||
}
|
||||
|
||||
private static ObjectMapper om() {
|
||||
if (OBJECT_MAPPER == null) {
|
||||
throw new IllegalStateException("ObjectMapper not initialized");
|
||||
}
|
||||
return OBJECT_MAPPER;
|
||||
}
|
||||
|
||||
public static String toJson(Object value) {
|
||||
try {
|
||||
return om().writeValueAsString(value);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static <T> T fromJson(String json, Class<T> clazz) {
|
||||
try {
|
||||
return om().readValue(json, clazz);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static <T> T fromJson(String json, TypeReference<T> ref) {
|
||||
try {
|
||||
return om().readValue(json, ref);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 암호화/보안 관련 유틸리티
|
||||
*/
|
||||
public static final class Crypto {
|
||||
/**
|
||||
* 상수 시간 비교 (타이밍 공격 방지)
|
||||
*/
|
||||
public static boolean constantTimeEquals(String a, String b) {
|
||||
return MessageDigest.isEqual(
|
||||
a.getBytes(StandardCharsets.UTF_8),
|
||||
b.getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 보안 난수 토큰 생성
|
||||
*/
|
||||
public static String randomToken(int bytes) {
|
||||
byte[] buf = new byte[bytes];
|
||||
SECURE_RANDOM.nextBytes(buf);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA-256 해시 생성
|
||||
*/
|
||||
public static String sha256(String input) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] out = md.digest(input.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : out) sb.append(String.format("%02x", b));
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜/시간 처리 유틸리티
|
||||
*/
|
||||
public static final class DateTime {
|
||||
public static LocalDateTime nowKst() {
|
||||
return LocalDateTime.now(KST);
|
||||
}
|
||||
|
||||
public static String format(LocalDateTime dt) {
|
||||
return dt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿠키 생성 유틸리티
|
||||
*/
|
||||
public static final class Cookie {
|
||||
public static String build(
|
||||
String name,
|
||||
String value,
|
||||
boolean httpOnly,
|
||||
boolean secure,
|
||||
String sameSite,
|
||||
long maxAge
|
||||
) {
|
||||
return ResponseCookie.from(name, value)
|
||||
.httpOnly(httpOnly)
|
||||
.secure(secure)
|
||||
.sameSite(sameSite)
|
||||
.maxAge(maxAge)
|
||||
.path("/")
|
||||
.build()
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 민감정보 마스킹 유틸리티
|
||||
*/
|
||||
public static final class Masking {
|
||||
private static final Set<String> SENSITIVE_KEYS = Set.of(
|
||||
"password", "passwd", "pwd",
|
||||
"accesstoken", "refreshtoken", "token",
|
||||
"authorization", "cookie", "set-cookie",
|
||||
"session", "sessionid", "sid", "jsessionid",
|
||||
"csrf", "xsrf", "xsrf-token"
|
||||
);
|
||||
|
||||
/**
|
||||
* Map/List 구조를 깊게 내려가며 민감키 값은 "***"로 치환
|
||||
*/
|
||||
public static Object maskDeep(Object body) {
|
||||
if (body == null) return null;
|
||||
|
||||
if (body instanceof Map<?, ?> map) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<?, ?> e : map.entrySet()) {
|
||||
String k = String.valueOf(e.getKey());
|
||||
Object v = e.getValue();
|
||||
if (isSensitiveKey(k)) {
|
||||
out.put(k, "***");
|
||||
} else {
|
||||
out.put(k, maskDeep(v));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (body instanceof List<?> list) {
|
||||
List<Object> out = new ArrayList<>(list.size());
|
||||
for (Object it : list) {
|
||||
out.add(maskDeep(it));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더 맵 마스킹(Authorization/Cookie 등)
|
||||
*/
|
||||
public static Map<String, String> maskHeaders(Map<String, String> headers) {
|
||||
if (headers == null) return new LinkedHashMap<>();
|
||||
Map<String, String> out = new LinkedHashMap<>();
|
||||
headers.forEach((k, v) -> out.put(k, isSensitiveKey(k) ? "***" : v));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그용: 너무 긴 문자열은 잘라서 기록
|
||||
*/
|
||||
public static String truncateForLog(String s, int maxLen) {
|
||||
if (s == null) return null;
|
||||
if (maxLen <= 0) return "";
|
||||
if (s.length() <= maxLen) return s;
|
||||
return s.substring(0, maxLen) + "...";
|
||||
}
|
||||
|
||||
private static boolean isSensitiveKey(String key) {
|
||||
if (key == null) return false;
|
||||
String k = key.toLowerCase(Locale.ROOT);
|
||||
return SENSITIVE_KEYS.contains(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package kr.tscc.base.security.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
/**
|
||||
* 비밀번호 인코더 설정
|
||||
*
|
||||
* 보안 규칙:
|
||||
* - BCrypt 사용 (느린 해시 알고리즘)
|
||||
* - DES/MD5/SHA-1 사용 금지
|
||||
*/
|
||||
@Configuration
|
||||
public class PasswordEncoderConfig {
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}
|
||||
104
src/main/java/kr/tscc/base/security/config/SecurityConfig.java
Normal file
104
src/main/java/kr/tscc/base/security/config/SecurityConfig.java
Normal file
@@ -0,0 +1,104 @@
|
||||
package kr.tscc.base.security.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.ProviderManager;
|
||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||
|
||||
/**
|
||||
* Spring Security 설정
|
||||
*
|
||||
* 역할:
|
||||
* - 인증/인가 정책 선언
|
||||
* - CSRF 설정
|
||||
* - 세션 정책 정의
|
||||
* - 필터 체인 구성
|
||||
* - AuthenticationManager Bean 명시적 정의
|
||||
*
|
||||
* 보안 규칙:
|
||||
* - SecurityConfig에 비즈니스 판단 로직 금지
|
||||
* - 세션 저장소 변경 로직 금지
|
||||
* - Redis 사용 여부는 코드에서 분기하지 않음
|
||||
* - CSRF는 CookieCsrfTokenRepository 사용
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
/**
|
||||
* AuthenticationManager Bean 명시적 정의
|
||||
*
|
||||
* 역할:
|
||||
* - UserDetailsService와 PasswordEncoder를 연결
|
||||
* - 인증 처리 로직 제공
|
||||
*
|
||||
* 보안 규칙:
|
||||
* - DaoAuthenticationProvider 사용 (DB 기반 인증)
|
||||
* - PasswordEncoder는 BCrypt 사용 (PasswordEncoderConfig에서 정의)
|
||||
*
|
||||
* 참고:
|
||||
* - Spring Security 6.x에서는 DaoAuthenticationProvider 생성자에 PasswordEncoder 전달
|
||||
* - setUserDetailsService는 여전히 사용 가능 (deprecated 경고 무시 가능)
|
||||
*/
|
||||
@Bean
|
||||
@SuppressWarnings("deprecation")
|
||||
public AuthenticationManager authenticationManager(
|
||||
UserDetailsService userDetailsService,
|
||||
PasswordEncoder passwordEncoder
|
||||
) {
|
||||
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
|
||||
authProvider.setUserDetailsService(userDetailsService);
|
||||
authProvider.setPasswordEncoder(passwordEncoder);
|
||||
return new ProviderManager(authProvider);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
|
||||
http
|
||||
// CSRF 설정 (SPA 환경)
|
||||
.csrf(csrf -> csrf
|
||||
.csrfTokenRepository(
|
||||
CookieCsrfTokenRepository.withHttpOnlyFalse()
|
||||
)
|
||||
)
|
||||
|
||||
// 인가 설정
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(
|
||||
"/api/auth/login",
|
||||
"/api/auth/logout",
|
||||
"/api/auth/csrf"
|
||||
).permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
|
||||
// 세션 관리
|
||||
.sessionManagement(session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
|
||||
.sessionFixation().migrateSession()
|
||||
)
|
||||
|
||||
// 예외 처리
|
||||
.exceptionHandling(ex -> ex
|
||||
.authenticationEntryPoint(new kr.tscc.base.security.handler.AuthenticationEntryPointImpl())
|
||||
.accessDeniedHandler(new kr.tscc.base.security.handler.AccessDeniedHandlerImpl())
|
||||
)
|
||||
|
||||
// 기본 인증 방식 비활성화 (REST API)
|
||||
.formLogin(form -> form.disable())
|
||||
.httpBasic(basic -> basic.disable())
|
||||
.logout(Customizer.withDefaults());
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package kr.tscc.base.security.config;
|
||||
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* UserDetailsService 구현
|
||||
*
|
||||
* 역할:
|
||||
* - Spring Security 인증 시 사용자 정보 조회
|
||||
* - username(실제로는 email)으로 사용자 조회
|
||||
* - LoginUserPrincipal 반환
|
||||
*
|
||||
* 설계 원칙:
|
||||
* - 실제 사용자 조회는 UserMapper를 통해 수행 (도메인 영역)
|
||||
* - 이 클래스는 Spring Security와 도메인 영역을 연결하는 어댑터 역할
|
||||
* - 비밀번호 검증은 AuthenticationManager가 처리
|
||||
*
|
||||
* 보안 규칙:
|
||||
* - 사용자 조회 실패 시 UsernameNotFoundException 발생
|
||||
* - 비밀번호는 반환하지 않음 (LoginUserPrincipal에서 null 반환)
|
||||
* - 민감 정보는 SessionUser에 포함하지 않음
|
||||
*
|
||||
* 주의:
|
||||
* - 실제 프로젝트에서는 UserMapper를 주입받아 사용자 조회
|
||||
* - 현재는 예제 구조만 제공 (실제 DB 조회 로직은 UserMapper에 구현)
|
||||
*/
|
||||
@Service
|
||||
public class UserDetailsServiceImpl implements UserDetailsService {
|
||||
|
||||
// TODO: 실제 프로젝트에서는 UserMapper 주입
|
||||
// private final UserMapper userMapper;
|
||||
|
||||
/**
|
||||
* 사용자 정보 조회
|
||||
*
|
||||
* @param username 실제로는 email (LoginUserPrincipal.getUsername()이 email 반환)
|
||||
* @return UserDetails (LoginUserPrincipal)
|
||||
* @throws UsernameNotFoundException 사용자를 찾을 수 없을 때
|
||||
*/
|
||||
@Override
|
||||
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||
// TODO: 실제 프로젝트에서는 UserMapper를 통해 사용자 조회
|
||||
// 예시:
|
||||
// UserEntity user = userMapper.findByEmail(username)
|
||||
// .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
|
||||
//
|
||||
// SessionUser sessionUser = new SessionUser(
|
||||
// user.getId(),
|
||||
// user.getEmail(),
|
||||
// user.getDisplayName(),
|
||||
// user.getRole()
|
||||
// );
|
||||
// return new LoginUserPrincipal(sessionUser);
|
||||
|
||||
// 현재는 예제 구조만 제공
|
||||
// 실제 프로젝트에서는 위의 주석 처리된 코드를 활성화하고 아래 코드를 제거
|
||||
throw new UsernameNotFoundException(
|
||||
"UserDetailsService not implemented. " +
|
||||
"Please implement user lookup logic using UserMapper. " +
|
||||
"Username: " + username
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package kr.tscc.base.security.handler;
|
||||
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import kr.tscc.base.common.exception.ErrorCode;
|
||||
import kr.tscc.base.common.response.ApiError;
|
||||
import kr.tscc.base.common.response.ApiResponse;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 접근 거부 핸들러
|
||||
*
|
||||
* 403 Forbidden 응답 처리
|
||||
*/
|
||||
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
|
||||
|
||||
@Override
|
||||
public void handle(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
AccessDeniedException accessDeniedException
|
||||
) throws IOException, ServletException {
|
||||
|
||||
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
|
||||
ApiResponse<Void> apiResponse = ApiResponse.error(
|
||||
new ApiError(ErrorCode.FORBIDDEN.code(), ErrorCode.FORBIDDEN.message())
|
||||
);
|
||||
|
||||
response.getWriter().write(
|
||||
new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(apiResponse)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package kr.tscc.base.security.handler;
|
||||
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import kr.tscc.base.common.exception.ErrorCode;
|
||||
import kr.tscc.base.common.response.ApiError;
|
||||
import kr.tscc.base.common.response.ApiResponse;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 인증 진입점 핸들러
|
||||
*
|
||||
* 401 Unauthorized 응답 처리
|
||||
*/
|
||||
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
|
||||
|
||||
@Override
|
||||
public void commence(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
AuthenticationException authException
|
||||
) throws IOException, ServletException {
|
||||
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
|
||||
ApiResponse<Void> apiResponse = ApiResponse.error(
|
||||
new ApiError(ErrorCode.UNAUTHORIZED.code(), ErrorCode.UNAUTHORIZED.message())
|
||||
);
|
||||
|
||||
response.getWriter().write(
|
||||
new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(apiResponse)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package kr.tscc.base.security.principal;
|
||||
|
||||
import kr.tscc.base.security.session.SessionUser;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
|
||||
/**
|
||||
* 인증 Principal
|
||||
*
|
||||
* Spring Security의 UserDetails를 구현하여 인증 정보를 담는 객체
|
||||
*
|
||||
* 설계 원칙:
|
||||
* - SessionUser를 기반으로 생성
|
||||
* - UserDetails 인터페이스 구현
|
||||
* - 권한 정보 포함
|
||||
*/
|
||||
public class LoginUserPrincipal implements UserDetails {
|
||||
|
||||
private final SessionUser sessionUser;
|
||||
private final Collection<? extends GrantedAuthority> authorities;
|
||||
|
||||
public LoginUserPrincipal(SessionUser sessionUser) {
|
||||
this.sessionUser = sessionUser;
|
||||
this.authorities = Collections.singletonList(
|
||||
new SimpleGrantedAuthority("ROLE_" + sessionUser.getRole())
|
||||
);
|
||||
}
|
||||
|
||||
public SessionUser getSessionUser() {
|
||||
return sessionUser;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return authorities;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPassword() {
|
||||
// 세션 기반이므로 password 반환 불필요
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUsername() {
|
||||
return sessionUser.getEmail();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonLocked() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCredentialsNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package kr.tscc.base.security.principal;
|
||||
|
||||
/**
|
||||
* 사용자 역할 정의
|
||||
*/
|
||||
public enum UserRoles {
|
||||
USER,
|
||||
ADMIN
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package kr.tscc.base.security.session;
|
||||
|
||||
/**
|
||||
* 세션 관련 상수
|
||||
*/
|
||||
public final class SessionConstants {
|
||||
|
||||
private SessionConstants() {}
|
||||
|
||||
/**
|
||||
* 세션에 저장되는 사용자 정보 키
|
||||
*/
|
||||
public static final String SESSION_USER_KEY = "USER";
|
||||
}
|
||||
46
src/main/java/kr/tscc/base/security/session/SessionUser.java
Normal file
46
src/main/java/kr/tscc/base/security/session/SessionUser.java
Normal file
@@ -0,0 +1,46 @@
|
||||
package kr.tscc.base.security.session;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 세션 사용자 모델
|
||||
*
|
||||
* 설계 원칙:
|
||||
* - 세션에 저장되는 정보는 최소화
|
||||
* - UserEntity 전체 저장 금지
|
||||
* - Serializable 구현 (세션 직렬화 필요)
|
||||
*
|
||||
* 보안 규칙:
|
||||
* - password, token 등 민감정보 절대 포함 금지
|
||||
* - 최소 정보만 저장 (userId, email, displayName 등)
|
||||
*/
|
||||
public class SessionUser implements Serializable {
|
||||
|
||||
private final Long userId;
|
||||
private final String email;
|
||||
private final String displayName;
|
||||
private final String role;
|
||||
|
||||
public SessionUser(Long userId, String email, String displayName, String role) {
|
||||
this.userId = userId;
|
||||
this.email = email;
|
||||
this.displayName = displayName;
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public String getRole() {
|
||||
return role;
|
||||
}
|
||||
}
|
||||
10
src/main/resources/application-dev.yml
Normal file
10
src/main/resources/application-dev.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
spring:
|
||||
datasource:
|
||||
driver-class-name: org.mariadb.jdbc.Driver
|
||||
url: jdbc:mariadb://localhost:3306/tscc
|
||||
username: tscc
|
||||
password: tscc1234
|
||||
|
||||
sql:
|
||||
init:
|
||||
mode: never
|
||||
10
src/main/resources/application-prod.yml
Normal file
10
src/main/resources/application-prod.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
spring:
|
||||
datasource:
|
||||
driver-class-name: org.mariadb.jdbc.Driver
|
||||
url: jdbc:mariadb://localhost:3306/tscc
|
||||
username: ${DB_USERNAME:tscc}
|
||||
password: ${DB_PASSWORD:tscc1234}
|
||||
|
||||
sql:
|
||||
init:
|
||||
mode: never
|
||||
12
src/main/resources/application.yaml
Normal file
12
src/main/resources/application.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
spring:
|
||||
application:
|
||||
name: base
|
||||
profiles:
|
||||
active: dev
|
||||
|
||||
# MyBatis 설정
|
||||
mybatis:
|
||||
mapper-locations: classpath:mapper/**/*.xml
|
||||
type-aliases-package: kr.tscc.base.api
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
31
src/main/resources/logback-spring.xml
Normal file
31
src/main/resources/logback-spring.xml
Normal file
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
|
||||
|
||||
<springProfile name="dev">
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
</springProfile>
|
||||
|
||||
<springProfile name="prod">
|
||||
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>logs/application.log</file>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>logs/application-%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||
<maxHistory>30</maxHistory>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
<root level="INFO">
|
||||
<appender-ref ref="FILE"/>
|
||||
</root>
|
||||
</springProfile>
|
||||
</configuration>
|
||||
13
src/test/java/kr/tscc/base/BootstrapApplicationTests.java
Normal file
13
src/test/java/kr/tscc/base/BootstrapApplicationTests.java
Normal file
@@ -0,0 +1,13 @@
|
||||
package kr.tscc.base;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest
|
||||
class BootstrapApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user