diff --git a/.idea/workspace.xml b/.idea/workspace.xml deleted file mode 100644 index 5accd00..0000000 --- a/.idea/workspace.xml +++ /dev/null @@ -1,256 +0,0 @@ - - - - - - - - - - - - - - - - { - "lastFilter": { - "state": "OPEN", - "assignee": "BelisAliosmanova" - } -} - { - "selectedUrlAndAccountId": { - "url": "https://github.com/BelisAliosmanova/LifeAI.git", - "accountId": "a9e64eaa-e818-4106-8acd-7f56e3dadcf0" - } -} - - - - { - "associatedIndex": 3 -} - - - - - { - "keyToString": { - "Application.LifeAiApplication.executor": "Run", - "Docker.server.pgvector: Compose Deployment.executor": "Run", - "Docker.server: Compose Deployment.executor": "Run", - "JUnit.LifeAiApplicationTests.executor": "Run", - "Maven.LifeAI [clean,test,install].executor": "Run", - "RunOnceActivity.ShowReadmeOnStart": "true", - "SHARE_PROJECT_CONFIGURATION_FILES": "true", - "git-widget-placeholder": "crawlers", - "ignore.virus.scanning.warn.message": "true", - "kotlin-language-version-configured": "true", - "last_opened_file_path": "C:/Users/Lenovo/Desktop/LifeAI/server/src/main/java/com/lifeAI/LifeAI/config" - } -} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1724664209058 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/server/pom.xml b/server/pom.xml index 5de7a39..6cad1c7 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -45,7 +45,122 @@ selenium-java 4.9.1 + + org.springframework.boot + spring-boot-starter-security + + + com.github.jsqlparser + jsqlparser + 5.3 + compile + + + io.github.resilience4j + resilience4j-spring-boot2 + 2.0.2 + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.8 + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.security + spring-security-test + test + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + org.springframework + spring-webflux + + + + com.google.api-client + google-api-client + 2.6.0 + + + org.json + json + 20231013 + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + + + org.springframework.boot + spring-boot-starter-mail + 3.2.1 + + + org.jetbrains + annotations + 16.0.1 + + + io.springfox + springfox-boot-starter + 3.0.0 + + + org.modelmapper + modelmapper + 3.2.0 + + + javax.servlet + javax.servlet-api + 4.0.1 + provided + + + io.github.resilience4j + resilience4j-spring-boot2 + 2.0.2 + + + org.springframework.security + spring-security-test + test + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + org.simplejavamail simple-java-mail diff --git a/server/src/main/java/com/lifeAI/LifeAI/config/ApplicationConfig.java b/server/src/main/java/com/lifeAI/LifeAI/config/ApplicationConfig.java index ebd233f..e948f85 100644 --- a/server/src/main/java/com/lifeAI/LifeAI/config/ApplicationConfig.java +++ b/server/src/main/java/com/lifeAI/LifeAI/config/ApplicationConfig.java @@ -3,12 +3,26 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.lifeAI.LifeAI.exceptions.user.UserNotFoundException; +import com.lifeAI.LifeAI.model.baseEntity.BaseEntity; +import com.lifeAI.LifeAI.model.dto.common.BaseDTO; +import com.lifeAI.LifeAI.respository.UserRepository; +import jakarta.validation.Validation; +import jakarta.validation.Validator; import lombok.RequiredArgsConstructor; import org.modelmapper.ModelMapper; -import org.springframework.context.MessageSource; +import org.modelmapper.convention.MatchingStrategies; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.EnableAspectJAutoProxy; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.client.RestTemplate; /** @@ -18,13 +32,36 @@ @Configuration @RequiredArgsConstructor @EnableAspectJAutoProxy +@EnableAsync public class ApplicationConfig { - - private final MessageSource messageSource; + private final UserRepository repository; @Bean public ModelMapper modelMapper() { - return new ModelMapper(); + ModelMapper modelMapper = new ModelMapper(); + + modelMapper + .getConfiguration() + .setPropertyCondition(context -> { + if ( + !(context.getParent().getDestination() instanceof BaseEntity && + context.getParent().getSource() instanceof BaseDTO) + ) { + return true; + } + + String destinationProperty = context.getMapping().getLastDestinationProperty().getName(); + + return !("id".equals(destinationProperty) || + "createdAt".equals(destinationProperty) || + "updatedAt".equals(destinationProperty) || + "deletedAt".equals(destinationProperty)); + }) + .setFieldMatchingEnabled(true) + .setFieldAccessLevel(org.modelmapper.config.Configuration.AccessLevel.PRIVATE) + .setMatchingStrategy(MatchingStrategies.STANDARD); + + return modelMapper; } @Bean @@ -38,10 +75,43 @@ public ObjectMapper objectMapper() { return objectMapper; } + @Bean + public Validator validator() { + Validator validator; + + try (var factory = Validation.buildDefaultValidatorFactory()) { + validator = factory.getValidator(); + } + + return validator; + } + + @Bean + public UserDetailsService userDetailsService() { + return email -> repository.findByEmail(email) + .orElseThrow(UserNotFoundException::new); + } + + @Bean + public AuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailsService()); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } @Bean public RestTemplate restTemplate() { return new RestTemplate(); } - } diff --git a/server/src/main/java/com/lifeAI/LifeAI/config/FrontendConfig.java b/server/src/main/java/com/lifeAI/LifeAI/config/FrontendConfig.java index 27932a8..7e4dec9 100644 --- a/server/src/main/java/com/lifeAI/LifeAI/config/FrontendConfig.java +++ b/server/src/main/java/com/lifeAI/LifeAI/config/FrontendConfig.java @@ -16,6 +16,6 @@ public class FrontendConfig { private String baseUrl; private String loginUrl; - private String finishRegisterUrl; private String forgottenPasswordUrl; + private String oauth2RedirectUrl; } diff --git a/server/src/main/java/com/lifeAI/LifeAI/config/MailConfig.java b/server/src/main/java/com/lifeAI/LifeAI/config/MailConfig.java new file mode 100644 index 0000000..cc72174 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/config/MailConfig.java @@ -0,0 +1,39 @@ +package com.lifeAI.LifeAI.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +import java.util.Properties; + +/** + * Configuration class for setting up email properties and creating + * a JavaMailSender bean for sending emails. + */ +@Configuration +public class MailConfig { + + @Value("${spring.security.mail.username}") + private String email; + + @Value("${spring.security.mail.password}") + private String password; + + @Bean + public JavaMailSender javaMailSender() { + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost("smtp.gmail.com"); + mailSender.setPort(587); + mailSender.setUsername(email); + mailSender.setPassword(password); + + Properties properties = new Properties(); + properties.put("mail.smtp.auth", true); + properties.put("mail.smtp.starttls.enable", true); + mailSender.setJavaMailProperties(properties); + + return mailSender; + } +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/config/SecurityConfiguration.java b/server/src/main/java/com/lifeAI/LifeAI/config/SecurityConfiguration.java new file mode 100644 index 0000000..878e549 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/config/SecurityConfiguration.java @@ -0,0 +1,58 @@ +package com.lifeAI.LifeAI.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lifeAI.LifeAI.filters.JwtAuthenticationFilter; +import com.lifeAI.LifeAI.handlers.JwtAuthenticationEntryPoint; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +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.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.logout.LogoutHandler; + +/** + * Configuration class for setting up security configurations, including authentication, + * authorization, and OAuth2 integration. + */ +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +@EnableMethodSecurity +public class SecurityConfiguration { + + private final JwtAuthenticationFilter jwtAuthFilter; + private final AuthenticationProvider authenticationProvider; + private final LogoutHandler logoutHandler; + private final ObjectMapper objectMapper; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .cors(Customizer.withDefaults()) + .csrf(AbstractHttpConfigurer::disable) + .exceptionHandling(customizer -> { + customizer.authenticationEntryPoint(new JwtAuthenticationEntryPoint(objectMapper)); + }) + .sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests((authorize) -> authorize + .anyRequest().permitAll() + ) + .authenticationProvider(authenticationProvider) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) + .logout(httpSecurityLogoutConfigurer -> { + httpSecurityLogoutConfigurer.logoutUrl("/api/v1/auth/logout"); + httpSecurityLogoutConfigurer.addLogoutHandler(logoutHandler); + httpSecurityLogoutConfigurer.logoutSuccessHandler((request, response, authentication) -> SecurityContextHolder.clearContext()); + }); + + return http.build(); + } +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/config/WebClientConfig.java b/server/src/main/java/com/lifeAI/LifeAI/config/WebClientConfig.java new file mode 100644 index 0000000..cd79065 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/config/WebClientConfig.java @@ -0,0 +1,18 @@ +package com.lifeAI.LifeAI.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +@Component +public class WebClientConfig { + + @Value("${spring.security.oauth2.resourceserver.opaque-token.introspection-uri}") + private String introspectUri; + + @Bean + public WebClient userInfoClient() { + return WebClient.builder().baseUrl(introspectUri).build(); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/config/rateLimiting/RateLimiterConfigProperties.java b/server/src/main/java/com/lifeAI/LifeAI/config/rateLimiting/RateLimiterConfigProperties.java new file mode 100644 index 0000000..9941412 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/config/rateLimiting/RateLimiterConfigProperties.java @@ -0,0 +1,24 @@ +package com.lifeAI.LifeAI.config.rateLimiting; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.Map; + +@Configuration +@ConfigurationProperties(prefix = "resilience4j.rate-limiter") +@Getter +@Setter +public class RateLimiterConfigProperties { + private Map instances; + + @Getter + @Setter + public static class RateLimiterInstanceConfig { + private int limitForPeriod; + private String limitRefreshPeriod; + private int timeoutDuration; + } +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/config/rateLimiting/Resilience4jConfig.java b/server/src/main/java/com/lifeAI/LifeAI/config/rateLimiting/Resilience4jConfig.java new file mode 100644 index 0000000..54be0cd --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/config/rateLimiting/Resilience4jConfig.java @@ -0,0 +1,39 @@ +package com.lifeAI.LifeAI.config.rateLimiting; + +import io.github.resilience4j.ratelimiter.RateLimiterConfig; +import io.github.resilience4j.ratelimiter.RateLimiterRegistry; +import lombok.AllArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; +import java.util.Map; + +@Configuration +@AllArgsConstructor +public class Resilience4jConfig { + private final RateLimiterConfigProperties rateLimiterConfigProperties; + + @Bean + public RateLimiterRegistry rateLimiterRegistry() { + RateLimiterRegistry registry = RateLimiterRegistry.ofDefaults(); + + Map instances = + rateLimiterConfigProperties.getInstances(); + + for (Map.Entry entry : instances.entrySet()) { + String limiterName = entry.getKey(); + RateLimiterConfigProperties.RateLimiterInstanceConfig config = entry.getValue(); + + RateLimiterConfig rateLimiterConfig = RateLimiterConfig.custom() + .limitForPeriod(config.getLimitForPeriod()) + .limitRefreshPeriod(Duration.parse("PT" + config.getLimitRefreshPeriod().toUpperCase())) + .timeoutDuration(Duration.ofMillis(config.getTimeoutDuration())) + .build(); + + registry.rateLimiter(limiterName, rateLimiterConfig); + } + + return registry; + } +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/controllers/ArticleController.java b/server/src/main/java/com/lifeAI/LifeAI/controllers/ArticleController.java new file mode 100644 index 0000000..bdaa08f --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/controllers/ArticleController.java @@ -0,0 +1,46 @@ +package com.lifeAI.LifeAI.controllers; + +import com.lifeAI.LifeAI.model.Article; +import com.lifeAI.LifeAI.model.dto.common.ArticleCardDTO; +import com.lifeAI.LifeAI.services.ArticleService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/articles") +@RequiredArgsConstructor +public class ArticleController { + + private final ArticleService articleService; + + @GetMapping + public ResponseEntity> getAll( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + return ResponseEntity.ok(articleService.getAllArticles(page, size)); + } + + @GetMapping("/subcategory/{subCategory}") + public ResponseEntity> getBySubCategory( + @PathVariable String subCategory, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + return ResponseEntity.ok(articleService.getArticlesBySubCategory(subCategory, page, size)); + } + + @GetMapping("/{id}") + public ResponseEntity
getById(@PathVariable Long id) { + return ResponseEntity.ok(articleService.getArticleById(id)); + } + + @GetMapping("/random/no-subcategory") + public ResponseEntity> getRandomNoSubCategory() { + List randomArticles = articleService.getRandomArticlesWithNoSubCategory(3); + return ResponseEntity.ok(randomArticles); + } + +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/controllers/AuthenticationController.java b/server/src/main/java/com/lifeAI/LifeAI/controllers/AuthenticationController.java new file mode 100644 index 0000000..a43a6cd --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/controllers/AuthenticationController.java @@ -0,0 +1,104 @@ +package com.lifeAI.LifeAI.controllers; + +import com.lifeAI.LifeAI.config.FrontendConfig; +import com.lifeAI.LifeAI.filters.JwtAuthenticationFilter; +import com.lifeAI.LifeAI.model.User; +import com.lifeAI.LifeAI.model.dto.auth.AuthenticationRequest; +import com.lifeAI.LifeAI.model.dto.auth.AuthenticationResponse; +import com.lifeAI.LifeAI.model.dto.auth.RegisterRequest; +import com.lifeAI.LifeAI.services.AuthenticationService; +import com.lifeAI.LifeAI.services.security.events.OnPasswordResetRequestEvent; +import com.lifeAI.LifeAI.services.security.events.OnRegistrationCompleteEvent; +import io.github.resilience4j.ratelimiter.annotation.RateLimiter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.modelmapper.ModelMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; + +/** + * Controller class for handling authentication-related operations. + * JWT (access and refresh token); + * OAuth2; + * Email confirmation; + * Forgotten password. + */ +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +@Tag(name = "Authentication") +public class AuthenticationController { + + private final AuthenticationService authenticationService; + private final ApplicationEventPublisher eventPublisher; + private final FrontendConfig frontendConfig; + private final ModelMapper modelMapper; + + @Value("${server.backend.baseUrl}") + private String appBaseUrl; + + @PostMapping("/register") + @RateLimiter(name = "sensitive_operations_rate_limiter") + public ResponseEntity register(@RequestBody RegisterRequest request) { + AuthenticationResponse authenticationResponse = authenticationService.register(request); + + // Email verification eventually + User user = modelMapper.map(authenticationResponse.getUser(), User.class); + eventPublisher.publishEvent(new OnRegistrationCompleteEvent(user, appBaseUrl)); + + return ResponseEntity.ok(authenticationResponse); + } + + //Endpoint for email confirmation during registration + @GetMapping("/registrationConfirm") + @RateLimiter(name = "sensitive_operations_rate_limiter") + public ResponseEntity confirmRegistration(@RequestParam("token") String token, HttpServletResponse httpServletResponse) throws IOException { + authenticationService.confirmRegistration(token); + httpServletResponse.sendRedirect(frontendConfig.getLoginUrl()); + return ResponseEntity.ok("User registration confirmed successfully!"); + } + + @PostMapping("/authenticate") // login + @RateLimiter(name = "sensitive_operations_rate_limiter") + public ResponseEntity authenticate(@RequestBody AuthenticationRequest request, HttpServletResponse servletResponse) { + AuthenticationResponse authenticationResponse = authenticationService.authenticate(request); + return ResponseEntity.ok(authenticationResponse); + } + + @GetMapping("/refresh-token/{refreshToken}") + @RateLimiter(name = "sensitive_operations_rate_limiter") + public ResponseEntity refreshToken(@PathVariable String refreshToken) throws IOException { + AuthenticationResponse authenticationResponse = authenticationService.refreshToken(refreshToken); + return ResponseEntity.ok(authenticationResponse); + } + + @GetMapping("/me") // Retrieves current user information. + @RateLimiter(name = "sensitive_operations_rate_limiter") + public ResponseEntity getMe(HttpServletRequest request) { + String jwtToken = (String) request.getAttribute(JwtAuthenticationFilter.JWT_KEY); + AuthenticationResponse authenticationResponse = authenticationService.me(jwtToken); + + return ResponseEntity.ok(authenticationResponse); + } + + @PostMapping("/forgot-password") // Sends link to email so the user can change their password + @RateLimiter(name = "sensitive_operations_rate_limiter") + public ResponseEntity forgotPassword(@RequestParam("email") String email) { + User user = authenticationService.forgotPassword(email); + eventPublisher.publishEvent(new OnPasswordResetRequestEvent(user, appBaseUrl)); + return ResponseEntity.ok("Password reset link sent to your email!"); + } + + @PostMapping("/password-reset") + @RateLimiter(name = "sensitive_operations_rate_limiter") + public ResponseEntity resetPassword(@RequestParam("token") String token, @RequestParam("newPassword") String newPassword) { + authenticationService.resetPassword(token, newPassword); + return ResponseEntity.ok("Password reset successfully"); + } +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/controllers/DailyProgressController.java b/server/src/main/java/com/lifeAI/LifeAI/controllers/DailyProgressController.java new file mode 100644 index 0000000..5fb9e69 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/controllers/DailyProgressController.java @@ -0,0 +1,49 @@ +package com.lifeAI.LifeAI.controllers; + +import com.lifeAI.LifeAI.model.DailyProgress; +import com.lifeAI.LifeAI.model.User; +import com.lifeAI.LifeAI.services.DailyProgressService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/progress") +@RequiredArgsConstructor +public class DailyProgressController { + + private final DailyProgressService dailyProgressService; + + @PostMapping("/complete") + public ResponseEntity completeChallenge( + @AuthenticationPrincipal User user, + @RequestBody Map request + ) { + Integer challengeNumber = request.get("challengeNumber"); + + if (challengeNumber == null || challengeNumber < 1 || challengeNumber > 5) { + return ResponseEntity.badRequest().build(); + } + + DailyProgress progress = dailyProgressService.completeChallenge( + user.getId(), + challengeNumber + ); + + return ResponseEntity.ok(progress); + } + + @GetMapping("/today") + public ResponseEntity getTodaysProgress( + @AuthenticationPrincipal User user + ) { + return dailyProgressService.getTodaysProgress(user.getId()) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.ok(DailyProgress.builder() + .userId(user.getId()) + .build())); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/controllers/MoodController.java b/server/src/main/java/com/lifeAI/LifeAI/controllers/MoodController.java new file mode 100644 index 0000000..32b611d --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/controllers/MoodController.java @@ -0,0 +1,41 @@ +package com.lifeAI.LifeAI.controllers; + +import com.lifeAI.LifeAI.model.MoodEntry; +import com.lifeAI.LifeAI.model.User; +import com.lifeAI.LifeAI.services.MoodService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/mood") +@RequiredArgsConstructor +public class MoodController { + + private final MoodService moodService; + + @PostMapping + public ResponseEntity saveMood( + @AuthenticationPrincipal User user, + @RequestBody Map request + ) { + Integer moodLevel = request.get("moodLevel"); + + if (moodLevel == null || moodLevel < 1 || moodLevel > 5) { + return ResponseEntity.badRequest().build(); + } + + MoodEntry saved = moodService.saveMood(user.getId(), moodLevel); + return ResponseEntity.ok(saved); + } + + @GetMapping("/today") + public ResponseEntity getTodaysMood(@AuthenticationPrincipal User user) { + return moodService.getTodaysMood(user.getId()) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/controllers/UserController.java b/server/src/main/java/com/lifeAI/LifeAI/controllers/UserController.java new file mode 100644 index 0000000..a288b5d --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/controllers/UserController.java @@ -0,0 +1,36 @@ +package com.lifeAI.LifeAI.controllers; + +import com.lifeAI.LifeAI.model.dto.request.UserRequestDTO; +import com.lifeAI.LifeAI.model.dto.request.UserResponseDTO; +import com.lifeAI.LifeAI.model.dto.response.BulkUserResponseDTO; +import com.lifeAI.LifeAI.model.dto.response.UserService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + @PostMapping("/create") + public ResponseEntity createUser(@Valid @RequestBody UserRequestDTO request) { + UserResponseDTO response = userService.createUser(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PostMapping("/bulk") + public ResponseEntity createUsers(@Valid @RequestBody List requests) { + BulkUserResponseDTO response = userService.createUsers(requests); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/enums/ExceptionSeverity.java b/server/src/main/java/com/lifeAI/LifeAI/enums/ExceptionSeverity.java new file mode 100644 index 0000000..8dd2313 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/enums/ExceptionSeverity.java @@ -0,0 +1,6 @@ +package com.lifeAI.LifeAI.enums; + +public enum ExceptionSeverity { + CRITICAL, + INFORMATIONAL +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/enums/Permission.java b/server/src/main/java/com/lifeAI/LifeAI/enums/Permission.java new file mode 100644 index 0000000..4be6b44 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/enums/Permission.java @@ -0,0 +1,25 @@ +package com.lifeAI.LifeAI.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * Enumeration representing permissions available in the application. + * Each permission has a corresponding string representation. + * This class is used to define and access different permissions required for authorization purposes. + */ +@Getter +@RequiredArgsConstructor +public enum Permission { + ADMIN_READ("admin:read"), + ADMIN_UPDATE("admin:update"), + ADMIN_CREATE("admin:create"), + ADMIN_DELETE("admin:delete"), + PATIENT_READ("user:read"), + PATIENT_UPDATE("user:update"), + PATIENT_CREATE("user:create"), + PATIENT_DELETE("user:delete"); + + private final String permission; +} + diff --git a/server/src/main/java/com/lifeAI/LifeAI/enums/Provider.java b/server/src/main/java/com/lifeAI/LifeAI/enums/Provider.java new file mode 100644 index 0000000..9aa1296 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/enums/Provider.java @@ -0,0 +1,35 @@ +package com.lifeAI.LifeAI.enums; + + +import lombok.Getter; + +import java.util.HashMap; +import java.util.Map; + +/** + * Enumeration representing OAuth2 providers available in the application. + * This class is used to define and access different OAuth2 providers supported for authentication. + */ +@Getter +public enum Provider { + LOCAL("local"), + GOOGLE("google"); + + private static final Map providerMap = new HashMap<>(); + + static { + for (Provider provider : values()) { + providerMap.put(provider.getRegistrationId(), provider); + } + } + + private final String registrationId; + + Provider(String registrationId) { + this.registrationId = registrationId; + } + + public static Provider getProvider(String registrationId) { + return providerMap.getOrDefault(registrationId, Provider.LOCAL); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/enums/Role.java b/server/src/main/java/com/lifeAI/LifeAI/enums/Role.java new file mode 100644 index 0000000..f5763b8 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/enums/Role.java @@ -0,0 +1,38 @@ +package com.lifeAI.LifeAI.enums; + + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static com.lifeAI.LifeAI.enums.Permission.*; + + +/** + * Enumeration representing different roles in the application. + * Each role has a set of associated permissions. + */ +@Getter +@RequiredArgsConstructor +public enum Role { + PATIENT(Set.of(PATIENT_READ, PATIENT_UPDATE, PATIENT_CREATE, PATIENT_DELETE)), + ADMIN(Set.of(ADMIN_READ, ADMIN_UPDATE, ADMIN_DELETE, ADMIN_CREATE, + PATIENT_READ, PATIENT_UPDATE, PATIENT_CREATE, PATIENT_DELETE)), + DOCTOR(Set.of(/* doctor permissions here */)), + ASSISTANT(Set.of(/* assistant permissions here */)); + + private final Set permissions; + + public List getAuthorities() { + List authorities = permissions.stream() + .map(p -> new SimpleGrantedAuthority(p.getPermission())) + .collect(Collectors.toList()); + + authorities.add(new SimpleGrantedAuthority("ROLE_" + this.name().toUpperCase())); + return authorities; + } +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/enums/TokenType.java b/server/src/main/java/com/lifeAI/LifeAI/enums/TokenType.java new file mode 100644 index 0000000..e09a2e4 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/enums/TokenType.java @@ -0,0 +1,6 @@ +package com.lifeAI.LifeAI.enums; + +public enum TokenType { + ACCESS, + REFRESH +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/exceptions/common/AccessDeniedException.java b/server/src/main/java/com/lifeAI/LifeAI/exceptions/common/AccessDeniedException.java index 83c6a2f..bfd9ef2 100644 --- a/server/src/main/java/com/lifeAI/LifeAI/exceptions/common/AccessDeniedException.java +++ b/server/src/main/java/com/lifeAI/LifeAI/exceptions/common/AccessDeniedException.java @@ -1,7 +1,5 @@ package com.lifeAI.LifeAI.exceptions.common; -import org.springframework.context.MessageSource; -import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.http.HttpStatus; /** @@ -10,7 +8,7 @@ * Sets the appropriate message using MessageSource (the messages are in src/main/resources/messages). */ public class AccessDeniedException extends ApiException { - public AccessDeniedException(MessageSource messageSource) { - super(messageSource.getMessage("access.denied", null, LocaleContextHolder.getLocale()), HttpStatus.FORBIDDEN); + public AccessDeniedException() { + super("Отказан достъп!", HttpStatus.FORBIDDEN); } } diff --git a/server/src/main/java/com/lifeAI/LifeAI/exceptions/common/BadRequestException.java b/server/src/main/java/com/lifeAI/LifeAI/exceptions/common/BadRequestException.java index b0e42c3..db3a2dc 100644 --- a/server/src/main/java/com/lifeAI/LifeAI/exceptions/common/BadRequestException.java +++ b/server/src/main/java/com/lifeAI/LifeAI/exceptions/common/BadRequestException.java @@ -1,7 +1,5 @@ package com.lifeAI.LifeAI.exceptions.common; -import org.springframework.context.MessageSource; -import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.http.HttpStatus; /** @@ -10,10 +8,6 @@ * Sets the appropriate message using MessageSource (the messages are in src/main/resources/messages). */ public class BadRequestException extends ApiException { - public BadRequestException(MessageSource messageSource) { - super(messageSource.getMessage("bad.request", null, LocaleContextHolder.getLocale()), HttpStatus.BAD_REQUEST); - } - public BadRequestException(String message) { super(message, HttpStatus.BAD_REQUEST); } diff --git a/server/src/main/java/com/lifeAI/LifeAI/exceptions/common/InternalServerErrorException.java b/server/src/main/java/com/lifeAI/LifeAI/exceptions/common/InternalServerErrorException.java index 912fb96..bacea7d 100644 --- a/server/src/main/java/com/lifeAI/LifeAI/exceptions/common/InternalServerErrorException.java +++ b/server/src/main/java/com/lifeAI/LifeAI/exceptions/common/InternalServerErrorException.java @@ -1,7 +1,7 @@ package com.lifeAI.LifeAI.exceptions.common; -import org.springframework.context.MessageSource; -import org.springframework.context.i18n.LocaleContextHolder; +import lombok.Getter; +import lombok.Setter; import org.springframework.http.HttpStatus; /** @@ -9,12 +9,18 @@ * Extends ApiException and sets the appropriate message and HTTP status code. * Sets the appropriate message using MessageSource (the messages are in src/main/resources/messages). */ +@Getter +@Setter public class InternalServerErrorException extends ApiException { - public InternalServerErrorException(MessageSource messageSource) { - super(messageSource.getMessage("internal.server.error", null, LocaleContextHolder.getLocale()), HttpStatus.INTERNAL_SERVER_ERROR); + private RuntimeException innerRuntimeException; + + public InternalServerErrorException(RuntimeException runtimeException) { + super("Вътрешна грешка на сървъра!", HttpStatus.INTERNAL_SERVER_ERROR); + setInnerRuntimeException(runtimeException); } public InternalServerErrorException(String message) { super(message, HttpStatus.INTERNAL_SERVER_ERROR); + setInnerRuntimeException(new RuntimeException(message)); } } diff --git a/server/src/main/java/com/lifeAI/LifeAI/exceptions/common/NoSuchElementException.java b/server/src/main/java/com/lifeAI/LifeAI/exceptions/common/NoSuchElementException.java index 32d6b81..9d980cf 100644 --- a/server/src/main/java/com/lifeAI/LifeAI/exceptions/common/NoSuchElementException.java +++ b/server/src/main/java/com/lifeAI/LifeAI/exceptions/common/NoSuchElementException.java @@ -1,7 +1,5 @@ package com.lifeAI.LifeAI.exceptions.common; -import org.springframework.context.MessageSource; -import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.http.HttpStatus; /** @@ -10,10 +8,6 @@ * Sets the appropriate message using MessageSource (the messages are in src/main/resources/messages). */ public class NoSuchElementException extends ApiException { - public NoSuchElementException(MessageSource messageSource) { - super(messageSource.getMessage("no.such.element", null, LocaleContextHolder.getLocale()), HttpStatus.NOT_FOUND); - } - public NoSuchElementException(String message) { super(message, HttpStatus.NOT_FOUND); } diff --git a/server/src/main/java/com/lifeAI/LifeAI/exceptions/common/TooManyRequestsException.java b/server/src/main/java/com/lifeAI/LifeAI/exceptions/common/TooManyRequestsException.java index 7852831..31c5900 100644 --- a/server/src/main/java/com/lifeAI/LifeAI/exceptions/common/TooManyRequestsException.java +++ b/server/src/main/java/com/lifeAI/LifeAI/exceptions/common/TooManyRequestsException.java @@ -2,13 +2,8 @@ import org.springframework.http.HttpStatus; -/** - * Exception thrown to indicate that the client has sent too many requests in a given amount of time. - * Related to @RateLimited() - * Extends ApiException and sets the appropriate message and HTTP status code. - */ public class TooManyRequestsException extends ApiException { - public TooManyRequestsException(String message) { - super(message, HttpStatus.TOO_MANY_REQUESTS); + public TooManyRequestsException() { + super("Твърде много заявки. Моля, опитайте отново по-късно.", HttpStatus.TOO_MANY_REQUESTS); } } diff --git a/server/src/main/java/com/lifeAI/LifeAI/exceptions/email/EmailNotVerified.java b/server/src/main/java/com/lifeAI/LifeAI/exceptions/email/EmailNotVerified.java new file mode 100644 index 0000000..d3d53aa --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/exceptions/email/EmailNotVerified.java @@ -0,0 +1,14 @@ +package com.lifeAI.LifeAI.exceptions.email; + + +import com.lifeAI.LifeAI.exceptions.common.BadRequestException; + +/** + * Exception thrown to indicate that the user's email has not been verified. + * Extends BadRequestException and sets the appropriate message using MessageSource (the messages are in src/main/resources/messages). + */ +public class EmailNotVerified extends BadRequestException { + public EmailNotVerified() { + super("Не можете да влезете, имейлът, даден по време на регистрацията, не е потвърден!"); + } +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/exceptions/token/ExpiredTokenException.java b/server/src/main/java/com/lifeAI/LifeAI/exceptions/token/ExpiredTokenException.java new file mode 100644 index 0000000..9ae311c --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/exceptions/token/ExpiredTokenException.java @@ -0,0 +1,10 @@ +package com.lifeAI.LifeAI.exceptions.token; + + +import com.lifeAI.LifeAI.exceptions.common.UnauthorizedException; + +public class ExpiredTokenException extends UnauthorizedException { + public ExpiredTokenException() { + super("Токенът е изтекъл!"); + } +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/exceptions/token/InvalidTokenException.java b/server/src/main/java/com/lifeAI/LifeAI/exceptions/token/InvalidTokenException.java new file mode 100644 index 0000000..38c215b --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/exceptions/token/InvalidTokenException.java @@ -0,0 +1,13 @@ +package com.lifeAI.LifeAI.exceptions.token; + +import com.lifeAI.LifeAI.exceptions.common.UnauthorizedException; + +/** + * Exception thrown when the provided authentication token is invalid or expired (so the request cannot be authorized). + * Sets the appropriate message using MessageSource (the messages are in src/main/resources/messages). + */ +public class InvalidTokenException extends UnauthorizedException { + public InvalidTokenException() { + super("Невалиден токен!"); + } +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/exceptions/user/UserCreateException.java b/server/src/main/java/com/lifeAI/LifeAI/exceptions/user/UserCreateException.java new file mode 100644 index 0000000..13b39ad --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/exceptions/user/UserCreateException.java @@ -0,0 +1,21 @@ +package com.lifeAI.LifeAI.exceptions.user; + +import com.lifeAI.LifeAI.exceptions.common.BadRequestException; + +/** + * Exception thrown when there is an issue creating a user, either due to invalid data or duplicate user details. + * Sets the appropriate message using MessageSource (the messages are in src/main/resources/messages). + */ +public class UserCreateException extends BadRequestException { + + /** + * Constructs a UserCreateException with a message indicating either a duplicate email or invalid user data. + */ + public UserCreateException(boolean isUnique) { + super( + isUnique + ? "Вече съществува потребител със същия имейл!" + : "Невалидни потребителски данни!" + ); + } +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/exceptions/user/UserLoginException.java b/server/src/main/java/com/lifeAI/LifeAI/exceptions/user/UserLoginException.java new file mode 100644 index 0000000..e6dad12 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/exceptions/user/UserLoginException.java @@ -0,0 +1,12 @@ +package com.lifeAI.LifeAI.exceptions.user; + +import com.lifeAI.LifeAI.exceptions.common.BadRequestException; + +/** + * Exception thrown when there is an issue with user login, such as invalid email or password. + */ +public class UserLoginException extends BadRequestException { + public UserLoginException() { + super("Невалиден имейл или парола!"); + } +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/exceptions/user/UserNotFoundException.java b/server/src/main/java/com/lifeAI/LifeAI/exceptions/user/UserNotFoundException.java new file mode 100644 index 0000000..9918529 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/exceptions/user/UserNotFoundException.java @@ -0,0 +1,14 @@ +package com.lifeAI.LifeAI.exceptions.user; + + +import com.lifeAI.LifeAI.exceptions.common.NoSuchElementException; + +/** + * Exception indicating that the user is not found. + * Sets the appropriate message using MessageSource (the messages are in src/main/resources/messages). + */ +public class UserNotFoundException extends NoSuchElementException { + public UserNotFoundException() { + super("Потребителят не е намерен!"); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/exceptions/user/UserValidationException.java b/server/src/main/java/com/lifeAI/LifeAI/exceptions/user/UserValidationException.java new file mode 100644 index 0000000..b9cc83f --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/exceptions/user/UserValidationException.java @@ -0,0 +1,12 @@ +package com.lifeAI.LifeAI.exceptions.user; + +import com.lifeAI.LifeAI.exceptions.common.ValidationException; +import jakarta.validation.ConstraintViolation; + +import java.util.Set; + +public class UserValidationException extends ValidationException { + public UserValidationException(Set> validationErrors) { + super(validationErrors); + } +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/filters/JwtAuthenticationFilter.java b/server/src/main/java/com/lifeAI/LifeAI/filters/JwtAuthenticationFilter.java new file mode 100644 index 0000000..fc39aee --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/filters/JwtAuthenticationFilter.java @@ -0,0 +1,134 @@ +package com.lifeAI.LifeAI.filters; + +import com.lifeAI.LifeAI.exceptions.user.UserNotFoundException; +import com.lifeAI.LifeAI.model.User; +import com.lifeAI.LifeAI.model.dto.auth.PublicUserDTO; +import com.lifeAI.LifeAI.respository.TokenRepository; +import com.lifeAI.LifeAI.services.JwtService; +import com.lifeAI.LifeAI.services.UserService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.modelmapper.ModelMapper; +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.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + + +/** + * Filter responsible for JWT-based authentication. + */ +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + /** + * Key to retrieve user information from request attribute. + */ + public static final String USER_KEY = "user"; + public static final String JWT_KEY = "jwt"; + + private final JwtService jwtService; + private final UserService userService; + private final ModelMapper modelMapper; + private final TokenRepository tokenRepository; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + String path = request.getServletPath(); + + // Skip authentication for specific paths related to authentication process + if (path.contains("/api/v1/auth") || path.contains("/api/v1/oauth2")) { + filterChain.doFilter(request, response); + return; + } + + request.setAttribute(USER_KEY, null); + request.setAttribute(JWT_KEY, null); + + final String authHeader = request.getHeader("Authorization"); + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + final String jwt = authHeader.substring(7); + if (jwt.isEmpty()) { + filterChain.doFilter(request, response); + return; + } + + String userEmail; + + try { + userEmail = jwtService.extractUsername(jwt); + } catch (Exception exception) { + filterChain.doFilter(request, response); + return; + } + + if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails; + + try { + userDetails = userService.findByEmail(userEmail); + } catch (UserNotFoundException exception) { + filterChain.doFilter(request, response); + return; + } + + // Check if token is valid and not revoked or expired + boolean isTokenValid = tokenRepository.findByToken(jwt) + .map(t -> !t.isExpired() && !t.isRevoked()) + .orElse(false); + + if (jwtService.isTokenValid(jwt, userDetails) && isTokenValid) { + + // Set user authentication in security context + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + + authToken.setDetails( + new WebAuthenticationDetailsSource().buildDetails(request) + ); + + SecurityContextHolder.getContext().setAuthentication(authToken); + } + + request.setAttribute(USER_KEY, JwtAuthenticationFilter.safeMapUser(userDetails)); + request.setAttribute(JWT_KEY, jwt); + } + + filterChain.doFilter(request, response); + } + + public static PublicUserDTO safeMapUser(UserDetails userDetails) { + if (userDetails instanceof User user) { + return PublicUserDTO.builder() + .id(user.getId()) + .firstName(user.getFirstName()) + .lastName(user.getLastName()) + .email(user.getEmail()) + .role(user.getRole()) + .build(); + } + return null; + } + +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/handlers/CustomExceptionHandler.java b/server/src/main/java/com/lifeAI/LifeAI/handlers/CustomExceptionHandler.java new file mode 100644 index 0000000..815cae3 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/handlers/CustomExceptionHandler.java @@ -0,0 +1,86 @@ +package com.lifeAI.LifeAI.handlers; + +import com.lifeAI.LifeAI.exceptions.common.*; +import com.lifeAI.LifeAI.exceptions.user.UserLoginException; +import com.lifeAI.LifeAI.model.dto.response.ExceptionResponse; +import com.lifeAI.LifeAI.services.ExceptionService; +import com.lifeAI.LifeAI.utils.ApiExceptionParser; +import io.github.resilience4j.ratelimiter.RequestNotPermitted; +import jakarta.validation.ConstraintViolationException; +import lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.InternalAuthenticationServiceException; +import org.springframework.transaction.TransactionException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +/** + * Global exception handler for handling various types of exceptions and converting them into standardized API responses. + */ +@ControllerAdvice +@AllArgsConstructor +public class CustomExceptionHandler extends ResponseEntityExceptionHandler { + private final ExceptionService exceptionService; + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity handleRuntimeExceptions(RuntimeException exception) { + return handleApiExceptions(new InternalServerErrorException(exception)); + } + + @ExceptionHandler(InternalAuthenticationServiceException.class) + public ResponseEntity handleInternalAuthServiceExceptions(InternalAuthenticationServiceException exception) { + Throwable cause = exception.getCause(); + + if (cause instanceof ApiException) { + return handleApiExceptions((ApiException) cause); + } + + return handleRuntimeExceptions(exception); + } + + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity handleBadCredentialsExceptions() { + return handleApiExceptions(new UserLoginException()); + } + + @ExceptionHandler(org.springframework.security.access.AccessDeniedException.class) + public ResponseEntity handleAccessDeniedExceptions() { + return handleApiExceptions(new AccessDeniedException()); + } + + @ExceptionHandler(TransactionException.class) + public ResponseEntity handleTransactionExceptions(TransactionException exception) { + if (exception.getRootCause() instanceof ConstraintViolationException) { + return handleConstraintValidationExceptions((ConstraintViolationException) exception.getRootCause()); + } + + return handleRuntimeExceptions(exception); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintValidationExceptions(ConstraintViolationException exception) { + return handleApiExceptions(new ValidationException(exception.getConstraintViolations())); + } + + @ExceptionHandler(RequestNotPermitted.class) + public ResponseEntity handleRateLimitException() { + return handleApiExceptions(new TooManyRequestsException()); + } + + @ExceptionHandler(ApiException.class) + public ResponseEntity handleApiExceptions(ApiException exception) { + ExceptionResponse apiException = ApiExceptionParser.parseException(exception); + + if (exception instanceof InternalServerErrorException internalException) { + exceptionService.log(internalException.getInnerRuntimeException(), internalException.getStatusCode()); + } else { + exceptionService.log(exception); + } + + return ResponseEntity + .status(apiException.getStatus()) + .body(apiException); + } +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/handlers/JwtAuthenticationEntryPoint.java b/server/src/main/java/com/lifeAI/LifeAI/handlers/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..c83f567 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/handlers/JwtAuthenticationEntryPoint.java @@ -0,0 +1,37 @@ +package com.lifeAI.LifeAI.handlers; + +import com.lifeAI.LifeAI.exceptions.common.AccessDeniedException; +import com.lifeAI.LifeAI.utils.ObjectMapperHelper; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * JwtAuthenticationEntryPoint class is responsible for handling authentication errors + * that occur when attempting to access secured endpoints without proper authentication credentials. + * It sends a standardized error response back to the client with appropriate HTTP status code and error message. + */ +@Component +@RequiredArgsConstructor +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, + AuthenticationException e) throws IOException { + // Serialize the error response using ObjectMapper and write it to the HttpServletResponse + ObjectMapperHelper + .writeExceptionToObjectMapper( + objectMapper, + new AccessDeniedException(), + httpServletResponse + ); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/handlers/LogoutHandler.java b/server/src/main/java/com/lifeAI/LifeAI/handlers/LogoutHandler.java new file mode 100644 index 0000000..7ba2192 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/handlers/LogoutHandler.java @@ -0,0 +1,50 @@ +package com.lifeAI.LifeAI.handlers; + +import com.lifeAI.LifeAI.exceptions.token.InvalidTokenException; +import com.lifeAI.LifeAI.services.TokenService; +import com.lifeAI.LifeAI.utils.ObjectMapperHelper; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +import java.io.IOException; + + +/** + * LogoutHandler is responsible for handling user logout by invalidating the JWT token and removing associated cookies. + */ +@Service +@RequiredArgsConstructor +public class LogoutHandler implements org.springframework.security.web.authentication.logout.LogoutHandler { + + private final TokenService tokenService; + private final ObjectMapper objectMapper; + + /** + * Performs user logout by invalidating the JWT token and removing associated cookies. + * If the token is invalid or missing, it sends a standardized error response back to the client. + */ + @Override + public void logout( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) { + final String authHeader = request.getHeader("Authorization"); + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + try { + ObjectMapperHelper.writeExceptionToObjectMapper(objectMapper, new InvalidTokenException(), response); + return; + } catch (IOException exception) { + return; + } + } + + final String jwt = authHeader.substring(7); + tokenService.logoutToken(jwt); + } +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/model/Article.java b/server/src/main/java/com/lifeAI/LifeAI/model/Article.java new file mode 100644 index 0000000..e91a3ab --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/model/Article.java @@ -0,0 +1,58 @@ +package com.lifeAI.LifeAI.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@ToString +@RequiredArgsConstructor +@Table(name = "articles") +public class Article { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Column(name = "title", columnDefinition = "TEXT") + private String title; + + @Column(name = "description", columnDefinition = "LONGTEXT") + private String description; + + @Column(name = "thumbnail_picture") + private String thumbnailPicture; + + @Column(name = "category_id") + private Integer categoryId; + + @Column(name = "visibility") + private String visibility; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Column(name = "sub_category") + private String subCategory; + + @Column(name = "calories") + private Integer calories; + + @Column(name = "hours") + private Integer hours; + + @Column(name = "minutes") + private Integer minutes; +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/model/DailyProgress.java b/server/src/main/java/com/lifeAI/LifeAI/model/DailyProgress.java new file mode 100644 index 0000000..663306a --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/model/DailyProgress.java @@ -0,0 +1,68 @@ +package com.lifeAI.LifeAI.model; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; + +@Entity +@Table(name = "daily_progress", uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "date"}) +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DailyProgress { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(nullable = false) + private LocalDate date; + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name = "completed_challenges", + joinColumns = @JoinColumn(name = "progress_id") + ) + @Column(name = "challenge_number") + private Set completedChallenges = new HashSet<>(); + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + if (date == null) { + date = LocalDate.now(); + } + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } + + public void addCompletedChallenge(Integer challengeNumber) { + if (completedChallenges == null) { + completedChallenges = new HashSet<>(); + } + completedChallenges.add(challengeNumber); + } +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/model/Exception.java b/server/src/main/java/com/lifeAI/LifeAI/model/Exception.java new file mode 100644 index 0000000..23dd923 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/model/Exception.java @@ -0,0 +1,58 @@ +package com.lifeAI.LifeAI.model; + +import com.lifeAI.LifeAI.enums.ExceptionSeverity; +import com.lifeAI.LifeAI.exceptions.common.ApiException; +import com.lifeAI.LifeAI.model.baseEntity.BaseEntity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.*; + +import java.util.Arrays; + +@Getter +@Setter +@ToString +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Exception extends BaseEntity { + private Integer statusCode; + private String exceptionType; + private String exceptionMessage; + private String stackTraceString; + private String methodName; + private String className; + private Integer lineNumber; + @Enumerated(EnumType.STRING) + private ExceptionSeverity severity; + + public static Exception mapFromRuntimeException(RuntimeException runtimeException, int statusCode) { + String exceptionType = runtimeException.getClass().getName(); + String exceptionMessage = runtimeException.getMessage(); + + StackTraceElement[] stackTrace = runtimeException.getStackTrace(); + String stackTraceString = Arrays.toString(stackTrace); + + String methodName = stackTrace[0].getMethodName(); + String className = stackTrace[0].getClassName(); + int lineNumber = stackTrace[0].getLineNumber(); + + return Exception.builder() + .statusCode(statusCode) + .exceptionType(exceptionType) + .exceptionMessage(exceptionMessage) + .stackTraceString(stackTraceString) + .methodName(methodName) + .className(className) + .lineNumber(lineNumber) + .severity(ExceptionSeverity.CRITICAL) + .build(); + } + + public static Exception mapFromApiException(ApiException apiException) { + Exception exception = mapFromRuntimeException(apiException, apiException.getStatusCode()); + exception.setSeverity(ExceptionSeverity.INFORMATIONAL); + + return exception; + } +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/model/MoodEntry.java b/server/src/main/java/com/lifeAI/LifeAI/model/MoodEntry.java new file mode 100644 index 0000000..ab8d9ce --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/model/MoodEntry.java @@ -0,0 +1,45 @@ +package com.lifeAI.LifeAI.model; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "mood_entries", uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "date"}) +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MoodEntry { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "mood_level", nullable = false) + private Integer moodLevel; // 1-5 + + @Column(nullable = false) + private LocalDate date; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + if (date == null) { + date = LocalDate.now(); + } + } +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/model/Token.java b/server/src/main/java/com/lifeAI/LifeAI/model/Token.java new file mode 100644 index 0000000..1d3bf54 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/model/Token.java @@ -0,0 +1,37 @@ +package com.lifeAI.LifeAI.model; + +import com.lifeAI.LifeAI.enums.TokenType; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "tokens") +public class Token { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + public Long id; + + @Column(unique = true) + public String token; + + @Enumerated(EnumType.STRING) + @Column(name = "token_type", nullable = false) + public TokenType tokenType; + + @Column(nullable = false) + public boolean revoked; + + @Column(nullable = false) + public boolean expired; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/model/User.java b/server/src/main/java/com/lifeAI/LifeAI/model/User.java new file mode 100644 index 0000000..f0c8f43 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/model/User.java @@ -0,0 +1,98 @@ +package com.lifeAI.LifeAI.model; + +import com.lifeAI.LifeAI.enums.Role; +import jakarta.persistence.*; +import lombok.*; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +@Entity +@Table(name = "users") +@Data +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class User implements UserDetails { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String uuid; + + private String firstName; + + private String lastName; + + @Column(unique = true) + private String email; + + private String password; + private Boolean encrypted; + private String role; + @Column(name = "enabled") + private boolean enabled; + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @Column(name = "email_verified_at") + private LocalDateTime emailVerifiedAt; + + private LocalDateTime deletedAt; + + public boolean isEnabled() { + return true; + } + + @Override + public Collection getAuthorities() { + if (this.role == null) return List.of(); + + try { + Role roleEnum = Role.valueOf(this.role.toUpperCase()); + return roleEnum.getAuthorities(); + } catch (IllegalArgumentException e) { + return List.of(); + } + } + + @Override + public String getUsername() { + return this.email; + } + + // Role check helpers + public boolean isAdmin() { + return "admin".equalsIgnoreCase(role); + } + + public boolean isDoctor() { + return "doctor".equalsIgnoreCase(role); + } + +// // Password update method example +// public boolean updatePassword(String currentPassword, String newPassword, String confirmPassword) { +// BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); +// if (currentPassword != null && encoder.matches(currentPassword, this.password) && newPassword.equals(confirmPassword)) { +// this.password = encoder.encode(newPassword); +// return true; +// } +// return false; +// } + + public boolean isAssistant() { + return "assistant".equalsIgnoreCase(role); + } + + public boolean isPatient() { + return "patient".equalsIgnoreCase(role); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/model/VerificationToken.java b/server/src/main/java/com/lifeAI/LifeAI/model/VerificationToken.java new file mode 100644 index 0000000..de02aa3 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/model/VerificationToken.java @@ -0,0 +1,43 @@ +package com.lifeAI.LifeAI.model; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.sql.Date; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.Calendar; + +@Entity +@Data +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "verification_tokens") +public class VerificationToken { + private static final int EXPIRATION = 60 * 24; + public LocalDateTime createdAt; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(unique = false) + private String token; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + private Date expiryDate; + + public VerificationToken(String token, User user) { + this.token = token; + this.user = user; + this.expiryDate = calculateExpiryDate(); + } + + private Date calculateExpiryDate() { + Calendar cal = Calendar.getInstance(); + cal.setTime(new Timestamp(cal.getTime().getTime())); + cal.add(Calendar.MINUTE, VerificationToken.EXPIRATION); + return new Date(cal.getTime().getTime()); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/model/baseEntity/BaseEntity.java b/server/src/main/java/com/lifeAI/LifeAI/model/baseEntity/BaseEntity.java new file mode 100644 index 0000000..138db44 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/model/baseEntity/BaseEntity.java @@ -0,0 +1,51 @@ +package com.lifeAI.LifeAI.model.baseEntity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Setter +@ToString +@RequiredArgsConstructor +@Entity +@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS) +public abstract class BaseEntity { + + @Id + private UUID id; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Column(name = "deleted_at", nullable = true) + private LocalDateTime deletedAt; + + @PrePersist + protected void onCreate() { + if (this.id == null) { + this.id = UUID.randomUUID(); + } + LocalDateTime now = LocalDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + this.deletedAt = null; + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + public void delete() { + this.deletedAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/model/dto/auth/AccessTokenBodyDTO.java b/server/src/main/java/com/lifeAI/LifeAI/model/dto/auth/AccessTokenBodyDTO.java new file mode 100644 index 0000000..43abdf7 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/model/dto/auth/AccessTokenBodyDTO.java @@ -0,0 +1,12 @@ +package com.lifeAI.LifeAI.model.dto.auth; + +import lombok.*; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AccessTokenBodyDTO { + private String accessToken; +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/model/dto/auth/AdminUserDTO.java b/server/src/main/java/com/lifeAI/LifeAI/model/dto/auth/AdminUserDTO.java new file mode 100644 index 0000000..fa33ffc --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/model/dto/auth/AdminUserDTO.java @@ -0,0 +1,14 @@ +package com.lifeAI.LifeAI.model.dto.auth; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class AdminUserDTO extends PublicUserDTO { + private String password; +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/model/dto/auth/AuthenticationRequest.java b/server/src/main/java/com/lifeAI/LifeAI/model/dto/auth/AuthenticationRequest.java new file mode 100644 index 0000000..42d2ee5 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/model/dto/auth/AuthenticationRequest.java @@ -0,0 +1,15 @@ +package com.lifeAI.LifeAI.model.dto.auth; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class AuthenticationRequest { + private String email; + private String password; +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/model/dto/auth/AuthenticationResponse.java b/server/src/main/java/com/lifeAI/LifeAI/model/dto/auth/AuthenticationResponse.java new file mode 100644 index 0000000..ac245b7 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/model/dto/auth/AuthenticationResponse.java @@ -0,0 +1,19 @@ +package com.lifeAI.LifeAI.model.dto.auth; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.*; + +import java.io.Serializable; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AuthenticationResponse implements Serializable { + private String accessToken; + private String refreshToken; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private PublicUserDTO user; +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/model/dto/auth/OAuth2UserInfoDTO.java b/server/src/main/java/com/lifeAI/LifeAI/model/dto/auth/OAuth2UserInfoDTO.java new file mode 100644 index 0000000..2f48cdf --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/model/dto/auth/OAuth2UserInfoDTO.java @@ -0,0 +1,22 @@ +package com.lifeAI.LifeAI.model.dto.auth; + +import com.lifeAI.LifeAI.enums.Provider; +import com.lifeAI.LifeAI.model.dto.common.BaseDTO; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +@EqualsAndHashCode(callSuper = true) +@Data +@NoArgsConstructor +public class OAuth2UserInfoDTO extends BaseDTO { + private String sub; + private String name; + private String given_name; + private String family_name; + private String picture; + private String email; + private boolean email_verified; + private String locale; + private Provider provider = Provider.LOCAL; +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/model/dto/auth/PublicUserDTO.java b/server/src/main/java/com/lifeAI/LifeAI/model/dto/auth/PublicUserDTO.java new file mode 100644 index 0000000..1135705 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/model/dto/auth/PublicUserDTO.java @@ -0,0 +1,16 @@ +package com.lifeAI.LifeAI.model.dto.auth; + +import lombok.*; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PublicUserDTO { + private Long id; + private String firstName; + private String lastName; + private String email; + private String role; +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/model/dto/auth/RefreshTokenBodyDTO.java b/server/src/main/java/com/lifeAI/LifeAI/model/dto/auth/RefreshTokenBodyDTO.java new file mode 100644 index 0000000..3f810e5 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/model/dto/auth/RefreshTokenBodyDTO.java @@ -0,0 +1,12 @@ +package com.lifeAI.LifeAI.model.dto.auth; + +import lombok.*; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RefreshTokenBodyDTO { + private String refreshToken; +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/model/dto/auth/RegisterRequest.java b/server/src/main/java/com/lifeAI/LifeAI/model/dto/auth/RegisterRequest.java new file mode 100644 index 0000000..1146413 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/model/dto/auth/RegisterRequest.java @@ -0,0 +1,26 @@ +package com.lifeAI.LifeAI.model.dto.auth; + +import com.lifeAI.LifeAI.enums.Provider; +import com.lifeAI.LifeAI.enums.Role; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class RegisterRequest { + private String email; + private String password; + private String firstName; + private String lastName; + private String nationalIdNumber; + private Long cityId; + private String gender; + private String phoneNumber; + private LocalDate birthDate; + private Role role = Role.PATIENT; + private Provider provider = Provider.LOCAL; +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/model/dto/common/ArticleCardDTO.java b/server/src/main/java/com/lifeAI/LifeAI/model/dto/common/ArticleCardDTO.java new file mode 100644 index 0000000..242924b --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/model/dto/common/ArticleCardDTO.java @@ -0,0 +1,19 @@ +package com.lifeAI.LifeAI.model.dto.common; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +public class ArticleCardDTO { + private Long id; + private String title; + private String description; + private String thumbnailPicture; + private String subCategory; + private Integer calories; + private Integer hours; + private Integer minutes; +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/model/dto/common/BaseDTO.java b/server/src/main/java/com/lifeAI/LifeAI/model/dto/common/BaseDTO.java new file mode 100644 index 0000000..6b4e47d --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/model/dto/common/BaseDTO.java @@ -0,0 +1,4 @@ +package com.lifeAI.LifeAI.model.dto.common; + +abstract public class BaseDTO { +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/model/dto/request/UserRequestDTO.java b/server/src/main/java/com/lifeAI/LifeAI/model/dto/request/UserRequestDTO.java new file mode 100644 index 0000000..d3e8839 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/model/dto/request/UserRequestDTO.java @@ -0,0 +1,23 @@ +package com.lifeAI.LifeAI.model.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserRequestDTO { + + private String firstName; + + private String lastName; + + private String email; + + private String password; + + private String role; +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/model/dto/request/UserResponseDTO.java b/server/src/main/java/com/lifeAI/LifeAI/model/dto/request/UserResponseDTO.java new file mode 100644 index 0000000..33affc4 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/model/dto/request/UserResponseDTO.java @@ -0,0 +1,20 @@ +package com.lifeAI.LifeAI.model.dto.request; + +import lombok.*; +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserResponseDTO { + + private Long id; + private String uuid; + private String firstName; + private String lastName; + private String email; + private String role; + private boolean enabled; + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/model/dto/response/BulkUserResponseDTO.java b/server/src/main/java/com/lifeAI/LifeAI/model/dto/response/BulkUserResponseDTO.java new file mode 100644 index 0000000..3d72f67 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/model/dto/response/BulkUserResponseDTO.java @@ -0,0 +1,16 @@ +package com.lifeAI.LifeAI.model.dto.response; + +import com.lifeAI.LifeAI.model.dto.request.UserResponseDTO; +import lombok.*; +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class BulkUserResponseDTO { + private int successCount; + private int failureCount; + private List createdUsers; + private List errors; +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/model/dto/response/ExceptionResponse.java b/server/src/main/java/com/lifeAI/LifeAI/model/dto/response/ExceptionResponse.java new file mode 100644 index 0000000..a8346ce --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/model/dto/response/ExceptionResponse.java @@ -0,0 +1,20 @@ +package com.lifeAI.LifeAI.model.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.http.HttpStatus; + +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ExceptionResponse { + private String message; + private LocalDateTime dateTime; + private HttpStatus status; + private Integer statusCode; +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/model/dto/response/UserService.java b/server/src/main/java/com/lifeAI/LifeAI/model/dto/response/UserService.java new file mode 100644 index 0000000..fe379fe --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/model/dto/response/UserService.java @@ -0,0 +1,74 @@ +package com.lifeAI.LifeAI.model.dto.response; + +import com.lifeAI.LifeAI.model.User; +import com.lifeAI.LifeAI.model.dto.request.UserRequestDTO; +import com.lifeAI.LifeAI.model.dto.request.UserResponseDTO; +import com.lifeAI.LifeAI.respository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public BulkUserResponseDTO createUsers(List requests) { + List created = new ArrayList<>(); + List failed = new ArrayList<>(); + + for (UserRequestDTO request : requests) { + try { + created.add(createUser(request)); + } catch (Exception e) { + failed.add(request.getEmail() + ": " + e.getMessage()); + } + } + + return BulkUserResponseDTO.builder() + .successCount(created.size()) + .failureCount(failed.size()) + .createdUsers(created) + .errors(failed) + .build(); + } + + public UserResponseDTO createUser(UserRequestDTO request) { + if (userRepository.existsByEmail(request.getEmail())) { + throw new RuntimeException("Email already in use: " + request.getEmail()); + } + + User user = User.builder() + .uuid(UUID.randomUUID().toString()) + .firstName(request.getFirstName()) + .lastName(request.getLastName()) + .email(request.getEmail()) + .password(passwordEncoder.encode(request.getPassword())) + .role(request.getRole().toUpperCase()) + .encrypted(true) + .enabled(true) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + + User saved = userRepository.save(user); + + return UserResponseDTO.builder() + .id(saved.getId()) + .uuid(saved.getUuid()) + .firstName(saved.getFirstName()) + .lastName(saved.getLastName()) + .email(saved.getEmail()) + .role(saved.getRole()) + .enabled(saved.isEnabled()) + .createdAt(saved.getCreatedAt()) + .build(); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/respository/ArticleRepository.java b/server/src/main/java/com/lifeAI/LifeAI/respository/ArticleRepository.java new file mode 100644 index 0000000..35168c2 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/respository/ArticleRepository.java @@ -0,0 +1,46 @@ +package com.lifeAI.LifeAI.respository; + +import com.lifeAI.LifeAI.model.Article; +import com.lifeAI.LifeAI.model.dto.common.ArticleCardDTO; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ArticleRepository extends JpaRepository { + + @Query("SELECT new com.lifeAI.LifeAI.model.dto.common.ArticleCardDTO(" + + "a.id, a.title, " + + "CASE WHEN LENGTH(a.description) > 150 THEN CONCAT(SUBSTRING(a.description, 1, 150), '...') " + + "ELSE a.description END, " + + "a.thumbnailPicture, a.subCategory, a.calories, a.hours, a.minutes) " + + "FROM Article a " + + "WHERE a.deletedAt IS NULL AND a.subCategory IS NOT NULL " + + "ORDER BY a.createdAt DESC") + Page findAllCards(Pageable pageable); + + @Query("SELECT new com.lifeAI.LifeAI.model.dto.common.ArticleCardDTO(" + + "a.id, a.title, " + + "CASE WHEN LENGTH(a.description) > 150 THEN CONCAT(SUBSTRING(a.description, 1, 150), '...') " + + "ELSE a.description END, " + + "a.thumbnailPicture, a.subCategory, a.calories, a.hours, a.minutes) " + + "FROM Article a " + + "WHERE a.subCategory = :subCategory AND a.deletedAt IS NULL " + + "ORDER BY a.createdAt DESC") + Page findCardsBySubCategory(@Param("subCategory") String subCategory, Pageable pageable); + + @Query("SELECT new com.lifeAI.LifeAI.model.dto.common.ArticleCardDTO(" + + "a.id, a.title, " + + "CASE WHEN LENGTH(a.description) > 150 THEN CONCAT(SUBSTRING(a.description, 1, 150), '...') " + + "ELSE a.description END, " + + "a.thumbnailPicture, a.subCategory, a.calories, a.hours, a.minutes) " + + "FROM Article a " + + "WHERE a.subCategory IS NULL AND a.deletedAt IS NULL " + + "ORDER BY function('RAND')") + List findRandomArticlesWithNoSubCategory(Pageable pageable); +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/respository/DailyProgressRepository.java b/server/src/main/java/com/lifeAI/LifeAI/respository/DailyProgressRepository.java new file mode 100644 index 0000000..ee17b6c --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/respository/DailyProgressRepository.java @@ -0,0 +1,14 @@ +package com.lifeAI.LifeAI.respository; + +import com.lifeAI.LifeAI.model.DailyProgress; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.Optional; + +@Repository +public interface DailyProgressRepository extends JpaRepository { + + Optional findByUserIdAndDate(Long userId, LocalDate date); +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/respository/MoodRepository.java b/server/src/main/java/com/lifeAI/LifeAI/respository/MoodRepository.java new file mode 100644 index 0000000..2d43b64 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/respository/MoodRepository.java @@ -0,0 +1,16 @@ +package com.lifeAI.LifeAI.respository; + +import com.lifeAI.LifeAI.model.MoodEntry; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.Optional; + +@Repository +public interface MoodRepository extends JpaRepository { + + Optional findByUserIdAndDate(Long userId, LocalDate date); + + boolean existsByUserIdAndDate(Long userId, LocalDate date); +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/respository/TokenRepository.java b/server/src/main/java/com/lifeAI/LifeAI/respository/TokenRepository.java new file mode 100644 index 0000000..fc90783 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/respository/TokenRepository.java @@ -0,0 +1,14 @@ +package com.lifeAI.LifeAI.respository; + +import com.lifeAI.LifeAI.model.Token; +import com.lifeAI.LifeAI.model.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface TokenRepository extends JpaRepository { + List findAllByUser(User user); + + Optional findByToken(String token); +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/respository/UserRepository.java b/server/src/main/java/com/lifeAI/LifeAI/respository/UserRepository.java new file mode 100644 index 0000000..79b5a18 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/respository/UserRepository.java @@ -0,0 +1,17 @@ +package com.lifeAI.LifeAI.respository; + +import com.lifeAI.LifeAI.model.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + + Optional findByEmail(String email); + boolean existsByEmail(String email); + List findByEnabledFalseAndCreatedAtBeforeAndDeletedAtIsNull(LocalDateTime thresholdDateTime); +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/respository/VerificationTokenRepository.java b/server/src/main/java/com/lifeAI/LifeAI/respository/VerificationTokenRepository.java new file mode 100644 index 0000000..086a048 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/respository/VerificationTokenRepository.java @@ -0,0 +1,19 @@ +package com.lifeAI.LifeAI.respository; + +import com.lifeAI.LifeAI.model.User; +import com.lifeAI.LifeAI.model.VerificationToken; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.List; + +public interface VerificationTokenRepository extends JpaRepository { + + VerificationToken findByToken(String token); + + VerificationToken findByUser(User user); + + List findByUserAndCreatedAtBefore(User user, LocalDateTime thresholdDateTime); + + void deleteAllByUser(User user); +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/services/ArticleService.java b/server/src/main/java/com/lifeAI/LifeAI/services/ArticleService.java new file mode 100644 index 0000000..cc4d808 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/services/ArticleService.java @@ -0,0 +1,14 @@ +package com.lifeAI.LifeAI.services; + +import com.lifeAI.LifeAI.model.Article; +import com.lifeAI.LifeAI.model.dto.common.ArticleCardDTO; +import org.springframework.data.domain.Page; + +import java.util.List; + +public interface ArticleService { + Page getAllArticles(int page, int size); + Page getArticlesBySubCategory(String subCategory, int page, int size); + Article getArticleById(Long id); + List getRandomArticlesWithNoSubCategory(int count); +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/services/AuthenticationService.java b/server/src/main/java/com/lifeAI/LifeAI/services/AuthenticationService.java new file mode 100644 index 0000000..5285a16 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/services/AuthenticationService.java @@ -0,0 +1,26 @@ +package com.lifeAI.LifeAI.services; + +import com.lifeAI.LifeAI.model.User; +import com.lifeAI.LifeAI.model.dto.auth.AuthenticationRequest; +import com.lifeAI.LifeAI.model.dto.auth.AuthenticationResponse; +import com.lifeAI.LifeAI.model.dto.auth.RegisterRequest; + +import java.io.IOException; + +public interface AuthenticationService { + AuthenticationResponse register(RegisterRequest request); + + AuthenticationResponse authenticate(AuthenticationRequest request); + + AuthenticationResponse refreshToken(String refreshToken) throws IOException; + + AuthenticationResponse me( + String jwtToken + ); + + void resetPassword(String token, String newPassword); + + void confirmRegistration(String verificationToken); + + User forgotPassword(String email); +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/services/DailyProgressService.java b/server/src/main/java/com/lifeAI/LifeAI/services/DailyProgressService.java new file mode 100644 index 0000000..0cf0537 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/services/DailyProgressService.java @@ -0,0 +1,41 @@ +package com.lifeAI.LifeAI.services; + +import com.lifeAI.LifeAI.model.DailyProgress; +import com.lifeAI.LifeAI.respository.DailyProgressRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class DailyProgressService { + + private final DailyProgressRepository dailyProgressRepository; + + @Transactional + public DailyProgress completeChallenge(Long userId, Integer challengeNumber) { + if (challengeNumber < 1 || challengeNumber > 5) { + throw new IllegalArgumentException("Challenge number must be between 1 and 5"); + } + + LocalDate today = LocalDate.now(); + + DailyProgress progress = dailyProgressRepository + .findByUserIdAndDate(userId, today) + .orElse(DailyProgress.builder() + .userId(userId) + .date(today) + .build()); + + progress.addCompletedChallenge(challengeNumber); + + return dailyProgressRepository.save(progress); + } + + public Optional getTodaysProgress(Long userId) { + return dailyProgressRepository.findByUserIdAndDate(userId, LocalDate.now()); + } +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/services/ExceptionService.java b/server/src/main/java/com/lifeAI/LifeAI/services/ExceptionService.java new file mode 100644 index 0000000..ff4e303 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/services/ExceptionService.java @@ -0,0 +1,11 @@ +package com.lifeAI.LifeAI.services; + + +import com.lifeAI.LifeAI.exceptions.common.ApiException; + +public interface ExceptionService { + + void log(ApiException runtimeException); + + void log(RuntimeException runtimeException, int statusCode); +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/services/JwtService.java b/server/src/main/java/com/lifeAI/LifeAI/services/JwtService.java new file mode 100644 index 0000000..dff8824 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/services/JwtService.java @@ -0,0 +1,21 @@ +package com.lifeAI.LifeAI.services; + +import io.jsonwebtoken.Claims; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Map; +import java.util.function.Function; + +public interface JwtService { + String extractUsername(String token); + + T extractClaim(String token, Function claimsResolver); + + String generateToken(UserDetails userDetails); + + String generateToken(Map extraClaims, UserDetails userDetails); + + String generateRefreshToken(UserDetails userDetails); + + boolean isTokenValid(String token, UserDetails userDetails); +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/services/MoodService.java b/server/src/main/java/com/lifeAI/LifeAI/services/MoodService.java new file mode 100644 index 0000000..fe8a923 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/services/MoodService.java @@ -0,0 +1,44 @@ +package com.lifeAI.LifeAI.services; + +import com.lifeAI.LifeAI.model.MoodEntry; +import com.lifeAI.LifeAI.respository.MoodRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class MoodService { + + private final MoodRepository moodRepository; + + @Transactional + public MoodEntry saveMood(Long userId, Integer moodLevel) { + LocalDate today = LocalDate.now(); + + // Check if mood already exists for today + Optional existing = moodRepository.findByUserIdAndDate(userId, today); + + if (existing.isPresent()) { + // Update existing mood + MoodEntry mood = existing.get(); + mood.setMoodLevel(moodLevel); + return moodRepository.save(mood); + } else { + // Create new mood entry + MoodEntry mood = MoodEntry.builder() + .userId(userId) + .moodLevel(moodLevel) + .date(today) + .build(); + return moodRepository.save(mood); + } + } + + public Optional getTodaysMood(Long userId) { + return moodRepository.findByUserIdAndDate(userId, LocalDate.now()); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/services/OAuth2AuthenticationService.java b/server/src/main/java/com/lifeAI/LifeAI/services/OAuth2AuthenticationService.java new file mode 100644 index 0000000..12e5e8d --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/services/OAuth2AuthenticationService.java @@ -0,0 +1,10 @@ +package com.lifeAI.LifeAI.services; + +import com.lifeAI.LifeAI.model.dto.auth.AuthenticationResponse; + +public interface OAuth2AuthenticationService { + + String getOAuthGoogleLoginUrl(); + + AuthenticationResponse processOAuthGoogleLogin(String code); +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/services/TokenService.java b/server/src/main/java/com/lifeAI/LifeAI/services/TokenService.java new file mode 100644 index 0000000..86b0d0d --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/services/TokenService.java @@ -0,0 +1,28 @@ +package com.lifeAI.LifeAI.services; + +import com.lifeAI.LifeAI.enums.TokenType; +import com.lifeAI.LifeAI.model.Token; +import com.lifeAI.LifeAI.model.User; +import com.lifeAI.LifeAI.model.dto.auth.AuthenticationResponse; + +import java.util.List; + +public interface TokenService { + Token findByToken(String jwt); + + List findByUser(User user); + + void saveToken(User user, String jwtToken, TokenType tokenType); + + void revokeToken(Token token); + + void revokeAllUserTokens(User user); + + void logoutToken(String jwt); + + AuthenticationResponse generateAuthResponse(User user); + + void createVerificationToken(User user, String token); + + void clearVerificationTokensByUser(User user); +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/services/UserService.java b/server/src/main/java/com/lifeAI/LifeAI/services/UserService.java new file mode 100644 index 0000000..16a2dc4 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/services/UserService.java @@ -0,0 +1,28 @@ +package com.lifeAI.LifeAI.services; + + +import com.lifeAI.LifeAI.model.User; +import com.lifeAI.LifeAI.model.dto.auth.AdminUserDTO; +import com.lifeAI.LifeAI.model.dto.auth.OAuth2UserInfoDTO; +import com.lifeAI.LifeAI.model.dto.auth.PublicUserDTO; +import com.lifeAI.LifeAI.model.dto.auth.RegisterRequest; + +import java.util.List; + +public interface UserService { + User createUser(RegisterRequest request); + + User findByEmail(String email); + + List getAllUsers(); + + AdminUserDTO getByIdAdmin(Long id); + + AdminUserDTO updateUser(Long id, AdminUserDTO userDTO, PublicUserDTO currentUser); + + void deleteUserById(Long id, PublicUserDTO currentUser); + + User processOAuthUser(OAuth2UserInfoDTO oAuth2User); + + User findById(Long id); +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/services/UserServiceImpl.java b/server/src/main/java/com/lifeAI/LifeAI/services/UserServiceImpl.java new file mode 100644 index 0000000..cb110b5 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/services/UserServiceImpl.java @@ -0,0 +1,176 @@ +package com.lifeAI.LifeAI.services; + +import com.lifeAI.LifeAI.enums.Provider; +import com.lifeAI.LifeAI.enums.Role; +import com.lifeAI.LifeAI.exceptions.common.AccessDeniedException; +import com.lifeAI.LifeAI.exceptions.user.UserCreateException; +import com.lifeAI.LifeAI.exceptions.user.UserNotFoundException; +import com.lifeAI.LifeAI.exceptions.user.UserValidationException; +import com.lifeAI.LifeAI.model.User; +import com.lifeAI.LifeAI.model.dto.auth.AdminUserDTO; +import com.lifeAI.LifeAI.model.dto.auth.OAuth2UserInfoDTO; +import com.lifeAI.LifeAI.model.dto.auth.PublicUserDTO; +import com.lifeAI.LifeAI.model.dto.auth.RegisterRequest; +import com.lifeAI.LifeAI.respository.UserRepository; +import jakarta.validation.ConstraintViolationException; +import lombok.RequiredArgsConstructor; +import org.modelmapper.ModelMapper; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@RequiredArgsConstructor +@Service +public class UserServiceImpl implements UserService { + private final PasswordEncoder passwordEncoder; + private final UserRepository userRepository; + private final ModelMapper modelMapper; + + /** + * Creates a new user based on the provided registration request. + * + * @param request The registration request containing user details. + * @return The created user. + * @throws UserCreateException If there is an issue creating the user. + * @throws DataIntegrityViolationException If there is a data integrity violation while creating the user. + * @throws ConstraintViolationException If there is a constraint violation while creating the user. + */ + @Override + public User createUser(RegisterRequest request) { + if (userRepository.findByEmail(request.getEmail()).isPresent()) { + throw new UserCreateException(true); + } + + try { + User user = buildUser(request); + user.setRole("patient"); + + return userRepository.save(user); + } catch (DataIntegrityViolationException exception) { + throw new UserCreateException(true); + } catch (ConstraintViolationException exception) { + throw new UserValidationException(exception.getConstraintViolations()); + } + } + + @Override + public User findByEmail(String email) { + return userRepository.findByEmail(email) + .orElseThrow(UserNotFoundException::new); + } + + @Override + public List getAllUsers() { + return userRepository + .findAll(); + } + + @Override + public AdminUserDTO updateUser(Long id, AdminUserDTO userDTO, PublicUserDTO currentUser) { + User userToUpdate = findById(id); + + if (!(userToUpdate.getId().equals(currentUser.getId())) && !currentUser.getRole().equals(Role.ADMIN)) { + throw new AccessDeniedException(); + } + + if (currentUser.getRole().equals(Role.PATIENT)) { + userToUpdate.setFirstName(userDTO.getFirstName()); + userToUpdate.setLastName(userDTO.getLastName()); + } else if (currentUser.getRole().equals(Role.ADMIN)) { + // It is not null it is "" so don't change it + if (userDTO.getPassword() == "") { + userDTO.setPassword(userToUpdate.getPassword()); + } else { + userDTO.setPassword(passwordEncoder.encode(userDTO.getPassword())); + } + + modelMapper.map(userDTO, userToUpdate); + } + + userToUpdate.setId(id); + + User updatedUser = userRepository.save(userToUpdate); + return modelMapper.map(updatedUser, AdminUserDTO.class); + } + + + @Override + public void deleteUserById(Long id, PublicUserDTO currentUser) { + User user = findById(id); + + if (user.getId().equals(currentUser.getId())) { + throw new AccessDeniedException(); + } + + if (user.getDeletedAt() == null) { + user.setDeletedAt(LocalDateTime.now()); + } else { + user.setDeletedAt(null); + } + + userRepository.save(user); + } + + /** + * Processes the OAuth user obtained from the OAuth2 provider. + * If the user does not exist in the database, a new user is created based on the OAuth user details. + * + * @param oAuth2User The OAuth2 user obtained from the OAuth provider. + * @return The processed user. + */ + @Override + public User processOAuthUser(OAuth2UserInfoDTO oAuth2User) { + User user = userRepository.findByEmail(oAuth2User.getEmail()).orElse(null); + + if (user == null) { + RegisterRequest registerRequest = new RegisterRequest(); + + registerRequest.setEmail(oAuth2User.getEmail()); + registerRequest.setProvider(oAuth2User.getProvider()); + + if (oAuth2User.getProvider().equals(Provider.GOOGLE)) { + registerRequest.setFirstName(oAuth2User.getGiven_name()); + registerRequest.setLastName(oAuth2User.getFamily_name()); + } + + user = userRepository.save(buildUser(registerRequest)); + } + + return user; + } + + @Override + public User findById(Long id) { + return userRepository.findById(id) + .orElseThrow(UserNotFoundException::new); + } + + @Override + public AdminUserDTO getByIdAdmin(Long id) { + User user = userRepository.findById(id).orElseThrow(UserNotFoundException::new); + return modelMapper.map(user, AdminUserDTO.class); + } + + private User buildUser(RegisterRequest request) { + User.UserBuilder userBuilder = User + .builder() + .uuid(UUID.randomUUID().toString()) + .firstName(request.getFirstName()) + .lastName(request.getLastName()) + .email(request.getEmail()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .encrypted(false) // public data + .role("patient"); + + if (request.getPassword() != null) { + userBuilder.password(passwordEncoder.encode(request.getPassword())); + } + + return userBuilder.build(); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/services/impl/ArticleServiceImpl.java b/server/src/main/java/com/lifeAI/LifeAI/services/impl/ArticleServiceImpl.java new file mode 100644 index 0000000..7749be3 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/services/impl/ArticleServiceImpl.java @@ -0,0 +1,44 @@ +package com.lifeAI.LifeAI.services.impl; + +import com.lifeAI.LifeAI.model.Article; +import com.lifeAI.LifeAI.model.dto.common.ArticleCardDTO; +import com.lifeAI.LifeAI.respository.ArticleRepository; +import com.lifeAI.LifeAI.services.ArticleService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.NoSuchElementException; + +@Service +@RequiredArgsConstructor +public class ArticleServiceImpl implements ArticleService { + private final ArticleRepository articleRepository; + + @Override + public Page getAllArticles(int page, int size) { + Pageable pageable = PageRequest.of(page, size); + return articleRepository.findAllCards(pageable); + } + + @Override + public Page getArticlesBySubCategory(String subCategory, int page, int size) { + Pageable pageable = PageRequest.of(page, size); + return articleRepository.findCardsBySubCategory(subCategory, pageable); + } + + @Override + public Article getArticleById(Long id) { + return articleRepository.findById(id) + .orElseThrow(() -> new NoSuchElementException("Article with id " + id + " not found")); + } + + @Override + public List getRandomArticlesWithNoSubCategory(int count) { + return articleRepository.findRandomArticlesWithNoSubCategory(Pageable.ofSize(count)); + } + +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/services/impl/ExceptionServiceImpl.java b/server/src/main/java/com/lifeAI/LifeAI/services/impl/ExceptionServiceImpl.java new file mode 100644 index 0000000..a811325 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/services/impl/ExceptionServiceImpl.java @@ -0,0 +1,27 @@ +package com.lifeAI.LifeAI.services.impl; + +import com.lifeAI.LifeAI.exceptions.common.ApiException; +import com.lifeAI.LifeAI.model.Exception; +import com.lifeAI.LifeAI.services.ExceptionService; +import lombok.AllArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; + +@Service +@Component +@AllArgsConstructor +public class ExceptionServiceImpl implements ExceptionService { + + @Override + @Async + public void log(ApiException apiException) { + Exception exception = Exception.mapFromApiException(apiException); + } + + @Override + @Async + public void log(RuntimeException runtimeException, int statusCode) { + Exception exception = Exception.mapFromRuntimeException(runtimeException, statusCode); + } +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/services/security/AuthenticationServiceImpl.java b/server/src/main/java/com/lifeAI/LifeAI/services/security/AuthenticationServiceImpl.java new file mode 100644 index 0000000..ce86114 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/services/security/AuthenticationServiceImpl.java @@ -0,0 +1,272 @@ +package com.lifeAI.LifeAI.services.security; + +import com.lifeAI.LifeAI.config.rateLimiting.RateLimiterConfigProperties; +import com.lifeAI.LifeAI.enums.TokenType; +import com.lifeAI.LifeAI.exceptions.email.EmailNotVerified; +import com.lifeAI.LifeAI.exceptions.token.ExpiredTokenException; +import com.lifeAI.LifeAI.exceptions.token.InvalidTokenException; +import com.lifeAI.LifeAI.exceptions.user.UserLoginException; +import com.lifeAI.LifeAI.exceptions.user.UserNotFoundException; +import com.lifeAI.LifeAI.model.Token; +import com.lifeAI.LifeAI.model.User; +import com.lifeAI.LifeAI.model.VerificationToken; +import com.lifeAI.LifeAI.model.dto.auth.AuthenticationRequest; +import com.lifeAI.LifeAI.model.dto.auth.AuthenticationResponse; +import com.lifeAI.LifeAI.model.dto.auth.PublicUserDTO; +import com.lifeAI.LifeAI.model.dto.auth.RegisterRequest; +import com.lifeAI.LifeAI.respository.UserRepository; +import com.lifeAI.LifeAI.respository.VerificationTokenRepository; +import com.lifeAI.LifeAI.services.AuthenticationService; +import com.lifeAI.LifeAI.services.JwtService; +import com.lifeAI.LifeAI.services.TokenService; +import com.lifeAI.LifeAI.services.UserService; +import com.lifeAI.LifeAI.utils.PasswordEncryptionUtils; +import lombok.RequiredArgsConstructor; +import org.modelmapper.ModelMapper; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Calendar; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AuthenticationServiceImpl implements AuthenticationService { + private final UserService userService; + private final TokenService tokenService; + private final JwtService jwtService; + private final AuthenticationManager authenticationManager; + private final ModelMapper modelMapper; + private final VerificationTokenRepository verificationTokenRepository; + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final RateLimiterConfigProperties rateLimiterConfigProperties; + + /** + * Registers a new user based on the provided registration request. + */ + @Override + public AuthenticationResponse register(RegisterRequest request) { + User user = userService.createUser(request); + + userRepository.save(user); + + return tokenService.generateAuthResponse(user); + } + + // Login with correct email and password + @Override + public AuthenticationResponse authenticate(AuthenticationRequest request) { + User user; + + try { + user = userService.findByEmail(request.getEmail()); + } catch (UserNotFoundException userNotFoundException) { + throw new UserLoginException(); + } + + boolean passedFirstCheck = PasswordEncryptionUtils.validatePassword(request.getPassword(), user.getPassword()); + + if (passedFirstCheck) { + user.setPassword(passwordEncoder.encode(request.getPassword())); + userRepository.save(user); + + if (!user.isEnabled()) { + throw new EmailNotVerified(); + } + } else { + try { + authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken( + request.getEmail(), + request.getPassword() + ) + ); + } catch (DisabledException exception) { + throw new EmailNotVerified(); + } catch (AuthenticationException exception) { + throw new UserLoginException(); + } + } + + System.out.println(user.getEmail()); + tokenService.revokeAllUserTokens(user); + return tokenService.generateAuthResponse(user); + } + + /** + * Generates a new access token and updates the refresh token based on the provided refresh token. + * If the refresh token is missing or invalid, it throws an InvalidTokenException. + * If the refresh token is valid, it generates a new access token, revokes all existing user tokens, + * and updates the refresh token to the provided one. + */ + @Override + public AuthenticationResponse refreshToken(String refreshToken) { + if (refreshToken == null || refreshToken.isEmpty()) { + throw new InvalidTokenException(); + } + + String userEmail; + + try { + userEmail = jwtService.extractUsername(refreshToken); + } catch (JwtException exception) { + throw new InvalidTokenException(); + } + + if (userEmail == null) { + throw new InvalidTokenException(); + } + + // Make sure token is a refresh token not access token + Token token = tokenService.findByToken(refreshToken); + if (token != null && token.tokenType != TokenType.REFRESH) { + throw new InvalidTokenException(); + } + + User user = userService.findByEmail(userEmail); + + if (!jwtService.isTokenValid(refreshToken, user)) { + tokenService.revokeToken(token); + throw new InvalidTokenException(); + } + + String accessToken = jwtService.generateToken(user); + + tokenService.revokeAllUserTokens(user); + tokenService.saveToken(user, accessToken, TokenType.ACCESS); + tokenService.saveToken(user, refreshToken, TokenType.REFRESH); + + return AuthenticationResponse + .builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + /** + * Retrieves user information based on the provided JWT token. + * If the token is invalid or missing, it throws an InvalidTokenException. + * If the token is valid, it retrieves the user's access and refresh tokens, updates the refresh token if necessary, + * and returns an authentication response containing the user's information and tokens. + */ + @Override + public AuthenticationResponse me(String jwtToken) { + if (jwtToken == null || jwtToken.isEmpty()) { + throw new InvalidTokenException(); + } + + Token accessToken = tokenService.findByToken(jwtToken); + + if (accessToken == null) { + throw new InvalidTokenException(); + } + + User user = accessToken.getUser(); + + boolean isTokenValid; + + try { + isTokenValid = jwtService.isTokenValid(accessToken.getToken(), user); + } catch (JwtException jwtException) { + isTokenValid = false; + } + + if (!isTokenValid) { + tokenService.revokeAllUserTokens(user); + throw new InvalidTokenException(); + } + + List tokens = tokenService.findByUser(user); + List refreshTokens = tokens.stream().filter(x -> x.getTokenType() == TokenType.REFRESH).toList(); + + if (refreshTokens.isEmpty()) { + throw new InvalidTokenException(); + } + + Token refreshToken = refreshTokens.get(0); + + if (refreshToken == null) { + throw new InvalidTokenException(); + } + + String refreshTokenString; + + if (!jwtService.isTokenValid(refreshToken.getToken(), user)) { + refreshTokenString = jwtService.generateRefreshToken(user); + tokenService.saveToken(user, refreshTokenString, TokenType.REFRESH); + } else { + refreshTokenString = refreshToken.getToken(); + } + + PublicUserDTO publicUser = modelMapper.map(accessToken.getUser(), PublicUserDTO.class); + + return AuthenticationResponse + .builder() + .accessToken(accessToken.getToken()) + .refreshToken(refreshTokenString) + .user(publicUser) + .build(); + } + + /** + * Resets the password for a user based on the provided token and new password. + */ + public void resetPassword(String token, String newPassword) { + VerificationToken verificationToken = verificationTokenRepository.findByToken(token); + if (verificationToken == null) { + throw new InvalidTokenException(); + } + + + User user = verificationToken.getUser(); + if (user == null) { + throw new InvalidTokenException(); + } + + verificationToken.setCreatedAt(LocalDateTime.now()); + user.setPassword(passwordEncoder.encode(newPassword)); + + userRepository.save(user); + verificationTokenRepository.delete(verificationToken); + } + + @Override + public void confirmRegistration(String token) { + VerificationToken verificationToken = verificationTokenRepository.findByToken(token); + if (verificationToken == null) { + throw new ExpiredTokenException(); + } + + verificationToken.setCreatedAt(LocalDateTime.now()); + + Calendar cal = Calendar.getInstance(); + if ((verificationToken.getExpiryDate().getTime() - cal.getTime().getTime()) <= 0) { + throw new ExpiredTokenException(); + } + + User user = verificationToken.getUser(); + user.setEnabled(true); + user.setEmailVerifiedAt(LocalDateTime.now()); + + userRepository.save(user); + verificationTokenRepository.delete(verificationToken); + } + + @Override + public User forgotPassword(String email) { + User user = userService.findByEmail(email); + + if (!user.isEnabled()) { + throw new EmailNotVerified(); + } + + return user; + } +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/services/security/JwtServiceImpl.java b/server/src/main/java/com/lifeAI/LifeAI/services/security/JwtServiceImpl.java new file mode 100644 index 0000000..dbfce0b --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/services/security/JwtServiceImpl.java @@ -0,0 +1,119 @@ +package com.lifeAI.LifeAI.services.security; + +import com.lifeAI.LifeAI.services.JwtService; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +import java.security.Key; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +/** + * Implementation of the JwtService interface responsible for JWT token generation, validation, and extraction. + */ +@Service +public class JwtServiceImpl implements JwtService { + + // Secret key used for signing JWT tokens + @Value("${spring.security.jwt.secret-key}") + private String secretKey; + + // JWT token expiration time in milliseconds + @Value("${spring.security.jwt.expiration}") + private long jwtExpiration; + + // JWT token expiration time in milliseconds + @Value("${spring.security.jwt.refresh-token.expiration}") + private long refreshExpiration; + + public JwtServiceImpl() { + } + + public JwtServiceImpl(String secretKey, long jwtExpiration, long refreshExpiration) { + this.secretKey = secretKey; + this.jwtExpiration = jwtExpiration; + this.refreshExpiration = refreshExpiration; + } + + @Override + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + + @Override + public T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + @Override + public String generateToken(UserDetails userDetails) { + return generateToken(new HashMap<>(), userDetails); + } + + @Override + public String generateToken( + Map extraClaims, + UserDetails userDetails + ) { + return buildToken(extraClaims, userDetails, jwtExpiration); + } + + @Override + public String generateRefreshToken( + UserDetails userDetails + ) { + return buildToken(new HashMap<>(), userDetails, refreshExpiration); + } + + @Override + public boolean isTokenValid(String token, UserDetails userDetails) { + final String username = extractUsername(token); + return (username.equals(userDetails.getUsername())) && !isTokenExpired(token); + } + + public String buildToken( + Map extraClaims, + UserDetails userDetails, + long expiration + ) { + return Jwts + .builder() + .setClaims(extraClaims) + .setSubject(userDetails.getUsername()) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + expiration)) + .signWith(getSignInKey(), SignatureAlgorithm.HS256) + .compact(); + } + + private boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + private Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); + } + + public Claims extractAllClaims(String token) { + return Jwts + .parserBuilder() + .setSigningKey(getSignInKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } + + private Key getSignInKey() { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + return Keys.hmacShaKeyFor(keyBytes); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/services/security/OAuth2AuthenticationServiceImpl.java b/server/src/main/java/com/lifeAI/LifeAI/services/security/OAuth2AuthenticationServiceImpl.java new file mode 100644 index 0000000..9964da0 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/services/security/OAuth2AuthenticationServiceImpl.java @@ -0,0 +1,88 @@ +package com.lifeAI.LifeAI.services.security; + +import com.lifeAI.LifeAI.config.FrontendConfig; +import com.lifeAI.LifeAI.enums.Provider; +import com.lifeAI.LifeAI.exceptions.token.InvalidTokenException; +import com.lifeAI.LifeAI.model.User; +import com.lifeAI.LifeAI.model.dto.auth.AuthenticationResponse; +import com.lifeAI.LifeAI.model.dto.auth.OAuth2UserInfoDTO; +import com.lifeAI.LifeAI.services.OAuth2AuthenticationService; +import com.lifeAI.LifeAI.services.TokenService; +import com.lifeAI.LifeAI.services.UserService; +import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeRequestUrl; +import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeTokenRequest; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +import java.io.IOException; +import java.util.List; + +/** + * Implementation of the OAuth2AuthenticationService interface responsible for processing OAuth2 user authentication. + * This services handles the post-login process, including token generation and cookie attachment. + */ +@Service +@RequiredArgsConstructor +public class OAuth2AuthenticationServiceImpl implements OAuth2AuthenticationService { + private final List SCOPES = List.of("email", "profile", "openid"); + private final FrontendConfig frontendConfig; + private final WebClient userInfoClient; + private final UserService userService; + private final TokenService tokenService; + + + @Value("${spring.security.oauth2.resourceserver.opaquetoken.client-id}") + private String clientId; + @Value("${spring.security.oauth2.resourceserver.opaquetoken.client-secret}") + private String clientSecret; + + @Override + public String getOAuthGoogleLoginUrl() { + return new GoogleAuthorizationCodeRequestUrl(clientId, frontendConfig.getOauth2RedirectUrl(), SCOPES).build(); + } + + @Override + public AuthenticationResponse processOAuthGoogleLogin(String code) { + // Authenticate user with Google + String token = authorizeWithGoogle(code); + OAuth2UserInfoDTO oAuth2UserInfoDTO = getUserInfoFromGoogleToken(token); + oAuth2UserInfoDTO.setProvider(Provider.GOOGLE); + + // Process OAuth2 user and retrieve associated user entity + User user = userService.processOAuthUser(oAuth2UserInfoDTO); + tokenService.revokeAllUserTokens(user); + + return tokenService.generateAuthResponse(user); + } + + private OAuth2UserInfoDTO getUserInfoFromGoogleToken(String googleToken) { + return userInfoClient.get() + .uri(uriBuilder -> uriBuilder + .path("/oauth2/v3/userinfo") + .queryParam("access_token", googleToken) + .build()) + .retrieve() + .bodyToMono(OAuth2UserInfoDTO.class) + .block(); + } + + private String authorizeWithGoogle(String code) { + try { + return new GoogleAuthorizationCodeTokenRequest( + new NetHttpTransport(), + new GsonFactory(), + clientId, + clientSecret, + code, + frontendConfig.getOauth2RedirectUrl()) + .execute() + .getAccessToken(); + } catch (IOException e) { + throw new InvalidTokenException(); + } + } +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/services/security/PasswordResetListener.java b/server/src/main/java/com/lifeAI/LifeAI/services/security/PasswordResetListener.java new file mode 100644 index 0000000..d749fce --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/services/security/PasswordResetListener.java @@ -0,0 +1,72 @@ +package com.lifeAI.LifeAI.services.security; + +import com.lifeAI.LifeAI.config.FrontendConfig; +import com.lifeAI.LifeAI.model.User; +import com.lifeAI.LifeAI.services.TokenService; +import com.lifeAI.LifeAI.services.security.events.OnPasswordResetRequestEvent; +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.NotNull; +import org.springframework.context.ApplicationListener; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +/** + * Component responsible for listening to password reset request events and sending password reset emails. + */ +@Component +@RequiredArgsConstructor +public class PasswordResetListener implements ApplicationListener { + + private final JavaMailSender mailSender; + private final FrontendConfig frontendConfig; + private final TokenService tokenService; + + /** + * Sends a password reset email upon receiving the password reset request event. + */ + @Override + @Async + public void onApplicationEvent(@NotNull OnPasswordResetRequestEvent event) { + sendPasswordResetEmail(event); + } + + protected void sendPasswordResetEmail(OnPasswordResetRequestEvent event) { + User user = event.getUser(); + + // Generate a password reset token and create a verification token for the user + String token = generateResetToken(user); + tokenService.createVerificationToken(user, token); + + String recipientAddress = user.getEmail(); + String subject = "Заявка за нулиране на парола в LIFE"; + String message = getEmailMessage(token, user); + + SimpleMailMessage email = new SimpleMailMessage(); + email.setTo(recipientAddress); + email.setSubject(subject); + email.setText(message); + + mailSender.send(email); + } + + @NotNull + private String getEmailMessage(String token, User user) { + String confirmationUrl = frontendConfig.getForgottenPasswordUrl() + "?token=" + token; + + return "Здравейте, " + user.getUsername() + ",\n\n" + + "Получихме заявка за нулиране на паролата за вашия акаунт.\n" + + "Моля, кликнете върху следния линк, за да нулирате паролата си:\n" + + confirmationUrl + "\n\n" + + "Ако не сте изпращали такава заявка, моля игнорирайте този имейл.\n\n" + + "С най-добри пожелания,\n" + + "Екипът на LIFE!"; + } + + private String generateResetToken(User user) { + return UUID.randomUUID().toString(); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/services/security/RegistrationListener.java b/server/src/main/java/com/lifeAI/LifeAI/services/security/RegistrationListener.java new file mode 100644 index 0000000..6009a62 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/services/security/RegistrationListener.java @@ -0,0 +1,61 @@ +package com.lifeAI.LifeAI.services.security; + +import com.lifeAI.LifeAI.model.User; +import com.lifeAI.LifeAI.services.TokenService; +import com.lifeAI.LifeAI.services.security.events.OnRegistrationCompleteEvent; +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.NotNull; +import org.springframework.context.ApplicationListener; +import org.springframework.context.MessageSource; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +/** + * Component responsible for handling registration confirmation emails. + */ +@Component +@RequiredArgsConstructor +public class RegistrationListener implements ApplicationListener { + private final TokenService tokenService; + private final MessageSource messages; + private final JavaMailSender mailSender; + + @Override + @Async + public void onApplicationEvent(@NotNull OnRegistrationCompleteEvent event) { + this.confirmRegistration(event); + } + + protected void confirmRegistration(OnRegistrationCompleteEvent event) { + User user = event.getUser(); + String token = UUID.randomUUID().toString(); + tokenService.createVerificationToken(user, token); + + String recipientAddress = user.getEmail(); + String subject = "Потвърждение на регистрацията в LIFE"; + String message = getEmailNote(event, token, user); + + SimpleMailMessage email = new SimpleMailMessage(); + email.setTo(recipientAddress); + email.setSubject(subject); + email.setText(message); + + mailSender.send(email); + } + + private static @NotNull String getEmailNote(OnRegistrationCompleteEvent event, String token, User user) { + String confirmationUrl = event.getAppUrl() + "auth/registrationConfirm?token=" + token; + + return "Здравейте, " + user.getUsername() + "\n\n" + + "Благодарим Ви, че се регистрирахте в LIFE!\n\n" + + "За да завършите регистрацията си, моля кликнете върху следния линк, за да потвърдите вашия имейл:\n" + + confirmationUrl + "\n\n" + + "Ако не сте създали акаунт при нас, моля игнорирайте този имейл.\n\n" + + "С най-добри пожелания,\n" + + "Екипът на LIFE!"; + } +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/services/security/TokenServiceImpl.java b/server/src/main/java/com/lifeAI/LifeAI/services/security/TokenServiceImpl.java new file mode 100644 index 0000000..872b2f3 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/services/security/TokenServiceImpl.java @@ -0,0 +1,116 @@ +package com.lifeAI.LifeAI.services.security; + +import com.lifeAI.LifeAI.enums.TokenType; +import com.lifeAI.LifeAI.model.Token; +import com.lifeAI.LifeAI.model.User; +import com.lifeAI.LifeAI.model.VerificationToken; +import com.lifeAI.LifeAI.model.dto.auth.AuthenticationResponse; +import com.lifeAI.LifeAI.model.dto.auth.PublicUserDTO; +import com.lifeAI.LifeAI.respository.TokenRepository; +import com.lifeAI.LifeAI.respository.VerificationTokenRepository; +import com.lifeAI.LifeAI.services.JwtService; +import com.lifeAI.LifeAI.services.TokenService; +import lombok.RequiredArgsConstructor; +import org.modelmapper.ModelMapper; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * Service implementation responsible for handling authentication tokens. + */ +@RequiredArgsConstructor +@Service +public class TokenServiceImpl implements TokenService { + + private final TokenRepository tokenRepository; + private final VerificationTokenRepository verificationTokenRepository; + private final JwtService jwtService; + private final ModelMapper modelMapper; + + @Override + public Token findByToken(String jwt) { + return tokenRepository.findByToken(jwt).orElse(null); + } + + @Override + public List findByUser(User user) { + return tokenRepository.findAllByUser(user); + } + + @Override + public void saveToken(User user, String jwtToken, TokenType tokenType) { + Token token = Token.builder() + .user(user) + .token(jwtToken) + .tokenType(tokenType) + .expired(false) + .revoked(false) + .build(); + + tokenRepository.save(token); + } + + @Override + public void revokeToken(Token token) { + tokenRepository.delete(token); + } + + @Override + public void revokeAllUserTokens(User user) { + tokenRepository.deleteAll(tokenRepository.findAllByUser(user)); + } + + @Override + @Transactional + public void logoutToken(String jwt) { + Token storedToken = tokenRepository.findByToken(jwt) + .orElse(null); + + if (storedToken == null) { + return; + } + + revokeAllUserTokens(storedToken.getUser()); + SecurityContextHolder.clearContext(); + } + + @Override + public AuthenticationResponse generateAuthResponse(User user) { + String jwtToken = jwtService.generateToken(user); + String refreshToken = jwtService.generateRefreshToken(user); + + saveToken(user, jwtToken, TokenType.ACCESS); + saveToken(user, refreshToken, TokenType.REFRESH); + + PublicUserDTO dto = PublicUserDTO.builder() + .id(user.getId()) + .firstName(user.getFirstName() != null ? user.getFirstName() : "") + .lastName(user.getLastName() != null ? user.getLastName() : "") + .email(user.getEmail() != null ? user.getEmail() : "") + .role(user.getRole() != null ? user.getRole() : "") + .build(); + + return AuthenticationResponse.builder() + .accessToken(jwtToken) + .refreshToken(refreshToken) + .user(dto) + .build(); + } + + @Override + @Transactional + public void createVerificationToken(User user, String token) { + clearVerificationTokensByUser(user); + VerificationToken myToken = new VerificationToken(token, user); + verificationTokenRepository.save(myToken); + } + + @Override + public void clearVerificationTokensByUser(User user) { + verificationTokenRepository.deleteAllByUser(user); + } + +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/services/security/events/OnPasswordResetRequestEvent.java b/server/src/main/java/com/lifeAI/LifeAI/services/security/events/OnPasswordResetRequestEvent.java new file mode 100644 index 0000000..52249c9 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/services/security/events/OnPasswordResetRequestEvent.java @@ -0,0 +1,22 @@ +package com.lifeAI.LifeAI.services.security.events; + +import com.lifeAI.LifeAI.model.User; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +/** + * OnPasswordResetRequestEvent represents an event raised when a password reset request is initiated. + * It carries information about the user requesting the password reset and the base URL of the application. + */ +@Getter +public class OnPasswordResetRequestEvent extends ApplicationEvent { + + private final User user; + private final String appBaseUrl; + + public OnPasswordResetRequestEvent(User user, String appBaseUrl) { + super(user); + this.user = user; + this.appBaseUrl = appBaseUrl; + } +} \ No newline at end of file diff --git a/server/src/main/java/com/lifeAI/LifeAI/services/security/events/OnRegistrationCompleteEvent.java b/server/src/main/java/com/lifeAI/LifeAI/services/security/events/OnRegistrationCompleteEvent.java new file mode 100644 index 0000000..8842fe0 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/services/security/events/OnRegistrationCompleteEvent.java @@ -0,0 +1,30 @@ +package com.lifeAI.LifeAI.services.security.events; + +import com.lifeAI.LifeAI.model.User; +import lombok.Getter; +import lombok.Setter; +import org.springframework.context.ApplicationEvent; + +import java.util.Locale; + +/** + * OnRegistrationCompleteEvent represents an event raised when a user completes registration. + * It carries information about the registered user, the application URL, and the locale. + */ +@Getter +public class OnRegistrationCompleteEvent extends ApplicationEvent { + @Setter + private String appUrl; + private Locale locale; + @Setter + @Getter + private User user; + + public OnRegistrationCompleteEvent( + User user, String appUrl) { + super(user); + + this.user = user; + this.appUrl = appUrl; + } +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/utils/ApiExceptionParser.java b/server/src/main/java/com/lifeAI/LifeAI/utils/ApiExceptionParser.java new file mode 100644 index 0000000..7d1b73f --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/utils/ApiExceptionParser.java @@ -0,0 +1,22 @@ +package com.lifeAI.LifeAI.utils; + +import com.lifeAI.LifeAI.exceptions.common.ApiException; +import com.lifeAI.LifeAI.model.dto.response.ExceptionResponse; + +import java.time.LocalDateTime; + +/** + * ApiExceptionParser is a utility class responsible for parsing ApiException objects into ExceptionResponse objects. + * It provides a static method to perform this parsing operation. + */ +public class ApiExceptionParser { + public static ExceptionResponse parseException(ApiException exception) { + return ExceptionResponse + .builder() + .dateTime(LocalDateTime.now()) + .message(exception.getMessage()) + .status(exception.getStatus()) + .statusCode(exception.getStatusCode()) + .build(); + } +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/utils/CookieHelper.java b/server/src/main/java/com/lifeAI/LifeAI/utils/CookieHelper.java new file mode 100644 index 0000000..1a657f0 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/utils/CookieHelper.java @@ -0,0 +1,29 @@ +package com.lifeAI.LifeAI.utils; + +import jakarta.servlet.http.Cookie; + +import java.util.Arrays; +import java.util.Optional; + +public class CookieHelper { + + /** + * Reads the value of a cookie with the specified key from an array of cookies. + * + * @param key The key of the cookie to be read. + * @param cookies An array of Cookie objects. + * @return An Optional containing the value of the cookie if found, or Optional.empty() if not found. + */ + public static Optional readCookie(String key, Cookie[] cookies) { + // If cookies array is null, return Optional.empty() + if (cookies == null) { + return Optional.empty(); + } + + // Stream through the array of cookies, filter by key, and map to the value + return Arrays.stream(cookies) + .filter(c -> key.equals(c.getName())) // Filter by key + .map(Cookie::getValue) // Map to value + .findAny(); // Find any matching cookie value + } +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/utils/ObjectMapperHelper.java b/server/src/main/java/com/lifeAI/LifeAI/utils/ObjectMapperHelper.java new file mode 100644 index 0000000..e2bd804 --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/utils/ObjectMapperHelper.java @@ -0,0 +1,34 @@ +package com.lifeAI.LifeAI.utils; + +import com.lifeAI.LifeAI.exceptions.common.ApiException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lifeAI.LifeAI.model.dto.response.ExceptionResponse; +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; + +import java.io.IOException; + +/** + * ObjectMapperHelper provides utility methods for working with ObjectMapper and handling exceptions. + */ +public class ObjectMapperHelper { + public static void writeExceptionToObjectMapper( + ObjectMapper objectMapper, + ApiException exception, + HttpServletResponse httpServletResponse + ) throws IOException { + // Parse the ApiException to an ExceptionResponse + ExceptionResponse exceptionResponse = ApiExceptionParser.parseException(exception); + + // Set the HTTP response status and content type + httpServletResponse.setStatus(exceptionResponse.getStatusCode()); + httpServletResponse.setContentType(MediaType.APPLICATION_JSON_VALUE); + + // Get the output stream of the response + ServletOutputStream out = httpServletResponse.getOutputStream(); + + // Write the exception response as JSON to the output stream using the provided ObjectMapper + objectMapper.writeValue(out, exceptionResponse); + } +} diff --git a/server/src/main/java/com/lifeAI/LifeAI/utils/PasswordEncryptionUtils.java b/server/src/main/java/com/lifeAI/LifeAI/utils/PasswordEncryptionUtils.java new file mode 100644 index 0000000..5e93d0c --- /dev/null +++ b/server/src/main/java/com/lifeAI/LifeAI/utils/PasswordEncryptionUtils.java @@ -0,0 +1,49 @@ +package com.lifeAI.LifeAI.utils; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class PasswordEncryptionUtils { + // Validate the password by comparing plain text and encrypted + public static boolean validatePassword(String plain, String encrypted) { + if (notNull(plain) && notNull(encrypted)) { + // Split the encrypted password into hash and salt + String[] stack = encrypted.split(":"); + + if (stack.length != 2) return false; + + // Hash the salt and plain text password and compare with the stored hash + return md5(stack[1] + plain).equals(stack[0]); + } + + return false; + } + + // Check if the value is not null + private static boolean notNull(Object value) { + if (value instanceof String strValue) { + return !strValue.trim().isEmpty() && !strValue.equals("NULL"); + } else if (value instanceof Integer) { + return true; + } else if (value instanceof Object[]) { + return ((Object[]) value).length > 0; + } + + return value != null; + } + + // Generate MD5 hash of a string + private static String md5(String input) { + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] messageDigest = md.digest(input.getBytes()); + StringBuilder hexString = new StringBuilder(); + for (byte b : messageDigest) { + hexString.append(String.format("%02x", b)); + } + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } +} diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 9e0d555..fd5a53f 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -3,27 +3,39 @@ management: web: exposure: include: [ 'health', 'healthcheck', 'prometheus' ] - base-path: '/admin' + base-path: /admin path-mapping: - prometheus: '/metrics' + prometheus: /metrics + server: port: 8080 + frontend: + baseUrl: ${FRONTEND_URL} + loginUrl: ${server.frontend.baseUrl}/login + finishRegisterUrl: ${server.frontend.baseUrl}/finish-register + oauth2RedirectUrl: ${server.frontend.baseUrl}/process-oauth2 + forgottenPasswordUrl: ${server.frontend.baseUrl}/forgotten-password + backend: + baseUrl: ${BACKEND_URL} + spring: - application.name: plug-n-play - ai: - openai: - api-key: ${OPENAI_API_KEY} - assistant-id: ${ASSISTANT_ID} - positiveAssistant-id: ${POSITIVE_ASSISTANT_ID} - fileAssistant-id: ${FILE_ASSISTANT_ID} + application: + name: plug-n-play + + config: + import: "classpath:rate-limiter.yaml" + + servlet: + multipart: + max-file-size: 20MB + max-request-size: 20MB + datasource: url: jdbc:${MYSQL_URL} username: ${MYSQLUSER} password: ${MYSQLPASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver - flyway: - enabled: true - validate-on-migrate: false + jpa: generate-ddl: false hibernate: @@ -31,9 +43,53 @@ spring: properties: hibernate: dialect: org.hibernate.dialect.MySQLDialect + + flyway: + enabled: true + validate-on-migrate: false + + security: + jwt: + secret-key: ${JWT_SECRET} + expiration: ${JWT_EXPIRATION} # 1 day + refresh-token: + expiration: ${REFRESH_TOKEN_EXPIRATION} # 7 days + oauth2: + resourceserver: + opaquetoken: + introspection-uri: https://www.googleapis.com + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + + mail: + host: smtp.gmail.com + port: 465 + protocol: smtps + username: ${GMAIL_USERNAME} + password: ${GMAIL_PASSWORD} + properties: + mail: + transport: + protocol: smtps + smtps: + auth: true + starttls: + enable: true + timeout: 8000 + + ai: + openai: + api-key: ${OPENAI_API_KEY} + assistant-id: ${ASSISTANT_ID} + positiveAssistant-id: ${POSITIVE_ASSISTANT_ID} + fileAssistant-id: ${FILE_ASSISTANT_ID} + +application: + upload: + directory: server/src/main/resources/static/uploads/ + springdoc: api-docs: path: /apidoc swagger-ui: path: /swagger-ui/index.html - diff --git a/server/src/main/resources/rate-limiter.yaml b/server/src/main/resources/rate-limiter.yaml new file mode 100644 index 0000000..09c52db --- /dev/null +++ b/server/src/main/resources/rate-limiter.yaml @@ -0,0 +1,11 @@ +resilience4j: + rate-limiter: + instances: + general_api_rate_limiter: + limitForPeriod: 100 # requests + limitRefreshPeriod: 30m # refresh period + timeoutDuration: 0 # no timeout + sensitive_operations_rate_limiter: + limitForPeriod: 10 + limitRefreshPeriod: 1m + timeoutDuration: 0 \ No newline at end of file