Compare commits

..

No commits in common. "main" and "feature/wing-proxy" have entirely different histories.

11개의 변경된 파일13개의 추가작업 그리고 332개의 파일을 삭제

파일 보기

@ -2,10 +2,8 @@ package com.gcsc.guide;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@EnableAsync
public class GcGuideApiApplication {
public static void main(String[] args) {

파일 보기

@ -9,7 +9,6 @@ import com.gcsc.guide.repository.RoleRepository;
import com.gcsc.guide.repository.UserRepository;
import com.gcsc.guide.service.ActivityService;
import com.gcsc.guide.service.SettingsService;
import com.gcsc.guide.util.ClientIpUtils;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
@ -85,7 +84,7 @@ public class AuthController {
activityService.recordLogin(
userWithRoles.getId(),
ClientIpUtils.resolve(httpRequest),
resolveClientIp(httpRequest),
httpRequest.getHeader("User-Agent"));
String token = jwtTokenProvider.generateToken(
@ -143,4 +142,16 @@ public class AuthController {
newUser.updateLastLogin();
return userRepository.save(newUser);
}
private String resolveClientIp(HttpServletRequest request) {
String xff = request.getHeader("X-Forwarded-For");
if (xff != null && !xff.isBlank()) {
return xff.split(",")[0].trim();
}
String realIp = request.getHeader("X-Real-IP");
if (realIp != null && !realIp.isBlank()) {
return realIp.trim();
}
return request.getRemoteAddr();
}
}

파일 보기

@ -46,11 +46,6 @@ public class JwtTokenProvider {
return Long.parseLong(claims.getSubject());
}
public String getEmailFromToken(String token) {
Claims claims = parseToken(token);
return claims.get("email", String.class);
}
public boolean validateToken(String token) {
try {
parseToken(token);

파일 보기

@ -1,90 +0,0 @@
package com.gcsc.guide.config;
import com.gcsc.guide.auth.JwtTokenProvider;
import com.gcsc.guide.entity.ApiAccessLog;
import com.gcsc.guide.service.ActivityService;
import com.gcsc.guide.util.ClientIpUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.net.URI;
@Slf4j
@Component
@RequiredArgsConstructor
public class ApiAccessLogInterceptor implements HandlerInterceptor {
private static final String ATTR_START_TIME = "_auditStartTime";
private final ActivityService activityService;
private final JwtTokenProvider jwtTokenProvider;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
request.setAttribute(ATTR_START_TIME, System.currentTimeMillis());
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
try {
Long startTime = (Long) request.getAttribute(ATTR_START_TIME);
long durationMs = startTime != null ? System.currentTimeMillis() - startTime : 0;
Long userId = null;
String userEmail = null;
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof Long) {
userId = (Long) auth.getPrincipal();
Object credentials = auth.getCredentials();
if (credentials instanceof String token) {
userEmail = jwtTokenProvider.getEmailFromToken(token);
}
}
String originDomain = resolveOriginDomain(
request.getHeader("Origin"), request.getHeader("Referer"));
String queryString = request.getQueryString();
if (queryString != null && queryString.length() > 2000) {
queryString = queryString.substring(0, 2000);
}
ApiAccessLog accessLog = ApiAccessLog.builder()
.userId(userId)
.userEmail(userEmail)
.clientIp(ClientIpUtils.resolve(request))
.originDomain(originDomain)
.httpMethod(request.getMethod())
.requestUri(request.getRequestURI())
.queryString(queryString)
.responseStatus(response.getStatus())
.durationMs(durationMs)
.userAgent(request.getHeader("User-Agent"))
.build();
activityService.saveAccessLog(accessLog);
} catch (Exception e) {
log.warn("감사 로그 기록 실패: {}", e.getMessage());
}
}
private String resolveOriginDomain(String origin, String referer) {
String url = (origin != null && !origin.isBlank()) ? origin : referer;
if (url == null || url.isBlank()) {
return null;
}
try {
return URI.create(url).getHost();
} catch (Exception e) {
return null;
}
}
}

파일 보기

@ -1,20 +0,0 @@
package com.gcsc.guide.config;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final ApiAccessLogInterceptor apiAccessLogInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(apiAccessLogInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/health", "/api/health/**");
}
}

파일 보기

@ -1,44 +0,0 @@
package com.gcsc.guide.controller;
import com.gcsc.guide.dto.AuditLogResponse;
import com.gcsc.guide.service.ActivityService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
@RestController
@RequestMapping("/api/admin/audit-logs")
@RequiredArgsConstructor
@Tag(name = "07. 감사 로그", description = "API 접근 감사 로그 조회 (관리자 전용)")
@SecurityRequirement(name = "Bearer JWT")
public class AdminAuditController {
private final ActivityService activityService;
@Operation(summary = "감사 로그 목록 조회",
description = "API 접근 로그를 필터링하여 조회합니다. 모든 파라미터는 선택사항입니다.")
@GetMapping
public ResponseEntity<Page<AuditLogResponse>> getAuditLogs(
@RequestParam(required = false) String origin,
@RequestParam(required = false) Long userId,
@RequestParam(required = false) String uri,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to,
@PageableDefault(size = 50, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {
return ResponseEntity.ok(
activityService.getAuditLogs(origin, userId, uri, from, to, pageable));
}
}

파일 보기

@ -1,35 +0,0 @@
package com.gcsc.guide.dto;
import com.gcsc.guide.entity.ApiAccessLog;
import java.time.LocalDateTime;
public record AuditLogResponse(
Long id,
Long userId,
String userEmail,
String clientIp,
String originDomain,
String httpMethod,
String requestUri,
String queryString,
Integer responseStatus,
Long durationMs,
LocalDateTime createdAt
) {
public static AuditLogResponse from(ApiAccessLog log) {
return new AuditLogResponse(
log.getId(),
log.getUserId(),
log.getUserEmail(),
log.getClientIp(),
log.getOriginDomain(),
log.getHttpMethod(),
log.getRequestUri(),
log.getQueryString(),
log.getResponseStatus(),
log.getDurationMs(),
log.getCreatedAt()
);
}
}

파일 보기

@ -1,64 +0,0 @@
package com.gcsc.guide.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "api_access_logs", indexes = {
@Index(name = "idx_access_logs_created", columnList = "created_at"),
@Index(name = "idx_access_logs_user", columnList = "user_id"),
@Index(name = "idx_access_logs_uri", columnList = "request_uri")
})
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ApiAccessLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id")
private Long userId;
@Column(name = "user_email")
private String userEmail;
@Column(name = "client_ip", length = 45)
private String clientIp;
@Column(name = "origin_domain")
private String originDomain;
@Column(name = "http_method", nullable = false, length = 10)
private String httpMethod;
@Column(name = "request_uri", nullable = false, length = 500)
private String requestUri;
@Column(name = "query_string", length = 2000)
private String queryString;
@Column(name = "response_status")
private Integer responseStatus;
@Column(name = "duration_ms")
private Long durationMs;
@Column(name = "user_agent", length = 500)
private String userAgent;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
}
}

파일 보기

@ -1,27 +0,0 @@
package com.gcsc.guide.repository;
import com.gcsc.guide.entity.ApiAccessLog;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
public interface ApiAccessLogRepository extends JpaRepository<ApiAccessLog, Long> {
@Query("SELECT a FROM ApiAccessLog a WHERE "
+ "(:originDomain IS NULL OR a.originDomain = :originDomain) AND "
+ "(:userId IS NULL OR a.userId = :userId) AND "
+ "(:requestUri IS NULL OR a.requestUri LIKE CONCAT(:requestUri, '%')) AND "
+ "(:from IS NULL OR a.createdAt >= :from) AND "
+ "(:to IS NULL OR a.createdAt <= :to)")
Page<ApiAccessLog> findFiltered(
@Param("originDomain") String originDomain,
@Param("userId") Long userId,
@Param("requestUri") String requestUri,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
Pageable pageable);
}

파일 보기

@ -1,24 +1,17 @@
package com.gcsc.guide.service;
import com.gcsc.guide.dto.AuditLogResponse;
import com.gcsc.guide.dto.LoginHistoryResponse;
import com.gcsc.guide.entity.ApiAccessLog;
import com.gcsc.guide.entity.LoginHistory;
import com.gcsc.guide.entity.PageView;
import com.gcsc.guide.entity.User;
import com.gcsc.guide.exception.ResourceNotFoundException;
import com.gcsc.guide.repository.ApiAccessLogRepository;
import com.gcsc.guide.repository.LoginHistoryRepository;
import com.gcsc.guide.repository.PageViewRepository;
import com.gcsc.guide.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
@Service
@ -28,7 +21,6 @@ public class ActivityService {
private final LoginHistoryRepository loginHistoryRepository;
private final PageViewRepository pageViewRepository;
private final UserRepository userRepository;
private final ApiAccessLogRepository apiAccessLogRepository;
@Transactional
public void recordLogin(Long userId, String ipAddress, String userAgent) {
@ -50,18 +42,4 @@ public class ActivityService {
.map(LoginHistoryResponse::from)
.toList();
}
@Async
@Transactional
public void saveAccessLog(ApiAccessLog log) {
apiAccessLogRepository.save(log);
}
@Transactional(readOnly = true)
public Page<AuditLogResponse> getAuditLogs(
String originDomain, Long userId, String requestUri,
LocalDateTime from, LocalDateTime to, Pageable pageable) {
return apiAccessLogRepository.findFiltered(originDomain, userId, requestUri, from, to, pageable)
.map(AuditLogResponse::from);
}
}

파일 보기

@ -1,21 +0,0 @@
package com.gcsc.guide.util;
import jakarta.servlet.http.HttpServletRequest;
public final class ClientIpUtils {
private ClientIpUtils() {
}
public static String resolve(HttpServletRequest request) {
String xff = request.getHeader("X-Forwarded-For");
if (xff != null && !xff.isBlank()) {
return xff.split(",")[0].trim();
}
String realIp = request.getHeader("X-Real-IP");
if (realIp != null && !realIp.isBlank()) {
return realIp.trim();
}
return request.getRemoteAddr();
}
}