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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ bin/
out/
!**/src/main/**/out/
!**/src/test/**/out/
**/application-local.properties

### NetBeans ###
/nbproject/private/
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
# P2P-Shopping

Please check [CONTRIBUTING.md](/docs/CONTRIBUTING.md) for guidelines, and [HELP.md](/docs/HELP.md) for help and reference documentation.

## Local development

You may create a local properties file at `src/main/resources/application-local.properties`, which overrides matching keys from `application.properties`.
To use it, run with the `local` Spring profile:

```bash
SPRING_PROFILES_ACTIVE=local ./gradlew bootRun
```

In IntelliJ IDEA, you can set this up by following these steps:

- Click the configurations dropdown
- Click **Edit Configurations...**
- Select the only configuration under **Spring Boot**
- In the right pane, type *local* in the **Active profiles** box.
4 changes: 2 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ dependencies {

testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.junit.platform:junit-platform-suite-api")
testImplementation("org.testcontainers:testcontainers:1.19.0")
testImplementation("org.testcontainers:postgresql:1.19.0")
testImplementation("org.testcontainers:testcontainers")
testImplementation("org.testcontainers:postgresql:1.21.4")
testRuntimeOnly("org.junit.platform:junit-platform-suite-engine")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
Expand Down
60 changes: 35 additions & 25 deletions src/main/java/com/p2ps/auth/security/JwtAuthFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,41 +22,51 @@ public JwtAuthFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}

@Override
public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
public String extractBearerToken(String authorizationHeader) {
if (authorizationHeader == null || authorizationHeader.isBlank()) {
return null;
}

String token = null;
String userEmail = null;
String token = authorizationHeader.trim();

// Extract token from Authorization header
String authorizationHeader = request.getHeader("Authorization");
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
token = authorizationHeader.substring(7);
if (token.regionMatches(true, 0, "Bearer ", 0, 7)) {
return token.substring(7).trim();
}

return token;
}

public UsernamePasswordAuthenticationToken authenticateToken(String token) {
if (token == null || token.isBlank()) {
return null;
}

try {
if (token != null) {
userEmail = jwtUtil.extractEmail(token);
String userEmail = jwtUtil.extractEmail(token);

if (userEmail != null && !jwtUtil.isTokenExpired(token)) {
return new UsernamePasswordAuthenticationToken(userEmail, null, new ArrayList<>());
}
} catch (Exception _) {
return null;
}

// Authenticate if user is not already in the SecurityContext
if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null && !jwtUtil.isTokenExpired(token)){
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userEmail, null, new ArrayList<>()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
return null;
}

// Set user as authenticated
SecurityContextHolder.getContext().setAuthentication(authToken);
}
@Override
public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {

} catch (Exception _) {
// Clear context if token is expired, malformed, or invalid
SecurityContextHolder.clearContext();
String token = extractBearerToken(request.getHeader("Authorization"));

UsernamePasswordAuthenticationToken authToken = authenticateToken(token);

if (authToken != null && SecurityContextHolder.getContext().getAuthentication() == null) {
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}

// Continue the filter chain
filterChain.doFilter(request, response);
}
}
}
2 changes: 2 additions & 0 deletions src/main/java/com/p2ps/auth/security/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/ws/**").permitAll()
.requestMatchers("/").permitAll()
.anyRequest().authenticated()
)
.sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
Expand Down
64 changes: 64 additions & 0 deletions src/main/java/com/p2ps/config/JwtHandshakeInterceptor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.p2ps.config;

import com.p2ps.auth.security.JwtAuthFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;
import org.springframework.web.util.UriComponentsBuilder;

import java.util.Map;

@Component
public class JwtHandshakeInterceptor implements HandshakeInterceptor {

public static final String SESSION_TOKEN_ATTRIBUTE = "wsJwtToken";

private static final Logger logger = LoggerFactory.getLogger(JwtHandshakeInterceptor.class);

private final JwtAuthFilter jwtAuthFilter;
private final boolean enableUrlToken;

public JwtHandshakeInterceptor(JwtAuthFilter jwtAuthFilter,
@Value("${websocket.compatibility.enableUrlToken:false}") boolean enableUrlToken) {
this.jwtAuthFilter = jwtAuthFilter;
this.enableUrlToken = enableUrlToken;
}

@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,
Map<String, Object> attributes) {
if (!enableUrlToken) {
return true;
}

String token = UriComponentsBuilder.fromUri(request.getURI())
.build()
.getQueryParams()
.getFirst("token");

if (token == null || token.isBlank()) {
return true;
}

if (jwtAuthFilter.authenticateToken(token) == null) {
logger.warn("Rejecting websocket handshake with invalid JWT query token");
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return false;
}

attributes.put(SESSION_TOKEN_ATTRIBUTE, token);
return true;
}

@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,
Exception exception) {
// No-op.
}
}
11 changes: 9 additions & 2 deletions src/main/java/com/p2ps/config/RoomSubscriptionInterceptor.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.lang.Nullable;
import org.jspecify.annotations.Nullable;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.stereotype.Component;
import org.springframework.security.core.Authentication;

import java.security.Principal;
import java.util.regex.Pattern;

/**
Expand Down Expand Up @@ -41,9 +43,14 @@ public Message<?> preSend(Message<?> message, MessageChannel channel) {
String destination = accessor.getDestination();

if (destination != null && destination.startsWith("/topic/list/")) {
Principal principal = accessor.getUser();
if (!(principal instanceof Authentication authentication) || !authentication.isAuthenticated()) {
logger.warn("Security Alert: Blocked subscription attempt without authenticated principal");
return null;
}

String listId = destination.substring("/topic/list/".length());

// Security Check: Only allow alphanumeric list IDs (plus hyphens). Prevents directory traversal or wildcard injection.
if (!VALID_LIST_ID.matcher(listId).matches()) {
logger.warn("Security Alert: Blocked malformed room subscription attempt");
return null;
Expand Down
99 changes: 99 additions & 0 deletions src/main/java/com/p2ps/config/StompJwtAuthInterceptor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package com.p2ps.config;

import com.p2ps.auth.security.JwtAuthFilter;
import org.jspecify.annotations.Nullable;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.stereotype.Component;

import java.util.Map;

@Component
public class StompJwtAuthInterceptor implements ChannelInterceptor {

private final JwtAuthFilter jwtAuthFilter;

public StompJwtAuthInterceptor(JwtAuthFilter jwtAuthFilter) {
this.jwtAuthFilter = jwtAuthFilter;
}

@Override
@Nullable
public Message<?> preSend(Message<?> message, MessageChannel channel) {

Check failure on line 29 in src/main/java/com/p2ps/config/StompJwtAuthInterceptor.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Fix the incompatibility of the annotation @Nullable to honor @NullMarked at package level of the overridden method.

See more on https://sonarcloud.io/project/issues?id=P2P-Shopping_P2P-Shopping&issues=AZ1KX1FjPVQoes3Yr0EF&open=AZ1KX1FjPVQoes3Yr0EF&pullRequest=134
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);

if (StompCommand.CONNECT.equals(accessor.getCommand())) {
UsernamePasswordAuthenticationToken authentication = resolveAuthentication(accessor);

if (authentication != null) {
accessor.setUser(authentication);
return MessageBuilder.fromMessage(message)
.setHeader(SimpMessageHeaderAccessor.USER_HEADER, authentication)
.build();
}

return message;
}

return message;
}

private UsernamePasswordAuthenticationToken resolveAuthentication(StompHeaderAccessor accessor) {
String token = resolveToken(accessor);
if (token == null) {
return null;
}

UsernamePasswordAuthenticationToken authentication = jwtAuthFilter.authenticateToken(token);
if (authentication == null) {
throw new BadCredentialsException("Invalid JWT token");
}
return authentication;
}

private String resolveToken(StompHeaderAccessor accessor) {
String headerToken = accessor.getFirstNativeHeader("Authorization");
if (headerToken == null) {
headerToken = accessor.getFirstNativeHeader("authorization");
}
if (headerToken == null) {
headerToken = accessor.getFirstNativeHeader("token");
}
if (headerToken == null) {
headerToken = accessor.getFirstNativeHeader("access_token");
}

if (headerToken != null) {
return extractBearerToken(headerToken);
}

Map<String, Object> sessionAttributes = accessor.getSessionAttributes();
if (sessionAttributes == null) {
return null;
}

Object sessionToken = sessionAttributes.get(JwtHandshakeInterceptor.SESSION_TOKEN_ATTRIBUTE);
return sessionToken instanceof String string ? string : null;
}

private String extractBearerToken(String authorizationHeader) {
if (authorizationHeader == null || authorizationHeader.isBlank()) {
return null;
}

String token = authorizationHeader.trim();
if (token.regionMatches(true, 0, "Bearer ", 0, 7)) {
return token.substring(7).trim();
}

return token;
}

}
13 changes: 10 additions & 3 deletions src/main/java/com/p2ps/config/WebSocketConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,20 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Value("${app.cors.allowed-origins}")
private String[] allowedOrigins;

private final JwtHandshakeInterceptor jwtHandshakeInterceptor;
private final StompJwtAuthInterceptor stompJwtAuthInterceptor;
private final RoomSubscriptionInterceptor subscriptionInterceptor;

/**
* Constructor injection for the subscription security interceptor.
* @param subscriptionInterceptor the interceptor validating inbound traffic
*/
@Autowired
public WebSocketConfig(RoomSubscriptionInterceptor subscriptionInterceptor) {
public WebSocketConfig(JwtHandshakeInterceptor jwtHandshakeInterceptor,
StompJwtAuthInterceptor stompJwtAuthInterceptor,
RoomSubscriptionInterceptor subscriptionInterceptor) {
this.jwtHandshakeInterceptor = jwtHandshakeInterceptor;
this.stompJwtAuthInterceptor = stompJwtAuthInterceptor;
this.subscriptionInterceptor = subscriptionInterceptor;
}

Expand All @@ -40,6 +46,7 @@ public WebSocketConfig(RoomSubscriptionInterceptor subscriptionInterceptor) {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.addInterceptors(jwtHandshakeInterceptor)
.setAllowedOriginPatterns(allowedOrigins)
.withSockJS();
}
Expand All @@ -62,6 +69,6 @@ public void configureMessageBroker(MessageBrokerRegistry config) {
*/
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(subscriptionInterceptor);
registration.interceptors(stompJwtAuthInterceptor, subscriptionInterceptor);
}
}
}
Loading
Loading