From ca05a981698a129bb161c880bafe665db73423a3 Mon Sep 17 00:00:00 2001 From: Shaik Mohammed Ashraf Ali Date: Wed, 15 Jul 2026 21:46:55 +0530 Subject: [PATCH] Phase 3: API Gateway, JWT Authentication and Ownership Enforcement --- backend/api-gateway/.gitignore | 13 ++ backend/api-gateway/Dockerfile | 18 ++ backend/api-gateway/pom.xml | 69 ++++++ .../apigateway/ApiGatewayApplication.java | 18 ++ .../filter/JwtValidationGlobalFilter.java | 82 ++++++++ .../src/main/resources/application.yml | 59 ++++++ backend/common-lib/pom.xml | 32 +++ .../example/common/security/JwtPrincipal.java | 36 ++++ .../common/security/JwtTokenValidator.java | 55 +++++ .../common/security/SecurityUtils.java | 19 ++ backend/enrollment-service/Dockerfile | 14 +- backend/enrollment-service/pom.xml | 9 + .../client/StudentClient.java | 20 +- .../config/SecurityConfig.java | 73 +++++++ .../controller/CourseController.java | 9 + .../controller/EnrollmentController.java | 44 ++-- .../CourseCapacityExceededException.java | 4 +- .../exception/GlobalExceptionHandler.java | 81 +++---- .../StudentServiceUnavailableException.java | 8 +- .../security/JwtAuthenticationFilter.java | 49 +++++ .../service/EnrollmentService.java | 104 ++++++--- .../src/main/resources/application.properties | 5 +- backend/student-service/Dockerfile | 14 +- backend/student-service/pom.xml | 9 + .../studentservice/config/SecurityConfig.java | 75 +++++++ .../controller/StudentController.java | 26 ++- .../exception/GlobalExceptionHandler.java | 13 ++ .../security/JwtAuthenticationFilter.java | 57 +++++ .../src/main/resources/application.properties | 3 + docker-compose.yml | 81 ++++++- ...hase2-Gateway-Flow.postman_collection.json | 197 ++++++++++++++++++ ...ment-Microservices.postman_collection.json | 118 ----------- 32 files changed, 1168 insertions(+), 246 deletions(-) create mode 100644 backend/api-gateway/.gitignore create mode 100644 backend/api-gateway/Dockerfile create mode 100644 backend/api-gateway/pom.xml create mode 100644 backend/api-gateway/src/main/java/com/example/apigateway/ApiGatewayApplication.java create mode 100644 backend/api-gateway/src/main/java/com/example/apigateway/filter/JwtValidationGlobalFilter.java create mode 100644 backend/api-gateway/src/main/resources/application.yml create mode 100644 backend/common-lib/src/main/java/com/example/common/security/JwtPrincipal.java create mode 100644 backend/common-lib/src/main/java/com/example/common/security/JwtTokenValidator.java create mode 100644 backend/common-lib/src/main/java/com/example/common/security/SecurityUtils.java create mode 100644 backend/enrollment-service/src/main/java/com/example/enrollmentservice/config/SecurityConfig.java create mode 100644 backend/enrollment-service/src/main/java/com/example/enrollmentservice/security/JwtAuthenticationFilter.java create mode 100644 backend/student-service/src/main/java/com/example/studentservice/config/SecurityConfig.java create mode 100644 backend/student-service/src/main/java/com/example/studentservice/security/JwtAuthenticationFilter.java create mode 100644 postman/Phase2-Gateway-Flow.postman_collection.json delete mode 100644 postman/Student-Management-Microservices.postman_collection.json diff --git a/backend/api-gateway/.gitignore b/backend/api-gateway/.gitignore new file mode 100644 index 0000000..6255ee4 --- /dev/null +++ b/backend/api-gateway/.gitignore @@ -0,0 +1,13 @@ +target/ +*.class +*.jar +!.mvn/wrapper/maven-wrapper.jar +.idea/ +*.iml +.vscode/ +.DS_Store +*.log +HELP.md +.mvn/ +mvnw +mvnw.cmd diff --git a/backend/api-gateway/Dockerfile b/backend/api-gateway/Dockerfile new file mode 100644 index 0000000..dd4a353 --- /dev/null +++ b/backend/api-gateway/Dockerfile @@ -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"] diff --git a/backend/api-gateway/pom.xml b/backend/api-gateway/pom.xml new file mode 100644 index 0000000..cb91828 --- /dev/null +++ b/backend/api-gateway/pom.xml @@ -0,0 +1,69 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.4 + + + + com.example + api-gateway + 1.0.0 + api-gateway + Single entry point for the Student Management System: routing, JWT validation, and CORS + + + 17 + 2023.0.3 + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + + com.example + common-lib + 1.0.0 + + + + org.springframework.cloud + spring-cloud-starter-gateway + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/backend/api-gateway/src/main/java/com/example/apigateway/ApiGatewayApplication.java b/backend/api-gateway/src/main/java/com/example/apigateway/ApiGatewayApplication.java new file mode 100644 index 0000000..fbe1c81 --- /dev/null +++ b/backend/api-gateway/src/main/java/com/example/apigateway/ApiGatewayApplication.java @@ -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); + } +} diff --git a/backend/api-gateway/src/main/java/com/example/apigateway/filter/JwtValidationGlobalFilter.java b/backend/api-gateway/src/main/java/com/example/apigateway/filter/JwtValidationGlobalFilter.java new file mode 100644 index 0000000..4a0611d --- /dev/null +++ b/backend/api-gateway/src/main/java/com/example/apigateway/filter/JwtValidationGlobalFilter.java @@ -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 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 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 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; + } +} diff --git a/backend/api-gateway/src/main/resources/application.yml b/backend/api-gateway/src/main/resources/application.yml new file mode 100644 index 0000000..fd19482 --- /dev/null +++ b/backend/api-gateway/src/main/resources/application.yml @@ -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 diff --git a/backend/common-lib/pom.xml b/backend/common-lib/pom.xml index c0121c5..6ff1c36 100644 --- a/backend/common-lib/pom.xml +++ b/backend/common-lib/pom.xml @@ -18,6 +18,8 @@ 3.0.2 2.17.2 1.18.34 + 0.12.6 + 6.3.3 @@ -37,6 +39,36 @@ ${lombok.version} provided + + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + + + org.springframework.security + spring-security-core + ${spring-security.version} + provided + diff --git a/backend/common-lib/src/main/java/com/example/common/security/JwtPrincipal.java b/backend/common-lib/src/main/java/com/example/common/security/JwtPrincipal.java new file mode 100644 index 0000000..5886397 --- /dev/null +++ b/backend/common-lib/src/main/java/com/example/common/security/JwtPrincipal.java @@ -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); + } +} diff --git a/backend/common-lib/src/main/java/com/example/common/security/JwtTokenValidator.java b/backend/common-lib/src/main/java/com/example/common/security/JwtTokenValidator.java new file mode 100644 index 0000000..27007d6 --- /dev/null +++ b/backend/common-lib/src/main/java/com/example/common/security/JwtTokenValidator.java @@ -0,0 +1,55 @@ +package com.example.common.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; + +import javax.crypto.SecretKey; +import java.util.Date; + +/** + * Validates and decodes JWTs issued by auth-service. Every resource + * service constructs one of these (as a @Bean, seeded with the same + * shared jwt.secret auth-service uses) instead of re-implementing JWT + * parsing locally. + * + * This class is intentionally framework-agnostic (no servlet/reactive + * dependency) so it works from both a servlet-stack filter (student-service, + * enrollment-service) and a WebFlux GlobalFilter (api-gateway). + */ +public class JwtTokenValidator { + + private final SecretKey signingKey; + + public JwtTokenValidator(String secret) { + this.signingKey = Keys.hmacShaKeyFor(secret.getBytes()); + } + + public boolean isValid(String token) { + try { + Claims claims = extractAllClaims(token); + return claims.getExpiration().after(new Date()); + } catch (Exception ex) { + return false; + } + } + + public JwtPrincipal extractPrincipal(String token) { + Claims claims = extractAllClaims(token); + Long studentId = claims.get("studentId", Long.class); + return JwtPrincipal.builder() + .userId(claims.get("userId", Long.class)) + .email(claims.getSubject()) + .role(claims.get("role", String.class)) + .studentId(studentId) + .build(); + } + + private Claims extractAllClaims(String token) { + return Jwts.parser() + .verifyWith(signingKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} diff --git a/backend/common-lib/src/main/java/com/example/common/security/SecurityUtils.java b/backend/common-lib/src/main/java/com/example/common/security/SecurityUtils.java new file mode 100644 index 0000000..a955125 --- /dev/null +++ b/backend/common-lib/src/main/java/com/example/common/security/SecurityUtils.java @@ -0,0 +1,19 @@ +package com.example.common.security; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +/** Convenience accessor for the current authenticated JwtPrincipal. */ +public final class SecurityUtils { + + private SecurityUtils() { + } + + public static JwtPrincipal currentUser() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !(auth.getPrincipal() instanceof JwtPrincipal principal)) { + return null; + } + return principal; + } +} diff --git a/backend/enrollment-service/Dockerfile b/backend/enrollment-service/Dockerfile index 5071364..b734bb2 100644 --- a/backend/enrollment-service/Dockerfile +++ b/backend/enrollment-service/Dockerfile @@ -1,14 +1,18 @@ # ---- Build stage ---- FROM maven:3.9-eclipse-temurin-17 AS build WORKDIR /app -COPY pom.xml . -RUN mvn -B dependency:go-offline -COPY src ./src -RUN mvn -B clean package -DskipTests + +COPY common-lib ./common-lib +RUN cd common-lib && mvn -B clean install -DskipTests + +COPY enrollment-service/pom.xml ./enrollment-service/pom.xml +RUN cd enrollment-service && mvn -B dependency:go-offline +COPY enrollment-service/src ./enrollment-service/src +RUN cd enrollment-service && mvn -B clean package -DskipTests # ---- Run stage ---- FROM eclipse-temurin:17-jre-alpine WORKDIR /app -COPY --from=build /app/target/*.jar app.jar +COPY --from=build /app/enrollment-service/target/*.jar app.jar EXPOSE 8082 ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/backend/enrollment-service/pom.xml b/backend/enrollment-service/pom.xml index c5d0c4d..77c2acb 100644 --- a/backend/enrollment-service/pom.xml +++ b/backend/enrollment-service/pom.xml @@ -22,6 +22,15 @@ + + com.example + common-lib + 1.0.0 + + + org.springframework.boot + spring-boot-starter-security + org.springframework.boot spring-boot-starter-web diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/client/StudentClient.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/client/StudentClient.java index ecfb565..dd74c8b 100644 --- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/client/StudentClient.java +++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/client/StudentClient.java @@ -4,10 +4,10 @@ import com.example.enrollmentservice.exception.StudentServiceUnavailableException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; -import reactor.core.publisher.Mono; import java.time.Duration; import java.util.Optional; @@ -16,6 +16,12 @@ * Encapsulates all HTTP calls from enrollment-service to student-service. * Keeping this in a single client class means the rest of the codebase * never talks HTTP directly - it only knows about StudentDTO. + * + * Every method takes the caller's original Bearer token and forwards it + * downstream ("pass-through auth"), rather than enrollment-service having + * its own service-account credentials. That way student-service applies + * the SAME ownership rule it would for a direct call: a STUDENT token can + * only read their own profile, an ADMIN token can read anyone's. */ @Component @RequiredArgsConstructor @@ -25,13 +31,14 @@ public class StudentClient { private final WebClient studentServiceWebClient; /** - * Fetches the full student profile. Returns empty if the student - * does not exist (404 from student-service). + * Fetches the full student profile using the caller's own bearer token. + * Returns empty if the student does not exist (404 from student-service). */ - public Optional getStudentById(Long studentId) { + public Optional getStudentById(Long studentId, String bearerToken) { try { StudentDTO student = studentServiceWebClient.get() .uri("/api/v1/students/{id}", studentId) + .header(HttpHeaders.AUTHORIZATION, bearerToken) .retrieve() .bodyToMono(StudentDTO.class) .timeout(Duration.ofSeconds(5)) @@ -39,6 +46,8 @@ public Optional getStudentById(Long studentId) { return Optional.ofNullable(student); } catch (WebClientResponseException.NotFound ex) { return Optional.empty(); + } catch (WebClientResponseException.Forbidden | WebClientResponseException.Unauthorized ex) { + throw ex; } catch (Exception ex) { log.error("Failed to reach student-service for id {}: {}", studentId, ex.getMessage()); throw new StudentServiceUnavailableException( @@ -50,10 +59,11 @@ public Optional getStudentById(Long studentId) { * Cheap existence check, used before creating an enrollment, * so we don't have to deserialize a full profile just to validate. */ - public boolean studentExists(Long studentId) { + public boolean studentExists(Long studentId, String bearerToken) { try { Boolean exists = studentServiceWebClient.get() .uri("/api/v1/students/{id}/exists", studentId) + .header(HttpHeaders.AUTHORIZATION, bearerToken) .retrieve() .bodyToMono(Boolean.class) .timeout(Duration.ofSeconds(5)) diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/config/SecurityConfig.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/config/SecurityConfig.java new file mode 100644 index 0000000..b75a6d9 --- /dev/null +++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/config/SecurityConfig.java @@ -0,0 +1,73 @@ +package com.example.enrollmentservice.config; + +import com.example.common.security.JwtTokenValidator; +import com.example.enrollmentservice.security.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + @Value("${jwt.secret}") + private String jwtSecret; + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter( + JwtTokenValidator jwtTokenValidator) { + + return new JwtAuthenticationFilter(jwtTokenValidator); + } + + @Bean + public JwtTokenValidator jwtTokenValidator() { + return new JwtTokenValidator(jwtSecret); + } + + @Bean + public SecurityFilterChain securityFilterChain( + HttpSecurity http, + JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception { + + http + .csrf(csrf -> csrf.disable()) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/actuator/health").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, + UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOriginPatterns(List.of("*")); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/controller/CourseController.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/controller/CourseController.java index c244218..996e1f0 100644 --- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/controller/CourseController.java +++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/controller/CourseController.java @@ -6,10 +6,16 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.util.List; +/** + * Access rules: any authenticated user (ADMIN or STUDENT) can browse + * courses - students need this to see what's available to enroll in. + * Only ADMIN can create, update, or delete a course. + */ @RestController @RequestMapping("/api/v1/courses") @RequiredArgsConstructor @@ -18,6 +24,7 @@ public class CourseController { private final CourseService courseService; @PostMapping + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity createCourse(@Valid @RequestBody CourseDTO courseDTO) { return new ResponseEntity<>(courseService.createCourse(courseDTO), HttpStatus.CREATED); } @@ -33,11 +40,13 @@ public ResponseEntity> getAllCourses() { } @PutMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity updateCourse(@PathVariable Long id, @RequestBody CourseDTO courseDTO) { return ResponseEntity.ok(courseService.updateCourse(id, courseDTO)); } @DeleteMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity deleteCourse(@PathVariable Long id) { courseService.deleteCourse(id); return ResponseEntity.noContent().build(); diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/controller/EnrollmentController.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/controller/EnrollmentController.java index 7b740a7..d327e23 100644 --- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/controller/EnrollmentController.java +++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/controller/EnrollmentController.java @@ -5,13 +5,24 @@ import com.example.enrollmentservice.service.EnrollmentService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; +/** + * Access rules: + * - POST /enrollments: any authenticated user; a STUDENT can only enroll + * themselves (enforced in EnrollmentService using the JWT's studentId, + * regardless of the body), an ADMIN can enroll anyone. + * - GET by student / by id: ADMIN or the owning STUDENT only. + * - GET by course, PATCH status/grade: ADMIN only. + * - DELETE (drop): ADMIN or the owning STUDENT only. + */ @RestController @RequestMapping("/api/v1/enrollments") @RequiredArgsConstructor @@ -20,33 +31,42 @@ public class EnrollmentController { private final EnrollmentService enrollmentService; @PostMapping - public ResponseEntity enroll(@Valid @RequestBody EnrollmentRequestDTO request) { - return new ResponseEntity<>(enrollmentService.enrollStudent(request), HttpStatus.CREATED); + public ResponseEntity enroll(@Valid @RequestBody EnrollmentRequestDTO request, + @RequestHeader(HttpHeaders.AUTHORIZATION) String authHeader) { + return new ResponseEntity<>(enrollmentService.enrollStudent(request, authHeader), HttpStatus.CREATED); } @GetMapping("/{id}") - public ResponseEntity getEnrollment(@PathVariable Long id) { - return ResponseEntity.ok(enrollmentService.getEnrollmentById(id)); + public ResponseEntity getEnrollment(@PathVariable Long id, + @RequestHeader(HttpHeaders.AUTHORIZATION) String authHeader) { + return ResponseEntity.ok(enrollmentService.getEnrollmentById(id, authHeader)); } @GetMapping("/student/{studentId}") - public ResponseEntity> getByStudent(@PathVariable Long studentId) { - return ResponseEntity.ok(enrollmentService.getEnrollmentsByStudent(studentId)); + public ResponseEntity> getByStudent(@PathVariable Long studentId, + @RequestHeader(HttpHeaders.AUTHORIZATION) String authHeader) { + return ResponseEntity.ok(enrollmentService.getEnrollmentsByStudent(studentId, authHeader)); } @GetMapping("/course/{courseId}") - public ResponseEntity> getByCourse(@PathVariable Long courseId) { - return ResponseEntity.ok(enrollmentService.getEnrollmentsByCourse(courseId)); + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> getByCourse(@PathVariable Long courseId, + @RequestHeader(HttpHeaders.AUTHORIZATION) String authHeader) { + return ResponseEntity.ok(enrollmentService.getEnrollmentsByCourse(courseId, authHeader)); } @PatchMapping("/{id}/status") - public ResponseEntity updateStatus(@PathVariable Long id, @RequestBody Map body) { - return ResponseEntity.ok(enrollmentService.updateStatus(id, body.get("status"))); + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity updateStatus(@PathVariable Long id, @RequestBody Map body, + @RequestHeader(HttpHeaders.AUTHORIZATION) String authHeader) { + return ResponseEntity.ok(enrollmentService.updateStatus(id, body.get("status"), authHeader)); } @PatchMapping("/{id}/grade") - public ResponseEntity recordGrade(@PathVariable Long id, @RequestBody Map body) { - return ResponseEntity.ok(enrollmentService.recordGrade(id, body.get("grade"))); + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity recordGrade(@PathVariable Long id, @RequestBody Map body, + @RequestHeader(HttpHeaders.AUTHORIZATION) String authHeader) { + return ResponseEntity.ok(enrollmentService.recordGrade(id, body.get("grade"), authHeader)); } @DeleteMapping("/{id}") diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/CourseCapacityExceededException.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/CourseCapacityExceededException.java index 9446314..6d720eb 100644 --- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/CourseCapacityExceededException.java +++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/CourseCapacityExceededException.java @@ -1,8 +1,8 @@ package com.example.enrollmentservice.exception; +/** Thrown when trying to enroll a student in a course that is already full. */ public class CourseCapacityExceededException extends RuntimeException { - public CourseCapacityExceededException(String message) { super(message); } -} \ No newline at end of file +} diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/GlobalExceptionHandler.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/GlobalExceptionHandler.java index bfb8957..52e1f93 100644 --- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/GlobalExceptionHandler.java +++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -15,89 +16,55 @@ @RestControllerAdvice public class GlobalExceptionHandler { + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDenied(AccessDeniedException ex, HttpServletRequest req) { + return build(HttpStatus.FORBIDDEN, "Forbidden", "You do not have permission to access this resource", req, null); + } + @ExceptionHandler(ResourceNotFoundException.class) public ResponseEntity handleNotFound(ResourceNotFoundException ex, HttpServletRequest req) { - ErrorResponse error = ErrorResponse.builder() - .timestamp(LocalDateTime.now()) - .status(HttpStatus.NOT_FOUND.value()) - .error("Not Found") - .message(ex.getMessage()) - .path(req.getRequestURI()) - .build(); - return new ResponseEntity<>(error, HttpStatus.NOT_FOUND); + return build(HttpStatus.NOT_FOUND, "Not Found", ex.getMessage(), req, null); } @ExceptionHandler(DuplicateResourceException.class) public ResponseEntity handleDuplicate(DuplicateResourceException ex, HttpServletRequest req) { - ErrorResponse error = ErrorResponse.builder() - .timestamp(LocalDateTime.now()) - .status(HttpStatus.CONFLICT.value()) - .error("Conflict") - .message(ex.getMessage()) - .path(req.getRequestURI()) - .build(); - return new ResponseEntity<>(error, HttpStatus.CONFLICT); + return build(HttpStatus.CONFLICT, "Conflict", ex.getMessage(), req, null); } @ExceptionHandler(CourseCapacityExceededException.class) - public ResponseEntity handleCourseCapacityExceeded( - CourseCapacityExceededException ex, - HttpServletRequest request) { - - ErrorResponse response = ErrorResponse.builder() - .timestamp(LocalDateTime.now()) - .status(HttpStatus.CONFLICT.value()) - .error("Course Capacity Exceeded") - .message(ex.getMessage()) - .path(request.getRequestURI()) - .build(); - - return new ResponseEntity<>(response, HttpStatus.CONFLICT); + public ResponseEntity handleCapacity(CourseCapacityExceededException ex, HttpServletRequest req) { + return build(HttpStatus.CONFLICT, "Course Full", ex.getMessage(), req, null); } @ExceptionHandler(StudentServiceUnavailableException.class) - public ResponseEntity handleStudentServiceUnavailable( - StudentServiceUnavailableException ex, - HttpServletRequest request) { - - ErrorResponse response = ErrorResponse.builder() - .timestamp(LocalDateTime.now()) - .status(HttpStatus.SERVICE_UNAVAILABLE.value()) - .error("Student Service Unavailable") - .message(ex.getMessage()) - .path(request.getRequestURI()) - .build(); - - return new ResponseEntity<>(response, HttpStatus.SERVICE_UNAVAILABLE); + public ResponseEntity handleUnavailable(StudentServiceUnavailableException ex, HttpServletRequest req) { + return build(HttpStatus.SERVICE_UNAVAILABLE, "Dependency Unavailable", ex.getMessage(), req, null); } - @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleValidation(MethodArgumentNotValidException ex, HttpServletRequest req) { Map validationErrors = new HashMap<>(); for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) { validationErrors.put(fieldError.getField(), fieldError.getDefaultMessage()); } - ErrorResponse error = ErrorResponse.builder() - .timestamp(LocalDateTime.now()) - .status(HttpStatus.BAD_REQUEST.value()) - .error("Validation Failed") - .message("One or more fields are invalid") - .path(req.getRequestURI()) - .validationErrors(validationErrors) - .build(); - return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST); + return build(HttpStatus.BAD_REQUEST, "Validation Failed", "One or more fields are invalid", req, validationErrors); } @ExceptionHandler(Exception.class) public ResponseEntity handleGeneric(Exception ex, HttpServletRequest req) { - ErrorResponse error = ErrorResponse.builder() + return build(HttpStatus.INTERNAL_SERVER_ERROR, "Internal Server Error", ex.getMessage(), req, null); + } + + private ResponseEntity build(HttpStatus status, String error, String message, + HttpServletRequest req, Map validationErrors) { + ErrorResponse body = ErrorResponse.builder() .timestamp(LocalDateTime.now()) - .status(HttpStatus.INTERNAL_SERVER_ERROR.value()) - .error("Internal Server Error") - .message(ex.getMessage()) + .status(status.value()) + .error(error) + .message(message) .path(req.getRequestURI()) + .validationErrors(validationErrors) .build(); - return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR); + return new ResponseEntity<>(body, status); } } diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/StudentServiceUnavailableException.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/StudentServiceUnavailableException.java index 165f589..ee282c5 100644 --- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/StudentServiceUnavailableException.java +++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/StudentServiceUnavailableException.java @@ -1,12 +1,8 @@ package com.example.enrollmentservice.exception; +/** Thrown when enrollment-service cannot reach student-service to validate a student. */ public class StudentServiceUnavailableException extends RuntimeException { - public StudentServiceUnavailableException(String message) { super(message); } - - public StudentServiceUnavailableException(String message, Throwable cause) { - super(message, cause); - } -} \ No newline at end of file +} diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/security/JwtAuthenticationFilter.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..533fca5 --- /dev/null +++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/security/JwtAuthenticationFilter.java @@ -0,0 +1,49 @@ +package com.example.enrollmentservice.security; + +import com.example.common.security.JwtPrincipal; +import com.example.common.security.JwtTokenValidator; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenValidator jwtTokenValidator; + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + + if (jwtTokenValidator.isValid(token) && SecurityContextHolder.getContext().getAuthentication() == null) { + JwtPrincipal principal = jwtTokenValidator.extractPrincipal(token); + List authorities = List.of(new SimpleGrantedAuthority("ROLE_" + principal.getRole())); + + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken(principal, null, authorities); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/service/EnrollmentService.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/service/EnrollmentService.java index 6d28307..ffc95c2 100644 --- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/service/EnrollmentService.java +++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/service/EnrollmentService.java @@ -1,5 +1,7 @@ package com.example.enrollmentservice.service; +import com.example.common.security.JwtPrincipal; +import com.example.common.security.SecurityUtils; import com.example.enrollmentservice.client.StudentClient; import com.example.enrollmentservice.dto.EnrollmentRequestDTO; import com.example.enrollmentservice.dto.EnrollmentResponseDTO; @@ -12,16 +14,24 @@ import com.example.enrollmentservice.repository.CourseRepository; import com.example.enrollmentservice.repository.EnrollmentRepository; import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; -import java.util.Optional; /** * Orchestrates the student <-> course enrollment lifecycle. - * This is where the inter-service REST call happens: before an enrollment - * is created, the student's existence is validated against student-service. + * + * Access rules enforced here (in addition to @PreAuthorize on the + * controller for admin-only actions): + * - A STUDENT may only enroll THEMSELVES (their token's studentId is used, + * overriding whatever studentId was sent in the request body) and may + * only view/drop their OWN enrollments. + * - An ADMIN may enroll/view/drop on behalf of any student. + * + * Every call to student-service forwards the original caller's bearer + * token, so student-service applies the exact same ownership rule. */ @Service @RequiredArgsConstructor @@ -32,89 +42,129 @@ public class EnrollmentService { private final CourseRepository courseRepository; private final StudentClient studentClient; - public EnrollmentResponseDTO enrollStudent(EnrollmentRequestDTO request) { - // 1. Validate the course exists and has room. + public EnrollmentResponseDTO enrollStudent(EnrollmentRequestDTO request, String bearerToken) { + JwtPrincipal principal = requirePrincipal(); + + // Determine the effective student ID exactly once + final Long effectiveStudentId; + + if (principal.isAdmin()) { + effectiveStudentId = request.getStudentId(); + } else { + effectiveStudentId = principal.getStudentId(); + } + Course course = courseRepository.findById(request.getCourseId()) - .orElseThrow(() -> new ResourceNotFoundException("Course not found with id: " + request.getCourseId())); + .orElseThrow(() -> new ResourceNotFoundException( + "Course not found with id: " + request.getCourseId())); + + long confirmedCount = enrollmentRepository.countByCourseIdAndStatus( + course.getId(), + Enrollment.EnrollmentStatus.CONFIRMED); - long confirmedCount = enrollmentRepository.countByCourseIdAndStatus(course.getId(), Enrollment.EnrollmentStatus.CONFIRMED); if (confirmedCount >= course.getCapacity()) { throw new CourseCapacityExceededException( - "Course '" + course.getCourseCode() + "' has reached its capacity of " + course.getCapacity()); + "Course '" + course.getCourseCode() + + "' has reached its capacity of " + + course.getCapacity()); } - // 2. Validate the student exists via inter-service REST call to student-service. - StudentDTO student = studentClient.getStudentById(request.getStudentId()) - .orElseThrow(() -> new ResourceNotFoundException("Student not found with id: " + request.getStudentId())); + StudentDTO student = studentClient.getStudentById(effectiveStudentId, bearerToken) + .orElseThrow(() -> new ResourceNotFoundException( + "Student not found with id: " + effectiveStudentId)); + + if (enrollmentRepository.existsByStudentIdAndCourseId( + effectiveStudentId, + request.getCourseId())) { - // 3. Prevent duplicate enrollment. - if (enrollmentRepository.existsByStudentIdAndCourseId(request.getStudentId(), request.getCourseId())) { throw new DuplicateResourceException( - "Student " + request.getStudentId() + " is already enrolled in course " + request.getCourseId()); + "Student " + effectiveStudentId + + " is already enrolled in course " + + request.getCourseId()); } - // 4. Persist the enrollment as CONFIRMED (workflow could route through - // PENDING -> approval step first; kept simple here). Enrollment enrollment = Enrollment.builder() - .studentId(request.getStudentId()) + .studentId(effectiveStudentId) .course(course) .status(Enrollment.EnrollmentStatus.CONFIRMED) .build(); Enrollment saved = enrollmentRepository.save(enrollment); + return EnrollmentResponseDTO.fromEntity(saved, student); } @Transactional(readOnly = true) - public EnrollmentResponseDTO getEnrollmentById(Long id) { + public EnrollmentResponseDTO getEnrollmentById(Long id, String bearerToken) { Enrollment enrollment = enrollmentRepository.findById(id) .orElseThrow(() -> new ResourceNotFoundException("Enrollment not found with id: " + id)); - StudentDTO student = studentClient.getStudentById(enrollment.getStudentId()).orElse(null); + requireAdminOrOwner(enrollment.getStudentId()); + StudentDTO student = studentClient.getStudentById(enrollment.getStudentId(), bearerToken).orElse(null); return EnrollmentResponseDTO.fromEntity(enrollment, student); } @Transactional(readOnly = true) - public List getEnrollmentsByStudent(Long studentId) { - StudentDTO student = studentClient.getStudentById(studentId) + public List getEnrollmentsByStudent(Long studentId, String bearerToken) { + requireAdminOrOwner(studentId); + StudentDTO student = studentClient.getStudentById(studentId, bearerToken) .orElseThrow(() -> new ResourceNotFoundException("Student not found with id: " + studentId)); return enrollmentRepository.findByStudentId(studentId).stream() .map(e -> EnrollmentResponseDTO.fromEntity(e, student)) .toList(); } + /** ADMIN only - see EnrollmentController's @PreAuthorize. */ @Transactional(readOnly = true) - public List getEnrollmentsByCourse(Long courseId) { + public List getEnrollmentsByCourse(Long courseId, String bearerToken) { return enrollmentRepository.findByCourseId(courseId).stream() .map(e -> { - StudentDTO student = studentClient.getStudentById(e.getStudentId()).orElse(null); + StudentDTO student = studentClient.getStudentById(e.getStudentId(), bearerToken).orElse(null); return EnrollmentResponseDTO.fromEntity(e, student); }) .toList(); } - public EnrollmentResponseDTO updateStatus(Long enrollmentId, String status) { + /** ADMIN only - see EnrollmentController's @PreAuthorize. */ + public EnrollmentResponseDTO updateStatus(Long enrollmentId, String status, String bearerToken) { Enrollment enrollment = enrollmentRepository.findById(enrollmentId) .orElseThrow(() -> new ResourceNotFoundException("Enrollment not found with id: " + enrollmentId)); enrollment.setStatus(Enrollment.EnrollmentStatus.valueOf(status.toUpperCase())); Enrollment saved = enrollmentRepository.save(enrollment); - StudentDTO student = studentClient.getStudentById(saved.getStudentId()).orElse(null); + StudentDTO student = studentClient.getStudentById(saved.getStudentId(), bearerToken).orElse(null); return EnrollmentResponseDTO.fromEntity(saved, student); } - public EnrollmentResponseDTO recordGrade(Long enrollmentId, Double grade) { + /** ADMIN only - see EnrollmentController's @PreAuthorize. */ + public EnrollmentResponseDTO recordGrade(Long enrollmentId, Double grade, String bearerToken) { Enrollment enrollment = enrollmentRepository.findById(enrollmentId) .orElseThrow(() -> new ResourceNotFoundException("Enrollment not found with id: " + enrollmentId)); enrollment.setGrade(grade); enrollment.setStatus(Enrollment.EnrollmentStatus.COMPLETED); Enrollment saved = enrollmentRepository.save(enrollment); - StudentDTO student = studentClient.getStudentById(saved.getStudentId()).orElse(null); + StudentDTO student = studentClient.getStudentById(saved.getStudentId(), bearerToken).orElse(null); return EnrollmentResponseDTO.fromEntity(saved, student); } public void dropEnrollment(Long enrollmentId) { Enrollment enrollment = enrollmentRepository.findById(enrollmentId) .orElseThrow(() -> new ResourceNotFoundException("Enrollment not found with id: " + enrollmentId)); + requireAdminOrOwner(enrollment.getStudentId()); enrollment.setStatus(Enrollment.EnrollmentStatus.DROPPED); enrollmentRepository.save(enrollment); } + + private JwtPrincipal requirePrincipal() { + JwtPrincipal principal = SecurityUtils.currentUser(); + if (principal == null) { + throw new AccessDeniedException("Authentication required"); + } + return principal; + } + + private void requireAdminOrOwner(Long studentId) { + JwtPrincipal principal = requirePrincipal(); + if (!principal.isAdmin() && !principal.ownsStudentId(studentId)) { + throw new AccessDeniedException("You may only access your own enrollments"); + } + } } diff --git a/backend/enrollment-service/src/main/resources/application.properties b/backend/enrollment-service/src/main/resources/application.properties index 8fa63b7..0e9c3b6 100644 --- a/backend/enrollment-service/src/main/resources/application.properties +++ b/backend/enrollment-service/src/main/resources/application.properties @@ -2,7 +2,7 @@ spring.application.name=enrollment-service server.port=8082 # ---- MySQL Datasource ---- -spring.datasource.url=jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:enrollment_db}?createDatabaseIfNotExist=true&useSSL=false&serverTimezone=UTC +spring.datasource.url=jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:enrollment_db}?createDatabaseIfNotExist=true&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC spring.datasource.username=${DB_USER:root} spring.datasource.password=${DB_PASSWORD:root} spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver @@ -17,6 +17,9 @@ spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect # Base URL of student-service. In docker-compose this resolves via the service name. student-service.base-url=${STUDENT_SERVICE_URL:http://localhost:8081} +# ---- JWT (must match auth-service's jwt.secret for token validation to succeed) ---- +jwt.secret=${JWT_SECRET:MzM0NmM4NDNhOWM5NDcyM2FhZmY3NmY2ZGRhYTQ5ZjM0Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVm} + # ---- Actuator ---- management.endpoints.web.exposure.include=health,info,metrics diff --git a/backend/student-service/Dockerfile b/backend/student-service/Dockerfile index eb9e205..0e08ffc 100644 --- a/backend/student-service/Dockerfile +++ b/backend/student-service/Dockerfile @@ -1,14 +1,18 @@ # ---- Build stage ---- FROM maven:3.9-eclipse-temurin-17 AS build WORKDIR /app -COPY pom.xml . -RUN mvn -B dependency:go-offline -COPY src ./src -RUN mvn -B clean package -DskipTests + +COPY common-lib ./common-lib +RUN cd common-lib && mvn -B clean install -DskipTests + +COPY student-service/pom.xml ./student-service/pom.xml +RUN cd student-service && mvn -B dependency:go-offline +COPY student-service/src ./student-service/src +RUN cd student-service && mvn -B clean package -DskipTests # ---- Run stage ---- FROM eclipse-temurin:17-jre-alpine WORKDIR /app -COPY --from=build /app/target/*.jar app.jar +COPY --from=build /app/student-service/target/*.jar app.jar EXPOSE 8081 ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/backend/student-service/pom.xml b/backend/student-service/pom.xml index 31d61ab..67af1a6 100644 --- a/backend/student-service/pom.xml +++ b/backend/student-service/pom.xml @@ -22,6 +22,15 @@ + + com.example + common-lib + 1.0.0 + + + org.springframework.boot + spring-boot-starter-security + org.springframework.boot spring-boot-starter-web diff --git a/backend/student-service/src/main/java/com/example/studentservice/config/SecurityConfig.java b/backend/student-service/src/main/java/com/example/studentservice/config/SecurityConfig.java new file mode 100644 index 0000000..1055485 --- /dev/null +++ b/backend/student-service/src/main/java/com/example/studentservice/config/SecurityConfig.java @@ -0,0 +1,75 @@ +package com.example.studentservice.config; + +import com.example.studentservice.security.JwtAuthenticationFilter; +import com.example.common.security.JwtTokenValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + + + @Value("${jwt.secret}") + private String jwtSecret; + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter( + JwtTokenValidator jwtTokenValidator) { + + return new JwtAuthenticationFilter(jwtTokenValidator); + } + + @Bean + public JwtTokenValidator jwtTokenValidator() { + return new JwtTokenValidator(jwtSecret); + } + + @Bean + public SecurityFilterChain securityFilterChain( + HttpSecurity http, + JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception { + + http + .csrf(csrf -> csrf.disable()) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/actuator/health").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, + UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOriginPatterns(List.of("*")); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/backend/student-service/src/main/java/com/example/studentservice/controller/StudentController.java b/backend/student-service/src/main/java/com/example/studentservice/controller/StudentController.java index e1149e3..3e6140f 100644 --- a/backend/student-service/src/main/java/com/example/studentservice/controller/StudentController.java +++ b/backend/student-service/src/main/java/com/example/studentservice/controller/StudentController.java @@ -1,5 +1,7 @@ package com.example.studentservice.controller; +import com.example.common.security.JwtPrincipal; +import com.example.common.security.SecurityUtils; import com.example.studentservice.dto.StudentDTO; import com.example.studentservice.service.StudentService; import jakarta.validation.Valid; @@ -9,6 +11,8 @@ import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -16,6 +20,12 @@ /** * REST API for student CRUD operations. * Base path: /api/v1/students + * + * Access rules: + * - ADMIN can do everything. + * - STUDENT can only read/update their OWN profile (matched via the + * studentId claim embedded in their JWT by auth-service), and cannot + * list/search all students, create, or delete any profile. */ @RestController @RequestMapping("/api/v1/students") @@ -25,6 +35,7 @@ public class StudentController { private final StudentService studentService; @PostMapping + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity createStudent(@Valid @RequestBody StudentDTO studentDTO) { StudentDTO created = studentService.createStudent(studentDTO); return new ResponseEntity<>(created, HttpStatus.CREATED); @@ -32,15 +43,18 @@ public ResponseEntity createStudent(@Valid @RequestBody StudentDTO s @GetMapping("/{id}") public ResponseEntity getStudentById(@PathVariable Long id) { + requireAdminOrOwner(id); return ResponseEntity.ok(studentService.getStudentById(id)); } @GetMapping("/email/{email}") + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity getStudentByEmail(@PathVariable String email) { return ResponseEntity.ok(studentService.getStudentByEmail(email)); } @GetMapping + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity getStudents( @RequestParam(required = false) String keyword, @RequestParam(defaultValue = "0") int page, @@ -62,10 +76,12 @@ public ResponseEntity getStudents( @PutMapping("/{id}") public ResponseEntity updateStudent(@PathVariable Long id, @RequestBody StudentDTO studentDTO) { + requireAdminOrOwner(id); return ResponseEntity.ok(studentService.updateStudent(id, studentDTO)); } @DeleteMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity deleteStudent(@PathVariable Long id) { studentService.deleteStudent(id); return ResponseEntity.noContent().build(); @@ -73,10 +89,18 @@ public ResponseEntity deleteStudent(@PathVariable Long id) { /** * Lightweight existence check used by enrollment-service before - * creating an enrollment, to avoid pulling the full profile. + * creating an enrollment. Any authenticated caller may use this - + * it leaks no profile data, only a boolean. */ @GetMapping("/{id}/exists") public ResponseEntity existsById(@PathVariable Long id) { return ResponseEntity.ok(studentService.existsById(id)); } + + private void requireAdminOrOwner(Long studentId) { + JwtPrincipal principal = SecurityUtils.currentUser(); + if (principal == null || (!principal.isAdmin() && !principal.ownsStudentId(studentId))) { + throw new AccessDeniedException("You may only access your own student record"); + } + } } diff --git a/backend/student-service/src/main/java/com/example/studentservice/exception/GlobalExceptionHandler.java b/backend/student-service/src/main/java/com/example/studentservice/exception/GlobalExceptionHandler.java index b999363..d38dd99 100644 --- a/backend/student-service/src/main/java/com/example/studentservice/exception/GlobalExceptionHandler.java +++ b/backend/student-service/src/main/java/com/example/studentservice/exception/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -15,6 +16,18 @@ @RestControllerAdvice public class GlobalExceptionHandler { + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDenied(AccessDeniedException ex, HttpServletRequest req) { + ErrorResponse error = ErrorResponse.builder() + .timestamp(LocalDateTime.now()) + .status(HttpStatus.FORBIDDEN.value()) + .error("Forbidden") + .message("You do not have permission to access this resource") + .path(req.getRequestURI()) + .build(); + return new ResponseEntity<>(error, HttpStatus.FORBIDDEN); + } + @ExceptionHandler(ResourceNotFoundException.class) public ResponseEntity handleNotFound(ResourceNotFoundException ex, HttpServletRequest req) { ErrorResponse error = ErrorResponse.builder() diff --git a/backend/student-service/src/main/java/com/example/studentservice/security/JwtAuthenticationFilter.java b/backend/student-service/src/main/java/com/example/studentservice/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..ca0d83c --- /dev/null +++ b/backend/student-service/src/main/java/com/example/studentservice/security/JwtAuthenticationFilter.java @@ -0,0 +1,57 @@ +package com.example.studentservice.security; + +import com.example.common.security.JwtPrincipal; +import com.example.common.security.JwtTokenValidator; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +/** + * Validates the Bearer token issued by auth-service and populates the + * SecurityContext with a JwtPrincipal, so controllers can enforce + * ownership rules (e.g. a STUDENT may only read/update their own record). + * This service does NOT call auth-service to validate - it verifies the + * JWT signature locally using the shared secret, which is the whole + * point of using signed, stateless access tokens between services. + */ + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenValidator jwtTokenValidator; + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + + if (jwtTokenValidator.isValid(token) && SecurityContextHolder.getContext().getAuthentication() == null) { + JwtPrincipal principal = jwtTokenValidator.extractPrincipal(token); + List authorities = List.of(new SimpleGrantedAuthority("ROLE_" + principal.getRole())); + + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken(principal, null, authorities); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/backend/student-service/src/main/resources/application.properties b/backend/student-service/src/main/resources/application.properties index 66231ae..c489235 100644 --- a/backend/student-service/src/main/resources/application.properties +++ b/backend/student-service/src/main/resources/application.properties @@ -13,6 +13,9 @@ spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect +# ---- JWT (must match auth-service's jwt.secret for token validation to succeed) ---- +jwt.secret=${JWT_SECRET:MzM0NmM4NDNhOWM5NDcyM2FhZmY3NmY2ZGRhYTQ5ZjM0Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVm} + # ---- Actuator ---- management.endpoints.web.exposure.include=health,info,metrics diff --git a/docker-compose.yml b/docker-compose.yml index 19b4f4f..39a7937 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,25 @@ version: "3.8" services: + mysql-auth: + image: mysql:8.0 + container_name: mysql-auth + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: auth_db + ports: + - "3310:3306" + volumes: + - mysql_auth_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - sms-net + mysql-student: image: mysql:8.0 container_name: mysql-student @@ -18,7 +37,7 @@ services: timeout: 5s retries: 5 networks: - - student-net + - sms-net mysql-enrollment: image: mysql:8.0 @@ -37,10 +56,33 @@ services: timeout: 5s retries: 5 networks: - - student-net + - sms-net + + auth-service: + build: + context: . + dockerfile: auth-service/Dockerfile + container_name: auth-service + restart: unless-stopped + depends_on: + mysql-auth: + condition: service_healthy + environment: + DB_HOST: mysql-auth + DB_PORT: 3306 + DB_NAME: auth_db + DB_USER: root + DB_PASSWORD: root + JWT_SECRET: ${JWT_SECRET:-MzM0NmM4NDNhOWM5NDcyM2FhZmY3NmY2ZGRhYTQ5ZjM0Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVm} + ports: + - "8080:8080" + networks: + - sms-net student-service: - build: ./student-service + build: + context: . + dockerfile: student-service/Dockerfile container_name: student-service restart: unless-stopped depends_on: @@ -52,13 +94,16 @@ services: DB_NAME: student_db DB_USER: root DB_PASSWORD: root + JWT_SECRET: ${JWT_SECRET:-MzM0NmM4NDNhOWM5NDcyM2FhZmY3NmY2ZGRhYTQ5ZjM0Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVm} ports: - "8081:8081" networks: - - student-net + - sms-net enrollment-service: - build: ./enrollment-service + build: + context: . + dockerfile: enrollment-service/Dockerfile container_name: enrollment-service restart: unless-stopped depends_on: @@ -72,16 +117,38 @@ services: DB_NAME: enrollment_db DB_USER: root DB_PASSWORD: root + JWT_SECRET: ${JWT_SECRET:-MzM0NmM4NDNhOWM5NDcyM2FhZmY3NmY2ZGRhYTQ5ZjM0Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVm} STUDENT_SERVICE_URL: http://student-service:8081 ports: - "8082:8082" networks: - - student-net + - sms-net + + api-gateway: + build: + context: . + dockerfile: api-gateway/Dockerfile + container_name: api-gateway + restart: unless-stopped + depends_on: + - auth-service + - student-service + - enrollment-service + environment: + JWT_SECRET: ${JWT_SECRET:-MzM0NmM4NDNhOWM5NDcyM2FhZmY3NmY2ZGRhYTQ5ZjM0Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVm} + AUTH_SERVICE_URL: http://auth-service:8080 + STUDENT_SERVICE_URL: http://student-service:8081 + ENROLLMENT_SERVICE_URL: http://enrollment-service:8082 + ports: + - "9000:9000" + networks: + - sms-net networks: - student-net: + sms-net: driver: bridge volumes: + mysql_auth_data: mysql_student_data: mysql_enrollment_data: diff --git a/postman/Phase2-Gateway-Flow.postman_collection.json b/postman/Phase2-Gateway-Flow.postman_collection.json new file mode 100644 index 0000000..4780dce --- /dev/null +++ b/postman/Phase2-Gateway-Flow.postman_collection.json @@ -0,0 +1,197 @@ +{ + "info": { + "name": "Student Management System - Phase 2 (via API Gateway)", + "description": "Full auth + role-based workflow, routed through api-gateway on port 9000", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { "key": "gatewayUrl", "value": "http://localhost:9000/api/v1" }, + { "key": "studentAccessToken", "value": "" }, + { "key": "studentRefreshToken", "value": "" }, + { "key": "adminAccessToken", "value": "" } + ], + "item": [ + { + "name": "1. Auth", + "item": [ + { + "name": "Register (creates a STUDENT account)", + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "url": "{{gatewayUrl}}/auth/register", + "body": { + "mode": "raw", + "raw": "{\n \"fullName\": \"Asha Rao\",\n \"email\": \"asha.rao@example.com\",\n \"password\": \"SecurePass123\"\n}" + } + } + }, + { + "name": "Login as Student", + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "url": "{{gatewayUrl}}/auth/login", + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"asha.rao@example.com\",\n \"password\": \"SecurePass123\"\n}" + } + } + }, + { + "name": "Refresh Token", + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "url": "{{gatewayUrl}}/auth/refresh", + "body": { + "mode": "raw", + "raw": "{\n \"refreshToken\": \"{{studentRefreshToken}}\"\n}" + } + } + }, + { + "name": "Logout", + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "url": "{{gatewayUrl}}/auth/logout", + "body": { + "mode": "raw", + "raw": "{\n \"refreshToken\": \"{{studentRefreshToken}}\"\n}" + } + } + } + ] + }, + { + "name": "2. Admin - User Management (requires an ADMIN token)", + "item": [ + { + "name": "List All Users", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{adminAccessToken}}" }], + "url": "{{gatewayUrl}}/admin/users" + } + }, + { + "name": "Promote a User to ADMIN", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{adminAccessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "url": "{{gatewayUrl}}/admin/users/1/role", + "body": { "mode": "raw", "raw": "{\n \"role\": \"ADMIN\"\n}" } + } + } + ] + }, + { + "name": "3. Students (ownership enforced)", + "item": [ + { + "name": "Admin Creates a Student Profile", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{adminAccessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "url": "{{gatewayUrl}}/students", + "body": { + "mode": "raw", + "raw": "{\n \"firstName\": \"Asha\",\n \"lastName\": \"Rao\",\n \"email\": \"asha.rao@example.com\",\n \"dateOfBirth\": \"2001-05-12\"\n}" + } + } + }, + { + "name": "Student Views Own Profile (should succeed)", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{studentAccessToken}}" }], + "url": "{{gatewayUrl}}/students/1" + } + }, + { + "name": "Student Views Someone Else's Profile (should 403)", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{studentAccessToken}}" }], + "url": "{{gatewayUrl}}/students/2" + } + }, + { + "name": "Admin Lists All Students", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{adminAccessToken}}" }], + "url": "{{gatewayUrl}}/students?unpaged=true" + } + } + ] + }, + { + "name": "4. Courses & Enrollments (ownership enforced)", + "item": [ + { + "name": "Admin Creates a Course", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{adminAccessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "url": "{{gatewayUrl}}/courses", + "body": { + "mode": "raw", + "raw": "{\n \"courseCode\": \"CS101\",\n \"title\": \"Intro to Computer Science\",\n \"credits\": 4,\n \"capacity\": 30\n}" + } + } + }, + { + "name": "Student Views Available Courses", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{studentAccessToken}}" }], + "url": "{{gatewayUrl}}/courses" + } + }, + { + "name": "Student Enrolls Themselves", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{studentAccessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "url": "{{gatewayUrl}}/enrollments", + "body": { "mode": "raw", "raw": "{\n \"studentId\": 1,\n \"courseId\": 1\n}" } + } + }, + { + "name": "Student Views Own Enrollments", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{studentAccessToken}}" }], + "url": "{{gatewayUrl}}/enrollments/student/1" + } + }, + { + "name": "Admin Records a Grade", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{adminAccessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "url": "{{gatewayUrl}}/enrollments/1/grade", + "body": { "mode": "raw", "raw": "{\n \"grade\": 8.7\n}" } + } + } + ] + } + ] +} diff --git a/postman/Student-Management-Microservices.postman_collection.json b/postman/Student-Management-Microservices.postman_collection.json deleted file mode 100644 index f7a2928..0000000 --- a/postman/Student-Management-Microservices.postman_collection.json +++ /dev/null @@ -1,118 +0,0 @@ -{ - "info": { - "name": "Student Management Microservices", - "description": "CRUD + inter-service workflow tests for student-service and enrollment-service", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" - }, - "variable": [ - { "key": "studentBaseUrl", "value": "http://localhost:8081/api/v1" }, - { "key": "enrollmentBaseUrl", "value": "http://localhost:8082/api/v1" } - ], - "item": [ - { - "name": "Student Service", - "item": [ - { - "name": "Create Student", - "request": { - "method": "POST", - "header": [{ "key": "Content-Type", "value": "application/json" }], - "url": "{{studentBaseUrl}}/students", - "body": { - "mode": "raw", - "raw": "{\n \"firstName\": \"Asha\",\n \"lastName\": \"Rao\",\n \"email\": \"asha.rao@example.com\",\n \"phoneNumber\": \"9876543210\",\n \"dateOfBirth\": \"2001-05-12\"\n}" - } - } - }, - { - "name": "Get Student By Id", - "request": { "method": "GET", "url": "{{studentBaseUrl}}/students/1" } - }, - { - "name": "Get All Students", - "request": { "method": "GET", "url": "{{studentBaseUrl}}/students?unpaged=true" } - }, - { - "name": "Search Students", - "request": { "method": "GET", "url": "{{studentBaseUrl}}/students?keyword=asha&page=0&size=10" } - }, - { - "name": "Update Student", - "request": { - "method": "PUT", - "header": [{ "key": "Content-Type", "value": "application/json" }], - "url": "{{studentBaseUrl}}/students/1", - "body": { "mode": "raw", "raw": "{\n \"phoneNumber\": \"9999999999\"\n}" } - } - }, - { - "name": "Delete Student", - "request": { "method": "DELETE", "url": "{{studentBaseUrl}}/students/1" } - } - ] - }, - { - "name": "Enrollment Service", - "item": [ - { - "name": "Create Course", - "request": { - "method": "POST", - "header": [{ "key": "Content-Type", "value": "application/json" }], - "url": "{{enrollmentBaseUrl}}/courses", - "body": { - "mode": "raw", - "raw": "{\n \"courseCode\": \"CS101\",\n \"title\": \"Intro to Computer Science\",\n \"description\": \"Fundamentals of CS\",\n \"credits\": 4,\n \"capacity\": 30\n}" - } - } - }, - { - "name": "Get All Courses", - "request": { "method": "GET", "url": "{{enrollmentBaseUrl}}/courses" } - }, - { - "name": "Enroll Student (calls student-service internally)", - "request": { - "method": "POST", - "header": [{ "key": "Content-Type", "value": "application/json" }], - "url": "{{enrollmentBaseUrl}}/enrollments", - "body": { - "mode": "raw", - "raw": "{\n \"studentId\": 1,\n \"courseId\": 1\n}" - } - } - }, - { - "name": "Get Enrollments By Student", - "request": { "method": "GET", "url": "{{enrollmentBaseUrl}}/enrollments/student/1" } - }, - { - "name": "Get Enrollments By Course", - "request": { "method": "GET", "url": "{{enrollmentBaseUrl}}/enrollments/course/1" } - }, - { - "name": "Update Enrollment Status", - "request": { - "method": "PATCH", - "header": [{ "key": "Content-Type", "value": "application/json" }], - "url": "{{enrollmentBaseUrl}}/enrollments/1/status", - "body": { "mode": "raw", "raw": "{\n \"status\": \"COMPLETED\"\n}" } - } - }, - { - "name": "Record Grade", - "request": { - "method": "PATCH", - "header": [{ "key": "Content-Type", "value": "application/json" }], - "url": "{{enrollmentBaseUrl}}/enrollments/1/grade", - "body": { "mode": "raw", "raw": "{\n \"grade\": 8.7\n}" } - } - }, - { - "name": "Drop Enrollment", - "request": { "method": "DELETE", "url": "{{enrollmentBaseUrl}}/enrollments/1" } - } - ] - } - ] -}