diff --git a/build.gradle.kts b/build.gradle.kts index 45433e3..fb34cb6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -23,6 +23,9 @@ extra["springCloudVersion"] = "2025.1.0" dependencies { implementation("org.springframework.boot:spring-boot-starter-webmvc") implementation("org.springframework.cloud:spring-cloud-starter-gateway-server-webmvc") + implementation("io.jsonwebtoken:jjwt-api:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6") implementation("net.logstash.logback:logstash-logback-encoder:8.0") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("io.micrometer:micrometer-registry-prometheus") diff --git a/environment/.local.env b/environment/.local.env index ff9f157..e00a233 100644 --- a/environment/.local.env +++ b/environment/.local.env @@ -8,3 +8,4 @@ SEARCH_SERVICE_URL=http://devoops-search-service:8080 LOGSTASH_HOST=logstash:5000 ZIPKIN_HOST=zipkin ZIPKIN_PORT=9411 +JWT_SECRET=dGhpcyBpcyBhIHZlcnkgc2VjdXJlIGtleSBmb3IgSFMyNTYgdGhhdCBpcyBhdCBsZWFzdCAyNTYgYml0cyBsb25n diff --git a/src/main/java/com/devoops/gateway/filter/JwtAuthenticationFilter.java b/src/main/java/com/devoops/gateway/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..9f9568f --- /dev/null +++ b/src/main/java/com/devoops/gateway/filter/JwtAuthenticationFilter.java @@ -0,0 +1,124 @@ +package com.devoops.gateway.filter; + +import com.devoops.gateway.service.JwtService; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.NonNull; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.*; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 10) +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final List PUBLIC_PATHS = List.of( + "/api/user/auth/**", + "/api/user/test", + "/actuator/**" + ); + + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + private final JwtService jwtService; + + public JwtAuthenticationFilter(JwtService jwtService) { + this.jwtService = jwtService; + } + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + String path = request.getRequestURI(); + + if (isPublicPath(path)) { + filterChain.doFilter(request, response); + return; + } + + String authHeader = request.getHeader("Authorization"); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + sendError(response, "Missing or invalid Authorization header"); + return; + } + + try { + String token = authHeader.substring(7); + Claims claims = jwtService.parseToken(token); + + String userId = jwtService.getUserId(claims); + String role = jwtService.getRole(claims); + + if (userId == null || role == null) { + sendError(response, "Invalid token claims"); + return; + } + + HttpServletRequest wrappedRequest = new HeaderAddingRequestWrapper(request, Map.of( + "X-User-Id", userId, + "X-User-Role", role + )); + + filterChain.doFilter(wrappedRequest, response); + } catch (JwtException e) { + sendError(response, "Invalid or expired token"); + } + } + + private boolean isPublicPath(String path) { + return PUBLIC_PATHS.stream().anyMatch(pattern -> pathMatcher.match(pattern, path)); + } + + private void sendError(HttpServletResponse response, String message) throws IOException { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType("application/json"); + response.getWriter().write("{\"status\":401,\"detail\":\"%s\"}".formatted(message)); + } + + private static class HeaderAddingRequestWrapper extends HttpServletRequestWrapper { + private final Map extraHeaders; + + public HeaderAddingRequestWrapper(HttpServletRequest request, Map extraHeaders) { + super(request); + this.extraHeaders = extraHeaders; + } + + @Override + public String getHeader(String name) { + if (extraHeaders.containsKey(name)) { + return extraHeaders.get(name); + } + return super.getHeader(name); + } + + @Override + public Enumeration getHeaders(String name) { + if (extraHeaders.containsKey(name)) { + return Collections.enumeration(List.of(extraHeaders.get(name))); + } + return super.getHeaders(name); + } + + @Override + public Enumeration getHeaderNames() { + Set names = new LinkedHashSet<>(); + Enumeration original = super.getHeaderNames(); + while (original.hasMoreElements()) { + names.add(original.nextElement()); + } + names.addAll(extraHeaders.keySet()); + return Collections.enumeration(names); + } + } +} diff --git a/src/main/java/com/devoops/gateway/service/JwtService.java b/src/main/java/com/devoops/gateway/service/JwtService.java new file mode 100644 index 0000000..8ee4f3a --- /dev/null +++ b/src/main/java/com/devoops/gateway/service/JwtService.java @@ -0,0 +1,36 @@ +package com.devoops.gateway.service; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; + +@Service +public class JwtService { + + private final SecretKey signingKey; + + public JwtService(@Value("${jwt.secret}") String secret) { + this.signingKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); + } + + public Claims parseToken(String token) { + return Jwts.parser() + .verifyWith(signingKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + public String getUserId(Claims claims) { + return claims.get("userId", String.class); + } + + public String getRole(Claims claims) { + return claims.get("role", String.class); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3ff62bc..4254648 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -33,6 +33,9 @@ logging.level.com.devoops=DEBUG logging.level.org.springframework.web=INFO logging.level.org.springframework.cloud.gateway=INFO +# JWT +jwt.secret=${JWT_SECRET:dGhpcyBpcyBhIHZlcnkgc2VjdXJlIGtleSBmb3IgSFMyNTYgdGhhdCBpcyBhdCBsZWFzdCAyNTYgYml0cyBsb25n} + #Zipkin tracing configuration management.tracing.sampling.probability=1.0 management.tracing.export.zipkin.endpoint=http://${ZIPKIN_HOST:zipkin}:${ZIPKIN_PORT:9411}/api/v2/spans