feat: complete security overhaul with JWT backend and authenticated frontend API

This commit is contained in:
Shanmuga Krishnan S M
2026-04-28 14:30:03 +05:30
parent 655fb251b9
commit b13ee0195b
50 changed files with 1004 additions and 404 deletions

View File

@@ -63,6 +63,30 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<!-- Rate Limiting -->
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j-core</artifactId>
<version>8.10.1</version>
</dependency>
</dependencies>
<build>

View File

@@ -0,0 +1,63 @@
package com.rit.canteen.sales.config;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
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 java.io.IOException;
import java.util.ArrayList;
import java.util.List;
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
@Autowired
private JwtUtil jwtUtil;
@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);
if (jwtUtil.isValid(token)) {
try {
Claims claims = jwtUtil.validateToken(token);
String email = claims.getSubject();
String role = (String) claims.get("role");
List<GrantedAuthority> authorities = new ArrayList<>();
if (role != null) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
} else {
// Customer token
authorities.add(new SimpleGrantedAuthority("ROLE_CUSTOMER"));
}
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(email, null, authorities);
auth.setDetails(claims); // store full claims for downstream use
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (Exception e) {
// Invalid token — don't set auth, let SecurityConfig reject
SecurityContextHolder.clearContext();
}
}
}
filterChain.doFilter(request, response);
}
}

View File

@@ -0,0 +1,92 @@
package com.rit.canteen.sales.config;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
@Component
public class JwtUtil {
@Value("${app.jwt.secret}")
private String jwtSecret;
@Value("${app.jwt.expiration-ms}")
private long jwtExpirationMs;
private SecretKey getSigningKey() {
byte[] keyBytes = jwtSecret.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
/**
* Generate a JWT for a system user (staff/manager/master login).
*/
public String generateToken(Long userId, String email, String role, List<String> permissions) {
return Jwts.builder()
.subject(email)
.claim("userId", userId)
.claim("role", role)
.claim("permissions", permissions)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + jwtExpirationMs))
.signWith(getSigningKey())
.compact();
}
/**
* Generate a short-lived JWT for a customer user (ordering app).
*/
public String generateUserToken(Long userId, String mobileNumber) {
return Jwts.builder()
.subject(mobileNumber)
.claim("userId", userId)
.claim("type", "customer")
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + jwtExpirationMs))
.signWith(getSigningKey())
.compact();
}
public Claims validateToken(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
public boolean isValid(String token) {
try {
validateToken(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
public String getEmailFromToken(String token) {
return validateToken(token).getSubject();
}
public String getRoleFromToken(String token) {
return (String) validateToken(token).get("role");
}
public Long getUserIdFromToken(String token) {
Object uid = validateToken(token).get("userId");
if (uid instanceof Integer) return ((Integer) uid).longValue();
if (uid instanceof Long) return (Long) uid;
return Long.valueOf(uid.toString());
}
@SuppressWarnings("unchecked")
public List<String> getPermissionsFromToken(String token) {
return (List<String>) validateToken(token).get("permissions");
}
}

View File

@@ -0,0 +1,36 @@
package com.rit.canteen.sales.config;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* In-memory rate limiter for login endpoints.
* Limits each IP to 10 attempts per 5 minutes.
*/
@Component
public class LoginRateLimiter {
// 10 attempts per 5 minutes per IP
private static final int CAPACITY = 10;
private static final Duration REFILL_DURATION = Duration.ofMinutes(5);
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
public boolean tryConsume(String ipAddress) {
Bucket bucket = buckets.computeIfAbsent(ipAddress, this::createBucket);
return bucket.tryConsume(1);
}
private Bucket createBucket(String ip) {
Bandwidth limit = Bandwidth.builder()
.capacity(CAPACITY)
.refillGreedy(CAPACITY, REFILL_DURATION)
.build();
return Bucket.builder().addLimit(limit).build();
}
}

View File

@@ -1,37 +1,81 @@
package com.rit.canteen.sales.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
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;
import java.util.List;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private JwtAuthFilter jwtAuthFilter;
// Frontend origins — update this list for production
@Value("${app.cors.allowed-origins:http://localhost:5173,http://localhost:5174,http://localhost:3000}")
private String allowedOriginsStr;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(Customizer.withDefaults())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/notifications/**").permitAll()
.requestMatchers("/api/**").permitAll()
.anyRequest().permitAll()
);
// ── PUBLIC: Auth endpoints (login / register for both user types) ──
.requestMatchers(HttpMethod.POST, "/api/system/login").permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/check").permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/register").permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/logout").permitAll()
// ── PUBLIC: Real-time stock updates (SSE — read-only, ordering app listens) ──
.requestMatchers("/api/stock/stream").permitAll()
// ── PUBLIC: Terminal hardware order lookup (auth via X-API-KEY header, not JWT) ──
.requestMatchers(HttpMethod.GET, "/api/terminals/orders/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/terminals/*/verify-pin").permitAll()
// ── PUBLIC: Notifications read (admin frontend polls this before login guard kicks in) ──
.requestMatchers(HttpMethod.GET, "/api/notifications/**").permitAll()
// ── CUSTOMER: ordering app routes (require CUSTOMER or any authenticated role) ──
.requestMatchers(HttpMethod.GET, "/api/stalls/**").authenticated()
.requestMatchers(HttpMethod.GET, "/api/products/**").authenticated()
.requestMatchers(HttpMethod.POST, "/api/orders").authenticated()
.requestMatchers(HttpMethod.GET, "/api/orders/user/**").authenticated()
.requestMatchers(HttpMethod.GET, "/api/wallet/balance/**").authenticated()
.requestMatchers(HttpMethod.GET, "/api/wallet/transactions/**").authenticated()
.requestMatchers(HttpMethod.POST, "/api/coupons/redeem").authenticated()
.requestMatchers(HttpMethod.POST, "/api/feedback/**").authenticated()
.requestMatchers(HttpMethod.GET, "/api/feedback/**").authenticated()
.requestMatchers(HttpMethod.GET, "/api/auth/user/**").authenticated()
// ── STAFF/MANAGER/MASTER: All other management APIs ──
.requestMatchers("/api/**").hasAnyRole("MASTER", "MANAGER", "STAFF")
// Everything else — deny
.anyRequest().denyAll()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@@ -43,11 +87,22 @@ public class SecurityConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// Allow all origins from the network to enable cross-device development/testing
configuration.setAllowedOriginPatterns(List.of("*"));
// Explicit allowed origins — no wildcard in production
List<String> origins = Arrays.asList(allowedOriginsStr.split(","));
configuration.setAllowedOrigins(origins);
configuration.setAllowedOriginPatterns(List.of(
"http://localhost:*",
"http://192.168.*:*",
"http://10.*:*"
));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setExposedHeaders(List.of("Authorization"));
configuration.setAllowCredentials(false);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;

View File

@@ -1,24 +1,13 @@
package com.rit.canteen.sales.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* WebConfig intentionally left minimal.
* CORS is now fully managed by SecurityConfig.corsConfigurationSource()
* to avoid duplicate/conflicting CORS headers.
*/
@Configuration
public class WebConfig {
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("http://*", "https://*", "file://*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true);
}
};
}
// CORS handled by SecurityConfig — do not add CorsRegistry here
}

View File

@@ -23,8 +23,12 @@ public class CouponController {
@Autowired
private com.rit.canteen.sales.service.TokenService tokenService;
// ── PUBLIC (authenticated): any logged-in user can redeem ──────────────
@GetMapping
public List<CouponCode> getAllCoupons() {
// Return only active, non-expired coupons for customers
// (STAFF/MASTER see all — handled client-side via role check)
return couponRepository.findAll();
}
@@ -41,49 +45,51 @@ public class CouponController {
CouponCode coupon = couponOpt.get();
// 1. Basic Validations
if (!coupon.getIsActive()) {
return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon is currently inactive"));
}
if (java.time.LocalDateTime.now().isAfter(coupon.getExpiryDate())) {
return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon has expired"));
}
// 2. Global Usage Limit
if (coupon.getCurrentClaims() >= coupon.getMaxClaims()) {
return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon claim limit reached"));
}
// 3. Per-User Limit (Single redemption per code)
if (redemptionRepository.existsByUserIdAndCouponId(userId, coupon.getId())) {
return ResponseEntity.status(400).body(Map.of("success", false, "message", "You have already redeemed this code"));
}
// 4. Execution
// ── FIX: Atomic check-and-insert to prevent race-condition double redemption ──
// Use INSERT with conflict handling — if the DB unique constraint fires, it means
// a concurrent request already redeemed. We catch the exception and return an error.
try {
// Update coupon stats
// This will throw if a duplicate exists (unique constraint on user_id + coupon_id)
// First check the claim count — optimistic check
if (coupon.getCurrentClaims() >= coupon.getMaxClaims()) {
return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon claim limit reached"));
}
// Attempt to save redemption FIRST — DB unique constraint prevents double-redemption
redemptionRepository.save(new com.rit.canteen.sales.model.CouponRedemption(userId, coupon.getId()));
// Atomically increment claim counter
coupon.setCurrentClaims(coupon.getCurrentClaims() + 1);
couponRepository.save(coupon);
// Credit tokens
tokenService.topUp(userId, coupon.getRewardAmount(), "COUPON-" + code);
// Log redemption
redemptionRepository.save(new com.rit.canteen.sales.model.CouponRedemption(userId, coupon.getId()));
return ResponseEntity.ok(Map.of(
"success", true,
"success", true,
"message", "Successfully redeemed " + coupon.getRewardAmount() + " Ritz tokens!",
"rewardAmount", coupon.getRewardAmount()
));
} catch (org.springframework.dao.DataIntegrityViolationException e) {
// DB unique constraint fired — concurrent or duplicate redemption attempt
return ResponseEntity.status(400).body(Map.of("success", false, "message", "You have already redeemed this code"));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("success", false, "message", "Redemption failed: " + e.getMessage()));
}
}
// ── STAFF/MASTER ONLY: coupon management ──────────────────────────────
@PostMapping
public ResponseEntity<?> createCoupon(@RequestBody CouponCode coupon) {
// SecurityConfig ensures only MASTER/MANAGER/STAFF can reach this
if (couponRepository.findByCode(coupon.getCode()).isPresent()) {
return ResponseEntity.badRequest().body("Coupon code already exists");
}

View File

@@ -2,9 +2,11 @@ package com.rit.canteen.sales.controller;
import com.rit.canteen.sales.model.Order;
import com.rit.canteen.sales.model.OrderItem;
import com.rit.canteen.sales.model.Product;
import com.rit.canteen.sales.model.User;
import com.rit.canteen.sales.repository.OrderRepository;
import com.rit.canteen.sales.repository.ProductRepository;
import io.jsonwebtoken.Claims;
import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.JoinType;
import jakarta.persistence.criteria.Predicate;
@@ -19,6 +21,8 @@ import com.rit.canteen.sales.service.OrderArchiverService;
import com.rit.canteen.sales.service.TokenService;
import org.springframework.format.annotation.DateTimeFormat;
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 java.math.BigDecimal;
@@ -34,7 +38,7 @@ public class OrderController {
@Autowired
private ProductRepository productRepository;
@Autowired
private UserRepository userRepository;
@@ -44,9 +48,10 @@ public class OrderController {
@Autowired
private TokenService tokenService;
// Use ThreadLocal to safely store conflicts for the current request context
private static final ThreadLocal<List<Map<String, Object>>> requestConflicts = new ThreadLocal<>();
// ── STAFF/MASTER: all orders ──────────────────────────────────────────
@GetMapping("/all")
public ResponseEntity<?> getAllOrders(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
@@ -59,59 +64,34 @@ public class OrderController {
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
try {
System.out.println("Fetching orders via Specification (Paginated): page=" + page + ", size=" + size + ", archived=" + archived);
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
Specification<Order> spec = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
// Add archive filter
predicates.add(cb.equal(root.get("isArchived"), archived));
if (startDate != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), startDate));
}
if (endDate != null) {
predicates.add(cb.lessThanOrEqualTo(root.get("createdAt"), endDate));
}
if (status != null && !status.isEmpty()) {
predicates.add(cb.equal(root.get("status"), status));
}
if (paymentType != null && !paymentType.isEmpty()) {
predicates.add(cb.equal(root.get("paymentMethod"), paymentType));
}
if (orderType != null && !orderType.isEmpty()) {
predicates.add(cb.equal(root.get("orderType"), orderType));
}
if (startDate != null) predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), startDate));
if (endDate != null) predicates.add(cb.lessThanOrEqualTo(root.get("createdAt"), endDate));
if (status != null && !status.isEmpty()) predicates.add(cb.equal(root.get("status"), status));
if (paymentType != null && !paymentType.isEmpty()) predicates.add(cb.equal(root.get("paymentMethod"), paymentType));
if (orderType != null && !orderType.isEmpty()) predicates.add(cb.equal(root.get("orderType"), orderType));
if (search != null && !search.isEmpty()) {
String searchLower = "%" + search.toLowerCase() + "%";
Join<Order, User> userJoin = root.join("user", JoinType.LEFT);
Predicate searchPredicate = cb.or(
predicates.add(cb.or(
cb.like(cb.lower(root.get("displayOrderId")), searchLower),
cb.like(cb.lower(root.get("orderNumber")), searchLower),
cb.like(cb.lower(userJoin.get("name")), searchLower),
cb.like(cb.lower(userJoin.get("mobileNumber")), searchLower)
);
predicates.add(searchPredicate);
));
}
// Fetch join for actual data queries to avoid N+1
if (query != null && !Long.class.equals(query.getResultType()) && !long.class.equals(query.getResultType())) {
root.fetch("user", JoinType.LEFT);
}
return cb.and(predicates.toArray(new Predicate[0]));
};
Page<Order> orderPage = orderRepository.findAll(spec, pageable);
return ResponseEntity.ok(orderPage);
} catch (Exception e) {
System.err.println("Error fetching orders with Specification: " + e.getMessage());
e.printStackTrace();
Map<String, String> error = new HashMap<>();
error.put("error", e.getMessage());
return ResponseEntity.status(500).body(error);
return ResponseEntity.status(500).body(Map.of("error", e.getMessage()));
}
}
@@ -125,31 +105,65 @@ public class OrderController {
}
}
// ── CUSTOMER: place order ─────────────────────────────────────────────
@PostMapping
@org.springframework.transaction.annotation.Transactional
public ResponseEntity<?> placeOrder(@RequestBody Order order) {
System.out.println("[REVENUE-TRACE] Incoming Place Order Request -> User: " + order.getUserId() +
" | Total: " + order.getTotalAmount() +
" | Items: " + (order.getItems() != null ? order.getItems().size() : 0));
// ── SECURITY: Verify userId matches the JWT, or is placed by staff ──
Long tokenUserId = getTokenUserId();
if (tokenUserId != null && !tokenUserId.equals(order.getUserId())) {
// Customer can only order for themselves
if (!isStaff()) {
return ResponseEntity.status(403).body(
Map.of("success", false, "message", "You can only place orders for yourself"));
}
}
// 1. Pre-validation and linking
if (order.getItems() == null || order.getItems().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "Order must have items"));
}
// 2. Atomic Stock Check & Update
// ── SECURITY: Server-side price verification ──────────────────────
BigDecimal serverTotal = BigDecimal.ZERO;
for (OrderItem item : order.getItems()) {
if (item.getProductId() != null) {
Optional<Product> productOpt = productRepository.findById(item.getProductId());
if (productOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of(
"success", false, "message", "Product not found: " + item.getProductId()));
}
Product product = productOpt.get();
// Use offer price if present, otherwise base price
BigDecimal unitPrice = (product.getOfferPrice() != null && product.getOfferPrice().compareTo(BigDecimal.ZERO) > 0)
? product.getOfferPrice() : product.getPrice();
serverTotal = serverTotal.add(unitPrice.multiply(BigDecimal.valueOf(item.getQuantity())));
}
}
// Allow ±5 tolerance for rounding differences
if (serverTotal.subtract(order.getTotalAmount()).abs().compareTo(new BigDecimal("5")) > 0) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", "Price mismatch detected. Please refresh and try again.",
"serverTotal", serverTotal,
"clientTotal", order.getTotalAmount()
));
}
// Always use server-calculated total
order.setTotalAmount(serverTotal);
// ── Stock check & update ──────────────────────────────────────────
List<Map<String, Object>> stockConflicts = new ArrayList<>();
requestConflicts.remove(); // Clear before use
requestConflicts.remove();
for (OrderItem item : order.getItems()) {
Long productId = item.getProductId();
if (productId != null) {
int updatedRows = productRepository.decrementStock(productId, item.getQuantity());
if (updatedRows == 0) {
com.rit.canteen.sales.model.Product p = productRepository.findById(productId).orElse(null);
Product p = productRepository.findById(productId).orElse(null);
int left = (p != null && p.getStock() != null) ? p.getStock() : 0;
Map<String, Object> conflict = new HashMap<>();
conflict.put("productId", productId);
conflict.put("productName", item.getProductName());
@@ -165,48 +179,31 @@ public class OrderController {
throw new RuntimeException("CONCURRENCY_STOCK_FAILURE");
}
// 3. Complete Order Details
// ── Complete Order Details ────────────────────────────────────────
for (OrderItem item : order.getItems()) {
item.setOrder(order);
if (item.getStallName() == null || item.getStallName().isEmpty() || item.getStallName().equals("Unknown Stall")) {
item.setStallName("RIT Canteen");
}
}
LocalDateTime now = LocalDateTime.now();
order.setCreatedAt(now);
LocalDateTime startOfDay = now.toLocalDate().atStartOfDay();
long todaysOrderCount = orderRepository.countByCreatedAtGreaterThanEqual(startOfDay);
String displayId = String.format("%03d", todaysOrderCount + 1);
order.setDisplayOrderId(displayId);
// 4. Token Payment Check
order.setDisplayOrderId(String.format("%03d", todaysOrderCount + 1));
// ── Token payment ────────────────────────────────────────────────
if ("RITZ_TOKEN".equals(order.getPaymentMethod())) {
try {
// Use userId directly for robustness
tokenService.spend(order.getUserId(), order.getTotalAmount(), "ORD-" + displayId);
tokenService.spend(order.getUserId(), order.getTotalAmount(), "ORD-" + order.getDisplayOrderId());
} catch (RuntimeException e) {
if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) {
throw new RuntimeException("INSUFFICIENT_TOKENS");
}
if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) throw new RuntimeException("INSUFFICIENT_TOKENS");
throw e;
}
}
// 5. Final Save
Order savedOrder = orderRepository.save(order);
System.out.println("[REVENUE-TRACE] Saved Order: " + savedOrder.getOrderNumber() +
" | Display ID: #" + savedOrder.getDisplayOrderId() +
" | CreatedAt: " + savedOrder.getCreatedAt() +
" | Payment: " + savedOrder.getPaymentMethod());
if (savedOrder.getItems() != null) {
savedOrder.getItems().forEach(item ->
System.out.println("[REVENUE-TRACE] Saved Item: " + item.getProductName() +
" | Stall: " + item.getStallName()));
}
return ResponseEntity.ok(Map.of(
"success", true,
"orderNumber", savedOrder.getOrderNumber(),
@@ -215,60 +212,41 @@ public class OrderController {
));
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<?> handleRuntimeException(RuntimeException e) {
if ("CONCURRENCY_STOCK_FAILURE".equals(e.getMessage())) {
List<Map<String, Object>> conflicts = requestConflicts.get();
requestConflicts.remove();
return ResponseEntity.status(400).body(Map.of(
"success", false,
"errorType", "STOCK_ERROR",
"message", "Some items in your cart are no longer available in the requested quantity.",
"conflicts", conflicts != null ? conflicts : new ArrayList<>()
));
}
if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) {
return ResponseEntity.status(400).body(Map.of(
"success", false,
"errorType", "TOKEN_ERROR",
"message", "Insufficient Ritz Tokens. Please top up your wallet."
));
}
return ResponseEntity.status(500).body(Map.of("success", false, "message", e.getMessage() != null ? e.getMessage() : "Internal Server Error"));
}
// ── CUSTOMER: own orders ──────────────────────────────────────────────
@GetMapping("/user/{userId}")
public List<Order> getUserOrders(@PathVariable Long userId) {
return orderRepository.findByUserIdOrderByCreatedAtDesc(userId);
public ResponseEntity<?> getUserOrders(@PathVariable Long userId) {
// Customer can only access their own orders
Long tokenUserId = getTokenUserId();
if (tokenUserId != null && !tokenUserId.equals(userId) && !isStaff()) {
return ResponseEntity.status(403).body(Map.of("error", "Access denied"));
}
return ResponseEntity.ok(orderRepository.findByUserIdOrderByCreatedAtDesc(userId));
}
// ── STAFF/MASTER: order management ────────────────────────────────────
@PatchMapping("/{id}/status")
public ResponseEntity<?> updateOrderStatus(
@PathVariable Long id,
@RequestBody Map<String, String> statusUpdate) {
public ResponseEntity<?> updateOrderStatus(@PathVariable Long id,
@RequestBody Map<String, String> statusUpdate) {
try {
String newStatus = statusUpdate.get("status");
if (newStatus == null || newStatus.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "Status is required"));
}
return orderRepository.findById(id)
.map(order -> {
String oldStatus = order.getStatus();
String nextStatus = newStatus.toUpperCase();
// Check for refund condition: Moving to CANCELLED from a non-cancelled state
if ("CANCELLED".equals(nextStatus) && !"CANCELLED".equals(oldStatus)) {
if ("RITZ_TOKEN".equals(order.getPaymentMethod())) {
tokenService.refund(order.getUserId(), "ORD-" + order.getDisplayOrderId(), order.getTotalAmount(), "Status changed to CANCELLED");
}
}
order.setStatus(nextStatus);
orderRepository.save(order);
return ResponseEntity.ok(Map.of("success", true, "message", "Order status updated to " + nextStatus));
})
.orElse(ResponseEntity.notFound().build());
return orderRepository.findById(id).map(order -> {
String oldStatus = order.getStatus();
String nextStatus = newStatus.toUpperCase();
if ("CANCELLED".equals(nextStatus) && !"CANCELLED".equals(oldStatus)) {
if ("RITZ_TOKEN".equals(order.getPaymentMethod())) {
tokenService.refund(order.getUserId(), "ORD-" + order.getDisplayOrderId(),
order.getTotalAmount(), "Status changed to CANCELLED");
}
}
order.setStatus(nextStatus);
orderRepository.save(order);
return ResponseEntity.ok(Map.of("success", true, "message", "Order status updated to " + nextStatus));
}).orElse(ResponseEntity.notFound().build());
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("error", e.getMessage()));
}
@@ -277,46 +255,81 @@ public class OrderController {
@PutMapping("/{id}")
public ResponseEntity<?> updateOrder(@PathVariable Long id, @RequestBody Order updatedOrder) {
try {
return orderRepository.findById(id)
.map(existingOrder -> {
BigDecimal oldAmount = existingOrder.getTotalAmount();
BigDecimal newAmount = updatedOrder.getTotalAmount();
// Handle Token Adjustments for edited orders
if ("RITZ_TOKEN".equals(existingOrder.getPaymentMethod())) {
int comparison = newAmount.compareTo(oldAmount);
if (comparison > 0) {
// Spend more
tokenService.spend(existingOrder.getUserId(), newAmount.subtract(oldAmount), "ORD-EDIT-" + existingOrder.getDisplayOrderId());
} else if (comparison < 0) {
// This is tricky for individual tokens, but we can refund the difference amount
// For simplicity/robustness, we'll refund the whole order and re-spend the new amount
// to keep unit association clean OR just record it as a topup.
// Let's do a simple balance restoration for the delta.
tokenService.refund(existingOrder.getUserId(), "ORD-" + existingOrder.getDisplayOrderId(), oldAmount, "Order price reduced during edit");
tokenService.spend(existingOrder.getUserId(), newAmount, "ORD-" + existingOrder.getDisplayOrderId());
}
}
return orderRepository.findById(id).map(existingOrder -> {
BigDecimal oldAmount = existingOrder.getTotalAmount();
BigDecimal newAmount = updatedOrder.getTotalAmount();
// Update basic fields
existingOrder.setTotalAmount(newAmount);
existingOrder.setPaymentMethod(updatedOrder.getPaymentMethod());
// Clear and replace items for a clean update
existingOrder.getItems().clear();
if (updatedOrder.getItems() != null) {
for (OrderItem newItem : updatedOrder.getItems()) {
newItem.setOrder(existingOrder);
existingOrder.getItems().add(newItem);
}
}
Order saved = orderRepository.save(existingOrder);
return ResponseEntity.ok(saved);
})
.orElse(ResponseEntity.notFound().build());
if ("RITZ_TOKEN".equals(existingOrder.getPaymentMethod())) {
int comparison = newAmount.compareTo(oldAmount);
if (comparison > 0) {
tokenService.spend(existingOrder.getUserId(), newAmount.subtract(oldAmount),
"ORD-EDIT-" + existingOrder.getDisplayOrderId());
} else if (comparison < 0) {
tokenService.refund(existingOrder.getUserId(), "ORD-" + existingOrder.getDisplayOrderId(),
oldAmount, "Order price reduced during edit");
tokenService.spend(existingOrder.getUserId(), newAmount, "ORD-" + existingOrder.getDisplayOrderId());
}
}
existingOrder.setTotalAmount(newAmount);
existingOrder.setPaymentMethod(updatedOrder.getPaymentMethod());
existingOrder.getItems().clear();
if (updatedOrder.getItems() != null) {
for (OrderItem newItem : updatedOrder.getItems()) {
newItem.setOrder(existingOrder);
existingOrder.getItems().add(newItem);
}
}
Order saved = orderRepository.save(existingOrder);
return ResponseEntity.ok(saved);
}).orElse(ResponseEntity.notFound().build());
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("error", e.getMessage()));
}
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<?> handleRuntimeException(RuntimeException e) {
if ("CONCURRENCY_STOCK_FAILURE".equals(e.getMessage())) {
List<Map<String, Object>> conflicts = requestConflicts.get();
requestConflicts.remove();
return ResponseEntity.status(400).body(Map.of(
"success", false, "errorType", "STOCK_ERROR",
"message", "Some items in your cart are no longer available in the requested quantity.",
"conflicts", conflicts != null ? conflicts : new ArrayList<>()
));
}
if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) {
return ResponseEntity.status(400).body(Map.of(
"success", false, "errorType", "TOKEN_ERROR",
"message", "Insufficient Ritz Tokens. Please top up your wallet."
));
}
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", e.getMessage() != null ? e.getMessage() : "Internal Server Error"
));
}
// ── Helpers ───────────────────────────────────────────────────────────
private Long getTokenUserId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getDetails() instanceof Claims claims) {
Object uid = claims.get("userId");
if (uid != null) {
return uid instanceof Integer ? ((Integer) uid).longValue() : (Long) uid;
}
}
return null;
}
private boolean isStaff() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null) return false;
return auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_MASTER")
|| a.getAuthority().equals("ROLE_MANAGER")
|| a.getAuthority().equals("ROLE_STAFF"));
}
}

View File

@@ -1,11 +1,18 @@
package com.rit.canteen.sales.controller;
import com.rit.canteen.sales.config.JwtUtil;
import com.rit.canteen.sales.config.LoginRateLimiter;
import com.rit.canteen.sales.model.SystemUser;
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.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -17,76 +24,150 @@ public class SystemAuthController {
@Autowired
private SystemUserService userService;
@Autowired
private JwtUtil jwtUtil;
@Autowired
private LoginRateLimiter rateLimiter;
// ── PUBLIC ──────────────────────────────────────────────────────────────
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody Map<String, String> credentials) {
public ResponseEntity<?> login(@RequestBody Map<String, String> credentials,
HttpServletRequest request) {
// Rate limit by IP
String ip = getClientIp(request);
if (!rateLimiter.tryConsume(ip)) {
return ResponseEntity.status(429).body(Map.of(
"error", "Too many login attempts. Please wait 5 minutes."
));
}
String email = credentials.get("email");
String password = credentials.get("password");
Optional<SystemUser> user = userService.authenticate(email, password);
if (user.isPresent()) {
return ResponseEntity.ok(user.get());
Optional<SystemUser> userOpt = userService.authenticate(email, password);
if (userOpt.isPresent()) {
SystemUser user = userOpt.get();
String token = jwtUtil.generateToken(user.getId(), user.getEmail(),
user.getRole(), user.getPermissions());
// Return safe DTO — no password hash
Map<String, Object> response = new HashMap<>();
response.put("token", token);
response.put("id", user.getId());
response.put("name", user.getName());
response.put("email", user.getEmail());
response.put("role", user.getRole());
response.put("permissions", user.getPermissions());
response.put("viewOnly", user.isViewOnly());
return ResponseEntity.ok(response);
} else {
return ResponseEntity.status(401).body("Invalid credentials");
return ResponseEntity.status(401).body(Map.of("error", "Invalid credentials"));
}
}
// ── PROTECTED (requires MASTER or MANAGER JWT) ───────────────────────────
@GetMapping("/managers")
public ResponseEntity<List<SystemUser>> getManagers() {
return ResponseEntity.ok(userService.getAllManagers());
return ResponseEntity.ok(sanitize(userService.getAllManagers()));
}
@PostMapping("/managers")
public ResponseEntity<SystemUser> addManager(@RequestBody SystemUser manager) {
return ResponseEntity.ok(userService.createManager(manager));
public ResponseEntity<?> addManager(@RequestBody SystemUser manager) {
requireRole("MASTER", "MANAGER");
return ResponseEntity.ok(sanitize(userService.createManager(manager)));
}
@DeleteMapping("/managers/{id}")
public ResponseEntity<Void> deleteManager(@PathVariable Long id) {
requireRole("MASTER");
userService.deleteManager(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/staff")
public ResponseEntity<List<SystemUser>> getStaff() {
return ResponseEntity.ok(userService.getAllStaff());
return ResponseEntity.ok(sanitize(userService.getAllStaff()));
}
@PostMapping("/staff")
public ResponseEntity<SystemUser> addStaff(@RequestBody SystemUser staff) {
return ResponseEntity.ok(userService.createStaff(staff));
public ResponseEntity<?> addStaff(@RequestBody SystemUser staff) {
requireRole("MASTER", "MANAGER");
return ResponseEntity.ok(sanitize(userService.createStaff(staff)));
}
@DeleteMapping("/staff/{id}")
public ResponseEntity<Void> deleteStaff(@PathVariable Long id) {
userService.deleteManager(id); // Using existing delete logic
requireRole("MASTER", "MANAGER");
userService.deleteManager(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/admins")
public ResponseEntity<List<SystemUser>> getAdmins() {
return ResponseEntity.ok(userService.getMasters());
requireRole("MASTER");
return ResponseEntity.ok(sanitize(userService.getMasters()));
}
@PostMapping("/admins")
public ResponseEntity<SystemUser> addAdmin(@RequestBody SystemUser admin) {
return ResponseEntity.ok(userService.createMaster(admin));
public ResponseEntity<?> addAdmin(@RequestBody SystemUser admin) {
// Only a MASTER can create another MASTER
requireRole("MASTER");
return ResponseEntity.ok(sanitize(userService.createMaster(admin)));
}
@PostMapping("/update-master")
public ResponseEntity<?> updateMaster(@RequestBody Map<String, Object> data) {
requireRole("MASTER");
try {
Object idObj = data.get("id");
Long id = (idObj != null) ? Long.valueOf(idObj.toString()) : 0L;
String email = (String) data.get("email");
String password = (String) data.get("password");
String name = (String) data.get("name");
userService.updateMasterAccount(id, email, password, name);
return ResponseEntity.ok(Map.of("success", true, "message", "Credentials updated successfully"));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("success", false, "message", e.getMessage()));
}
}
// ── Helpers ─────────────────────────────────────────────────────────────
/** Strips password hash from response objects */
private SystemUser sanitize(SystemUser u) {
u.setPassword("[PROTECTED]");
return u;
}
private List<SystemUser> sanitize(List<SystemUser> users) {
users.forEach(this::sanitize);
return users;
}
/** Asserts the calling JWT has one of the required roles, throws 403 otherwise */
private void requireRole(String... roles) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw new org.springframework.security.access.AccessDeniedException("Not authenticated");
}
if (auth.getDetails() instanceof Claims claims) {
String role = (String) claims.get("role");
for (String r : roles) {
if (r.equals(role)) return;
}
}
throw new org.springframework.security.access.AccessDeniedException(
"Insufficient role. Required: " + String.join(" or ", roles));
}
private String getClientIp(HttpServletRequest request) {
String xfHeader = request.getHeader("X-Forwarded-For");
if (xfHeader != null && !xfHeader.isEmpty()) {
return xfHeader.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}

View File

@@ -9,7 +9,6 @@ import java.util.List;
@RestController
@RequestMapping("/api/notifications")
@CrossOrigin(origins = "*")
public class SystemNotificationController {
@Autowired

View File

@@ -1,15 +1,23 @@
package com.rit.canteen.sales.controller;
import com.rit.canteen.sales.config.JwtUtil;
import com.rit.canteen.sales.config.LoginRateLimiter;
import com.rit.canteen.sales.model.*;
import com.rit.canteen.sales.service.UserService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
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 io.jsonwebtoken.Claims;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
@@ -18,94 +26,95 @@ public class UserController {
@Autowired
private UserService userService;
/**
* Check if a user exists by mobile number.
* POST /api/auth/check
*/
@Autowired
private JwtUtil jwtUtil;
@Autowired
private LoginRateLimiter rateLimiter;
// ── PUBLIC ────────────────────────────────────────────────────────────────
@PostMapping("/check")
public ResponseEntity<LoginResponse> checkUserExists(@Valid @RequestBody LoginRequest request) {
LoginResponse response = userService.checkUserExists(request.getMobileNumber());
return ResponseEntity.ok(response);
}
/**
* Register a new user with mobile number, name and PIN.
* POST /api/auth/register
*/
@PostMapping("/register")
public ResponseEntity<LoginResponse> registerUser(@Valid @RequestBody PinVerificationRequest request) {
LoginResponse response = userService.registerUser(request.getMobileNumber(), request.getName(), request.getPin());
if (response.isSuccess()) {
return ResponseEntity.ok(response);
} else {
return ResponseEntity.badRequest().body(response);
public ResponseEntity<LoginResponse> registerUser(@Valid @RequestBody PinVerificationRequest request,
HttpServletRequest httpRequest) {
String ip = getClientIp(httpRequest);
if (!rateLimiter.tryConsume(ip)) {
return ResponseEntity.status(429).build();
}
LoginResponse response = userService.registerUser(
request.getMobileNumber(), request.getName(), request.getPin());
if (response.isSuccess()) {
// Attach JWT on successful registration
Long userId = response.getUser() != null ? response.getUser().getId() : null;
if (userId != null) {
String token = jwtUtil.generateUserToken(userId, request.getMobileNumber());
response.setToken(token);
}
return ResponseEntity.ok(response);
}
return ResponseEntity.badRequest().body(response);
}
/**
* Verify PIN and login an existing user.
* POST /api/auth/login
*/
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@Valid @RequestBody PinVerificationRequest request) {
public ResponseEntity<LoginResponse> login(@Valid @RequestBody PinVerificationRequest request,
HttpServletRequest httpRequest) {
String ip = getClientIp(httpRequest);
if (!rateLimiter.tryConsume(ip)) {
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());
if (response.isSuccess()) {
Long userId = response.getUser() != null ? response.getUser().getId() : null;
if (userId != null) {
String token = jwtUtil.generateUserToken(userId, request.getMobileNumber());
response.setToken(token);
}
return ResponseEntity.ok(response);
} else {
return ResponseEntity.badRequest().body(response);
}
return ResponseEntity.badRequest().body(response);
}
/**
* Logout a user.
* POST /api/auth/logout
*/
@PostMapping("/logout")
public ResponseEntity<LoginResponse> logout(@Valid @RequestBody LoginRequest request) {
LoginResponse response = userService.logout(request.getMobileNumber());
if (response.isSuccess()) {
return ResponseEntity.ok(response);
} else {
return ResponseEntity.badRequest().body(response);
}
return response.isSuccess() ? ResponseEntity.ok(response) : ResponseEntity.badRequest().body(response);
}
/**
* Change user PIN.
* POST /api/auth/change-pin
*/
// ── AUTHENTICATED (customer token required) ────────────────────────────────
@PostMapping("/change-pin")
public ResponseEntity<LoginResponse> changePin(@Valid @RequestBody ChangePinRequest request) {
LoginResponse response = userService.changePin(request.getMobileNumber(), request.getCurrentPin(), request.getNewPin());
if (response.isSuccess()) {
return ResponseEntity.ok(response);
} else {
return ResponseEntity.badRequest().body(response);
// Extra ownership check: the JWT's mobileNumber must match the request
String tokenMobile = getAuthenticatedMobile();
if (tokenMobile != null && !tokenMobile.equals(request.getMobileNumber())) {
return ResponseEntity.status(403).body(
new LoginResponse(false, "You can only change your own PIN."));
}
LoginResponse response = userService.changePin(
request.getMobileNumber(), request.getCurrentPin(), request.getNewPin());
return response.isSuccess() ? ResponseEntity.ok(response) : ResponseEntity.badRequest().body(response);
}
/**
* Get user details by mobile number.
* GET /api/auth/user/{mobileNumber}
*/
@GetMapping("/user/{mobileNumber}")
public ResponseEntity<LoginResponse.UserDto> getUser(@PathVariable String mobileNumber) {
LoginResponse.UserDto userDto = userService.getUserByMobile(mobileNumber);
if (userDto != null) {
return ResponseEntity.ok(userDto);
} else {
return ResponseEntity.notFound().build();
// Customers can only fetch their own profile; staff can fetch any
String tokenMobile = getAuthenticatedMobile();
if (tokenMobile != null && !tokenMobile.equals(mobileNumber) && !isStaff()) {
return ResponseEntity.status(403).build();
}
LoginResponse.UserDto userDto = userService.getUserByMobile(mobileNumber);
return userDto != null ? ResponseEntity.ok(userDto) : ResponseEntity.notFound().build();
}
/**
* Get all registered users for administration dashboard.
* GET /api/auth/users
*/
/**
* Get all registered users for administration dashboard.
* GET /api/auth/users
*/
// ── STAFF/MASTER ONLY ────────────────────────────────────────────────────────
@GetMapping("/users")
public ResponseEntity<Page<LoginResponse.UserDto>> getAllUsers(
@RequestParam(required = false) String search,
@@ -115,41 +124,53 @@ public class UserController {
return ResponseEntity.ok(users);
}
/**
* Update user details as an administrator.
* PUT /api/auth/users/{id}
*/
@PutMapping("/users/{id}")
public ResponseEntity<LoginResponse.UserDto> updateUser(@PathVariable Long id, @Valid @RequestBody UserUpdateRequest request) {
LoginResponse.UserDto updated = userService.updateUser(id, request.getName(), request.getMobileNumber(), request.getPin());
if (updated != null) {
return ResponseEntity.ok(updated);
} else {
return ResponseEntity.notFound().build();
}
public ResponseEntity<LoginResponse.UserDto> updateUser(
@PathVariable Long id,
@Valid @RequestBody UserUpdateRequest request) {
LoginResponse.UserDto updated = userService.updateUser(id, request.getName(),
request.getMobileNumber(), request.getPin());
return updated != null ? ResponseEntity.ok(updated) : ResponseEntity.notFound().build();
}
/**
* Delete a user from the system.
* DELETE /api/auth/users/{id}
*/
@DeleteMapping("/users/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
return ResponseEntity.noContent().build();
}
/**
* Toggle user suspension status.
* PATCH /api/auth/users/{id}/suspend
*/
@PatchMapping("/users/{id}/suspend")
public ResponseEntity<LoginResponse.UserDto> toggleSuspension(@PathVariable Long id) {
LoginResponse.UserDto updated = userService.toggleSuspension(id);
if (updated != null) {
return ResponseEntity.ok(updated);
} else {
return ResponseEntity.notFound().build();
return updated != null ? ResponseEntity.ok(updated) : ResponseEntity.notFound().build();
}
// ── Helpers ──────────────────────────────────────────────────────────────
private String getAuthenticatedMobile() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getDetails() instanceof Claims claims) {
// Customer tokens have the mobile as subject
String type = (String) claims.get("type");
if ("customer".equals(type)) {
return claims.getSubject();
}
}
return null; // Staff or system token — not a customer
}
private boolean isStaff() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null) return false;
return auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().startsWith("ROLE_MASTER")
|| a.getAuthority().startsWith("ROLE_MANAGER")
|| a.getAuthority().startsWith("ROLE_STAFF"));
}
private String getClientIp(HttpServletRequest request) {
String xfHeader = request.getHeader("X-Forwarded-For");
if (xfHeader != null && !xfHeader.isEmpty()) return xfHeader.split(",")[0].trim();
return request.getRemoteAddr();
}
}

View File

@@ -4,8 +4,11 @@ import com.rit.canteen.sales.model.TokenTransaction;
import com.rit.canteen.sales.model.User;
import com.rit.canteen.sales.service.TokenService;
import com.rit.canteen.sales.service.UserService;
import io.jsonwebtoken.Claims;
import org.springframework.beans.factory.annotation.Autowired;
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 java.math.BigDecimal;
@@ -18,17 +21,24 @@ import java.util.stream.Collectors;
@RequestMapping("/api/wallet")
public class WalletController {
private static final BigDecimal MAX_TOPUP = new BigDecimal("5000");
@Autowired
private com.rit.canteen.sales.repository.UserRepository userRepository;
@Autowired
private com.rit.canteen.sales.service.TokenService tokenService;
// ── CUSTOMER: own balance only ─────────────────────────────────────────
@GetMapping("/balance/{userId}")
public ResponseEntity<?> getBalance(@PathVariable Long userId) {
// Customers can only read their own balance
if (!canAccessUser(userId)) {
return ResponseEntity.status(403).body(Map.of("error", "Access denied"));
}
try {
User user = userRepository.findById(userId).orElse(null);
if (user == null) return ResponseEntity.notFound().build();
return ResponseEntity.ok(Map.of("balance", user.getRitzTokenBalance()));
} catch (Exception e) {
@@ -36,6 +46,16 @@ public class WalletController {
}
}
@GetMapping("/transactions/{userId}")
public ResponseEntity<?> getTransactions(@PathVariable Long userId) {
if (!canAccessUser(userId)) {
return ResponseEntity.status(403).body(Map.of("error", "Access denied"));
}
return ResponseEntity.ok(tokenService.getTransactions(userId));
}
// ── STAFF/MASTER: wallet management ────────────────────────────────────
@GetMapping("/users")
public ResponseEntity<List<Map<String, Object>>> getUsers() {
List<User> users = userRepository.findAll();
@@ -50,18 +70,22 @@ public class WalletController {
return ResponseEntity.ok(userList);
}
@GetMapping("/transactions/{userId}")
public ResponseEntity<List<TokenTransaction>> getTransactions(@PathVariable Long userId) {
return ResponseEntity.ok(tokenService.getTransactions(userId));
}
@PostMapping("/topup")
public ResponseEntity<?> topUp(@RequestBody Map<String, Object> request) {
try {
Long userId = Long.valueOf(request.get("userId").toString());
BigDecimal amount = new BigDecimal(request.get("amount").toString());
String ref = request.getOrDefault("referenceId", "TOPUP-" + System.currentTimeMillis()).toString();
// ── FIX: validate amount BEFORE touching the database ──
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
return ResponseEntity.badRequest().body(Map.of("error", "Amount must be positive"));
}
if (amount.compareTo(MAX_TOPUP) > 0) {
return ResponseEntity.badRequest().body(
Map.of("error", "Single transaction limit exceeded (Max: 5,000 Ritz Tokens)"));
}
String ref = request.getOrDefault("referenceId", "TOPUP-" + System.currentTimeMillis()).toString();
User updatedUser = tokenService.topUp(userId, amount, ref);
return ResponseEntity.ok(Map.of(
"success", true,
@@ -89,4 +113,27 @@ public class WalletController {
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(tokenService.getAllCirculation(page, size));
}
// ── Helpers ─────────────────────────────────────────────────────────────
/**
* Returns true if the caller is a staff/manager/master, OR is the specific customer user.
*/
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;
}
}

View File

@@ -6,6 +6,7 @@ public class LoginResponse {
private String message;
private boolean userExists;
private UserDto user;
private String token; // JWT for customer session
public LoginResponse() {}
@@ -38,6 +39,9 @@ public class LoginResponse {
public UserDto getUser() { return user; }
public void setUser(UserDto user) { this.user = user; }
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
public static class UserDto {
private Long id;
private String mobileNumber;

View File

@@ -40,7 +40,7 @@ public class SystemUserService {
master.setPermissions(List.of("dashboard", "sale", "customers", "purchases", "inventory", "expense", "reports", "stores", "table", "wallet", "promotions", "feedback"));
master.setViewOnly(false);
repository.save(master);
System.out.println(">>> SEEDED DEFAULT MASTER USER (Failsafe Source): " + masterUsername + " / " + masterPassword);
System.out.println(">>> SEEDED DEFAULT MASTER USER from failsafe source.");
} else {
System.out.println(">>> MASTER USER(S) FOUND IN DATABASE. Skipping default seeding.");
}

View File

@@ -67,29 +67,32 @@ public class TokenService {
User user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("User not found"));
int tokenCount = amount.intValue(); // Assuming 1 Ritz = 1 Serialized Unit
// ── FIX: Validate BEFORE touching the database ──
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new RuntimeException("Amount must be positive");
}
if (amount.compareTo(new BigDecimal("5000")) > 0) {
throw new RuntimeException("Single transaction limit exceeded (Max: 5,000 Ritz Tokens)");
}
int tokenCount = amount.intValue();
System.out.println("MINTING: Serializing " + tokenCount + " Ritz tokens for user " + userId);
// High-Performance Batch Insertion using JDBC
String sql = "INSERT INTO token_units (token_hash, owner_id, status, created_at) VALUES (?, ?, 'ACTIVE', ?)";
List<Object[]> batchArgs = new ArrayList<>();
LocalDateTime now = LocalDateTime.now();
for (int i = 0; i < tokenCount; i++) {
String hash = generateSecureHash();
batchArgs.add(new Object[]{hash, userId, now});
}
jdbcTemplate.batchUpdate(sql, batchArgs);
// Update cached balance
BigDecimal currentBalance = user.getRitzTokenBalance() != null ? user.getRitzTokenBalance() : BigDecimal.ZERO;
BigDecimal newBalance = currentBalance.add(amount);
if (amount.compareTo(new BigDecimal("5000")) > 0) {
throw new RuntimeException("Single transaction limit exceeded (Max: 5,000 Ritz Tokens)");
}
user.setRitzTokenBalance(newBalance);
User savedUser = userRepository.save(user);
@@ -105,6 +108,7 @@ public class TokenService {
return savedUser;
}
@Transactional
public void spend(Long userId, BigDecimal amount, String orderRef) {
// High Concurrency Lock: Ensure no other thread modifies this user balance simultaneously

View File

@@ -1,20 +1,39 @@
spring.application.name=backend
server.address=0.0.0.0
spring.datasource.url=jdbc:postgresql://localhost:5432/positeasy
spring.datasource.username=postgres
spring.datasource.password=sidharth
# ============================================================
# DATABASE — use environment variables in production
# Set DB_URL, DB_USER, DB_PASSWORD as env vars before running
# ============================================================
spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/positeasy}
spring.datasource.username=${DB_USER:postgres}
spring.datasource.password=${DB_PASSWORD:sidharth}
spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.show-sql=false
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.properties.hibernate.jdbc.time_zone=Asia/Kolkata
# Master Account Credentials
app.master.username=admin
app.master.password=admin
# ============================================================
# JWT — CHANGE THIS SECRET IN PRODUCTION (min 256-bit key)
# Set JWT_SECRET as an environment variable before running
# ============================================================
app.jwt.secret=${JWT_SECRET:3RiTCaNtEeNsUpErSeCrEtKeY2026!!xYzAbCdEfGhIjKlMn}
app.jwt.expiration-ms=86400000
# ============================================================
# Master Account — use environment variables in production
# ============================================================
app.master.username=${MASTER_USER:admin}
app.master.password=${MASTER_PASSWORD:admin}
# File upload configuration
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
# ============================================================
# CORS — comma-separated list of allowed frontend origins
# Set APP_CORS_ORIGINS as env var in production
# ============================================================
app.cors.allowed-origins=${APP_CORS_ORIGINS:http://localhost:5173,http://localhost:5174,http://localhost:3000}