diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9da0d1b..a830910 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,10 @@ jobs: run: chmod +x gradlew - name: Build project + env: + JWT_SECRET: ${{ secrets.JWT_SECRET }} + JWT_SHORT: ${{ secrets.JWT_SHORT }} + JWT_LONG: ${{ secrets.JWT_LONG }} run: ./gradlew bootJar test: @@ -50,6 +54,10 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew + - name: Verify test resources + run: ls src/test/resources + + - name: Run tests run: ./gradlew test diff --git a/.gitignore b/.gitignore index c2065bc..821cea6 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ out/ ### VS Code ### .vscode/ + +### yml ### +src/main/resources/jwt.yml +src/main/resources/test.yml diff --git a/build.gradle b/build.gradle index 65c3864..2251581 100644 --- a/build.gradle +++ b/build.gradle @@ -27,6 +27,8 @@ checkstyle { dependencies { // spring implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-security' // db implementation 'org.springframework.boot:spring-boot-starter-data-jpa' @@ -37,6 +39,11 @@ dependencies { implementation 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + // jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.6' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.6' + // test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.testcontainers:junit-jupiter:1.20.1' diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index b16eff8..16d5967 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -17,11 +17,14 @@ services: depends_on: - db environment: + - SPRING_PROFILES_ACTIVE=prod - POSTGRES_CONTAINER_NAME=db - POSTGRES_PORT=5432 - POSTGRES_USER=postgres - POSTGRES_PASSWORD=123 - POSTGRES_DB=CodeforcesLocalDB - - SPRING_PROFILES_ACTIVE=prod + - JWT_SECRET=mysuperSecretPasswordformysuperbestAppwithsuperloginandBisness123Good2wow3magic1 + - JWT_SHORT=10m + - JWT_LONG=30d volumes: db_data: \ No newline at end of file diff --git a/src/main/java/com/cf/cfteam/advicers/security/SecurityExceptionHandler.java b/src/main/java/com/cf/cfteam/advicers/security/SecurityExceptionHandler.java new file mode 100644 index 0000000..256a2fc --- /dev/null +++ b/src/main/java/com/cf/cfteam/advicers/security/SecurityExceptionHandler.java @@ -0,0 +1,61 @@ +package com.cf.cfteam.advicers.security; + +import com.cf.cfteam.exceptions.security.InvalidTwoFactorCodeException; +import com.cf.cfteam.exceptions.security.TokenRevokedException; +import com.cf.cfteam.exceptions.security.UserAlreadyRegisterException; +import com.cf.cfteam.exceptions.security.UserNotFoundException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +@ControllerAdvice +public class SecurityExceptionHandler { + + private static final String LOGIN = "login"; + private static final String TOKEN = "token"; + private static final String UNEXPECTED_ERROR = "unexpected.error"; + + @ExceptionHandler(UserAlreadyRegisterException.class) + public ResponseEntity handleUserAlreadyRegisterException(UserAlreadyRegisterException ex) { + return buildErrorResponse(ex.getMessage(), HttpStatus.CONFLICT, Map.of(LOGIN, ex.getLogin())); + } + + @ExceptionHandler(UserNotFoundException.class) + public ResponseEntity handleUserNotFoundException(UserNotFoundException ex) { + return buildErrorResponse(ex.getMessage(), HttpStatus.NOT_FOUND, Map.of(LOGIN, ex.getLogin())); + } + + @ExceptionHandler(TokenRevokedException.class) + public ResponseEntity handleTokenRevokedException(TokenRevokedException ex) { + return buildErrorResponse(ex.getMessage(), HttpStatus.UNAUTHORIZED, Map.of(TOKEN, ex.getToken())); + } + + @ExceptionHandler(InvalidTwoFactorCodeException.class) + public ResponseEntity handleInvalidTwoFactorCodeException(InvalidTwoFactorCodeException ex) { + return buildErrorResponse(ex.getMessage(), HttpStatus.BAD_REQUEST, null); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException(Exception ex) { + return buildErrorResponse(UNEXPECTED_ERROR, HttpStatus.INTERNAL_SERVER_ERROR, null); + } + + private ResponseEntity buildErrorResponse(String message, HttpStatus status, Map details) { + Map errorResponse = new HashMap<>(); + errorResponse.put("timestamp", LocalDateTime.now()); + errorResponse.put("status", status.value()); + errorResponse.put("error", status.getReasonPhrase()); + errorResponse.put("message", message); + + if (details != null) { + errorResponse.put("details", details); + } + + return new ResponseEntity<>(errorResponse, status); + } +} diff --git a/src/main/java/com/cf/cfteam/auth/JwtAuthenticationFilter.java b/src/main/java/com/cf/cfteam/auth/JwtAuthenticationFilter.java new file mode 100644 index 0000000..d87de17 --- /dev/null +++ b/src/main/java/com/cf/cfteam/auth/JwtAuthenticationFilter.java @@ -0,0 +1,67 @@ +package com.cf.cfteam.auth; + +import com.cf.cfteam.exceptions.security.TokenRevokedException; +import com.cf.cfteam.services.security.JwtService; +import com.cf.cfteam.services.security.TokenService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + + +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + public static final String BEARER_PREFIX = "Bearer "; + + private final JwtService jwtService; + private final TokenService tokenService; + + private final UserDetailsService userDetailsService; + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) + throws ServletException, IOException, TokenRevokedException { + var authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + + if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) { + filterChain.doFilter(request, response); + return; + } + + var jwt = authHeader.substring(BEARER_PREFIX.length()); + + if (tokenService.isTokenRevoked(jwt)) throw new TokenRevokedException(jwt); + + var userLogin = jwtService.extractUserLogin(jwt); + + var userDetails = userDetailsService.loadUserByUsername(userLogin); + + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken(userDetails, + userDetails.getPassword(), + userDetails.getAuthorities()); + + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + if (SecurityContextHolder.getContext().getAuthentication() == null) { + SecurityContextHolder.getContext().setAuthentication(authToken); + } + + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/com/cf/cfteam/config/AppConfig.java b/src/main/java/com/cf/cfteam/config/AppConfig.java new file mode 100644 index 0000000..9f1750e --- /dev/null +++ b/src/main/java/com/cf/cfteam/config/AppConfig.java @@ -0,0 +1,44 @@ +package com.cf.cfteam.config; + +import com.cf.cfteam.services.security.MyUserDetailsService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +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; + + +@Configuration +@RequiredArgsConstructor +public class AppConfig { + + private final MyUserDetailsService myUserDetailsService; + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + @Bean + public AuthenticationProvider authenticationProvider() { + var authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailsService()); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + @Bean + public UserDetailsService userDetailsService() { + return myUserDetailsService; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/cf/cfteam/config/SecurityConfig.java b/src/main/java/com/cf/cfteam/config/SecurityConfig.java new file mode 100644 index 0000000..4730e34 --- /dev/null +++ b/src/main/java/com/cf/cfteam/config/SecurityConfig.java @@ -0,0 +1,52 @@ +package com.cf.cfteam.config; + +import com.cf.cfteam.auth.JwtAuthenticationFilter; +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.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.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; + + +import java.util.List; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthFilter; + private final AuthenticationProvider authenticationProvider; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(request -> { + var corsConfiguration = new CorsConfiguration(); + corsConfiguration.setAllowedOriginPatterns(List.of("*")); + corsConfiguration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + corsConfiguration.setAllowedHeaders(List.of("*")); + corsConfiguration.setAllowCredentials(true); + return corsConfiguration; + })) + .authorizeHttpRequests(request -> request + .requestMatchers("/auth/register", "/auth/login").permitAll() +// .requestMatchers("/**").hasRole("User") + .anyRequest().authenticated()) + .sessionManagement(sessionManagementConfigurer -> + sessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authenticationProvider(authenticationProvider) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/cf/cfteam/controllers/security/UserController.java b/src/main/java/com/cf/cfteam/controllers/security/UserController.java new file mode 100644 index 0000000..6b691d5 --- /dev/null +++ b/src/main/java/com/cf/cfteam/controllers/security/UserController.java @@ -0,0 +1,45 @@ +package com.cf.cfteam.controllers.security; + +import com.cf.cfteam.services.security.AuthenticationService; +import com.cf.cfteam.transfer.payloads.security.AuthenticationPayload; +import com.cf.cfteam.transfer.payloads.security.ChangePasswordPayload; +import com.cf.cfteam.transfer.payloads.security.RegistrationPayload; +import com.cf.cfteam.transfer.responses.security.JwtAuthenticationResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth") +public class UserController { + + private final AuthenticationService authenticationService; + + @PostMapping("/register") + public JwtAuthenticationResponse register(@RequestBody RegistrationPayload registrationRequest) { + return authenticationService.register(registrationRequest); + } + + @PostMapping("/login") + public JwtAuthenticationResponse login(@RequestBody AuthenticationPayload authenticationPayload, + Authentication authentication) { + return authenticationService.login(authenticationPayload); + } + + @PostMapping("/logout") + public ResponseEntity logout(Authentication authentication) { + authenticationService.logout(authentication); + return ResponseEntity.ok().build(); + } + + @PatchMapping("change-password") + public ResponseEntity changePassword( + @RequestBody ChangePasswordPayload changePasswordRequest, + Authentication authentication + ) { + authenticationService.changePassword(changePasswordRequest, authentication); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/cf/cfteam/exceptions/security/InvalidTwoFactorCodeException.java b/src/main/java/com/cf/cfteam/exceptions/security/InvalidTwoFactorCodeException.java new file mode 100644 index 0000000..dc9e29f --- /dev/null +++ b/src/main/java/com/cf/cfteam/exceptions/security/InvalidTwoFactorCodeException.java @@ -0,0 +1,7 @@ +package com.cf.cfteam.exceptions.security; + +public class InvalidTwoFactorCodeException extends RuntimeException { + public InvalidTwoFactorCodeException() { + super("two-factor.code_invalid"); + } +} \ No newline at end of file diff --git a/src/main/java/com/cf/cfteam/exceptions/security/TokenNotFoundException.java b/src/main/java/com/cf/cfteam/exceptions/security/TokenNotFoundException.java new file mode 100644 index 0000000..27d8c1f --- /dev/null +++ b/src/main/java/com/cf/cfteam/exceptions/security/TokenNotFoundException.java @@ -0,0 +1,13 @@ +package com.cf.cfteam.exceptions.security; + +import lombok.Getter; + +@Getter +public class TokenNotFoundException extends RuntimeException { + private final String token; + + public TokenNotFoundException(String token) { + super("token.not_found"); + this.token = token; + } +} diff --git a/src/main/java/com/cf/cfteam/exceptions/security/TokenRevokedException.java b/src/main/java/com/cf/cfteam/exceptions/security/TokenRevokedException.java new file mode 100644 index 0000000..feac45f --- /dev/null +++ b/src/main/java/com/cf/cfteam/exceptions/security/TokenRevokedException.java @@ -0,0 +1,14 @@ +package com.cf.cfteam.exceptions.security; + +import lombok.Getter; + +@Getter +public class TokenRevokedException extends RuntimeException { + + private final String token; + + public TokenRevokedException(String token) { + super("token.is_revoked"); + this.token = token; + } +} \ No newline at end of file diff --git a/src/main/java/com/cf/cfteam/exceptions/security/UserAlreadyRegisterException.java b/src/main/java/com/cf/cfteam/exceptions/security/UserAlreadyRegisterException.java new file mode 100644 index 0000000..976a084 --- /dev/null +++ b/src/main/java/com/cf/cfteam/exceptions/security/UserAlreadyRegisterException.java @@ -0,0 +1,14 @@ +package com.cf.cfteam.exceptions.security; + +import lombok.Getter; + +@Getter +public class UserAlreadyRegisterException extends RuntimeException { + + private final String login; + + public UserAlreadyRegisterException(String login) { + super("login.already_register"); + this.login = login; + } +} \ No newline at end of file diff --git a/src/main/java/com/cf/cfteam/exceptions/security/UserNotFoundException.java b/src/main/java/com/cf/cfteam/exceptions/security/UserNotFoundException.java new file mode 100644 index 0000000..a7b6510 --- /dev/null +++ b/src/main/java/com/cf/cfteam/exceptions/security/UserNotFoundException.java @@ -0,0 +1,14 @@ +package com.cf.cfteam.exceptions.security; + +import lombok.Getter; + +@Getter +public class UserNotFoundException extends RuntimeException { + + private final String login; + + public UserNotFoundException(String login) { + super("login.not_found"); + this.login = login; + } +} \ No newline at end of file diff --git a/src/main/java/com/cf/cfteam/models/entities/codeforces/CfUser.java b/src/main/java/com/cf/cfteam/models/entities/codeforces/CfUser.java index aaf2edb..248a39c 100644 --- a/src/main/java/com/cf/cfteam/models/entities/codeforces/CfUser.java +++ b/src/main/java/com/cf/cfteam/models/entities/codeforces/CfUser.java @@ -3,7 +3,6 @@ import jakarta.persistence.*; import lombok.*; -import java.time.Instant; @Builder @Setter @@ -25,9 +24,6 @@ public class CfUser { @Column(name = "c_description", nullable = true) private String description; - @Column(name = "с_time", nullable = false) - private Instant createdTime; - @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "c_group_id", nullable = false) private CfUsersGroup group; diff --git a/src/main/java/com/cf/cfteam/models/entities/codeforces/CfUsersGroup.java b/src/main/java/com/cf/cfteam/models/entities/codeforces/CfUsersGroup.java index 325568a..7ae6f56 100644 --- a/src/main/java/com/cf/cfteam/models/entities/codeforces/CfUsersGroup.java +++ b/src/main/java/com/cf/cfteam/models/entities/codeforces/CfUsersGroup.java @@ -4,7 +4,6 @@ import jakarta.persistence.*; import lombok.*; -import java.time.Instant; import java.util.List; @Builder @@ -27,9 +26,6 @@ public class CfUsersGroup { @Column(name = "c_description", nullable = true) private String description; - @Column(name = "с_time", nullable = false) - private Instant createdTime; - @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "c_user_id", nullable = false) private User user; diff --git a/src/main/java/com/cf/cfteam/models/entities/codeforces/CfUsersTeam.java b/src/main/java/com/cf/cfteam/models/entities/codeforces/CfUsersTeam.java index 3f59b78..321a9a3 100644 --- a/src/main/java/com/cf/cfteam/models/entities/codeforces/CfUsersTeam.java +++ b/src/main/java/com/cf/cfteam/models/entities/codeforces/CfUsersTeam.java @@ -3,8 +3,6 @@ import jakarta.persistence.*; import lombok.*; -import java.time.Instant; - @Builder @Setter @Getter @@ -34,9 +32,6 @@ public class CfUsersTeam { @Column(name = "c_third_user_login", nullable = true) private String thirdUser; - @Column(name = "с_time", nullable = false) - private Instant createdTime; - @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "c_group_id", nullable = false) private CfUsersTeamsGroup group; diff --git a/src/main/java/com/cf/cfteam/models/entities/codeforces/CfUsersTeamsGroup.java b/src/main/java/com/cf/cfteam/models/entities/codeforces/CfUsersTeamsGroup.java index 5761e6e..9f06d8c 100644 --- a/src/main/java/com/cf/cfteam/models/entities/codeforces/CfUsersTeamsGroup.java +++ b/src/main/java/com/cf/cfteam/models/entities/codeforces/CfUsersTeamsGroup.java @@ -4,7 +4,6 @@ import jakarta.persistence.*; import lombok.*; -import java.time.Instant; import java.util.List; @Builder @@ -27,9 +26,6 @@ public class CfUsersTeamsGroup { @Column(name = "c_description", nullable = true) private String description; - @Column(name = "с_time", nullable = false) - private Instant createdTime; - @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "c_user_id", nullable = false) private User user; diff --git a/src/main/java/com/cf/cfteam/models/entities/security/Role.java b/src/main/java/com/cf/cfteam/models/entities/security/Role.java index fc2a89b..e43de12 100644 --- a/src/main/java/com/cf/cfteam/models/entities/security/Role.java +++ b/src/main/java/com/cf/cfteam/models/entities/security/Role.java @@ -1,6 +1,5 @@ package com.cf.cfteam.models.entities.security; public enum Role { - USER, - ADMIN + USER } \ No newline at end of file diff --git a/src/main/java/com/cf/cfteam/models/entities/security/Token.java b/src/main/java/com/cf/cfteam/models/entities/security/Token.java index f448482..1edf3d9 100644 --- a/src/main/java/com/cf/cfteam/models/entities/security/Token.java +++ b/src/main/java/com/cf/cfteam/models/entities/security/Token.java @@ -3,8 +3,6 @@ import jakarta.persistence.*; import lombok.*; -import java.time.Instant; - @Builder @Setter @Getter @@ -25,9 +23,6 @@ public class Token { @Column(name = "c_revoked", nullable = false) private boolean revoked; - @Column(name = "с_time", nullable = false) - private Instant createdTime; - @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "c_user_id", nullable = false) private User user; diff --git a/src/main/java/com/cf/cfteam/models/entities/security/User.java b/src/main/java/com/cf/cfteam/models/entities/security/User.java index e788ad5..739326d 100644 --- a/src/main/java/com/cf/cfteam/models/entities/security/User.java +++ b/src/main/java/com/cf/cfteam/models/entities/security/User.java @@ -3,7 +3,6 @@ import jakarta.persistence.*; import lombok.*; -import java.time.Instant; import java.util.List; @Builder @@ -33,9 +32,6 @@ public class User { @Column(name = "c_role", nullable = false) private Role role; - @Column(name = "с_time", nullable = false) - private Instant createdTime; - @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List tokens; } diff --git a/src/main/java/com/cf/cfteam/models/entities/security/UserDetails.java b/src/main/java/com/cf/cfteam/models/entities/security/UserDetails.java new file mode 100644 index 0000000..e6722c7 --- /dev/null +++ b/src/main/java/com/cf/cfteam/models/entities/security/UserDetails.java @@ -0,0 +1,31 @@ +package com.cf.cfteam.models.entities.security; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.Collection; +import java.util.List; + +@RequiredArgsConstructor +@Getter +public class UserDetails implements org.springframework.security.core.userdetails.UserDetails { + + private final User user; + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority(user.getRole().name())); + } + + @Override + public String getPassword() { + return user.getHashedPassword(); + } + + @Override + public String getUsername() { + return user.getLogin(); + } +} \ No newline at end of file diff --git a/src/main/java/com/cf/cfteam/services/security/AuthenticationService.java b/src/main/java/com/cf/cfteam/services/security/AuthenticationService.java new file mode 100644 index 0000000..e697d8c --- /dev/null +++ b/src/main/java/com/cf/cfteam/services/security/AuthenticationService.java @@ -0,0 +1,105 @@ +package com.cf.cfteam.services.security; + +import com.cf.cfteam.exceptions.security.InvalidTwoFactorCodeException; +import com.cf.cfteam.exceptions.security.UserAlreadyRegisterException; +import com.cf.cfteam.exceptions.security.UserNotFoundException; +import com.cf.cfteam.models.entities.security.Role; +import com.cf.cfteam.models.entities.security.Token; +import com.cf.cfteam.models.entities.security.User; +import com.cf.cfteam.models.entities.security.UserDetails; +import com.cf.cfteam.repositories.jpa.security.TokenRepository; +import com.cf.cfteam.repositories.jpa.security.UserRepository; +import com.cf.cfteam.transfer.payloads.security.AuthenticationPayload; +import com.cf.cfteam.transfer.payloads.security.ChangePasswordPayload; +import com.cf.cfteam.transfer.payloads.security.RegistrationPayload; +import com.cf.cfteam.transfer.responses.security.JwtAuthenticationResponse; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AuthenticationService { + + private final UserRepository userRepository; + private final TokenRepository tokenRepository; + private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; + private final AuthenticationManager authenticationManager; + + public JwtAuthenticationResponse register(@NotNull RegistrationPayload registrationRequest) { + userRepository.findByLogin(registrationRequest.login()) + .ifPresent(user -> { + throw new UserAlreadyRegisterException(user.getLogin()); + }); + + User user = User.builder() + .name(registrationRequest.name()) + .login(registrationRequest.login()) + .role(Role.USER) + .hashedPassword(passwordEncoder.encode(registrationRequest.password())) + .build(); + + String jwtToken = jwtService.generateToken(new UserDetails(user), false); + + Token token = Token.builder() + .token(jwtToken) + .user(user) + .build(); + + userRepository.save(user); + tokenRepository.save(token); + + return new JwtAuthenticationResponse(jwtToken); + } + + public JwtAuthenticationResponse login(@NotNull AuthenticationPayload authenticationPayload) { + authenticationManager.authenticate(new UsernamePasswordAuthenticationToken( + authenticationPayload.login(), + authenticationPayload.password() + )); + + var user = userRepository.findByLogin(authenticationPayload.login()) + .orElseThrow(() -> new UserNotFoundException(authenticationPayload.login())); + + var tokens = tokenRepository.findAllByUserAndRevoked(user, false); + tokens.forEach(token -> token.setRevoked(true)); + tokenRepository.saveAll(tokens); + + String jwtToken = jwtService.generateToken(new UserDetails(user), authenticationPayload.rememberMe()); + + Token token = Token.builder() + .token(jwtToken) + .user(user) + .build(); + tokenRepository.save(token); + + return new JwtAuthenticationResponse(jwtToken); + } + + public void logout(@NotNull Authentication authentication) { + var userDetails = (UserDetails) authentication.getPrincipal(); + var user = userDetails.getUser(); + + var tokens = tokenRepository.findAllByUserAndRevoked(user, false); + tokens.forEach(token -> token.setRevoked(true)); + tokenRepository.saveAll(tokens); + } + + public void changePassword(@NotNull ChangePasswordPayload changePasswordPayload, + @NotNull Authentication authentication) { + if (!changePasswordPayload.twoFactorCode().equals("0000")) { + throw new InvalidTwoFactorCodeException(); + } + + var userDetails = (UserDetails) authentication.getPrincipal(); + var user = userDetails.getUser(); + + user.setHashedPassword(passwordEncoder.encode(changePasswordPayload.newPassword())); + userRepository.save(user); + } +} diff --git a/src/main/java/com/cf/cfteam/services/security/JwtService.java b/src/main/java/com/cf/cfteam/services/security/JwtService.java new file mode 100644 index 0000000..35c42df --- /dev/null +++ b/src/main/java/com/cf/cfteam/services/security/JwtService.java @@ -0,0 +1,65 @@ +package com.cf.cfteam.services.security; + +import com.cf.cfteam.models.entities.security.UserDetails; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.time.Duration; +import java.util.Date; +import java.util.Map; +import java.util.function.Function; + +@Service +public class JwtService { + + @Value("${jwt.short}") + private Duration shortTerm; + + @Value("${jwt.long}") + private Duration longTerm; + + @Value("${jwt.secret}") + private String jwtSigningKey; + + public String generateToken(UserDetails userDetails, boolean rememberMe) { + return generateToken(Map.of(), userDetails, rememberMe); + } + + private String generateToken(Map extraClaims, UserDetails userDetails, boolean rememberMe) { + var tokenDuration = rememberMe ? longTerm : shortTerm; + return Jwts.builder() + .claims(extraClaims) + .subject(userDetails.getUsername()) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + tokenDuration.toMillis())) + .signWith(getSigningKey(), Jwts.SIG.HS256) + .compact(); + } + + private SecretKey getSigningKey() { + byte[] keyBytes = Decoders.BASE64.decode(jwtSigningKey); + return Keys.hmacShaKeyFor(keyBytes); + } + + public String extractUserLogin(String token) { + return extractClaim(token, Claims::getSubject); + } + + private T extractClaim(String token, Function claimsResolvers) { + final Claims claims = extractAllClaims(token); + return claimsResolvers.apply(claims); + } + + private Claims extractAllClaims(String token) { + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} diff --git a/src/main/java/com/cf/cfteam/services/security/MyUserDetailsService.java b/src/main/java/com/cf/cfteam/services/security/MyUserDetailsService.java new file mode 100644 index 0000000..13b3856 --- /dev/null +++ b/src/main/java/com/cf/cfteam/services/security/MyUserDetailsService.java @@ -0,0 +1,24 @@ +package com.cf.cfteam.services.security; + +import com.cf.cfteam.models.entities.security.User; +import com.cf.cfteam.repositories.jpa.security.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class MyUserDetailsService implements org.springframework.security.core.userdetails.UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String login) throws UsernameNotFoundException { + Optional user = userRepository.findByLogin(login); + if (user.isPresent()) return new com.cf.cfteam.models.entities.security.UserDetails(user.get()); + else throw new UsernameNotFoundException(login); + } +} \ No newline at end of file diff --git a/src/main/java/com/cf/cfteam/services/security/TokenService.java b/src/main/java/com/cf/cfteam/services/security/TokenService.java new file mode 100644 index 0000000..20c3d87 --- /dev/null +++ b/src/main/java/com/cf/cfteam/services/security/TokenService.java @@ -0,0 +1,19 @@ +package com.cf.cfteam.services.security; + +import com.cf.cfteam.exceptions.security.TokenNotFoundException; +import com.cf.cfteam.repositories.jpa.security.TokenRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class TokenService { + + private final TokenRepository tokenRepository; + + public boolean isTokenRevoked(String token) { + return tokenRepository.findByToken(token) + .orElseThrow(() -> new TokenNotFoundException(token)) + .isRevoked(); + } +} \ No newline at end of file diff --git a/src/main/java/com/cf/cfteam/transfer/payloads/security/AuthenticationPayload.java b/src/main/java/com/cf/cfteam/transfer/payloads/security/AuthenticationPayload.java new file mode 100644 index 0000000..2180235 --- /dev/null +++ b/src/main/java/com/cf/cfteam/transfer/payloads/security/AuthenticationPayload.java @@ -0,0 +1,11 @@ +package com.cf.cfteam.transfer.payloads.security; + +import lombok.Builder; + +@Builder +public record AuthenticationPayload( + String login, + String password, + boolean rememberMe +) { +} \ No newline at end of file diff --git a/src/main/java/com/cf/cfteam/transfer/payloads/security/ChangePasswordPayload.java b/src/main/java/com/cf/cfteam/transfer/payloads/security/ChangePasswordPayload.java new file mode 100644 index 0000000..d92db9b --- /dev/null +++ b/src/main/java/com/cf/cfteam/transfer/payloads/security/ChangePasswordPayload.java @@ -0,0 +1,10 @@ +package com.cf.cfteam.transfer.payloads.security; + +import lombok.Builder; + +@Builder +public record ChangePasswordPayload( + String newPassword, + String twoFactorCode +) { +} diff --git a/src/main/java/com/cf/cfteam/transfer/payloads/security/RegistrationPayload.java b/src/main/java/com/cf/cfteam/transfer/payloads/security/RegistrationPayload.java new file mode 100644 index 0000000..93d9102 --- /dev/null +++ b/src/main/java/com/cf/cfteam/transfer/payloads/security/RegistrationPayload.java @@ -0,0 +1,11 @@ +package com.cf.cfteam.transfer.payloads.security; + +import lombok.Builder; + +@Builder +public record RegistrationPayload( + String name, + String login, + String password +) { +} \ No newline at end of file diff --git a/src/main/java/com/cf/cfteam/transfer/responses/security/JwtAuthenticationResponse.java b/src/main/java/com/cf/cfteam/transfer/responses/security/JwtAuthenticationResponse.java new file mode 100644 index 0000000..21658e6 --- /dev/null +++ b/src/main/java/com/cf/cfteam/transfer/responses/security/JwtAuthenticationResponse.java @@ -0,0 +1,9 @@ +package com.cf.cfteam.transfer.responses.security; + +import lombok.Builder; + +@Builder +public record JwtAuthenticationResponse( + String token +) { +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d50fe62..3f1b622 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -6,5 +6,10 @@ spring: username: ${POSTGRES_USER:postgres} password: ${POSTGRES_PASSWORD:123} driver-class-name: org.postgresql.Driver + config: + import: jwt.yml profiles: - active: prod \ No newline at end of file + active: prod + + + diff --git a/src/main/resources/db/changelog/column-rename-changelog.sql b/src/main/resources/db/changelog/column-rename-changelog.sql new file mode 100644 index 0000000..dc8fcb2 --- /dev/null +++ b/src/main/resources/db/changelog/column-rename-changelog.sql @@ -0,0 +1,19 @@ +--liquibase formatted sql + +--changeset alexandergarifullin:6 +ALTER TABLE security.t_users RENAME COLUMN "с_time" TO "c_time"; + +--changeset alexandergarifullin:7 +ALTER TABLE security.t_tokens RENAME COLUMN "с_time" TO "c_time"; + +--changeset alexandergarifullin:8 +ALTER TABLE codeforces.t_cf_users_teams_groups RENAME COLUMN "с_time" TO "c_time"; + +--changeset alexandergarifullin:9 +ALTER TABLE codeforces.t_cf_teams RENAME COLUMN "с_time" TO "c_time"; + +--changeset alexandergarifullin:10 +ALTER TABLE codeforces.t_cf_users_groups RENAME COLUMN "с_time" TO "c_time"; + +--changeset alexandergarifullin:11 +ALTER TABLE codeforces.cf_users RENAME COLUMN "с_time" TO "c_time"; diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml index 59a4c39..e37ca76 100644 --- a/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -2,4 +2,12 @@ databaseChangeLog: - include: file: db/changelog/security-changelog.sql - include: - file: db/changelog/codeforces-changelog.sql \ No newline at end of file + file: db/changelog/codeforces-changelog.sql + - include: + file: db/changelog/column-rename-changelog.sql + - include: + file: db/changelog/remove-not-null-constrains.sql + - include: + file: db/changelog/set-ctime-default-values.sql + - include: + file: db/changelog/remove-ctime.sql \ No newline at end of file diff --git a/src/main/resources/db/changelog/db.changelog-test.yaml b/src/main/resources/db/changelog/db.changelog-test.yaml index 59a4c39..d404583 100644 --- a/src/main/resources/db/changelog/db.changelog-test.yaml +++ b/src/main/resources/db/changelog/db.changelog-test.yaml @@ -2,4 +2,14 @@ databaseChangeLog: - include: file: db/changelog/security-changelog.sql - include: - file: db/changelog/codeforces-changelog.sql \ No newline at end of file + file: db/changelog/codeforces-changelog.sql + - include: + file: db/changelog/column-rename-changelog.sql + - include: + file: db/changelog/remove-not-null-constrains.sql + - include: + file: db/changelog/set-ctime-default-values.sql + - include: + file: db/changelog/remove-ctime.sql + - include: + file: db/changelog/init-security-changelog.sql \ No newline at end of file diff --git a/src/main/resources/db/changelog/init-security-changelog.sql b/src/main/resources/db/changelog/init-security-changelog.sql new file mode 100644 index 0000000..261533b --- /dev/null +++ b/src/main/resources/db/changelog/init-security-changelog.sql @@ -0,0 +1,5 @@ +--liquibase formatted sql + +--changeset alexandergarifullin:1 +INSERT INTO security.t_users(c_name, c_login, c_hashed_password, c_role) +VALUES ('Test User', 'user', '$2a$10$kjWwIGpZ2EjGl5edbc9ceuNE5ceP3vdtiPqoAUgCdHtk4on97uvOC', 'USER'); diff --git a/src/main/resources/db/changelog/remove-ctime.sql b/src/main/resources/db/changelog/remove-ctime.sql new file mode 100644 index 0000000..2b8287a --- /dev/null +++ b/src/main/resources/db/changelog/remove-ctime.sql @@ -0,0 +1,19 @@ +--liquibase formatted sql + +--changeset alexandergarifullin:25 +ALTER TABLE security.t_users DROP COLUMN IF EXISTS c_time; + +--changeset alexandergarifullin:26 +ALTER TABLE security.t_tokens DROP COLUMN IF EXISTS c_time; + +--changeset alexandergarifullin:27 +ALTER TABLE codeforces.t_cf_users_teams_groups DROP COLUMN IF EXISTS c_time; + +--changeset alexandergarifullin:28 +ALTER TABLE codeforces.t_cf_teams DROP COLUMN IF EXISTS c_time; + +--changeset alexandergarifullin:29 +ALTER TABLE codeforces.t_cf_users_groups DROP COLUMN IF EXISTS c_time; + +--changeset alexandergarifullin:30 +ALTER TABLE codeforces.cf_users DROP COLUMN IF EXISTS c_time; diff --git a/src/main/resources/db/changelog/remove-not-null-constrains.sql b/src/main/resources/db/changelog/remove-not-null-constrains.sql new file mode 100644 index 0000000..db2251b --- /dev/null +++ b/src/main/resources/db/changelog/remove-not-null-constrains.sql @@ -0,0 +1,26 @@ +--liquibase formatted sql + +--changeset alexandergarifullin:12 +ALTER TABLE security.t_users + ALTER COLUMN c_time DROP NOT NULL; + +--changeset alexandergarifullin:13 +ALTER TABLE security.t_tokens + ALTER COLUMN c_time DROP NOT NULL; + +--changeset alexandergarifullin:14 +ALTER TABLE codeforces.t_cf_users_teams_groups + ALTER COLUMN c_time DROP NOT NULL; + +--changeset alexandergarifullin:15 +ALTER TABLE codeforces.t_cf_teams + ALTER COLUMN c_time DROP NOT NULL; + +--changeset alexandergarifullin:16 +ALTER TABLE codeforces.t_cf_users_groups + ALTER COLUMN c_time DROP NOT NULL; + +--changeset alexandergarifullin:17 +ALTER TABLE codeforces.cf_users + ALTER COLUMN c_time DROP NOT NULL; + diff --git a/src/main/resources/db/changelog/set-ctime-default-values.sql b/src/main/resources/db/changelog/set-ctime-default-values.sql new file mode 100644 index 0000000..4580c84 --- /dev/null +++ b/src/main/resources/db/changelog/set-ctime-default-values.sql @@ -0,0 +1,20 @@ +--liquibase formatted sql + +--changeset alexandergarifullin:19 +ALTER TABLE security.t_users ALTER COLUMN c_time SET DEFAULT CURRENT_TIMESTAMP; + +--changeset alexandergarifullin:20 +ALTER TABLE security.t_tokens ALTER COLUMN c_time SET DEFAULT CURRENT_TIMESTAMP; + +--changeset alexandergarifullin:21 +ALTER TABLE codeforces.t_cf_users_teams_groups ALTER COLUMN c_time SET DEFAULT CURRENT_TIMESTAMP; + +--changeset alexandergarifullin:22 +ALTER TABLE codeforces.t_cf_teams ALTER COLUMN c_time SET DEFAULT CURRENT_TIMESTAMP; + +--changeset alexandergarifullin:23 +ALTER TABLE codeforces.t_cf_users_groups ALTER COLUMN c_time SET DEFAULT CURRENT_TIMESTAMP; + +--changeset alexandergarifullin:24 +ALTER TABLE codeforces.cf_users ALTER COLUMN c_time SET DEFAULT CURRENT_TIMESTAMP; + diff --git a/src/main/resources/jwt.yml b/src/main/resources/jwt.yml new file mode 100644 index 0000000..856f2ab --- /dev/null +++ b/src/main/resources/jwt.yml @@ -0,0 +1,4 @@ +jwt: + secret: ${JWT_SECRET:mysuperSecretPasswordformysuperbestAppwithsuperloginandBisness123Good2wow3magic1} + short: ${JWT_SHORT:10m} # Default value + long: ${JWT_LONG:30d} # Default value diff --git a/src/test/java/com/cf/cfteam/BaseIntegrationTest.java b/src/test/java/com/cf/cfteam/BaseIntegrationTest.java new file mode 100644 index 0000000..a050e0c --- /dev/null +++ b/src/test/java/com/cf/cfteam/BaseIntegrationTest.java @@ -0,0 +1,81 @@ +package com.cf.cfteam; + +import com.cf.cfteam.services.security.AuthenticationService; +import com.cf.cfteam.transfer.payloads.security.AuthenticationPayload; +import com.fasterxml.jackson.databind.ObjectMapper; +import liquibase.Contexts; +import liquibase.Liquibase; +import liquibase.database.Database; +import liquibase.database.DatabaseFactory; +import liquibase.database.jvm.JdbcConnection; +import liquibase.resource.ClassLoaderResourceAccessor; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.PostgreSQLContainer; + +import java.sql.Connection; +import java.sql.DriverManager; + +@AutoConfigureMockMvc +@SpringBootTest(classes = CfteamApplication.class) +@ActiveProfiles("test") +public abstract class BaseIntegrationTest { + + private static final PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:17") + .withUsername("postgres") + .withPassword("123") + .withDatabaseName("testdb"); + + @Autowired + protected MockMvc mockMvc; + + @Autowired + protected ObjectMapper objectMapper; + + @Autowired + protected AuthenticationService authenticationService; + + + protected static String userBearerToken; + + protected static final AuthenticationPayload userRequest = new AuthenticationPayload( + "user", + "password", + false + ); + + private static void runLiquibaseMigrations() throws Exception { + Connection connection = DriverManager.getConnection( + postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword()); + + Database database = DatabaseFactory.getInstance() + .findCorrectDatabaseImplementation(new JdbcConnection(connection)); + + String changeLogFile = System.getProperty("spring.liquibase.change-log", "db/changelog/db.changelog-test.yaml"); + + try (Liquibase liquibase = new Liquibase(changeLogFile, new ClassLoaderResourceAccessor(), database)) { + liquibase.update(new Contexts()); + } + } + + @DynamicPropertySource + static void postgresqlProperties(DynamicPropertyRegistry registry) throws Exception { + postgres.start(); + runLiquibaseMigrations(); + registry.add("spring.datasource.url", postgres::getJdbcUrl); + } + + + @BeforeEach + public void getToken() throws Exception { + if (userBearerToken == null) { + userBearerToken = "Bearer %s".formatted(authenticationService.login(userRequest).token()); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/cf/cfteam/controllers/security/UserControllerIntegrationTest.java b/src/test/java/com/cf/cfteam/controllers/security/UserControllerIntegrationTest.java new file mode 100644 index 0000000..31db666 --- /dev/null +++ b/src/test/java/com/cf/cfteam/controllers/security/UserControllerIntegrationTest.java @@ -0,0 +1,282 @@ +package com.cf.cfteam.controllers.security; + +import com.cf.cfteam.BaseIntegrationTest; +import com.cf.cfteam.models.entities.security.User; +import com.cf.cfteam.repositories.jpa.security.TokenRepository; +import com.cf.cfteam.repositories.jpa.security.UserRepository; +import com.cf.cfteam.transfer.payloads.security.AuthenticationPayload; +import com.cf.cfteam.transfer.payloads.security.ChangePasswordPayload; +import com.cf.cfteam.transfer.payloads.security.RegistrationPayload; +import com.cf.cfteam.transfer.responses.security.JwtAuthenticationResponse; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; + + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ActiveProfiles("test") +public class UserControllerIntegrationTest extends BaseIntegrationTest { + + private static final String uri = "/auth"; + + @Autowired + private UserRepository userRepository; + + @Autowired + private TokenRepository tokenRepository; + + @Test + public void register_success() throws Exception { + RegistrationPayload payload = RegistrationPayload.builder() + .login("register-login") + .name("register-name") + .password("register-password") + .build(); + + var jwtResponse = register(payload); + + var user = userRepository.findByLogin("register-login"); + var token = tokenRepository.findByToken(jwtResponse.token()); + + assertAll( + () -> assertThat(jwtResponse.token()).isNotEmpty(), + + () -> assertThat(token).isPresent(), + () -> assertThat(token.get().isRevoked()).isFalse(), + + () -> assertThat(user).isPresent() + ); + + deleteUserFromDb(user.get()); + } + + @Test + public void register_shouldThrowUserAlreadyRegisterException_WhenUserAlreadyRegistered() throws Exception { + String login = "user"; + String password = "password"; + String name = "Test User"; + + RegistrationPayload payload = RegistrationPayload.builder() + .login(login) + .password(password) + .name(name) + .build(); + + String requestBody = objectMapper.writeValueAsString(payload); + + mockMvc.perform(post("/auth/register") + .contentType("application/json") + .content(requestBody)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error").value("Conflict")) + .andExpect(jsonPath("$.message").value("login.already_register")) + .andExpect(jsonPath("$.details.login").value(login)) + .andExpect(jsonPath("$.status").value(409)) + .andExpect(jsonPath("$.timestamp").exists()); + } + + @Test + public void login_success() throws Exception { + RegistrationPayload payloadToRegister = RegistrationPayload.builder() + .login("register-login") + .name("register-name") + .password("register-password") + .build(); + + var jwtResponse = register(payloadToRegister); + + var payloadToLogin = AuthenticationPayload.builder() + .login("register-login") + .password("register-password") + .rememberMe(true) + .build(); + + var response = mockMvc.perform(post(uri + "/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payloadToLogin))) + .andExpectAll( + status().isOk(), + content().contentType(MediaType.APPLICATION_JSON)) + .andReturn() + .getResponse(); + + var jwtFromLogin = objectMapper.readValue(response.getContentAsString(), JwtAuthenticationResponse.class); + + var oldToken = tokenRepository.findByToken(jwtResponse.token()); + var newToken = tokenRepository.findByToken(jwtFromLogin.token()); + + assertAll( + () -> assertThat(jwtFromLogin.token()).isNotNull(), + + () -> assertThat(jwtResponse.token()).isNotNull(), + () -> assertThat(jwtFromLogin.token()).isNotEqualTo(jwtResponse.token()), + + () -> assertThat(oldToken).isPresent(), + () -> assertThat(newToken).isPresent(), + + () -> assertThat(oldToken.get().isRevoked()).isTrue(), + () -> assertThat(newToken.get().isRevoked()).isFalse(), + + () -> assertThat(oldToken.get()).isNotEqualTo(newToken.get()) + ); + + deleteUserFromDb(userRepository.findByLogin("register-login").get()); + } + + @Test + public void logout_success() throws Exception { + RegistrationPayload payloadToRegister = RegistrationPayload.builder() + .login("register-login") + .name("register-name") + .password("register-password") + .build(); + + var jwtResponse = register(payloadToRegister); + + mockMvc.perform(post(uri + "/logout") + .header("Authorization", "Bearer %s".formatted(jwtResponse.token()))) + .andExpectAll( + status().isOk()) + .andReturn() + .getResponse(); + + var oldToken = tokenRepository.findByToken(jwtResponse.token()); + + assertAll( + () -> assertThat(oldToken).isPresent(), + () -> assertThat(oldToken.get().isRevoked()).isTrue() + ); + + deleteUserFromDb(userRepository.findByLogin("register-login").get()); + } + + @Test + public void changePassword_success() throws Exception { + RegistrationPayload payloadToRegister = RegistrationPayload.builder() + .login("register-login") + .name("register-name") + .password("register-password") + .build(); + + var jwtResponse = register(payloadToRegister); + + var oldUser = userRepository.findByLogin("register-login"); + + var changePasswordPayload = ChangePasswordPayload.builder() + .newPassword("new-password") + .twoFactorCode("0000") + .build(); + + mockMvc.perform(patch(uri + "/change-password") + .header("Authorization", "Bearer %s".formatted(jwtResponse.token())) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(changePasswordPayload))) + .andExpectAll( + status().isOk()) + .andReturn() + .getResponse(); + + var newUser = userRepository.findByLogin("register-login"); + + assertAll( + () -> assertThat(oldUser).isPresent(), + () -> assertThat(newUser).isPresent(), + + () -> assertThat(oldUser.get()).isNotEqualTo(newUser.get()), + () -> assertThat(oldUser.get().getHashedPassword()).isNotEqualTo(newUser.get().getHashedPassword()) + ); + + var payloadToLogin = AuthenticationPayload.builder() + .login("register-login") + .password("new-password") + .rememberMe(true) + .build(); + + var loginResponse = mockMvc.perform(post(uri + "/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payloadToLogin))) + .andExpectAll( + status().isOk(), + content().contentType(MediaType.APPLICATION_JSON)) + .andReturn() + .getResponse(); + + var jwtFromLogin = objectMapper.readValue(loginResponse.getContentAsString(), JwtAuthenticationResponse.class); + + var loginUser = userRepository.findByLogin("register-login"); + var loginToken= tokenRepository.findByToken(jwtFromLogin.token()); + var oldToken= tokenRepository.findByToken(jwtResponse.token()); + + assertAll( + () -> assertThat(jwtFromLogin.token()).isNotEmpty(), + + () -> assertThat(loginToken).isPresent(), + () -> assertThat(oldToken).isPresent(), + + () -> assertThat(loginToken.get().isRevoked()).isFalse(), + () -> assertThat(oldToken.get().isRevoked()).isTrue(), + + () -> assertThat(loginToken.get()).isNotEqualTo(oldToken), + + () -> assertThat(loginUser).isPresent(), + () -> assertThat(loginUser.get()).isEqualTo(newUser.get()) + ); + + deleteUserFromDb(loginUser.get()); + } + + @Test + public void changePassword_shouldReturnBadRequest_WhenTwoFactorCodeInvalid() throws Exception { + RegistrationPayload payloadToRegister = RegistrationPayload.builder() + .login("register-login") + .name("register-name") + .password("register-password") + .build(); + + var jwtResponse = register(payloadToRegister); + var user = userRepository.findByLogin("register-login"); + + + ChangePasswordPayload payload = ChangePasswordPayload.builder() + .newPassword("new-password") + .twoFactorCode("invalid-code") + .build(); + + mockMvc.perform(patch(uri + "/change-password") + .header("Authorization", "Bearer %s".formatted(jwtResponse.token())) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Bad Request")) + .andExpect(jsonPath("$.message").value("two-factor.code_invalid")) + .andExpect(jsonPath("$.timestamp").exists()) + .andExpect(jsonPath("$.status").value(400)); + + + deleteUserFromDb(user.get()); + } + + + private JwtAuthenticationResponse register(RegistrationPayload payload) throws Exception { + var response = mockMvc.perform(post(uri + "/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpectAll( + status().isOk(), + content().contentType(MediaType.APPLICATION_JSON)) + .andReturn() + .getResponse(); + + return objectMapper.readValue(response.getContentAsString(), JwtAuthenticationResponse.class); + } + + private void deleteUserFromDb(User user) { + userRepository.delete(user); + } +} diff --git a/src/test/java/com/cf/cfteam/controllers/security/UserControllerTest.java b/src/test/java/com/cf/cfteam/controllers/security/UserControllerTest.java new file mode 100644 index 0000000..fea4bc8 --- /dev/null +++ b/src/test/java/com/cf/cfteam/controllers/security/UserControllerTest.java @@ -0,0 +1,93 @@ +package com.cf.cfteam.controllers.security; + + +import com.cf.cfteam.services.security.AuthenticationService; +import com.cf.cfteam.transfer.payloads.security.AuthenticationPayload; +import com.cf.cfteam.transfer.payloads.security.ChangePasswordPayload; +import com.cf.cfteam.transfer.payloads.security.RegistrationPayload; +import com.cf.cfteam.transfer.responses.security.JwtAuthenticationResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.security.core.Authentication; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ActiveProfiles("test") +class UserControllerTest { + + @InjectMocks + private UserController userController; + + @Mock + private AuthenticationService authenticationService; + + @Mock + private Authentication authentication; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void testRegister_success() { + RegistrationPayload registrationPayload = getRegistrationPayload(); + JwtAuthenticationResponse expectedResponse = getJwtAuthenticationResponse(); + when(authenticationService.register(registrationPayload)).thenReturn(expectedResponse); + + JwtAuthenticationResponse response = userController.register(registrationPayload); + + assertThat(response).isEqualTo(expectedResponse); + verify(authenticationService).register(registrationPayload); + } + + @Test + void testLogin_success() { + AuthenticationPayload authenticationPayload = getAuthenticationPayload(); + JwtAuthenticationResponse expectedResponse = getJwtAuthenticationResponse(); + when(authenticationService.login(authenticationPayload)).thenReturn(expectedResponse); + + JwtAuthenticationResponse response = userController.login(authenticationPayload, authentication); + + assertThat(response).isEqualTo(expectedResponse); + verify(authenticationService).login(authenticationPayload); + } + + @Test + void testLogout_success() { + userController.logout(authentication); + + verify(authenticationService).logout(authentication); + } + + @Test + void testChangePassword_success() { + ChangePasswordPayload changePasswordPayload = getChangePasswordPayload(); + + userController.changePassword(changePasswordPayload, authentication); + + verify(authenticationService).changePassword(changePasswordPayload, authentication); + } + + private RegistrationPayload getRegistrationPayload() { + return new RegistrationPayload("Test User", "test_login", "test_password"); + } + + private JwtAuthenticationResponse getJwtAuthenticationResponse() { + return new JwtAuthenticationResponse("mockJwtToken"); + } + + private AuthenticationPayload getAuthenticationPayload() { + return new AuthenticationPayload("test_login", "test_password", true); + } + + private ChangePasswordPayload getChangePasswordPayload() { + return new ChangePasswordPayload("0000", "new_password"); + } +} \ No newline at end of file diff --git a/src/test/java/com/cf/cfteam/services/security/AuthenticationServiceTest.java b/src/test/java/com/cf/cfteam/services/security/AuthenticationServiceTest.java new file mode 100644 index 0000000..7106988 --- /dev/null +++ b/src/test/java/com/cf/cfteam/services/security/AuthenticationServiceTest.java @@ -0,0 +1,169 @@ +package com.cf.cfteam.services.security; + +import com.cf.cfteam.exceptions.security.InvalidTwoFactorCodeException; +import com.cf.cfteam.exceptions.security.UserAlreadyRegisterException; +import com.cf.cfteam.exceptions.security.UserNotFoundException; +import com.cf.cfteam.models.entities.security.Token; +import com.cf.cfteam.models.entities.security.User; +import com.cf.cfteam.models.entities.security.UserDetails; +import com.cf.cfteam.repositories.jpa.security.TokenRepository; +import com.cf.cfteam.repositories.jpa.security.UserRepository; +import com.cf.cfteam.transfer.payloads.security.AuthenticationPayload; +import com.cf.cfteam.transfer.payloads.security.ChangePasswordPayload; +import com.cf.cfteam.transfer.payloads.security.RegistrationPayload; +import com.cf.cfteam.transfer.responses.security.JwtAuthenticationResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@ActiveProfiles("test") +class AuthenticationServiceTest { + + @InjectMocks + private AuthenticationService authenticationService; + + @Mock + private UserRepository userRepository; + + @Mock + private TokenRepository tokenRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @Mock + private JwtService jwtService; + + @Mock + private AuthenticationManager authenticationManager; + + @Mock + private Authentication authentication; + + @Mock + private UserDetails userDetails; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void register_shouldThrowException_WhenUserAlreadyRegistered() { + String login = "testLogin"; + RegistrationPayload payload = getRegistrationPayload(login); + + User existingUser = new User(); + existingUser.setLogin(login); + when(userRepository.findByLogin(login)).thenReturn(Optional.of(existingUser)); + + assertThatThrownBy(() -> authenticationService.register(payload)) + .isInstanceOf(UserAlreadyRegisterException.class) + .hasMessageContaining("login.already_register"); + } + + @Test + void register_shouldReturnJwtToken_WhenRegistrationIsSuccessful() { + String login = "testLogin"; + RegistrationPayload payload = getRegistrationPayload(login); + + User user = new User(); + user.setLogin(login); + when(userRepository.findByLogin(login)).thenReturn(java.util.Optional.empty()); + when(jwtService.generateToken(any(UserDetails.class), eq(false))).thenReturn("jwtToken"); + when(userRepository.save(any(User.class))).thenReturn(user); + + JwtAuthenticationResponse response = authenticationService.register(payload); + + assertThat(response.token()).isEqualTo("jwtToken"); + verify(userRepository, times(1)).save(any(User.class)); + verify(tokenRepository, times(1)).save(any(Token.class)); + } + + @Test + void login_shouldThrowException_WhenUserNotFound() { + AuthenticationPayload payload = getAuthenticationPayload(); + when(userRepository.findByLogin(payload.login())).thenReturn(java.util.Optional.empty()); + + assertThatThrownBy(() -> authenticationService.login(payload)) + .isInstanceOf(UserNotFoundException.class) + .hasMessageContaining("login.not_found"); + } + + @Test + void login_shouldReturnJwtToken_WhenLoginIsSuccessful() { + String login = "testLogin"; + AuthenticationPayload payload = new AuthenticationPayload(login, "password", false); + + User user = new User(); + user.setLogin(login); + when(userRepository.findByLogin(login)).thenReturn(java.util.Optional.of(user)); + when(jwtService.generateToken(any(UserDetails.class), eq(false))).thenReturn("jwtToken"); + when(tokenRepository.save(any(Token.class))).thenReturn(new Token()); + + JwtAuthenticationResponse response = authenticationService.login(payload); + + assertThat(response.token()).isEqualTo("jwtToken"); + verify(tokenRepository).save(any(Token.class)); + } + + @Test + void changePassword_shouldThrowException_WhenTwoFactorCodeIsInvalid() { + ChangePasswordPayload payload = getChangePasswordPayload("newPassword", "1234"); + + assertThatThrownBy(() -> authenticationService.changePassword(payload, authentication)) + .isInstanceOf(InvalidTwoFactorCodeException.class) + .hasMessageContaining("two-factor.code_invalid"); + } + + @Test + void changePassword_shouldUpdatePassword_WhenTwoFactorCodeIsValid() { + ChangePasswordPayload payload = new ChangePasswordPayload("newPassword", "0000" ); + Authentication authentication = mock(Authentication.class); + + User user = new User(); + when(authentication.getPrincipal()).thenReturn(userDetails); + when(userDetails.getUser()).thenReturn(user); + when(passwordEncoder.encode("newPassword")).thenReturn("encodedPassword"); + + authenticationService.changePassword(payload, authentication); + + assertThat(user.getHashedPassword()).isEqualTo("encodedPassword"); + verify(userRepository).save(user); + } + + @Test + void logout_shouldRevokeTokens_WhenUserLogsOut() { + User user = new User(); + when(authentication.getPrincipal()).thenReturn(userDetails); + when(userDetails.getUser()).thenReturn(user); + + authenticationService.logout(authentication); + + verify(tokenRepository).saveAll(any()); + } + + private RegistrationPayload getRegistrationPayload(String login) { + return new RegistrationPayload("Test User", login, "password"); + } + + private AuthenticationPayload getAuthenticationPayload() { + return new AuthenticationPayload("testLogin", "password", false); + } + + private ChangePasswordPayload getChangePasswordPayload(String newPassword, String twoFactorCode) { + return new ChangePasswordPayload(newPassword, twoFactorCode); + } +} \ No newline at end of file diff --git a/src/test/java/com/cf/cfteam/services/security/MyUserDetailsServiceTest.java b/src/test/java/com/cf/cfteam/services/security/MyUserDetailsServiceTest.java new file mode 100644 index 0000000..334b04c --- /dev/null +++ b/src/test/java/com/cf/cfteam/services/security/MyUserDetailsServiceTest.java @@ -0,0 +1,59 @@ +package com.cf.cfteam.services.security; + +import com.cf.cfteam.models.entities.security.User; +import com.cf.cfteam.repositories.jpa.security.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ActiveProfiles("test") +class MyUserDetailsServiceTest { + + @Mock + private UserRepository userRepository; + + @InjectMocks + private MyUserDetailsService userDetailsService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void loadUserByUsername_shouldLoadUserByUsername_WhenUserExists() { + String login = "testLogin"; + User user = new User(); + user.setLogin(login); + com.cf.cfteam.models.entities.security.UserDetails userDetails = new com.cf.cfteam.models.entities.security.UserDetails(user); + + when(userRepository.findByLogin(login)).thenReturn(Optional.of(user)); + + UserDetails result = userDetailsService.loadUserByUsername(login); + + assertThat(result).isNotNull(); + assertThat(result.getUsername()).isEqualTo(login); + } + + @Test + void loadUserByUsername_shouldThrowUsernameNotFoundException_WhenUserDoesNotExist() { + String login = "nonExistentLogin"; + + when(userRepository.findByLogin(login)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userDetailsService.loadUserByUsername(login)) + .isInstanceOf(UsernameNotFoundException.class) + .hasMessage(login); + } +} \ No newline at end of file diff --git a/src/test/java/com/cf/cfteam/services/security/TokenServiceTest.java b/src/test/java/com/cf/cfteam/services/security/TokenServiceTest.java new file mode 100644 index 0000000..bd46dd7 --- /dev/null +++ b/src/test/java/com/cf/cfteam/services/security/TokenServiceTest.java @@ -0,0 +1,71 @@ +package com.cf.cfteam.services.security; + + +import com.cf.cfteam.exceptions.security.TokenNotFoundException; +import com.cf.cfteam.models.entities.security.Token; +import com.cf.cfteam.repositories.jpa.security.TokenRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@ActiveProfiles("test") +class TokenServiceTest { + + @InjectMocks + private TokenService tokenService; + + @Mock + private TokenRepository tokenRepository; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void isTokenRevoked_shouldReturnTrue_whenTokenIsRevoked() { + String tokenValue = "valid-token"; + Token token = new Token(); + token.setRevoked(true); + when(tokenRepository.findByToken(tokenValue)).thenReturn(Optional.of(token)); + + boolean result = tokenService.isTokenRevoked(tokenValue); + + assertThat(result).isTrue(); + verify(tokenRepository, times(1)).findByToken(tokenValue); + } + + @Test + void isTokenRevoked_shouldReturnFalse_whenTokenIsNotRevoked() { + String tokenValue = "valid-token"; + Token token = new Token(); + token.setRevoked(false); + when(tokenRepository.findByToken(tokenValue)).thenReturn(Optional.of(token)); + + boolean result = tokenService.isTokenRevoked(tokenValue); + + assertThat(result).isFalse(); + verify(tokenRepository, times(1)).findByToken(tokenValue); + } + + @Test + void isTokenRevoked_shouldThrowTokenNotFoundException_whenTokenDoesNotExist() { + String tokenValue = "invalid-token"; + when(tokenRepository.findByToken(tokenValue)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> tokenService.isTokenRevoked(tokenValue)) + .isInstanceOf(TokenNotFoundException.class) + .hasMessageContaining("token.not_found"); + + verify(tokenRepository, times(1)).findByToken(tokenValue); + } +} \ No newline at end of file