Compare commits

..

26 커밋

작성자 SHA1 메시지 날짜
8535d5e765 Merge pull request 'feat(auth): Nginx 프록시 서비스 인증/권한 체크 (PR #23 포함)' (#24) from develop into main
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 17s
Reviewed-on: #24
2026-02-18 12:56:48 +09:00
ac09c23d3a Merge pull request 'feat(auth): Nginx 프록시 서비스 인증/권한 체크 엔드포인트 구현' (#23) from feature/proxy-auth into develop 2026-02-18 12:54:41 +09:00
353bb3d091 feat(auth): Nginx 프록시 서비스 인증/권한 체크 엔드포인트 구현
- GET /api/auth/check: Nginx auth_request용 쿠키 기반 인증/RBAC 권한 체크
- GC_SESSION 쿠키: 로그인 시 JWT를 HttpOnly 쿠키로 자동 설정
- gc_proxy_auth 캐시 쿠키: HMAC 서명 기반 24시간 캐시 (DB 조회 최소화)
- AntPathMatcher로 사용자 롤의 URL 패턴과 X-Original-URI 매칭
- 관리자는 모든 프록시 URL 자동 허용, 일반 사용자는 롤 기반 제어

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:54:00 +09:00
983de6a71a Merge pull request 'develop' (#22) from develop into main
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 14s
Reviewed-on: #22
2026-02-17 17:46:13 +09:00
98956afcb1 Merge pull request 'fix(audit): origin_domain Referer fallback' (#21) from feature/audit-log into develop
Reviewed-on: #21
2026-02-17 17:45:57 +09:00
710cb1d0f6 fix(audit): origin_domain 추출 시 Referer 헤더 fallback 추가
same-origin 요청(guide→guide)은 Origin 헤더가 없으므로
Referer 헤더에서 도메인을 추출하도록 fallback 로직 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 17:37:41 +09:00
f558e43810 Merge pull request 'feat(audit): API 접근 감사 로그 시스템' (#20) from develop into main
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 18s
Reviewed-on: #20
2026-02-17 17:33:21 +09:00
1b08afee8b Merge pull request 'feat(audit): API 접근 감사 로그 시스템' (#19) from feature/audit-log into develop 2026-02-17 17:31:39 +09:00
539b018e45 feat(audit): API 접근 감사 로그 시스템 구현
- 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 <noreply@anthropic.com>
2026-02-17 17:31:00 +09:00
b39b0df6b9 Merge pull request 'develop → main: Wing 프록시 API + IP 추출 수정' (#18) from develop into main
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 14s
Reviewed-on: #18
2026-02-16 23:52:55 +09:00
c35084d3dc Merge pull request 'fix(auth): 로그인 IP를 X-Forwarded-For 헤더에서 추출' (#17) from feature/wing-proxy into develop 2026-02-16 23:50:58 +09:00
4092f5e8b4 fix(auth): 로그인 IP를 X-Forwarded-For 헤더에서 추출
Nginx 리버스 프록시 환경에서 getRemoteAddr()가 127.0.0.1 반환하는 문제 수정.
X-Forwarded-For → X-Real-IP → getRemoteAddr() 순서로 폴백.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:50:41 +09:00
f62751229e Merge pull request 'feat(settings): 관리자 설정 기반 신규 사용자 자동승인 + 기본 롤 부여' (#15) from develop into main
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 18s
Reviewed-on: #15
2026-02-16 23:36:32 +09:00
2619dce781 Merge pull request 'feat(wing): Wing 데모 사이트 프록시 API + 복수 Google Client ID 지원' (#16) from feature/wing-proxy into develop 2026-02-16 23:35:25 +09:00
69de3f9ae7 feat(wing): Wing 데모 사이트 프록시 API + 복수 Google Client ID 지원
- WingAisController: AIS 선박 위치 조회 프록시 (bbox 필터링 포함)
- WingDataController: 해역/케이블 정적 GeoJSON 데이터 서빙
- GoogleTokenVerifier: app.google.client-ids 복수 audience 지원
- wing-data/: zones, chinese-permitted GeoJSON 데이터 파일

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:34:58 +09:00
cf9b4d118b Merge pull request 'feat(settings): 관리자 설정 기반 신규 사용자 자동승인 + 기본 롤 부여' (#14) from codex/wing-login into develop 2026-02-16 23:20:30 +09:00
4c837b0ce4 Merge pull request 'fix(cors): wing.gc-si.dev CORS 허용 (application.yml + SecurityConfig)' (#13) from develop into main
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 13s
Reviewed-on: #13
2026-02-16 09:08:24 +09:00
460f941a8b Merge pull request 'fix(cors): application.yml CORS 기본값에 wing.gc-si.dev 추가' (#12) from codex/wing-login into develop 2026-02-16 09:07:51 +09:00
a5f58970a9 Merge pull request 'fix(cors): wing.gc-si.dev CORS 허용 + wing 관련 기능' (#11) from develop into main
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 17s
Reviewed-on: #11
2026-02-16 09:02:38 +09:00
89b7936671 Merge pull request 'fix(cors): @Value 기본값에 wing.gc-si.dev 추가' (#10) from codex/wing-login into develop 2026-02-16 09:02:07 +09:00
30f0b28460 Merge pull request 'fix(security): 인증 에러 401 응답 + permitAll 패턴 수정' (#9) from develop into main
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 17s
Reviewed-on: #9
2026-02-14 22:06:45 +09:00
c0e33e11d7 Merge pull request 'fix(security): 인증 에러 401 응답 + CORS 헤더 누락 수정' (#8) from develop into main
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 17s
Reviewed-on: #8
2026-02-14 21:55:11 +09:00
e92b0e15ef Merge pull request 'develop' (#7) from develop into main
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 17s
Reviewed-on: #7
2026-02-14 21:38:36 +09:00
57b11774eb Merge pull request 'develop' (#5) from develop into main
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 16s
Reviewed-on: #5
2026-02-14 21:15:16 +09:00
3e918baf74 Merge pull request 'fix: CI/CD 워크플로우 checkout 에러 수정' (#3) from develop into main
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 21s
Reviewed-on: #3
2026-02-14 20:21:12 +09:00
acf18221ae Merge pull request 'feat: CI/CD 자동 배포 워크플로우 추가' (#2) from develop into main
Some checks failed
Build and Deploy API / build-and-deploy (push) Failing after 21s
Reviewed-on: #2
2026-02-14 20:15:38 +09:00
17개의 변경된 파일801개의 추가작업 그리고 6개의 파일을 삭제

파일 보기

@ -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) {

파일 보기

@ -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;
@ -17,17 +18,22 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.time.Duration;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@ -60,7 +66,8 @@ public class AuthController {
@PostMapping("/google")
public ResponseEntity<AuthResponse> googleLogin(
@Valid @RequestBody GoogleLoginRequest request,
HttpServletRequest httpRequest) {
HttpServletRequest httpRequest,
HttpServletResponse httpResponse) {
GoogleIdToken.Payload payload = googleTokenVerifier.verify(request.idToken());
if (payload == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "유효하지 않은 Google 토큰입니다");
@ -85,12 +92,14 @@ public class AuthController {
activityService.recordLogin(
userWithRoles.getId(),
httpRequest.getRemoteAddr(),
ClientIpUtils.resolve(httpRequest),
httpRequest.getHeader("User-Agent"));
String token = jwtTokenProvider.generateToken(
userWithRoles.getId(), userWithRoles.getEmail(), userWithRoles.isAdmin());
addSessionCookie(httpResponse, token);
return ResponseEntity.ok(new AuthResponse(token, UserResponse.from(userWithRoles)));
}
@ -117,10 +126,125 @@ public class AuthController {
@ApiResponse(responseCode = "204", description = "로그아웃 성공")
@SecurityRequirement(name = "Bearer JWT")
@PostMapping("/logout")
public ResponseEntity<Void> logout() {
public ResponseEntity<Void> logout(HttpServletResponse httpResponse) {
clearSessionCookies(httpResponse);
return ResponseEntity.noContent().build();
}
@Operation(summary = "프록시 인증/권한 체크",
description = "Nginx auth_request용 내부 엔드포인트. GC_SESSION 쿠키로 인증하고, "
+ "X-Original-URI 헤더의 URL에 대한 롤 기반 접근 권한을 확인합니다.",
security = {})
@ApiResponses({
@ApiResponse(responseCode = "200", description = "인증 + 권한 확인 완료"),
@ApiResponse(responseCode = "401", description = "미인증 (로그인 필요)", content = @Content),
@ApiResponse(responseCode = "403", description = "인증됨, 권한 없음", content = @Content)
})
@GetMapping("/check")
public ResponseEntity<Void> checkProxyAuth(HttpServletRequest request, HttpServletResponse response) {
String proxyCacheToken = getCookieValue(request, "gc_proxy_auth");
if (jwtTokenProvider.validateProxyCacheToken(proxyCacheToken) != null) {
return ResponseEntity.ok().build();
}
String sessionToken = getCookieValue(request, "GC_SESSION");
if (sessionToken == null || !jwtTokenProvider.validateToken(sessionToken)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
Long userId = jwtTokenProvider.getUserIdFromToken(sessionToken);
String email = jwtTokenProvider.getEmailFromToken(sessionToken);
String targetUri = request.getHeader("X-Original-URI");
User user = userRepository.findByIdWithRoles(userId).orElse(null);
if (user == null || user.getStatus() != com.gcsc.guide.entity.UserStatus.ACTIVE) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
if (!user.isAdmin() && !hasUrlPermission(user, targetUri)) {
logProxyAccess(userId, email, request, targetUri, 403);
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
String cacheToken = jwtTokenProvider.generateProxyCacheToken(userId);
ResponseCookie cacheCookie = ResponseCookie.from("gc_proxy_auth", cacheToken)
.path("/")
.httpOnly(true)
.secure(true)
.sameSite("Lax")
.maxAge(Duration.ofMillis(jwtTokenProvider.getExpirationMs()))
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cacheCookie.toString());
logProxyAccess(userId, email, request, targetUri, 200);
return ResponseEntity.ok().build();
}
private boolean hasUrlPermission(User user, String targetUri) {
if (targetUri == null || targetUri.isBlank()) {
return false;
}
AntPathMatcher matcher = new AntPathMatcher();
for (Role role : user.getRoles()) {
for (var pattern : role.getUrlPatterns()) {
if (matcher.match(pattern.getUrlPattern(), targetUri)) {
return true;
}
}
}
return false;
}
private void logProxyAccess(Long userId, String email,
HttpServletRequest request, String targetUri, int status) {
try {
var accessLog = com.gcsc.guide.entity.ApiAccessLog.builder()
.userId(userId)
.userEmail(email)
.clientIp(ClientIpUtils.resolve(request))
.httpMethod("GET")
.requestUri(targetUri)
.responseStatus(status)
.durationMs(0L)
.userAgent(request.getHeader("User-Agent"))
.build();
activityService.saveAccessLog(accessLog);
} catch (Exception e) {
log.warn("프록시 접근 로그 기록 실패: {}", e.getMessage());
}
}
private void addSessionCookie(HttpServletResponse response, String jwt) {
ResponseCookie cookie = ResponseCookie.from("GC_SESSION", jwt)
.path("/")
.httpOnly(true)
.secure(true)
.sameSite("Lax")
.maxAge(Duration.ofMillis(jwtTokenProvider.getExpirationMs()))
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}
private void clearSessionCookies(HttpServletResponse response) {
response.addHeader(HttpHeaders.SET_COOKIE,
ResponseCookie.from("GC_SESSION", "").path("/").maxAge(0).build().toString());
response.addHeader(HttpHeaders.SET_COOKIE,
ResponseCookie.from("gc_proxy_auth", "").path("/").maxAge(0).build().toString());
}
private String getCookieValue(HttpServletRequest request, String name) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return null;
}
for (Cookie cookie : cookies) {
if (name.equals(cookie.getName())) {
return cookie.getValue();
}
}
return null;
}
private User createNewUser(String email, String name, String avatarUrl) {
User newUser = new User(email, name, avatarUrl);

파일 보기

@ -8,7 +8,9 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@Slf4j
@Component
@ -18,12 +20,26 @@ public class GoogleTokenVerifier {
private final String allowedEmailDomain;
public GoogleTokenVerifier(
@Value("${app.google.client-id}") String clientId,
@Value("${app.google.client-ids:}") String clientIdsCsv,
@Value("${app.google.client-id:}") String clientId,
@Value("${app.allowed-email-domain}") String allowedEmailDomain
) {
List<String> audiences = new ArrayList<>();
if (clientIdsCsv != null && !clientIdsCsv.isBlank()) {
for (String part : clientIdsCsv.split(",")) {
String trimmed = part == null ? "" : part.trim();
if (!trimmed.isEmpty()) audiences.add(trimmed);
}
}
if (audiences.isEmpty() && clientId != null && !clientId.isBlank()) {
audiences.add(clientId.trim());
}
if (audiences.isEmpty()) {
log.warn("Google client id is not configured (app.google.client-id / app.google.client-ids empty). Google login will fail.");
}
this.verifier = new GoogleIdTokenVerifier.Builder(
new NetHttpTransport(), GsonFactory.getDefaultInstance())
.setAudience(Collections.singletonList(clientId))
.setAudience(audiences.isEmpty() ? Collections.emptyList() : audiences)
.build();
this.allowedEmailDomain = allowedEmailDomain;
}

파일 보기

@ -8,8 +8,11 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Mac;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;
@Slf4j
@ -46,6 +49,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);
@ -56,6 +64,53 @@ public class JwtTokenProvider {
}
}
public long getExpirationMs() {
return expirationMs;
}
public String generateProxyCacheToken(Long userId) {
long expiry = Instant.now().plusMillis(expirationMs).getEpochSecond();
String payload = userId + ":" + expiry;
String hmac = hmacSha256(payload);
return payload + ":" + hmac;
}
public Long validateProxyCacheToken(String token) {
if (token == null || token.isBlank()) {
return null;
}
try {
String[] parts = token.split(":");
if (parts.length != 3) {
return null;
}
long userId = Long.parseLong(parts[0]);
long expiry = Long.parseLong(parts[1]);
String expectedHmac = hmacSha256(userId + ":" + expiry);
if (!expectedHmac.equals(parts[2])) {
return null;
}
if (Instant.now().getEpochSecond() > expiry) {
return null;
}
return userId;
} catch (Exception e) {
log.debug("프록시 캐시 토큰 검증 실패: {}", e.getMessage());
return null;
}
}
private String hmacSha256(String data) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(secretKey);
byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
} catch (Exception e) {
throw new RuntimeException("HMAC 생성 실패", e);
}
}
private Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(secretKey)

파일 보기

@ -0,0 +1,90 @@
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;
}
}
}

파일 보기

@ -43,6 +43,7 @@ public class SecurityConfig {
.requestMatchers(
"/api/auth/google",
"/api/auth/logout",
"/api/auth/check",
"/api/health",
"/actuator/health",
"/h2-console/**",

파일 보기

@ -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/**", "/api/auth/check");
}
}

파일 보기

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

파일 보기

@ -0,0 +1,215 @@
package com.gcsc.guide.controller;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
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.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api/wing/ais-target")
@RequiredArgsConstructor
@Tag(name = "WING · AIS", description = "WING demo AIS proxy (JWT required)")
public class WingAisController {
private final ObjectMapper objectMapper;
@Value("${app.wing.ais.upstream-base:http://211.208.115.83:8041}")
private String upstreamBase;
@Value("${app.wing.ais.timeout-ms:20000}")
private long timeoutMs;
private HttpClient httpClient;
@PostConstruct
void initHttpClient() {
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
}
private record Bbox(double lonMin, double latMin, double lonMax, double latMax) {
}
@GetMapping("/search")
public ResponseEntity<?> search(
@RequestParam(name = "minutes") String minutesRaw,
@RequestParam(name = "bbox", required = false) String bboxRaw,
@RequestParam(name = "centerLon", required = false) Double centerLon,
@RequestParam(name = "centerLat", required = false) Double centerLat,
@RequestParam(name = "radiusMeters", required = false) Double radiusMeters
) {
Integer minutes = parseMinutes(minutesRaw);
if (minutes == null) {
return error(HttpStatus.BAD_REQUEST, "invalid minutes", "BAD_REQUEST");
}
Bbox bbox = parseBbox(bboxRaw);
if (bboxRaw != null && bbox == null) {
return error(HttpStatus.BAD_REQUEST, "invalid bbox", "BAD_REQUEST");
}
URI upstreamUrl = buildUpstreamUrl(minutes, centerLon, centerLat, radiusMeters);
HttpRequest req = HttpRequest.newBuilder(upstreamUrl)
.timeout(Duration.ofMillis(timeoutMs))
.header("accept", "application/json")
.GET()
.build();
int status;
String body;
try {
HttpResponse<String> res = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
status = res.statusCode();
body = res.body() == null ? "" : res.body();
} catch (java.net.http.HttpTimeoutException e) {
log.warn("AIS upstream timeout ({}ms): {}", timeoutMs, upstreamUrl);
return error(HttpStatus.GATEWAY_TIMEOUT, "upstream timeout (" + timeoutMs + "ms)", "UPSTREAM_TIMEOUT");
} catch (Exception e) {
log.warn("AIS upstream fetch failed: {} ({})", upstreamUrl, e.toString());
return error(HttpStatus.BAD_GATEWAY, "upstream fetch failed", "UPSTREAM_FETCH_FAILED");
}
if (status < 200 || status >= 300) {
log.warn("AIS upstream error: status={} url={}", status, upstreamUrl);
return error(HttpStatus.BAD_GATEWAY, "upstream error", "UPSTREAM");
}
// Fast path: no bbox requested, proxy raw payload.
if (bbox == null) {
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(body);
}
try {
Map<String, Object> json = objectMapper.readValue(body, new TypeReference<>() {
});
Object dataObj = json.get("data");
List<?> rows = dataObj instanceof List<?> l ? l : List.of();
List<Object> filtered = new ArrayList<>(rows.size());
for (Object row : rows) {
if (inBbox(row, bbox)) {
filtered.add(row);
}
}
json.put("data", filtered);
Object msgObj = json.get("message");
String msg = msgObj instanceof String s ? s : "";
String suffix = " (bbox: " + filtered.size() + "/" + rows.size() + ")";
json.put("message", (msg + suffix).trim());
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(json);
} catch (Exception e) {
log.warn("AIS upstream JSON parse/filter failed: {}", e.toString());
return error(HttpStatus.BAD_GATEWAY, "upstream invalid json", "UPSTREAM_INVALID_JSON");
}
}
private Integer parseMinutes(String raw) {
if (raw == null) return null;
int minutes;
try {
minutes = Integer.parseInt(raw);
} catch (NumberFormatException e) {
return null;
}
if (minutes <= 0 || minutes > 60 * 24) return null;
return minutes;
}
private Bbox parseBbox(String raw) {
if (raw == null || raw.isBlank()) return null;
String[] parts = raw.split(",");
if (parts.length != 4) return null;
Double lonMin = toDouble(parts[0]);
Double latMin = toDouble(parts[1]);
Double lonMax = toDouble(parts[2]);
Double latMax = toDouble(parts[3]);
if (lonMin == null || latMin == null || lonMax == null || latMax == null) return null;
boolean ok =
lonMin >= -180 && lonMax <= 180 &&
latMin >= -90 && latMax <= 90 &&
lonMin < lonMax &&
latMin < latMax;
if (!ok) return null;
return new Bbox(lonMin, latMin, lonMax, latMax);
}
private boolean inBbox(Object row, Bbox bbox) {
if (!(row instanceof Map<?, ?> m)) return false;
Double lon = toDouble(m.get("lon"));
Double lat = toDouble(m.get("lat"));
if (lon == null || lat == null) return false;
return lon >= bbox.lonMin && lon <= bbox.lonMax && lat >= bbox.latMin && lat <= bbox.latMax;
}
private Double toDouble(Object value) {
if (value == null) return null;
if (value instanceof Number n) return n.doubleValue();
if (value instanceof String s) {
String t = s.trim();
if (t.isEmpty()) return null;
try {
return Double.parseDouble(t);
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
private URI buildUpstreamUrl(int minutes, Double centerLon, Double centerLat, Double radiusMeters) {
String base = upstreamBase == null ? "" : upstreamBase.trim();
if (base.endsWith("/")) base = base.substring(0, base.length() - 1);
StringBuilder sb = new StringBuilder(base);
sb.append("/snp-api/api/ais-target/search");
sb.append("?minutes=").append(minutes);
// Upstream supports center/radius filtering; bbox is ignored (filtered server-side here).
if (centerLon != null && Double.isFinite(centerLon)) sb.append("&centerLon=").append(centerLon);
if (centerLat != null && Double.isFinite(centerLat)) sb.append("&centerLat=").append(centerLat);
if (radiusMeters != null && Double.isFinite(radiusMeters)) sb.append("&radiusMeters=").append(radiusMeters);
return URI.create(sb.toString());
}
private ResponseEntity<Map<String, Object>> error(HttpStatus status, String message, String errorCode) {
return ResponseEntity.status(status)
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of(
"success", false,
"message", message,
"data", List.of(),
"errorCode", errorCode
));
}
}

파일 보기

@ -0,0 +1,57 @@
package com.gcsc.guide.controller;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
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.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.concurrent.TimeUnit;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@RestController
@RequestMapping("/api/wing/data")
@RequiredArgsConstructor
@Tag(name = "WING · Data", description = "WING embedded datasets (JWT required)")
public class WingDataController {
@GetMapping(value = "/zones", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Resource> zones() {
return serveJson("wing-data/zones.wgs84.geojson");
}
@GetMapping(value = "/legacy", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Resource> legacyChinesePermitted() {
return serveJson("wing-data/chinese-permitted.v1.json");
}
@GetMapping(value = "/subcables/geo", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Resource> subcablesGeo() {
return serveJson("wing-data/subcables/cable-geo.json");
}
@GetMapping(value = "/subcables/details", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Resource> subcablesDetails() {
return serveJson("wing-data/subcables/cable-details.min.json");
}
private ResponseEntity<Resource> serveJson(String classpathLocation) {
Resource resource = new ClassPathResource(classpathLocation);
if (!resource.exists()) {
throw new ResponseStatusException(NOT_FOUND, "Resource not found: " + classpathLocation);
}
return ResponseEntity.ok()
// Authenticated endpoint: allow browser caching but keep it private.
.cacheControl(CacheControl.maxAge(6, TimeUnit.HOURS).cachePrivate())
.contentType(MediaType.APPLICATION_JSON)
.body(resource);
}
}

파일 보기

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

파일 보기

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

파일 보기

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long