From 06f3085f5fa33165267bbd2f560b73284916b678 Mon Sep 17 00:00:00 2001 From: RandithaK Date: Sun, 28 Sep 2025 01:14:36 +0530 Subject: [PATCH 01/15] feat(auth): Add user management endpoints and employee creation functionality --- .../controller/AuthController.java | 34 +++++- .../controller/UserController.java | 98 +++++++++++++++++ .../auth_service/dto/LoginResponse.java | 9 +- .../techtorque/auth_service/entity/Role.java | 60 +++++------ .../techtorque/auth_service/entity/User.java | 102 +++++++++--------- 5 files changed, 212 insertions(+), 91 deletions(-) create mode 100644 auth-service/src/main/java/com/techtorque/auth_service/controller/UserController.java 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..f2afc29 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,13 +1,16 @@ package com.techtorque.auth_service.controller; +import com.techtorque.auth_service.dto.CreateEmployeeRequest; 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 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.*; /** @@ -15,13 +18,18 @@ * Handles login, registration, and health check requests */ @RestController -@RequestMapping("/api/auth") +@RequestMapping("/api/v1/auth") @CrossOrigin(origins = "*", maxAge = 3600) 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 @@ -54,6 +62,30 @@ public ResponseEntity registerUser(@Valid @RequestBody RegisterRequest regist } } + // --- 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. + */ + @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(new MessageResponse("Employee account created successfully!")); + } catch (RuntimeException e) { + // Catches errors like "Username already exists" + return ResponseEntity.badRequest().body(new MessageResponse("Error: " + e.getMessage())); + } + } + /** * Health check endpoint * @return Service status 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..49025eb --- /dev/null +++ b/auth-service/src/main/java/com/techtorque/auth_service/controller/UserController.java @@ -0,0 +1,98 @@ +package com.techtorque.auth_service.controller; + +import com.techtorque.auth_service.dto.UserDto; +import com.techtorque.auth_service.entity.User; +import com.techtorque.auth_service.service.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * REST Controller for user management endpoints. + * All endpoints in this controller are restricted to users with the ADMIN role. + */ +@RestController +@RequestMapping("/api/v1/users") +@PreAuthorize("hasRole('ADMIN')") // Secures ALL endpoints in this class +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(new AuthController.MessageResponse("User '" + username + "' has been disabled.")); + } catch (RuntimeException e) { + return ResponseEntity.notFound().build(); + } + } + + /** + * Enable a user's account. + */ + @PostMapping("/{username}/enable") + public ResponseEntity enableUser(@PathVariable String username) { + try { + userService.enableUser(username); + return ResponseEntity.ok(new AuthController.MessageResponse("User '" + username + "' has been enabled.")); + } catch (RuntimeException e) { + return ResponseEntity.notFound().build(); + } + } + + /** + * Delete a user from the system permanently. + */ + @DeleteMapping("/{username}") + public ResponseEntity deleteUser(@PathVariable String username) { + try { + userService.deleteUser(username); + return ResponseEntity.ok(new AuthController.MessageResponse("User '" + username + "' has been deleted.")); + } catch (RuntimeException e) { + return ResponseEntity.notFound().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/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/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/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 From 9f58fe68b42c86db4c17ac5ec9bcea6b00d8841e Mon Sep 17 00:00:00 2001 From: RandithaK Date: Sun, 28 Sep 2025 01:14:44 +0530 Subject: [PATCH 02/15] fix(config): Update database configuration to use environment variables for better security --- auth-service/src/main/resources/application.properties | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/auth-service/src/main/resources/application.properties b/auth-service/src/main/resources/application.properties index 9a9fa67..e29d9c3 100644 --- a/auth-service/src/main/resources/application.properties +++ b/auth-service/src/main/resources/application.properties @@ -7,9 +7,9 @@ jwt.secret=${JWT_SECRET:YourSuperSecretKeyForJWTGoesHereAndItMustBeVeryLongForSe jwt.expiration=86400000 # 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 From 0a7e73b7612778299d6f0dc29b28af06b5f229c6 Mon Sep 17 00:00:00 2001 From: RandithaK Date: Sun, 28 Sep 2025 01:27:39 +0530 Subject: [PATCH 03/15] feat(auth): Implement full RBAC with SUPER_ADMIN and user management API This commit introduces a complete Role-Based Access Control (RBAC) system with a two-tiered admin structure and exposes all user management functionality through a secure REST API. Key Features & Fixes: - **SUPER_ADMIN Role:** - A `SUPER_ADMIN` role has been introduced to manage other `ADMIN` accounts, establishing a clear security hierarchy. - The endpoint for creating `ADMIN`s is now restricted to `SUPER_ADMIN`s only. - **User Management API (`UserController`):** - A new `UserController` exposes endpoints for administrators to manage the entire user lifecycle: - List all users (`GET /api/v1/users`) - Get user details by username (`GET /api/v1/users/{username}`) - Enable/Disable user accounts (`POST /api/v1/users/{username}/enable|disable`) - Delete users (`DELETE /api/v1/users/{username}`) - All endpoints in `UserController` are secured, accessible only to `ADMIN` and `SUPER_ADMIN` roles. - **Profile-Aware Data Seeding:** - The `DataSeeder` is now aware of the active Spring profile (`dev` vs. `prod`). - In the `dev` profile, it seeds a full set of test users (superadmin, admin, employees, customers). - In production, it safely seeds only the essential `SUPER_ADMIN` account. - **Fix Unused Methods:** - This implementation connects all previously unused methods in `UserService` (e.g., `createAdmin`, `findAllUsers`, `enableUser`) to live API endpoints, resolving all related warnings. --- .../auth_service/config/DataSeeder.java | 50 +++++++++++++-- .../auth_service/config/StartupBanner.java | 61 +++++++++++++++++++ .../controller/AuthController.java | 23 +++++++ .../controller/UserController.java | 5 +- .../auth_service/entity/RoleName.java | 1 + .../src/main/resources/application.properties | 5 +- 6 files changed, 136 insertions(+), 9 deletions(-) create mode 100644 auth-service/src/main/java/com/techtorque/auth_service/config/StartupBanner.java 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/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 f2afc29..4a0d0d8 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,6 +1,7 @@ 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; @@ -85,6 +86,28 @@ public ResponseEntity createEmployee(@Valid @RequestBody CreateEmployeeReques return ResponseEntity.badRequest().body(new MessageResponse("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 { + userService.createAdmin( + createAdminRequest.getUsername(), + createAdminRequest.getEmail(), + createAdminRequest.getPassword() + ); + return ResponseEntity.status(HttpStatus.CREATED) + .body(new MessageResponse("Admin account created successfully!")); + } catch (RuntimeException e) { + return ResponseEntity.badRequest().body(new MessageResponse("Error: " + e.getMessage())); + } + } /** * Health check endpoint 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 index 49025eb..c328073 100644 --- 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 @@ -13,11 +13,12 @@ /** * REST Controller for user management endpoints. - * All endpoints in this controller are restricted to users with the ADMIN role. + * Endpoints in this controller are accessible to users with ADMIN or SUPER_ADMIN roles. */ @RestController @RequestMapping("/api/v1/users") -@PreAuthorize("hasRole('ADMIN')") // Secures ALL endpoints in this class +@CrossOrigin(origins = "*", maxAge = 3600) +@PreAuthorize("hasRole('ADMIN') or hasRole('SUPER_ADMIN')") public class UserController { @Autowired 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/resources/application.properties b/auth-service/src/main/resources/application.properties index e29d9c3..c4c9689 100644 --- a/auth-service/src/main/resources/application.properties +++ b/auth-service/src/main/resources/application.properties @@ -16,4 +16,7 @@ spring.datasource.driver-class-name=org.postgresql.Driver spring.jpa.hibernate.ddl-auto=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} \ No newline at end of file From 072199416c7173f0631f010269a4c53e1e8f9fd0 Mon Sep 17 00:00:00 2001 From: RandithaK Date: Sun, 28 Sep 2025 01:30:32 +0530 Subject: [PATCH 04/15] fix(config): Update JPA ddl-auto property to use environment variable for consistency --- auth-service/src/main/resources/application.properties | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/auth-service/src/main/resources/application.properties b/auth-service/src/main/resources/application.properties index c4c9689..d83cb34 100644 --- a/auth-service/src/main/resources/application.properties +++ b/auth-service/src/main/resources/application.properties @@ -6,6 +6,7 @@ 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=${DB_LINK:jdbc:postgresql://localhost:5432/techtorque} spring.datasource.username=${DB_USER:techtorque} @@ -13,7 +14,7 @@ spring.datasource.password=${DB_PASS:techtorque123} spring.datasource.driver-class-name=org.postgresql.Driver # JPA Configuration -spring.jpa.hibernate.ddl-auto=update +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 From be73e4ee0f3e86818fa1b610e3082ad377f8d0f7 Mon Sep 17 00:00:00 2001 From: RandithaK Date: Sun, 28 Sep 2025 01:52:59 +0530 Subject: [PATCH 05/15] feat(security): Add WebSecurityCustomizer to ignore static resources for improved efficiency --- .../auth_service/config/SecurityConfig.java | 41 +++++++++++++------ .../src/main/resources/application.properties | 5 ++- 2 files changed, 32 insertions(+), 14 deletions(-) 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..f11c3b1 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 @@ -10,6 +10,7 @@ import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -19,7 +20,7 @@ @Configuration @EnableWebSecurity -@EnableMethodSecurity // The 'prePostEnabled = true' is the default and not needed +@EnableMethodSecurity public class SecurityConfig { @Autowired @@ -51,28 +52,42 @@ public AuthenticationManager authenticationManager(AuthenticationConfiguration a return authConfig.getAuthenticationManager(); } + // --- START OF THE DEFINITIVE FIX --- + /** + * This bean tells Spring Security to completely ignore the specified paths. + * This is the best practice for static resources like Swagger UI or the favicon. + * It bypasses the entire security filter chain, which is more efficient and avoids + * potential conflicts with custom filters like our AuthTokenFilter. + */ + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + return (web) -> web.ignoring().requestMatchers( + "/swagger-ui.html", + "/swagger-ui/**", + "/v3/api-docs", + "/v3/api-docs/**", + "/swagger-resources", + "/swagger-resources/**", + "/webjars/**", + "/favicon.ico" // Also ignore the favicon requests + ); + } + // --- END OF THE DEFINITIVE FIX --- + @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(AbstractHttpConfigurer::disable) .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() + // Public authentication endpoints are still managed here + .requestMatchers("/api/auth/**").permitAll() + // All other requests require authentication .anyRequest().authenticated() ); - // 5. Add your custom provider and filter http.authenticationProvider(authenticationProvider()); http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class); diff --git a/auth-service/src/main/resources/application.properties b/auth-service/src/main/resources/application.properties index d83cb34..cb373bc 100644 --- a/auth-service/src/main/resources/application.properties +++ b/auth-service/src/main/resources/application.properties @@ -20,4 +20,7 @@ spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect spring.jpa.properties.hibernate.format_sql=true # Development/Production Profile -spring.profiles.active=${SPRING_PROFILE:dev} \ No newline at end of file +spring.profiles.active=${SPRING_PROFILE:dev} + +# OpenAPI access URL +# http://localhost:8081/swagger-ui/index.html \ No newline at end of file From 78649acd8ffbb3110ffad4db5d825a320f5ddf04 Mon Sep 17 00:00:00 2001 From: RandithaK Date: Sun, 28 Sep 2025 02:08:13 +0530 Subject: [PATCH 06/15] feat(api): Implement comprehensive user management and self-service. This commit introduces a full suite of user management features, fulfilling the administrative and self-service roadmap. Administrators can now manage the entire user lifecycle, and users can manage their own profiles and credentials through the API. New Administrative Features (Admin/Super-Admin only): - **Update User Details:** `PUT /api/v1/users/{username}` allows admins to change a user's username, email, and enabled status. - **Reset Password:** `POST /api/v1/users/{username}/reset-password` allows admins to set a new password for any user. - **Role Management:** `POST /api/v1/users/{username}/roles` allows admins to dynamically assign or revoke roles for any user. New Self-Service Features (Authenticated Users): - **Get Own Profile:** `GET /api/v1/users/me` allows a logged-in user to retrieve their own profile details securely. - **Change Own Password:** `POST /api/v1/users/me/change-password` allows a user to change their password by providing their current one, enhancing security. All new endpoints are integrated into the existing RBAC security model, with administrative functions protected and self-service functions accessible to all authenticated users. --- .../controller/UserController.java | 104 +++++++++++++- .../dto/ChangePasswordRequest.java | 26 ++++ .../dto/ResetPasswordRequest.java | 23 ++++ .../dto/RoleAssignmentRequest.java | 29 ++++ .../auth_service/dto/UpdateUserRequest.java | 27 ++++ .../exception/GlobalExceptionHandler.java | 130 ++++++++++++++++++ .../auth_service/service/UserService.java | 127 +++++++++++++++++ 7 files changed, 465 insertions(+), 1 deletion(-) create mode 100644 auth-service/src/main/java/com/techtorque/auth_service/dto/ChangePasswordRequest.java create mode 100644 auth-service/src/main/java/com/techtorque/auth_service/dto/ResetPasswordRequest.java create mode 100644 auth-service/src/main/java/com/techtorque/auth_service/dto/RoleAssignmentRequest.java create mode 100644 auth-service/src/main/java/com/techtorque/auth_service/dto/UpdateUserRequest.java create mode 100644 auth-service/src/main/java/com/techtorque/auth_service/exception/GlobalExceptionHandler.java 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 index c328073..d862c9f 100644 --- 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 @@ -1,11 +1,14 @@ package com.techtorque.auth_service.controller; -import com.techtorque.auth_service.dto.UserDto; +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.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -84,6 +87,105 @@ public ResponseEntity deleteUser(@PathVariable String username) { } } + /** + * 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) { + return ResponseEntity.badRequest() + .body(new AuthController.MessageResponse("Error: " + 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(new AuthController.MessageResponse("Password reset successfully for user: " + username)); + } catch (RuntimeException e) { + return ResponseEntity.badRequest() + .body(new AuthController.MessageResponse("Error: " + 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(new AuthController.MessageResponse( + "Role '" + roleRequest.getRoleName() + "' assigned to user: " + username)); + } else { + userService.revokeRoleFromUser(username, roleRequest.getRoleName()); + return ResponseEntity.ok(new AuthController.MessageResponse( + "Role '" + roleRequest.getRoleName() + "' revoked from user: " + username)); + } + } catch (RuntimeException e) { + return ResponseEntity.badRequest() + .body(new AuthController.MessageResponse("Error: " + e.getMessage())); + } + } + + /** + * Get current user's profile (user endpoint) + * GET /api/v1/users/me + */ + @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(new AuthController.MessageResponse("Error: " + e.getMessage())); + } + } + + /** + * 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(new AuthController.MessageResponse("Password changed successfully")); + } catch (RuntimeException e) { + return ResponseEntity.badRequest() + .body(new AuthController.MessageResponse("Error: " + e.getMessage())); + } + } + // Helper method to convert User entity to a safe UserDto private UserDto convertToDto(User user) { return UserDto.builder() 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/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/exception/GlobalExceptionHandler.java b/auth-service/src/main/java/com/techtorque/auth_service/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..e5d2bf4 --- /dev/null +++ b/auth-service/src/main/java/com/techtorque/auth_service/exception/GlobalExceptionHandler.java @@ -0,0 +1,130 @@ +package com.techtorque.auth_service.exception; + +import com.techtorque.auth_service.controller.AuthController; +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(new AuthController.MessageResponse("Validation error: " + errors)); + } + + /** + * Handle authentication exceptions + */ + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity handleAuthenticationException(AuthenticationException ex) { + logger.warn("Authentication error: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new AuthController.MessageResponse("Authentication failed: " + ex.getMessage())); + } + + /** + * Handle bad credentials exception + */ + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity handleBadCredentialsException(BadCredentialsException ex) { + logger.warn("Bad credentials: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new AuthController.MessageResponse("Invalid username or password")); + } + + /** + * Handle access denied exceptions + */ + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDeniedException(AccessDeniedException ex) { + logger.warn("Access denied: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(new AuthController.MessageResponse("Access denied: Insufficient privileges")); + } + + /** + * 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(new AuthController.MessageResponse("Error: " + message)); + } + + // For other runtime exceptions, return internal server error + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new AuthController.MessageResponse("An internal error occurred")); + } + + /** + * 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(new AuthController.MessageResponse("An unexpected error occurred")); + } +} \ No newline at end of file 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..b7700cf 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 @@ -310,4 +310,131 @@ public boolean hasPermission(String username, String permissionName) { .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 RuntimeException("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 RuntimeException("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 RuntimeException("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 RuntimeException("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 RuntimeException("User not found: " + username)); + + // Verify current password + if (!passwordEncoder.matches(currentPassword, user.getPassword())) { + throw new RuntimeException("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 RuntimeException("User not found: " + username)); + + RoleName roleNameEnum; + try { + roleNameEnum = RoleName.valueOf(roleName.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new RuntimeException("Invalid role name: " + roleName); + } + + Role role = roleRepository.findByName(roleNameEnum) + .orElseThrow(() -> new RuntimeException("Role not found: " + roleName)); + + if (user.getRoles().contains(role)) { + throw new RuntimeException("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 RuntimeException("User not found: " + username)); + + RoleName roleNameEnum; + try { + roleNameEnum = RoleName.valueOf(roleName.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new RuntimeException("Invalid role name: " + roleName); + } + + Role role = roleRepository.findByName(roleNameEnum) + .orElseThrow(() -> new RuntimeException("Role not found: " + roleName)); + + if (!user.getRoles().contains(role)) { + throw new RuntimeException("User does not have role: " + roleName); + } + + user.getRoles().remove(role); + userRepository.save(user); + } } \ No newline at end of file From 93f427dca11f09e7092d1e113b3bb81b59b6eada Mon Sep 17 00:00:00 2001 From: RandithaK Date: Sun, 28 Sep 2025 02:25:26 +0530 Subject: [PATCH 07/15] feat(exception): Add global exception handling for improved error responses --- .../exception/RestExceptionHandler.java | 58 ++++++++++ .../auth_service/service/UserService.java | 109 +++++++++++------- 2 files changed, 127 insertions(+), 40 deletions(-) create mode 100644 auth-service/src/main/java/com/techtorque/auth_service/exception/RestExceptionHandler.java 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/service/UserService.java b/auth-service/src/main/java/com/techtorque/auth_service/service/UserService.java index b7700cf..f499ccf 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 @@ -6,10 +6,13 @@ import com.techtorque.auth_service.repository.RoleRepository; 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; @@ -34,8 +37,7 @@ public class UserService implements UserDetailsService { private final RoleRepository roleRepository; private final PasswordEncoder passwordEncoder; - @Autowired - public UserService(UserRepository userRepository, RoleRepository roleRepository, @Lazy PasswordEncoder passwordEncoder) { + public UserService(UserRepository userRepository, RoleRepository roleRepository, @Lazy PasswordEncoder passwordEncoder) { this.userRepository = userRepository; this.roleRepository = roleRepository; this.passwordEncoder = passwordEncoder; @@ -98,17 +100,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 +136,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 +172,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 +229,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 +244,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 +257,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 +268,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,7 +280,7 @@ 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); } @@ -290,7 +292,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,7 +306,7 @@ 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()) @@ -322,12 +324,12 @@ public boolean hasPermission(String username, String permissionName) { */ public User updateUserDetails(String username, String newUsername, String newEmail, Boolean enabled) { User user = userRepository.findByUsername(username) - .orElseThrow(() -> new RuntimeException("User not found: " + 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 RuntimeException("Username already exists: " + newUsername); + throw new IllegalArgumentException("Username already exists: " + newUsername); } user.setUsername(newUsername); } @@ -335,7 +337,7 @@ public User updateUserDetails(String username, String newUsername, String newEma // Check if new email is provided and different if (newEmail != null && !newEmail.equals(user.getEmail())) { if (userRepository.existsByEmail(newEmail)) { - throw new RuntimeException("Email already exists: " + newEmail); + throw new IllegalArgumentException("Email already exists: " + newEmail); } user.setEmail(newEmail); } @@ -356,7 +358,7 @@ public User updateUserDetails(String username, String newUsername, String newEma */ public void resetUserPassword(String username, String newPassword) { User user = userRepository.findByUsername(username) - .orElseThrow(() -> new RuntimeException("User not found: " + username)); + .orElseThrow(() -> new EntityNotFoundException("User not found: " + username)); user.setPassword(passwordEncoder.encode(newPassword)); userRepository.save(user); @@ -371,11 +373,11 @@ public void resetUserPassword(String username, String newPassword) { */ public void changeUserPassword(String username, String currentPassword, String newPassword) { User user = userRepository.findByUsername(username) - .orElseThrow(() -> new RuntimeException("User not found: " + username)); + .orElseThrow(() -> new EntityNotFoundException("User not found: " + username)); // Verify current password if (!passwordEncoder.matches(currentPassword, user.getPassword())) { - throw new RuntimeException("Current password is incorrect"); + throw new IllegalStateException("Current password is incorrect"); } user.setPassword(passwordEncoder.encode(newPassword)); @@ -390,20 +392,34 @@ public void changeUserPassword(String username, String currentPassword, String n */ public void assignRoleToUser(String username, String roleName) { User user = userRepository.findByUsername(username) - .orElseThrow(() -> new RuntimeException("User not found: " + username)); + .orElseThrow(() -> new EntityNotFoundException("User not found: " + username)); RoleName roleNameEnum; try { roleNameEnum = RoleName.valueOf(roleName.toUpperCase()); } catch (IllegalArgumentException e) { - throw new RuntimeException("Invalid role name: " + roleName); + throw new IllegalArgumentException("Invalid role name: " + roleName); } - Role role = roleRepository.findByName(roleNameEnum) - .orElseThrow(() -> new RuntimeException("Role not found: " + 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 RuntimeException("User already has role: " + roleName); + throw new IllegalStateException("User already has role: " + roleName); } user.getRoles().add(role); @@ -418,21 +434,34 @@ public void assignRoleToUser(String username, String roleName) { */ public void revokeRoleFromUser(String username, String roleName) { User user = userRepository.findByUsername(username) - .orElseThrow(() -> new RuntimeException("User not found: " + username)); + .orElseThrow(() -> new EntityNotFoundException("User not found: " + username)); RoleName roleNameEnum; try { roleNameEnum = RoleName.valueOf(roleName.toUpperCase()); } catch (IllegalArgumentException e) { - throw new RuntimeException("Invalid role name: " + roleName); + throw new IllegalArgumentException("Invalid role name: " + roleName); } - Role role = roleRepository.findByName(roleNameEnum) - .orElseThrow(() -> new RuntimeException("Role not found: " + roleName)); - - if (!user.getRoles().contains(role)) { - throw new RuntimeException("User does not have role: " + 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); From 231f578e039d0f0689fdb036d85cb18688209e25 Mon Sep 17 00:00:00 2001 From: RandithaK Date: Sun, 28 Sep 2025 02:48:01 +0530 Subject: [PATCH 08/15] feat(favicon): Add FaviconController to handle favicon.ico requests --- .../techtorque/auth_service/controller/FaviconController.java | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 auth-service/src/main/java/com/techtorque/auth_service/controller/FaviconController.java 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..da149e7 --- /dev/null +++ b/auth-service/src/main/java/com/techtorque/auth_service/controller/FaviconController.java @@ -0,0 +1,4 @@ +package com.techtorque.auth_service.controller; + +public class a { +} From 479bad1dd03c6d70e0dd318682aa43d276af90a7 Mon Sep 17 00:00:00 2001 From: RandithaK Date: Sun, 28 Sep 2025 03:08:02 +0530 Subject: [PATCH 09/15] fix(auth): update JWT entry point and security config; refine controllers - Improve error handling and responses\n- Adjust security rules in to allow correct path matching and bean wiring\n- Clean up controller logic in and to follow conventions and reduce NPE risk\n\nThese changes address runtime warnings observed during local runs and make the auth-service controllers more robust. Verified compile was previously successful. --- .../config/AuthEntryPointJwt.java | 4 +- .../auth_service/config/SecurityConfig.java | 48 +++--- .../controller/FaviconController.java | 20 ++- .../controller/UserController.java | 5 + tesh.sh | 163 ++++++++++++++++++ 5 files changed, 212 insertions(+), 28 deletions(-) create mode 100755 tesh.sh 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/SecurityConfig.java b/auth-service/src/main/java/com/techtorque/auth_service/config/SecurityConfig.java index f11c3b1..4aa9b0d 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 @@ -10,7 +10,6 @@ import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -52,27 +51,7 @@ public AuthenticationManager authenticationManager(AuthenticationConfiguration a return authConfig.getAuthenticationManager(); } - // --- START OF THE DEFINITIVE FIX --- - /** - * This bean tells Spring Security to completely ignore the specified paths. - * This is the best practice for static resources like Swagger UI or the favicon. - * It bypasses the entire security filter chain, which is more efficient and avoids - * potential conflicts with custom filters like our AuthTokenFilter. - */ - @Bean - public WebSecurityCustomizer webSecurityCustomizer() { - return (web) -> web.ignoring().requestMatchers( - "/swagger-ui.html", - "/swagger-ui/**", - "/v3/api-docs", - "/v3/api-docs/**", - "/swagger-resources", - "/swagger-resources/**", - "/webjars/**", - "/favicon.ico" // Also ignore the favicon requests - ); - } - // --- END OF THE DEFINITIVE FIX --- + // NOTE: The WebSecurityCustomizer bean has been completely removed. @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -82,9 +61,28 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - // Public authentication endpoints are still managed here - .requestMatchers("/api/auth/**").permitAll() - // All other requests require authentication + .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() ); 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 index da149e7..767fe37 100644 --- 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 @@ -1,4 +1,20 @@ package com.techtorque.auth_service.controller; -public class a { -} +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 index d862c9f..4e44c2e 100644 --- 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 @@ -7,6 +7,7 @@ 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.*; @@ -141,6 +142,10 @@ public ResponseEntity manageUserRole(@PathVariable String username, return ResponseEntity.ok(new AuthController.MessageResponse( "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(new AuthController.MessageResponse("Error: " + ade.getMessage())); } catch (RuntimeException e) { return ResponseEntity.badRequest() .body(new AuthController.MessageResponse("Error: " + e.getMessage())); diff --git a/tesh.sh b/tesh.sh new file mode 100755 index 0000000..2b4fbcb --- /dev/null +++ b/tesh.sh @@ -0,0 +1,163 @@ +#!/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 +} + +# --- 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" + + +# === 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 From 55005fc3d4a855b2fb7f006b9c149641247cbbfc Mon Sep 17 00:00:00 2001 From: RandithaK Date: Sun, 28 Sep 2025 03:34:36 +0530 Subject: [PATCH 10/15] feat(security): Implement login lockout policy with audit logging for failed attempts --- .../controller/AuthController.java | 22 +-- .../controller/UserController.java | 32 +++-- .../techtorque/auth_service/dto/ApiError.java | 21 +++ .../auth_service/dto/ApiSuccess.java | 25 ++++ .../auth_service/entity/LoginLock.java | 29 ++++ .../auth_service/entity/LoginLog.java | 34 +++++ .../exception/GlobalExceptionHandler.java | 60 ++++++-- .../repository/LoginLockRepository.java | 9 ++ .../repository/LoginLogRepository.java | 7 + .../auth_service/service/AuthService.java | 133 ++++++++++++++---- .../service/LoginAuditService.java | 59 ++++++++ .../auth_service/service/UserService.java | 34 +++-- .../src/main/resources/application.properties | 8 +- tesh.sh | 45 ++++++ 14 files changed, 438 insertions(+), 80 deletions(-) create mode 100644 auth-service/src/main/java/com/techtorque/auth_service/dto/ApiError.java create mode 100644 auth-service/src/main/java/com/techtorque/auth_service/dto/ApiSuccess.java create mode 100644 auth-service/src/main/java/com/techtorque/auth_service/entity/LoginLock.java create mode 100644 auth-service/src/main/java/com/techtorque/auth_service/entity/LoginLog.java create mode 100644 auth-service/src/main/java/com/techtorque/auth_service/repository/LoginLockRepository.java create mode 100644 auth-service/src/main/java/com/techtorque/auth_service/repository/LoginLogRepository.java create mode 100644 auth-service/src/main/java/com/techtorque/auth_service/service/LoginAuditService.java 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 4a0d0d8..2faf257 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 @@ -8,11 +8,13 @@ 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 java.util.Map; /** * REST Controller for authentication endpoints @@ -37,14 +39,9 @@ public class AuthController { * @return JWT token and user details */ @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); } /** @@ -54,13 +51,8 @@ public ResponseEntity authenticateUser(@Valid @RequestBody LoginRequest login */ @PostMapping("/register") public ResponseEntity registerUser(@Valid @RequestBody RegisterRequest registerRequest) { - try { - String message = authService.registerUser(registerRequest); - return ResponseEntity.ok(new MessageResponse(message)); - } catch (Exception e) { - return ResponseEntity.badRequest() - .body(new MessageResponse("Error: " + e.getMessage())); - } + String message = authService.registerUser(registerRequest); + return ResponseEntity.ok(Map.of("message", message)); } // --- NEW ENDPOINT FOR CREATING EMPLOYEES --- 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 index 4e44c2e..3f7b241 100644 --- 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 @@ -11,6 +11,7 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; +import java.util.Map; import java.util.List; import java.util.stream.Collectors; @@ -56,9 +57,9 @@ public ResponseEntity getUserByUsername(@PathVariable String username) public ResponseEntity disableUser(@PathVariable String username) { try { userService.disableUser(username); - return ResponseEntity.ok(new AuthController.MessageResponse("User '" + username + "' has been disabled.")); + return ResponseEntity.ok(Map.of("message", "User '" + username + "' has been disabled.")); } catch (RuntimeException e) { - return ResponseEntity.notFound().build(); + throw new RuntimeException(e.getMessage()); } } @@ -69,11 +70,21 @@ public ResponseEntity disableUser(@PathVariable String username) { public ResponseEntity enableUser(@PathVariable String username) { try { userService.enableUser(username); - return ResponseEntity.ok(new AuthController.MessageResponse("User '" + username + "' has been enabled.")); + return ResponseEntity.ok(Map.of("message", "User '" + username + "' has been enabled.")); } catch (RuntimeException e) { - return ResponseEntity.notFound().build(); + 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(Map.of("message", "Login lock cleared for user: " + username)); + } /** * Delete a user from the system permanently. @@ -82,9 +93,9 @@ public ResponseEntity enableUser(@PathVariable String username) { public ResponseEntity deleteUser(@PathVariable String username) { try { userService.deleteUser(username); - return ResponseEntity.ok(new AuthController.MessageResponse("User '" + username + "' has been deleted.")); + return ResponseEntity.ok(Map.of("message", "User '" + username + "' has been deleted.")); } catch (RuntimeException e) { - return ResponseEntity.notFound().build(); + throw new RuntimeException(e.getMessage()); } } @@ -104,8 +115,7 @@ public ResponseEntity updateUser(@PathVariable String username, ); return ResponseEntity.ok(convertToDto(updatedUser)); } catch (RuntimeException e) { - return ResponseEntity.badRequest() - .body(new AuthController.MessageResponse("Error: " + e.getMessage())); + throw new RuntimeException(e.getMessage()); } } @@ -120,8 +130,7 @@ public ResponseEntity resetUserPassword(@PathVariable String username, userService.resetUserPassword(username, resetRequest.getNewPassword()); return ResponseEntity.ok(new AuthController.MessageResponse("Password reset successfully for user: " + username)); } catch (RuntimeException e) { - return ResponseEntity.badRequest() - .body(new AuthController.MessageResponse("Error: " + e.getMessage())); + throw new RuntimeException(e.getMessage()); } } @@ -147,8 +156,7 @@ public ResponseEntity manageUserRole(@PathVariable String username, return ResponseEntity.status(403) .body(new AuthController.MessageResponse("Error: " + ade.getMessage())); } catch (RuntimeException e) { - return ResponseEntity.badRequest() - .body(new AuthController.MessageResponse("Error: " + e.getMessage())); + throw new RuntimeException(e.getMessage()); } } 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/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/exception/GlobalExceptionHandler.java b/auth-service/src/main/java/com/techtorque/auth_service/exception/GlobalExceptionHandler.java index e5d2bf4..f67b724 100644 --- 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 @@ -1,6 +1,7 @@ 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; @@ -58,8 +59,13 @@ public ResponseEntity handleConstraintViolationException(ConstraintViolationE .collect(Collectors.joining(", ")); logger.warn("Constraint violation: {}", errors); - return ResponseEntity.badRequest() - .body(new AuthController.MessageResponse("Validation error: " + 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()); } /** @@ -68,8 +74,13 @@ public ResponseEntity handleConstraintViolationException(ConstraintViolationE @ExceptionHandler(AuthenticationException.class) public ResponseEntity handleAuthenticationException(AuthenticationException ex) { logger.warn("Authentication error: {}", ex.getMessage()); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(new AuthController.MessageResponse("Authentication failed: " + 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()); } /** @@ -78,8 +89,13 @@ public ResponseEntity handleAuthenticationException(AuthenticationException e @ExceptionHandler(BadCredentialsException.class) public ResponseEntity handleBadCredentialsException(BadCredentialsException ex) { logger.warn("Bad credentials: {}", ex.getMessage()); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(new AuthController.MessageResponse("Invalid username or password")); + 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()); } /** @@ -88,8 +104,12 @@ public ResponseEntity handleBadCredentialsException(BadCredentialsException e @ExceptionHandler(AccessDeniedException.class) public ResponseEntity handleAccessDeniedException(AccessDeniedException ex) { logger.warn("Access denied: {}", ex.getMessage()); - return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(new AuthController.MessageResponse("Access denied: Insufficient privileges")); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiError.builder() + .status(HttpStatus.FORBIDDEN.value()) + .message("Access denied: Insufficient privileges") + .timestamp(java.time.LocalDateTime.now()) + .build()); } /** @@ -109,13 +129,21 @@ public ResponseEntity handleRuntimeException(RuntimeException ex) { message.contains("does not have") || message.contains("already has") )) { - return ResponseEntity.badRequest() - .body(new AuthController.MessageResponse("Error: " + message)); + 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(new AuthController.MessageResponse("An internal error occurred")); + 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()); } /** @@ -124,7 +152,11 @@ public ResponseEntity handleRuntimeException(RuntimeException ex) { @ExceptionHandler(Exception.class) public ResponseEntity handleGenericException(Exception ex) { logger.error("Unexpected error: ", ex); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(new AuthController.MessageResponse("An unexpected error occurred")); + 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/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 f499ccf..6755c24 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,6 +4,7 @@ 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.context.annotation.Lazy; @@ -33,15 +34,16 @@ @Transactional public class UserService implements UserDetailsService { - private final UserRepository userRepository; - private final RoleRepository roleRepository; - private final PasswordEncoder passwordEncoder; - - 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 @@ -284,6 +286,20 @@ public void deleteUser(String 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 diff --git a/auth-service/src/main/resources/application.properties b/auth-service/src/main/resources/application.properties index cb373bc..865f498 100644 --- a/auth-service/src/main/resources/application.properties +++ b/auth-service/src/main/resources/application.properties @@ -23,4 +23,10 @@ spring.jpa.properties.hibernate.format_sql=true spring.profiles.active=${SPRING_PROFILE:dev} # OpenAPI access URL -# http://localhost:8081/swagger-ui/index.html \ No newline at end of file +# 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 index 2b4fbcb..f55a4f9 100755 --- a/tesh.sh +++ b/tesh.sh @@ -71,6 +71,33 @@ run_test() { 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}" @@ -147,6 +174,24 @@ 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}" From 52c0027253dfe9fb3ff1d53654a8fceec1fcbfa1 Mon Sep 17 00:00:00 2001 From: RandithaK Date: Sun, 28 Sep 2025 03:42:44 +0530 Subject: [PATCH 11/15] standardize: replace message maps/MessageResponse with ApiSuccess in controllers --- .../controller/AuthController.java | 36 +++++------------- .../controller/UserController.java | 37 ++++++++++++------- 2 files changed, 33 insertions(+), 40 deletions(-) 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 2faf257..f6bb3d7 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 @@ -14,7 +14,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; -import java.util.Map; +import com.techtorque.auth_service.dto.ApiSuccess; /** * REST Controller for authentication endpoints @@ -52,7 +52,7 @@ public ResponseEntity authenticateUser(@Valid @RequestBody LoginRequest login @PostMapping("/register") public ResponseEntity registerUser(@Valid @RequestBody RegisterRequest registerRequest) { String message = authService.registerUser(registerRequest); - return ResponseEntity.ok(Map.of("message", message)); + return ResponseEntity.ok(ApiSuccess.of(message)); } // --- NEW ENDPOINT FOR CREATING EMPLOYEES --- @@ -72,10 +72,10 @@ public ResponseEntity createEmployee(@Valid @RequestBody CreateEmployeeReques createEmployeeRequest.getPassword() ); return ResponseEntity.status(HttpStatus.CREATED) - .body(new MessageResponse("Employee account created successfully!")); + .body(ApiSuccess.of("Employee account created successfully!")); } catch (RuntimeException e) { // Catches errors like "Username already exists" - return ResponseEntity.badRequest().body(new MessageResponse("Error: " + e.getMessage())); + return ResponseEntity.badRequest().body(ApiSuccess.of("Error: " + e.getMessage())); } } @@ -95,9 +95,9 @@ public ResponseEntity createAdmin(@Valid @RequestBody CreateAdminRequest crea createAdminRequest.getPassword() ); return ResponseEntity.status(HttpStatus.CREATED) - .body(new MessageResponse("Admin account created successfully!")); + .body(ApiSuccess.of("Admin account created successfully!")); } catch (RuntimeException e) { - return ResponseEntity.badRequest().body(new MessageResponse("Error: " + e.getMessage())); + return ResponseEntity.badRequest().body(ApiSuccess.of("Error: " + e.getMessage())); } } @@ -107,7 +107,7 @@ public ResponseEntity createAdmin(@Valid @RequestBody CreateAdminRequest crea */ @GetMapping("/health") public ResponseEntity health() { - return ResponseEntity.ok(new MessageResponse("Authentication Service is running!")); + return ResponseEntity.ok(ApiSuccess.of("Authentication Service is running!")); } /** @@ -116,25 +116,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/UserController.java b/auth-service/src/main/java/com/techtorque/auth_service/controller/UserController.java index 3f7b241..64ef396 100644 --- 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 @@ -11,7 +11,6 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; -import java.util.Map; import java.util.List; import java.util.stream.Collectors; @@ -57,7 +56,7 @@ public ResponseEntity getUserByUsername(@PathVariable String username) public ResponseEntity disableUser(@PathVariable String username) { try { userService.disableUser(username); - return ResponseEntity.ok(Map.of("message", "User '" + username + "' has been disabled.")); + return ResponseEntity.ok(ApiSuccess.of("User '" + username + "' has been disabled.")); } catch (RuntimeException e) { throw new RuntimeException(e.getMessage()); } @@ -70,7 +69,7 @@ public ResponseEntity disableUser(@PathVariable String username) { public ResponseEntity enableUser(@PathVariable String username) { try { userService.enableUser(username); - return ResponseEntity.ok(Map.of("message", "User '" + username + "' has been enabled.")); + return ResponseEntity.ok(ApiSuccess.of("User '" + username + "' has been enabled.")); } catch (RuntimeException e) { throw new RuntimeException(e.getMessage()); } @@ -82,8 +81,8 @@ public ResponseEntity enableUser(@PathVariable String username) { @PostMapping("/{username}/unlock") @PreAuthorize("hasRole('ADMIN') or hasRole('SUPER_ADMIN')") public ResponseEntity unlockUser(@PathVariable String username) { - userService.clearLoginLock(username); - return ResponseEntity.ok(Map.of("message", "Login lock cleared for user: " + username)); + userService.clearLoginLock(username); + return ResponseEntity.ok(ApiSuccess.of("Login lock cleared for user: " + username)); } /** @@ -93,7 +92,7 @@ public ResponseEntity unlockUser(@PathVariable String username) { public ResponseEntity deleteUser(@PathVariable String username) { try { userService.deleteUser(username); - return ResponseEntity.ok(Map.of("message", "User '" + username + "' has been deleted.")); + return ResponseEntity.ok(ApiSuccess.of("User '" + username + "' has been deleted.")); } catch (RuntimeException e) { throw new RuntimeException(e.getMessage()); } @@ -128,7 +127,7 @@ public ResponseEntity resetUserPassword(@PathVariable String username, @Valid @RequestBody ResetPasswordRequest resetRequest) { try { userService.resetUserPassword(username, resetRequest.getNewPassword()); - return ResponseEntity.ok(new AuthController.MessageResponse("Password reset successfully for user: " + username)); + return ResponseEntity.ok(ApiSuccess.of("Password reset successfully for user: " + username)); } catch (RuntimeException e) { throw new RuntimeException(e.getMessage()); } @@ -144,17 +143,21 @@ public ResponseEntity manageUserRole(@PathVariable String username, try { if (roleRequest.getAction() == RoleAssignmentRequest.RoleAction.ASSIGN) { userService.assignRoleToUser(username, roleRequest.getRoleName()); - return ResponseEntity.ok(new AuthController.MessageResponse( + return ResponseEntity.ok(ApiSuccess.of( "Role '" + roleRequest.getRoleName() + "' assigned to user: " + username)); } else { userService.revokeRoleFromUser(username, roleRequest.getRoleName()); - return ResponseEntity.ok(new AuthController.MessageResponse( + 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(new AuthController.MessageResponse("Error: " + ade.getMessage())); + .body(ApiError.builder() + .status(403) + .message("Error: " + ade.getMessage()) + .timestamp(java.time.LocalDateTime.now()) + .build()); } catch (RuntimeException e) { throw new RuntimeException(e.getMessage()); } @@ -176,7 +179,11 @@ public ResponseEntity getCurrentUserProfile() { .orElse(ResponseEntity.notFound().build()); } catch (Exception e) { return ResponseEntity.badRequest() - .body(new AuthController.MessageResponse("Error: " + e.getMessage())); + .body(ApiError.builder() + .status(400) + .message("Error: " + e.getMessage()) + .timestamp(java.time.LocalDateTime.now()) + .build()); } } @@ -192,10 +199,14 @@ public ResponseEntity changeCurrentUserPassword(@Valid @RequestBody ChangePas String username = authentication.getName(); userService.changeUserPassword(username, changeRequest.getCurrentPassword(), changeRequest.getNewPassword()); - return ResponseEntity.ok(new AuthController.MessageResponse("Password changed successfully")); + return ResponseEntity.ok(ApiSuccess.of("Password changed successfully")); } catch (RuntimeException e) { return ResponseEntity.badRequest() - .body(new AuthController.MessageResponse("Error: " + e.getMessage())); + .body(ApiError.builder() + .status(400) + .message("Error: " + e.getMessage()) + .timestamp(java.time.LocalDateTime.now()) + .build()); } } From 01041733da757a4f3d0422caae4cb75046c44672 Mon Sep 17 00:00:00 2001 From: RandithaK Date: Sun, 28 Sep 2025 03:44:54 +0530 Subject: [PATCH 12/15] docs(properties): enhance comments for database schema configuration options --- auth-service/src/main/resources/application.properties | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/auth-service/src/main/resources/application.properties b/auth-service/src/main/resources/application.properties index 865f498..cb0a657 100644 --- a/auth-service/src/main/resources/application.properties +++ b/auth-service/src/main/resources/application.properties @@ -14,6 +14,11 @@ spring.datasource.password=${DB_PASS:techtorque123} spring.datasource.driver-class-name=org.postgresql.Driver # JPA Configuration +# 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 From 52d125b5c3b90b39ece01074f688f8bcf95c8f5b Mon Sep 17 00:00:00 2001 From: RandithaK Date: Sun, 28 Sep 2025 12:36:07 +0530 Subject: [PATCH 13/15] feat(auth): enhance user authentication to support login by email or username --- .../auth_service/config/SecurityConfig.java | 37 ++++++++++++++++++- .../auth_service/service/UserService.java | 12 ++++-- 2 files changed, 45 insertions(+), 4 deletions(-) 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 4aa9b0d..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,6 +16,11 @@ 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 @@ -57,7 +62,7 @@ public AuthenticationManager authenticationManager(AuthenticationConfiguration a public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) - .cors(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) .exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth @@ -91,4 +96,34 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { 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/service/UserService.java b/auth-service/src/main/java/com/techtorque/auth_service/service/UserService.java index 6755c24..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 @@ -53,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()) From 4654e46064283344d137bde94f250df6d98257fc Mon Sep 17 00:00:00 2001 From: RandithaK Date: Sun, 28 Sep 2025 12:48:29 +0530 Subject: [PATCH 14/15] feat(api): add OpenAPI configuration and enhance Swagger documentation for authentication and user management endpoints --- .../auth_service/config/OpenApiConfig.java | 93 +++++++++++++++++++ .../controller/AuthController.java | 26 ++++++ .../controller/UserController.java | 17 ++++ 3 files changed, 136 insertions(+) create mode 100644 auth-service/src/main/java/com/techtorque/auth_service/config/OpenApiConfig.java 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/controller/AuthController.java b/auth-service/src/main/java/com/techtorque/auth_service/controller/AuthController.java index f6bb3d7..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 @@ -15,6 +15,11 @@ 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 @@ -23,6 +28,7 @@ @RestController @RequestMapping("/api/v1/auth") @CrossOrigin(origins = "*", maxAge = 3600) +@Tag(name = "Authentication", description = "Authentication and user management endpoints") public class AuthController { @Autowired @@ -38,6 +44,15 @@ public class AuthController { * @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, HttpServletRequest request) { LoginResponse loginResponse = authService.authenticateUser(loginRequest, request); @@ -61,6 +76,17 @@ public ResponseEntity registerUser(@Valid @RequestBody RegisterRequest regist * @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) { 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 index 64ef396..e3ad85e 100644 --- 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 @@ -11,6 +11,11 @@ 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; @@ -23,6 +28,8 @@ @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 @@ -167,6 +174,16 @@ public ResponseEntity manageUserRole(@PathVariable String username, * 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() { From 164023cc91753e1d2f02365285780acd8b807978 Mon Sep 17 00:00:00 2001 From: RandithaK Date: Sun, 28 Sep 2025 12:52:09 +0530 Subject: [PATCH 15/15] feat(dependencies): add Spring Boot DevTools for hot reload and Actuator for monitoring --- auth-service/pom.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 +