From 353bb3d091a2e06b02ff8d128b3b67146960132e Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Feb 2026 12:54:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(auth):=20Nginx=20=ED=94=84=EB=A1=9D?= =?UTF-8?q?=EC=8B=9C=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=9D=B8=EC=A6=9D/?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EC=B2=B4=ED=81=AC=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../com/gcsc/guide/auth/AuthController.java | 128 +++++++++++++++++- .../com/gcsc/guide/auth/JwtTokenProvider.java | 50 +++++++ .../com/gcsc/guide/config/SecurityConfig.java | 1 + .../com/gcsc/guide/config/WebMvcConfig.java | 2 +- 4 files changed, 178 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/gcsc/guide/auth/AuthController.java b/src/main/java/com/gcsc/guide/auth/AuthController.java index 1837066..575fc35 100644 --- a/src/main/java/com/gcsc/guide/auth/AuthController.java +++ b/src/main/java/com/gcsc/guide/auth/AuthController.java @@ -18,16 +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 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 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 토큰입니다"); @@ -91,6 +98,8 @@ public class AuthController { 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 logout() { + public ResponseEntity 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 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); diff --git a/src/main/java/com/gcsc/guide/auth/JwtTokenProvider.java b/src/main/java/com/gcsc/guide/auth/JwtTokenProvider.java index 985d651..518b990 100644 --- a/src/main/java/com/gcsc/guide/auth/JwtTokenProvider.java +++ b/src/main/java/com/gcsc/guide/auth/JwtTokenProvider.java @@ -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 @@ -61,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) diff --git a/src/main/java/com/gcsc/guide/config/SecurityConfig.java b/src/main/java/com/gcsc/guide/config/SecurityConfig.java index 6c3888f..66dbdba 100644 --- a/src/main/java/com/gcsc/guide/config/SecurityConfig.java +++ b/src/main/java/com/gcsc/guide/config/SecurityConfig.java @@ -43,6 +43,7 @@ public class SecurityConfig { .requestMatchers( "/api/auth/google", "/api/auth/logout", + "/api/auth/check", "/api/health", "/actuator/health", "/h2-console/**", diff --git a/src/main/java/com/gcsc/guide/config/WebMvcConfig.java b/src/main/java/com/gcsc/guide/config/WebMvcConfig.java index f6aea73..e02367e 100644 --- a/src/main/java/com/gcsc/guide/config/WebMvcConfig.java +++ b/src/main/java/com/gcsc/guide/config/WebMvcConfig.java @@ -15,6 +15,6 @@ public class WebMvcConfig implements WebMvcConfigurer { public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(apiAccessLogInterceptor) .addPathPatterns("/api/**") - .excludePathPatterns("/api/health", "/api/health/**"); + .excludePathPatterns("/api/health", "/api/health/**", "/api/auth/check"); } } -- 2.45.2