first commit

This commit is contained in:
NYD
2026-01-30 11:28:18 +09:00
commit 37b00599e9
40 changed files with 3204 additions and 0 deletions

View 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);
}
}

View File

@@ -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);
}
}

View 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;
}
}

View 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;
}
}

View 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()
);
}
}

View File

@@ -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;
}
}

View 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);
}
}

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -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()
)));
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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);
}
}
}

View 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");
}
}

View 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);
}
}
}

View File

@@ -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();
}
}

View 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();
}
}

View File

@@ -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
);
}
}

View File

@@ -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)
);
}
}

View File

@@ -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)
);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,9 @@
package kr.tscc.base.security.principal;
/**
* 사용자 역할 정의
*/
public enum UserRoles {
USER,
ADMIN
}

View File

@@ -0,0 +1,14 @@
package kr.tscc.base.security.session;
/**
* 세션 관련 상수
*/
public final class SessionConstants {
private SessionConstants() {}
/**
* 세션에 저장되는 사용자 정보 키
*/
public static final String SESSION_USER_KEY = "USER";
}

View 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;
}
}

View 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

View 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

View 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

View 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>

View 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() {
}
}