Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
@NonNull FilterChain filterChain) throws ServletException, IOException {

// ✅ Allow preflight
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
Expand All @@ -40,10 +39,12 @@ protected void doFilterInternal(
}

String path = request.getServletPath();
System.out.println("🔍 JwtAuthFilter - Path: " + path);

// ✅ Public endpoints (do not try to parse JWT)
if (path.startsWith("/api/auth")
|| path.startsWith("/api/mobile/auth")
if (path.startsWith("/api/auth/login")
|| path.startsWith("/api/auth/refresh")
|| path.startsWith("/api/mobile/auth/login")
|| path.startsWith("/api/health")
|| path.startsWith("/api/duties")
|| path.startsWith("/api/leaves")){
Expand All @@ -52,9 +53,12 @@ protected void doFilterInternal(
}

final String authHeader = request.getHeader("Authorization");
System.out.println("🔍 Auth Header: "
+ (authHeader != null ? authHeader.substring(0, Math.min(20, authHeader.length())) + "..." : "NULL"));

// ✅ No token -> continue (SecurityConfig will decide permit/deny)
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
System.out.println("❌ No Bearer token found");
Comment on lines 41 to +61

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JwtAuthenticationFilter uses multiple System.out.println statements, including logging the Authorization header prefix. Avoid printing tokens/user data to stdout; use a logger with configurable levels (debug) and do not log any part of credentials/tokens in production logs.

Copilot uses AI. Check for mistakes.
filterChain.doFilter(request, response);
return;
}
Expand All @@ -67,20 +71,26 @@ protected void doFilterInternal(
UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail);

if (jwtService.isTokenValid(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities());

authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);

// 🔍 DEBUG: Log authentication success
System.out.println("✅ JWT Auth Success: " + userEmail);
System.out.println(" Authorities: " + userDetails.getAuthorities());
System.out.println(" Accessing: " + path);
Comment on lines +81 to +85

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JwtAuthenticationFilter logs authentication success details (userEmail, authorities, path) to stdout. This is noisy and can leak security-relevant information. Switch to structured logging at debug level (or remove) and avoid logging authority sets for every request in normal operation.

Copilot uses AI. Check for mistakes.
} else {
System.out.println("❌ JWT Invalid for user: " + userEmail);
}
}
} catch (Exception ex) {
// ✅ DO NOT block request just because token is bad
// Let SecurityConfig handle authorization
System.out.println("⚠️ JWT parsing error: " + ex.getMessage());
}

filterChain.doFilter(request, response);
Expand Down
34 changes: 18 additions & 16 deletions src/main/java/com/crimeLink/analyzer/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.crimeLink.analyzer.config;

import java.util.Arrays;
import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
Expand All @@ -21,9 +24,6 @@
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.Arrays;
import java.util.List;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
Expand All @@ -41,32 +41,34 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.csrf(csrf -> csrf.disable())
// CRITICAL FIX: Enable CORS using the bean configuration
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.cors(cors -> cors.configure(http))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/health").permitAll()
.requestMatchers("/api/admin/health").permitAll()
.requestMatchers("/api/database/**").permitAll()
.requestMatchers("/api/vehicles/**").permitAll()
.requestMatchers("/api/mobile/auth/**").permitAll()
.requestMatchers("/api/duty-schedules/**").hasRole("OIC")
.requestMatchers("/api/mobile/**").hasRole("FieldOfficer")
.requestMatchers("/api/test").permitAll()
.requestMatchers("/api/leaves/**").permitAll()
.requestMatchers("/api/debug/**").permitAll() // 🔍 Debug endpoints
Comment thread
arosha-w marked this conversation as resolved.

// Allow duty schedule operations for OIC
.requestMatchers("/api/duty-schedules/**").hasRole("OIC")
// Public endpoints
.requestMatchers("/api/vehicle**").permitAll()

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SecurityConfig permits "/api/vehicle**", but the VehicleController is mapped to "/api/vehicles". As written, vehicle endpoints likely won’t be publicly accessible as intended (and may unexpectedly require authentication). Update the matcher to the correct path (e.g., "/api/vehicles/**").

Suggested change
.requestMatchers("/api/vehicle**").permitAll()
.requestMatchers("/api/vehicles/**").permitAll()

Copilot uses AI. Check for mistakes.
.requestMatchers("/api/mobile/auth/**").permitAll()
.requestMatchers("/api/duties/**").permitAll()
.requestMatchers("/api/crime-reports/map").permitAll()

// Field Officer routes
.requestMatchers("/api/officers/me/**").hasRole("FieldOfficer")
.requestMatchers("/api/mobile/**").hasRole("FieldOfficer")
Comment on lines +55 to +61

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"/api/duties/**" is configured as permitAll. These endpoints expose duty assignments by officerId and date, which is sensitive operational data; permitting unauthenticated access is a security risk. Consider requiring authentication (e.g., hasRole("FieldOfficer")/hasAnyRole(...)) and enforcing that a field officer can only query their own duties.

Copilot uses AI. Check for mistakes.
.requestMatchers("/api/leaves/**").permitAll()

// Allow duty schedule operations for OIC
// OIC-only routes
.requestMatchers("/api/duty-schedules/**").hasRole("OIC")

// Allow weapon operations for OIC
.requestMatchers("/api/weapon/**").hasRole("OIC")
.requestMatchers("/api/weapon-issue/**").hasRole("OIC")
.requestMatchers("/api/duties/**").permitAll()
.requestMatchers("/duties/**").permitAll()

// Admin/OIC routes (officer data, locations, users)
.requestMatchers("/api/users/field-officers").hasAnyRole("Admin", "OIC")
.requestMatchers("/api/admin/**").hasAnyRole("OIC", "Admin")

.anyRequest().authenticated())
.sessionManagement(session -> session
Expand Down
33 changes: 21 additions & 12 deletions src/main/java/com/crimeLink/analyzer/controller/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,25 +44,34 @@ public ResponseEntity<LoginResponse> login(
public ResponseEntity<TokenRefreshResponse> refreshToken(@RequestBody TokenRefreshRequest request) {
String refreshTokenStr = request.getRefreshToken();

return refreshTokenService.findByToken(refreshTokenStr)
.map(refreshTokenService::verifyExpiration)
.map(RefreshToken::getUser)
.map(user -> {
if (refreshTokenStr == null || refreshTokenStr.isBlank()) {
return ResponseEntity.badRequest().body(new TokenRefreshResponse(
false,
"Refresh token is required",
null,
null
));
}

return refreshTokenService.findValidToken(refreshTokenStr)
.map(validToken -> {
RefreshToken rotated = refreshTokenService.rotateRefreshToken(validToken);
User user = rotated.getUser();
String accessToken = jwtService.generateToken(user);

return ResponseEntity.ok(new TokenRefreshResponse(
true,
"Token refreshed successfully",
accessToken,
refreshTokenStr
rotated.getToken()
));
})
.orElseGet(() -> ResponseEntity.status(401)
.body(new TokenRefreshResponse(
false,
"Invalid refresh token",
null,
null
)));
.orElseGet(() -> ResponseEntity.status(401).body(new TokenRefreshResponse(
false,
"Invalid or expired refresh token",
null,
null
)));
}

@PostMapping("/logout")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ public ResponseEntity<List<OfficerDutyRowDTO>> getOfficersForDate(
List<OfficerDutyRowDTO> rows = dutyService.getOfficerRowsForDate(date);
return ResponseEntity.ok(rows);
}
@GetMapping("/locations")
public ResponseEntity<List<String>> getDutyLocations() {
return ResponseEntity.ok(dutyService.getDutyLocations());
}
// 2) Create / Save a duty (upsert via service.saveDuty)
@PostMapping
public ResponseEntity<?> createDuty(@RequestBody DutyScheduleRequest request) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.crimeLink.analyzer.controller;

import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.crimeLink.analyzer.dto.LocationPointDTO;
import com.crimeLink.analyzer.entity.User;
import com.crimeLink.analyzer.service.impl.LocationServiceImpl;

import lombok.RequiredArgsConstructor;

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class LocationController {
private final LocationServiceImpl service;

Comment on lines +20 to +29

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LocationController injects the concrete LocationServiceImpl instead of the LocationService interface. Prefer depending on the interface to keep the controller decoupled and make testing/mocking easier.

Copilot uses AI. Check for mistakes.
@PostMapping("/officers/me/locations/bulk")
public void uploadMyLocations(@AuthenticationPrincipal User user, @RequestBody List<LocationPointDTO> points) {
System.out.println("Received locations: " + points.size()); // REMOVE: for testing
if (user == null) {
throw new RuntimeException("Unauthorized");
}
Comment on lines +31 to +35

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uploadMyLocations() calls points.size() before validating the request body. If the client sends a null body, this will throw a NullPointerException and return 500. Add a null/empty check (and return 400) before accessing points.

Copilot uses AI. Check for mistakes.

if (!"FieldOfficer".equalsIgnoreCase(user.getRole())) {
throw new RuntimeException("Only field officers can upload locations");
}

String officerBadgeNo = user.getBadgeNo();
if (officerBadgeNo == null || officerBadgeNo.isBlank()) {
throw new RuntimeException("Badge number missing");
}
Comment on lines +33 to +44

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This controller throws generic RuntimeException for auth/authorization/validation failures (e.g., "Unauthorized", "Only field officers..."). Without a @ControllerAdvice mapping, these will become 500 responses. Use proper HTTP statuses (e.g., ResponseStatusException with 401/403/400, or @PreAuthorize + validation) so clients get correct error codes.

Copilot uses AI. Check for mistakes.
service.saveBulk(officerBadgeNo, points);
}

@GetMapping("/admin/officers/{officerBadgeNo}/locations")
public Object history(
@AuthenticationPrincipal User user,
@PathVariable String officerBadgeNo,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) {
System.out.println("📍 LocationController.history() called");
System.out.println(" Badge: " + officerBadgeNo);
System.out.println(" From: " + from + ", To: " + to);
System.out.println(" User: " + (user != null ? user.getEmail() : "NULL"));
System.out.println(" Role: " + (user != null ? user.getRole() : "NULL"));
System.out.println(" Authorities: " + (user != null ? user.getAuthorities() : "NULL"));
return service.getHistory(officerBadgeNo, from, to);
Comment on lines +54 to +60

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This controller logs request details (including user info, badge numbers, and time ranges) via System.out.println. Please replace with structured logging (logger) at an appropriate level and remove the verbose debug output before merge to avoid leaking sensitive data and spamming logs.

Copilot uses AI. Check for mistakes.
}

@GetMapping("/debug/whoami")
public Map<String, Object> whoAmI(@AuthenticationPrincipal User user) {
Map<String, Object> info = new HashMap<>();
if (user != null) {
info.put("email", user.getEmail());
info.put("name", user.getName());
info.put("role", user.getRole());
info.put("authorities", user.getAuthorities().stream()
.map(auth -> auth.getAuthority())
.toList());
info.put("userId", user.getUserId());
info.put("badgeNo", user.getBadgeNo());
} else {
info.put("error", "No authenticated user");
}
return info;
}

@GetMapping("/admin/officers/{officerBadgeNo}/locations/last")
public Object lastLocation(@PathVariable String officerBadgeNo) {
return service.getLastLocation(officerBadgeNo);
}
}
16 changes: 16 additions & 0 deletions src/main/java/com/crimeLink/analyzer/dto/LocationPointDTO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.crimeLink.analyzer.dto;

import java.time.Instant;
import java.util.Map;

public record LocationPointDTO(
Instant ts,
double latitude,
double longitude,
Float accuracyM,
Float speedMps,
Float headingDeg,
String provider,
Map<String, Object> meta) {

}
49 changes: 49 additions & 0 deletions src/main/java/com/crimeLink/analyzer/entity/LocationPoint.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.crimeLink.analyzer.entity;

import java.time.Instant;

import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;

import com.fasterxml.jackson.databind.JsonNode;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
@Table(name = "location_points", indexes = {
@Index(name = "idx_location_points_officer_ts", columnList = "officer_badge_no, ts") })
public class LocationPoint {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "officer_badge_no", nullable = false, length = 20)
private String officerBadgeNo;
private Instant ts;
private double latitude;
private double longitude;

private Float accuracyM;
private Float speedMps;
private Float headingDeg;

private String provider;

@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "meta", columnDefinition = "jsonb")
private JsonNode meta;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.crimeLink.analyzer.entity.DutySchedule;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.time.LocalDate;
Expand Down Expand Up @@ -38,4 +39,13 @@ long countByAssignedOfficer_UserIdAndDateBetween(
LocalDate start,
LocalDate end
);

@Query("""
SELECT DISTINCT d.location
FROM DutySchedule d
WHERE d.location IS NOT NULL
AND LENGTH(TRIM(d.location)) > 0
ORDER BY d.location
""")
List<String> findDistinctLocations();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.crimeLink.analyzer.repository;

import java.time.Instant;
import java.util.List;

import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

import com.crimeLink.analyzer.entity.LocationPoint;

public interface LocationPointRepository extends JpaRepository<LocationPoint, Long> {
List<LocationPoint> findByOfficerBadgeNoAndTsBetweenOrderByTsAsc(String officerBadgeNo, Instant from, Instant to);

List<LocationPoint> findByOfficerBadgeNoOrderByTsDesc(String officerBadgeNo, Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByToken(String token);

@Query("SELECT rt FROM RefreshToken rt JOIN FETCH rt.user WHERE rt.token = ?1")
Optional<RefreshToken> findByTokenWithUser(String token);

@Modifying
@Query("DELETE FROM RefreshToken rt WHERE rt.expiryDate < ?1")
Expand Down
Loading