diff --git a/.github/workflows/buildtest.yaml b/.github/workflows/buildtest.yaml index 1ef3db8..e29f5af 100644 --- a/.github/workflows/buildtest.yaml +++ b/.github/workflows/buildtest.yaml @@ -1,12 +1,11 @@ name: Build and Test Admin Service on: - push: - branches: - - '**' pull_request: branches: - - '**' + - main + - dev + - devOps jobs: build-test: diff --git a/admin-service/pom.xml b/admin-service/pom.xml index eaa958c..60ba627 100644 --- a/admin-service/pom.xml +++ b/admin-service/pom.xml @@ -46,6 +46,10 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + org.springframework.boot spring-boot-starter-validation @@ -54,6 +58,29 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-webflux + + + + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + org.springframework.boot diff --git a/admin-service/src/main/java/com/techtorque/admin_service/config/JwtAuthenticationFilter.java b/admin-service/src/main/java/com/techtorque/admin_service/config/JwtAuthenticationFilter.java new file mode 100644 index 0000000..592e152 --- /dev/null +++ b/admin-service/src/main/java/com/techtorque/admin_service/config/JwtAuthenticationFilter.java @@ -0,0 +1,82 @@ +package com.techtorque.admin_service.config; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import javax.crypto.SecretKey; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + @Value("${jwt.secret:mysecretkey}") + private String jwtSecret; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + + try { + SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)); + + Claims claims = Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + + String username = claims.getSubject(); + @SuppressWarnings("unchecked") + List roles = (List) claims.get("roles"); + + if (username != null && roles != null) { + List authorities = roles.stream() + .map(role -> { + String roleUpper = role.trim().toUpperCase(); + // Treat SUPER_ADMIN as ADMIN for authorization purposes + if ("SUPER_ADMIN".equals(roleUpper)) { + // Add both SUPER_ADMIN and ADMIN roles + return Arrays.asList( + new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"), + new SimpleGrantedAuthority("ROLE_ADMIN") + ); + } + return Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + roleUpper)); + }) + .flatMap(List::stream) + .collect(Collectors.toList()); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(username, null, authorities); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception e) { + logger.warn("JWT token validation failed: " + e.getMessage()); + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/admin-service/src/main/java/com/techtorque/admin_service/config/SecurityConfig.java b/admin-service/src/main/java/com/techtorque/admin_service/config/SecurityConfig.java index 7339bc9..c3c3706 100644 --- a/admin-service/src/main/java/com/techtorque/admin_service/config/SecurityConfig.java +++ b/admin-service/src/main/java/com/techtorque/admin_service/config/SecurityConfig.java @@ -1,5 +1,6 @@ package com.techtorque.admin_service.config; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; @@ -12,8 +13,11 @@ @Configuration @EnableWebSecurity @EnableMethodSecurity(prePostEnabled = true) +@RequiredArgsConstructor public class SecurityConfig { + private final JwtAuthenticationFilter jwtAuthenticationFilter; + // A more comprehensive whitelist for Swagger/OpenAPI, based on the auth-service config. private static final String[] SWAGGER_WHITELIST = { "/v3/api-docs/**", @@ -24,6 +28,11 @@ public class SecurityConfig { "/api-docs/**" }; + // Public endpoints accessible by all authenticated users (including CUSTOMER role) + private static final String[] PUBLIC_ENDPOINTS = { + "/public/**" + }; + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http @@ -42,12 +51,18 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // Permit all requests to the Swagger UI and API docs paths .requestMatchers(SWAGGER_WHITELIST).permitAll() + // Allow authenticated users to access public endpoints + .requestMatchers(PUBLIC_ENDPOINTS).authenticated() + // All other requests must be authenticated .anyRequest().authenticated() ) - // Add our custom filter to read headers from the Gateway - .addFilterBefore(new GatewayHeaderFilter(), UsernamePasswordAuthenticationFilter.class); + // Add JWT filter first (for direct service-to-service calls with JWT) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + + // Add our custom filter to read headers from the Gateway (for gateway-routed calls) + .addFilterAfter(new GatewayHeaderFilter(), JwtAuthenticationFilter.class); return http.build(); } diff --git a/admin-service/src/main/java/com/techtorque/admin_service/config/WebClientConfig.java b/admin-service/src/main/java/com/techtorque/admin_service/config/WebClientConfig.java index 531fc62..d1726fa 100644 --- a/admin-service/src/main/java/com/techtorque/admin_service/config/WebClientConfig.java +++ b/admin-service/src/main/java/com/techtorque/admin_service/config/WebClientConfig.java @@ -5,7 +5,12 @@ import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import reactor.core.publisher.Mono; +import jakarta.servlet.http.HttpServletRequest; /** * Configuration for WebClient to communicate with other microservices @@ -31,12 +36,35 @@ public class WebClientConfig { @Value("${services.vehicle.url:http://localhost:8082}") private String vehicleServiceUrl; + /** + * Exchange filter function to propagate JWT token from incoming request to outgoing WebClient calls + */ + private ExchangeFilterFunction jwtTokenPropagationFilter() { + return ExchangeFilterFunction.ofRequestProcessor(clientRequest -> { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes != null) { + HttpServletRequest request = attributes.getRequest(); + String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + return Mono.just( + org.springframework.web.reactive.function.client.ClientRequest + .from(clientRequest) + .header(HttpHeaders.AUTHORIZATION, authHeader) + .build() + ); + } + } + return Mono.just(clientRequest); + }); + } + @Bean(name = "authServiceWebClient") public WebClient authServiceWebClient(WebClient.Builder webClientBuilder) { return webClientBuilder .baseUrl(authServiceUrl) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .filter(jwtTokenPropagationFilter()) .build(); } @@ -46,6 +74,7 @@ public WebClient paymentServiceWebClient(WebClient.Builder webClientBuilder) { .baseUrl(paymentServiceUrl) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .filter(jwtTokenPropagationFilter()) .build(); } @@ -55,6 +84,7 @@ public WebClient appointmentServiceWebClient(WebClient.Builder webClientBuilder) .baseUrl(appointmentServiceUrl) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .filter(jwtTokenPropagationFilter()) .build(); } @@ -64,6 +94,7 @@ public WebClient projectServiceWebClient(WebClient.Builder webClientBuilder) { .baseUrl(projectServiceUrl) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .filter(jwtTokenPropagationFilter()) .build(); } @@ -73,6 +104,7 @@ public WebClient timeLoggingServiceWebClient(WebClient.Builder webClientBuilder) .baseUrl(timeLoggingServiceUrl) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .filter(jwtTokenPropagationFilter()) .build(); } @@ -82,6 +114,7 @@ public WebClient vehicleServiceWebClient(WebClient.Builder webClientBuilder) { .baseUrl(vehicleServiceUrl) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .filter(jwtTokenPropagationFilter()) .build(); } } diff --git a/admin-service/src/main/java/com/techtorque/admin_service/controller/PublicServiceTypeController.java b/admin-service/src/main/java/com/techtorque/admin_service/controller/PublicServiceTypeController.java new file mode 100644 index 0000000..2f0796f --- /dev/null +++ b/admin-service/src/main/java/com/techtorque/admin_service/controller/PublicServiceTypeController.java @@ -0,0 +1,43 @@ +package com.techtorque.admin_service.controller; + +import com.techtorque.admin_service.dto.response.ServiceTypeResponse; +import com.techtorque.admin_service.service.AdminServiceConfigService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * Public controller for service types - accessible by all authenticated users and other microservices + * This allows customers and other services to read active service types without admin privileges + */ +@RestController +@RequestMapping("/public/service-types") +@RequiredArgsConstructor +@Slf4j +@Tag(name = "Public Service Types", description = "Public API for accessing service types") +public class PublicServiceTypeController { + + private final AdminServiceConfigService adminServiceConfigService; + + @GetMapping + @Operation(summary = "Get all active service types", description = "Retrieve all active service types available to customers") + public ResponseEntity> getActiveServiceTypes() { + log.info("Public request: Fetching all active service types"); + List serviceTypes = adminServiceConfigService.getAllServiceTypes(true); // activeOnly = true + log.info("Retrieved {} active service types for public access", serviceTypes.size()); + return ResponseEntity.ok(serviceTypes); + } + + @GetMapping("/{id}") + @Operation(summary = "Get service type by ID", description = "Retrieve a specific service type by its ID") + public ResponseEntity getServiceTypeById(@PathVariable String id) { + log.info("Public request: Fetching service type with ID: {}", id); + ServiceTypeResponse serviceType = adminServiceConfigService.getServiceTypeById(id); + return ResponseEntity.ok(serviceType); + } +} diff --git a/admin-service/src/main/java/com/techtorque/admin_service/dto/request/UpdateUserRequest.java b/admin-service/src/main/java/com/techtorque/admin_service/dto/request/UpdateUserRequest.java index dd2584e..1f42e91 100644 --- a/admin-service/src/main/java/com/techtorque/admin_service/dto/request/UpdateUserRequest.java +++ b/admin-service/src/main/java/com/techtorque/admin_service/dto/request/UpdateUserRequest.java @@ -24,11 +24,24 @@ public class UpdateUserRequest { @Pattern(regexp = "ADMIN|EMPLOYEE|CUSTOMER", message = "Role must be ADMIN, EMPLOYEE, or CUSTOMER") private String role; + private List roles; + private Boolean active; + + // Alternative field name for activation status (frontend compatibility) + private Boolean enabled; @Size(max = 20, message = "Maximum 20 permissions allowed") private List permissions; @Size(max = 100) private String department; + + /** + * Get the activation status, checking both 'active' and 'enabled' fields + */ + public Boolean getActivationStatus() { + // Prioritize 'enabled' if set, otherwise use 'active' + return enabled != null ? enabled : active; + } } \ No newline at end of file diff --git a/admin-service/src/main/java/com/techtorque/admin_service/dto/response/ServiceTypeResponse.java b/admin-service/src/main/java/com/techtorque/admin_service/dto/response/ServiceTypeResponse.java index 16bf72f..5c00f2c 100644 --- a/admin-service/src/main/java/com/techtorque/admin_service/dto/response/ServiceTypeResponse.java +++ b/admin-service/src/main/java/com/techtorque/admin_service/dto/response/ServiceTypeResponse.java @@ -20,8 +20,8 @@ public class ServiceTypeResponse { private String name; private String description; private String category; - private BigDecimal price; - private Integer durationMinutes; + private BigDecimal basePriceLKR; // Changed from 'price' to match frontend + private Integer estimatedDurationMinutes; // Changed from 'durationMinutes' to match frontend private Boolean active; private Boolean requiresApproval; private Integer dailyCapacity; diff --git a/admin-service/src/main/java/com/techtorque/admin_service/service/impl/AdminServiceConfigServiceImpl.java b/admin-service/src/main/java/com/techtorque/admin_service/service/impl/AdminServiceConfigServiceImpl.java index 48a96f5..894b10b 100644 --- a/admin-service/src/main/java/com/techtorque/admin_service/service/impl/AdminServiceConfigServiceImpl.java +++ b/admin-service/src/main/java/com/techtorque/admin_service/service/impl/AdminServiceConfigServiceImpl.java @@ -121,9 +121,8 @@ public void deleteServiceType(String id, String deletedBy) { ServiceType serviceType = serviceTypeRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("Service type not found: " + id)); - // Soft delete - serviceType.setActive(false); - serviceTypeRepository.save(serviceType); + // Hard delete - actually remove from database + serviceTypeRepository.delete(serviceType); log.info("Service type deleted successfully: {}", id); } @@ -134,8 +133,8 @@ private ServiceTypeResponse convertToResponse(ServiceType serviceType) { .name(serviceType.getName()) .description(serviceType.getDescription()) .category(serviceType.getCategory()) - .price(serviceType.getPrice()) - .durationMinutes(serviceType.getDefaultDurationMinutes()) + .basePriceLKR(serviceType.getPrice()) + .estimatedDurationMinutes(serviceType.getDefaultDurationMinutes()) .active(serviceType.getActive()) .requiresApproval(serviceType.getRequiresApproval()) .dailyCapacity(serviceType.getDailyCapacity()) diff --git a/admin-service/src/main/java/com/techtorque/admin_service/service/impl/AdminUserServiceImpl.java b/admin-service/src/main/java/com/techtorque/admin_service/service/impl/AdminUserServiceImpl.java index 1090b0b..23819d1 100644 --- a/admin-service/src/main/java/com/techtorque/admin_service/service/impl/AdminUserServiceImpl.java +++ b/admin-service/src/main/java/com/techtorque/admin_service/service/impl/AdminUserServiceImpl.java @@ -75,23 +75,21 @@ public List getAllUsers(String role, Boolean active, int page, int @Override public UserResponse getUserById(String userId) { - log.info("Fetching user: {} from auth service", userId); + log.info("Fetching user by ID: {} from auth service", userId); try { - UserResponse user = authServiceWebClient.get() - .uri("/users/" + userId) - .retrieve() - .bodyToMono(UserResponse.class) - .block(); - - if (user == null) { - throw new RuntimeException("User not found: " + userId); - } + // Auth service endpoints use username, not userId + // We need to first get all users and find the one with matching ID + List allUsers = getAllUsers(null, null, 0, 1000); - // Convert id to userId if needed - if (user.getUserId() == null && user.getId() != null) { - user.setUserId(String.valueOf(user.getId())); - } + UserResponse user = allUsers.stream() + .filter(u -> { + String userIdStr = u.getUserId() != null ? u.getUserId() : String.valueOf(u.getId()); + return userIdStr.equals(userId); + }) + .findFirst() + .orElseThrow(() -> new RuntimeException("User not found with ID: " + userId)); + log.info("Found user: {} with username: {}", userId, user.getUsername()); return user; } catch (Exception e) { log.error("Error fetching user: {}", userId, e); @@ -139,14 +137,105 @@ public UserResponse createAdmin(CreateEmployeeRequest request) { public UserResponse updateUser(String userId, UpdateUserRequest request) { log.info("Updating user: {} via auth service", userId); try { - UserResponse response = authServiceWebClient.put() - .uri("/users/" + userId) - .bodyValue(request) - .retrieve() - .bodyToMono(UserResponse.class) - .block(); - - return response; + // First, get the current user details to obtain the username + UserResponse currentUser = getUserById(userId); + String username = currentUser.getUsername(); + + // Handle role updates separately via the roles endpoint + if (request.getRoles() != null || request.getRole() != null) { + // Get current roles + List currentRoles = currentUser.getRoles() != null ? currentUser.getRoles() : new java.util.ArrayList<>(); + List newRoles = new java.util.ArrayList<>(); + + // Build the new role list - prioritize roles array over single role + if (request.getRoles() != null && !request.getRoles().isEmpty()) { + newRoles.addAll(request.getRoles()); + } else if (request.getRole() != null) { + newRoles.add(request.getRole()); + } + + // Always preserve CUSTOMER role if it exists + if (currentRoles.contains("CUSTOMER")) { + if (!newRoles.contains("CUSTOMER")) { + newRoles.add("CUSTOMER"); + } + } + + // Always preserve SUPER_ADMIN role if it exists (cannot be removed via this endpoint) + if (currentRoles.contains("SUPER_ADMIN")) { + if (!newRoles.contains("SUPER_ADMIN")) { + newRoles.add("SUPER_ADMIN"); + } + } + + // Determine which roles to add and which to remove + List rolesToAdd = new java.util.ArrayList<>(); + List rolesToRemove = new java.util.ArrayList<>(); + + // Find roles to add + for (String role : newRoles) { + if (!currentRoles.contains(role)) { + rolesToAdd.add(role); + } + } + + // Find roles to remove (only remove EMPLOYEE and ADMIN, never CUSTOMER or SUPER_ADMIN) + for (String role : currentRoles) { + if (!newRoles.contains(role) && (role.equals("EMPLOYEE") || role.equals("ADMIN"))) { + rolesToRemove.add(role); + } + } + + // Apply role changes + for (String roleToAdd : rolesToAdd) { + log.info("Assigning role {} to user {}", roleToAdd, username); + java.util.Map roleRequest = new java.util.HashMap<>(); + roleRequest.put("roleName", roleToAdd); + roleRequest.put("action", "ASSIGN"); + + authServiceWebClient.post() + .uri("/users/" + username + "/roles") + .bodyValue(roleRequest) + .retrieve() + .bodyToMono(Void.class) + .block(); + } + + for (String roleToRemove : rolesToRemove) { + log.info("Revoking role {} from user {}", roleToRemove, username); + java.util.Map roleRequest = new java.util.HashMap<>(); + roleRequest.put("roleName", roleToRemove); + roleRequest.put("action", "REVOKE"); + + authServiceWebClient.post() + .uri("/users/" + username + "/roles") + .bodyValue(roleRequest) + .retrieve() + .bodyToMono(Void.class) + .block(); + } + } + + // Handle other updates (active status, department, etc.) + Boolean activationStatus = request.getActivationStatus(); + if (activationStatus != null || request.getDepartment() != null) { + java.util.Map updateRequest = new java.util.HashMap<>(); + if (activationStatus != null) { + updateRequest.put("enabled", activationStatus); + } + + if (updateRequest.size() > 0) { + authServiceWebClient.put() + .uri("/users/" + username) + .bodyValue(updateRequest) + .retrieve() + .bodyToMono(Void.class) + .block(); + } + } + + // Return the updated user + return getUserById(userId); } catch (Exception e) { log.error("Error updating user: {}", userId, e); throw new RuntimeException("Failed to update user: " + e.getMessage()); diff --git a/admin-service/src/main/resources/application.properties b/admin-service/src/main/resources/application.properties index a26b0dc..58c025c 100644 --- a/admin-service/src/main/resources/application.properties +++ b/admin-service/src/main/resources/application.properties @@ -17,6 +17,9 @@ spring.jpa.properties.hibernate.format_sql=true # Development/Production Profile spring.profiles.active=${SPRING_PROFILE:dev} +# JWT Configuration (must match Auth Service secret) +jwt.secret=${JWT_SECRET:YourSuperSecretKeyForJWTGoesHereAndItMustBeVeryLongForSecurityPurposes} + # OpenAPI access URL # http://localhost:8087/swagger-ui/index.html