package com.gcsc.guide.auth; import com.gcsc.guide.dto.AuthResponse; import com.gcsc.guide.dto.GoogleLoginRequest; import com.gcsc.guide.dto.UserResponse; import com.gcsc.guide.entity.Role; import com.gcsc.guide.entity.User; 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; import io.swagger.v3.oas.annotations.media.Schema; 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; @Slf4j @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor @Tag(name = "01. 인증", description = "Google OAuth2 로그인 및 JWT 토큰 관리") public class AuthController { private static final String AUTO_ADMIN_EMAIL = "htlee@gcsc.co.kr"; private final GoogleTokenVerifier googleTokenVerifier; private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; private final RoleRepository roleRepository; private final ActivityService activityService; private final SettingsService settingsService; @Operation(summary = "Google 로그인", description = "Google ID Token을 검증하고 JWT를 발급합니다. " + "신규 사용자는 PENDING 상태로 생성되며, htlee@gcsc.co.kr은 자동 ACTIVE + 관리자 부여됩니다.", security = {}) @ApiResponses({ @ApiResponse(responseCode = "200", description = "로그인 성공, JWT 토큰 발급", content = @Content(schema = @Schema(implementation = AuthResponse.class))), @ApiResponse(responseCode = "401", description = "유효하지 않은 Google 토큰 또는 허용되지 않은 이메일 도메인", content = @Content) }) @PostMapping("/google") public ResponseEntity googleLogin( @Valid @RequestBody GoogleLoginRequest request, HttpServletRequest httpRequest, HttpServletResponse httpResponse) { GoogleIdToken.Payload payload = googleTokenVerifier.verify(request.idToken()); if (payload == null) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "유효하지 않은 Google 토큰입니다"); } String email = payload.getEmail(); String name = (String) payload.get("name"); String avatarUrl = (String) payload.get("picture"); userRepository.findByEmail(email) .ifPresentOrElse( existingUser -> { existingUser.updateProfile(name, avatarUrl); existingUser.updateLastLogin(); userRepository.save(existingUser); }, () -> createNewUser(email, name, avatarUrl) ); User userWithRoles = userRepository.findByEmailWithRoles(email) .orElseThrow(); activityService.recordLogin( userWithRoles.getId(), 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))); } @Operation(summary = "현재 사용자 정보 조회", description = "JWT 토큰으로 인증된 현재 사용자의 상세 정보와 롤 목록을 반환합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "사용자 정보 조회 성공"), @ApiResponse(responseCode = "401", description = "인증 실패 (토큰 없음/만료)", content = @Content), @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", content = @Content) }) @SecurityRequirement(name = "Bearer JWT") @GetMapping("/me") public ResponseEntity getCurrentUser(Authentication authentication) { Long userId = (Long) authentication.getPrincipal(); User user = userRepository.findByIdWithRoles(userId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다")); return ResponseEntity.ok(UserResponse.from(user)); } @Operation(summary = "로그아웃", description = "Stateless JWT 방식이므로 서버 측 처리 없이 204를 반환합니다. 클라이언트에서 토큰을 삭제하세요.") @ApiResponse(responseCode = "204", description = "로그아웃 성공") @SecurityRequirement(name = "Bearer JWT") @PostMapping("/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); if (AUTO_ADMIN_EMAIL.equals(email)) { newUser.activate(); newUser.grantAdmin(); log.info("관리자 자동 승인: {}", email); } else if (settingsService.isAutoApproveEnabled()) { newUser.activate(); log.info("자동 승인 (설정 활성화): {}", email); } List defaultRoles = roleRepository.findByDefaultGrantTrue(); if (!defaultRoles.isEmpty()) { newUser.updateRoles(new HashSet<>(defaultRoles)); log.info("기본 롤 부여: {} → {}", email, defaultRoles.stream().map(Role::getName).toList()); } newUser.updateLastLogin(); return userRepository.save(newUser); } }