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 extends GrantedAuthority> 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);
+ }
+}