diff --git a/auth-service/pom.xml b/auth-service/pom.xml
index 31668e3..6835c54 100644
--- a/auth-service/pom.xml
+++ b/auth-service/pom.xml
@@ -53,6 +53,14 @@
true
+
+
+ org.springframework.boot
+ spring-boot-devtools
+ runtime
+ true
+
+
org.springframework.boot
spring-boot-starter-test
@@ -89,6 +97,10 @@
springdoc-openapi-starter-webmvc-ui
2.8.13
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
diff --git a/auth-service/src/main/java/com/techtorque/auth_service/config/AuthEntryPointJwt.java b/auth-service/src/main/java/com/techtorque/auth_service/config/AuthEntryPointJwt.java
index 95a2b9e..7e167d3 100644
--- a/auth-service/src/main/java/com/techtorque/auth_service/config/AuthEntryPointJwt.java
+++ b/auth-service/src/main/java/com/techtorque/auth_service/config/AuthEntryPointJwt.java
@@ -27,7 +27,9 @@ public class AuthEntryPointJwt implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
- logger.error("Unauthorized error: {}", authException.getMessage());
+ // Log as INFO instead of ERROR for expected unauthorized requests
+ // ERROR level would be more appropriate for actual application errors
+ logger.info("Unauthorized access attempt to {}: {}", request.getRequestURI(), authException.getMessage());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
diff --git a/auth-service/src/main/java/com/techtorque/auth_service/config/DataSeeder.java b/auth-service/src/main/java/com/techtorque/auth_service/config/DataSeeder.java
index 0339a2a..e726714 100644
--- a/auth-service/src/main/java/com/techtorque/auth_service/config/DataSeeder.java
+++ b/auth-service/src/main/java/com/techtorque/auth_service/config/DataSeeder.java
@@ -8,6 +8,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.env.Environment;
import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
@@ -32,6 +33,9 @@ public class DataSeeder implements CommandLineRunner {
@Autowired
private PasswordEncoder passwordEncoder;
+
+ @Autowired
+ private Environment env;
@Override
public void run(String... args) throws Exception {
@@ -40,8 +44,8 @@ public void run(String... args) throws Exception {
// First, create roles if they don't exist
seedRoles();
- // Then, seed users with proper roles
- seedUsers();
+ // Then, seed users with proper roles depending on active profile
+ seedUsersByProfile();
logger.info("Data seeding completed successfully!");
}
@@ -50,6 +54,7 @@ public void run(String... args) throws Exception {
* Create all required roles in the system
*/
private void seedRoles() {
+ createRoleIfNotExists(RoleName.SUPER_ADMIN);
createRoleIfNotExists(RoleName.ADMIN);
createRoleIfNotExists(RoleName.EMPLOYEE);
createRoleIfNotExists(RoleName.CUSTOMER);
@@ -78,16 +83,49 @@ private void seedUsers() {
return;
}
- // Create default test users with roles
- createUserWithRole("admin", "admin123", "admin@techtorque.com", RoleName.ADMIN);
- createUserWithRole("employee", "emp123", "employee@techtorque.com", RoleName.EMPLOYEE);
- createUserWithRole("customer", "cust123", "customer@techtorque.com", RoleName.CUSTOMER);
+ // Create default test users with roles
+ // The first privileged account is the SUPER_ADMIN (created only once by the seeder)
+ createUserWithRole("superadmin", "superadmin123", "superadmin@techtorque.com", RoleName.SUPER_ADMIN);
+
+ // Create a regular ADMIN for day-to-day management (cannot create other ADMINs)
+ createUserWithRole("admin", "admin123", "admin@techtorque.com", RoleName.ADMIN);
+
+ createUserWithRole("employee", "emp123", "employee@techtorque.com", RoleName.EMPLOYEE);
+ createUserWithRole("customer", "cust123", "customer@techtorque.com", RoleName.CUSTOMER);
// Keep your original test users as customers
createUserWithRole("user", "password", "user@techtorque.com", RoleName.CUSTOMER);
createUserWithRole("testuser", "test123", "test@techtorque.com", RoleName.CUSTOMER);
createUserWithRole("demo", "demo123", "demo@techtorque.com", RoleName.CUSTOMER);
}
+
+ /**
+ * Seed users based on active Spring profile.
+ * - In 'dev' profile: create all test users (superadmin, admin, employee, customer, etc.)
+ * - In non-dev (e.g., production): only create the superadmin account to avoid seeding test data
+ */
+ private void seedUsersByProfile() {
+ String[] activeProfiles = env.getActiveProfiles();
+ boolean isDev = false;
+ for (String p : activeProfiles) {
+ if ("dev".equalsIgnoreCase(p)) {
+ isDev = true;
+ break;
+ }
+ }
+
+ if (isDev) {
+ // Full seeding for development
+ logger.info("Active profile is 'dev' — seeding development users (including test accounts).");
+ System.out.println("[DEV MODE] Seeding development users: superadmin, admin, employee, customer, test users.");
+ seedUsers();
+ } else {
+ // Production/non-dev: only ensure SUPER_ADMIN exists
+ createUserWithRole("superadmin", "superadmin123", "superadmin@techtorque.com", RoleName.SUPER_ADMIN);
+ // Optionally create a day-to-day admin (commented out by default for production)
+ // createUserWithRole("admin", "admin123", "admin@techtorque.com", RoleName.ADMIN);
+ }
+ }
/**
* Create user with encoded password and assigned role
diff --git a/auth-service/src/main/java/com/techtorque/auth_service/config/OpenApiConfig.java b/auth-service/src/main/java/com/techtorque/auth_service/config/OpenApiConfig.java
new file mode 100644
index 0000000..e3e2b64
--- /dev/null
+++ b/auth-service/src/main/java/com/techtorque/auth_service/config/OpenApiConfig.java
@@ -0,0 +1,93 @@
+package com.techtorque.auth_service.config;
+
+import io.swagger.v3.oas.annotations.OpenAPIDefinition;
+import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn;
+import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
+import io.swagger.v3.oas.annotations.info.Contact;
+import io.swagger.v3.oas.annotations.info.Info;
+import io.swagger.v3.oas.annotations.info.License;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.security.SecurityScheme;
+import io.swagger.v3.oas.annotations.servers.Server;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * OpenAPI 3.0 configuration for the TechTorque Authentication Service
+ *
+ * This configuration:
+ * 1. Defines API documentation metadata
+ * 2. Sets up JWT Bearer token authentication scheme
+ * 3. Configures the authorization button in Swagger UI
+ * 4. Defines server information
+ */
+@Configuration
+@OpenAPIDefinition(
+ info = @Info(
+ title = "TechTorque Auth Service API",
+ version = "1.0.0",
+ description = """
+ Authentication and User Management API for TechTorque Auto Service Platform.
+
+ This API provides:
+ - User authentication (login/logout)
+ - User registration and management
+ - Role-based access control (RBAC)
+ - JWT token management
+ - Account security features (rate limiting, lockout protection)
+
+ ## Security
+ Most endpoints require JWT authentication. Use the 'Authorize' button above to provide your Bearer token.
+
+ ## Rate Limiting
+ Login attempts are rate-limited to prevent brute force attacks:
+ - Maximum 3 failed attempts per account
+ - 15-minute lockout after exceeding limit
+ - Automatic reset on successful login
+ """,
+ contact = @Contact(
+ name = "TechTorque Development Team",
+ email = "dev@techtorque.com",
+ url = "https://github.com/TechTorque-2025"
+ ),
+ license = @License(
+ name = "MIT License",
+ url = "https://opensource.org/licenses/MIT"
+ )
+ ),
+ servers = {
+ @Server(
+ url = "http://localhost:8081",
+ description = "Development Server"
+ ),
+ @Server(
+ url = "https://api.techtorque.com",
+ description = "Production Server"
+ )
+ },
+ security = {
+ @SecurityRequirement(name = "bearerAuth")
+ }
+)
+@SecurityScheme(
+ name = "bearerAuth",
+ description = """
+ JWT Bearer Token Authentication
+
+ To obtain a token:
+ 1. Use the /api/v1/auth/login endpoint with valid credentials
+ 2. Copy the 'token' value from the response
+ 3. Click the 'Authorize' button above
+ 4. Enter:
+ 5. Click 'Authorize' to apply the token to all requests
+
+ Example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
+ """,
+ type = SecuritySchemeType.HTTP,
+ scheme = "bearer",
+ bearerFormat = "JWT",
+ in = SecuritySchemeIn.HEADER
+)
+public class OpenApiConfig {
+ // This configuration class uses annotations only
+ // Spring Boot will automatically pick up the @OpenAPIDefinition and @SecurityScheme annotations
+}
\ No newline at end of file
diff --git a/auth-service/src/main/java/com/techtorque/auth_service/config/SecurityConfig.java b/auth-service/src/main/java/com/techtorque/auth_service/config/SecurityConfig.java
index 2851528..19f6b1d 100644
--- a/auth-service/src/main/java/com/techtorque/auth_service/config/SecurityConfig.java
+++ b/auth-service/src/main/java/com/techtorque/auth_service/config/SecurityConfig.java
@@ -16,10 +16,15 @@
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.Arrays;
@Configuration
@EnableWebSecurity
-@EnableMethodSecurity // The 'prePostEnabled = true' is the default and not needed
+@EnableMethodSecurity
public class SecurityConfig {
@Autowired
@@ -51,31 +56,74 @@ public AuthenticationManager authenticationManager(AuthenticationConfiguration a
return authConfig.getAuthenticationManager();
}
+ // NOTE: The WebSecurityCustomizer bean has been completely removed.
+
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
- // 1. Disable CSRF and CORS using the new lambda style
.csrf(AbstractHttpConfigurer::disable)
- .cors(AbstractHttpConfigurer::disable) // For production, you should configure this properly
-
- // 2. Set up exception handling
+ .cors(cors -> cors.configurationSource(corsConfigurationSource()))
.exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler))
-
- // 3. Set the session management to stateless
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
-
- // 4. Set up authorization rules
.authorizeHttpRequests(auth -> auth
- // Be specific with your paths. Your controller is likely under /api/v1/auth
- .requestMatchers("/api/v1/auth/**").permitAll()
- .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll()
+ .requestMatchers(
+ // Public API endpoints
+ "/api/v1/auth/**", // Fixed: more specific auth path
+ "/api/auth/**", // Keep both for backward compatibility
+
+ // Public controller endpoints
+ "/favicon.ico",
+ "/error", // Add error page
+
+ // Health check and actuator endpoints (if needed)
+ "/actuator/**",
+
+ // All OpenAPI and Swagger UI resources
+ "/v3/api-docs/**",
+ "/swagger-ui/**",
+ "/swagger-ui.html",
+ "/swagger-resources/**", // Include swagger-resources
+ "/webjars/**", // Include webjars
+ "/api-docs/**" // Additional swagger endpoint pattern
+ ).permitAll()
+
+ // All other requests require authentication.
.anyRequest().authenticated()
);
- // 5. Add your custom provider and filter
http.authenticationProvider(authenticationProvider());
http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
+
+ @Bean
+ public CorsConfigurationSource corsConfigurationSource() {
+ CorsConfiguration configuration = new CorsConfiguration();
+
+ // Allow specific origins
+ configuration.setAllowedOrigins(Arrays.asList(
+ "http://localhost:3000", // Next.js dev server
+ "http://127.0.0.1:3000" // Alternative localhost
+ ));
+
+ // Allow all headers
+ configuration.setAllowedHeaders(Arrays.asList("*"));
+
+ // Allow specific HTTP methods
+ configuration.setAllowedMethods(Arrays.asList(
+ "GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"
+ ));
+
+ // Allow credentials (important for cookies/auth tokens)
+ configuration.setAllowCredentials(true);
+
+ // Cache preflight response for 1 hour
+ configuration.setMaxAge(3600L);
+
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/**", configuration);
+
+ return source;
+ }
}
\ No newline at end of file
diff --git a/auth-service/src/main/java/com/techtorque/auth_service/config/StartupBanner.java b/auth-service/src/main/java/com/techtorque/auth_service/config/StartupBanner.java
new file mode 100644
index 0000000..4453914
--- /dev/null
+++ b/auth-service/src/main/java/com/techtorque/auth_service/config/StartupBanner.java
@@ -0,0 +1,61 @@
+package com.techtorque.auth_service.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.event.EventListener;
+import org.springframework.core.env.Environment;
+import org.springframework.boot.context.event.ApplicationReadyEvent;
+import org.springframework.stereotype.Component;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Prints a small startup banner/message when the application is ready.
+ * Only prints when running with the 'dev' profile and when enabled via config.
+ */
+@Component
+public class StartupBanner {
+
+ private static final Logger logger = LoggerFactory.getLogger(StartupBanner.class);
+
+ private final Environment env;
+
+ private final boolean bannerEnabled;
+
+ public StartupBanner(Environment env, @Value("${app.banner.enabled:true}") boolean bannerEnabled) {
+ this.env = env;
+ this.bannerEnabled = bannerEnabled;
+ }
+
+ @EventListener(ApplicationReadyEvent.class)
+ public void onApplicationReady() {
+ if (!bannerEnabled) {
+ return;
+ }
+
+ String[] active = env.getActiveProfiles();
+ boolean isDev = false;
+ for (String p : active) {
+ if ("dev".equalsIgnoreCase(p)) {
+ isDev = true;
+ break;
+ }
+ }
+
+ if (isDev) {
+ String[] banner = new String[] {
+ "========================================",
+ "= DEVELOPMENT MODE - TECHTORQUE =",
+ "= Seeding development users now =",
+ "========================================"
+ };
+
+ for (String line : banner) {
+ // Log and also print to stdout for immediate CLI visibility
+ logger.info(line);
+ System.out.println(line);
+ }
+ } else {
+ logger.info("Application started with profiles: {}", String.join(",", active));
+ }
+ }
+}
diff --git a/auth-service/src/main/java/com/techtorque/auth_service/controller/AuthController.java b/auth-service/src/main/java/com/techtorque/auth_service/controller/AuthController.java
index d7a3f41..941eff5 100644
--- a/auth-service/src/main/java/com/techtorque/auth_service/controller/AuthController.java
+++ b/auth-service/src/main/java/com/techtorque/auth_service/controller/AuthController.java
@@ -1,41 +1,62 @@
package com.techtorque.auth_service.controller;
+import com.techtorque.auth_service.dto.CreateEmployeeRequest;
+import com.techtorque.auth_service.dto.CreateAdminRequest;
import com.techtorque.auth_service.dto.LoginRequest;
import com.techtorque.auth_service.dto.LoginResponse;
import com.techtorque.auth_service.dto.RegisterRequest;
import com.techtorque.auth_service.service.AuthService;
+import com.techtorque.auth_service.service.UserService;
import jakarta.validation.Valid;
+import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
+import com.techtorque.auth_service.dto.ApiSuccess;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.tags.Tag;
/**
* REST Controller for authentication endpoints
* Handles login, registration, and health check requests
*/
@RestController
-@RequestMapping("/api/auth")
+@RequestMapping("/api/v1/auth")
@CrossOrigin(origins = "*", maxAge = 3600)
+@Tag(name = "Authentication", description = "Authentication and user management endpoints")
public class AuthController {
@Autowired
private AuthService authService;
+ // --- NEW DEPENDENCY ---
+ // We need UserService to call the createEmployee method
+ @Autowired
+ private UserService userService;
+
/**
* User login endpoint
* @param loginRequest Login credentials
* @return JWT token and user details
*/
+ @Operation(
+ summary = "User Login",
+ description = "Authenticate user with username/email and password. Returns JWT token on success. Rate limited to prevent brute force attacks."
+ )
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Login successful, JWT token returned"),
+ @ApiResponse(responseCode = "401", description = "Invalid credentials or account locked"),
+ @ApiResponse(responseCode = "400", description = "Invalid request format")
+ })
@PostMapping("/login")
- public ResponseEntity> authenticateUser(@Valid @RequestBody LoginRequest loginRequest) {
- try {
- LoginResponse loginResponse = authService.authenticateUser(loginRequest);
- return ResponseEntity.ok(loginResponse);
- } catch (Exception e) {
- return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
- .body(new MessageResponse("Error: " + e.getMessage()));
- }
+ public ResponseEntity> authenticateUser(@Valid @RequestBody LoginRequest loginRequest, HttpServletRequest request) {
+ LoginResponse loginResponse = authService.authenticateUser(loginRequest, request);
+ return ResponseEntity.ok(loginResponse);
}
/**
@@ -45,12 +66,64 @@ public ResponseEntity> authenticateUser(@Valid @RequestBody LoginRequest login
*/
@PostMapping("/register")
public ResponseEntity> registerUser(@Valid @RequestBody RegisterRequest registerRequest) {
+ String message = authService.registerUser(registerRequest);
+ return ResponseEntity.ok(ApiSuccess.of(message));
+ }
+
+ // --- NEW ENDPOINT FOR CREATING EMPLOYEES ---
+ /**
+ * ADMIN-ONLY endpoint for creating a new employee account.
+ * @param createEmployeeRequest DTO with username, email, and password.
+ * @return A success or error message.
+ */
+ @Operation(
+ summary = "Create Employee Account",
+ description = "Create a new employee account. Requires ADMIN role.",
+ security = @SecurityRequirement(name = "bearerAuth")
+ )
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "201", description = "Employee account created successfully"),
+ @ApiResponse(responseCode = "400", description = "Invalid request or username already exists"),
+ @ApiResponse(responseCode = "401", description = "Authentication required"),
+ @ApiResponse(responseCode = "403", description = "Admin role required")
+ })
+ @PostMapping("/users/employee")
+ @PreAuthorize("hasRole('ADMIN')")
+ public ResponseEntity> createEmployee(@Valid @RequestBody CreateEmployeeRequest createEmployeeRequest) {
+ try {
+ // Now we are calling the method that was previously unused
+ userService.createEmployee(
+ createEmployeeRequest.getUsername(),
+ createEmployeeRequest.getEmail(),
+ createEmployeeRequest.getPassword()
+ );
+ return ResponseEntity.status(HttpStatus.CREATED)
+ .body(ApiSuccess.of("Employee account created successfully!"));
+ } catch (RuntimeException e) {
+ // Catches errors like "Username already exists"
+ return ResponseEntity.badRequest().body(ApiSuccess.of("Error: " + e.getMessage()));
+ }
+ }
+
+ // --- NEW ENDPOINT FOR CREATING ADMINS (SUPER_ADMIN ONLY) ---
+ /**
+ * SUPER_ADMIN-ONLY endpoint for creating a new admin account.
+ * @param createAdminRequest DTO with username, email, and password.
+ * @return A success or error message.
+ */
+ @PostMapping("/users/admin")
+ @PreAuthorize("hasRole('SUPER_ADMIN')")
+ public ResponseEntity> createAdmin(@Valid @RequestBody CreateAdminRequest createAdminRequest) {
try {
- String message = authService.registerUser(registerRequest);
- return ResponseEntity.ok(new MessageResponse(message));
- } catch (Exception e) {
- return ResponseEntity.badRequest()
- .body(new MessageResponse("Error: " + e.getMessage()));
+ userService.createAdmin(
+ createAdminRequest.getUsername(),
+ createAdminRequest.getEmail(),
+ createAdminRequest.getPassword()
+ );
+ return ResponseEntity.status(HttpStatus.CREATED)
+ .body(ApiSuccess.of("Admin account created successfully!"));
+ } catch (RuntimeException e) {
+ return ResponseEntity.badRequest().body(ApiSuccess.of("Error: " + e.getMessage()));
}
}
@@ -60,7 +133,7 @@ public ResponseEntity> registerUser(@Valid @RequestBody RegisterRequest regist
*/
@GetMapping("/health")
public ResponseEntity> health() {
- return ResponseEntity.ok(new MessageResponse("Authentication Service is running!"));
+ return ResponseEntity.ok(ApiSuccess.of("Authentication Service is running!"));
}
/**
@@ -69,25 +142,7 @@ public ResponseEntity> health() {
*/
@GetMapping("/test")
public ResponseEntity> test() {
- return ResponseEntity.ok(new MessageResponse("Test endpoint accessible!"));
- }
-
- /**
- * Inner class for simple message responses
- */
- public static class MessageResponse {
- private String message;
-
- public MessageResponse(String message) {
- this.message = message;
- }
-
- public String getMessage() {
- return message;
- }
-
- public void setMessage(String message) {
- this.message = message;
- }
+ return ResponseEntity.ok(ApiSuccess.of("Test endpoint accessible!"));
}
+
}
\ No newline at end of file
diff --git a/auth-service/src/main/java/com/techtorque/auth_service/controller/FaviconController.java b/auth-service/src/main/java/com/techtorque/auth_service/controller/FaviconController.java
new file mode 100644
index 0000000..767fe37
--- /dev/null
+++ b/auth-service/src/main/java/com/techtorque/auth_service/controller/FaviconController.java
@@ -0,0 +1,20 @@
+package com.techtorque.auth_service.controller;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ResponseBody;
+
+/**
+ * A controller to handle the default favicon.ico request by redirecting
+ * it to the favicon provided by the Swagger UI resources.
+ */
+@Controller
+public class FaviconController {
+
+ @GetMapping("favicon.ico")
+ @ResponseBody
+ public String faviconRedirect() {
+ // Redirect to the favicon included with the springdoc-openapi-ui library
+ return "redirect:/swagger-ui/favicon-32x32.png";
+ }
+}
\ No newline at end of file
diff --git a/auth-service/src/main/java/com/techtorque/auth_service/controller/UserController.java b/auth-service/src/main/java/com/techtorque/auth_service/controller/UserController.java
new file mode 100644
index 0000000..e3ad85e
--- /dev/null
+++ b/auth-service/src/main/java/com/techtorque/auth_service/controller/UserController.java
@@ -0,0 +1,242 @@
+package com.techtorque.auth_service.controller;
+
+import com.techtorque.auth_service.dto.*;
+import com.techtorque.auth_service.entity.User;
+import com.techtorque.auth_service.service.UserService;
+import jakarta.validation.Valid;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.bind.annotation.*;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * REST Controller for user management endpoints.
+ * Endpoints in this controller are accessible to users with ADMIN or SUPER_ADMIN roles.
+ */
+@RestController
+@RequestMapping("/api/v1/users")
+@CrossOrigin(origins = "*", maxAge = 3600)
+@PreAuthorize("hasRole('ADMIN') or hasRole('SUPER_ADMIN')")
+@Tag(name = "User Management", description = "User management endpoints (Admin/Super Admin only)")
+@SecurityRequirement(name = "bearerAuth")
+public class UserController {
+
+ @Autowired
+ private UserService userService;
+
+ /**
+ * Get a list of all users in the system.
+ */
+ @GetMapping
+ public ResponseEntity> getAllUsers() {
+ List users = userService.findAllUsers().stream()
+ .map(this::convertToDto)
+ .collect(Collectors.toList());
+ return ResponseEntity.ok(users);
+ }
+
+ /**
+ * Get detailed information for a single user by their username.
+ */
+ @GetMapping("/{username}")
+ public ResponseEntity getUserByUsername(@PathVariable String username) {
+ return userService.findByUsername(username)
+ .map(user -> ResponseEntity.ok(convertToDto(user)))
+ .orElse(ResponseEntity.notFound().build());
+ }
+
+ /**
+ * Disable a user's account.
+ */
+ @PostMapping("/{username}/disable")
+ public ResponseEntity> disableUser(@PathVariable String username) {
+ try {
+ userService.disableUser(username);
+ return ResponseEntity.ok(ApiSuccess.of("User '" + username + "' has been disabled."));
+ } catch (RuntimeException e) {
+ throw new RuntimeException(e.getMessage());
+ }
+ }
+
+ /**
+ * Enable a user's account.
+ */
+ @PostMapping("/{username}/enable")
+ public ResponseEntity> enableUser(@PathVariable String username) {
+ try {
+ userService.enableUser(username);
+ return ResponseEntity.ok(ApiSuccess.of("User '" + username + "' has been enabled."));
+ } catch (RuntimeException e) {
+ throw new RuntimeException(e.getMessage());
+ }
+ }
+
+ /**
+ * Unlock a user's login lock (admin only)
+ */
+ @PostMapping("/{username}/unlock")
+ @PreAuthorize("hasRole('ADMIN') or hasRole('SUPER_ADMIN')")
+ public ResponseEntity> unlockUser(@PathVariable String username) {
+ userService.clearLoginLock(username);
+ return ResponseEntity.ok(ApiSuccess.of("Login lock cleared for user: " + username));
+ }
+
+ /**
+ * Delete a user from the system permanently.
+ */
+ @DeleteMapping("/{username}")
+ public ResponseEntity> deleteUser(@PathVariable String username) {
+ try {
+ userService.deleteUser(username);
+ return ResponseEntity.ok(ApiSuccess.of("User '" + username + "' has been deleted."));
+ } catch (RuntimeException e) {
+ throw new RuntimeException(e.getMessage());
+ }
+ }
+
+ /**
+ * Update a user's details (admin only)
+ * PUT /api/v1/users/{username}
+ */
+ @PutMapping("/{username}")
+ public ResponseEntity> updateUser(@PathVariable String username,
+ @Valid @RequestBody UpdateUserRequest updateRequest) {
+ try {
+ User updatedUser = userService.updateUserDetails(
+ username,
+ updateRequest.getUsername(),
+ updateRequest.getEmail(),
+ updateRequest.getEnabled()
+ );
+ return ResponseEntity.ok(convertToDto(updatedUser));
+ } catch (RuntimeException e) {
+ throw new RuntimeException(e.getMessage());
+ }
+ }
+
+ /**
+ * Reset a user's password (admin only)
+ * POST /api/v1/users/{username}/reset-password
+ */
+ @PostMapping("/{username}/reset-password")
+ public ResponseEntity> resetUserPassword(@PathVariable String username,
+ @Valid @RequestBody ResetPasswordRequest resetRequest) {
+ try {
+ userService.resetUserPassword(username, resetRequest.getNewPassword());
+ return ResponseEntity.ok(ApiSuccess.of("Password reset successfully for user: " + username));
+ } catch (RuntimeException e) {
+ throw new RuntimeException(e.getMessage());
+ }
+ }
+
+ /**
+ * Assign or revoke a role to/from a user (admin only)
+ * POST /api/v1/users/{username}/roles
+ */
+ @PostMapping("/{username}/roles")
+ public ResponseEntity> manageUserRole(@PathVariable String username,
+ @Valid @RequestBody RoleAssignmentRequest roleRequest) {
+ try {
+ if (roleRequest.getAction() == RoleAssignmentRequest.RoleAction.ASSIGN) {
+ userService.assignRoleToUser(username, roleRequest.getRoleName());
+ return ResponseEntity.ok(ApiSuccess.of(
+ "Role '" + roleRequest.getRoleName() + "' assigned to user: " + username));
+ } else {
+ userService.revokeRoleFromUser(username, roleRequest.getRoleName());
+ return ResponseEntity.ok(ApiSuccess.of(
+ "Role '" + roleRequest.getRoleName() + "' revoked from user: " + username));
+ }
+ } catch (AccessDeniedException ade) {
+ // Specific handling for access denied so clients/tests receive 403 Forbidden
+ return ResponseEntity.status(403)
+ .body(ApiError.builder()
+ .status(403)
+ .message("Error: " + ade.getMessage())
+ .timestamp(java.time.LocalDateTime.now())
+ .build());
+ } catch (RuntimeException e) {
+ throw new RuntimeException(e.getMessage());
+ }
+ }
+
+ /**
+ * Get current user's profile (user endpoint)
+ * GET /api/v1/users/me
+ */
+ @Operation(
+ summary = "Get Current User Profile",
+ description = "Get the profile information of the currently authenticated user. Available to all authenticated users.",
+ security = @SecurityRequirement(name = "bearerAuth")
+ )
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "User profile retrieved successfully"),
+ @ApiResponse(responseCode = "401", description = "Authentication required"),
+ @ApiResponse(responseCode = "404", description = "User not found")
+ })
+ @GetMapping("/me")
+ @PreAuthorize("hasRole('CUSTOMER') or hasRole('EMPLOYEE') or hasRole('ADMIN') or hasRole('SUPER_ADMIN')")
+ public ResponseEntity> getCurrentUserProfile() {
+ try {
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ String username = authentication.getName();
+
+ return userService.findByUsername(username)
+ .map(user -> ResponseEntity.ok(convertToDto(user)))
+ .orElse(ResponseEntity.notFound().build());
+ } catch (Exception e) {
+ return ResponseEntity.badRequest()
+ .body(ApiError.builder()
+ .status(400)
+ .message("Error: " + e.getMessage())
+ .timestamp(java.time.LocalDateTime.now())
+ .build());
+ }
+ }
+
+ /**
+ * Change current user's password (user endpoint)
+ * POST /api/v1/users/me/change-password
+ */
+ @PostMapping("/me/change-password")
+ @PreAuthorize("hasRole('CUSTOMER') or hasRole('EMPLOYEE') or hasRole('ADMIN') or hasRole('SUPER_ADMIN')")
+ public ResponseEntity> changeCurrentUserPassword(@Valid @RequestBody ChangePasswordRequest changeRequest) {
+ try {
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ String username = authentication.getName();
+
+ userService.changeUserPassword(username, changeRequest.getCurrentPassword(), changeRequest.getNewPassword());
+ return ResponseEntity.ok(ApiSuccess.of("Password changed successfully"));
+ } catch (RuntimeException e) {
+ return ResponseEntity.badRequest()
+ .body(ApiError.builder()
+ .status(400)
+ .message("Error: " + e.getMessage())
+ .timestamp(java.time.LocalDateTime.now())
+ .build());
+ }
+ }
+
+ // Helper method to convert User entity to a safe UserDto
+ private UserDto convertToDto(User user) {
+ return UserDto.builder()
+ .id(user.getId())
+ .username(user.getUsername())
+ .email(user.getEmail())
+ .enabled(user.getEnabled())
+ .createdAt(user.getCreatedAt())
+ .roles(userService.getUserRoles(user.getUsername()))
+ .permissions(userService.getUserPermissions(user.getUsername()))
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/auth-service/src/main/java/com/techtorque/auth_service/dto/ApiError.java b/auth-service/src/main/java/com/techtorque/auth_service/dto/ApiError.java
new file mode 100644
index 0000000..c160088
--- /dev/null
+++ b/auth-service/src/main/java/com/techtorque/auth_service/dto/ApiError.java
@@ -0,0 +1,21 @@
+package com.techtorque.auth_service.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ApiError {
+ private int status;
+ private String message;
+ private String errorCode;
+ private Map details;
+ private LocalDateTime timestamp;
+}
diff --git a/auth-service/src/main/java/com/techtorque/auth_service/dto/ApiSuccess.java b/auth-service/src/main/java/com/techtorque/auth_service/dto/ApiSuccess.java
new file mode 100644
index 0000000..e1f8b6b
--- /dev/null
+++ b/auth-service/src/main/java/com/techtorque/auth_service/dto/ApiSuccess.java
@@ -0,0 +1,25 @@
+package com.techtorque.auth_service.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class ApiSuccess {
+ private int status;
+ private String message;
+ private Object data;
+ private LocalDateTime timestamp = LocalDateTime.now();
+
+ public static ApiSuccess of(String message) {
+ return new ApiSuccess(200, message, null, LocalDateTime.now());
+ }
+
+ public static ApiSuccess of(String message, Object data) {
+ return new ApiSuccess(200, message, data, LocalDateTime.now());
+ }
+}
diff --git a/auth-service/src/main/java/com/techtorque/auth_service/dto/ChangePasswordRequest.java b/auth-service/src/main/java/com/techtorque/auth_service/dto/ChangePasswordRequest.java
new file mode 100644
index 0000000..56f44fe
--- /dev/null
+++ b/auth-service/src/main/java/com/techtorque/auth_service/dto/ChangePasswordRequest.java
@@ -0,0 +1,26 @@
+package com.techtorque.auth_service.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Data Transfer Object for changing user's own password
+ * Used for POST /api/v1/users/me/change-password endpoint
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class ChangePasswordRequest {
+
+ @NotBlank(message = "Current password is required")
+ private String currentPassword;
+
+ @NotBlank(message = "New password is required")
+ @Size(min = 6, max = 100, message = "New password must be between 6 and 100 characters")
+ private String newPassword;
+}
\ No newline at end of file
diff --git a/auth-service/src/main/java/com/techtorque/auth_service/dto/LoginResponse.java b/auth-service/src/main/java/com/techtorque/auth_service/dto/LoginResponse.java
index 975c979..c3e1752 100644
--- a/auth-service/src/main/java/com/techtorque/auth_service/dto/LoginResponse.java
+++ b/auth-service/src/main/java/com/techtorque/auth_service/dto/LoginResponse.java
@@ -18,12 +18,5 @@ public class LoginResponse {
private String username;
private String email;
private Set roles;
-
- public LoginResponse(String token, String username, String email, Set roles) {
- this.token = token;
- this.type = "Bearer";
- this.username = username;
- this.email = email;
- this.roles = roles;
- }
+
}
\ No newline at end of file
diff --git a/auth-service/src/main/java/com/techtorque/auth_service/dto/ResetPasswordRequest.java b/auth-service/src/main/java/com/techtorque/auth_service/dto/ResetPasswordRequest.java
new file mode 100644
index 0000000..6deb853
--- /dev/null
+++ b/auth-service/src/main/java/com/techtorque/auth_service/dto/ResetPasswordRequest.java
@@ -0,0 +1,23 @@
+package com.techtorque.auth_service.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Data Transfer Object for admin password reset
+ * Used for POST /api/v1/users/{username}/reset-password endpoint
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class ResetPasswordRequest {
+
+ @NotBlank(message = "New password is required")
+ @Size(min = 6, max = 100, message = "New password must be between 6 and 100 characters")
+ private String newPassword;
+}
\ No newline at end of file
diff --git a/auth-service/src/main/java/com/techtorque/auth_service/dto/RoleAssignmentRequest.java b/auth-service/src/main/java/com/techtorque/auth_service/dto/RoleAssignmentRequest.java
new file mode 100644
index 0000000..d5ff441
--- /dev/null
+++ b/auth-service/src/main/java/com/techtorque/auth_service/dto/RoleAssignmentRequest.java
@@ -0,0 +1,29 @@
+package com.techtorque.auth_service.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Data Transfer Object for role assignment/revocation
+ * Used for POST /api/v1/users/{username}/roles endpoint
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class RoleAssignmentRequest {
+
+ @NotBlank(message = "Role name is required")
+ private String roleName;
+
+ @NotNull(message = "Action is required")
+ private RoleAction action;
+
+ public enum RoleAction {
+ ASSIGN, REVOKE
+ }
+}
\ No newline at end of file
diff --git a/auth-service/src/main/java/com/techtorque/auth_service/dto/UpdateUserRequest.java b/auth-service/src/main/java/com/techtorque/auth_service/dto/UpdateUserRequest.java
new file mode 100644
index 0000000..0478937
--- /dev/null
+++ b/auth-service/src/main/java/com/techtorque/auth_service/dto/UpdateUserRequest.java
@@ -0,0 +1,27 @@
+package com.techtorque.auth_service.dto;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.Size;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Data Transfer Object for updating user details
+ * Used for PUT/PATCH /api/v1/users/{username} endpoint
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class UpdateUserRequest {
+
+ @Email(message = "Please provide a valid email address")
+ private String email;
+
+ @Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters")
+ private String username;
+
+ private Boolean enabled;
+}
\ No newline at end of file
diff --git a/auth-service/src/main/java/com/techtorque/auth_service/entity/LoginLock.java b/auth-service/src/main/java/com/techtorque/auth_service/entity/LoginLock.java
new file mode 100644
index 0000000..737982c
--- /dev/null
+++ b/auth-service/src/main/java/com/techtorque/auth_service/entity/LoginLock.java
@@ -0,0 +1,29 @@
+package com.techtorque.auth_service.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "login_locks")
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class LoginLock {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(unique = true, nullable = false)
+ private String username;
+
+ @Column(name = "failed_attempts", nullable = false)
+ @Builder.Default
+ private Integer failedAttempts = 0;
+
+ @Column(name = "lock_until")
+ private LocalDateTime lockUntil;
+}
diff --git a/auth-service/src/main/java/com/techtorque/auth_service/entity/LoginLog.java b/auth-service/src/main/java/com/techtorque/auth_service/entity/LoginLog.java
new file mode 100644
index 0000000..0de599f
--- /dev/null
+++ b/auth-service/src/main/java/com/techtorque/auth_service/entity/LoginLog.java
@@ -0,0 +1,34 @@
+package com.techtorque.auth_service.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "login_logs")
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class LoginLog {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false)
+ private String username;
+
+ @Column(nullable = false)
+ private Boolean success;
+
+ @Column(name = "ip_address")
+ private String ipAddress;
+
+ @Column(name = "user_agent")
+ private String userAgent;
+
+ @Column(name = "created_at")
+ private LocalDateTime createdAt;
+}
diff --git a/auth-service/src/main/java/com/techtorque/auth_service/entity/Role.java b/auth-service/src/main/java/com/techtorque/auth_service/entity/Role.java
index f65ab4b..b6e5b33 100644
--- a/auth-service/src/main/java/com/techtorque/auth_service/entity/Role.java
+++ b/auth-service/src/main/java/com/techtorque/auth_service/entity/Role.java
@@ -1,10 +1,7 @@
package com.techtorque.auth_service.entity;
import jakarta.persistence.*;
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Data;
-import lombok.NoArgsConstructor;
+import lombok.*; // Import EqualsAndHashCode, Getter, Setter, ToString
import java.util.Set;
@@ -14,35 +11,36 @@
*/
@Entity
@Table(name = "roles")
-@Data
+// --- Start of Changes ---
+@Getter
+@Setter
+@ToString(exclude = {"users", "permissions"}) // Exclude collections to prevent infinite loops
+@EqualsAndHashCode(exclude = {"users", "permissions"}) // Exclude collections from equals/hashCode
+// --- End of Changes ---
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Role {
-
- @Id
- @GeneratedValue(strategy = GenerationType.IDENTITY)
- private Long id;
-
- // Role name from the RoleName enum (ADMIN, EMPLOYEE, CUSTOMER)
- @Column(unique = true, nullable = false)
- @Enumerated(EnumType.STRING)
- private RoleName name;
-
- // Human-readable description of the role
- private String description;
-
- // Many-to-Many relationship with User - a role can be assigned to multiple users
- @ManyToMany(mappedBy = "roles")
- private Set users;
-
- // Many-to-Many relationship with Permission - a role contains multiple permissions
- // EAGER fetch ensures permissions are loaded when we load a role
- @ManyToMany(fetch = FetchType.EAGER)
- @JoinTable(
- name = "role_permissions", // Junction table name
- joinColumns = @JoinColumn(name = "role_id"), // Foreign key to role
- inverseJoinColumns = @JoinColumn(name = "permission_id") // Foreign key to permission
- )
- private Set permissions;
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(unique = true, nullable = false)
+ @Enumerated(EnumType.STRING)
+ private RoleName name;
+
+ private String description;
+
+ // This is the lazy collection causing the LazyInitializationException
+ @ManyToMany(mappedBy = "roles")
+ private Set users;
+
+ @ManyToMany(fetch = FetchType.EAGER)
+ @JoinTable(
+ name = "role_permissions",
+ joinColumns = @JoinColumn(name = "role_id"),
+ inverseJoinColumns = @JoinColumn(name = "permission_id")
+ )
+ private Set permissions;
}
\ No newline at end of file
diff --git a/auth-service/src/main/java/com/techtorque/auth_service/entity/RoleName.java b/auth-service/src/main/java/com/techtorque/auth_service/entity/RoleName.java
index 9221107..5b7ef35 100644
--- a/auth-service/src/main/java/com/techtorque/auth_service/entity/RoleName.java
+++ b/auth-service/src/main/java/com/techtorque/auth_service/entity/RoleName.java
@@ -7,6 +7,7 @@
* CUSTOMER - Access to customer-specific features
*/
public enum RoleName {
+ SUPER_ADMIN("Super Administrator - Manages Admins and the entire system"),
ADMIN("Administrator - Full system access"),
EMPLOYEE("Employee - Limited system access for staff operations"),
CUSTOMER("Customer - Access to customer-specific features");
diff --git a/auth-service/src/main/java/com/techtorque/auth_service/entity/User.java b/auth-service/src/main/java/com/techtorque/auth_service/entity/User.java
index 2273d18..9c51137 100644
--- a/auth-service/src/main/java/com/techtorque/auth_service/entity/User.java
+++ b/auth-service/src/main/java/com/techtorque/auth_service/entity/User.java
@@ -1,10 +1,7 @@
package com.techtorque.auth_service.entity;
import jakarta.persistence.*;
-import lombok.Data;
-import lombok.NoArgsConstructor;
-import lombok.AllArgsConstructor;
-import lombok.Builder;
+import lombok.*; // Import EqualsAndHashCode, Getter, Setter, ToString
import java.time.LocalDateTime;
import java.util.HashSet;
@@ -16,55 +13,58 @@
*/
@Entity
@Table(name = "users")
-@Data
+// --- Start of Changes ---
+@Getter
+@Setter
+@ToString(exclude = "roles") // Exclude the collection to prevent infinite loops
+@EqualsAndHashCode(exclude = "roles") // Exclude the collection from equals/hashCode
+// --- End of Changes ---
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
-
- @Id
- @GeneratedValue(strategy = GenerationType.IDENTITY)
- private Long id;
-
- @Column(unique = true, nullable = false)
- private String username;
-
- @Column(nullable = false)
- private String password;
-
- @Column(unique = true, nullable = false)
- private String email;
-
- @Column(nullable = false)
- @Builder.Default
- private Boolean enabled = true;
-
- @Column(name = "created_at")
- @Builder.Default
- private LocalDateTime createdAt = LocalDateTime.now();
-
- // Many-to-Many relationship with Role entity
- @ManyToMany(fetch = FetchType.EAGER)
- @JoinTable(
- name = "user_roles",
- joinColumns = @JoinColumn(name = "user_id"),
- inverseJoinColumns = @JoinColumn(name = "role_id")
- )
- @Builder.Default
- private Set roles = new HashSet<>();
-
- // Constructor for easy user creation
- public User(String username, String password, String email) {
- this.username = username;
- this.password = password;
- this.email = email;
- this.enabled = true;
- this.createdAt = LocalDateTime.now();
- this.roles = new HashSet<>();
- }
-
- // Helper method to add roles
- public void addRole(Role role) {
- this.roles.add(role);
- }
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(unique = true, nullable = false)
+ private String username;
+
+ @Column(nullable = false)
+ private String password;
+
+ @Column(unique = true, nullable = false)
+ private String email;
+
+ @Column(nullable = false)
+ @Builder.Default
+ private Boolean enabled = true;
+
+ @Column(name = "created_at")
+ @Builder.Default
+ private LocalDateTime createdAt = LocalDateTime.now();
+
+ // This is the other side of the relationship
+ @ManyToMany(fetch = FetchType.EAGER)
+ @JoinTable(
+ name = "user_roles",
+ joinColumns = @JoinColumn(name = "user_id"),
+ inverseJoinColumns = @JoinColumn(name = "role_id")
+ )
+ @Builder.Default
+ private Set roles = new HashSet<>();
+
+ public User(String username, String password, String email) {
+ this.username = username;
+ this.password = password;
+ this.email = email;
+ this.enabled = true;
+ this.createdAt = LocalDateTime.now();
+ this.roles = new HashSet<>();
+ }
+
+ public void addRole(Role role) {
+ this.roles.add(role);
+ }
}
\ No newline at end of file
diff --git a/auth-service/src/main/java/com/techtorque/auth_service/exception/GlobalExceptionHandler.java b/auth-service/src/main/java/com/techtorque/auth_service/exception/GlobalExceptionHandler.java
new file mode 100644
index 0000000..f67b724
--- /dev/null
+++ b/auth-service/src/main/java/com/techtorque/auth_service/exception/GlobalExceptionHandler.java
@@ -0,0 +1,162 @@
+package com.techtorque.auth_service.exception;
+
+import com.techtorque.auth_service.controller.AuthController;
+import com.techtorque.auth_service.dto.ApiError;
+import jakarta.validation.ConstraintViolation;
+import jakarta.validation.ConstraintViolationException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.core.AuthenticationException;
+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.util.HashMap;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Global Exception Handler for the Authentication Service
+ * Provides centralized exception handling with proper error responses
+ */
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
+
+ /**
+ * Handle validation errors from @Valid annotations
+ */
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ResponseEntity> handleValidationExceptions(MethodArgumentNotValidException ex) {
+ Map errors = new HashMap<>();
+ ex.getBindingResult().getAllErrors().forEach((error) -> {
+ String fieldName = ((FieldError) error).getField();
+ String errorMessage = error.getDefaultMessage();
+ errors.put(fieldName, errorMessage);
+ });
+
+ logger.warn("Validation error: {}", errors);
+ return ResponseEntity.badRequest().body(Map.of(
+ "message", "Validation failed",
+ "errors", errors
+ ));
+ }
+
+ /**
+ * Handle constraint violation exceptions
+ */
+ @ExceptionHandler(ConstraintViolationException.class)
+ public ResponseEntity> handleConstraintViolationException(ConstraintViolationException ex) {
+ String errors = ex.getConstraintViolations()
+ .stream()
+ .map(ConstraintViolation::getMessage)
+ .collect(Collectors.joining(", "));
+
+ logger.warn("Constraint violation: {}", errors);
+ return ResponseEntity.badRequest()
+ .body(ApiError.builder()
+ .status(HttpStatus.BAD_REQUEST.value())
+ .message("Validation error")
+ .details(Map.of("errors", errors))
+ .timestamp(java.time.LocalDateTime.now())
+ .build());
+ }
+
+ /**
+ * Handle authentication exceptions
+ */
+ @ExceptionHandler(AuthenticationException.class)
+ public ResponseEntity> handleAuthenticationException(AuthenticationException ex) {
+ logger.warn("Authentication error: {}", ex.getMessage());
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
+ .body(ApiError.builder()
+ .status(HttpStatus.UNAUTHORIZED.value())
+ .message("Authentication failed")
+ .details(Map.of("error", ex.getMessage()))
+ .timestamp(java.time.LocalDateTime.now())
+ .build());
+ }
+
+ /**
+ * Handle bad credentials exception
+ */
+ @ExceptionHandler(BadCredentialsException.class)
+ public ResponseEntity> handleBadCredentialsException(BadCredentialsException ex) {
+ logger.warn("Bad credentials: {}", ex.getMessage());
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
+ .body(ApiError.builder()
+ .status(HttpStatus.UNAUTHORIZED.value())
+ .message("Invalid username or password")
+ .details(Map.of("error", ex.getMessage()))
+ .timestamp(java.time.LocalDateTime.now())
+ .build());
+ }
+
+ /**
+ * Handle access denied exceptions
+ */
+ @ExceptionHandler(AccessDeniedException.class)
+ public ResponseEntity> handleAccessDeniedException(AccessDeniedException ex) {
+ logger.warn("Access denied: {}", ex.getMessage());
+ return ResponseEntity.status(HttpStatus.FORBIDDEN)
+ .body(ApiError.builder()
+ .status(HttpStatus.FORBIDDEN.value())
+ .message("Access denied: Insufficient privileges")
+ .timestamp(java.time.LocalDateTime.now())
+ .build());
+ }
+
+ /**
+ * Handle runtime exceptions (business logic errors)
+ */
+ @ExceptionHandler(RuntimeException.class)
+ public ResponseEntity> handleRuntimeException(RuntimeException ex) {
+ logger.error("Runtime error: {}", ex.getMessage());
+
+ // Check if it's a user-friendly error message (business logic)
+ String message = ex.getMessage();
+ if (message != null && (
+ message.contains("not found") ||
+ message.contains("already exists") ||
+ message.contains("incorrect") ||
+ message.contains("Invalid") ||
+ message.contains("does not have") ||
+ message.contains("already has")
+ )) {
+ return ResponseEntity.badRequest()
+ .body(ApiError.builder()
+ .status(HttpStatus.BAD_REQUEST.value())
+ .message(message)
+ .timestamp(java.time.LocalDateTime.now())
+ .build());
+ }
+
+ // For other runtime exceptions, return internal server error
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .body(ApiError.builder()
+ .status(HttpStatus.INTERNAL_SERVER_ERROR.value())
+ .message("An internal error occurred")
+ .timestamp(java.time.LocalDateTime.now())
+ .build());
+ }
+
+ /**
+ * Handle all other exceptions
+ */
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity> handleGenericException(Exception ex) {
+ logger.error("Unexpected error: ", ex);
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .body(ApiError.builder()
+ .status(HttpStatus.INTERNAL_SERVER_ERROR.value())
+ .message("An unexpected error occurred")
+ .timestamp(java.time.LocalDateTime.now())
+ .build());
+ }
+}
\ No newline at end of file
diff --git a/auth-service/src/main/java/com/techtorque/auth_service/exception/RestExceptionHandler.java b/auth-service/src/main/java/com/techtorque/auth_service/exception/RestExceptionHandler.java
new file mode 100644
index 0000000..eba65ad
--- /dev/null
+++ b/auth-service/src/main/java/com/techtorque/auth_service/exception/RestExceptionHandler.java
@@ -0,0 +1,58 @@
+package com.techtorque.auth_service.exception;
+
+import jakarta.persistence.EntityNotFoundException;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.context.request.WebRequest;
+
+import java.time.Instant;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+@ControllerAdvice
+public class RestExceptionHandler {
+
+ private ResponseEntity