diff --git a/backend/src/main/java/com/rit/canteen/sales/config/SecurityConfig.java b/backend/src/main/java/com/rit/canteen/sales/config/SecurityConfig.java index e63ac3b8..446e3fa5 100644 --- a/backend/src/main/java/com/rit/canteen/sales/config/SecurityConfig.java +++ b/backend/src/main/java/com/rit/canteen/sales/config/SecurityConfig.java @@ -81,6 +81,7 @@ public class SecurityConfig { .requestMatchers(HttpMethod.POST, "/api/auth/change-pin").authenticated() .requestMatchers(HttpMethod.PUT, "/api/auth/users/*").authenticated() .requestMatchers(HttpMethod.PUT, "/api/orders/*").authenticated() + .requestMatchers("/api/counter/**").hasAnyRole("MASTER", "MANAGER", "STAFF") // ── STAFF/MANAGER/MASTER: All other management APIs ── .requestMatchers("/api/**").hasAnyRole("MASTER", "MANAGER", "STAFF", "OPERATOR") @@ -106,9 +107,7 @@ public class SecurityConfig { List origins = Arrays.asList(allowedOriginsStr.split(",")); configuration.setAllowedOrigins(origins); configuration.setAllowedOriginPatterns(List.of( - "http://localhost:*", - "http://192.168.*:*", - "http://10.*:*" + "http://localhost:*" )); configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); diff --git a/backend/src/main/java/com/rit/canteen/sales/controller/CouponController.java b/backend/src/main/java/com/rit/canteen/sales/controller/CouponController.java index 79cb6313..56d6c642 100644 --- a/backend/src/main/java/com/rit/canteen/sales/controller/CouponController.java +++ b/backend/src/main/java/com/rit/canteen/sales/controller/CouponController.java @@ -9,6 +9,9 @@ import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; import java.util.Optional; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import io.jsonwebtoken.Claims; @RestController @RequestMapping("/api/coupons") @@ -38,6 +41,10 @@ public class CouponController { String code = ((String) request.get("code")).toUpperCase().trim(); Long userId = Long.valueOf(request.get("userId").toString()); + if (!canAccessUser(userId)) { + return ResponseEntity.status(403).body(Map.of("success", false, "message", "Access denied")); + } + Optional couponOpt = couponRepository.findByCode(code); if (couponOpt.isEmpty()) { return ResponseEntity.status(404).body(Map.of("success", false, "message", "Invalid coupon code")); @@ -112,4 +119,22 @@ public class CouponController { } return ResponseEntity.notFound().build(); } + + private boolean canAccessUser(Long targetUserId) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) return false; + + if (auth.getDetails() instanceof Claims claims) { + String role = (String) claims.get("role"); + // Staff roles can access anyone + if ("MASTER".equals(role) || "MANAGER".equals(role) || "STAFF".equals(role)) return true; + // Customers can only access themselves + Object uid = claims.get("userId"); + if (uid != null) { + Long tokenUserId = uid instanceof Integer ? ((Integer) uid).longValue() : (Long) uid; + return tokenUserId.equals(targetUserId); + } + } + return false; + } } diff --git a/backend/src/main/java/com/rit/canteen/sales/controller/FeedbackController.java b/backend/src/main/java/com/rit/canteen/sales/controller/FeedbackController.java index 9326f2a8..49f27436 100644 --- a/backend/src/main/java/com/rit/canteen/sales/controller/FeedbackController.java +++ b/backend/src/main/java/com/rit/canteen/sales/controller/FeedbackController.java @@ -14,6 +14,9 @@ import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import com.rit.canteen.sales.service.SystemNotificationService; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import io.jsonwebtoken.Claims; import java.util.*; @@ -134,6 +137,9 @@ public class FeedbackController { @GetMapping("/latest-unrated/{userId}") public ResponseEntity getLatestUnratedOrder(@PathVariable Long userId) { + if (!canAccessUser(userId)) { + return ResponseEntity.status(403).build(); + } // Look for any order that is either PAID or COMPLETED Optional latestOrderOpt = orderRepository.findFirstByUserIdOrderByCreatedAtDesc(userId); @@ -153,6 +159,9 @@ public class FeedbackController { Optional orderOpt = orderRepository.findById(orderId); if (orderOpt.isPresent()) { Order order = orderOpt.get(); + if (!canAccessUser(order.getUserId())) { + return ResponseEntity.status(403).build(); + } order.setHasFeedback(true); orderRepository.save(order); return ResponseEntity.ok().build(); @@ -173,6 +182,9 @@ public class FeedbackController { } Order order = orderOpt.get(); + if (!canAccessUser(order.getUserId())) { + return ResponseEntity.status(403).build(); + } feedback.setOrder(order); // Link item ratings to feedback and propagate comment @@ -202,4 +214,22 @@ public class FeedbackController { return ResponseEntity.ok(savedFeedback); } + + private boolean canAccessUser(Long targetUserId) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) return false; + + if (auth.getDetails() instanceof Claims claims) { + String role = (String) claims.get("role"); + // Staff roles can access anyone + if ("MASTER".equals(role) || "MANAGER".equals(role) || "STAFF".equals(role)) return true; + // Customers can only access themselves + Object uid = claims.get("userId"); + if (uid != null) { + Long tokenUserId = uid instanceof Integer ? ((Integer) uid).longValue() : (Long) uid; + return tokenUserId.equals(targetUserId); + } + } + return false; + } } diff --git a/backend/src/main/java/com/rit/canteen/sales/controller/SystemAuthController.java b/backend/src/main/java/com/rit/canteen/sales/controller/SystemAuthController.java index 84ef03e9..e7a58604 100644 --- a/backend/src/main/java/com/rit/canteen/sales/controller/SystemAuthController.java +++ b/backend/src/main/java/com/rit/canteen/sales/controller/SystemAuthController.java @@ -7,10 +7,12 @@ import com.rit.canteen.sales.service.SystemUserService; import io.jsonwebtoken.Claims; import jakarta.servlet.http.HttpServletRequest; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; +import com.rit.canteen.sales.service.LoginLockoutService; import java.util.HashMap; import java.util.List; @@ -30,6 +32,12 @@ public class SystemAuthController { @Autowired private LoginRateLimiter rateLimiter; + @Autowired + private LoginLockoutService lockoutService; + + @Value("${app.security.trust-proxy-headers:false}") + private boolean trustProxyHeaders; + // ── PUBLIC ────────────────────────────────────────────────────────────── @PostMapping("/login") public ResponseEntity login(@RequestBody Map credentials, @@ -45,9 +53,22 @@ public class SystemAuthController { String email = credentials.get("email"); String password = credentials.get("password"); + if (email != null && lockoutService.isLockedOut(email)) { + long remainingMinutes = (lockoutService.getRemainingLockoutTimeMs(email) / 1000) / 60; + if (remainingMinutes == 0) { + remainingMinutes = 1; + } + return ResponseEntity.status(423).body(Map.of( + "error", "Account is locked due to too many failed attempts. Please try again in " + remainingMinutes + " minutes." + )); + } + Optional userOpt = userService.authenticate(email, password); if (userOpt.isPresent()) { + if (email != null) { + lockoutService.resetAttempts(email); + } SystemUser user = userOpt.get(); String token = jwtUtil.generateToken(user.getId(), user.getEmail(), user.getRole(), user.getPermissions()); @@ -62,6 +83,9 @@ public class SystemAuthController { response.put("viewOnly", user.isViewOnly()); return ResponseEntity.ok(response); } else { + if (email != null) { + lockoutService.registerFailedAttempt(email); + } return ResponseEntity.status(401).body(Map.of("error", "Invalid credentials")); } } @@ -164,9 +188,11 @@ public class SystemAuthController { } private String getClientIp(HttpServletRequest request) { - String xfHeader = request.getHeader("X-Forwarded-For"); - if (xfHeader != null && !xfHeader.isEmpty()) { - return xfHeader.split(",")[0].trim(); + if (trustProxyHeaders) { + String xfHeader = request.getHeader("X-Forwarded-For"); + if (xfHeader != null && !xfHeader.isEmpty()) { + return xfHeader.split(",")[0].trim(); + } } return request.getRemoteAddr(); } diff --git a/backend/src/main/java/com/rit/canteen/sales/controller/UserController.java b/backend/src/main/java/com/rit/canteen/sales/controller/UserController.java index 00dcf7ba..f4287c37 100644 --- a/backend/src/main/java/com/rit/canteen/sales/controller/UserController.java +++ b/backend/src/main/java/com/rit/canteen/sales/controller/UserController.java @@ -13,6 +13,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; +import org.springframework.beans.factory.annotation.Value; +import com.rit.canteen.sales.service.LoginLockoutService; import io.jsonwebtoken.Claims; import java.util.HashMap; @@ -32,6 +34,12 @@ public class UserController { @Autowired private LoginRateLimiter rateLimiter; + @Autowired + private LoginLockoutService lockoutService; + + @Value("${app.security.trust-proxy-headers:false}") + private boolean trustProxyHeaders; + // ── PUBLIC ──────────────────────────────────────────────────────────────── @PostMapping("/check") @@ -69,16 +77,53 @@ public class UserController { LoginResponse rateResp = new LoginResponse(false, "Too many login attempts. Please wait 5 minutes."); return ResponseEntity.status(429).body(rateResp); } - LoginResponse response = userService.verifyPinAndLogin(request.getMobileNumber(), request.getPin()); + + String mobileNumber = request.getMobileNumber(); + + // 1. Check if user is suspended in DB first, because suspension takes precedence over in-memory lockout + if (mobileNumber != null) { + LoginResponse.UserDto userDto = userService.getUserByMobile(mobileNumber); + if (userDto != null && userDto.isSuspended()) { + LoginResponse suspResp = new LoginResponse(false, "Your account has been suspended. Please contact the administrator.", userDto); + return ResponseEntity.status(403).body(suspResp); + } + } + + // 2. Check in-memory lockout + if (mobileNumber != null && lockoutService.isLockedOut(mobileNumber)) { + long remainingMinutes = (lockoutService.getRemainingLockoutTimeMs(mobileNumber) / 1000) / 60; + if (remainingMinutes == 0) { + remainingMinutes = 1; + } + LoginResponse lockResp = new LoginResponse(false, + "Account is locked due to too many failed attempts. Please try again in " + remainingMinutes + " minutes."); + return ResponseEntity.status(423).body(lockResp); + } + + LoginResponse response = userService.verifyPinAndLogin(mobileNumber, request.getPin()); if (response.isSuccess()) { + if (mobileNumber != null) { + lockoutService.resetAttempts(mobileNumber); + } Long userId = response.getUser() != null ? response.getUser().getId() : null; if (userId != null) { - String token = jwtUtil.generateUserToken(userId, request.getMobileNumber()); + String token = jwtUtil.generateUserToken(userId, mobileNumber); response.setToken(token); } return ResponseEntity.ok(response); + } else { + if (mobileNumber != null) { + lockoutService.registerFailedAttempt(mobileNumber); + if (lockoutService.isLockedOut(mobileNumber)) { + userService.suspendUserByMobile(mobileNumber); + LoginResponse.UserDto userDto = userService.getUserByMobile(mobileNumber); + LoginResponse lockResp = new LoginResponse(false, + "Too many login attempts. Your account has been suspended indefinitely due to brute force detection. Please contact the administrator.", userDto); + return ResponseEntity.status(423).body(lockResp); + } + } + return ResponseEntity.badRequest().body(response); } - return ResponseEntity.badRequest().body(response); } @PostMapping("/logout") @@ -147,6 +192,10 @@ public class UserController { @PatchMapping("/users/{id}/suspend") public ResponseEntity toggleSuspension(@PathVariable Long id) { LoginResponse.UserDto updated = userService.toggleSuspension(id); + if (updated != null && !updated.isSuspended()) { + // Reset in-memory lockout attempts if the account was unsuspended + lockoutService.resetAttempts(updated.getMobileNumber()); + } return updated != null ? ResponseEntity.ok(updated) : ResponseEntity.notFound().build(); } @@ -185,8 +234,10 @@ public class UserController { } private String getClientIp(HttpServletRequest request) { - String xfHeader = request.getHeader("X-Forwarded-For"); - if (xfHeader != null && !xfHeader.isEmpty()) return xfHeader.split(",")[0].trim(); + if (trustProxyHeaders) { + String xfHeader = request.getHeader("X-Forwarded-For"); + if (xfHeader != null && !xfHeader.isEmpty()) return xfHeader.split(",")[0].trim(); + } return request.getRemoteAddr(); } } diff --git a/backend/src/main/java/com/rit/canteen/sales/counter/controller/CounterImageUploadController.java b/backend/src/main/java/com/rit/canteen/sales/counter/controller/CounterImageUploadController.java index 773b0056..20683c6a 100644 --- a/backend/src/main/java/com/rit/canteen/sales/counter/controller/CounterImageUploadController.java +++ b/backend/src/main/java/com/rit/canteen/sales/counter/controller/CounterImageUploadController.java @@ -24,17 +24,35 @@ public class CounterImageUploadController { return ResponseEntity.badRequest().body(Map.of("error", "No file selected")); } + // 1. File size check (e.g. 5MB limit) + long maxSizeBytes = 5 * 1024 * 1024; + if (file.getSize() > maxSizeBytes) { + return ResponseEntity.badRequest().body(Map.of("error", "File exceeds maximum limit of 5MB")); + } + + // 2. MIME type check + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + return ResponseEntity.badRequest().body(Map.of("error", "Only image files are allowed")); + } + + // 3. Extension check + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null || !originalFilename.contains(".")) { + return ResponseEntity.badRequest().body(Map.of("error", "Invalid file name. Extension required.")); + } + + String extension = originalFilename.substring(originalFilename.lastIndexOf(".")).toLowerCase(); + if (!extension.equals(".jpg") && !extension.equals(".jpeg") && !extension.equals(".png") && !extension.equals(".gif") && !extension.equals(".webp")) { + return ResponseEntity.badRequest().body(Map.of("error", "Only JPG, JPEG, PNG, GIF, and WEBP images are allowed")); + } + try { // Create uploads directory if it doesn't exist Path uploadPath = Paths.get(UPLOAD_DIR).toAbsolutePath().normalize(); Files.createDirectories(uploadPath); // Generate unique filename to prevent collisions - String originalFilename = file.getOriginalFilename(); - String extension = ""; - if (originalFilename != null && originalFilename.contains(".")) { - extension = originalFilename.substring(originalFilename.lastIndexOf(".")); - } String uniqueFilename = UUID.randomUUID().toString() + extension; // Save the file diff --git a/backend/src/main/java/com/rit/canteen/sales/service/LoginLockoutService.java b/backend/src/main/java/com/rit/canteen/sales/service/LoginLockoutService.java new file mode 100644 index 00000000..0fc29083 --- /dev/null +++ b/backend/src/main/java/com/rit/canteen/sales/service/LoginLockoutService.java @@ -0,0 +1,91 @@ +package com.rit.canteen.sales.service; + +import org.springframework.stereotype.Service; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Service to manage account lockouts due to consecutive failed login attempts. + * If 5 failed attempts occur where successive attempts are within 5 seconds, + * or 5 failed attempts occur in a 5-second window, the account is locked for 1 hour. + */ +@Service +public class LoginLockoutService { + + private static final long LOCKOUT_DURATION_MS = 60 * 60 * 1000L; // 1 hour + private static final int MAX_ATTEMPTS = 5; + private static final long MAX_INTERVAL_MS = 5000L; // 5 seconds + + private final Map lockouts = new ConcurrentHashMap<>(); + private final Map> failedAttempts = new ConcurrentHashMap<>(); + + public boolean isLockedOut(String key) { + if (key == null) return false; + Long lockoutExpiry = lockouts.get(key); + if (lockoutExpiry != null) { + if (Instant.now().toEpochMilli() < lockoutExpiry) { + return true; + } else { + // Lockout expired - clean up + lockouts.remove(key); + failedAttempts.remove(key); + } + } + return false; + } + + public long getRemainingLockoutTimeMs(String key) { + if (key == null) return 0; + Long lockoutExpiry = lockouts.get(key); + if (lockoutExpiry != null) { + long remaining = lockoutExpiry - Instant.now().toEpochMilli(); + return Math.max(0, remaining); + } + return 0; + } + + public void registerFailedAttempt(String key) { + if (key == null) return; + long now = Instant.now().toEpochMilli(); + List attempts = failedAttempts.computeIfAbsent(key, k -> Collections.synchronizedList(new ArrayList<>())); + + synchronized (attempts) { + attempts.add(now); + + // Keep only the last MAX_ATTEMPTS attempts + while (attempts.size() > MAX_ATTEMPTS) { + attempts.remove(0); + } + + if (attempts.size() == MAX_ATTEMPTS) { + // Check if all 5 attempts occurred within a 5 second window + long firstAttempt = attempts.get(0); + long lastAttempt = attempts.get(attempts.size() - 1); + boolean allWithinWindow = (lastAttempt - firstAttempt) <= MAX_INTERVAL_MS; + + // Also check if each successive attempt is within 5 seconds of the previous one + boolean successiveWithinInterval = true; + for (int i = 1; i < attempts.size(); i++) { + if ((attempts.get(i) - attempts.get(i - 1)) > MAX_INTERVAL_MS) { + successiveWithinInterval = false; + break; + } + } + + if (allWithinWindow || successiveWithinInterval) { + lockouts.put(key, now + LOCKOUT_DURATION_MS); + } + } + } + } + + public void resetAttempts(String key) { + if (key == null) return; + failedAttempts.remove(key); + lockouts.remove(key); + } +} diff --git a/backend/src/main/java/com/rit/canteen/sales/service/SystemUserService.java b/backend/src/main/java/com/rit/canteen/sales/service/SystemUserService.java index 4af9e427..d9a12519 100644 --- a/backend/src/main/java/com/rit/canteen/sales/service/SystemUserService.java +++ b/backend/src/main/java/com/rit/canteen/sales/service/SystemUserService.java @@ -109,25 +109,18 @@ public class SystemUserService { } public Optional authenticate(String email, String password) { - System.out.println(">>> Attempting authentication for: " + email); - // 1. Try Database First Optional user = repository.findByEmail(email); if (user.isPresent()) { boolean matches = passwordEncoder.matches(password, user.get().getPassword()); - System.out.println(">>> User found in DB. Password match: " + matches); if (matches) { return user; } } else { - System.out.println(">>> User NOT found in DB. Checking Failsafe eligibility..."); - // 2. Try Failsafe (Properties) - ONLY if no Master users exist in DB List masters = repository.findByRole("MASTER"); if (masters.isEmpty()) { - if (email.equals(masterUsername) && password.equals(masterPassword)) { - System.out.println(">>> FAILSAFE AUTHENTICATION SUCCESSFUL (No DB Master Found)"); - + if (email.equals(masterUsername) && passwordEncoder.matches(password, passwordEncoder.encode(masterPassword))) { SystemUser failsafeUser = new SystemUser(); failsafeUser.setId(0L); failsafeUser.setName("Failsafe Admin"); @@ -136,8 +129,6 @@ public class SystemUserService { failsafeUser.setPermissions(List.of("dashboard", "sale", "customers", "purchases", "inventory", "expense", "reports", "stores", "table", "wallet", "promotions", "feedback")); return Optional.of(failsafeUser); } - } else { - System.out.println(">>> Failsafe disabled because custom master account exists in database."); } } diff --git a/backend/src/main/java/com/rit/canteen/sales/service/TokenService.java b/backend/src/main/java/com/rit/canteen/sales/service/TokenService.java index a95b0055..7ef0ee08 100644 --- a/backend/src/main/java/com/rit/canteen/sales/service/TokenService.java +++ b/backend/src/main/java/com/rit/canteen/sales/service/TokenService.java @@ -14,6 +14,7 @@ import java.math.BigDecimal; import java.security.MessageDigest; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.UUID; import java.util.stream.Collectors; @@ -132,10 +133,10 @@ public class TokenService { // PERMANENT REMOVAL: Physical delete from database List unitIds = unitsToSpend.stream().map(TokenUnit::getId).collect(Collectors.toList()); - String deleteSql = "DELETE FROM token_units WHERE id IN (" + - unitIds.stream().map(String::valueOf).collect(Collectors.joining(",")) + ")"; + String placeholders = Collections.nCopies(unitIds.size(), "?").stream().collect(Collectors.joining(",")); + String deleteSql = "DELETE FROM token_units WHERE id IN (" + placeholders + ")"; - jdbcTemplate.update(deleteSql); + jdbcTemplate.update(deleteSql, unitIds.toArray()); // Update cached user balance user.setRitzTokenBalance(currentBalance.subtract(amount)); diff --git a/backend/src/main/java/com/rit/canteen/sales/service/UserService.java b/backend/src/main/java/com/rit/canteen/sales/service/UserService.java index ed5aeb9e..9ff780c9 100644 --- a/backend/src/main/java/com/rit/canteen/sales/service/UserService.java +++ b/backend/src/main/java/com/rit/canteen/sales/service/UserService.java @@ -80,7 +80,10 @@ public class UserService { User user = userOpt.get(); if (user.isSuspended()) { - return new LoginResponse(false, "Your account has been suspended. Please contact the administrator."); + LoginResponse.UserDto userDto = new LoginResponse.UserDto( + user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance() + ); + return new LoginResponse(false, "Your account has been suspended. Please contact the administrator.", userDto); } if (!passwordEncoder.matches(pin, user.getPinHash())) { @@ -241,6 +244,20 @@ public class UserService { return new LoginResponse.UserDto(user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance()); } + /** + * Suspend a user by their mobile number. + */ + public void suspendUserByMobile(String mobileNumber) { + if (mobileNumber == null) return; + userRepository.findByMobileNumber(mobileNumber).ifPresent(user -> { + if (!user.isSuspended()) { + user.setSuspended(true); + user.setLoggedIn(false); + userRepository.save(user); + } + }); + } + /** * Delete a user from the system. */ @@ -248,3 +265,4 @@ public class UserService { userRepository.deleteById(userId); } } +