diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5afd98c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: + - main + - phase2 + + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + + - name: Build Common Library + working-directory: backend/common-lib + run: mvn clean install + + - name: Build Auth Service + working-directory: backend/auth-service + run: mvn clean package + + - name: Build Student Service + working-directory: backend/student-service + run: mvn clean package + + - name: Build Enrollment Service + working-directory: backend/enrollment-service + run: mvn clean package \ No newline at end of file diff --git a/backend/auth-service/.gitignore b/backend/auth-service/.gitignore new file mode 100644 index 0000000..6255ee4 --- /dev/null +++ b/backend/auth-service/.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/auth-service/Dockerfile b/backend/auth-service/Dockerfile new file mode 100644 index 0000000..13b50b3 --- /dev/null +++ b/backend/auth-service/Dockerfile @@ -0,0 +1,20 @@ +# ---- Build stage ---- +FROM maven:3.9-eclipse-temurin-17 AS build +WORKDIR /app + +# Build and install common-lib into the local Maven repo first, +# since auth-service depends on it as a local artifact. +COPY common-lib ./common-lib +RUN cd common-lib && mvn -B clean install -DskipTests + +COPY auth-service/pom.xml ./auth-service/pom.xml +RUN cd auth-service && mvn -B dependency:go-offline +COPY auth-service/src ./auth-service/src +RUN cd auth-service && mvn -B clean package -DskipTests + +# ---- Run stage ---- +FROM eclipse-temurin:17-jre-alpine +WORKDIR /app +COPY --from=build /app/auth-service/target/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/backend/auth-service/pom.xml b/backend/auth-service/pom.xml new file mode 100644 index 0000000..b8e8f54 --- /dev/null +++ b/backend/auth-service/pom.xml @@ -0,0 +1,123 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.4 + + + + com.example + auth-service + 1.0.0 + auth-service + Authentication & Authorization service - registration, login, JWT issuance/refresh, role management + + + 17 + 0.12.6 + + + + + com.example + common-lib + 1.0.0 + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-actuator + + + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.6.0 + + + + com.mysql + mysql-connector-j + runtime + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + com.h2database + h2 + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/backend/auth-service/src/main/java/com/example/authservice/AuthServiceApplication.java b/backend/auth-service/src/main/java/com/example/authservice/AuthServiceApplication.java new file mode 100644 index 0000000..46fa692 --- /dev/null +++ b/backend/auth-service/src/main/java/com/example/authservice/AuthServiceApplication.java @@ -0,0 +1,18 @@ +package com.example.authservice; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Entry point for the Authentication & Authorization service. + * Owns user identity: registration, login, JWT issuance/refresh/revocation, + * and role management (ADMIN / STUDENT). Every other service trusts the + * JWTs this service issues rather than re-implementing auth. + */ +@SpringBootApplication +public class AuthServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(AuthServiceApplication.class, args); + } +} diff --git a/backend/auth-service/src/main/java/com/example/authservice/config/SecurityConfig.java b/backend/auth-service/src/main/java/com/example/authservice/config/SecurityConfig.java new file mode 100644 index 0000000..17d535c --- /dev/null +++ b/backend/auth-service/src/main/java/com/example/authservice/config/SecurityConfig.java @@ -0,0 +1,88 @@ +package com.example.authservice.config; + +import com.example.authservice.security.JwtAuthFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +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 { + + private final JwtAuthFilter jwtAuthFilter; + private final UserDetailsService userDetailsService; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public DaoAuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + provider.setUserDetailsService(userDetailsService); + provider.setPasswordEncoder(passwordEncoder()); + return provider; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/api/v1/auth/register", + "/api/v1/auth/login", + "/api/v1/auth/refresh", + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/actuator/health" + ).permitAll() + .anyRequest().authenticated() + ) + .authenticationProvider(authenticationProvider()) + .addFilterBefore(jwtAuthFilter, 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/auth-service/src/main/java/com/example/authservice/controller/AuthController.java b/backend/auth-service/src/main/java/com/example/authservice/controller/AuthController.java new file mode 100644 index 0000000..c2b7348 --- /dev/null +++ b/backend/auth-service/src/main/java/com/example/authservice/controller/AuthController.java @@ -0,0 +1,51 @@ +package com.example.authservice.controller; + +import com.example.authservice.dto.*; +import com.example.authservice.service.AuthService; +import com.example.common.dto.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +@Tag(name = "Authentication", description = "Registration, login, token refresh, and logout") +public class AuthController { + + private final AuthService authService; + + @PostMapping("/register") + @Operation(summary = "Register a new STUDENT account") + public ResponseEntity> register(@Valid @RequestBody RegisterRequest request) { + AuthResponse response = authService.register(request); + return new ResponseEntity<>(ApiResponse.success("Registration successful", response), HttpStatus.CREATED); + } + + @PostMapping("/login") + @Operation(summary = "Authenticate and receive an access + refresh token pair") + public ResponseEntity> login(@Valid @RequestBody LoginRequest request) { + AuthResponse response = authService.login(request); + return ResponseEntity.ok(ApiResponse.success("Login successful", response)); + } + + @PostMapping("/refresh") + @Operation(summary = "Exchange a valid refresh token for a new access + refresh token pair") + public ResponseEntity> refresh(@Valid @RequestBody RefreshRequest request) { + AuthResponse response = authService.refresh(request); + return ResponseEntity.ok(ApiResponse.success("Token refreshed", response)); + } + + @PostMapping("/logout") + @Operation(summary = "Revoke a refresh token, logging the user out") + public ResponseEntity> logout(@RequestBody Map body) { + authService.logout(body.get("refreshToken")); + return ResponseEntity.ok(ApiResponse.success("Logged out successfully", null)); + } +} diff --git a/backend/auth-service/src/main/java/com/example/authservice/controller/UserManagementController.java b/backend/auth-service/src/main/java/com/example/authservice/controller/UserManagementController.java new file mode 100644 index 0000000..2716537 --- /dev/null +++ b/backend/auth-service/src/main/java/com/example/authservice/controller/UserManagementController.java @@ -0,0 +1,51 @@ +package com.example.authservice.controller; + +import com.example.authservice.dto.UserResponse; +import com.example.authservice.service.UserManagementService; +import com.example.common.dto.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * ADMIN-only endpoints for managing user accounts: listing, role + * assignment, and activation/deactivation. + */ +@RestController +@RequestMapping("/api/v1/admin/users") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +@Tag(name = "User Management", description = "Admin-only: manage user accounts and roles") +public class UserManagementController { + + private final UserManagementService userManagementService; + + @GetMapping + @Operation(summary = "List all users") + public ApiResponse> getAllUsers() { + return ApiResponse.success(userManagementService.getAllUsers()); + } + + @GetMapping("/{id}") + @Operation(summary = "Get a single user by id") + public ApiResponse getUserById(@PathVariable Long id) { + return ApiResponse.success(userManagementService.getUserById(id)); + } + + @PatchMapping("/{id}/role") + @Operation(summary = "Assign a role (ADMIN or STUDENT) to a user") + public ApiResponse assignRole(@PathVariable Long id, @RequestBody Map body) { + return ApiResponse.success("Role updated", userManagementService.assignRole(id, body.get("role"))); + } + + @PatchMapping("/{id}/status") + @Operation(summary = "Activate or deactivate a user account") + public ApiResponse setEnabled(@PathVariable Long id, @RequestBody Map body) { + return ApiResponse.success("Status updated", userManagementService.setEnabled(id, body.get("enabled"))); + } +} diff --git a/backend/auth-service/src/main/java/com/example/authservice/dto/AuthResponse.java b/backend/auth-service/src/main/java/com/example/authservice/dto/AuthResponse.java new file mode 100644 index 0000000..d8a7942 --- /dev/null +++ b/backend/auth-service/src/main/java/com/example/authservice/dto/AuthResponse.java @@ -0,0 +1,21 @@ +package com.example.authservice.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AuthResponse { + private String accessToken; + private String refreshToken; + private String tokenType; + private Long userId; + private String email; + private String fullName; + private String role; + private Long studentId; +} diff --git a/backend/auth-service/src/main/java/com/example/authservice/dto/LoginRequest.java b/backend/auth-service/src/main/java/com/example/authservice/dto/LoginRequest.java new file mode 100644 index 0000000..c1a70e6 --- /dev/null +++ b/backend/auth-service/src/main/java/com/example/authservice/dto/LoginRequest.java @@ -0,0 +1,22 @@ +package com.example.authservice.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LoginRequest { + + @NotBlank(message = "Email is required") + @Email(message = "Email must be valid") + private String email; + + @NotBlank(message = "Password is required") + private String password; +} diff --git a/backend/auth-service/src/main/java/com/example/authservice/dto/RefreshRequest.java b/backend/auth-service/src/main/java/com/example/authservice/dto/RefreshRequest.java new file mode 100644 index 0000000..bda9f0f --- /dev/null +++ b/backend/auth-service/src/main/java/com/example/authservice/dto/RefreshRequest.java @@ -0,0 +1,17 @@ +package com.example.authservice.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RefreshRequest { + + @NotBlank(message = "refreshToken is required") + private String refreshToken; +} diff --git a/backend/auth-service/src/main/java/com/example/authservice/dto/RegisterRequest.java b/backend/auth-service/src/main/java/com/example/authservice/dto/RegisterRequest.java new file mode 100644 index 0000000..39611a3 --- /dev/null +++ b/backend/auth-service/src/main/java/com/example/authservice/dto/RegisterRequest.java @@ -0,0 +1,35 @@ +package com.example.authservice.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RegisterRequest { + + @NotBlank(message = "Full name is required") + private String fullName; + + @NotBlank(message = "Email is required") + @Email(message = "Email must be valid") + private String email; + + @NotBlank(message = "Password is required") + @Size(min = 8, message = "Password must be at least 8 characters") + private String password; + + /** + * Optional: if registering a STUDENT and their student-service profile + * already exists, link it here. Left null for ADMIN registrations + * (which should typically be seeded/created by another admin, not + * self-registered - see AuthService for the registration policy). + */ + private Long studentId; +} diff --git a/backend/auth-service/src/main/java/com/example/authservice/dto/UserResponse.java b/backend/auth-service/src/main/java/com/example/authservice/dto/UserResponse.java new file mode 100644 index 0000000..d4748fd --- /dev/null +++ b/backend/auth-service/src/main/java/com/example/authservice/dto/UserResponse.java @@ -0,0 +1,35 @@ +package com.example.authservice.dto; + +import com.example.authservice.entity.User; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserResponse { + private Long id; + private String email; + private String fullName; + private String role; + private Long studentId; + private boolean enabled; + private LocalDateTime createdAt; + + public static UserResponse fromEntity(User user) { + return UserResponse.builder() + .id(user.getId()) + .email(user.getEmail()) + .fullName(user.getFullName()) + .role(user.getRole().name()) + .studentId(user.getStudentId()) + .enabled(user.isEnabled()) + .createdAt(user.getCreatedAt()) + .build(); + } +} diff --git a/backend/auth-service/src/main/java/com/example/authservice/entity/RefreshToken.java b/backend/auth-service/src/main/java/com/example/authservice/entity/RefreshToken.java new file mode 100644 index 0000000..da2e5ae --- /dev/null +++ b/backend/auth-service/src/main/java/com/example/authservice/entity/RefreshToken.java @@ -0,0 +1,46 @@ +package com.example.authservice.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "refresh_tokens", indexes = { + @Index(name = "idx_refresh_token", columnList = "token", unique = true) +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RefreshToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false, unique = true, length = 512) + private String token; + + @Column(nullable = false) + private LocalDateTime expiryDate; + + @Column(nullable = false) + @Builder.Default + private boolean revoked = false; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + } +} diff --git a/backend/auth-service/src/main/java/com/example/authservice/entity/Role.java b/backend/auth-service/src/main/java/com/example/authservice/entity/Role.java new file mode 100644 index 0000000..255baef --- /dev/null +++ b/backend/auth-service/src/main/java/com/example/authservice/entity/Role.java @@ -0,0 +1,7 @@ +package com.example.authservice.entity; + +/** Application roles. Kept as a simple enum since only two roles exist today. */ +public enum Role { + ADMIN, + STUDENT +} diff --git a/backend/auth-service/src/main/java/com/example/authservice/entity/User.java b/backend/auth-service/src/main/java/com/example/authservice/entity/User.java new file mode 100644 index 0000000..a0fcb57 --- /dev/null +++ b/backend/auth-service/src/main/java/com/example/authservice/entity/User.java @@ -0,0 +1,64 @@ +package com.example.authservice.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "users", indexes = { + @Index(name = "idx_user_email", columnList = "email", unique = true) +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 150) + private String email; + + /** BCrypt-hashed password. Never store or return plaintext. */ + @Column(nullable = false) + private String password; + + @Column(nullable = false, length = 100) + private String fullName; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private Role role; + + /** + * If this user has role STUDENT, this links to the corresponding + * record owned by student-service. Nullable for ADMIN users. + */ + private Long studentId; + + @Column(nullable = false) + @Builder.Default + private boolean enabled = true; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/auth-service/src/main/java/com/example/authservice/exception/GlobalExceptionHandler.java b/backend/auth-service/src/main/java/com/example/authservice/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..a321fdc --- /dev/null +++ b/backend/auth-service/src/main/java/com/example/authservice/exception/GlobalExceptionHandler.java @@ -0,0 +1,68 @@ +package com.example.authservice.exception; + +import com.example.common.dto.ErrorResponse; +import com.example.common.exception.DuplicateResourceException; +import com.example.common.exception.ResourceNotFoundException; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler({InvalidCredentialsException.class, BadCredentialsException.class}) + public ResponseEntity handleInvalidCredentials(Exception ex, HttpServletRequest req) { + return build(HttpStatus.UNAUTHORIZED, "Invalid Credentials", ex.getMessage(), req, null); + } + + @ExceptionHandler(InvalidTokenException.class) + public ResponseEntity handleInvalidToken(InvalidTokenException ex, HttpServletRequest req) { + return build(HttpStatus.UNAUTHORIZED, "Invalid Token", ex.getMessage(), req, null); + } + + @ExceptionHandler(DuplicateResourceException.class) + public ResponseEntity handleDuplicate(DuplicateResourceException ex, HttpServletRequest req) { + return build(HttpStatus.CONFLICT, "Conflict", ex.getMessage(), req, null); + } + + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity handleNotFound(ResourceNotFoundException ex, HttpServletRequest req) { + return build(HttpStatus.NOT_FOUND, "Not Found", 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()); + } + 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) { + 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(status.value()) + .error(error) + .message(message) + .path(req.getRequestURI()) + .validationErrors(validationErrors) + .build(); + return new ResponseEntity<>(body, status); + } +} diff --git a/backend/auth-service/src/main/java/com/example/authservice/exception/InvalidCredentialsException.java b/backend/auth-service/src/main/java/com/example/authservice/exception/InvalidCredentialsException.java new file mode 100644 index 0000000..206a171 --- /dev/null +++ b/backend/auth-service/src/main/java/com/example/authservice/exception/InvalidCredentialsException.java @@ -0,0 +1,7 @@ +package com.example.authservice.exception; + +public class InvalidCredentialsException extends RuntimeException { + public InvalidCredentialsException(String message) { + super(message); + } +} diff --git a/backend/auth-service/src/main/java/com/example/authservice/exception/InvalidTokenException.java b/backend/auth-service/src/main/java/com/example/authservice/exception/InvalidTokenException.java new file mode 100644 index 0000000..6b00d44 --- /dev/null +++ b/backend/auth-service/src/main/java/com/example/authservice/exception/InvalidTokenException.java @@ -0,0 +1,7 @@ +package com.example.authservice.exception; + +public class InvalidTokenException extends RuntimeException { + public InvalidTokenException(String message) { + super(message); + } +} diff --git a/backend/auth-service/src/main/java/com/example/authservice/repository/RefreshTokenRepository.java b/backend/auth-service/src/main/java/com/example/authservice/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..9401f90 --- /dev/null +++ b/backend/auth-service/src/main/java/com/example/authservice/repository/RefreshTokenRepository.java @@ -0,0 +1,13 @@ +package com.example.authservice.repository; + +import com.example.authservice.entity.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface RefreshTokenRepository extends JpaRepository { + Optional findByToken(String token); + void deleteByUserId(Long userId); +} diff --git a/backend/auth-service/src/main/java/com/example/authservice/repository/UserRepository.java b/backend/auth-service/src/main/java/com/example/authservice/repository/UserRepository.java new file mode 100644 index 0000000..83fdf65 --- /dev/null +++ b/backend/auth-service/src/main/java/com/example/authservice/repository/UserRepository.java @@ -0,0 +1,13 @@ +package com.example.authservice.repository; + +import com.example.authservice.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); + boolean existsByEmail(String email); +} diff --git a/backend/auth-service/src/main/java/com/example/authservice/security/JwtAuthFilter.java b/backend/auth-service/src/main/java/com/example/authservice/security/JwtAuthFilter.java new file mode 100644 index 0000000..4458e15 --- /dev/null +++ b/backend/auth-service/src/main/java/com/example/authservice/security/JwtAuthFilter.java @@ -0,0 +1,66 @@ +package com.example.authservice.security; + +import com.example.authservice.service.CustomUserDetailsService; +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.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * Runs once per request: extracts the Bearer token, validates it, and + * (if valid) populates the SecurityContext so downstream @PreAuthorize + * checks and controller-level role checks work. + */ +@Component +@RequiredArgsConstructor +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + private final CustomUserDetailsService userDetailsService; + + @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 ")) { + filterChain.doFilter(request, response); + return; + } + + String token = authHeader.substring(7); + String email; + + try { + email = jwtService.extractEmail(token); + } catch (Exception ex) { + filterChain.doFilter(request, response); + return; + } + + if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = userDetailsService.loadUserByUsername(email); + + if (jwtService.isTokenValid(token, userDetails.getUsername())) { + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/backend/auth-service/src/main/java/com/example/authservice/security/JwtService.java b/backend/auth-service/src/main/java/com/example/authservice/security/JwtService.java new file mode 100644 index 0000000..4f54c4a --- /dev/null +++ b/backend/auth-service/src/main/java/com/example/authservice/security/JwtService.java @@ -0,0 +1,90 @@ +package com.example.authservice.security; + +import com.example.authservice.entity.User; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +/** + * Responsible for issuing and validating access-token JWTs. + * Refresh tokens are opaque, persisted, random strings (see + * RefreshTokenService) - only the short-lived access token is a JWT, + * which keeps the blast radius of a leaked access token small. + */ +@Component +public class JwtService { + + @Value("${jwt.secret}") + private String secret; + + @Value("${jwt.access-token-expiry-ms}") + private long accessTokenExpiryMs; + + private SecretKey signingKey() { + return Keys.hmacShaKeyFor(secret.getBytes()); + } + + public String generateAccessToken(User user) { + Map claims = new HashMap<>(); + claims.put("role", user.getRole().name()); + claims.put("userId", user.getId()); + claims.put("studentId", user.getStudentId()); + claims.put("fullName", user.getFullName()); + + Date now = new Date(); + Date expiry = new Date(now.getTime() + accessTokenExpiryMs); + + return Jwts.builder() + .claims(claims) + .subject(user.getEmail()) + .issuedAt(now) + .expiration(expiry) + .signWith(signingKey()) + .compact(); + } + + public String extractEmail(String token) { + return extractClaim(token, Claims::getSubject); + } + + public String extractRole(String token) { + return extractAllClaims(token).get("role", String.class); + } + + public Long extractUserId(String token) { + return extractAllClaims(token).get("userId", Long.class); + } + + public boolean isTokenValid(String token, String expectedEmail) { + try { + String email = extractEmail(token); + return email.equals(expectedEmail) && !isTokenExpired(token); + } catch (Exception ex) { + return false; + } + } + + public boolean isTokenExpired(String token) { + return extractClaim(token, Claims::getExpiration).before(new Date()); + } + + private T extractClaim(String token, Function resolver) { + return resolver.apply(extractAllClaims(token)); + } + + private Claims extractAllClaims(String token) { + return Jwts.parser() + .verifyWith(signingKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} diff --git a/backend/auth-service/src/main/java/com/example/authservice/security/UserPrincipal.java b/backend/auth-service/src/main/java/com/example/authservice/security/UserPrincipal.java new file mode 100644 index 0000000..5f9ebff --- /dev/null +++ b/backend/auth-service/src/main/java/com/example/authservice/security/UserPrincipal.java @@ -0,0 +1,57 @@ +package com.example.authservice.security; + +import com.example.authservice.entity.User; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +public class UserPrincipal implements UserDetails { + + private final User user; + + public UserPrincipal(User user) { + this.user = user; + } + + public User getUser() { + return user; + } + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name())); + } + + @Override + public String getPassword() { + return user.getPassword(); + } + + @Override + public String getUsername() { + return user.getEmail(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return user.isEnabled(); + } +} diff --git a/backend/auth-service/src/main/java/com/example/authservice/service/AuthService.java b/backend/auth-service/src/main/java/com/example/authservice/service/AuthService.java new file mode 100644 index 0000000..e2fa7fc --- /dev/null +++ b/backend/auth-service/src/main/java/com/example/authservice/service/AuthService.java @@ -0,0 +1,105 @@ +package com.example.authservice.service; + +import com.example.authservice.dto.*; +import com.example.authservice.entity.RefreshToken; +import com.example.authservice.entity.Role; +import com.example.authservice.entity.User; +import com.example.authservice.exception.InvalidCredentialsException; +import com.example.authservice.exception.InvalidTokenException; +import com.example.authservice.repository.UserRepository; +import com.example.authservice.security.JwtService; +import com.example.common.exception.DuplicateResourceException; +import com.example.common.exception.ResourceNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class AuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final AuthenticationManager authenticationManager; + private final JwtService jwtService; + private final RefreshTokenService refreshTokenService; + + /** + * Public self-registration always creates a STUDENT account. + * ADMIN accounts are intentionally not self-service - they should be + * created by an existing admin via a separate, protected endpoint + * (kept simple here: seed the first admin directly in the database, + * or extend AuthController with an admin-only "createAdmin" endpoint). + */ + public AuthResponse register(RegisterRequest request) { + if (userRepository.existsByEmail(request.getEmail())) { + throw new DuplicateResourceException("An account with email '" + request.getEmail() + "' already exists"); + } + + User user = User.builder() + .email(request.getEmail()) + .password(passwordEncoder.encode(request.getPassword())) + .fullName(request.getFullName()) + .role(Role.STUDENT) + .studentId(request.getStudentId()) + .enabled(true) + .build(); + + User saved = userRepository.save(user); + return issueTokens(saved); + } + + public AuthResponse login(LoginRequest request) { + try { + authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword())); + } catch (Exception ex) { + throw new InvalidCredentialsException("Invalid email or password"); + } + + User user = userRepository.findByEmail(request.getEmail()) + .orElseThrow(() -> new ResourceNotFoundException("User not found: " + request.getEmail())); + + if (!user.isEnabled()) { + throw new InvalidCredentialsException("This account has been deactivated"); + } + + return issueTokens(user); + } + + public AuthResponse refresh(RefreshRequest request) { + RefreshToken refreshToken = refreshTokenService.verifyAndGet(request.getRefreshToken()); + User user = refreshToken.getUser(); + + // Rotate: revoke the old refresh token and issue a new pair. + refreshTokenService.revoke(request.getRefreshToken()); + return issueTokens(user); + } + + public void logout(String refreshToken) { + if (refreshToken == null || refreshToken.isBlank()) { + throw new InvalidTokenException("refreshToken is required to logout"); + } + refreshTokenService.revoke(refreshToken); + } + + private AuthResponse issueTokens(User user) { + String accessToken = jwtService.generateAccessToken(user); + RefreshToken refreshToken = refreshTokenService.createRefreshToken(user); + + return AuthResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken.getToken()) + .tokenType("Bearer") + .userId(user.getId()) + .email(user.getEmail()) + .fullName(user.getFullName()) + .role(user.getRole().name()) + .studentId(user.getStudentId()) + .build(); + } +} diff --git a/backend/auth-service/src/main/java/com/example/authservice/service/CustomUserDetailsService.java b/backend/auth-service/src/main/java/com/example/authservice/service/CustomUserDetailsService.java new file mode 100644 index 0000000..bc17b4d --- /dev/null +++ b/backend/auth-service/src/main/java/com/example/authservice/service/CustomUserDetailsService.java @@ -0,0 +1,24 @@ +package com.example.authservice.service; + +import com.example.authservice.entity.User; +import com.example.authservice.repository.UserRepository; +import com.example.authservice.security.UserPrincipal; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("No user found with email: " + email)); + return new UserPrincipal(user); + } +} diff --git a/backend/auth-service/src/main/java/com/example/authservice/service/RefreshTokenService.java b/backend/auth-service/src/main/java/com/example/authservice/service/RefreshTokenService.java new file mode 100644 index 0000000..19e3868 --- /dev/null +++ b/backend/auth-service/src/main/java/com/example/authservice/service/RefreshTokenService.java @@ -0,0 +1,73 @@ +package com.example.authservice.service; + +import com.example.authservice.entity.RefreshToken; +import com.example.authservice.entity.User; +import com.example.authservice.exception.InvalidTokenException; +import com.example.authservice.repository.RefreshTokenRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; + +/** + * Refresh tokens are opaque random strings (not JWTs) persisted in the + * database. This lets us actually revoke a single refresh token on + * logout, which a stateless JWT alone cannot do. + */ +@Service +@RequiredArgsConstructor +public class RefreshTokenService { + + private final RefreshTokenRepository refreshTokenRepository; + private final SecureRandom secureRandom = new SecureRandom(); + + @Value("${jwt.refresh-token-expiry-ms}") + private long refreshTokenExpiryMs; + + @Transactional + public RefreshToken createRefreshToken(User user) { + byte[] randomBytes = new byte[64]; + secureRandom.nextBytes(randomBytes); + String token = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); + + RefreshToken refreshToken = RefreshToken.builder() + .user(user) + .token(token) + .expiryDate(LocalDateTime.now().plusNanos(refreshTokenExpiryMs * 1_000_000)) + .revoked(false) + .build(); + + return refreshTokenRepository.save(refreshToken); + } + + @Transactional(readOnly = true) + public RefreshToken verifyAndGet(String token) { + RefreshToken refreshToken = refreshTokenRepository.findByToken(token) + .orElseThrow(() -> new InvalidTokenException("Refresh token not recognized")); + + if (refreshToken.isRevoked()) { + throw new InvalidTokenException("Refresh token has been revoked"); + } + if (refreshToken.getExpiryDate().isBefore(LocalDateTime.now())) { + throw new InvalidTokenException("Refresh token has expired"); + } + return refreshToken; + } + + @Transactional + public void revoke(String token) { + refreshTokenRepository.findByToken(token).ifPresent(rt -> { + rt.setRevoked(true); + refreshTokenRepository.save(rt); + }); + } + + @Transactional + public void revokeAllForUser(Long userId) { + refreshTokenRepository.deleteByUserId(userId); + } +} diff --git a/backend/auth-service/src/main/java/com/example/authservice/service/UserManagementService.java b/backend/auth-service/src/main/java/com/example/authservice/service/UserManagementService.java new file mode 100644 index 0000000..28c641f --- /dev/null +++ b/backend/auth-service/src/main/java/com/example/authservice/service/UserManagementService.java @@ -0,0 +1,52 @@ +package com.example.authservice.service; + +import com.example.authservice.dto.UserResponse; +import com.example.authservice.entity.Role; +import com.example.authservice.entity.User; +import com.example.authservice.repository.UserRepository; +import com.example.common.exception.ResourceNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * Admin-only operations on user accounts: listing, role assignment, + * and activation/deactivation. Kept separate from AuthService, which + * only concerns itself with the authentication flow itself. + */ +@Service +@RequiredArgsConstructor +@Transactional +public class UserManagementService { + + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public List getAllUsers() { + return userRepository.findAll().stream().map(UserResponse::fromEntity).toList(); + } + + @Transactional(readOnly = true) + public UserResponse getUserById(Long id) { + return UserResponse.fromEntity(findUserOrThrow(id)); + } + + public UserResponse assignRole(Long id, String role) { + User user = findUserOrThrow(id); + user.setRole(Role.valueOf(role.toUpperCase())); + return UserResponse.fromEntity(userRepository.save(user)); + } + + public UserResponse setEnabled(Long id, boolean enabled) { + User user = findUserOrThrow(id); + user.setEnabled(enabled); + return UserResponse.fromEntity(userRepository.save(user)); + } + + private User findUserOrThrow(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id)); + } +} diff --git a/backend/auth-service/src/main/resources/application.properties b/backend/auth-service/src/main/resources/application.properties new file mode 100644 index 0000000..c82668d --- /dev/null +++ b/backend/auth-service/src/main/resources/application.properties @@ -0,0 +1,29 @@ +spring.application.name=auth-service +server.port=8080 + +# ---- MySQL Datasource ---- +spring.datasource.url=jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:auth_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 + +# ---- JPA / Hibernate ---- +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true + +# ---- JWT ---- +# IMPORTANT: override this via env var in real deployments. This default is for local dev only. +jwt.secret=${JWT_SECRET:MzM0NmM4NDNhOWM5NDcyM2FhZmY3NmY2ZGRhYTQ5ZjM0Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVm} +jwt.access-token-expiry-ms=${JWT_ACCESS_EXPIRY:900000} +jwt.refresh-token-expiry-ms=${JWT_REFRESH_EXPIRY:604800000} + +# ---- Swagger ---- +springdoc.api-docs.path=/v3/api-docs +springdoc.swagger-ui.path=/swagger-ui.html + +# ---- Actuator ---- +management.endpoints.web.exposure.include=health,info,metrics + +# ---- Logging ---- +logging.level.com.example.authservice=DEBUG diff --git a/backend/common-lib/.gitignore b/backend/common-lib/.gitignore new file mode 100644 index 0000000..6255ee4 --- /dev/null +++ b/backend/common-lib/.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/common-lib/pom.xml b/backend/common-lib/pom.xml new file mode 100644 index 0000000..c0121c5 --- /dev/null +++ b/backend/common-lib/pom.xml @@ -0,0 +1,68 @@ + + + 4.0.0 + + com.example + common-lib + 1.0.0 + jar + common-lib + Shared DTOs, exceptions, and constants used across all Student Management System microservices + + + 17 + 17 + UTF-8 + 3.0.2 + 2.17.2 + 1.18.34 + + + + + jakarta.validation + jakarta.validation-api + ${jakarta.validation.version} + + + com.fasterxml.jackson.core + jackson-annotations + ${jackson.version} + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 17 + 17 + + + org.projectlombok + lombok + ${lombok.version} + + + + + + org.apache.maven.plugins + maven-install-plugin + 3.1.2 + + + + + diff --git a/backend/common-lib/src/main/java/com/example/common/constants/AppConstants.java b/backend/common-lib/src/main/java/com/example/common/constants/AppConstants.java new file mode 100644 index 0000000..8f9d19a --- /dev/null +++ b/backend/common-lib/src/main/java/com/example/common/constants/AppConstants.java @@ -0,0 +1,15 @@ +package com.example.common.constants; + +/** Generic constants shared across services (default pagination, date formats, etc). */ +public final class AppConstants { + + private AppConstants() { + } + + public static final int DEFAULT_PAGE_NUMBER = 0; + public static final int DEFAULT_PAGE_SIZE = 10; + public static final String DEFAULT_SORT_BY = "id"; + public static final String DEFAULT_SORT_DIRECTION = "asc"; + + public static final String DATE_TIME_PATTERN = "yyyy-MM-dd'T'HH:mm:ss"; +} diff --git a/backend/common-lib/src/main/java/com/example/common/constants/SecurityConstants.java b/backend/common-lib/src/main/java/com/example/common/constants/SecurityConstants.java new file mode 100644 index 0000000..7ca52fa --- /dev/null +++ b/backend/common-lib/src/main/java/com/example/common/constants/SecurityConstants.java @@ -0,0 +1,19 @@ +package com.example.common.constants; + +/** Shared JWT / security constants used by auth-service, api-gateway, and resource services. */ +public final class SecurityConstants { + + private SecurityConstants() { + } + + public static final String AUTH_HEADER = "Authorization"; + public static final String TOKEN_PREFIX = "Bearer "; + public static final String ROLE_CLAIM = "role"; + public static final String USER_ID_CLAIM = "userId"; + + public static final String ROLE_ADMIN = "ADMIN"; + public static final String ROLE_STUDENT = "STUDENT"; + + public static final long ACCESS_TOKEN_EXPIRY_MS = 15 * 60 * 1000L; // 15 minutes + public static final long REFRESH_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000L; // 7 days +} diff --git a/backend/common-lib/src/main/java/com/example/common/dto/ApiResponse.java b/backend/common-lib/src/main/java/com/example/common/dto/ApiResponse.java new file mode 100644 index 0000000..0d13240 --- /dev/null +++ b/backend/common-lib/src/main/java/com/example/common/dto/ApiResponse.java @@ -0,0 +1,54 @@ +package com.example.common.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Standard success-envelope returned by every microservice in the + * Student Management System, so frontend clients can rely on one shape + * regardless of which service answered the request. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ApiResponse { + + private boolean success; + private String message; + private T data; + + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + @Builder.Default + private LocalDateTime timestamp = LocalDateTime.now(); + + public static ApiResponse success(T data) { + return ApiResponse.builder() + .success(true) + .message("Request completed successfully") + .data(data) + .build(); + } + + public static ApiResponse success(String message, T data) { + return ApiResponse.builder() + .success(true) + .message(message) + .data(data) + .build(); + } + + public static ApiResponse failure(String message) { + return ApiResponse.builder() + .success(false) + .message(message) + .build(); + } +} diff --git a/backend/common-lib/src/main/java/com/example/common/dto/ErrorResponse.java b/backend/common-lib/src/main/java/com/example/common/dto/ErrorResponse.java new file mode 100644 index 0000000..44f3c82 --- /dev/null +++ b/backend/common-lib/src/main/java/com/example/common/dto/ErrorResponse.java @@ -0,0 +1,26 @@ +package com.example.common.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Map; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ErrorResponse { + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime timestamp; + private int status; + private String error; + private String message; + private String path; + private Map validationErrors; +} diff --git a/backend/common-lib/src/main/java/com/example/common/dto/PageResponse.java b/backend/common-lib/src/main/java/com/example/common/dto/PageResponse.java new file mode 100644 index 0000000..e65a881 --- /dev/null +++ b/backend/common-lib/src/main/java/com/example/common/dto/PageResponse.java @@ -0,0 +1,21 @@ +package com.example.common.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PageResponse { + private List content; + private int pageNumber; + private int pageSize; + private long totalElements; + private int totalPages; + private boolean last; +} diff --git a/backend/common-lib/src/main/java/com/example/common/exception/DuplicateResourceException.java b/backend/common-lib/src/main/java/com/example/common/exception/DuplicateResourceException.java new file mode 100644 index 0000000..db49bf7 --- /dev/null +++ b/backend/common-lib/src/main/java/com/example/common/exception/DuplicateResourceException.java @@ -0,0 +1,8 @@ +package com.example.common.exception; + +/** Thrown by any service when a uniqueness constraint would be violated. */ +public class DuplicateResourceException extends RuntimeException { + public DuplicateResourceException(String message) { + super(message); + } +} diff --git a/backend/common-lib/src/main/java/com/example/common/exception/InvalidRequestException.java b/backend/common-lib/src/main/java/com/example/common/exception/InvalidRequestException.java new file mode 100644 index 0000000..93f6a2f --- /dev/null +++ b/backend/common-lib/src/main/java/com/example/common/exception/InvalidRequestException.java @@ -0,0 +1,8 @@ +package com.example.common.exception; + +/** Thrown for business-rule validation failures that aren't simple field validation. */ +public class InvalidRequestException extends RuntimeException { + public InvalidRequestException(String message) { + super(message); + } +} diff --git a/backend/common-lib/src/main/java/com/example/common/exception/ResourceNotFoundException.java b/backend/common-lib/src/main/java/com/example/common/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..2ddae43 --- /dev/null +++ b/backend/common-lib/src/main/java/com/example/common/exception/ResourceNotFoundException.java @@ -0,0 +1,8 @@ +package com.example.common.exception; + +/** Thrown by any service when a requested entity does not exist. */ +public class ResourceNotFoundException extends RuntimeException { + public ResourceNotFoundException(String message) { + super(message); + } +} diff --git a/backend/common-lib/src/main/java/com/example/common/exception/ServiceUnavailableException.java b/backend/common-lib/src/main/java/com/example/common/exception/ServiceUnavailableException.java new file mode 100644 index 0000000..3c81fcf --- /dev/null +++ b/backend/common-lib/src/main/java/com/example/common/exception/ServiceUnavailableException.java @@ -0,0 +1,8 @@ +package com.example.common.exception; + +/** Thrown when an inter-service (Feign) call fails or times out. */ +public class ServiceUnavailableException extends RuntimeException { + public ServiceUnavailableException(String message) { + super(message); + } +} diff --git a/backend/common-lib/src/main/java/com/example/common/exception/UnauthorizedException.java b/backend/common-lib/src/main/java/com/example/common/exception/UnauthorizedException.java new file mode 100644 index 0000000..3917e01 --- /dev/null +++ b/backend/common-lib/src/main/java/com/example/common/exception/UnauthorizedException.java @@ -0,0 +1,8 @@ +package com.example.common.exception; + +/** Thrown when a caller is authenticated but not permitted to perform an action. */ +public class UnauthorizedException extends RuntimeException { + public UnauthorizedException(String message) { + super(message); + } +}