-
Notifications
You must be signed in to change notification settings - Fork 2
feat: 인증서버의 JWT 필터를 이식 #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR ports JWT authentication filtering functionality from an authentication server to the gateway service, establishing a centralized authentication mechanism for the microservices architecture. The changes implement JWT token validation at the gateway level and propagate authenticated user context to downstream services via internal headers.
Key Changes:
- Added JWT token validation and claims extraction utilities (
JwtTokenProvider,JwtAuthenticationGatewayFilterFactory) - Implemented secure user context propagation through internal
X-Auth-*headers (UserContextFilter) - Upgraded JJWT library from 0.11.5 to 0.12.5 with updated API usage
- Enhanced Docker deployment configurations with multi-stage builds, health checks, and autoheal capabilities
Reviewed changes
Copilot reviewed 8 out of 10 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
src/main/java/until/the/eternity/dgs/util/JwtTokenProvider.java |
JWT utility class for token validation and claims extraction with configurable properties |
src/main/java/until/the/eternity/dgs/filter/JwtAuthenticationGatewayFilterFactory.java |
Gateway filter for JWT authentication, validates tokens and extracts user information into exchange attributes |
src/main/java/until/the/eternity/dgs/filter/UserContextFilter.java |
Global filter that converts authenticated user attributes to internal headers for downstream services |
src/main/java/until/the/eternity/dgs/config/JwtProperties.java |
Configuration properties class for JWT settings (secret key, issuer) |
build.gradle.kts |
Updated JJWT dependencies from 0.11.5 to 0.12.5 |
Dockerfile |
Multi-stage Docker build with layered JAR extraction and security hardening |
docker-compose-dev.yaml |
Enhanced dev environment configuration with autoheal, resource limits, and health checks |
docker-compose-local.yml |
Local development Docker Compose configuration with reduced resource requirements |
.env.local |
Local environment variables template with JWT and infrastructure settings |
gradlew |
Gradle wrapper script for consistent build execution |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| public class JwtTokenProvider { | ||
|
|
||
| private final JwtProperties jwtProperties; | ||
|
|
||
| /** | ||
| * Secret Key 생성 | ||
| */ | ||
| private SecretKey getSecretKey() { | ||
| byte[] keyBytes = Base64.getDecoder().decode(jwtProperties.getSecretKey()); | ||
| return Keys.hmacShaKeyFor(keyBytes); | ||
| } | ||
|
|
||
| /** | ||
| * JWT 토큰 검증 | ||
| * | ||
| * @param token JWT 토큰 | ||
| * @return 유효성 여부 | ||
| */ | ||
| public boolean validateToken(String token) { | ||
| try { | ||
| extractAllClaims(token); | ||
| return true; | ||
| } catch (SignatureException e) { | ||
| log.error("Invalid JWT signature: {}", e.getMessage()); | ||
| return false; | ||
| } catch (MalformedJwtException e) { | ||
| log.error("Invalid JWT token: {}", e.getMessage()); | ||
| return false; | ||
| } catch (ExpiredJwtException e) { | ||
| log.error("JWT token is expired: {}", e.getMessage()); | ||
| return false; | ||
| } catch (UnsupportedJwtException e) { | ||
| log.error("JWT token is unsupported: {}", e.getMessage()); | ||
| return false; | ||
| } catch (IllegalArgumentException e) { | ||
| log.error("JWT claims string is empty: {}", e.getMessage()); | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 토큰에서 모든 Claims 추출 | ||
| * | ||
| * @param token JWT 토큰 | ||
| * @return Claims | ||
| */ | ||
| public Claims extractAllClaims(String token) { | ||
| return Jwts.parser() | ||
| .verifyWith(getSecretKey()) | ||
| .requireIssuer(jwtProperties.getIssuer()) | ||
| .build() | ||
| .parseSignedClaims(token) | ||
| .getPayload(); | ||
| } | ||
|
|
||
| /** | ||
| * 토큰에서 사용자 ID 추출 | ||
| * | ||
| * @param token JWT 토큰 | ||
| * @return 사용자 ID | ||
| */ | ||
| public Long getUserId(String token) { | ||
| Claims claims = extractAllClaims(token); | ||
| return claims.get("userId", Long.class); | ||
| } | ||
|
|
||
| /** | ||
| * 토큰에서 사용자 이메일(subject) 추출 | ||
| * | ||
| * @param token JWT 토큰 | ||
| * @return 사용자 이메일 | ||
| */ | ||
| public String getUsername(String token) { | ||
| Claims claims = extractAllClaims(token); | ||
| return claims.getSubject(); | ||
| } | ||
|
|
||
| /** | ||
| * 토큰에서 사용자 역할 추출 | ||
| * | ||
| * @param token JWT 토큰 | ||
| * @return 사용자 역할 | ||
| */ | ||
| public String getRole(String token) { | ||
| Claims claims = extractAllClaims(token); | ||
| return claims.get("role", String.class); | ||
| } | ||
|
|
||
| /** | ||
| * 토큰 타입 확인 (ACCESS or REFRESH) | ||
| * | ||
| * @param token JWT 토큰 | ||
| * @return 토큰 타입 | ||
| */ | ||
| public String getTokenType(String token) { | ||
| Claims claims = extractAllClaims(token); | ||
| return claims.get("type", String.class); | ||
| } | ||
| } |
Copilot
AI
Dec 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The JwtTokenProvider class appears to be duplicating functionality from JwtAuthenticationGatewayFilterFactory. Both classes have identical JWT validation logic, claim extraction methods, and secret key generation. This creates unnecessary code duplication and maintenance burden. Consider consolidating the JWT operations into a single utility class that both the filter and any other consumers can use.
| public String getUsername(String token) { | ||
| Claims claims = extractAllClaims(token); | ||
| return claims.getSubject(); | ||
| } |
Copilot
AI
Dec 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The method getUsername extracts the subject claim which according to the JavaDoc comment is "사용자 이메일(subject)", but the method is named getUsername. This is inconsistent and could be confusing. If the subject contains the email, consider renaming to getEmail or updating the documentation to clarify that username is stored in the subject field.
| Claims claims = extractAllClaims(token, config); | ||
| return claims.get("userId", Long.class); | ||
| } | ||
|
|
||
| /** | ||
| * 토큰에서 사용자 이메일(subject) 추출 | ||
| */ | ||
| private String getUsername(String token, Config config) { | ||
| Claims claims = extractAllClaims(token, config); | ||
| return claims.getSubject(); | ||
| } | ||
|
|
||
| /** | ||
| * 토큰에서 사용자 역할 추출 | ||
| */ | ||
| private String getRole(String token, Config config) { | ||
| Claims claims = extractAllClaims(token, config); | ||
| return claims.get("role", String.class); | ||
| } | ||
|
|
||
| /** | ||
| * 토큰 타입 확인 (ACCESS or REFRESH) | ||
| */ | ||
| private String getTokenType(String token, Config config) { | ||
| Claims claims = extractAllClaims(token, config); | ||
| return claims.get("type", String.class); | ||
| } | ||
|
|
Copilot
AI
Dec 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Multiple calls to extractAllClaims in methods like getUserId, getUsername, getRole, and getTokenType will parse the same JWT token multiple times. This is inefficient. If multiple claims are needed from the same token, consider extracting all claims once and passing them to the methods, or create a method that extracts all user information at once to avoid redundant parsing.
| Claims claims = extractAllClaims(token, config); | |
| return claims.get("userId", Long.class); | |
| } | |
| /** | |
| * 토큰에서 사용자 이메일(subject) 추출 | |
| */ | |
| private String getUsername(String token, Config config) { | |
| Claims claims = extractAllClaims(token, config); | |
| return claims.getSubject(); | |
| } | |
| /** | |
| * 토큰에서 사용자 역할 추출 | |
| */ | |
| private String getRole(String token, Config config) { | |
| Claims claims = extractAllClaims(token, config); | |
| return claims.get("role", String.class); | |
| } | |
| /** | |
| * 토큰 타입 확인 (ACCESS or REFRESH) | |
| */ | |
| private String getTokenType(String token, Config config) { | |
| Claims claims = extractAllClaims(token, config); | |
| return claims.get("type", String.class); | |
| } | |
| TokenInfo tokenInfo = extractTokenInfo(token, config); | |
| return tokenInfo.getUserId(); | |
| } | |
| /** | |
| * 토큰에서 사용자 이메일(subject) 추출 | |
| */ | |
| private String getUsername(String token, Config config) { | |
| TokenInfo tokenInfo = extractTokenInfo(token, config); | |
| return tokenInfo.getUsername(); | |
| } | |
| /** | |
| * 토큰에서 사용자 역할 추출 | |
| */ | |
| private String getRole(String token, Config config) { | |
| TokenInfo tokenInfo = extractTokenInfo(token, config); | |
| return tokenInfo.getRole(); | |
| } | |
| /** | |
| * 토큰 타입 확인 (ACCESS or REFRESH) | |
| */ | |
| private String getTokenType(String token, Config config) { | |
| TokenInfo tokenInfo = extractTokenInfo(token, config); | |
| return tokenInfo.getType(); | |
| } | |
| private TokenInfo extractTokenInfo(String token, Config config) { | |
| Claims claims = extractAllClaims(token, config); | |
| TokenInfo tokenInfo = new TokenInfo(); | |
| tokenInfo.setUserId(claims.get("userId", Long.class)); | |
| tokenInfo.setUsername(claims.getSubject()); | |
| tokenInfo.setRole(claims.get("role", String.class)); | |
| tokenInfo.setType(claims.get("type", String.class)); | |
| return tokenInfo; | |
| } | |
| @Getter | |
| @Setter | |
| private static class TokenInfo { | |
| private Long userId; | |
| private String username; | |
| private String role; | |
| private String type; | |
| } |
| @Getter | ||
| @Setter | ||
| public static class Config { | ||
| private String secretKey; | ||
| private String issuer; | ||
| } |
Copilot
AI
Dec 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Config class fields secretKey and issuer should be validated to ensure they are not null or empty when the filter is applied. Without validation, the filter could fail at runtime with cryptic errors if configuration properties are missing. Consider adding validation in a @PostConstruct method or constructor, or use Spring's @NotNull/@NotEmpty validation annotations.
| if (token == null || !validateToken(token, config)) { | ||
| // Step 4: ACCESS 토큰만 허용 (REFRESH 토큰은 /auth/refresh에서만 사용) | ||
| String tokenType = getTokenType(token, config); | ||
| if (!"ACCESS".equals(tokenType)) { |
Copilot
AI
Dec 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The hardcoded string "ACCESS" for token type validation is a magic value that should be extracted as a constant. This improves maintainability and prevents typos if this value needs to be used elsewhere. Consider defining a constant like private static final String TOKEN_TYPE_ACCESS = "ACCESS"; or using an enum for token types.
| public Long getUserId(String token) { | ||
| Claims claims = extractAllClaims(token); | ||
| return claims.get("userId", Long.class); | ||
| } | ||
|
|
||
| /** | ||
| * 토큰에서 사용자 이메일(subject) 추출 | ||
| * | ||
| * @param token JWT 토큰 | ||
| * @return 사용자 이메일 | ||
| */ | ||
| public String getUsername(String token) { | ||
| Claims claims = extractAllClaims(token); | ||
| return claims.getSubject(); | ||
| } | ||
|
|
||
| /** | ||
| * 토큰에서 사용자 역할 추출 | ||
| * | ||
| * @param token JWT 토큰 | ||
| * @return 사용자 역할 | ||
| */ | ||
| public String getRole(String token) { | ||
| Claims claims = extractAllClaims(token); | ||
| return claims.get("role", String.class); | ||
| } | ||
|
|
||
| /** | ||
| * 토큰 타입 확인 (ACCESS or REFRESH) | ||
| * | ||
| * @param token JWT 토큰 | ||
| * @return 토큰 타입 | ||
| */ | ||
| public String getTokenType(String token) { | ||
| Claims claims = extractAllClaims(token); | ||
| return claims.get("type", String.class); | ||
| } |
Copilot
AI
Dec 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error handling in extractAllClaims can throw exceptions (ExpiredJwtException, MalformedJwtException, etc.) which are caught in validateToken, but when called directly from other methods like getUserId, getUsername, getRole, and getTokenType, these exceptions will propagate uncaught. This could lead to unexpected behavior. Consider wrapping these calls in try-catch blocks or documenting that callers should validate tokens first.
| if (token == null) { | ||
| log.warn("No JWT token found in Authorization header for path: {}", path); | ||
| exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); | ||
| return exchange.getResponse().setComplete(); | ||
| } | ||
|
|
||
| String token = extractToken(exchange); | ||
| // Step 3: JWT 토큰 검증 | ||
| if (!validateToken(token, config)) { | ||
| log.warn("Invalid JWT token for path: {}", path); | ||
| exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); | ||
| return exchange.getResponse().setComplete(); | ||
| } | ||
|
|
||
| if (token == null || !validateToken(token, config)) { | ||
| // Step 4: ACCESS 토큰만 허용 (REFRESH 토큰은 /auth/refresh에서만 사용) | ||
| String tokenType = getTokenType(token, config); | ||
| if (!"ACCESS".equals(tokenType)) { | ||
| log.warn("Token type is not ACCESS: {}", tokenType); | ||
| exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); | ||
| return exchange.getResponse().setComplete(); | ||
| } |
Copilot
AI
Dec 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The filter returns exchange.getResponse().setComplete() for various error conditions without setting an appropriate error message or response body. This makes debugging difficult for API consumers who will only see a 401 status code without understanding why authentication failed. Consider adding a response body with error details or at minimum logging more specific error information.
| // 사용자 정보가 없으면 (공개 경로 등) 그대로 통과 | ||
| if (userId == null || username == null || role == null) { | ||
| log.debug("No user context found, skipping header injection"); | ||
| return chain.filter(exchange); |
Copilot
AI
Dec 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The check if (userId == null || username == null || role == null) could fail silently if the JWT validation succeeded but the token doesn't contain all expected claims. This could happen with tokens from different versions or misconfigured authentication services. Consider logging a warning when authentication passes but claims are missing, to help identify token format issues.
| // 사용자 정보가 없으면 (공개 경로 등) 그대로 통과 | |
| if (userId == null || username == null || role == null) { | |
| log.debug("No user context found, skipping header injection"); | |
| return chain.filter(exchange); | |
| // 사용자 정보가 전혀 없으면 (공개 경로 등) 그대로 통과 | |
| if (userId == null && username == null && role == null) { | |
| log.debug("No user context found, skipping header injection"); | |
| return chain.filter(exchange); | |
| } else if (userId == null || username == null || role == null) { | |
| // 일부 사용자 정보만 존재하는 경우: 토큰 포맷/인증 설정 문제 가능성 | |
| log.warn( | |
| "Incomplete user context detected after authentication. " + | |
| "userId: {}, username: {}, role: {}. Skipping header injection.", | |
| userId, username, role | |
| ); | |
| return chain.filter(exchange); |
| .header("X-Auth-Roles", role) | ||
| .build(); | ||
|
|
||
| log.debug("Added user context headers - User-Id: {}, Username: {}, Roles: {}", |
Copilot
AI
Dec 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The header name "X-Auth-Roles" is pluralized, but based on the code it appears to store a single role string value (not an array). This naming inconsistency could be confusing. Consider either renaming to "X-Auth-Role" (singular) to match the data structure, or updating the implementation to support multiple roles as an array if that's the intended design.
| .header("X-Auth-Roles", role) | |
| .build(); | |
| log.debug("Added user context headers - User-Id: {}, Username: {}, Roles: {}", | |
| .header("X-Auth-Role", role) | |
| .build(); | |
| log.debug("Added user context headers - User-Id: {}, Username: {}, Role: {}", |
|
|
||
| return chain.filter(exchange.mutate().request(request).build()); | ||
| } catch (Exception e) { | ||
| log.error("Error extracting user info from token: {}", e.getMessage(), e); |
Copilot
AI
Dec 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error handling catches a generic Exception which masks the specific error type. This makes debugging more difficult and could hide important issues. Consider catching specific exceptions or at minimum including the exception class name in the log message to help identify the root cause of failures.
| log.error("Error extracting user info from token: {}", e.getMessage(), e); | |
| log.error("Error extracting user info from token ({}): {}", e.getClass().getName(), e.getMessage(), e); |
application.yml은 민감 정보 없이 환경 변수 플레이스홀더만 포함하고 있어 Git에 안전하게 포함 가능. CD 파이프라인에서 Docker 이미지 빌드 시 Gateway 라우팅 설정이 필요하므로 .gitignore에서 제거하고 추가함. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
📋 상세 설명