Compare commits
24 커밋
feature/au
...
main
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| f96e082ae2 | |||
| 72d77899ab | |||
| 299d8bd333 | |||
| 990e69c7db | |||
| 49a954a1dd | |||
| 3761545d09 | |||
| a5d70b0553 | |||
| 8535d5e765 | |||
| ac09c23d3a | |||
| 353bb3d091 | |||
| 983de6a71a | |||
| 98956afcb1 | |||
| f558e43810 | |||
| 1b08afee8b | |||
| b39b0df6b9 | |||
| f62751229e | |||
| 4c837b0ce4 | |||
| a5f58970a9 | |||
| 30f0b28460 | |||
| c0e33e11d7 | |||
| e92b0e15ef | |||
| 57b11774eb | |||
| 3e918baf74 | |||
| acf18221ae |
@ -44,7 +44,21 @@
|
||||
- `@Builder` 허용
|
||||
- `@Data` 사용 금지 (명시적으로 필요한 어노테이션만)
|
||||
- `@AllArgsConstructor` 단독 사용 금지 (`@Builder`와 함께 사용)
|
||||
- `@Slf4j` 로거 사용
|
||||
|
||||
## 로깅
|
||||
- `@Slf4j` (Lombok) 로거 사용
|
||||
- SLF4J `{}` 플레이스홀더에 printf 포맷 사용 금지 (`{:.1f}`, `{:d}`, `{%s}` 등)
|
||||
- 숫자 포맷이 필요하면 `String.format()`으로 변환 후 전달
|
||||
```java
|
||||
// 잘못됨
|
||||
log.info("처리율: {:.1f}%", rate);
|
||||
// 올바름
|
||||
log.info("처리율: {}%", String.format("%.1f", rate));
|
||||
```
|
||||
- 예외 로깅 시 예외 객체는 마지막 인자로 전달 (플레이스홀더 불필요)
|
||||
```java
|
||||
log.error("처리 실패: {}", id, exception);
|
||||
```
|
||||
|
||||
## 예외 처리
|
||||
- 비즈니스 예외는 커스텀 Exception 클래스 정의
|
||||
|
||||
@ -20,13 +20,14 @@ fi
|
||||
# Conventional Commits 정규식
|
||||
# type(scope): subject
|
||||
# - type: feat|fix|docs|style|refactor|test|chore|ci|perf (필수)
|
||||
# - scope: 영문, 숫자, 한글, 점, 밑줄, 하이픈 허용 (선택)
|
||||
# - subject: 1~72자, 한/영 혼용 허용 (필수)
|
||||
PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|perf)(\([a-zA-Z0-9가-힣._-]+\))?: .{1,72}$'
|
||||
# - scope: 괄호 제외 모든 문자 허용 — 한/영/숫자/특수문자 (선택)
|
||||
# - subject: 1자 이상 (길이는 바이트 기반 별도 검증)
|
||||
PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|perf)(\([^)]+\))?: .+$'
|
||||
MAX_SUBJECT_BYTES=200 # UTF-8 한글(3byte) 허용: 72문자 ≈ 최대 216byte
|
||||
|
||||
FIRST_LINE=$(head -1 "$COMMIT_MSG_FILE")
|
||||
|
||||
if ! [[ "$FIRST_LINE" =~ $PATTERN ]]; then
|
||||
if ! echo "$FIRST_LINE" | grep -qE "$PATTERN"; then
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ 커밋 메시지가 Conventional Commits 형식에 맞지 않습니다 ║"
|
||||
@ -58,3 +59,13 @@ if ! [[ "$FIRST_LINE" =~ $PATTERN ]]; then
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 길이 검증 (바이트 기반 — UTF-8 한글 허용)
|
||||
MSG_LEN=$(echo -n "$FIRST_LINE" | wc -c | tr -d ' ')
|
||||
if [ "$MSG_LEN" -gt "$MAX_SUBJECT_BYTES" ]; then
|
||||
echo ""
|
||||
echo " ✗ 커밋 메시지가 너무 깁니다 (${MSG_LEN}바이트, 최대 ${MAX_SUBJECT_BYTES})"
|
||||
echo " 현재 메시지: $FIRST_LINE"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@ -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,127 @@ 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("/").httpOnly(true).secure(true).sameSite("Lax").maxAge(0).build().toString());
|
||||
response.addHeader(HttpHeaders.SET_COOKIE,
|
||||
ResponseCookie.from("gc_proxy_auth", "")
|
||||
.path("/").httpOnly(true).secure(true).sameSite("Lax").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");
|
||||
}
|
||||
}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user