Compare commits
No commits in common. "main" and "codex/wing-login" have entirely different histories.
main
...
codex/wing
@ -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;
|
||||
@ -18,22 +17,17 @@ 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 org.springframework.http.HttpHeaders;
|
||||
import java.util.Set;
|
||||
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;
|
||||
|
||||
@ -66,8 +60,7 @@ public class AuthController {
|
||||
@PostMapping("/google")
|
||||
public ResponseEntity<AuthResponse> googleLogin(
|
||||
@Valid @RequestBody GoogleLoginRequest request,
|
||||
HttpServletRequest httpRequest,
|
||||
HttpServletResponse httpResponse) {
|
||||
HttpServletRequest httpRequest) {
|
||||
GoogleIdToken.Payload payload = googleTokenVerifier.verify(request.idToken());
|
||||
if (payload == null) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "유효하지 않은 Google 토큰입니다");
|
||||
@ -92,14 +85,12 @@ public class AuthController {
|
||||
|
||||
activityService.recordLogin(
|
||||
userWithRoles.getId(),
|
||||
ClientIpUtils.resolve(httpRequest),
|
||||
httpRequest.getRemoteAddr(),
|
||||
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)));
|
||||
}
|
||||
|
||||
@ -126,125 +117,10 @@ public class AuthController {
|
||||
@ApiResponse(responseCode = "204", description = "로그아웃 성공")
|
||||
@SecurityRequirement(name = "Bearer JWT")
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<Void> logout(HttpServletResponse httpResponse) {
|
||||
clearSessionCookies(httpResponse);
|
||||
public ResponseEntity<Void> logout() {
|
||||
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,9 +8,7 @@ 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
|
||||
@ -20,26 +18,12 @@ public class GoogleTokenVerifier {
|
||||
private final String allowedEmailDomain;
|
||||
|
||||
public GoogleTokenVerifier(
|
||||
@Value("${app.google.client-ids:}") String clientIdsCsv,
|
||||
@Value("${app.google.client-id:}") String clientId,
|
||||
@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(audiences.isEmpty() ? Collections.emptyList() : audiences)
|
||||
.setAudience(Collections.singletonList(clientId))
|
||||
.build();
|
||||
this.allowedEmailDomain = allowedEmailDomain;
|
||||
}
|
||||
|
||||
@ -8,11 +8,8 @@ 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
|
||||
@ -49,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);
|
||||
@ -64,53 +56,6 @@ 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)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -43,7 +43,6 @@ public class SecurityConfig {
|
||||
.requestMatchers(
|
||||
"/api/auth/google",
|
||||
"/api/auth/logout",
|
||||
"/api/auth/check",
|
||||
"/api/health",
|
||||
"/actuator/health",
|
||||
"/h2-console/**",
|
||||
|
||||
@ -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/**", "/api/auth/check");
|
||||
}
|
||||
}
|
||||
@ -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,215 +0,0 @@
|
||||
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("¢erLon=").append(centerLon);
|
||||
if (centerLat != null && Double.isFinite(centerLat)) sb.append("¢erLat=").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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
불러오는 중...
Reference in New Issue
Block a user