diff --git a/backend/pom.xml b/backend/pom.xml index bc08d3f..901365d 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -88,6 +88,29 @@ springdoc-openapi-starter-webmvc-ui 2.3.0 + + + io.jsonwebtoken + jjwt-api + 0.12.3 + + + + + io.jsonwebtoken + jjwt-impl + 0.12.3 + runtime + + + + + io.jsonwebtoken + jjwt-jackson + 0.12.3 + runtime + + diff --git a/backend/src/main/java/com/llm_service/llm_service/config/CustomLogoutHandler.java b/backend/src/main/java/com/llm_service/llm_service/config/CustomLogoutHandler.java new file mode 100644 index 0000000..8696edf --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/config/CustomLogoutHandler.java @@ -0,0 +1,33 @@ +package com.llm_service.llm_service.config; + +import com.llm_service.llm_service.persistance.entities.TokenEntity; +import com.llm_service.llm_service.persistance.repositories.token.TokenRepository; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutHandler; + +@Configuration +@RequiredArgsConstructor +public class CustomLogoutHandler implements LogoutHandler { + private final TokenRepository tokenRepository; + + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { + String authHeader = request.getHeader("Authorization"); + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + return; + } + + String token = authHeader.substring(7); + TokenEntity storedToken = tokenRepository.findByToken(token).orElse(null); + + if (storedToken != null) { + storedToken.setLoggedOut(true); + tokenRepository.save(storedToken); + } + } +} diff --git a/backend/src/main/java/com/llm_service/llm_service/config/SecurityConfiguration.java b/backend/src/main/java/com/llm_service/llm_service/config/SecurityConfiguration.java index 8527b0b..11014e2 100644 --- a/backend/src/main/java/com/llm_service/llm_service/config/SecurityConfiguration.java +++ b/backend/src/main/java/com/llm_service/llm_service/config/SecurityConfiguration.java @@ -1,21 +1,67 @@ package com.llm_service.llm_service.config; +import com.llm_service.llm_service.persistance.entities.Role; +import com.llm_service.llm_service.service.jwt.filter.JwtAuthenticationFilter; +import com.llm_service.llm_service.service.user.UserDetailsServiceImp; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.Customizer; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +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.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.context.SecurityContextHolder; +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.HttpStatusEntryPoint; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfiguration { + private final UserDetailsServiceImp userDetailsServiceImp; + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + private final CustomLogoutHandler logoutHandler; + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + + // TODO fix the swagger security config return http.csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(req -> req.anyRequest().permitAll()) - .httpBasic(Customizer.withDefaults()) + .authorizeHttpRequests(req -> req.requestMatchers( + "/login/**", "/register/**", "/forget-password/**", "/swagger-ui/**", "/v3/api-docs/**") + .permitAll() + .requestMatchers("/paid/**") + .hasAuthority(Role.PAID.name()) + .anyRequest() + .authenticated()) + .userDetailsService(userDetailsServiceImp) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .exceptionHandling(e -> e.accessDeniedHandler((request, response, accessDeniedException) -> + response.setStatus(HttpStatus.FORBIDDEN.value())) + .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) + .logout(l -> l.logoutUrl("/logout") + .addLogoutHandler(logoutHandler) + .logoutSuccessHandler( + (request, response, authentication) -> SecurityContextHolder.clearContext())) .build(); } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { + return configuration.getAuthenticationManager(); + } } diff --git a/backend/src/main/java/com/llm_service/llm_service/controller/conversation/ConversationController.java b/backend/src/main/java/com/llm_service/llm_service/controller/conversation/ConversationController.java index d1f8e2c..9163e78 100644 --- a/backend/src/main/java/com/llm_service/llm_service/controller/conversation/ConversationController.java +++ b/backend/src/main/java/com/llm_service/llm_service/controller/conversation/ConversationController.java @@ -91,6 +91,14 @@ public ResponseEntity> continueConversation( .body(discussions.stream().map(conversationApiMapper::map).toList()); } + @ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "Update a conversation", + content = {@Content(mediaType = "application/json")}) + }) + @Operation(summary = "update conversation title") @PutMapping("/{id}") public ResponseEntity editConversation( @PathVariable UUID id, @RequestBody ConversationTitleRequest conversationTitleRequest) @@ -102,6 +110,14 @@ public ResponseEntity editConversation( return ResponseEntity.status(HttpStatus.OK).body(conversationApiMapper.map(conversation)); } + @ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "Deletes a conversation", + content = {@Content(mediaType = "application/json")}) + }) + @Operation(summary = "deletes a conversation") @DeleteMapping("/{id}") public ResponseEntity deleteConversation(@PathVariable UUID id) throws ConversationNotFoundException { conversationService.getByID(id).orElseThrow(() -> new ConversationNotFoundException(id)); @@ -109,6 +125,14 @@ public ResponseEntity deleteConversation(@PathVariable UUID id) throws Con return ResponseEntity.status(HttpStatus.OK).body(null); } + @ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "Deletes all conversation", + content = {@Content(mediaType = "application/json")}) + }) + @Operation(summary = "deletes all conversations") @DeleteMapping public ResponseEntity deleteConversation() { conversationService.deleteAll(); diff --git a/backend/src/main/java/com/llm_service/llm_service/controller/conversation/ConversationRequest.java b/backend/src/main/java/com/llm_service/llm_service/controller/conversation/ConversationRequest.java index adfce82..2bfe797 100644 --- a/backend/src/main/java/com/llm_service/llm_service/controller/conversation/ConversationRequest.java +++ b/backend/src/main/java/com/llm_service/llm_service/controller/conversation/ConversationRequest.java @@ -1,6 +1,7 @@ package com.llm_service.llm_service.controller.conversation; import lombok.Builder; +import lombok.NonNull; import lombok.Value; import lombok.extern.jackson.Jacksonized; @@ -8,5 +9,6 @@ @Builder @Jacksonized public class ConversationRequest { + @NonNull String text; } diff --git a/backend/src/main/java/com/llm_service/llm_service/controller/conversation/ConversationTitleRequest.java b/backend/src/main/java/com/llm_service/llm_service/controller/conversation/ConversationTitleRequest.java index 8f1b1f9..3d67fea 100644 --- a/backend/src/main/java/com/llm_service/llm_service/controller/conversation/ConversationTitleRequest.java +++ b/backend/src/main/java/com/llm_service/llm_service/controller/conversation/ConversationTitleRequest.java @@ -1,6 +1,7 @@ package com.llm_service.llm_service.controller.conversation; import lombok.Builder; +import lombok.NonNull; import lombok.Value; import lombok.extern.jackson.Jacksonized; @@ -8,5 +9,6 @@ @Builder @Jacksonized public class ConversationTitleRequest { + @NonNull String title; } diff --git a/backend/src/main/java/com/llm_service/llm_service/controller/user/LoginRequest.java b/backend/src/main/java/com/llm_service/llm_service/controller/user/LoginRequest.java new file mode 100644 index 0000000..cb933ca --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/controller/user/LoginRequest.java @@ -0,0 +1,17 @@ +package com.llm_service.llm_service.controller.user; + +import lombok.Builder; +import lombok.NonNull; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; + +@Value +@Builder +@Jacksonized +public class LoginRequest { + @NonNull + String username; + + @NonNull + String password; +} diff --git a/backend/src/main/java/com/llm_service/llm_service/controller/user/LoginResponse.java b/backend/src/main/java/com/llm_service/llm_service/controller/user/LoginResponse.java new file mode 100644 index 0000000..77d3482 --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/controller/user/LoginResponse.java @@ -0,0 +1,12 @@ +package com.llm_service.llm_service.controller.user; + +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; + +@Value +@Builder +@Jacksonized +public class LoginResponse { + String token; +} diff --git a/backend/src/main/java/com/llm_service/llm_service/controller/user/UserApiMapper.java b/backend/src/main/java/com/llm_service/llm_service/controller/user/UserApiMapper.java new file mode 100644 index 0000000..5282295 --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/controller/user/UserApiMapper.java @@ -0,0 +1,14 @@ +package com.llm_service.llm_service.controller.user; + +import com.llm_service.llm_service.dto.User; +import com.llm_service.llm_service.service.jwt.AuthenticationResponse; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface UserApiMapper { + LoginResponse map(AuthenticationResponse response); + + @Mapping(target = "name", source = "username") + UserResponse map(User user); +} diff --git a/backend/src/main/java/com/llm_service/llm_service/controller/user/UserController.java b/backend/src/main/java/com/llm_service/llm_service/controller/user/UserController.java new file mode 100644 index 0000000..8dc6631 --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/controller/user/UserController.java @@ -0,0 +1,80 @@ +package com.llm_service.llm_service.controller.user; + +import com.llm_service.llm_service.dto.User; +import com.llm_service.llm_service.exception.user.UserAlreadyExistsException; +import com.llm_service.llm_service.exception.user.UserNotFoundException; +import com.llm_service.llm_service.exception.user.UsernameAlreadyExistsException; +import com.llm_service.llm_service.service.jwt.AuthenticationResponse; +import com.llm_service.llm_service.service.jwt.AuthenticationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.AuthenticationException; +import org.springframework.web.bind.annotation.*; + +@CrossOrigin("http://localhost:4040") +@RestController +public class UserController { + private final AuthenticationService authenticationService; + private final UserApiMapper userApiMapper; + + public UserController(AuthenticationService authenticationService, UserApiMapper userApiMapper) { + this.authenticationService = authenticationService; + this.userApiMapper = userApiMapper; + } + + @ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "Creates a new user", + content = {@Content(mediaType = "application/json")}) + }) + @Operation(summary = "register the user") + @PostMapping("/register") + public ResponseEntity register(@RequestBody UserRequest userRequest) + throws UsernameAlreadyExistsException { + User user = authenticationService.register(userRequest); + return ResponseEntity.status(HttpStatus.OK).body(userApiMapper.map(user)); + } + + @ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "Logs the user into the system", + content = {@Content(mediaType = "application/json")}) + }) + @Operation(summary = "login phase of the user") + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest loginRequest) + throws UserNotFoundException, AuthenticationException { + AuthenticationResponse authenticationResponse = authenticationService.authenticate(loginRequest); + return ResponseEntity.ok(userApiMapper.map(authenticationResponse)); + } + + @ExceptionHandler(UserAlreadyExistsException.class) + ResponseEntity handleUsernameAlreadyExistsExceptions( + UserAlreadyExistsException usernameAlreadyExistsException) { + return ResponseEntity.status(HttpStatus.CONFLICT).body(usernameAlreadyExistsException.getMessage()); + } + + @ExceptionHandler(UsernameAlreadyExistsException.class) + ResponseEntity handleUsernameAlreadyExistsExceptions( + UsernameAlreadyExistsException usernameAlreadyExistsException) { + return ResponseEntity.status(HttpStatus.CONFLICT).body(usernameAlreadyExistsException.getMessage()); + } + + @ExceptionHandler(UserNotFoundException.class) + ResponseEntity handleUsernameNotFoundExceptions(UserNotFoundException usernameNotFoundException) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(usernameNotFoundException.getMessage()); + } + + @ExceptionHandler(AuthenticationException.class) + ResponseEntity handleAuthenticationException(AuthenticationException authenticationException) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(authenticationException.getMessage()); + } +} diff --git a/backend/src/main/java/com/llm_service/llm_service/controller/user/UserRequest.java b/backend/src/main/java/com/llm_service/llm_service/controller/user/UserRequest.java new file mode 100644 index 0000000..527d84a --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/controller/user/UserRequest.java @@ -0,0 +1,25 @@ +package com.llm_service.llm_service.controller.user; + +import com.llm_service.llm_service.persistance.entities.Role; +import lombok.*; +import lombok.extern.jackson.Jacksonized; + +@Value +@Builder +@Jacksonized +public class UserRequest { + @NonNull + String username; + + @NonNull + String password; + + @NonNull + String firstName; + + @NonNull + String lastName; + + @NonNull + Role role; +} diff --git a/backend/src/main/java/com/llm_service/llm_service/controller/user/UserResponse.java b/backend/src/main/java/com/llm_service/llm_service/controller/user/UserResponse.java new file mode 100644 index 0000000..b70bf58 --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/controller/user/UserResponse.java @@ -0,0 +1,12 @@ +package com.llm_service.llm_service.controller.user; + +import lombok.Value; +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +@Value +@Jacksonized +@SuperBuilder +public class UserResponse { + String name; +} diff --git a/backend/src/main/java/com/llm_service/llm_service/dto/User.java b/backend/src/main/java/com/llm_service/llm_service/dto/User.java new file mode 100644 index 0000000..7e13547 --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/dto/User.java @@ -0,0 +1,17 @@ +package com.llm_service.llm_service.dto; + +import com.llm_service.llm_service.persistance.entities.Role; +import java.util.UUID; +import lombok.Builder; +import lombok.Value; + +@Value +@Builder(toBuilder = true) +public class User { + UUID id; + String firstName; + String lastName; + String username; + String password; + Role role; +} diff --git a/backend/src/main/java/com/llm_service/llm_service/exception/user/UserNotFoundException.java b/backend/src/main/java/com/llm_service/llm_service/exception/user/UserNotFoundException.java index 9738b0a..e9915fa 100644 --- a/backend/src/main/java/com/llm_service/llm_service/exception/user/UserNotFoundException.java +++ b/backend/src/main/java/com/llm_service/llm_service/exception/user/UserNotFoundException.java @@ -6,4 +6,8 @@ public class UserNotFoundException extends Exception { public UserNotFoundException(UUID id) { super("User with id " + id + " is not found"); } + + public UserNotFoundException() { + super("User with is not found"); + } } diff --git a/backend/src/main/java/com/llm_service/llm_service/exception/user/UsernameAlreadyExistsException.java b/backend/src/main/java/com/llm_service/llm_service/exception/user/UsernameAlreadyExistsException.java new file mode 100644 index 0000000..32d279b --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/exception/user/UsernameAlreadyExistsException.java @@ -0,0 +1,7 @@ +package com.llm_service.llm_service.exception.user; + +public class UsernameAlreadyExistsException extends Exception { + public UsernameAlreadyExistsException(String username) { + super("Username with name " + username + " already exists"); + } +} diff --git a/backend/src/main/java/com/llm_service/llm_service/persistance/entities/FullName.java b/backend/src/main/java/com/llm_service/llm_service/persistance/entities/FullName.java new file mode 100644 index 0000000..275e0d5 --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/persistance/entities/FullName.java @@ -0,0 +1,15 @@ +package com.llm_service.llm_service.persistance.entities; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.Data; + +@Data +@Embeddable +public class FullName { + @Column(name = "first_name") + private String firstName; + + @Column(name = "last_name") + private String lastName; +} diff --git a/backend/src/main/java/com/llm_service/llm_service/persistance/entities/Role.java b/backend/src/main/java/com/llm_service/llm_service/persistance/entities/Role.java new file mode 100644 index 0000000..11432df --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/persistance/entities/Role.java @@ -0,0 +1,6 @@ +package com.llm_service.llm_service.persistance.entities; + +public enum Role { + PAID, + FREE +} diff --git a/backend/src/main/java/com/llm_service/llm_service/persistance/entities/TokenEntity.java b/backend/src/main/java/com/llm_service/llm_service/persistance/entities/TokenEntity.java new file mode 100644 index 0000000..3b18fae --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/persistance/entities/TokenEntity.java @@ -0,0 +1,21 @@ +package com.llm_service.llm_service.persistance.entities; + +import jakarta.persistence.*; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "token") +@Data +public class TokenEntity extends BaseEntity { + @Column(name = "token") + private String token; + + @Column(name = "logged_out") + private boolean loggedOut; + + @ManyToOne + @JoinColumn(name = "user_id") + private UserEntity user; +} diff --git a/backend/src/main/java/com/llm_service/llm_service/persistance/entities/UserEntity.java b/backend/src/main/java/com/llm_service/llm_service/persistance/entities/UserEntity.java new file mode 100644 index 0000000..13f8d61 --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/persistance/entities/UserEntity.java @@ -0,0 +1,59 @@ +package com.llm_service.llm_service.persistance.entities; + +import jakarta.persistence.*; +import java.util.Collection; +import java.util.List; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = "users") +@Data +public class UserEntity extends BaseEntity implements UserDetails { + + @Embedded + private FullName fullName; + + @Column(name = "username") + private String username; + + @Column(name = "password") + private String password; + + @Enumerated(value = EnumType.STRING) + @Column(name = "role") + private Role role; + + @OneToMany(mappedBy = "user") + private List tokens; + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority(role.name())); + } + + // TODO the following should be researched to be figure out how it us being used in SecurityConfig + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/backend/src/main/java/com/llm_service/llm_service/persistance/repositories/token/TokenEntityMapper.java b/backend/src/main/java/com/llm_service/llm_service/persistance/repositories/token/TokenEntityMapper.java new file mode 100644 index 0000000..95ca484 --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/persistance/repositories/token/TokenEntityMapper.java @@ -0,0 +1,12 @@ +package com.llm_service.llm_service.persistance.repositories.token; + +import com.llm_service.llm_service.persistance.entities.TokenEntity; +import com.llm_service.llm_service.service.jwt.Token; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public interface TokenEntityMapper { + Token map(TokenEntity tokenEntity); + + TokenEntity map(Token token); +} diff --git a/backend/src/main/java/com/llm_service/llm_service/persistance/repositories/token/TokenJpaPersistenceManager.java b/backend/src/main/java/com/llm_service/llm_service/persistance/repositories/token/TokenJpaPersistenceManager.java new file mode 100644 index 0000000..ef6d965 --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/persistance/repositories/token/TokenJpaPersistenceManager.java @@ -0,0 +1,51 @@ +package com.llm_service.llm_service.persistance.repositories.token; + +import com.llm_service.llm_service.service.jwt.Token; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class TokenJpaPersistenceManager implements TokenPersistenceManager { + + private final TokenRepository tokenRepository; + private final TokenEntityMapper tokenEntityMapper; + + @Autowired + public TokenJpaPersistenceManager(TokenRepository tokenRepository, TokenEntityMapper tokenEntityMapper) { + this.tokenRepository = tokenRepository; + this.tokenEntityMapper = tokenEntityMapper; + } + + @Override + public List findAll() { + return tokenRepository.findAll().stream().map(tokenEntityMapper::map).toList(); + } + + @Override + public List findAllTokensByUser(UUID id) { + return tokenRepository.findAllTokensByUserId(id).stream() + .map(tokenEntityMapper::map) + .toList(); + } + + @Override + public Optional findById(UUID id) { + return tokenRepository.findById(id).map(tokenEntityMapper::map); + } + + @Override + public void save(Token token) { + tokenRepository.save(tokenEntityMapper.map(token)); + } + + @Override + public void saveAll(List tokens) {} + + @Override + public void delete(UUID id) { + tokenRepository.deleteById(id); + } +} diff --git a/backend/src/main/java/com/llm_service/llm_service/persistance/repositories/token/TokenPersistenceManager.java b/backend/src/main/java/com/llm_service/llm_service/persistance/repositories/token/TokenPersistenceManager.java new file mode 100644 index 0000000..fa2cb88 --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/persistance/repositories/token/TokenPersistenceManager.java @@ -0,0 +1,21 @@ +package com.llm_service.llm_service.persistance.repositories.token; + +import com.llm_service.llm_service.service.jwt.Token; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface TokenPersistenceManager { + + List findAll(); + + List findAllTokensByUser(UUID id); + + Optional findById(UUID id); + + void save(Token token); + + void saveAll(List tokens); + + void delete(UUID id); +} diff --git a/backend/src/main/java/com/llm_service/llm_service/persistance/repositories/token/TokenRepository.java b/backend/src/main/java/com/llm_service/llm_service/persistance/repositories/token/TokenRepository.java new file mode 100644 index 0000000..5c0bbf7 --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/persistance/repositories/token/TokenRepository.java @@ -0,0 +1,14 @@ +package com.llm_service.llm_service.persistance.repositories.token; + +import com.llm_service.llm_service.persistance.entities.TokenEntity; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TokenRepository extends JpaRepository { + + List findAllTokensByUserId(UUID id); + + Optional findByToken(String token); +} diff --git a/backend/src/main/java/com/llm_service/llm_service/persistance/repositories/user/UserEntityMapper.java b/backend/src/main/java/com/llm_service/llm_service/persistance/repositories/user/UserEntityMapper.java new file mode 100644 index 0000000..52a97e8 --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/persistance/repositories/user/UserEntityMapper.java @@ -0,0 +1,21 @@ +package com.llm_service.llm_service.persistance.repositories.user; + +import com.llm_service.llm_service.dto.User; +import com.llm_service.llm_service.persistance.entities.FullName; +import com.llm_service.llm_service.persistance.entities.UserEntity; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface UserEntityMapper { + + @Mapping(target = "firstName", source = "userEntity.fullName.firstName") + @Mapping(target = "lastName", source = "userEntity.fullName.lastName") + User map(UserEntity userEntity); + + FullName map(String firstName, String lastName); + + @Mapping(target = "fullName.firstName", source = "firstName") + @Mapping(target = "fullName.lastName", source = "lastName") + UserEntity map(User user); +} diff --git a/backend/src/main/java/com/llm_service/llm_service/persistance/repositories/user/UserJpaPersistenceManager.java b/backend/src/main/java/com/llm_service/llm_service/persistance/repositories/user/UserJpaPersistenceManager.java new file mode 100644 index 0000000..ae888ea --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/persistance/repositories/user/UserJpaPersistenceManager.java @@ -0,0 +1,46 @@ +package com.llm_service.llm_service.persistance.repositories.user; + +import com.llm_service.llm_service.dto.User; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class UserJpaPersistenceManager implements UserPersistenceManager { + private final UserRepository userRepository; + + private final UserEntityMapper userEntityMapper; + + @Autowired + public UserJpaPersistenceManager(UserRepository userRepository, UserEntityMapper customerEntityMapper) { + this.userRepository = userRepository; + this.userEntityMapper = customerEntityMapper; + } + + @Override + public List findAll() { + return userRepository.findAll().stream().map(userEntityMapper::map).toList(); + } + + @Override + public Optional findById(UUID id) { + return userRepository.findById(id).map(userEntityMapper::map); + } + + @Override + public Optional findByUsername(String username) { + return userRepository.findByUsername(username).map(userEntityMapper::map); + } + + @Override + public User save(User customer) { + return userEntityMapper.map(userRepository.save(userEntityMapper.map(customer))); + } + + @Override + public void delete(UUID id) { + userRepository.deleteById(id); + } +} diff --git a/backend/src/main/java/com/llm_service/llm_service/persistance/repositories/user/UserPersistenceManager.java b/backend/src/main/java/com/llm_service/llm_service/persistance/repositories/user/UserPersistenceManager.java new file mode 100644 index 0000000..58c1dc6 --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/persistance/repositories/user/UserPersistenceManager.java @@ -0,0 +1,18 @@ +package com.llm_service.llm_service.persistance.repositories.user; + +import com.llm_service.llm_service.dto.User; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface UserPersistenceManager { + List findAll(); + + Optional findById(UUID id); + + Optional findByUsername(String username); + + User save(User customer); + + void delete(UUID id); // TODO to be removed in the future +} diff --git a/backend/src/main/java/com/llm_service/llm_service/persistance/repositories/user/UserRepository.java b/backend/src/main/java/com/llm_service/llm_service/persistance/repositories/user/UserRepository.java new file mode 100644 index 0000000..565f1fd --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/persistance/repositories/user/UserRepository.java @@ -0,0 +1,10 @@ +package com.llm_service.llm_service.persistance.repositories.user; + +import com.llm_service.llm_service.persistance.entities.UserEntity; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { + Optional findByUsername(String username); +} diff --git a/backend/src/main/java/com/llm_service/llm_service/service/jwt/AuthenticationResponse.java b/backend/src/main/java/com/llm_service/llm_service/service/jwt/AuthenticationResponse.java new file mode 100644 index 0000000..5d81f37 --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/service/jwt/AuthenticationResponse.java @@ -0,0 +1,14 @@ +package com.llm_service.llm_service.service.jwt; + +import lombok.Data; + +@Data +public class AuthenticationResponse { + private String token; + private String message; + + public AuthenticationResponse(String token, String message) { + this.token = token; + this.message = message; + } +} diff --git a/backend/src/main/java/com/llm_service/llm_service/service/jwt/AuthenticationService.java b/backend/src/main/java/com/llm_service/llm_service/service/jwt/AuthenticationService.java new file mode 100644 index 0000000..e2bc2a5 --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/service/jwt/AuthenticationService.java @@ -0,0 +1,96 @@ +package com.llm_service.llm_service.service.jwt; + +import com.llm_service.llm_service.controller.user.LoginRequest; +import com.llm_service.llm_service.controller.user.UserRequest; +import com.llm_service.llm_service.dto.User; +import com.llm_service.llm_service.exception.user.UserNotFoundException; +import com.llm_service.llm_service.exception.user.UsernameAlreadyExistsException; +import com.llm_service.llm_service.persistance.entities.UserEntity; +import com.llm_service.llm_service.persistance.repositories.token.TokenPersistenceManager; +import com.llm_service.llm_service.persistance.repositories.user.UserEntityMapper; +import com.llm_service.llm_service.persistance.repositories.user.UserPersistenceManager; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class AuthenticationService { + + private final UserPersistenceManager userPersistenceManager; + + private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; + + private final TokenPersistenceManager tokenPersistenceManager; + + private final AuthenticationManager authenticationManager; + + private final UserEntityMapper userEntityMapper; + + public User register(UserRequest userRequest) throws UsernameAlreadyExistsException { + User user = User.builder() + .role(userRequest.getRole()) + .username(userRequest.getUsername()) + .password(userRequest.getPassword()) + .firstName(userRequest.getFirstName()) + .lastName(userRequest.getLastName()) + .build(); + + // check if user already exist. if exist than authenticate the user + if (userPersistenceManager.findByUsername(user.getUsername()).isPresent()) { + throw new UsernameAlreadyExistsException(user.getUsername()); + } + + User newUser = user.toBuilder() + .password(this.passwordEncoder.encode(user.getPassword())) + .build(); + + UserEntity userEntity = userEntityMapper.map(newUser); + + User savedUser = userPersistenceManager.save(userEntityMapper.map(userEntity)); + + String jwt = jwtService.generateToken(savedUser); + + saveUserToken(jwt, savedUser); + + return savedUser; + } + + public AuthenticationResponse authenticate(LoginRequest loginRequest) + throws UserNotFoundException, AuthenticationException { + authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword())); + + User user = userPersistenceManager + .findByUsername(loginRequest.getUsername()) + .orElseThrow(UserNotFoundException::new); + String jwt = jwtService.generateToken(user); + + revokeAllTokenByUser(user); + saveUserToken(jwt, user); + + return new AuthenticationResponse(jwt, "User login was successful"); + } + + private void revokeAllTokenByUser(User user) { + List validTokens = tokenPersistenceManager.findAllTokensByUser(user.getId()); + if (validTokens.isEmpty()) { + return; + } + + validTokens.forEach(t -> t.toBuilder().loggedOut(false).build()); + + tokenPersistenceManager.saveAll(validTokens); + } + + private void saveUserToken(String jwt, User user) { + Token token = Token.builder().token(jwt).loggedOut(false).user(user).build(); + + tokenPersistenceManager.save(token); + } +} diff --git a/backend/src/main/java/com/llm_service/llm_service/service/jwt/JwtService.java b/backend/src/main/java/com/llm_service/llm_service/service/jwt/JwtService.java new file mode 100644 index 0000000..fef9059 --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/service/jwt/JwtService.java @@ -0,0 +1,72 @@ +package com.llm_service.llm_service.service.jwt; + +import com.llm_service.llm_service.dto.User; +import com.llm_service.llm_service.persistance.repositories.token.TokenRepository; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import java.util.Date; +import java.util.function.Function; +import javax.crypto.SecretKey; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +@Service +public class JwtService { + private final String SECRET_KEY = "4bb6d1dfbafb64a681139d1586b6f1160d18159afd57c8c79136d7490630407c"; + private final TokenRepository tokenRepository; + + public JwtService(TokenRepository tokenRepository) { + this.tokenRepository = tokenRepository; + } + + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + + public boolean isValid(String token, UserDetails user) { + String username = extractUsername(token); + + boolean validToken = + tokenRepository.findByToken(token).map(t -> !t.isLoggedOut()).orElse(false); + + return (username.equals(user.getUsername())) && !isTokenExpired(token) && validToken; + } + + private boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + private Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); + } + + public T extractClaim(String token, Function resolver) { + Claims claims = extractAllClaims(token); + return resolver.apply(claims); + } + + private Claims extractAllClaims(String token) { + return Jwts.parser() + .verifyWith(getSignInKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + public String generateToken(User user) { + + return Jwts.builder() + .subject(user.getUsername()) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000)) + .signWith(getSignInKey()) + .compact(); + } + + private SecretKey getSignInKey() { + byte[] keyBytes = Decoders.BASE64URL.decode(SECRET_KEY); + return Keys.hmacShaKeyFor(keyBytes); + } +} diff --git a/backend/src/main/java/com/llm_service/llm_service/service/jwt/Token.java b/backend/src/main/java/com/llm_service/llm_service/service/jwt/Token.java new file mode 100644 index 0000000..289c92b --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/service/jwt/Token.java @@ -0,0 +1,15 @@ +package com.llm_service.llm_service.service.jwt; + +import com.llm_service.llm_service.dto.User; +import java.util.UUID; +import lombok.Builder; +import lombok.Value; + +@Value +@Builder(toBuilder = true) +public class Token { + UUID id; + String token; + boolean loggedOut; + User user; +} diff --git a/backend/src/main/java/com/llm_service/llm_service/service/jwt/filter/JwtAuthenticationFilter.java b/backend/src/main/java/com/llm_service/llm_service/service/jwt/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..ebfdc97 --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/service/jwt/filter/JwtAuthenticationFilter.java @@ -0,0 +1,61 @@ +package com.llm_service.llm_service.service.jwt.filter; + +import com.llm_service.llm_service.service.jwt.JwtService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + private final UserDetailsService userDetailsService; + + public JwtAuthenticationFilter(JwtService jwtService, UserDetailsService userDetailsService) { + this.jwtService = jwtService; + this.userDetailsService = userDetailsService; + } + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) + throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + String token = authHeader.substring(7); + String username = jwtService.extractUsername(token); + + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + + if (jwtService.isValid(token, userDetails)) { + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + filterChain.doFilter(request, response); + } +} diff --git a/backend/src/main/java/com/llm_service/llm_service/service/user/UserDetailsServiceImp.java b/backend/src/main/java/com/llm_service/llm_service/service/user/UserDetailsServiceImp.java new file mode 100644 index 0000000..b681f17 --- /dev/null +++ b/backend/src/main/java/com/llm_service/llm_service/service/user/UserDetailsServiceImp.java @@ -0,0 +1,27 @@ +package com.llm_service.llm_service.service.user; + +import com.llm_service.llm_service.persistance.repositories.user.UserEntityMapper; +import com.llm_service.llm_service.persistance.repositories.user.UserPersistenceManager; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +public class UserDetailsServiceImp implements UserDetailsService { + private final UserPersistenceManager userPersistenceManager; + private final UserEntityMapper userEntityMapper; + + public UserDetailsServiceImp(UserPersistenceManager userPersistenceManager, UserEntityMapper userEntityMapper) { + this.userPersistenceManager = userPersistenceManager; + this.userEntityMapper = userEntityMapper; + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + return userPersistenceManager + .findByUsername(username) + .map(userEntityMapper::map) + .orElseThrow(() -> new UsernameNotFoundException(username)); + } +}