Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions backend/api-gateway/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
target/
*.class
*.jar
!.mvn/wrapper/maven-wrapper.jar
.idea/
*.iml
.vscode/
.DS_Store
*.log
HELP.md
.mvn/
mvnw
mvnw.cmd
18 changes: 18 additions & 0 deletions backend/api-gateway/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# ---- Build stage ----
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app

COPY common-lib ./common-lib
RUN cd common-lib && mvn -B clean install -DskipTests

COPY api-gateway/pom.xml ./api-gateway/pom.xml
RUN cd api-gateway && mvn -B dependency:go-offline
COPY api-gateway/src ./api-gateway/src
RUN cd api-gateway && mvn -B clean package -DskipTests

# ---- Run stage ----
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=build /app/api-gateway/target/*.jar app.jar
EXPOSE 9000
ENTRYPOINT ["java", "-jar", "app.jar"]
69 changes: 69 additions & 0 deletions backend/api-gateway/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version>
<relativePath/>
</parent>

<groupId>com.example</groupId>
<artifactId>api-gateway</artifactId>
<version>1.0.0</version>
<name>api-gateway</name>
<description>Single entry point for the Student Management System: routing, JWT validation, and CORS</description>

<properties>
<java.version>17</java.version>
<spring-cloud.version>2023.0.3</spring-cloud.version>
</properties>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>common-lib</artifactId>
<version>1.0.0</version>
</dependency>

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.example.apigateway;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
* Single entry point for all clients (the React frontend, Postman, etc).
* Routes requests to the correct downstream service and rejects requests
* with a missing/invalid/expired JWT before they ever reach a backend
* service - defense in depth alongside each service's own JWT filter.
*/
@SpringBootApplication
public class ApiGatewayApplication {

public static void main(String[] args) {
SpringApplication.run(ApiGatewayApplication.class, args);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package com.example.apigateway.filter;

import com.example.common.security.JwtTokenValidator;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.util.List;

/**
* Runs before every request is routed. Public endpoints (register, login,
* refresh, actuator health) pass straight through; everything else must
* carry a syntactically valid, unexpired Bearer token or the gateway
* rejects it with 401 before it ever reaches a backend service.
*
* This is deliberately a coarse check (signature + expiry only) - the
* gateway doesn't know about roles/ownership rules for each resource, so
* fine-grained authorization still happens in the owning service, which
* independently re-validates the same JWT.
*/
@Component
public class JwtValidationGlobalFilter implements GlobalFilter, Ordered {

private static final List<String> PUBLIC_PATHS = List.of(
"/api/v1/auth/register",
"/api/v1/auth/login",
"/api/v1/auth/refresh",
"/actuator/health"
);

private final JwtTokenValidator jwtTokenValidator;

public JwtValidationGlobalFilter(@Value("${jwt.secret}") String jwtSecret) {
this.jwtTokenValidator = new JwtTokenValidator(jwtSecret);
}

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String path = request.getURI().getPath();

if (isPublic(path)) {
return chain.filter(exchange);
}

String authHeader = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return reject(exchange, "Missing or malformed Authorization header");
}

String token = authHeader.substring(7);
if (!jwtTokenValidator.isValid(token)) {
return reject(exchange, "Invalid or expired token");
}

return chain.filter(exchange);
}

private boolean isPublic(String path) {
return PUBLIC_PATHS.stream().anyMatch(path::startsWith);
}

private Mono<Void> reject(ServerWebExchange exchange, String message) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
exchange.getResponse().getHeaders().add("Content-Type", "application/json");
byte[] body = ("{\"success\":false,\"message\":\"" + message + "\"}").getBytes();
return exchange.getResponse().writeWith(
Mono.just(exchange.getResponse().bufferFactory().wrap(body)));
}

@Override
public int getOrder() {
return -1;
}
}
59 changes: 59 additions & 0 deletions backend/api-gateway/src/main/resources/application.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
server:
port: 9000

spring:
application:
name: api-gateway
cloud:
gateway:
routes:
- id: auth-service
uri: ${AUTH_SERVICE_URL:http://localhost:8080}
predicates:
- Path=/api/v1/auth/**,/api/v1/admin/users/**

- id: student-service
uri: ${STUDENT_SERVICE_URL:http://localhost:8081}
predicates:
- Path=/api/v1/students/**

- id: enrollment-service-courses
uri: ${ENROLLMENT_SERVICE_URL:http://localhost:8082}
predicates:
- Path=/api/v1/courses/**

- id: enrollment-service-enrollments
uri: ${ENROLLMENT_SERVICE_URL:http://localhost:8082}
predicates:
- Path=/api/v1/enrollments/**

globalcors:
cors-configurations:
'[/**]':
allowedOriginPatterns: "*"
allowedMethods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
allowedHeaders: "*"
allowCredentials: true

# ---- JWT (must match auth-service's jwt.secret - the gateway validates
# signature/expiry only; fine-grained ownership checks still happen
# in each downstream service, which also re-validates independently). ----
jwt:
secret: ${JWT_SECRET:MzM0NmM4NDNhOWM5NDcyM2FhZmY3NmY2ZGRhYTQ5ZjM0Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVm}

management:
endpoints:
web:
exposure:
include: health,info,gateway

logging:
level:
com.example.apigateway: DEBUG
org.springframework.cloud.gateway: INFO
32 changes: 32 additions & 0 deletions backend/common-lib/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
<jakarta.validation.version>3.0.2</jakarta.validation.version>
<jackson.version>2.17.2</jackson.version>
<lombok.version>1.18.34</lombok.version>
<jjwt.version>0.12.6</jjwt.version>
<spring-security.version>6.3.3</spring-security.version>
</properties>

<dependencies>
Expand All @@ -37,6 +39,36 @@
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>

<!-- Shared JWT validation used by every resource service (student-service,
enrollment-service, course-service, grade-service, api-gateway) so token
parsing logic lives in exactly one place. -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>

<!-- Only for Authentication / GrantedAuthority / SecurityContextHolder types -
consuming services still bring their own spring-boot-starter-security. -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
<version>${spring-security.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.example.common.security;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
* The authenticated caller, as decoded from a JWT issued by auth-service.
* This is what SecurityContextHolder's Authentication#getPrincipal()
* returns in every resource service (student-service, enrollment-service,
* course-service, grade-service) once JwtAuthenticationFilter runs.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class JwtPrincipal {
private Long userId;
private String email;
private String role;
private Long studentId;

public boolean isAdmin() {
return "ADMIN".equalsIgnoreCase(role);
}

public boolean isStudent() {
return "STUDENT".equalsIgnoreCase(role);
}

/** True if this principal is a STUDENT whose linked profile matches the given id. */
public boolean ownsStudentId(Long id) {
return isStudent() && studentId != null && studentId.equals(id);
}
}
Loading
Loading