From 5a730db3b49851f88902961bd1c15cd35a5c8f70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6ktu=C4=9F=20Berke=20G=C3=BCng=C3=B6ren?= Date: Fri, 3 Oct 2025 15:39:09 +0300 Subject: [PATCH 1/4] Implements chat functionalities and OAuth2 Adds chat history retrieval, search, delete, and rename functionalities, enhancing user chat management capabilities. Integrates CustomOAuth2UserService for user authentication and authorization. Updates user model with fields from OAuth2, facilitating seamless user data handling. --- .../chat/controller/ChatController.java | 69 ++++++++++--------- .../security/CustomOAuth2UserService.java | 51 +++++++++++++- .../java/com/mentora/backend/user/User.java | 20 ------ .../mentora/backend/user/UserRepository.java | 3 +- .../com/mentora/backend/user/model/Users.java | 12 ++-- 5 files changed, 92 insertions(+), 63 deletions(-) delete mode 100644 backend/src/main/java/com/mentora/backend/user/User.java diff --git a/backend/src/main/java/com/mentora/backend/chat/controller/ChatController.java b/backend/src/main/java/com/mentora/backend/chat/controller/ChatController.java index ee72f69..b351b34 100644 --- a/backend/src/main/java/com/mentora/backend/chat/controller/ChatController.java +++ b/backend/src/main/java/com/mentora/backend/chat/controller/ChatController.java @@ -7,6 +7,7 @@ import com.mentora.backend.chat.dto.UserMessageDto; import com.mentora.backend.chat.service.ChatListService; import com.mentora.backend.chat.service.ChatService; +import com.mentora.backend.security.CustomOAuth2UserService; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -23,30 +24,30 @@ public class ChatController { private final ChatService chatService; - // private final AuthService authService; + private final CustomOAuth2UserService authService; private final ChatListService chatListService; -// public ChatController(ChatService chatService, AuthService authService, ChatListService chatListService, AgentGateway agentGateway) { -// -// this.chatService = chatService; -// this.authService = authService; -// this.chatListService = chatListService; -// } - -// @GetMapping("/chats_history") -// public ResponseEntity>> getChatsHistory() { -// String currentUserId = authService.currentUserId(); -// Map> chatListMap = chatListService.getChatList(currentUserId); -// return ResponseEntity.ok(chatListMap); -// -// } - -// @GetMapping("/search") -// public ResponseEntity> searchChats(@RequestParam(required = false) String title) { -// String currentUserId = authService.currentUserId(); -// List searchResults = chatService.searchChatsByTitle(currentUserId, title); -// return ResponseEntity.ok(searchResults); -// } + public ChatController(ChatService chatService, CustomOAuth2UserService authService, ChatListService chatListService) { + + this.chatService = chatService; + this.chatListService = chatListService; + this.authService = authService; + } + + @GetMapping("/chats_history") + public ResponseEntity>> getChatsHistory() { + String currentUserId = authService.currentUserId(); + Map> chatListMap = chatListService.getChatList(currentUserId); + return ResponseEntity.ok(chatListMap); + + } + + @GetMapping("/search") + public ResponseEntity> searchChats(@RequestParam(required = false) String title) { + String currentUserId = authService.currentUserId(); + List searchResults = chatService.searchChatsByTitle(currentUserId, title); + return ResponseEntity.ok(searchResults); + } @DeleteMapping("/delete/{chatId}") public ResponseEntity deleteChatId(@PathVariable UUID chatId) { @@ -65,19 +66,19 @@ public ResponseEntity renameChat(@PathVariable UUID chatId, @Requ } -// @GetMapping("/history/{chatId}") -// public ResponseEntity getChatHistory(@PathVariable UUID chatId) { -// String currentUserId = authService.currentUserId(); -// ChatResponseDto chatHistory = chatService.getChatHistoryById(currentUserId, chatId); -// return ResponseEntity.ok(chatHistory); -// } + @GetMapping("/history/{chatId}") + public ResponseEntity getChatHistory(@PathVariable UUID chatId) { + String currentUserId = authService.currentUserId(); + ChatResponseDto chatHistory = chatService.getChatHistoryById(currentUserId, chatId); + return ResponseEntity.ok(chatHistory); + } -// @DeleteMapping("/deleteAllChats/") -// public ResponseEntity deleteAllChats() { -// String currentUserId = authService.currentUserId(); -// chatService.deleteAllChatsByUserId(currentUserId); -// return ResponseEntity.ok("All Chats Deleted Successfully"); -// } + @DeleteMapping("/deleteAllChats/") + public ResponseEntity deleteAllChats() { + String currentUserId = authService.currentUserId(); + chatService.deleteAllChatsByUserId(currentUserId); + return ResponseEntity.ok("All Chats Deleted Successfully"); + } diff --git a/backend/src/main/java/com/mentora/backend/security/CustomOAuth2UserService.java b/backend/src/main/java/com/mentora/backend/security/CustomOAuth2UserService.java index 253803d..bdd3ffb 100644 --- a/backend/src/main/java/com/mentora/backend/security/CustomOAuth2UserService.java +++ b/backend/src/main/java/com/mentora/backend/security/CustomOAuth2UserService.java @@ -1,12 +1,21 @@ package com.mentora.backend.security; -import com.mentora.backend.user.User; +import com.mentora.backend.error.UserNotAuthenticatedException; import com.mentora.backend.user.UserRepository; +import com.mentora.backend.user.model.Users; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.util.UUID; @Service public class CustomOAuth2UserService extends DefaultOAuth2UserService { @@ -26,8 +35,8 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic String email = oauth2User.getAttribute("email"); String picture = oauth2User.getAttribute("picture"); - User user = new User(); - user.setId(id); + Users user = new Users(); + user.setId(UUID.fromString(id)); user.setName(name); user.setEmail(email); user.setPicture(picture); @@ -36,4 +45,40 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic return oauth2User; } + + public String currentUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (!(authentication instanceof OAuth2AuthenticationToken oauthToken)) { + throw new UserNotAuthenticatedException(); + } + + OidcUser oidcUser = (OidcUser) oauthToken.getPrincipal(); + + return oidcUser.getAttribute("oid"); + } + + public String currentUserName() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (!(authentication instanceof OAuth2AuthenticationToken oauthToken)) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unauthenticated"); + } + + OidcUser oidcUser = (OidcUser) oauthToken.getPrincipal(); + + return oidcUser.getAttribute("name"); + } + + public String currentUserEmail() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (!(authentication instanceof OAuth2AuthenticationToken oauthToken)) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unauthenticated"); + } + + OidcUser oidcUser = (OidcUser) oauthToken.getPrincipal(); + + return oidcUser.getAttribute("preferred_username"); + } } diff --git a/backend/src/main/java/com/mentora/backend/user/User.java b/backend/src/main/java/com/mentora/backend/user/User.java deleted file mode 100644 index 28bd1eb..0000000 --- a/backend/src/main/java/com/mentora/backend/user/User.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.mentora.backend.user; - -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import lombok.Getter; -import lombok.Setter; - -@Entity -@Table(name = "users") -@Getter -@Setter -public class User { - - @Id - private String id; - private String name; - private String email; - private String picture; -} diff --git a/backend/src/main/java/com/mentora/backend/user/UserRepository.java b/backend/src/main/java/com/mentora/backend/user/UserRepository.java index 53e5ad2..763555b 100644 --- a/backend/src/main/java/com/mentora/backend/user/UserRepository.java +++ b/backend/src/main/java/com/mentora/backend/user/UserRepository.java @@ -1,8 +1,9 @@ package com.mentora.backend.user; +import com.mentora.backend.user.model.Users; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository -public interface UserRepository extends JpaRepository { +public interface UserRepository extends JpaRepository { } diff --git a/backend/src/main/java/com/mentora/backend/user/model/Users.java b/backend/src/main/java/com/mentora/backend/user/model/Users.java index e24dac2..d52263d 100644 --- a/backend/src/main/java/com/mentora/backend/user/model/Users.java +++ b/backend/src/main/java/com/mentora/backend/user/model/Users.java @@ -2,9 +2,7 @@ import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; @@ -17,6 +15,8 @@ @Data @NoArgsConstructor @AllArgsConstructor +@Getter +@Setter public class Users { @Id @GeneratedValue(strategy = GenerationType.UUID) @@ -28,8 +28,8 @@ public class Users { @NotNull private String email; - @Column(name = "username") - private String username; + @Column(name = "name") + private String name; @Column(name = "current_streak", nullable = false, columnDefinition = "integer default 0") private Integer currentStreak = 0; @@ -53,6 +53,8 @@ public class Users { @Temporal(TemporalType.TIMESTAMP) private Date updatedAt; + private String picture; + public void updateStreak() { LocalDate today = LocalDate.now(); From 58a16cda7d4963f076ac0649813441f2c462cc7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emir=20Enes=20Akal=C4=B1n?= Date: Sun, 5 Oct 2025 13:43:40 +0300 Subject: [PATCH 2/4] Implement transaction management, validation, and authorization Added Files: - user/UserService.java: Service layer for user operations with @Transactional support - chat/dto/ChatRenameRequest.java: DTO with validation annotations for chat rename requests - error/UnauthorizedAccessException.java: Custom exception for authorization failures (403) - error/ChatNotFoundException.java: Custom exception for chat not found errors (404) Modified Files: - chat/controller/ChatController.java: Added @Valid annotation and userId parameters for authorization - chat/service/ChatService.java: Implemented ownership validation for all chat operations - error/GlobalExceptionHandler.java: Added handlers for UnauthorizedAccessException and ChatNotFoundException - security/CustomOAuth2UserService.java: Standardized to use "sub" claim and added null checks - user/model/Users.java: Changed ID type from UUID to String for OAuth2 compatibility Key Changes: - Transaction management: Streak operations now use @Transactional in service layer - Input validation: Bean Validation added for chat rename with @NotBlank, @Size, @Pattern - Authorization: All chat operations now verify user ownership before execution - Security: Consistent exception handling across authentication and authorization flows Breaking Changes: - ChatService methods now require userId parameter for authorization checks - Users entity ID changed from UUID to String --- .../chat/controller/ChatController.java | 52 ++++---- .../backend/chat/dto/ChatRenameRequest.java | 25 ++++ .../backend/chat/service/ChatService.java | 120 +++++++++++++----- .../backend/error/ChatNotFoundException.java | 22 ++++ .../backend/error/GlobalExceptionHandler.java | 108 ++++++++++++++-- .../error/UnauthorizedAccessException.java | 16 +++ .../security/CustomOAuth2UserService.java | 30 +++-- .../com/mentora/backend/user/UserService.java | 98 ++++++++++++++ .../com/mentora/backend/user/model/Users.java | 10 +- 9 files changed, 395 insertions(+), 86 deletions(-) create mode 100644 backend/src/main/java/com/mentora/backend/chat/dto/ChatRenameRequest.java create mode 100644 backend/src/main/java/com/mentora/backend/error/ChatNotFoundException.java create mode 100644 backend/src/main/java/com/mentora/backend/error/UnauthorizedAccessException.java create mode 100644 backend/src/main/java/com/mentora/backend/user/UserService.java diff --git a/backend/src/main/java/com/mentora/backend/chat/controller/ChatController.java b/backend/src/main/java/com/mentora/backend/chat/controller/ChatController.java index b351b34..e8e1baf 100644 --- a/backend/src/main/java/com/mentora/backend/chat/controller/ChatController.java +++ b/backend/src/main/java/com/mentora/backend/chat/controller/ChatController.java @@ -1,22 +1,19 @@ package com.mentora.backend.chat.controller; -//import com.mentora.backend.auth.service.AuthService; import com.mentora.backend.chat.dto.ChatListDto; import com.mentora.backend.chat.dto.ChatRenameDto; +import com.mentora.backend.chat.dto.ChatRenameRequest; import com.mentora.backend.chat.dto.ChatResponseDto; -import com.mentora.backend.chat.dto.UserMessageDto; import com.mentora.backend.chat.service.ChatListService; import com.mentora.backend.chat.service.ChatService; import com.mentora.backend.security.CustomOAuth2UserService; -import org.springframework.http.MediaType; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.util.List; import java.util.Map; -import java.util.NoSuchElementException; import java.util.UUID; @RestController @@ -24,62 +21,57 @@ public class ChatController { private final ChatService chatService; - private final CustomOAuth2UserService authService; + private final CustomOAuth2UserService oAuth2UserService; private final ChatListService chatListService; - public ChatController(ChatService chatService, CustomOAuth2UserService authService, ChatListService chatListService) { - + public ChatController(ChatService chatService, CustomOAuth2UserService oAuth2UserService, ChatListService chatListService) { this.chatService = chatService; this.chatListService = chatListService; - this.authService = authService; + this.oAuth2UserService = oAuth2UserService; } @GetMapping("/chats_history") public ResponseEntity>> getChatsHistory() { - String currentUserId = authService.currentUserId(); + String currentUserId = oAuth2UserService.currentUserId(); Map> chatListMap = chatListService.getChatList(currentUserId); return ResponseEntity.ok(chatListMap); - } @GetMapping("/search") public ResponseEntity> searchChats(@RequestParam(required = false) String title) { - String currentUserId = authService.currentUserId(); + String currentUserId = oAuth2UserService.currentUserId(); List searchResults = chatService.searchChatsByTitle(currentUserId, title); return ResponseEntity.ok(searchResults); } @DeleteMapping("/delete/{chatId}") - public ResponseEntity deleteChatId(@PathVariable UUID chatId) { - try { - chatService.DeleteChatById(chatId); - return ResponseEntity.noContent().build(); - } catch (NoSuchElementException e) { - return ResponseEntity.notFound().build(); - } + public ResponseEntity deleteChatId(@PathVariable @NotNull UUID chatId) { + String currentUserId = oAuth2UserService.currentUserId(); + chatService.deleteChatById(currentUserId, chatId); + return ResponseEntity.noContent().build(); } @PutMapping("/rename/{chatId}") - public ResponseEntity renameChat(@PathVariable UUID chatId, @RequestParam String newTitle) { - ChatRenameDto responseDto = chatService.ChatRenameById(chatId, newTitle); + public ResponseEntity renameChat( + @PathVariable @NotNull UUID chatId, + @Valid @RequestBody ChatRenameRequest request + ) { + String currentUserId = oAuth2UserService.currentUserId(); + ChatRenameDto responseDto = chatService.chatRenameById(currentUserId, chatId, request.getNewTitle()); return ResponseEntity.ok(responseDto); } - @GetMapping("/history/{chatId}") - public ResponseEntity getChatHistory(@PathVariable UUID chatId) { - String currentUserId = authService.currentUserId(); + public ResponseEntity getChatHistory(@PathVariable @NotNull UUID chatId) { + String currentUserId = oAuth2UserService.currentUserId(); ChatResponseDto chatHistory = chatService.getChatHistoryById(currentUserId, chatId); return ResponseEntity.ok(chatHistory); } @DeleteMapping("/deleteAllChats/") public ResponseEntity deleteAllChats() { - String currentUserId = authService.currentUserId(); + String currentUserId = oAuth2UserService.currentUserId(); chatService.deleteAllChatsByUserId(currentUserId); return ResponseEntity.ok("All Chats Deleted Successfully"); } - - - -} +} \ No newline at end of file diff --git a/backend/src/main/java/com/mentora/backend/chat/dto/ChatRenameRequest.java b/backend/src/main/java/com/mentora/backend/chat/dto/ChatRenameRequest.java new file mode 100644 index 0000000..6d3fe00 --- /dev/null +++ b/backend/src/main/java/com/mentora/backend/chat/dto/ChatRenameRequest.java @@ -0,0 +1,25 @@ +package com.mentora.backend.chat.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * DTO for chat rename requests with validation. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ChatRenameRequest { + + @NotBlank(message = "Title cannot be blank") + @Size(min = 1, max = 255, message = "Title must be between 1 and 255 characters") + @Pattern( + regexp = "^[a-zA-Z0-9\\s\\-_.,!?()]+$", + message = "Title contains invalid characters. Only letters, numbers, spaces, and basic punctuation are allowed" + ) + private String newTitle; +} \ No newline at end of file diff --git a/backend/src/main/java/com/mentora/backend/chat/service/ChatService.java b/backend/src/main/java/com/mentora/backend/chat/service/ChatService.java index 8b4c66a..0228adb 100644 --- a/backend/src/main/java/com/mentora/backend/chat/service/ChatService.java +++ b/backend/src/main/java/com/mentora/backend/chat/service/ChatService.java @@ -1,20 +1,16 @@ package com.mentora.backend.chat.service; - import com.mentora.backend.chat.dto.ChatRenameDto; import com.mentora.backend.chat.dto.ChatResponseDto; -import com.mentora.backend.chat.dto.UserMessageDto; +import com.mentora.backend.chat.dto.ChatListDto; import com.mentora.backend.chat.model.Chat; import com.mentora.backend.chat.model.Message; import com.mentora.backend.chat.repository.ChatRepository; import com.mentora.backend.enums.MessageSender; -import com.mentora.backend.chat.dto.ChatListDto; +import com.mentora.backend.error.ChatNotFoundException; +import com.mentora.backend.error.UnauthorizedAccessException; import jakarta.transaction.Transactional; -import org.springframework.http.codec.ServerSentEvent; import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; -import reactor.core.publisher.Flux; import java.util.*; import java.util.stream.Collectors; @@ -36,9 +32,25 @@ private Chat createNewChat(String userId, String text) { } private Chat findExistingChat(UUID chatId) { - return chatRepository.findById(chatId).orElseThrow(() -> new NoSuchElementException("Chat not found.")); + return chatRepository.findById(chatId) + .orElseThrow(() -> new ChatNotFoundException(chatId)); } + /** + * Validates that the current user owns the chat. + * Throws UnauthorizedAccessException if the user doesn't own the chat. + * + * @param chat the chat to validate + * @param userId the current user's ID + * @throws UnauthorizedAccessException if user doesn't own the chat + */ + private void validateChatOwnership(Chat chat, String userId) { + if (!chat.getUserId().equals(userId)) { + throw new UnauthorizedAccessException( + "You don't have permission to access this chat" + ); + } + } private Message createUserMessage(String messageText, String modelName) { Message message = new Message(); @@ -64,35 +76,68 @@ private String generateTitleFromMessage(String messageText) { return String.join(" ", Arrays.copyOfRange(words, 0, Math.min(words.length, 3))); } + /** + * Deletes a chat by ID with authorization check. + * + * @param userId the current user's ID + * @param chatId the ID of the chat to delete + * @throws ChatNotFoundException if chat not found + * @throws UnauthorizedAccessException if user doesn't own the chat + */ @Transactional - public void DeleteChatById(UUID id) { - if (chatRepository.existsById(id)) { - chatRepository.deleteById(id); - } else { - throw new NoSuchElementException("Chat not found."); - } + public void deleteChatById(String userId, UUID chatId) { + Chat chat = chatRepository.findById(chatId) + .orElseThrow(() -> new ChatNotFoundException(chatId)); + + validateChatOwnership(chat, userId); + + chatRepository.delete(chat); } + /** + * Deletes all chats belonging to a user. + * + * @param userId the user's ID + */ @Transactional public void deleteAllChatsByUserId(String userId) { chatRepository.deleteAllByUserId(userId); } + /** + * Renames a chat with authorization check and validation. + * + * @param userId the current user's ID + * @param chatId the ID of the chat to rename + * @param newTitle the new title (already validated by controller) + * @return ChatRenameDto containing the updated chat info + * @throws ChatNotFoundException if chat not found + * @throws UnauthorizedAccessException if user doesn't own the chat + */ @Transactional - public ChatRenameDto ChatRenameById(UUID id, String title) { - if (title == null || title.trim().isEmpty()) { - throw new IllegalArgumentException("Title cannot be empty."); - } - Chat chat = chatRepository.findById(id).orElseThrow(() -> new NoSuchElementException("Chat not found.")); + public ChatRenameDto chatRenameById(String userId, UUID chatId, String newTitle) { + Chat chat = chatRepository.findById(chatId) + .orElseThrow(() -> new ChatNotFoundException(chatId)); + + validateChatOwnership(chat, userId); - chat.setTitle(title.trim()); + chat.setTitle(newTitle.trim()); chatRepository.save(chat); - return ChatRenameDto.builder().chatId(chat.getId()).newTitle(chat.getTitle()).build(); + return ChatRenameDto.builder() + .chatId(chat.getId()) + .newTitle(chat.getTitle()) + .build(); } + /** + * Searches chats by title for a specific user. + * + * @param userId the user's ID + * @param title the search query (optional) + * @return list of matching chats + */ public List searchChatsByTitle(String userId, String title) { - List chats; if (title == null || title.trim().isEmpty()) { @@ -104,19 +149,32 @@ public List searchChatsByTitle(String userId, String title) { chats = chatRepository.findByUserIdAndTitleContainingIgnoreCaseOrderByUpdatedAtDesc(userId, title); } - return chats.stream().map(chat -> ChatListDto.builder().chatId(chat.getId()).title(chat.getTitle()).build()).collect(Collectors.toList()); + return chats.stream() + .map(chat -> ChatListDto.builder() + .chatId(chat.getId()) + .title(chat.getTitle()) + .build()) + .collect(Collectors.toList()); } - + /** + * Gets chat history with authorization check. + * + * @param userId the current user's ID + * @param chatId the chat ID + * @return ChatResponseDto containing chat history + * @throws ChatNotFoundException if chat not found + * @throws UnauthorizedAccessException if user doesn't own the chat + */ public ChatResponseDto getChatHistoryById(String userId, UUID chatId) { - Chat chat = chatRepository.findById(chatId).orElseThrow(() -> new RuntimeException("Chat not found")); + Chat chat = chatRepository.findById(chatId) + .orElseThrow(() -> new ChatNotFoundException(chatId)); + validateChatOwnership(chat, userId); - if (!chat.getUserId().equals(userId)) { - throw new RuntimeException("Unauthorized access to chat"); - } - - return ChatResponseDto.builder().chatId(chat.getId()).newMessages(chat.getMessages()).build(); + return ChatResponseDto.builder() + .chatId(chat.getId()) + .newMessages(chat.getMessages()) + .build(); } - } \ No newline at end of file diff --git a/backend/src/main/java/com/mentora/backend/error/ChatNotFoundException.java b/backend/src/main/java/com/mentora/backend/error/ChatNotFoundException.java new file mode 100644 index 0000000..d392940 --- /dev/null +++ b/backend/src/main/java/com/mentora/backend/error/ChatNotFoundException.java @@ -0,0 +1,22 @@ +package com.mentora.backend.error; + +import java.util.UUID; + +/** + * Exception thrown when a chat is not found. + * This is used for 404 Not Found responses. + */ +public class ChatNotFoundException extends RuntimeException { + + public ChatNotFoundException(UUID chatId) { + super("Chat not found: " + chatId); + } + + public ChatNotFoundException(String message) { + super(message); + } + + public ChatNotFoundException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mentora/backend/error/GlobalExceptionHandler.java b/backend/src/main/java/com/mentora/backend/error/GlobalExceptionHandler.java index 49a7c43..33abfc0 100644 --- a/backend/src/main/java/com/mentora/backend/error/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/mentora/backend/error/GlobalExceptionHandler.java @@ -19,7 +19,15 @@ public class GlobalExceptionHandler { public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException exception) { log.debug("Validation error: {}", exception.getMessage()); - final var responseBody = ResponseError.builder().errorCode("VALIDATION_ERROR").errorMessages(exception.getFieldErrors().stream().map(fieldError -> ResponseError.ErrorMessage.builder().field(fieldError.getField()).message(fieldError.getDefaultMessage()).build()).toList()).build(); + final var responseBody = ResponseError.builder() + .errorCode("VALIDATION_ERROR") + .errorMessages(exception.getFieldErrors().stream() + .map(fieldError -> ResponseError.ErrorMessage.builder() + .field(fieldError.getField()) + .message(fieldError.getDefaultMessage()) + .build()) + .toList()) + .build(); return ResponseEntity.badRequest().body(responseBody); } @@ -28,18 +36,92 @@ public ResponseEntity handleMethodArgumentNotValidException(Metho public ResponseEntity handleFileValidationException(FileValidationException exception) { log.debug("File validation error: {}", exception.getMessage()); - final var responseBody = FileValidationErrorResponse.builder().error("FILE_VALIDATION_FAILED").message("Some files could not be processed").details(exception.getErrors().stream().map(error -> FileValidationErrorResponse.FileErrorDetail.builder().fileName(error.getFileName()).error(error.getErrorCode()).message(error.getMessage()).maxSize(error.getMaxSize()).build()).toList()).build(); + final var responseBody = FileValidationErrorResponse.builder() + .error("FILE_VALIDATION_FAILED") + .message("Some files could not be processed") + .details(exception.getErrors().stream() + .map(error -> FileValidationErrorResponse.FileErrorDetail.builder() + .fileName(error.getFileName()) + .error(error.getErrorCode()) + .message(error.getMessage()) + .maxSize(error.getMaxSize()) + .build()) + .toList()) + .build(); return ResponseEntity.badRequest().body(responseBody); } + @ExceptionHandler(UnauthorizedAccessException.class) + public ResponseEntity handleUnauthorizedAccessException(UnauthorizedAccessException exception) { + log.warn("Unauthorized access attempt: {}", exception.getMessage()); + + ResponseError responseBody = ResponseError.builder() + .errorCode("FORBIDDEN") + .errorMessages(List.of( + ResponseError.ErrorMessage.builder() + .field("authorization") + .message(exception.getMessage()) + .build() + )) + .build(); + + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(responseBody); + } + + @ExceptionHandler(ChatNotFoundException.class) + public ResponseEntity handleChatNotFoundException(ChatNotFoundException exception) { + log.debug("Chat not found: {}", exception.getMessage()); + + ResponseError responseBody = ResponseError.builder() + .errorCode("CHAT_NOT_FOUND") + .errorMessages(List.of( + ResponseError.ErrorMessage.builder() + .field("chatId") + .message(exception.getMessage()) + .build() + )) + .build(); + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(responseBody); + } + + @ExceptionHandler(UserNotAuthenticatedException.class) + public ResponseEntity handleUserNotAuthenticatedException(UserNotAuthenticatedException exception) { + log.warn("Authentication required: {}", exception.getMessage()); + + ResponseError responseBody = ResponseError.builder() + .errorCode("AUTHENTICATION_REQUIRED") + .errorMessages(List.of( + ResponseError.ErrorMessage.builder() + .field("authentication") + .message(exception.getMessage() != null ? exception.getMessage() : "Authentication required") + .build() + )) + .build(); + + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(responseBody); + } + @ExceptionHandler(RestClientException.class) public ResponseEntity handleRestClientException(RestClientException exception) { log.error("REST client error: {}", exception.getMessage()); - String message = (exception instanceof HttpClientErrorException httpEx && !httpEx.getResponseBodyAsString().isBlank()) ? httpEx.getResponseBodyAsString() : (exception.getMessage() == null || exception.getMessage().isBlank()) ? "A REST client error occurred." : exception.getMessage(); - - ResponseError responseBody = ResponseError.builder().errorCode("REST_CLIENT_ERROR").errorMessages(List.of(ResponseError.ErrorMessage.builder().field("externalService").message(message).build())).build(); + String message = (exception instanceof HttpClientErrorException httpEx && !httpEx.getResponseBodyAsString().isBlank()) + ? httpEx.getResponseBodyAsString() + : (exception.getMessage() == null || exception.getMessage().isBlank()) + ? "A REST client error occurred." + : exception.getMessage(); + + ResponseError responseBody = ResponseError.builder() + .errorCode("REST_CLIENT_ERROR") + .errorMessages(List.of( + ResponseError.ErrorMessage.builder() + .field("externalService") + .message(message) + .build() + )) + .build(); return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(responseBody); } @@ -48,9 +130,19 @@ public ResponseEntity handleRestClientException(RestClientExcepti public ResponseEntity handleRuntimeException(RuntimeException exception) { log.error("Runtime error: {}", exception.getMessage(), exception); - String message = exception.getMessage() != null && !exception.getMessage().isBlank() ? exception.getMessage() : "An unexpected error occurred while processing your request."; - - ResponseError responseBody = ResponseError.builder().errorCode("PROCESSING_ERROR").errorMessages(List.of(ResponseError.ErrorMessage.builder().field("general").message(message).build())).build(); + String message = exception.getMessage() != null && !exception.getMessage().isBlank() + ? exception.getMessage() + : "An unexpected error occurred while processing your request."; + + ResponseError responseBody = ResponseError.builder() + .errorCode("PROCESSING_ERROR") + .errorMessages(List.of( + ResponseError.ErrorMessage.builder() + .field("general") + .message(message) + .build() + )) + .build(); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(responseBody); } diff --git a/backend/src/main/java/com/mentora/backend/error/UnauthorizedAccessException.java b/backend/src/main/java/com/mentora/backend/error/UnauthorizedAccessException.java new file mode 100644 index 0000000..cb17992 --- /dev/null +++ b/backend/src/main/java/com/mentora/backend/error/UnauthorizedAccessException.java @@ -0,0 +1,16 @@ +package com.mentora.backend.error; + +/** + * Exception thrown when a user attempts to access a resource they don't own. + * This is used for authorization failures (403 Forbidden). + */ +public class UnauthorizedAccessException extends RuntimeException { + + public UnauthorizedAccessException(String message) { + super(message); + } + + public UnauthorizedAccessException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mentora/backend/security/CustomOAuth2UserService.java b/backend/src/main/java/com/mentora/backend/security/CustomOAuth2UserService.java index bdd3ffb..db3eefe 100644 --- a/backend/src/main/java/com/mentora/backend/security/CustomOAuth2UserService.java +++ b/backend/src/main/java/com/mentora/backend/security/CustomOAuth2UserService.java @@ -3,7 +3,6 @@ import com.mentora.backend.error.UserNotAuthenticatedException; import com.mentora.backend.user.UserRepository; import com.mentora.backend.user.model.Users; -import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; @@ -13,9 +12,6 @@ import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; -import org.springframework.web.server.ResponseStatusException; - -import java.util.UUID; @Service public class CustomOAuth2UserService extends DefaultOAuth2UserService { @@ -35,10 +31,10 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic String email = oauth2User.getAttribute("email"); String picture = oauth2User.getAttribute("picture"); - Users user = new Users(); - user.setId(UUID.fromString(id)); - user.setName(name); + Users user = userRepository.findById(id).orElse(new Users()); + user.setId(id); user.setEmail(email); + user.setName(name); user.setPicture(picture); userRepository.save(user); @@ -49,20 +45,28 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic public String currentUserId() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + throw new UserNotAuthenticatedException(); + } + if (!(authentication instanceof OAuth2AuthenticationToken oauthToken)) { throw new UserNotAuthenticatedException(); } OidcUser oidcUser = (OidcUser) oauthToken.getPrincipal(); - return oidcUser.getAttribute("oid"); + return oidcUser.getAttribute("sub"); } public String currentUserName() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + throw new UserNotAuthenticatedException(); + } + if (!(authentication instanceof OAuth2AuthenticationToken oauthToken)) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unauthenticated"); + throw new UserNotAuthenticatedException(); } OidcUser oidcUser = (OidcUser) oauthToken.getPrincipal(); @@ -73,12 +77,16 @@ public String currentUserName() { public String currentUserEmail() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + throw new UserNotAuthenticatedException(); + } + if (!(authentication instanceof OAuth2AuthenticationToken oauthToken)) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unauthenticated"); + throw new UserNotAuthenticatedException(); } OidcUser oidcUser = (OidcUser) oauthToken.getPrincipal(); return oidcUser.getAttribute("preferred_username"); } -} +} \ No newline at end of file diff --git a/backend/src/main/java/com/mentora/backend/user/UserService.java b/backend/src/main/java/com/mentora/backend/user/UserService.java new file mode 100644 index 0000000..016ba64 --- /dev/null +++ b/backend/src/main/java/com/mentora/backend/user/UserService.java @@ -0,0 +1,98 @@ +package com.mentora.backend.user; + +import com.mentora.backend.user.model.Users; +import jakarta.transaction.Transactional; +import org.springframework.stereotype.Service; + +import java.util.NoSuchElementException; + +/** + * Service layer for User-related business logic. + * Handles streak management and user operations with proper transaction management. + */ +@Service +public class UserService { + + private final UserRepository userRepository; + + public UserService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + /** + * Updates the user's streak based on their activity. + * This method is transactional - changes are automatically persisted. + * + * @param userId the ID of the user + * @throws NoSuchElementException if user not found + */ + @Transactional + public void updateUserStreak(String userId) { + Users user = userRepository.findById(userId) + .orElseThrow(() -> new NoSuchElementException("User not found: " + userId)); + + user.updateStreak(); + } + + /** + * Uses a streak freeze for the user. + * This method is transactional - changes are automatically persisted. + * + * @param userId the ID of the user + * @throws NoSuchElementException if user not found + * @throws IllegalStateException if user has no streak freezes available + */ + @Transactional + public void useStreakFreeze(String userId) { + Users user = userRepository.findById(userId) + .orElseThrow(() -> new NoSuchElementException("User not found: " + userId)); + + if (!user.canUseStreakFreeze()) { + throw new IllegalStateException("No streak freezes available"); + } + + user.useStreakFreeze(); + } + + /** + * Gets the current streak count for a user. + * + * @param userId the ID of the user + * @return the current streak count + * @throws NoSuchElementException if user not found + */ + public Integer getCurrentStreak(String userId) { + Users user = userRepository.findById(userId) + .orElseThrow(() -> new NoSuchElementException("User not found: " + userId)); + + return user.getCurrentStreak(); + } + + /** + * Gets the longest streak count for a user. + * + * @param userId the ID of the user + * @return the longest streak count + * @throws NoSuchElementException if user not found + */ + public Integer getLongestStreak(String userId) { + Users user = userRepository.findById(userId) + .orElseThrow(() -> new NoSuchElementException("User not found: " + userId)); + + return user.getLongestStreak(); + } + + /** + * Gets the remaining streak freeze count for a user. + * + * @param userId the ID of the user + * @return the number of streak freezes available + * @throws NoSuchElementException if user not found + */ + public Integer getStreakFreezeCount(String userId) { + Users user = userRepository.findById(userId) + .orElseThrow(() -> new NoSuchElementException("User not found: " + userId)); + + return user.getStreakFreezeCount(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mentora/backend/user/model/Users.java b/backend/src/main/java/com/mentora/backend/user/model/Users.java index d52263d..43350d1 100644 --- a/backend/src/main/java/com/mentora/backend/user/model/Users.java +++ b/backend/src/main/java/com/mentora/backend/user/model/Users.java @@ -8,21 +8,17 @@ import java.time.LocalDate; import java.util.Date; -import java.util.UUID; @Entity @Table(name = "users") @Data @NoArgsConstructor @AllArgsConstructor -@Getter -@Setter public class Users { @Id - @GeneratedValue(strategy = GenerationType.UUID) @Column(name = "id") @NotNull - private UUID id; + private String id; @Column(name = "email", unique = true) @NotNull @@ -41,7 +37,7 @@ public class Users { private LocalDate lastActivityDate; @Column(name = "streak_freeze_count", nullable = false, columnDefinition = "integer default 0") - private Integer streakFreezeCount = 5; + private Integer streakFreezeCount = 5; //It's hardcoded right now. Can be fetched from config later on for flexibility. @CreationTimestamp @Column(name = "created_at") @@ -55,6 +51,7 @@ public class Users { private String picture; + // TODO: Transaction management - Bu metod bir service içinde @Transactional olarak çağrılmalı public void updateStreak() { LocalDate today = LocalDate.now(); @@ -80,6 +77,7 @@ public boolean canUseStreakFreeze() { return streakFreezeCount > 0; } + // TODO: Transaction management - Bu metod bir service içinde @Transactional olarak çağrılmalı public void useStreakFreeze() { if (canUseStreakFreeze()) { streakFreezeCount--; From e9c8a43ae855a3a1a48bc565cb4784e9d6fbdf31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emir=20Enes=20Akal=C4=B1n?= Date: Sun, 5 Oct 2025 14:01:28 +0300 Subject: [PATCH 3/4] Improves user authentication and chat title validation Enhances OAuth2 user authentication by adding checks to ensure the principal is an OidcUser and provides fallback mechanism to fetch email. Refactors chat title validation by introducing a constant for the title pattern, improving code readability and maintainability. Adds documentation explaining the streak freeze count. --- .../backend/chat/dto/ChatRenameRequest.java | 4 +++- .../security/CustomOAuth2UserService.java | 22 ++++++++++++++++--- .../com/mentora/backend/user/model/Users.java | 10 ++++++--- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/backend/src/main/java/com/mentora/backend/chat/dto/ChatRenameRequest.java b/backend/src/main/java/com/mentora/backend/chat/dto/ChatRenameRequest.java index 6d3fe00..41963e0 100644 --- a/backend/src/main/java/com/mentora/backend/chat/dto/ChatRenameRequest.java +++ b/backend/src/main/java/com/mentora/backend/chat/dto/ChatRenameRequest.java @@ -15,10 +15,12 @@ @AllArgsConstructor public class ChatRenameRequest { + public static final String TITLE_PATTERN = "^[a-zA-Z0-9\\s\\-_.,!?()]+$"; + @NotBlank(message = "Title cannot be blank") @Size(min = 1, max = 255, message = "Title must be between 1 and 255 characters") @Pattern( - regexp = "^[a-zA-Z0-9\\s\\-_.,!?()]+$", + regexp = ChatRenameRequest.TITLE_PATTERN, message = "Title contains invalid characters. Only letters, numbers, spaces, and basic punctuation are allowed" ) private String newTitle; diff --git a/backend/src/main/java/com/mentora/backend/security/CustomOAuth2UserService.java b/backend/src/main/java/com/mentora/backend/security/CustomOAuth2UserService.java index db3eefe..ec740ba 100644 --- a/backend/src/main/java/com/mentora/backend/security/CustomOAuth2UserService.java +++ b/backend/src/main/java/com/mentora/backend/security/CustomOAuth2UserService.java @@ -53,7 +53,10 @@ public String currentUserId() { throw new UserNotAuthenticatedException(); } - OidcUser oidcUser = (OidcUser) oauthToken.getPrincipal(); + Object principal = oauthToken.getPrincipal(); + if (!(principal instanceof OidcUser oidcUser)) { + throw new UserNotAuthenticatedException("Principal is not an OidcUser"); + } return oidcUser.getAttribute("sub"); } @@ -69,7 +72,10 @@ public String currentUserName() { throw new UserNotAuthenticatedException(); } - OidcUser oidcUser = (OidcUser) oauthToken.getPrincipal(); + Object principal = oauthToken.getPrincipal(); + if (!(principal instanceof OidcUser oidcUser)) { + throw new UserNotAuthenticatedException("Principal is not an OidcUser"); + } return oidcUser.getAttribute("name"); } @@ -85,8 +91,18 @@ public String currentUserEmail() { throw new UserNotAuthenticatedException(); } - OidcUser oidcUser = (OidcUser) oauthToken.getPrincipal(); + Object principal = oauthToken.getPrincipal(); + if (!(principal instanceof OidcUser oidcUser)) { + throw new UserNotAuthenticatedException("Principal is not an OidcUser"); + } + + // Try to get email from 'email' claim first, fallback to 'preferred_username' + String email = oidcUser.getAttribute("email"); + if (email != null && !email.isEmpty()) { + return email; + } + // Fallback to preferred_username (may not always be an email) return oidcUser.getAttribute("preferred_username"); } } \ No newline at end of file diff --git a/backend/src/main/java/com/mentora/backend/user/model/Users.java b/backend/src/main/java/com/mentora/backend/user/model/Users.java index 43350d1..74bac26 100644 --- a/backend/src/main/java/com/mentora/backend/user/model/Users.java +++ b/backend/src/main/java/com/mentora/backend/user/model/Users.java @@ -37,7 +37,13 @@ public class Users { private LocalDate lastActivityDate; @Column(name = "streak_freeze_count", nullable = false, columnDefinition = "integer default 0") - private Integer streakFreezeCount = 5; //It's hardcoded right now. Can be fetched from config later on for flexibility. + + /** + * The number of streak freezes available to the user. + *

+ * This value is currently hardcoded to 5. It can be fetched from configuration later on for flexibility. + */ + private Integer streakFreezeCount = 5; @CreationTimestamp @Column(name = "created_at") @@ -51,7 +57,6 @@ public class Users { private String picture; - // TODO: Transaction management - Bu metod bir service içinde @Transactional olarak çağrılmalı public void updateStreak() { LocalDate today = LocalDate.now(); @@ -77,7 +82,6 @@ public boolean canUseStreakFreeze() { return streakFreezeCount > 0; } - // TODO: Transaction management - Bu metod bir service içinde @Transactional olarak çağrılmalı public void useStreakFreeze() { if (canUseStreakFreeze()) { streakFreezeCount--; From 7027241c11c7593431c7386299d677c85d25c20e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emir=20Enes=20Akal=C4=B1n?= Date: Sun, 5 Oct 2025 14:06:30 +0300 Subject: [PATCH 4/4] Enhances UserNotAuthenticatedException Improves the `UserNotAuthenticatedException` by adding constructors to handle more specific error messages and include a cause. This provides better context and debugging information when a user is not properly authenticated. --- .../error/UserNotAuthenticatedException.java | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/com/mentora/backend/error/UserNotAuthenticatedException.java b/backend/src/main/java/com/mentora/backend/error/UserNotAuthenticatedException.java index 626bc50..53fc076 100644 --- a/backend/src/main/java/com/mentora/backend/error/UserNotAuthenticatedException.java +++ b/backend/src/main/java/com/mentora/backend/error/UserNotAuthenticatedException.java @@ -1,7 +1,19 @@ package com.mentora.backend.error; +/** + * Exception thrown when a user is not authenticated. + */ public class UserNotAuthenticatedException extends RuntimeException { + public UserNotAuthenticatedException() { - super("User is not authenticated"); + super("User not authenticated"); + } + + public UserNotAuthenticatedException(String message) { + super(message); + } + + public UserNotAuthenticatedException(String message, Throwable cause) { + super(message, cause); } -} +} \ No newline at end of file