Skip to content
Merged
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
2 changes: 1 addition & 1 deletion backend/chatapp/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ dependencies {
implementation 'io.jsonwebtoken:jjwt-api:0.13.0'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0'

implementation 'org.springframework.security:spring-security-messaging'
// Testcontainers dependencies
testImplementation 'org.testcontainers:testcontainers:1.19.7'
testImplementation 'org.testcontainers:postgresql:1.19.7'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import com.magmusacy.chat.chatapp.auth.dto.RegisterRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,28 @@
import com.magmusacy.chat.chatapp.user.User;
import com.magmusacy.chat.chatapp.user.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;


//TODO: Should this be transactional, like the message should be sent if the transaction goes through
//TODO it might be better to just send the added user but whatever for now that works well
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserService userService;
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
private final PasswordEncoder passwordEncoder;
private final SimpMessagingTemplate messagingTemplate;

public AuthenticationResponse register(RegisterRequest request) {
User user = userService.createUser(request, passwordEncoder);
String jwtToken = jwtService.generateToken(user);
messagingTemplate.convertAndSend("/topic/users", userService.findAllUsers());
return new AuthenticationResponse((jwtToken));
}

Expand All @@ -32,7 +38,9 @@ public AuthenticationResponse login(LoginRequest request) {
);

User user = userService.findByEmail(request.email());
user.setIsOnline(true);
String jwtToken = jwtService.generateToken(user);
messagingTemplate.convertAndSend("/topic/users", userService.findAllUsers());
return new AuthenticationResponse((jwtToken));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ public String generateToken(UserDetails userDetails) {
return Jwts.builder()
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 24))
//TODO change expiration behaviour
.setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 240000))
.signWith(getSignInKey(), SignatureAlgorithm.HS256)
.compact();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.magmusacy.chat.chatapp.config;

import com.magmusacy.chat.chatapp.auth.JwtService;
import com.magmusacy.chat.chatapp.user.User;
import com.magmusacy.chat.chatapp.user.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
Expand All @@ -15,16 +17,18 @@
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.security.Principal;

@Component
@RequiredArgsConstructor
public class WebSocketAuthenticationInterceptor implements ChannelInterceptor {
public class JwtInterceptor implements ChannelInterceptor {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
private final UserService userService;

@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

if (StompCommand.CONNECT.equals(accessor.getCommand())) {
String authorizationHeader = accessor.getFirstNativeHeader("Authorization");
if (StringUtils.hasText(authorizationHeader) && authorizationHeader.startsWith("Bearer ")) {
Expand All @@ -37,10 +41,24 @@ public Message<?> preSend(Message<?> message, MessageChannel channel) {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);

User user = userService.findByEmail(username);
userService.handleUserLogin(user);

// This could be authToken.getPrincipal() ???
accessor.setUser(authToken);
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
} else {
throw new IllegalStateException("No JWT token supplied.");
}
}

if (StompCommand.DISCONNECT.equals(accessor.getCommand())) {
Principal potentialUser = accessor.getUser();
if (potentialUser != null) {
User user = userService.findByEmail(potentialUser.getName());
userService.handleUserLogout(user);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.socket.EnableWebSocketSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@EnableWebSecurity
Expand All @@ -39,7 +41,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.requestMatchers("/auth/**", "/ws/**").permitAll()
.anyRequest().authenticated()
)
.exceptionHandling(exception -> exception
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,19 @@
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final WebSocketAuthenticationInterceptor webSocketAuthenticationInterceptor;
private final JwtInterceptor webSocketAuthenticationInterceptor;

@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/user");
config.enableSimpleBroker("/user", "/topic");
config.setApplicationDestinationPrefixes("/app");
config.setUserDestinationPrefix("/user");
}

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.magmusacy.chat.chatapp.user;

import com.magmusacy.chat.chatapp.auth.dto.RegisterRequest;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
@Profile("dev")
public class DummyUserInitializer {

@Bean
public CommandLineRunner initDummyUsers(UserService userService, PasswordEncoder passwordEncoder) {
return args -> {
userService.createUser(
new RegisterRequest("Dummy One", "dummy1@example.com", "password", "password"),
passwordEncoder
);
userService.createUser(
new RegisterRequest("Dummy Two", "dummy2@example.com", "password", "password"),
passwordEncoder
);
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,33 +9,14 @@
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

import java.util.List;

@Controller
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
private final ServerProperties serverProperties;

@MessageMapping("/user.addUser")
@SendTo("/user/topic")
public User addUser(@Payload User user) {
userService.saveUser(user);
return user;
}

@MessageMapping("/user.disconnectUser")
@SendTo("/user/topic")
public User disconnect(
@Payload User user
) {
userService.disconnectUser(user);
return user;
}

// @GetMapping("/users")
// public ResponseEntity<List<User>> findConnectedUsers() {
// return ResponseEntity.ok(userService.findConnectedUsers());
// }

@GetMapping("/user/me")
public ResponseEntity<UserMeDTO> getUserInfo() {
return ResponseEntity.ok(userService.getUserInfo());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.magmusacy.chat.chatapp.user;

import java.time.LocalDateTime;

public record UserDTO(int id, String name, Boolean isOnline, LocalDateTime lastSeen) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.magmusacy.chat.chatapp.user;

public record UserIdPayload(int userId) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public User createUser(RegisterRequest request, PasswordEncoder passwordEncoder)
} else if (userRepository.existsByEmail(request.email())) {
throw new UserAlreadyExistsException("This email is already in use");
}

User user = new User();
user.setEmail(request.email());
user.setName(request.name());
Expand All @@ -37,6 +38,13 @@ public void saveUser(User user) {
userRepository.save(user);
}

public UserDTO setUserOnline(int userId) {
User user = userRepository.findById(userId).orElseThrow(() -> new IllegalArgumentException("Wrong userId"));
user.setIsOnline(true);
user.setLastSeen(null);
return new UserDTO(user.getId(), user.getName(), user.getIsOnline(), user.getLastSeen());
}

public void disconnectUser(User user) {
Optional<User> connectedUser = userRepository.findById(user.getId());
connectedUser.ifPresent(u -> {
Expand All @@ -54,8 +62,20 @@ public void connectUser(User user) {
});
}

public List<User> findConnectedUsers() {
return userRepository.findAllByIsOnline(true);
public List<UserDTO> findAllUsers() {
return userRepository.findAll().stream().map(user -> new UserDTO(user.getId(), user.getName(), user.getIsOnline(), user.getLastSeen())).toList();
}

public void handleUserLogout(User user) {
user.setIsOnline(false);
user.setLastSeen(LocalDateTime.now());
userRepository.save(user);
}

public void handleUserLogin(User user) {
user.setIsOnline(true);
user.setLastSeen(null);
userRepository.save(user);
}

public Optional<User> findById(int id) {
Expand Down
6 changes: 4 additions & 2 deletions backend/chatapp/src/main/resources/application-dev.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
spring:
datasource:
url: ${DATABASE_URL}
url: jdbc:postgresql://localhost:5433/chatapp
username: chatuser
password: chatpassword
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
Expand All @@ -11,5 +13,5 @@ server:
include-message: always

jwt:
secret: ${JWT_SECRET}
secret: 029DObcj44vaBTbx1PrSsFW0CbbdKefq6gLJGKY7meQ=

4 changes: 3 additions & 1 deletion backend/chatapp/src/main/resources/application.properties
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
spring.application.name=chatapp
spring.application.name=chatapp
logging.level.org.springframework.messaging=DEBUG
logging.level.org.springframework.web.socket=DEBUG
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.AuthenticationException;
Expand All @@ -39,6 +40,9 @@ class AuthServiceTest {
@Mock
private PasswordEncoder passwordEncoder;

@Mock
private SimpMessagingTemplate messagingTemplate;

@InjectMocks
private AuthService authService;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,21 +161,4 @@ void connectUser_ShouldUpdateUserStatus() {
verify(userRepository).save(any(User.class));
// Would verify the user object had its isOnline set to true
}

@Test
@DisplayName("When findConnectedUsers is called, then list of online users should be returned")
void findConnectedUsers_ShouldReturnOnlineUsers() {
// Given
User user1 = new User();
User user2 = new User();
List<User> onlineUsers = Arrays.asList(user1, user2);
when(userRepository.findAllByIsOnline(true)).thenReturn(onlineUsers);

// When
List<User> result = userService.findConnectedUsers();

// Then
assertEquals(2, result.size());
assertEquals(onlineUsers, result);
}
}
36 changes: 36 additions & 0 deletions mobile/components/UserCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Image, Text, View } from "react-native";

interface UserCardProps {
name: string;
avatar?: string;
}

export default function UserCard({ name, avatar }: UserCardProps) {
// Get the initials from the name for the avatar fallback
const initials = name
.split(" ")
.map((word) => word[0])
.join("")
.toUpperCase()
.substring(0, 2);

return (
<View className="flex-row items-center bg-[#23293a] rounded-lg p-4 m-2">
{avatar ? (
<Image
source={{ uri: avatar }}
className="w-12 h-12 rounded-full bg-gray-700"
/>
) : (
<View className="w-12 h-12 rounded-full bg-gray-700 items-center justify-center">
<Text className="text-white font-bold text-lg">{initials}</Text>
</View>
)}

{/* User Name */}
<View className="ml-4 flex-1">
<Text className="text-white font-medium text-lg">{name}</Text>
</View>
</View>
);
}
Loading