Merge pull request 'feat(audit): API 접근 감사 로그 시스템' (#19) from feature/audit-log into develop
This commit is contained in:
커밋
1b08afee8b
@ -2,8 +2,10 @@ package com.gcsc.guide;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableAsync
|
||||||
public class GcGuideApiApplication {
|
public class GcGuideApiApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import com.gcsc.guide.repository.RoleRepository;
|
|||||||
import com.gcsc.guide.repository.UserRepository;
|
import com.gcsc.guide.repository.UserRepository;
|
||||||
import com.gcsc.guide.service.ActivityService;
|
import com.gcsc.guide.service.ActivityService;
|
||||||
import com.gcsc.guide.service.SettingsService;
|
import com.gcsc.guide.service.SettingsService;
|
||||||
|
import com.gcsc.guide.util.ClientIpUtils;
|
||||||
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
|
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.media.Content;
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
@ -84,7 +85,7 @@ public class AuthController {
|
|||||||
|
|
||||||
activityService.recordLogin(
|
activityService.recordLogin(
|
||||||
userWithRoles.getId(),
|
userWithRoles.getId(),
|
||||||
resolveClientIp(httpRequest),
|
ClientIpUtils.resolve(httpRequest),
|
||||||
httpRequest.getHeader("User-Agent"));
|
httpRequest.getHeader("User-Agent"));
|
||||||
|
|
||||||
String token = jwtTokenProvider.generateToken(
|
String token = jwtTokenProvider.generateToken(
|
||||||
@ -142,16 +143,4 @@ public class AuthController {
|
|||||||
newUser.updateLastLogin();
|
newUser.updateLastLogin();
|
||||||
return userRepository.save(newUser);
|
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,6 +46,11 @@ public class JwtTokenProvider {
|
|||||||
return Long.parseLong(claims.getSubject());
|
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) {
|
public boolean validateToken(String token) {
|
||||||
try {
|
try {
|
||||||
parseToken(token);
|
parseToken(token);
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/main/java/com/gcsc/guide/config/WebMvcConfig.java
Normal file
20
src/main/java/com/gcsc/guide/config/WebMvcConfig.java
Normal file
@ -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/**");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<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));
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/main/java/com/gcsc/guide/dto/AuditLogResponse.java
Normal file
35
src/main/java/com/gcsc/guide/dto/AuditLogResponse.java
Normal file
@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/main/java/com/gcsc/guide/entity/ApiAccessLog.java
Normal file
64
src/main/java/com/gcsc/guide/entity/ApiAccessLog.java
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<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,17 +1,24 @@
|
|||||||
package com.gcsc.guide.service;
|
package com.gcsc.guide.service;
|
||||||
|
|
||||||
|
import com.gcsc.guide.dto.AuditLogResponse;
|
||||||
import com.gcsc.guide.dto.LoginHistoryResponse;
|
import com.gcsc.guide.dto.LoginHistoryResponse;
|
||||||
|
import com.gcsc.guide.entity.ApiAccessLog;
|
||||||
import com.gcsc.guide.entity.LoginHistory;
|
import com.gcsc.guide.entity.LoginHistory;
|
||||||
import com.gcsc.guide.entity.PageView;
|
import com.gcsc.guide.entity.PageView;
|
||||||
import com.gcsc.guide.entity.User;
|
import com.gcsc.guide.entity.User;
|
||||||
import com.gcsc.guide.exception.ResourceNotFoundException;
|
import com.gcsc.guide.exception.ResourceNotFoundException;
|
||||||
|
import com.gcsc.guide.repository.ApiAccessLogRepository;
|
||||||
import com.gcsc.guide.repository.LoginHistoryRepository;
|
import com.gcsc.guide.repository.LoginHistoryRepository;
|
||||||
import com.gcsc.guide.repository.PageViewRepository;
|
import com.gcsc.guide.repository.PageViewRepository;
|
||||||
import com.gcsc.guide.repository.UserRepository;
|
import com.gcsc.guide.repository.UserRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
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.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@ -21,6 +28,7 @@ public class ActivityService {
|
|||||||
private final LoginHistoryRepository loginHistoryRepository;
|
private final LoginHistoryRepository loginHistoryRepository;
|
||||||
private final PageViewRepository pageViewRepository;
|
private final PageViewRepository pageViewRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
private final ApiAccessLogRepository apiAccessLogRepository;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void recordLogin(Long userId, String ipAddress, String userAgent) {
|
public void recordLogin(Long userId, String ipAddress, String userAgent) {
|
||||||
@ -42,4 +50,18 @@ public class ActivityService {
|
|||||||
.map(LoginHistoryResponse::from)
|
.map(LoginHistoryResponse::from)
|
||||||
.toList();
|
.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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/main/java/com/gcsc/guide/util/ClientIpUtils.java
Normal file
21
src/main/java/com/gcsc/guide/util/ClientIpUtils.java
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
불러오는 중...
Reference in New Issue
Block a user