From 539b018e45e2b31b7b78a21a4c75cc21972838cc Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 17 Feb 2026 17:31:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(audit):=20API=20=EC=A0=91=EA=B7=BC=20?= =?UTF-8?q?=EA=B0=90=EC=82=AC=20=EB=A1=9C=EA=B7=B8=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ApiAccessLog 엔티티: 사용자/도메인/URI/파라미터/응답코드/처리시간 기록 - HandlerInterceptor로 /api/** 전체 요청 자동 기록 (health 제외) - Origin 헤더로 guide/wing 도메인 구분 - @Async 비동기 저장으로 응답 지연 방지 - GET /api/admin/audit-logs 관리자 조회 API (필터: origin, userId, uri, 기간) - ClientIpUtils 유틸 분리 (AuthController에서 공용화) Co-Authored-By: Claude Opus 4.6 --- .../com/gcsc/guide/GcGuideApiApplication.java | 2 + .../com/gcsc/guide/auth/AuthController.java | 15 +--- .../com/gcsc/guide/auth/JwtTokenProvider.java | 5 ++ .../guide/config/ApiAccessLogInterceptor.java | 88 +++++++++++++++++++ .../com/gcsc/guide/config/WebMvcConfig.java | 20 +++++ .../controller/AdminAuditController.java | 44 ++++++++++ .../com/gcsc/guide/dto/AuditLogResponse.java | 35 ++++++++ .../com/gcsc/guide/entity/ApiAccessLog.java | 64 ++++++++++++++ .../repository/ApiAccessLogRepository.java | 27 ++++++ .../gcsc/guide/service/ActivityService.java | 22 +++++ .../com/gcsc/guide/util/ClientIpUtils.java | 21 +++++ 11 files changed, 330 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/gcsc/guide/config/ApiAccessLogInterceptor.java create mode 100644 src/main/java/com/gcsc/guide/config/WebMvcConfig.java create mode 100644 src/main/java/com/gcsc/guide/controller/AdminAuditController.java create mode 100644 src/main/java/com/gcsc/guide/dto/AuditLogResponse.java create mode 100644 src/main/java/com/gcsc/guide/entity/ApiAccessLog.java create mode 100644 src/main/java/com/gcsc/guide/repository/ApiAccessLogRepository.java create mode 100644 src/main/java/com/gcsc/guide/util/ClientIpUtils.java diff --git a/src/main/java/com/gcsc/guide/GcGuideApiApplication.java b/src/main/java/com/gcsc/guide/GcGuideApiApplication.java index 694ed28..e33ea0e 100644 --- a/src/main/java/com/gcsc/guide/GcGuideApiApplication.java +++ b/src/main/java/com/gcsc/guide/GcGuideApiApplication.java @@ -2,8 +2,10 @@ 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) { diff --git a/src/main/java/com/gcsc/guide/auth/AuthController.java b/src/main/java/com/gcsc/guide/auth/AuthController.java index 8e10675..1837066 100644 --- a/src/main/java/com/gcsc/guide/auth/AuthController.java +++ b/src/main/java/com/gcsc/guide/auth/AuthController.java @@ -9,6 +9,7 @@ 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; @@ -84,7 +85,7 @@ public class AuthController { activityService.recordLogin( userWithRoles.getId(), - resolveClientIp(httpRequest), + ClientIpUtils.resolve(httpRequest), httpRequest.getHeader("User-Agent")); String token = jwtTokenProvider.generateToken( @@ -142,16 +143,4 @@ 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(); - } } diff --git a/src/main/java/com/gcsc/guide/auth/JwtTokenProvider.java b/src/main/java/com/gcsc/guide/auth/JwtTokenProvider.java index 58a0127..985d651 100644 --- a/src/main/java/com/gcsc/guide/auth/JwtTokenProvider.java +++ b/src/main/java/com/gcsc/guide/auth/JwtTokenProvider.java @@ -46,6 +46,11 @@ 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); diff --git a/src/main/java/com/gcsc/guide/config/ApiAccessLogInterceptor.java b/src/main/java/com/gcsc/guide/config/ApiAccessLogInterceptor.java new file mode 100644 index 0000000..d1db598 --- /dev/null +++ b/src/main/java/com/gcsc/guide/config/ApiAccessLogInterceptor.java @@ -0,0 +1,88 @@ +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 = extractOriginDomain(request.getHeader("Origin")); + + 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 extractOriginDomain(String origin) { + if (origin == null || origin.isBlank()) { + return null; + } + try { + return URI.create(origin).getHost(); + } catch (Exception e) { + return origin; + } + } +} diff --git a/src/main/java/com/gcsc/guide/config/WebMvcConfig.java b/src/main/java/com/gcsc/guide/config/WebMvcConfig.java new file mode 100644 index 0000000..f6aea73 --- /dev/null +++ b/src/main/java/com/gcsc/guide/config/WebMvcConfig.java @@ -0,0 +1,20 @@ +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/**"); + } +} diff --git a/src/main/java/com/gcsc/guide/controller/AdminAuditController.java b/src/main/java/com/gcsc/guide/controller/AdminAuditController.java new file mode 100644 index 0000000..aa44378 --- /dev/null +++ b/src/main/java/com/gcsc/guide/controller/AdminAuditController.java @@ -0,0 +1,44 @@ +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> 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)); + } +} diff --git a/src/main/java/com/gcsc/guide/dto/AuditLogResponse.java b/src/main/java/com/gcsc/guide/dto/AuditLogResponse.java new file mode 100644 index 0000000..c54f070 --- /dev/null +++ b/src/main/java/com/gcsc/guide/dto/AuditLogResponse.java @@ -0,0 +1,35 @@ +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() + ); + } +} diff --git a/src/main/java/com/gcsc/guide/entity/ApiAccessLog.java b/src/main/java/com/gcsc/guide/entity/ApiAccessLog.java new file mode 100644 index 0000000..e24b357 --- /dev/null +++ b/src/main/java/com/gcsc/guide/entity/ApiAccessLog.java @@ -0,0 +1,64 @@ +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(); + } +} diff --git a/src/main/java/com/gcsc/guide/repository/ApiAccessLogRepository.java b/src/main/java/com/gcsc/guide/repository/ApiAccessLogRepository.java new file mode 100644 index 0000000..88c4d93 --- /dev/null +++ b/src/main/java/com/gcsc/guide/repository/ApiAccessLogRepository.java @@ -0,0 +1,27 @@ +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 { + + @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 findFiltered( + @Param("originDomain") String originDomain, + @Param("userId") Long userId, + @Param("requestUri") String requestUri, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to, + Pageable pageable); +} diff --git a/src/main/java/com/gcsc/guide/service/ActivityService.java b/src/main/java/com/gcsc/guide/service/ActivityService.java index 0e706af..95f4063 100644 --- a/src/main/java/com/gcsc/guide/service/ActivityService.java +++ b/src/main/java/com/gcsc/guide/service/ActivityService.java @@ -1,17 +1,24 @@ 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 @@ -21,6 +28,7 @@ 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) { @@ -42,4 +50,18 @@ public class ActivityService { .map(LoginHistoryResponse::from) .toList(); } + + @Async + @Transactional + public void saveAccessLog(ApiAccessLog log) { + apiAccessLogRepository.save(log); + } + + @Transactional(readOnly = true) + public Page 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); + } } diff --git a/src/main/java/com/gcsc/guide/util/ClientIpUtils.java b/src/main/java/com/gcsc/guide/util/ClientIpUtils.java new file mode 100644 index 0000000..2e41ae5 --- /dev/null +++ b/src/main/java/com/gcsc/guide/util/ClientIpUtils.java @@ -0,0 +1,21 @@ +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(); + } +}