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 buildResponse(HttpStatus status, String message, WebRequest request) { + Map body = new LinkedHashMap<>(); + body.put("timestamp", Instant.now().toString()); + body.put("status", status.value()); + body.put("error", status.getReasonPhrase()); + body.put("message", message); + body.put("path", request.getDescription(false).replace("uri=", "")); + return new ResponseEntity<>(body, status); + } + + @ExceptionHandler(EntityNotFoundException.class) + public ResponseEntity handleNotFound(EntityNotFoundException ex, WebRequest request) { + return buildResponse(HttpStatus.NOT_FOUND, ex.getMessage(), request); + } + + @ExceptionHandler(UsernameNotFoundException.class) + public ResponseEntity handleUsernameNotFound(UsernameNotFoundException ex, WebRequest request) { + return buildResponse(HttpStatus.NOT_FOUND, ex.getMessage(), request); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleBadRequest(IllegalArgumentException ex, WebRequest request) { + return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage(), request); + } + + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity handleConflict(IllegalStateException ex, WebRequest request) { + return buildResponse(HttpStatus.CONFLICT, ex.getMessage(), request); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDenied(AccessDeniedException ex, WebRequest request) { + return buildResponse(HttpStatus.FORBIDDEN, ex.getMessage(), request); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleAll(Exception ex, WebRequest request) { + return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage(), request); + } +} diff --git a/auth-service/src/main/java/com/techtorque/auth_service/repository/LoginLockRepository.java b/auth-service/src/main/java/com/techtorque/auth_service/repository/LoginLockRepository.java new file mode 100644 index 0000000..03bc783 --- /dev/null +++ b/auth-service/src/main/java/com/techtorque/auth_service/repository/LoginLockRepository.java @@ -0,0 +1,9 @@ +package com.techtorque.auth_service.repository; + +import com.techtorque.auth_service.entity.LoginLock; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface LoginLockRepository extends JpaRepository { + Optional findByUsername(String username); +} diff --git a/auth-service/src/main/java/com/techtorque/auth_service/repository/LoginLogRepository.java b/auth-service/src/main/java/com/techtorque/auth_service/repository/LoginLogRepository.java new file mode 100644 index 0000000..4866a3d --- /dev/null +++ b/auth-service/src/main/java/com/techtorque/auth_service/repository/LoginLogRepository.java @@ -0,0 +1,7 @@ +package com.techtorque.auth_service.repository; + +import com.techtorque.auth_service.entity.LoginLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LoginLogRepository extends JpaRepository { +} diff --git a/auth-service/src/main/java/com/techtorque/auth_service/service/AuthService.java b/auth-service/src/main/java/com/techtorque/auth_service/service/AuthService.java index d085215..80c75f2 100644 --- a/auth-service/src/main/java/com/techtorque/auth_service/service/AuthService.java +++ b/auth-service/src/main/java/com/techtorque/auth_service/service/AuthService.java @@ -12,6 +12,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; @@ -19,6 +20,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.beans.factory.annotation.Value; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import jakarta.servlet.http.HttpServletRequest; + import java.util.HashSet; import java.util.List; import java.util.Set; @@ -42,37 +48,106 @@ public class AuthService { @Autowired private JwtUtil jwtUtil; + + @Autowired + private com.techtorque.auth_service.repository.LoginLockRepository loginLockRepository; + + @Autowired + private com.techtorque.auth_service.repository.LoginLogRepository loginLogRepository; + + @Autowired + private LoginAuditService loginAuditService; + + @Value("${security.login.max-failed-attempts:3}") + private int maxFailedAttempts; + + // duration in minutes + @Value("${security.login.lock-duration-minutes:15}") + private long lockDurationMinutes; - public LoginResponse authenticateUser(LoginRequest loginRequest) { - Authentication authentication = authenticationManager.authenticate( - new UsernamePasswordAuthenticationToken( - loginRequest.getUsername(), - loginRequest.getPassword() - ) - ); - - UserDetails userDetails = (UserDetails) authentication.getPrincipal(); - - List roles = userDetails.getAuthorities().stream() - .map(GrantedAuthority::getAuthority) - .map(auth -> auth.replace("ROLE_", "")) - .collect(Collectors.toList()); - - String jwt = jwtUtil.generateJwtToken(userDetails, roles); - - User user = userRepository.findByUsername(userDetails.getUsername()) - .orElseThrow(() -> new RuntimeException("User not found")); - - Set roleNames = user.getRoles().stream() - .map(role -> role.getName().name()) - .collect(Collectors.toSet()); - - return LoginResponse.builder() - .token(jwt) - .username(user.getUsername()) - .email(user.getEmail()) // This was missing in the error - .roles(roleNames) + public LoginResponse authenticateUser(LoginRequest loginRequest, HttpServletRequest request) { + String uname = loginRequest.getUsername(); + + // load or create lock record + com.techtorque.auth_service.entity.LoginLock lock = loginLockRepository.findByUsername(uname) + .orElseGet(() -> com.techtorque.auth_service.entity.LoginLock.builder().username(uname).failedAttempts(0).build()); + + if (lock.getLockUntil() != null && lock.getLockUntil().isAfter(LocalDateTime.now())) { + long minutesLeft = ChronoUnit.MINUTES.between(LocalDateTime.now(), lock.getLockUntil()); + // record login log using audit service + String ip = request != null ? (request.getHeader("X-Forwarded-For") == null ? request.getRemoteAddr() : request.getHeader("X-Forwarded-For")) : null; + String ua = request != null ? request.getHeader("User-Agent") : null; + loginAuditService.recordLogin(uname, false, ip, ua); + throw new org.springframework.security.authentication.BadCredentialsException( + "Account is temporarily locked. Try again in " + minutesLeft + " minutes."); + } + + try { + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken( + loginRequest.getUsername(), + loginRequest.getPassword() + ) + ); + + // Successful authentication -> reset failed attempts on lock record + lock.setFailedAttempts(0); + lock.setLockUntil(null); + loginLockRepository.save(lock); + + UserDetails userDetails = (UserDetails) authentication.getPrincipal(); + + List roles = userDetails.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .map(auth -> auth.replace("ROLE_", "")) + .collect(Collectors.toList()); + + String jwt = jwtUtil.generateJwtToken(userDetails, roles); + + User foundUser = userRepository.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); + + Set roleNames = foundUser.getRoles().stream() + .map(role -> role.getName().name()) + .collect(Collectors.toSet()); + + recordLogin(uname, true, request); + + return LoginResponse.builder() + .token(jwt) + .username(foundUser.getUsername()) + .email(foundUser.getEmail()) + .roles(roleNames) + .build(); + + } catch (BadCredentialsException ex) { + // increment failed attempts and possibly lock the user using separate transaction + loginAuditService.incrementFailedAttempt(uname, lockDurationMinutes, maxFailedAttempts); + + String ip = request != null ? (request.getHeader("X-Forwarded-For") == null ? request.getRemoteAddr() : request.getHeader("X-Forwarded-For")) : null; + String ua = request != null ? request.getHeader("User-Agent") : null; + loginAuditService.recordLogin(uname, false, ip, ua); + + throw new org.springframework.security.authentication.BadCredentialsException("Invalid username or password"); + } + } + + private void recordLogin(String username, boolean success, HttpServletRequest request) { + String ip = null; + String ua = null; + if (request != null) { + ip = request.getHeader("X-Forwarded-For"); + if (ip == null) ip = request.getRemoteAddr(); + ua = request.getHeader("User-Agent"); + } + com.techtorque.auth_service.entity.LoginLog log = com.techtorque.auth_service.entity.LoginLog.builder() + .username(username) + .success(success) + .ipAddress(ip) + .userAgent(ua) + .createdAt(LocalDateTime.now()) .build(); + loginLogRepository.save(log); } public String registerUser(RegisterRequest registerRequest) { diff --git a/auth-service/src/main/java/com/techtorque/auth_service/service/LoginAuditService.java b/auth-service/src/main/java/com/techtorque/auth_service/service/LoginAuditService.java new file mode 100644 index 0000000..6fd2e3e --- /dev/null +++ b/auth-service/src/main/java/com/techtorque/auth_service/service/LoginAuditService.java @@ -0,0 +1,59 @@ +package com.techtorque.auth_service.service; + +import com.techtorque.auth_service.entity.LoginLock; +import com.techtorque.auth_service.entity.LoginLog; +import com.techtorque.auth_service.repository.LoginLockRepository; +import com.techtorque.auth_service.repository.LoginLogRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; + +@Service +public class LoginAuditService { + + @Autowired + private LoginLockRepository loginLockRepository; + + @Autowired + private LoginLogRepository loginLogRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void incrementFailedAttempt(String username, long lockDurationMinutes, int maxFailedAttempts) { + LoginLock lock = loginLockRepository.findByUsername(username) + .orElseGet(() -> LoginLock.builder().username(username).failedAttempts(0).build()); + + int attempts = lock.getFailedAttempts() == null ? 0 : lock.getFailedAttempts(); + attempts++; + lock.setFailedAttempts(attempts); + if (attempts >= maxFailedAttempts) { + lock.setLockUntil(LocalDateTime.now().plus(lockDurationMinutes, ChronoUnit.MINUTES)); + } + + loginLockRepository.save(lock); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void resetFailedAttempts(String username) { + LoginLock lock = loginLockRepository.findByUsername(username) + .orElseGet(() -> LoginLock.builder().username(username).failedAttempts(0).build()); + lock.setFailedAttempts(0); + lock.setLockUntil(null); + loginLockRepository.save(lock); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void recordLogin(String username, boolean success, String ip, String userAgent) { + LoginLog log = LoginLog.builder() + .username(username) + .success(success) + .ipAddress(ip) + .userAgent(userAgent) + .createdAt(LocalDateTime.now()) + .build(); + loginLogRepository.save(log); + } +} diff --git a/auth-service/src/main/java/com/techtorque/auth_service/service/UserService.java b/auth-service/src/main/java/com/techtorque/auth_service/service/UserService.java index d8b02de..cd3f05c 100644 --- a/auth-service/src/main/java/com/techtorque/auth_service/service/UserService.java +++ b/auth-service/src/main/java/com/techtorque/auth_service/service/UserService.java @@ -4,12 +4,16 @@ import com.techtorque.auth_service.entity.RoleName; import com.techtorque.auth_service.entity.User; import com.techtorque.auth_service.repository.RoleRepository; +import com.techtorque.auth_service.repository.LoginLockRepository; import com.techtorque.auth_service.repository.UserRepository; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.access.AccessDeniedException; +import jakarta.persistence.EntityNotFoundException; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -30,16 +34,16 @@ @Transactional public class UserService implements UserDetailsService { - private final UserRepository userRepository; - private final RoleRepository roleRepository; - private final PasswordEncoder passwordEncoder; - - @Autowired - public UserService(UserRepository userRepository, RoleRepository roleRepository, @Lazy PasswordEncoder passwordEncoder) { - this.userRepository = userRepository; - this.roleRepository = roleRepository; - this.passwordEncoder = passwordEncoder; - } + private final UserRepository userRepository; + private final RoleRepository roleRepository; + private final PasswordEncoder passwordEncoder; + private final LoginLockRepository loginLockRepository; + public UserService(UserRepository userRepository, RoleRepository roleRepository, @Lazy PasswordEncoder passwordEncoder, LoginLockRepository loginLockRepository) { + this.userRepository = userRepository; + this.roleRepository = roleRepository; + this.passwordEncoder = passwordEncoder; + this.loginLockRepository = loginLockRepository; + } /** * Load user by username for Spring Security authentication @@ -49,9 +53,15 @@ public UserService(UserRepository userRepository, RoleRepository roleRepository, * @throws UsernameNotFoundException if user not found */ @Override - public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); + public UserDetails loadUserByUsername(String identifier) throws UsernameNotFoundException { + // Support login by either username or email. + // Try to find by username first, then fall back to email. + java.util.Optional userOpt = userRepository.findByUsername(identifier); + if (userOpt.isEmpty()) { + userOpt = userRepository.findByEmail(identifier); + } + + User user = userOpt.orElseThrow(() -> new UsernameNotFoundException("User not found: " + identifier)); return org.springframework.security.core.userdetails.User.builder() .username(user.getUsername()) @@ -98,17 +108,17 @@ private Collection getAuthorities(User user) { public User registerCustomer(String username, String email, String password) { // Validate username doesn't exist if (userRepository.findByUsername(username).isPresent()) { - throw new RuntimeException("Username already exists: " + username); + throw new IllegalArgumentException("Username already exists: " + username); } // Validate email doesn't exist if (userRepository.findByEmail(email).isPresent()) { - throw new RuntimeException("Email already exists: " + email); + throw new IllegalArgumentException("Email already exists: " + email); } // Get CUSTOMER role from database - Role customerRole = roleRepository.findByName(RoleName.CUSTOMER) - .orElseThrow(() -> new RuntimeException("Customer role not found")); + Role customerRole = roleRepository.findByName(RoleName.CUSTOMER) + .orElseThrow(() -> new EntityNotFoundException("Customer role not found")); // Create user with CUSTOMER role only User user = User.builder() @@ -134,17 +144,17 @@ public User registerCustomer(String username, String email, String password) { public User createEmployee(String username, String email, String password) { // Validate username doesn't exist if (userRepository.findByUsername(username).isPresent()) { - throw new RuntimeException("Username already exists: " + username); + throw new IllegalArgumentException("Username already exists: " + username); } // Validate email doesn't exist if (userRepository.findByEmail(email).isPresent()) { - throw new RuntimeException("Email already exists: " + email); + throw new IllegalArgumentException("Email already exists: " + email); } // Get EMPLOYEE role from database - Role employeeRole = roleRepository.findByName(RoleName.EMPLOYEE) - .orElseThrow(() -> new RuntimeException("Employee role not found")); + Role employeeRole = roleRepository.findByName(RoleName.EMPLOYEE) + .orElseThrow(() -> new EntityNotFoundException("Employee role not found")); // Create user with EMPLOYEE role User user = User.builder() @@ -170,17 +180,17 @@ public User createEmployee(String username, String email, String password) { public User createAdmin(String username, String email, String password) { // Validate username doesn't exist if (userRepository.findByUsername(username).isPresent()) { - throw new RuntimeException("Username already exists: " + username); + throw new IllegalArgumentException("Username already exists: " + username); } // Validate email doesn't exist if (userRepository.findByEmail(email).isPresent()) { - throw new RuntimeException("Email already exists: " + email); + throw new IllegalArgumentException("Email already exists: " + email); } // Get ADMIN role from database - Role adminRole = roleRepository.findByName(RoleName.ADMIN) - .orElseThrow(() -> new RuntimeException("Admin role not found")); + Role adminRole = roleRepository.findByName(RoleName.ADMIN) + .orElseThrow(() -> new EntityNotFoundException("Admin role not found")); // Create user with ADMIN role User user = User.builder() @@ -227,7 +237,7 @@ public List findAllUsers() { */ public Set getUserPermissions(String username) { User user = userRepository.findByUsername(username) - .orElseThrow(() -> new RuntimeException("User not found: " + username)); + .orElseThrow(() -> new EntityNotFoundException("User not found: " + username)); return user.getRoles().stream() .flatMap(role -> role.getPermissions().stream()) @@ -242,7 +252,7 @@ public Set getUserPermissions(String username) { */ public Set getUserRoles(String username) { User user = userRepository.findByUsername(username) - .orElseThrow(() -> new RuntimeException("User not found: " + username)); + .orElseThrow(() -> new EntityNotFoundException("User not found: " + username)); return user.getRoles().stream() .map(role -> role.getName().name()) @@ -255,7 +265,7 @@ public Set getUserRoles(String username) { */ public void enableUser(String username) { User user = userRepository.findByUsername(username) - .orElseThrow(() -> new RuntimeException("User not found: " + username)); + .orElseThrow(() -> new EntityNotFoundException("User not found: " + username)); user.setEnabled(true); userRepository.save(user); } @@ -266,7 +276,7 @@ public void enableUser(String username) { */ public void disableUser(String username) { User user = userRepository.findByUsername(username) - .orElseThrow(() -> new RuntimeException("User not found: " + username)); + .orElseThrow(() -> new EntityNotFoundException("User not found: " + username)); user.setEnabled(false); userRepository.save(user); } @@ -278,10 +288,24 @@ public void disableUser(String username) { */ public void deleteUser(String username) { User user = userRepository.findByUsername(username) - .orElseThrow(() -> new RuntimeException("User not found: " + username)); + .orElseThrow(() -> new EntityNotFoundException("User not found: " + username)); userRepository.delete(user); } + /** + * Clear login lock for a username (admin action). + * Resets failed attempts and lock timestamp if an entry exists. + */ + public void clearLoginLock(String username) { + java.util.Optional lockOpt = loginLockRepository.findByUsername(username); + if (lockOpt.isPresent()) { + com.techtorque.auth_service.entity.LoginLock lock = lockOpt.get(); + lock.setFailedAttempts(0); + lock.setLockUntil(null); + loginLockRepository.save(lock); + } + } + /** * Check if a user has a specific role * @param username Username to check @@ -290,7 +314,7 @@ public void deleteUser(String username) { */ public boolean hasRole(String username, RoleName roleName) { User user = userRepository.findByUsername(username) - .orElseThrow(() -> new RuntimeException("User not found: " + username)); + .orElseThrow(() -> new EntityNotFoundException("User not found: " + username)); return user.getRoles().stream() .anyMatch(role -> role.getName().equals(roleName)); @@ -304,10 +328,164 @@ public boolean hasRole(String username, RoleName roleName) { */ public boolean hasPermission(String username, String permissionName) { User user = userRepository.findByUsername(username) - .orElseThrow(() -> new RuntimeException("User not found: " + username)); + .orElseThrow(() -> new EntityNotFoundException("User not found: " + username)); return user.getRoles().stream() .flatMap(role -> role.getPermissions().stream()) .anyMatch(permission -> permission.getName().equals(permissionName)); } + + /** + * Update user details (admin only) + * @param username Username of the user to update + * @param newUsername New username (optional) + * @param newEmail New email (optional) + * @param enabled New enabled status (optional) + * @return Updated user + * @throws RuntimeException if user not found or new values already exist + */ + public User updateUserDetails(String username, String newUsername, String newEmail, Boolean enabled) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new EntityNotFoundException("User not found: " + username)); + + // Check if new username is provided and different + if (newUsername != null && !newUsername.equals(user.getUsername())) { + if (userRepository.existsByUsername(newUsername)) { + throw new IllegalArgumentException("Username already exists: " + newUsername); + } + user.setUsername(newUsername); + } + + // Check if new email is provided and different + if (newEmail != null && !newEmail.equals(user.getEmail())) { + if (userRepository.existsByEmail(newEmail)) { + throw new IllegalArgumentException("Email already exists: " + newEmail); + } + user.setEmail(newEmail); + } + + // Update enabled status if provided + if (enabled != null) { + user.setEnabled(enabled); + } + + return userRepository.save(user); + } + + /** + * Reset a user's password (admin only) + * @param username Username whose password to reset + * @param newPassword New password (plain text) + * @throws RuntimeException if user not found + */ + public void resetUserPassword(String username, String newPassword) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new EntityNotFoundException("User not found: " + username)); + + user.setPassword(passwordEncoder.encode(newPassword)); + userRepository.save(user); + } + + /** + * Change user's own password (requires current password verification) + * @param username Username of the user changing password + * @param currentPassword Current password for verification + * @param newPassword New password (plain text) + * @throws RuntimeException if user not found or current password is incorrect + */ + public void changeUserPassword(String username, String currentPassword, String newPassword) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new EntityNotFoundException("User not found: " + username)); + + // Verify current password + if (!passwordEncoder.matches(currentPassword, user.getPassword())) { + throw new IllegalStateException("Current password is incorrect"); + } + + user.setPassword(passwordEncoder.encode(newPassword)); + userRepository.save(user); + } + + /** + * Assign a role to a user (admin only) + * @param username Username to assign role to + * @param roleName Role name to assign + * @throws RuntimeException if user or role not found, or role already assigned + */ + public void assignRoleToUser(String username, String roleName) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new EntityNotFoundException("User not found: " + username)); + + RoleName roleNameEnum; + try { + roleNameEnum = RoleName.valueOf(roleName.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid role name: " + roleName); + } + + // Rule: Only a SUPER_ADMIN can assign the ADMIN role. + if (roleNameEnum == RoleName.ADMIN) { + Authentication currentUser = SecurityContextHolder.getContext().getAuthentication(); + if (currentUser == null) { + throw new AccessDeniedException("Permission denied: unauthenticated users cannot assign roles."); + } + boolean isSuperAdmin = currentUser.getAuthorities().stream() + .anyMatch(grantedAuthority -> grantedAuthority.getAuthority().equals("ROLE_SUPER_ADMIN")); + + if (!isSuperAdmin) { + throw new AccessDeniedException("Permission denied: Only a SUPER_ADMIN can assign the ADMIN role."); + } + } + + Role role = roleRepository.findByName(roleNameEnum) + .orElseThrow(() -> new EntityNotFoundException("Role not found: " + roleName)); + + if (user.getRoles().contains(role)) { + throw new IllegalStateException("User already has role: " + roleName); + } + + user.getRoles().add(role); + userRepository.save(user); + } + + /** + * Revoke a role from a user (admin only) + * @param username Username to revoke role from + * @param roleName Role name to revoke + * @throws RuntimeException if user or role not found, or role not assigned + */ + public void revokeRoleFromUser(String username, String roleName) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new EntityNotFoundException("User not found: " + username)); + + RoleName roleNameEnum; + try { + roleNameEnum = RoleName.valueOf(roleName.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid role name: " + roleName); + } + + // Rule: A user cannot revoke their own SUPER_ADMIN role. + if (roleNameEnum == RoleName.SUPER_ADMIN) { + Authentication currentUser = SecurityContextHolder.getContext().getAuthentication(); + if (currentUser == null) { + throw new AccessDeniedException("Permission denied: unauthenticated users cannot revoke roles."); + } + String currentUsername = currentUser.getName(); + + if (currentUsername.equals(username)) { + throw new AccessDeniedException("Action denied: A SUPER_ADMIN cannot revoke their own SUPER_ADMIN role."); + } + } + + Role role = roleRepository.findByName(roleNameEnum) + .orElseThrow(() -> new EntityNotFoundException("Role not found: " + roleName)); + + if (!user.getRoles().contains(role)) { + throw new IllegalStateException("User does not have role: " + roleName); + } + + user.getRoles().remove(role); + userRepository.save(user); + } } \ No newline at end of file diff --git a/auth-service/src/main/resources/application.properties b/auth-service/src/main/resources/application.properties index 9a9fa67..cb0a657 100644 --- a/auth-service/src/main/resources/application.properties +++ b/auth-service/src/main/resources/application.properties @@ -6,14 +6,32 @@ server.port=8081 jwt.secret=${JWT_SECRET:YourSuperSecretKeyForJWTGoesHereAndItMustBeVeryLongForSecurityPurposes} jwt.expiration=86400000 +# Any Developer or AI that is editing this file, please do not change any existing comments in this file, you may add your own comments, but do not change any existing comments. # Database Configuration for local testing, it is recommended to make a database, using the following name, and make a user with the given username and password. -spring.datasource.url=jdbc:postgresql://localhost:5432/techtorque -spring.datasource.username=techtorque -spring.datasource.password=techtorque123 +spring.datasource.url=${DB_LINK:jdbc:postgresql://localhost:5432/techtorque} +spring.datasource.username=${DB_USER:techtorque} +spring.datasource.password=${DB_PASS:techtorque123} spring.datasource.driver-class-name=org.postgresql.Driver # JPA Configuration -spring.jpa.hibernate.ddl-auto=update +# If you want to change the database schema on startup, you can change the DB_MODE variable to one of the following values: +# validate - validate the schema, makes no changes to the database. +# update - update the schema, makes changes to the database. +# create - creates the schema, destroying previous data. +# create-drop - creates the schema on startup and drops it on shutdown. +spring.jpa.hibernate.ddl-auto=${DB_MODE:update} spring.jpa.show-sql=true spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect -spring.jpa.properties.hibernate.format_sql=true \ No newline at end of file +spring.jpa.properties.hibernate.format_sql=true + +# Development/Production Profile +spring.profiles.active=${SPRING_PROFILE:dev} + +# OpenAPI access URL +# http://localhost:8081/swagger-ui/index.html + +# Security: lockout policy for failed logins +# Maximum number of consecutive failed login attempts before locking the account +security.login.max-failed-attempts=${SECURITY_LOGIN_MAX_FAILED:3} +# Lock duration in minutes when the account is locked due to failed attempts +security.login.lock-duration-minutes=${SECURITY_LOGIN_LOCK_MINUTES:15} \ No newline at end of file diff --git a/tesh.sh b/tesh.sh new file mode 100755 index 0000000..f55a4f9 --- /dev/null +++ b/tesh.sh @@ -0,0 +1,208 @@ +#!/bin/bash + +# ============================================================================== +# Comprehensive Test Script for the TechTorque Authentication Service +# ============================================================================== +# +# This script validates the entire functionality of the auth-service, including: +# - Public endpoints (health, register, login) +# - User self-service (get profile, change password) +# - Admin capabilities (create employee, list users, manage accounts) +# - Super-Admin security rules (create admin, role management) +# - Negative tests for security permissions. +# +# Prerequisites: +# - The auth-service must be running on localhost:8081. +# - `curl` must be installed. +# - `jq` must be installed for parsing JSON (e.g., `sudo apt-get install jq`). +# +# ============================================================================== + +# --- Configuration --- +BASE_URL="http://localhost:8081/api/v1" +PASS_COUNT=0 +FAIL_COUNT=0 + +# --- Helper Functions for Colored Output --- +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to print test status +print_status() { + local message=$1 + local status=$2 + if [ "$status" == "PASS" ]; then + echo -e "${GREEN}[PASS]${NC} $message" + ((PASS_COUNT++)) + else + echo -e "${RED}[FAIL]${NC} $message" + ((FAIL_COUNT++)) + fi +} + +# --- Core Test Runner Function --- +# Usage: run_test "Test Name" [JWT Token] [JSON Data] +run_test() { + local test_name=$1 + local expected_status=$2 + local method=$3 + local endpoint=$4 + local token=$5 + local data=$6 + + local headers=(-H "Content-Type: application/json") + if [ -n "$token" ]; then + headers+=(-H "Authorization: Bearer $token") + fi + + # The -w "%{http_code}" flag makes curl output only the HTTP status code. + # The -s flag makes it silent, and -o /dev/null discards the body. + local http_code=$(curl -s -o /dev/null -w "%{http_code}" -X "$method" \ + "${headers[@]}" \ + -d "$data" \ + "$BASE_URL$endpoint") + + if [ "$http_code" == "$expected_status" ]; then + print_status "$test_name (Expected $expected_status, Got $http_code)" "PASS" + else + print_status "$test_name (Expected $expected_status, Got $http_code)" "FAIL" + fi +} + +# Run a request and assert HTTP status and that the body contains an expected substring +# Usage: run_test_body_contains "Test Name" [JWT Token] [JSON Data] +run_test_body_contains() { + local test_name=$1 + local expected_status=$2 + local method=$3 + local endpoint=$4 + local token=$5 + local data=$6 + local expected_substr=$7 + + local headers=(-H "Content-Type: application/json") + if [ -n "$token" ]; then + headers+=(-H "Authorization: Bearer $token") + fi + + response=$(curl -s -w "\n%{http_code}" -X "$method" "${headers[@]}" -d "$data" "$BASE_URL$endpoint") + http_code=$(echo "$response" | tail -n1) + body=$(echo "$response" | sed '$d') + + if [ "$http_code" == "$expected_status" ] && echo "$body" | grep -q "$expected_substr"; then + print_status "$test_name (Expected $expected_status, Got $http_code; body contains '$expected_substr')" "PASS" + else + print_status "$test_name (Expected $expected_status and body containing '$expected_substr'; Got $http_code) -- Body: $body" "FAIL" + fi +} + +# --- Main Test Execution --- + +echo -e "${YELLOW}===============================================${NC}" +echo -e "${YELLOW} Starting Auth Service Integration Tests... ${NC}" +echo -e "${YELLOW}===============================================${NC}" + +# Check for jq dependency +if ! command -v jq &> /dev/null; then + echo -e "${RED}Error: 'jq' is not installed. Please install it to run these tests.${NC}" + exit 1 +fi + +# === 1. Public Endpoints === +echo -e "\n--- Testing Public Endpoints ---" +run_test "Health check" 200 "GET" "/auth/health" + +# Generate a unique username for this test run +UNIQUE_ID=$RANDOM +CUSTOMER_USER="testcust$UNIQUE_ID" +CUSTOMER_EMAIL="testcust$UNIQUE_ID@techtorque.com" +run_test "Register a new customer" 200 "POST" "/auth/register" "" \ + '{"username":"'$CUSTOMER_USER'","email":"'$CUSTOMER_EMAIL'","password":"password123"}' + +# === 2. Login and Token Extraction === +echo -e "\n--- Logging in and Acquiring JWTs ---" + +# Log in as SUPER_ADMIN to get a token +SUPER_ADMIN_TOKEN=$(curl -s -X POST -H "Content-Type: application/json" -d '{"username":"superadmin","password":"superadmin123"}' "$BASE_URL/auth/login" | jq -r '.token') +if [ "$SUPER_ADMIN_TOKEN" != "null" ]; then print_status "Logged in as SUPER_ADMIN" "PASS"; else print_status "Failed to log in as SUPER_ADMIN" "FAIL"; fi + +# Log in as ADMIN to get a token +ADMIN_TOKEN=$(curl -s -X POST -H "Content-Type: application/json" -d '{"username":"admin","password":"admin123"}' "$BASE_URL/auth/login" | jq -r '.token') +if [ "$ADMIN_TOKEN" != "null" ]; then print_status "Logged in as ADMIN" "PASS"; else print_status "Failed to log in as ADMIN" "FAIL"; fi + +# Log in as the new CUSTOMER to get a token +CUSTOMER_TOKEN=$(curl -s -X POST -H "Content-Type: application/json" -d '{"username":"'$CUSTOMER_USER'","password":"password123"}' "$BASE_URL/auth/login" | jq -r '.token') +if [ "$CUSTOMER_TOKEN" != "null" ]; then print_status "Logged in as new CUSTOMER" "PASS"; else print_status "Failed to log in as new CUSTOMER" "FAIL"; fi + +# === 3. User Self-Service Endpoints === +echo -e "\n--- Testing User Self-Service ---" +run_test "Customer can get their own profile (/me)" 200 "GET" "/users/me" "$CUSTOMER_TOKEN" +run_test "Customer can change their own password" 200 "POST" "/users/me/change-password" "$CUSTOMER_TOKEN" \ + '{"currentPassword":"password123", "newPassword":"newPassword456"}' + +# === 4. Admin-Level Endpoints === +echo -e "\n--- Testing Admin Capabilities ---" +NEW_EMP_USER="testemp$UNIQUE_ID" +run_test "Admin can create an Employee" 201 "POST" "/auth/users/employee" "$ADMIN_TOKEN" \ + '{"username":"'$NEW_EMP_USER'","email":"'$NEW_EMP_USER'@techtorque.com","password":"password123"}' +run_test "Admin can list all users" 200 "GET" "/users" "$ADMIN_TOKEN" +run_test "Admin can disable a user" 200 "POST" "/users/$CUSTOMER_USER/disable" "$ADMIN_TOKEN" +run_test "Admin can re-enable a user" 200 "POST" "/users/$CUSTOMER_USER/enable" "$ADMIN_TOKEN" + +# === 5. Super-Admin-Level Endpoints and Security Rules === +echo -e "\n--- Testing Super-Admin Capabilities & Security Rules ---" +NEW_ADMIN_USER="newadmin$UNIQUE_ID" +run_test "Super-Admin can create an Admin" 201 "POST" "/auth/users/admin" "$SUPER_ADMIN_TOKEN" \ + '{"username":"'$NEW_ADMIN_USER'","email":"'$NEW_ADMIN_USER'@techtorque.com","password":"password123"}' +run_test "Super-Admin can assign ADMIN role to an employee" 200 "POST" "/users/$NEW_EMP_USER/roles" "$SUPER_ADMIN_TOKEN" \ + '{"roleName":"ADMIN", "action":"ASSIGN"}' + +# === 6. Negative Security Tests (Crucial!) === +echo -e "\n--- Testing Security Denials (Negative Tests) ---" +run_test "FAIL: Regular Admin CANNOT create another Admin" 403 "POST" "/auth/users/admin" "$ADMIN_TOKEN" \ + '{"username":"fakeadmin","email":"fake@admin.com","password":"password123"}' +run_test "FAIL: Regular Admin CANNOT assign ADMIN role" 403 "POST" "/users/$CUSTOMER_USER/roles" "$ADMIN_TOKEN" \ + '{"roleName":"ADMIN", "action":"ASSIGN"}' +run_test "FAIL: Customer CANNOT list all users" 403 "GET" "/users" "$CUSTOMER_TOKEN" +run_test "FAIL: Customer CANNOT create an Employee" 403 "POST" "/auth/users/employee" "$CUSTOMER_TOKEN" \ + '{"username":"fakeemployee","email":"fake@employee.com","password":"password123"}' + +# === 7. Final Cleanup Test === +echo -e "\n--- Testing Final Cleanup Action ---" +run_test "Admin can delete a user" 200 "DELETE" "/users/$CUSTOMER_USER" "$ADMIN_TOKEN" + + +# === 8. Lockout Policy Test === +echo -e "\n--- Testing Lockout Policy (3 failed attempts -> 15 minute lock) ---" +LOCK_USER="locktest$UNIQUE_ID" +LOCK_EMAIL="$LOCK_USER@techtorque.com" +run_test "Create user for lock test" 200 "POST" "/auth/register" "" \ + '{"username":"'$LOCK_USER'","email":"'$LOCK_EMAIL'","password":"correctPassword"}' + +# Perform 3 failed login attempts +for i in 1 2 3; do + run_test "Failed login attempt #$i for $LOCK_USER" 401 "POST" "/auth/login" "" \ + '{"username":"'$LOCK_USER'","password":"wrongPassword"}' +done + +# Now the account should be locked; login with correct password should return a message indicating temporary lock +run_test_body_contains "Locked account shows message" 401 "POST" "/auth/login" "" \ + '{"username":"'$LOCK_USER'","password":"correctPassword"}' "temporarily locked" + + +# === Summary === +echo -e "\n${YELLOW}===============================================${NC}" +echo -e "${YELLOW} Test Summary ${NC}" +echo -e "${YELLOW}===============================================${NC}" +echo -e "${GREEN}Passed: $PASS_COUNT${NC}" +echo -e "${RED}Failed: $FAIL_COUNT${NC}" +echo -e "${YELLOW}===============================================${NC}" + +# Return exit code based on failures +if [ "$FAIL_COUNT" -gt 0 ]; then + exit 1 +else + exit 0 +fi \ No newline at end of file