feat(auth): Nginx 프록시 서비스 인증/권한 체크 (PR #23 포함) #24

병합
htlee develop 에서 main 로 2 commits 를 머지했습니다 2026-02-18 12:56:48 +09:00
4개의 변경된 파일178개의 추가작업 그리고 3개의 파일을 삭제
Showing only changes of commit 353bb3d091 - Show all commits

파일 보기

@ -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<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 토큰입니다");
@ -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<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,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)

파일 보기

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

파일 보기

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