Skip to content

Commit e7fd3c1

Browse files
Merge branch 'develop' into copilot/enhance-message-timestamps-ui
# Conflicts: # webapp/src/main/resources/templates/chat.html
2 parents 158cd7d + 87807a4 commit e7fd3c1

28 files changed

+1690
-143
lines changed

backend/pom.xml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,38 @@
5151
<scope>runtime</scope>
5252
</dependency>
5353

54+
<!-- Spring Security -->
55+
<dependency>
56+
<groupId>org.springframework.boot</groupId>
57+
<artifactId>spring-boot-starter-security</artifactId>
58+
</dependency>
59+
60+
<!-- JWT -->
61+
<dependency>
62+
<groupId>io.jsonwebtoken</groupId>
63+
<artifactId>jjwt-api</artifactId>
64+
<version>0.11.5</version>
65+
</dependency>
66+
<dependency>
67+
<groupId>io.jsonwebtoken</groupId>
68+
<artifactId>jjwt-impl</artifactId>
69+
<version>0.11.5</version>
70+
<scope>runtime</scope>
71+
</dependency>
72+
<dependency>
73+
<groupId>io.jsonwebtoken</groupId>
74+
<artifactId>jjwt-jackson</artifactId>
75+
<version>0.11.5</version>
76+
<scope>runtime</scope>
77+
</dependency>
78+
79+
<!-- Spring Security Test -->
80+
<dependency>
81+
<groupId>org.springframework.security</groupId>
82+
<artifactId>spring-security-test</artifactId>
83+
<scope>test</scope>
84+
</dependency>
85+
5486
<!-- Spring Boot Test -->
5587
<dependency>
5688
<groupId>org.springframework.boot</groupId>
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package com.accord.config;
2+
3+
import com.accord.security.JwtAuthenticationFilter;
4+
import org.springframework.beans.factory.annotation.Autowired;
5+
import org.springframework.context.annotation.Bean;
6+
import org.springframework.context.annotation.Configuration;
7+
import org.springframework.security.authentication.AuthenticationManager;
8+
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
9+
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
10+
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
11+
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
12+
import org.springframework.security.config.http.SessionCreationPolicy;
13+
import org.springframework.security.core.userdetails.UserDetailsService;
14+
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
15+
import org.springframework.security.crypto.password.PasswordEncoder;
16+
import org.springframework.security.web.SecurityFilterChain;
17+
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
18+
19+
@Configuration
20+
@EnableWebSecurity
21+
public class SecurityConfig {
22+
23+
@Autowired
24+
private UserDetailsService userDetailsService;
25+
26+
@Autowired
27+
private JwtAuthenticationFilter jwtAuthenticationFilter;
28+
29+
@Bean
30+
public PasswordEncoder passwordEncoder() {
31+
return new BCryptPasswordEncoder();
32+
}
33+
34+
@Bean
35+
public DaoAuthenticationProvider authenticationProvider() {
36+
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
37+
authProvider.setUserDetailsService(userDetailsService);
38+
authProvider.setPasswordEncoder(passwordEncoder());
39+
return authProvider;
40+
}
41+
42+
@Bean
43+
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
44+
return authConfig.getAuthenticationManager();
45+
}
46+
47+
@Bean
48+
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
49+
http
50+
.csrf(csrf -> csrf.disable())
51+
.authorizeHttpRequests(auth -> auth
52+
.requestMatchers("/api/users/register", "/api/users/login").permitAll()
53+
// WebSocket endpoint is public for initial handshake, but STOMP CONNECT is authenticated
54+
.requestMatchers("/ws/**").permitAll()
55+
// WARNING: H2 console should be disabled in production or protected with authentication
56+
.requestMatchers("/h2-console/**").permitAll()
57+
.anyRequest().authenticated()
58+
)
59+
.sessionManagement(session -> session
60+
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
61+
)
62+
.authenticationProvider(authenticationProvider())
63+
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
64+
65+
// Allow H2 console frames - WARNING: Disable in production
66+
http.headers(headers -> headers.frameOptions(frame -> frame.disable()));
67+
68+
return http.build();
69+
}
70+
}

backend/src/main/java/com/accord/config/WebSocketConfig.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package com.accord.config;
22

3+
import com.accord.security.WebSocketAuthInterceptor;
4+
import org.springframework.beans.factory.annotation.Autowired;
35
import org.springframework.beans.factory.annotation.Value;
46
import org.springframework.context.annotation.Configuration;
7+
import org.springframework.messaging.simp.config.ChannelRegistration;
58
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
69
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
710
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
@@ -14,6 +17,9 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
1417
@Value("${app.cors.allowed-origins}")
1518
private String allowedOrigins;
1619

20+
@Autowired
21+
private WebSocketAuthInterceptor webSocketAuthInterceptor;
22+
1723
@Override
1824
public void configureMessageBroker(MessageBrokerRegistry config) {
1925
config.enableSimpleBroker("/topic");
@@ -34,4 +40,9 @@ public void registerStompEndpoints(StompEndpointRegistry registry) {
3440
.setAllowedOriginPatterns(origins)
3541
.withSockJS();
3642
}
43+
44+
@Override
45+
public void configureClientInboundChannel(ChannelRegistration registration) {
46+
registration.interceptors(webSocketAuthInterceptor);
47+
}
3748
}

backend/src/main/java/com/accord/controller/ChatController.java

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package com.accord.controller;
22

33
import com.accord.model.ChatMessage;
4+
import com.accord.model.TypingIndicator;
45
import com.accord.service.ChatService;
56
import com.accord.util.ValidationUtils;
67
import org.springframework.beans.factory.annotation.Autowired;
78
import org.springframework.beans.factory.annotation.Value;
89
import org.springframework.http.ResponseEntity;
10+
import org.springframework.messaging.handler.annotation.DestinationVariable;
911
import org.springframework.messaging.handler.annotation.MessageMapping;
1012
import org.springframework.messaging.handler.annotation.SendTo;
1113
import org.springframework.stereotype.Controller;
@@ -64,7 +66,7 @@ public ChatMessage sendMessage(Map<String, String> payload) {
6466

6567
@MessageMapping("/chat.send/{channelId}")
6668
@SendTo("/topic/messages/{channelId}")
67-
public ChatMessage sendMessageToChannel(@org.springframework.messaging.handler.annotation.DestinationVariable Long channelId,
69+
public ChatMessage sendMessageToChannel(@DestinationVariable Long channelId,
6870
Map<String, String> payload) {
6971
if (payload == null) {
7072
throw new IllegalArgumentException("Payload must not be null");
@@ -115,7 +117,7 @@ public ChatMessage userJoin(Map<String, String> payload) {
115117

116118
@MessageMapping("/chat.join/{channelId}")
117119
@SendTo("/topic/messages/{channelId}")
118-
public ChatMessage userJoinChannel(@org.springframework.messaging.handler.annotation.DestinationVariable Long channelId,
120+
public ChatMessage userJoinChannel(@DestinationVariable Long channelId,
119121
Map<String, String> payload) {
120122
if (payload == null) {
121123
throw new IllegalArgumentException("Payload must not be null");
@@ -135,6 +137,33 @@ public ChatMessage userJoinChannel(@org.springframework.messaging.handler.annota
135137
String trimmedUsername = username.trim();
136138
return chatService.saveMessage("System", trimmedUsername + " has joined the chat", channelId);
137139
}
140+
141+
@MessageMapping("/chat.typing/{channelId}")
142+
@SendTo("/topic/typing/{channelId}")
143+
public TypingIndicator userTyping(@DestinationVariable Long channelId,
144+
Map<String, Object> payload) {
145+
if (payload == null) {
146+
throw new IllegalArgumentException("Payload must not be null");
147+
}
148+
149+
String username = (String) payload.get("username");
150+
Boolean typing = (Boolean) payload.get("typing");
151+
152+
if (!ValidationUtils.isValidUsername(username, minUsernameLength, maxUsernameLength)) {
153+
throw new IllegalArgumentException("Invalid username");
154+
}
155+
156+
if (typing == null) {
157+
typing = true; // Default to typing=true if not specified
158+
}
159+
160+
// Verify channel exists
161+
if (!channelService.getChannelById(channelId).isPresent()) {
162+
throw new IllegalArgumentException("Channel does not exist");
163+
}
164+
165+
return new TypingIndicator(username.trim(), channelId, typing);
166+
}
138167
}
139168

140169
@RestController

backend/src/main/java/com/accord/controller/UserController.java

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,44 @@
11
package com.accord.controller;
22

3+
import com.accord.dto.AuthResponse;
4+
import com.accord.dto.LoginRequest;
5+
import com.accord.dto.RegisterRequest;
36
import com.accord.model.User;
7+
import com.accord.security.JwtUtil;
48
import com.accord.service.UserService;
59
import com.accord.util.ValidationUtils;
610
import org.springframework.beans.factory.annotation.Autowired;
711
import org.springframework.beans.factory.annotation.Value;
812
import org.springframework.http.ResponseEntity;
13+
import org.springframework.security.authentication.AuthenticationManager;
14+
import org.springframework.security.authentication.BadCredentialsException;
15+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
916
import org.springframework.web.bind.annotation.*;
1017

1118
import java.util.Map;
1219

20+
/**
21+
* User authentication controller handling registration and login.
22+
*
23+
* SECURITY NOTE: Rate Limiting
24+
* ----------------------------------------------------------------------------
25+
* This controller lacks rate limiting protection, making it vulnerable to:
26+
* - Username enumeration attacks
27+
* - Brute force password attacks
28+
* - Resource exhaustion through repeated requests
29+
*
30+
* For production deployments, consider implementing rate limiting using:
31+
* - Spring Security's built-in rate limiting (Spring Security 6.2+)
32+
* - Bucket4j library for token bucket rate limiting
33+
* - API Gateway rate limiting (e.g., AWS API Gateway, Kong)
34+
* - Web Application Firewall (WAF) with rate limiting rules
35+
*
36+
* Recommended limits:
37+
* - 5-10 failed login attempts per username per 15 minutes
38+
* - 10-20 registration attempts per IP address per hour
39+
* - Consider CAPTCHA after repeated failures
40+
* ----------------------------------------------------------------------------
41+
*/
1342
@RestController
1443
@RequestMapping("/api/users")
1544
@CrossOrigin(origins = "${app.cors.allowed-origins}")
@@ -21,19 +50,71 @@ public class UserController {
2150
@Value("${app.username.min-length}")
2251
private int minUsernameLength;
2352

53+
@Value("${app.password.min-length}")
54+
private int minPasswordLength;
55+
2456
@Autowired
2557
private UserService userService;
2658

27-
@PostMapping("/login")
28-
public ResponseEntity<User> login(@RequestBody Map<String, String> request) {
29-
String username = request.get("username");
59+
@Autowired
60+
private JwtUtil jwtUtil;
61+
62+
@Autowired
63+
private AuthenticationManager authenticationManager;
64+
65+
@PostMapping("/register")
66+
public ResponseEntity<?> register(@RequestBody RegisterRequest request) {
67+
String username = request.getUsername();
68+
String password = request.getPassword();
69+
70+
if (username == null || username.trim().isEmpty()) {
71+
return ResponseEntity.badRequest().body(Map.of("error", "Username is required"));
72+
}
3073

3174
if (!ValidationUtils.isValidUsername(username, minUsernameLength, maxUsernameLength)) {
32-
return ResponseEntity.badRequest().build();
75+
return ResponseEntity.badRequest().body(Map.of("error", "Invalid username"));
76+
}
77+
78+
if (password == null || password.isEmpty()) {
79+
return ResponseEntity.badRequest().body(Map.of("error", "Password is required"));
3380
}
3481

35-
User user = userService.createOrGetUser(username.trim());
36-
return ResponseEntity.ok(user);
82+
if (!ValidationUtils.isValidPassword(password, minPasswordLength)) {
83+
return ResponseEntity.badRequest().body(Map.of("error", "Password must be at least " + minPasswordLength +
84+
" characters and contain uppercase, lowercase, and digit"));
85+
}
86+
87+
try {
88+
User user = userService.registerUser(username.trim(), password);
89+
String token = jwtUtil.generateToken(user.getUsername());
90+
return ResponseEntity.ok(new AuthResponse(token, user.getUsername(), user.getId()));
91+
} catch (IllegalArgumentException e) {
92+
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
93+
}
94+
}
95+
96+
@PostMapping("/login")
97+
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
98+
String username = request.getUsername();
99+
String password = request.getPassword();
100+
101+
if (username == null || username.trim().isEmpty() || password == null || password.isEmpty()) {
102+
return ResponseEntity.badRequest().body(Map.of("error", "Username and password are required"));
103+
}
104+
105+
try {
106+
authenticationManager.authenticate(
107+
new UsernamePasswordAuthenticationToken(username.trim(), password)
108+
);
109+
110+
User user = userService.findByUsername(username.trim())
111+
.orElseThrow(() -> new BadCredentialsException("Invalid credentials"));
112+
113+
String token = jwtUtil.generateToken(user.getUsername());
114+
return ResponseEntity.ok(new AuthResponse(token, user.getUsername(), user.getId()));
115+
} catch (BadCredentialsException e) {
116+
return ResponseEntity.status(401).body(Map.of("error", "Invalid username or password"));
117+
}
37118
}
38119

39120
@GetMapping("/check/{username}")
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.accord.dto;
2+
3+
public class AuthResponse {
4+
private String token;
5+
private String username;
6+
private Long userId;
7+
8+
public AuthResponse() {
9+
}
10+
11+
public AuthResponse(String token, String username, Long userId) {
12+
this.token = token;
13+
this.username = username;
14+
this.userId = userId;
15+
}
16+
17+
public String getToken() {
18+
return token;
19+
}
20+
21+
public void setToken(String token) {
22+
this.token = token;
23+
}
24+
25+
public String getUsername() {
26+
return username;
27+
}
28+
29+
public void setUsername(String username) {
30+
this.username = username;
31+
}
32+
33+
public Long getUserId() {
34+
return userId;
35+
}
36+
37+
public void setUserId(Long userId) {
38+
this.userId = userId;
39+
}
40+
}

0 commit comments

Comments
 (0)