comment security and config

This commit is contained in:
2026-04-20 12:02:17 -06:00
parent b6f8131b2e
commit 9c94ba41fb
22 changed files with 244 additions and 0 deletions

View File

@@ -1,3 +1,11 @@
/*
* Logs write operations (POST, PUT, DELETE) to the activity log.
* Skips GET requests and internal endpoints like health checks.
* Builds a human-readable description for each action.
*
* Author: Harkamal
* Date: April 2026
*/
package com.petshop.backend.config;
import com.petshop.backend.entity.User;
@@ -77,11 +85,24 @@ public class ActivityLoggingFilter extends OncePerRequestFilter {
activityLogService.record(userId, activity);
}
/**
* Turns a request method + URL + status into a human-readable description
* like "Created a new product" or "Failed login attempt".
* Parses the URL as /api/v1/{resource}/{id?}/{sub?}/{subsub?} and matches
* against known patterns.
* @param method HTTP method (GET, POST, etc.)
* @param rawUri the request URI, possibly with query params
* @param status the HTTP response status code
* @return a readable description, or null if we don't recognize the pattern
*/
private String describe(String method, String rawUri, int status) {
// Strip query params so we only deal with the path
String uri = rawUri.contains("?") ? rawUri.substring(0, rawUri.indexOf('?')) : rawUri;
// Split into segments: [api, v1, resource, id/action, sub, ...]
String[] parts = uri.replaceFirst("^/+", "").split("/");
if (parts.length < 3) return null;
// parts[2] is the resource name (e.g. "products", "auth", "cart")
String r = parts[2];
String seg3 = parts.length > 3 ? parts[3] : null;
String seg4 = parts.length > 4 ? parts[4] : null;
@@ -89,6 +110,8 @@ public class ActivityLoggingFilter extends OncePerRequestFilter {
boolean seg3IsId = seg3 != null && seg3.matches("\\d+");
boolean seg4IsId = seg4 != null && seg4.matches("\\d+");
// Normalize: if seg3 is a numeric ID, treat seg4 as the sub-action
// otherwise seg3 itself is the sub-action (like "login", "add", etc.)
String id = seg3IsId ? seg3 : null;
String sub = seg3IsId ? seg4 : seg3;
String subsub = seg3IsId ? seg5 : seg4;

View File

@@ -1,3 +1,9 @@
/*
* Registers the activity logging filter with the servlet container.
*
* Author: Harkamal
* Date: April 2026
*/
package com.petshop.backend.config;
import org.springframework.boot.web.servlet.FilterRegistrationBean;

View File

@@ -1,3 +1,9 @@
/*
* Runs once on startup to mark past appointments as completed.
*
* Author: Harkamal
* Date: April 2026
*/
package com.petshop.backend.config;
import com.petshop.backend.service.AppointmentService;

View File

@@ -1,3 +1,10 @@
/*
* Business settings loaded from application.yml (store hours,
* slot intervals, image size limits, discount/loyalty config).
*
* Author: Harkamal
* Date: April 2026
*/
package com.petshop.backend.config;
import org.springframework.boot.context.properties.ConfigurationProperties;

View File

@@ -1,3 +1,10 @@
/*
* Sets up Caffeine caching for the app. Currently used to cache
* user auth lookups so the filter doesn't query the DB every time.
*
* Author: Harkamal
* Date: April 2026
*/
package com.petshop.backend.config;
import com.github.benmanes.caffeine.cache.Caffeine;

View File

@@ -1,3 +1,11 @@
/*
* Creates default admin, staff, and customer accounts on startup
* if they don't already exist. Also fills in missing fields on
* existing accounts to keep them consistent.
*
* Author: Harkamal
* Date: April 2026
*/
package com.petshop.backend.config;
import com.petshop.backend.entity.User;

View File

@@ -1,3 +1,10 @@
/*
* Runs Flyway migrations before the app starts. Retries up to
* 15 times so the app can wait for the database container to be ready.
*
* Author: Harkamal
* Date: April 2026
*/
package com.petshop.backend.config;
import org.flywaydb.core.Flyway;

View File

@@ -1,3 +1,10 @@
/*
* Adds extra pet and product seed data in the local dev profile
* if the database only has the base seed rows.
*
* Author: Harkamal
* Date: April 2026
*/
package com.petshop.backend.config;
import com.petshop.backend.repository.PetRepository;

View File

@@ -1,3 +1,10 @@
/*
* Tells Tomcat to accept backslashes in URLs so the Android
* and desktop clients don't get rejected for path issues.
*
* Author: Harkamal
* Date: April 2026
*/
package com.petshop.backend.config;
import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory;

View File

@@ -1,3 +1,10 @@
/*
* Strips trailing slashes and normalizes paths so /api/pets/
* and /api/pets both hit the same controller.
*
* Author: Harkamal
* Date: April 2026
*/
package com.petshop.backend.config;
import jakarta.servlet.FilterChain;
@@ -63,6 +70,12 @@ public class TrailingSlashNormalizationFilter extends OncePerRequestFilter {
filterChain.doFilter(wrapper, response);
}
/**
* Cleans up a URL path by fixing backslashes, collapsing double slashes,
* lowercasing API/WS paths, and stripping trailing slashes.
* @param value the raw path string
* @return the normalized path, or null if input was null
*/
private String normalizePath(String value) {
if (value == null) {
return null;
@@ -74,6 +87,7 @@ public class TrailingSlashNormalizationFilter extends OncePerRequestFilter {
if (shouldLowercase(normalized)) {
normalized = normalized.toLowerCase(java.util.Locale.ROOT);
}
// Strip trailing slashes but keep the root "/" intact
int end = normalized.length();
while (end > 1 && normalized.charAt(end - 1) == '/') {
end--;

View File

@@ -1,3 +1,11 @@
/*
* Intercepts WebSocket messages to authenticate and authorize users.
* Validates the token on CONNECT, then checks permissions on
* SUBSCRIBE and SEND for chat topics.
*
* Author: Harkamal
* Date: April 2026
*/
package com.petshop.backend.config;
import com.petshop.backend.entity.User;
@@ -33,6 +41,14 @@ public class WebSocketAuthChannelInterceptor implements ChannelInterceptor {
this.chatService = chatService;
}
/**
* Intercepts every STOMP message before it's sent. On CONNECT it validates the
* JWT and stores the authenticated user. On SUBSCRIBE/SEND it checks that the
* user has access to the requested chat destination.
* @param message the STOMP message being sent
* @param channel the channel the message is going through
* @return the original message if everything checks out
*/
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
@@ -95,6 +111,14 @@ public class WebSocketAuthChannelInterceptor implements ChannelInterceptor {
return message;
}
/**
* Tries to figure out who the user is from multiple sources: the principal on the
* message, the session attributes (fallback for some STOMP clients), or by
* re-parsing the token from headers as a last resort.
* @param principal the principal attached to the message, may be null
* @param accessor the STOMP header accessor for fallback lookups
* @return the User entity, or null if unauthenticated
*/
private User resolveUser(Principal principal, StompHeaderAccessor accessor) {
Principal currentPrincipal = principal;
if (currentPrincipal == null && accessor.getSessionAttributes() != null) {
@@ -136,6 +160,13 @@ public class WebSocketAuthChannelInterceptor implements ChannelInterceptor {
return user;
}
/**
* Checks if the user is allowed to subscribe to this destination.
* Customers can only subscribe to their own conversations, not the
* staff-wide conversation feed.
* @param destination the STOMP destination being subscribed to
* @param user the user trying to subscribe
*/
private void authorizeSubscription(String destination, User user) {
destination = normalizeDestination(destination);
if (destination == null || destination.startsWith("/user/queue/")) {
@@ -157,6 +188,12 @@ public class WebSocketAuthChannelInterceptor implements ChannelInterceptor {
throw new IllegalArgumentException("Not authorized to subscribe to destination");
}
/**
* Checks if the user is allowed to send a message to this destination.
* Only allows sending to conversation message endpoints that the user has access to.
* @param destination the STOMP destination being sent to
* @param user the user trying to send
*/
private void authorizeSend(String destination, User user) {
destination = normalizeDestination(destination);
Long conversationId = extractConversationId(destination, "/app/chat/conversations/");

View File

@@ -1,3 +1,10 @@
/*
* WebSocket configuration for the live chat feature.
* Registers STOMP endpoints and the auth interceptor.
*
* Author: Harkamal
* Date: April 2026
*/
package com.petshop.backend.config;
import org.springframework.context.annotation.Configuration;

View File

@@ -1,3 +1,10 @@
/*
* Represents the currently logged-in user in the security context.
* Holds the user ID, username, role, and token version.
*
* Author: Harkamal
* Date: April 2026
*/
package com.petshop.backend.security;
import com.petshop.backend.entity.User;

View File

@@ -1,3 +1,10 @@
/*
* Filter that runs on every request to validate the JWT token
* and set up the security context if the token is valid.
*
* Author: Harkamal
* Date: April 2026
*/
package com.petshop.backend.security;
import com.petshop.backend.entity.User;
@@ -30,12 +37,20 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
this.apiErrorResponder = apiErrorResponder;
}
/**
* Extracts the JWT from the Authorization header, validates it, and sets up
* the Spring Security context so downstream filters/controllers know who the user is.
* @param request the incoming HTTP request
* @param response the HTTP response (used to write 401 errors)
* @param filterChain the remaining filters to invoke
*/
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
// Skip requests that don't have a Bearer token
final String authHeader = request.getHeader("Authorization");
final String jwt;
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
@@ -43,6 +58,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
return;
}
// Strip "Bearer " prefix to get the raw token
jwt = authHeader.substring(7);
Long userId;
String username;
@@ -58,6 +74,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
return;
}
// Only set up auth context if there isn't one already for this request
if (userId != null && SecurityContextHolder.getContext().getAuthentication() == null) {
if (jwtUtil.extractExpiration(jwt).before(new Date())) {
writeUnauthorized(request, response, "Invalid or expired token", null);
@@ -69,6 +86,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
writeUnauthorized(request, response, "User account is inactive", null);
return;
}
// Token version mismatch means the user logged out or changed password
if (!authData.tokenVersion().equals(jwtTokenVersion)) {
writeUnauthorized(request, response, "Invalid or expired token", null);
return;
@@ -82,6 +100,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
return;
}
// Build the authentication object and store it in the security context
AppPrincipal principal = new AppPrincipal(userId, username, role, jwtTokenVersion);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
principal,

View File

@@ -1,3 +1,10 @@
/*
* JWT token utility for generating, parsing, and validating tokens.
* Tokens store userId, username, role, and a version for invalidation.
*
* Author: Harkamal
* Date: April 2026
*/
package com.petshop.backend.security;
import com.petshop.backend.entity.User;
@@ -50,6 +57,12 @@ public class JwtUtil {
return extractAllClaims(token).get("role", String.class);
}
/**
* Gets the token version from the JWT, used to invalidate old tokens
* when the user logs out or changes their password.
* @param token the JWT string
* @return the token version number, or null if not present
*/
public Integer extractTokenVersion(String token) {
Number tokenVersion = extractAllClaims(token).get("tokenVersion", Number.class);
return tokenVersion == null ? null : tokenVersion.intValue();
@@ -76,6 +89,11 @@ public class JwtUtil {
return extractExpiration(token).before(new Date());
}
/**
* Creates a new JWT containing the user's id, username, role, and token version.
* @param user the user to generate a token for
* @return the signed JWT string
*/
public String generateToken(User user) {
Map<String, Object> claims = new HashMap<>();
claims.put("username", user.getUsername());
@@ -94,6 +112,13 @@ public class JwtUtil {
.compact();
}
/**
* Checks that the token belongs to this user, has the right role and version,
* and hasn't expired yet.
* @param token the JWT string
* @param user the user to validate against
* @return true if the token is valid for this user
*/
public Boolean validateToken(String token, User user) {
Long userId = extractUserId(token);
String role = extractRole(token);

View File

@@ -1,3 +1,10 @@
/*
* Filter that applies rate limiting to auth endpoints.
* Each rule defines max requests and window size in minutes.
*
* Author: Harkamal
* Date: April 2026
*/
package com.petshop.backend.security;
import com.petshop.backend.exception.ApiErrorResponder;

View File

@@ -1,3 +1,10 @@
/*
* Sliding-window rate limiter that tracks request timestamps per key.
* Used to limit login/register attempts by IP.
*
* Author: Harkamal
* Date: April 2026
*/
package com.petshop.backend.security;
import org.springframework.scheduling.annotation.Scheduled;
@@ -15,12 +22,22 @@ public class RateLimiterService {
private final Map<String, Deque<Instant>> buckets = new ConcurrentHashMap<>();
/**
* Checks whether a request identified by key is within the rate limit.
* Uses a sliding window: keeps a queue of timestamps per key, drops any
* that fall outside the window, then checks if there's room for one more.
* @param key identifier for the rate limit bucket (e.g. IP address)
* @param maxRequests max number of requests allowed in the window
* @param window the time window to count requests in
* @return true if the request is allowed, false if rate limited
*/
public boolean isAllowed(String key, int maxRequests, Duration window) {
Instant now = Instant.now();
Instant windowStart = now.minus(window);
Deque<Instant> timestamps = buckets.computeIfAbsent(key, k -> new ArrayDeque<>());
synchronized (timestamps) {
// Remove timestamps that are older than the window
while (!timestamps.isEmpty() && timestamps.peekFirst().isBefore(windowStart)) {
timestamps.pollFirst();
}

View File

@@ -1,3 +1,10 @@
/*
* Returns a JSON error when a logged-in user tries to access
* something they don't have permission for.
*
* Author: Harkamal
* Date: April 2026
*/
package com.petshop.backend.security;
import com.petshop.backend.exception.ApiErrorResponder;

View File

@@ -1,3 +1,9 @@
/*
* Returns a JSON error when an unauthenticated request hits a protected endpoint.
*
* Author: Harkamal
* Date: April 2026
*/
package com.petshop.backend.security;
import com.petshop.backend.exception.ApiErrorResponder;

View File

@@ -1,3 +1,10 @@
/*
* Security setup for the app. Defines which endpoints are public,
* which need login, and configures the filter chain.
*
* Author: Harkamal
* Date: April 2026
*/
package com.petshop.backend.security;
import org.springframework.beans.factory.annotation.Value;

View File

@@ -1,3 +1,10 @@
/*
* Caches user auth data (active status, token version) so the
* JWT filter doesn't hit the database on every request.
*
* Author: Harkamal
* Date: April 2026
*/
package com.petshop.backend.security;
import com.petshop.backend.repository.UserRepository;

View File

@@ -1,3 +1,9 @@
/*
* Loads user details from the database for Spring Security login.
*
* Author: Harkamal
* Date: April 2026
*/
package com.petshop.backend.security;
import com.petshop.backend.entity.User;