diff --git a/build.gradle b/build.gradle index e6a570f..de21a35 100644 --- a/build.gradle +++ b/build.gradle @@ -27,6 +27,7 @@ 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 +38,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' + // aop implementation 'org.springframework.boot:spring-boot-starter-aop' implementation 'org.aspectj:aspectjweaver' diff --git a/src/main/java/com/fin/spr/auth/JwtAuthenticationFilter.java b/src/main/java/com/fin/spr/auth/JwtAuthenticationFilter.java new file mode 100644 index 0000000..16988c0 --- /dev/null +++ b/src/main/java/com/fin/spr/auth/JwtAuthenticationFilter.java @@ -0,0 +1,65 @@ +package com.fin.spr.auth; + +import com.fin.spr.exceptions.TokenRevokedException; +import com.fin.spr.services.security.JwtService; +import com.fin.spr.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); + } +} diff --git a/src/main/java/com/fin/spr/auth/JwtAuthenticationResponse.java b/src/main/java/com/fin/spr/auth/JwtAuthenticationResponse.java new file mode 100644 index 0000000..79164c8 --- /dev/null +++ b/src/main/java/com/fin/spr/auth/JwtAuthenticationResponse.java @@ -0,0 +1,6 @@ +package com.fin.spr.auth; + +public record JwtAuthenticationResponse( + String token +) { +} diff --git a/src/main/java/com/fin/spr/auth/UserController.java b/src/main/java/com/fin/spr/auth/UserController.java new file mode 100644 index 0000000..cd8ea71 --- /dev/null +++ b/src/main/java/com/fin/spr/auth/UserController.java @@ -0,0 +1,42 @@ +package com.fin.spr.auth; + +import com.fin.spr.controllers.payload.security.AuthenticationPayload; +import com.fin.spr.controllers.payload.security.ChangePasswordPayload; +import com.fin.spr.controllers.payload.security.RegistrationPayload; +import com.fin.spr.services.security.AuthenticationService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/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) { + 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/fin/spr/auth/UserDetails.java b/src/main/java/com/fin/spr/auth/UserDetails.java new file mode 100644 index 0000000..ad0d8df --- /dev/null +++ b/src/main/java/com/fin/spr/auth/UserDetails.java @@ -0,0 +1,32 @@ +package com.fin.spr.auth; + +import com.fin.spr.models.security.User; +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(); + } +} diff --git a/src/main/java/com/fin/spr/config/AppConfig.java b/src/main/java/com/fin/spr/config/AppConfig.java index ca3e3d9..300e23c 100644 --- a/src/main/java/com/fin/spr/config/AppConfig.java +++ b/src/main/java/com/fin/spr/config/AppConfig.java @@ -1,10 +1,19 @@ package com.fin.spr.config; +import com.fin.spr.services.security.MyUserDetailsService; +import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +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.RestClient; /** @@ -12,8 +21,12 @@ * It contains bean definitions that are used throughout the application. */ @Configuration +@RequiredArgsConstructor public class AppConfig { + private final MyUserDetailsService myUserDetailsService; + + /** * Creates and returns a new instance of {@link RestClient}. * @@ -26,4 +39,27 @@ public RestClient restClient(@Value("${kudago.api.base.url}") String url) { .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .build(); } + + @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/fin/spr/config/SecurityConfig.java b/src/main/java/com/fin/spr/config/SecurityConfig.java new file mode 100644 index 0000000..9ab2377 --- /dev/null +++ b/src/main/java/com/fin/spr/config/SecurityConfig.java @@ -0,0 +1,52 @@ +package com.fin.spr.config; + +import com.fin.spr.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.annotation.web.configurers.AbstractHttpConfigurer; +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 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", "DELETE", "OPTIONS")); + corsConfiguration.setAllowedHeaders(List.of("*")); + corsConfiguration.setAllowCredentials(true); + return corsConfiguration; + })) + .authorizeHttpRequests(request -> request + .requestMatchers("/api/v1/auth/register", "/api/v1/auth/login").permitAll() + .requestMatchers("/swagger-ui/**", "/swagger-resources/*", "/v3/api-docs/**").permitAll() + .requestMatchers("/endpoint", "/admin/**").hasRole("ADMIN") + .anyRequest().authenticated()) + .sessionManagement(sessionManagementConfigurer -> + sessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authenticationProvider(authenticationProvider) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/src/main/java/com/fin/spr/controllers/CategoryController.java b/src/main/java/com/fin/spr/controllers/CategoryController.java index 55196c6..a61095f 100644 --- a/src/main/java/com/fin/spr/controllers/CategoryController.java +++ b/src/main/java/com/fin/spr/controllers/CategoryController.java @@ -8,6 +8,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -74,6 +75,7 @@ public ResponseEntity getCategoryById(@PathVariable Integer id) { * Returns HTTP 409 if the category already exists. */ @PostMapping + @PreAuthorize("hasAuthority('ADMIN')") @Override public ResponseEntity createCategory(@RequestBody Category category) { try { @@ -92,6 +94,7 @@ public ResponseEntity createCategory(@RequestBody Category category) { * @return a ResponseEntity containing the updated category or an HTTP 404 status code if not found. */ @PutMapping("/{id}") + @PreAuthorize("hasAuthority('ADMIN')") @Override public ResponseEntity updateCategory(@PathVariable Integer id, @RequestBody Category category) { boolean updated = categoryService.updateCategory(id, category); @@ -106,6 +109,7 @@ public ResponseEntity updateCategory(@PathVariable Integer id, @Reques * Returns HTTP 204 if the category was deleted, or HTTP 404 if not found. */ @DeleteMapping("/{id}") + @PreAuthorize("hasAuthority('ADMIN')") @Override public ResponseEntity deleteCategory(@PathVariable Integer id) { boolean deleted = categoryService.deleteCategory(id); diff --git a/src/main/java/com/fin/spr/controllers/payload/security/AuthenticationPayload.java b/src/main/java/com/fin/spr/controllers/payload/security/AuthenticationPayload.java new file mode 100644 index 0000000..a4819c3 --- /dev/null +++ b/src/main/java/com/fin/spr/controllers/payload/security/AuthenticationPayload.java @@ -0,0 +1,11 @@ +package com.fin.spr.controllers.payload.security; + +import lombok.Builder; + +@Builder +public record AuthenticationPayload ( + String login, + String password, + boolean rememberMe +) { +} diff --git a/src/main/java/com/fin/spr/controllers/payload/security/ChangePasswordPayload.java b/src/main/java/com/fin/spr/controllers/payload/security/ChangePasswordPayload.java new file mode 100644 index 0000000..ab4075b --- /dev/null +++ b/src/main/java/com/fin/spr/controllers/payload/security/ChangePasswordPayload.java @@ -0,0 +1,10 @@ +package com.fin.spr.controllers.payload.security; + +import lombok.Builder; + +@Builder +public record ChangePasswordPayload ( + String newPassword, + String twoFactorCode +){ +} diff --git a/src/main/java/com/fin/spr/controllers/payload/security/RegistrationPayload.java b/src/main/java/com/fin/spr/controllers/payload/security/RegistrationPayload.java new file mode 100644 index 0000000..4459e3d --- /dev/null +++ b/src/main/java/com/fin/spr/controllers/payload/security/RegistrationPayload.java @@ -0,0 +1,11 @@ +package com.fin.spr.controllers.payload.security; + +import lombok.Builder; + +@Builder +public record RegistrationPayload( + String name, + String login, + String password +) { +} diff --git a/src/main/java/com/fin/spr/exceptions/InvalidTwoFactorCodeException.java b/src/main/java/com/fin/spr/exceptions/InvalidTwoFactorCodeException.java new file mode 100644 index 0000000..e9b212d --- /dev/null +++ b/src/main/java/com/fin/spr/exceptions/InvalidTwoFactorCodeException.java @@ -0,0 +1,7 @@ +package com.fin.spr.exceptions; + +public class InvalidTwoFactorCodeException extends RuntimeException { + public InvalidTwoFactorCodeException() { + super("two-factor.code_invalid"); + } +} diff --git a/src/main/java/com/fin/spr/exceptions/TokenNotFoundException.java b/src/main/java/com/fin/spr/exceptions/TokenNotFoundException.java new file mode 100644 index 0000000..914e005 --- /dev/null +++ b/src/main/java/com/fin/spr/exceptions/TokenNotFoundException.java @@ -0,0 +1,15 @@ +package com.fin.spr.exceptions; + + +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/fin/spr/exceptions/TokenRevokedException.java b/src/main/java/com/fin/spr/exceptions/TokenRevokedException.java new file mode 100644 index 0000000..f448def --- /dev/null +++ b/src/main/java/com/fin/spr/exceptions/TokenRevokedException.java @@ -0,0 +1,14 @@ +package com.fin.spr.exceptions; + +import lombok.Getter; + +@Getter +public class TokenRevokedException extends RuntimeException { + + private final String token; + + public TokenRevokedException(String token) { + super("token.is_revoked"); + this.token = token; + } +} diff --git a/src/main/java/com/fin/spr/exceptions/UserAlreadyRegisterException.java b/src/main/java/com/fin/spr/exceptions/UserAlreadyRegisterException.java new file mode 100644 index 0000000..8e5fb00 --- /dev/null +++ b/src/main/java/com/fin/spr/exceptions/UserAlreadyRegisterException.java @@ -0,0 +1,14 @@ +package com.fin.spr.exceptions; + +import lombok.Getter; + +@Getter +public class UserAlreadyRegisterException extends RuntimeException { + + private final String login; + + public UserAlreadyRegisterException(String login) { + super("login.already_register"); + this.login = login; + } +} diff --git a/src/main/java/com/fin/spr/exceptions/UserNotFoundException.java b/src/main/java/com/fin/spr/exceptions/UserNotFoundException.java new file mode 100644 index 0000000..50f2bc4 --- /dev/null +++ b/src/main/java/com/fin/spr/exceptions/UserNotFoundException.java @@ -0,0 +1,14 @@ +package com.fin.spr.exceptions; + +import lombok.Getter; + +@Getter +public class UserNotFoundException extends RuntimeException { + + private final String login; + + public UserNotFoundException(String login) { + super("login.not_found"); + this.login = login; + } +} diff --git a/src/main/java/com/fin/spr/models/security/Role.java b/src/main/java/com/fin/spr/models/security/Role.java new file mode 100644 index 0000000..6df0538 --- /dev/null +++ b/src/main/java/com/fin/spr/models/security/Role.java @@ -0,0 +1,6 @@ +package com.fin.spr.models.security; + +public enum Role { + USER, + ADMIN +} diff --git a/src/main/java/com/fin/spr/models/security/Token.java b/src/main/java/com/fin/spr/models/security/Token.java new file mode 100644 index 0000000..8cc5773 --- /dev/null +++ b/src/main/java/com/fin/spr/models/security/Token.java @@ -0,0 +1,30 @@ +package com.fin.spr.models.security; + +import jakarta.persistence.*; +import lombok.*; + +@Builder +@Setter +@Getter +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(exclude = "user") +@Entity +@Table(name = "t_tokens", schema = "security") +public class Token { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "c_token", nullable = false) + private String token; + + @Column(name = "c_revoked", nullable = false) + private boolean revoked; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "c_user_id", nullable = false) + private User user; + +} diff --git a/src/main/java/com/fin/spr/models/security/User.java b/src/main/java/com/fin/spr/models/security/User.java new file mode 100644 index 0000000..126c6f6 --- /dev/null +++ b/src/main/java/com/fin/spr/models/security/User.java @@ -0,0 +1,36 @@ +package com.fin.spr.models.security; + +import jakarta.persistence.*; +import lombok.*; +import java.util.List; + +@Builder +@Setter +@Getter +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(exclude = "tokens") +@Entity +@Table(name = "t_users", schema = "security") +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "c_name", nullable = false) + private String name; + + @Column(name = "c_login", unique = true, nullable = false) + private String login; + + @Column(name = "c_hashed_password", nullable = false) + private String hashedPassword; + + @Enumerated(EnumType.STRING) + @Column(name = "c_role", nullable = false) + private Role role; + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List tokens; +} diff --git a/src/main/java/com/fin/spr/repository/security/TokenRepository.java b/src/main/java/com/fin/spr/repository/security/TokenRepository.java new file mode 100644 index 0000000..776b0d1 --- /dev/null +++ b/src/main/java/com/fin/spr/repository/security/TokenRepository.java @@ -0,0 +1,14 @@ +package com.fin.spr.repository.security; + +import com.fin.spr.models.security.Token; +import com.fin.spr.models.security.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface TokenRepository extends JpaRepository { + Optional findByToken(String token); + + List findAllByUserAndRevoked(User user, boolean revoked); +} diff --git a/src/main/java/com/fin/spr/repository/security/UserRepository.java b/src/main/java/com/fin/spr/repository/security/UserRepository.java new file mode 100644 index 0000000..dd70bda --- /dev/null +++ b/src/main/java/com/fin/spr/repository/security/UserRepository.java @@ -0,0 +1,11 @@ +package com.fin.spr.repository.security; + +import com.fin.spr.models.security.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + + Optional findByLogin(String login); +} diff --git a/src/main/java/com/fin/spr/services/CategoryService.java b/src/main/java/com/fin/spr/services/CategoryService.java index db90ee9..3634ac1 100644 --- a/src/main/java/com/fin/spr/services/CategoryService.java +++ b/src/main/java/com/fin/spr/services/CategoryService.java @@ -7,6 +7,7 @@ import com.fin.spr.repository.history.CategoryHistory; import com.fin.spr.storage.InMemoryStorage; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; import java.util.List; @@ -61,6 +62,7 @@ public Optional getCategoryById(Integer id) { * * @param category the {@link Category} entity to be created */ + @Override public void createCategory(Category category) { categoryStorage.create(category.getId(), category); diff --git a/src/main/java/com/fin/spr/services/security/AuthenticationService.java b/src/main/java/com/fin/spr/services/security/AuthenticationService.java new file mode 100644 index 0000000..4a54ab3 --- /dev/null +++ b/src/main/java/com/fin/spr/services/security/AuthenticationService.java @@ -0,0 +1,106 @@ +package com.fin.spr.services.security; + +import com.fin.spr.auth.JwtAuthenticationResponse; +import com.fin.spr.auth.UserDetails; +import com.fin.spr.controllers.payload.security.AuthenticationPayload; +import com.fin.spr.controllers.payload.security.ChangePasswordPayload; +import com.fin.spr.controllers.payload.security.RegistrationPayload; +import com.fin.spr.exceptions.InvalidTwoFactorCodeException; +import com.fin.spr.exceptions.UserAlreadyRegisterException; +import com.fin.spr.exceptions.UserNotFoundException; +import com.fin.spr.models.security.Role; +import com.fin.spr.models.security.Token; +import com.fin.spr.models.security.User; +import com.fin.spr.repository.security.TokenRepository; +import com.fin.spr.repository.security.UserRepository; +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/fin/spr/services/security/JwtService.java b/src/main/java/com/fin/spr/services/security/JwtService.java new file mode 100644 index 0000000..6ae24f8 --- /dev/null +++ b/src/main/java/com/fin/spr/services/security/JwtService.java @@ -0,0 +1,71 @@ +package com.fin.spr.services.security; + +import io.jsonwebtoken.Claims; +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 io.jsonwebtoken.Jwts; + +import javax.crypto.SecretKey; +import java.security.Key; +import java.time.Duration; +import java.util.Date; +import java.util.HashMap; +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/fin/spr/services/security/MyUserDetailsService.java b/src/main/java/com/fin/spr/services/security/MyUserDetailsService.java new file mode 100644 index 0000000..6be7d5f --- /dev/null +++ b/src/main/java/com/fin/spr/services/security/MyUserDetailsService.java @@ -0,0 +1,22 @@ +package com.fin.spr.services.security; + +import com.fin.spr.repository.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.fin.spr.auth.UserDetails(user.get()); + else throw new UsernameNotFoundException(login); + } +} diff --git a/src/main/java/com/fin/spr/services/security/TokenService.java b/src/main/java/com/fin/spr/services/security/TokenService.java new file mode 100644 index 0000000..5449037 --- /dev/null +++ b/src/main/java/com/fin/spr/services/security/TokenService.java @@ -0,0 +1,20 @@ +package com.fin.spr.services.security; + +import com.fin.spr.exceptions.TokenNotFoundException; +import com.fin.spr.repository.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(); + } +} + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 58b4f35..72990b1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -28,3 +28,10 @@ app: init: fixed-thread-pool-size: 2 schedule-duration: PT1H + + +jwt: + secret: mysuperSecretPasswordformysuperbestAppwithsuperloginandBisness123Good2wow3magic1 + short: 10m # 10 minutes + long: 30d # 30 days + diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml index a8a51b0..70bd948 100644 --- a/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -1,3 +1,7 @@ databaseChangeLog: - include: - file: db/changelog/kudago-changelog.sql \ No newline at end of file + file: db/changelog/kudago-changelog.sql + - include: + file: db/changelog/security-changelog.sql + - include: + file: db/changelog/security-tokens-changelog.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 new file mode 100644 index 0000000..eeb6c14 --- /dev/null +++ b/src/main/resources/db/changelog/db.changelog-test.yaml @@ -0,0 +1,9 @@ +databaseChangeLog: + - include: + file: db/changelog/kudago-changelog.sql + - include: + file: db/changelog/security-changelog.sql + - include: + file: db/changelog/security-tokens-changelog.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..233aa2b --- /dev/null +++ b/src/main/resources/db/changelog/init-security-changelog.sql @@ -0,0 +1,9 @@ +--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'); + +--changeset alexandergarifullin:2 +INSERT INTO security.t_users(c_name, c_login, c_hashed_password, c_role) +VALUES ('Test Admin', 'admin', '$2a$10$kjWwIGpZ2EjGl5edbc9ceuNE5ceP3vdtiPqoAUgCdHtk4on97uvOC', 'ADMIN'); \ No newline at end of file diff --git a/src/main/resources/db/changelog/security-changelog.sql b/src/main/resources/db/changelog/security-changelog.sql new file mode 100644 index 0000000..bd710c8 --- /dev/null +++ b/src/main/resources/db/changelog/security-changelog.sql @@ -0,0 +1,17 @@ +--liquibase formatted sql + +--changeset alexandergarifullin:1 +CREATE SCHEMA IF NOT EXISTS security; + +--changeset alexandergarifullin:2 +CREATE TABLE security.t_users +( + id BIGSERIAL PRIMARY KEY, + c_name TEXT NOT NULL, + c_login TEXT UNIQUE NOT NULL, + c_hashed_password TEXT NOT NULL, + c_role TEXT NOT NULL +); + +--changeset alexandergarifullin:3 +CREATE INDEX IF NOT EXISTS idx_users_login ON security.t_users (c_login); \ No newline at end of file diff --git a/src/main/resources/db/changelog/security-tokens-changelog.sql b/src/main/resources/db/changelog/security-tokens-changelog.sql new file mode 100644 index 0000000..d01f2c0 --- /dev/null +++ b/src/main/resources/db/changelog/security-tokens-changelog.sql @@ -0,0 +1,8 @@ +CREATE TABLE security.t_tokens +( + id BIGSERIAL PRIMARY KEY, + c_token TEXT NOT NULL, + c_revoked BOOLEAN NOT NULL, + c_user_id BIGINT NOT NULL, + CONSTRAINT fk_user FOREIGN KEY (c_user_id) REFERENCES security.t_users (id) +); \ No newline at end of file diff --git a/src/test/java/com/fin/spr/BaseIntegrationTest.java b/src/test/java/com/fin/spr/BaseIntegrationTest.java index 93d1c58..4c67dd1 100644 --- a/src/test/java/com/fin/spr/BaseIntegrationTest.java +++ b/src/test/java/com/fin/spr/BaseIntegrationTest.java @@ -1,12 +1,15 @@ package com.fin.spr; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fin.spr.controllers.payload.security.AuthenticationPayload; +import com.fin.spr.services.security.AuthenticationService; import liquibase.Contexts; import liquibase.Liquibase; import liquibase.database.Database; import liquibase.database.jvm.JdbcConnection; import liquibase.exception.LiquibaseException; 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; @@ -31,6 +34,24 @@ public abstract class BaseIntegrationTest { @Autowired protected ObjectMapper objectMapper; + @Autowired + protected AuthenticationService authenticationService; + + protected static String userBearerToken; + protected static String adminBearerToken; + + protected static final AuthenticationPayload userRequest = new AuthenticationPayload( + "user", + "password", + false + ); + + protected static final AuthenticationPayload adminRequest = new AuthenticationPayload( + "admin", + "password", + false + ); + void setUp() { postgreSQLContainer = new PostgreSQLContainer<>("postgres:17") .withDatabaseName("testdb") @@ -61,10 +82,21 @@ private void runLiquibaseMigrations(Connection connection) throws LiquibaseExcep database.setConnection(new JdbcConnection(connection)); // Указываем основной файл миграций - try (Liquibase liquibase = new Liquibase("db/changelog/db.changelog-master.yaml", - new ClassLoaderResourceAccessor(), - database)) { + String changeLogFile = System.getProperty("spring.liquibase.change-log", "db/changelog/db.changelog-master.yaml"); + + try (Liquibase liquibase = new Liquibase(changeLogFile, new ClassLoaderResourceAccessor(), database)) { liquibase.update(new Contexts()); } } + + @BeforeEach + public void getToken() { + if (userBearerToken == null) { + userBearerToken = "Bearer %s".formatted(authenticationService.login(userRequest).token()); + } + + if (adminBearerToken == null) { + adminBearerToken = "Bearer %s".formatted(authenticationService.login(adminRequest).token()); + } + } } diff --git a/src/test/java/com/fin/spr/auth/UserControllerIntegrationTest.java b/src/test/java/com/fin/spr/auth/UserControllerIntegrationTest.java new file mode 100644 index 0000000..8f0ccd3 --- /dev/null +++ b/src/test/java/com/fin/spr/auth/UserControllerIntegrationTest.java @@ -0,0 +1,218 @@ +package com.fin.spr.auth; + +import com.fin.spr.BaseIntegrationTest; +import com.fin.spr.controllers.payload.security.AuthenticationPayload; +import com.fin.spr.controllers.payload.security.ChangePasswordPayload; +import com.fin.spr.controllers.payload.security.RegistrationPayload; +import com.fin.spr.repository.security.TokenRepository; +import com.fin.spr.repository.security.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; + + +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.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class UserControllerIntegrationTest extends BaseIntegrationTest { + + private static final String uri = "/api/v1/auth"; + + @Autowired + private UserRepository userRepository; + + @Autowired + private TokenRepository tokenRepository; + + @AfterEach + void cleanDatabase() { + userRepository.deleteAll(); + tokenRepository.deleteAll(); + } + + @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() + ); + } + + @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()) + ); + } + + @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() + ); + } + + @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()) + ); + } + + 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); + } + +} \ No newline at end of file diff --git a/src/test/java/com/fin/spr/controllers/CategoryControllerIntegrationTest.java b/src/test/java/com/fin/spr/controllers/CategoryControllerIntegrationTest.java index 6d3db7e..c6169c2 100644 --- a/src/test/java/com/fin/spr/controllers/CategoryControllerIntegrationTest.java +++ b/src/test/java/com/fin/spr/controllers/CategoryControllerIntegrationTest.java @@ -1,5 +1,6 @@ package com.fin.spr.controllers; +import com.fin.spr.BaseIntegrationTest; import com.fin.spr.exceptions.EntityAlreadyExistsException; import com.fin.spr.models.Category; import com.fin.spr.services.CategoryService; @@ -24,7 +25,7 @@ @SpringBootTest @AutoConfigureMockMvc -public class CategoryControllerIntegrationTest { +public class CategoryControllerIntegrationTest extends BaseIntegrationTest { @Autowired private MockMvc mockMvc; @@ -49,8 +50,8 @@ public void setup() { @Test public void testGetAllCategories() throws Exception { Mockito.when(categoryService.getAllCategories()).thenReturn(Arrays.asList(testCategory1, testCategory2)); - - mockMvc.perform(get(BASE_URL)) + mockMvc.perform(get(BASE_URL) + .header("Authorization", userBearerToken)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$[0].name").value("Museums")) @@ -61,7 +62,8 @@ public void testGetAllCategories() throws Exception { public void testGetCategoryById_Success() throws Exception { Mockito.when(categoryService.getCategoryById(1)).thenReturn(Optional.of(testCategory1)); - mockMvc.perform(get(BASE_URL + "/1")) + mockMvc.perform(get(BASE_URL + "/1") + .header("Authorization", userBearerToken)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.name").value("Museums")) @@ -72,7 +74,8 @@ public void testGetCategoryById_Success() throws Exception { public void testGetCategoryById_NotFound() throws Exception { Mockito.when(categoryService.getCategoryById(99)).thenReturn(Optional.empty()); - mockMvc.perform(get(BASE_URL + "/99")) + mockMvc.perform(get(BASE_URL + "/99") + .header("Authorization", userBearerToken)) .andExpect(status().isNotFound()); } @@ -81,6 +84,7 @@ public void testCreateCategory_Success() throws Exception { Mockito.doNothing().when(categoryService).createCategory(any(Category.class)); mockMvc.perform(post(BASE_URL) + .header("Authorization", adminBearerToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(testCategory1))) .andExpect(status().isCreated()) @@ -93,6 +97,7 @@ public void testCreateCategory_Conflict() throws Exception { Mockito.doThrow(new EntityAlreadyExistsException("Category already exists")).when(categoryService).createCategory(any(Category.class)); mockMvc.perform(post(BASE_URL) + .header("Authorization", adminBearerToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(testCategory1))) .andExpect(status().isConflict()); @@ -103,6 +108,7 @@ public void testUpdateCategory_Success() throws Exception { Mockito.when(categoryService.updateCategory(eq(1), any(Category.class))).thenReturn(true); mockMvc.perform(put(BASE_URL + "/1") + .header("Authorization", adminBearerToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(testCategory1))) .andExpect(status().isOk()) @@ -115,6 +121,7 @@ public void testUpdateCategory_NotFound() throws Exception { Mockito.when(categoryService.updateCategory(eq(99), any(Category.class))).thenReturn(false); mockMvc.perform(put(BASE_URL + "/99") + .header("Authorization", adminBearerToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(testCategory1))) .andExpect(status().isNotFound()); @@ -124,7 +131,8 @@ public void testUpdateCategory_NotFound() throws Exception { public void testDeleteCategory_Success() throws Exception { Mockito.when(categoryService.deleteCategory(1)).thenReturn(true); - mockMvc.perform(delete(BASE_URL + "/1")) + mockMvc.perform(delete(BASE_URL + "/1") + .header("Authorization", adminBearerToken)) .andExpect(status().isNoContent()); } @@ -132,7 +140,8 @@ public void testDeleteCategory_Success() throws Exception { public void testDeleteCategory_NotFound() throws Exception { Mockito.when(categoryService.deleteCategory(99)).thenReturn(false); - mockMvc.perform(delete(BASE_URL + "/99")) + mockMvc.perform(delete(BASE_URL + "/99") + .header("Authorization", adminBearerToken)) .andExpect(status().isNotFound()); } } diff --git a/src/test/java/com/fin/spr/controllers/EventControllerIntegrationTest.java b/src/test/java/com/fin/spr/controllers/EventControllerIntegrationTest.java index 03272f2..998dd72 100644 --- a/src/test/java/com/fin/spr/controllers/EventControllerIntegrationTest.java +++ b/src/test/java/com/fin/spr/controllers/EventControllerIntegrationTest.java @@ -72,7 +72,8 @@ public void getAllEvents_notEmpty() throws Exception { var createdEvent = eventService.createEvent(eventPayload.name(), eventPayload.startDate(), eventPayload.price(), eventPayload.free(), createdLocation.getId()); - var mvcResponse = mockMvc.perform(get(events_uri)) + var mvcResponse = mockMvc.perform(get(events_uri) + .header("Authorization", userBearerToken)) .andExpectAll( status().isOk(), content().contentType(MediaType.APPLICATION_JSON)) @@ -95,7 +96,8 @@ void getEventById_success() throws Exception { var createdEvent = eventService.createEvent(eventPayload.name(), eventPayload.startDate(), eventPayload.price(), eventPayload.free(), createdLocation.getId()); - var mvcResponse = mockMvc.perform(get(events_uri + "/{id}", createdEvent.getId())) + var mvcResponse = mockMvc.perform(get(events_uri + "/{id}", createdEvent.getId()) + .header("Authorization", userBearerToken)) .andExpectAll( status().isOk(), content().contentType(MediaType.APPLICATION_JSON)) @@ -112,7 +114,8 @@ void getEventById_success() throws Exception { @Test void getEventById_notFound() throws Exception { - mockMvc.perform(get(events_uri + "/88")) + mockMvc.perform(get(events_uri + "/88") + .header("Authorization", userBearerToken)) .andExpectAll( status().isNotFound(), content().contentType(MediaType.APPLICATION_PROBLEM_JSON) @@ -128,6 +131,7 @@ void createEvent_success() throws Exception { eventPayload.free(), createdLocation.getId()); var mvcResponse = mockMvc.perform(post(events_uri) + .header("Authorization", userBearerToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(eventPayload))) .andExpectAll( @@ -168,6 +172,7 @@ private static Stream invalidEventPayloads() { @MethodSource("invalidEventPayloads") void createEvent_badRequest(EventPayload badEventPayload) throws Exception { mockMvc.perform(post(events_uri) + .header("Authorization", userBearerToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(badEventPayload))) .andExpectAll( @@ -180,6 +185,7 @@ void createEvent_locationNotFound() throws Exception { eventPayload.free(), -1L); var mvcResponse = mockMvc.perform(post(events_uri) + .header("Authorization", userBearerToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(eventPayload))) .andExpectAll( @@ -200,6 +206,7 @@ void updateEvent_success() throws Exception { createdLocation.getId()); var mvcResponse = mockMvc.perform(put(events_uri + "/{id}", createdEvent.getId()) + .header("Authorization", userBearerToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(eventPayload))) .andExpectAll( @@ -225,6 +232,7 @@ void updateEvent_badRequest(EventPayload badEventPayload) throws Exception { eventPayload.price(), eventPayload.free(), createdLocation.getId()); mockMvc.perform(put(events_uri + "/{id}", createdEvent.getId()) + .header("Authorization", userBearerToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(badEventPayload))) .andExpectAll( @@ -234,6 +242,7 @@ void updateEvent_badRequest(EventPayload badEventPayload) throws Exception { @Test void updateEvent_notFound() throws Exception { var mvcResponse = mockMvc.perform(put(events_uri + "/8888") + .header("Authorization", userBearerToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(eventPayload))) .andExpectAll( @@ -248,7 +257,8 @@ void deleteEvent_success() throws Exception { var createdEvent = eventService.createEvent(eventPayload.name(), eventPayload.startDate(), eventPayload.price(), eventPayload.free(), createdLocation.getId()); - mockMvc.perform(delete(events_uri + "/" + createdEvent.getId())) + mockMvc.perform(delete(events_uri + "/" + createdEvent.getId()) + .header("Authorization", userBearerToken)) .andExpect(status().isNoContent()); assertThatThrownBy(() -> eventService.getEventById(createdEvent.getId())) @@ -258,7 +268,8 @@ void deleteEvent_success() throws Exception { @Test void deleteEvent_notFound() throws Exception { - mockMvc.perform(delete(events_uri + "/88888")) + mockMvc.perform(delete(events_uri + "/88888") + .header("Authorization", userBearerToken)) .andExpectAll( status().isNotFound(), content().contentType(MediaType.APPLICATION_PROBLEM_JSON)); @@ -270,7 +281,8 @@ void getLocationWithEvents_success() throws Exception { var createdEvent = eventService.createEvent(eventPayload.name(), eventPayload.startDate(), eventPayload.price(), eventPayload.free(), createdLocation.getId()); - var mvcResponse = mockMvc.perform(get(locations_uri + "/" + createdLocation.getId())) + var mvcResponse = mockMvc.perform(get(locations_uri + "/" + createdLocation.getId()) + .header("Authorization", userBearerToken)) .andExpectAll( status().isOk(), content().contentType(MediaType.APPLICATION_JSON)) @@ -292,6 +304,7 @@ void searchEvent_withParams() throws Exception { eventPayload.price(), eventPayload.free(), createdLocation.getId()); var mvcResponse = mockMvc.perform(get(events_uri + "/filter") + .header("Authorization", userBearerToken) .param("name", createdEvent.getName()) .param("fromDate", createdEvent.getStartDate().minus(Duration.ofDays(1)).toString()) .param("toDate", createdEvent.getStartDate().plus(Duration.ofDays(1)).toString())) @@ -317,7 +330,8 @@ void searchEvent_withNullParams() throws Exception { var createdEvent = eventService.createEvent(eventPayload.name(), eventPayload.startDate(), eventPayload.price(), eventPayload.free(), createdLocation.getId()); - var mvcResponse = mockMvc.perform(get(events_uri + "/filter")) + var mvcResponse = mockMvc.perform(get(events_uri + "/filter") + .header("Authorization", userBearerToken)) .andExpectAll( status().isOk(), content().contentType(MediaType.APPLICATION_JSON)) @@ -341,6 +355,7 @@ void searchEvent_returnEmptyList() throws Exception { eventPayload.price(), eventPayload.free(), createdLocation.getId()); var mvcResponse = mockMvc.perform(get(events_uri + "/filter") + .header("Authorization", userBearerToken) .param("name", createdEvent.getName()) .param("fromDate", createdEvent.getStartDate().plus(Duration.ofDays(10)).toString())) .andExpectAll( diff --git a/src/test/java/com/fin/spr/controllers/LocationControllerIntegrationTest.java b/src/test/java/com/fin/spr/controllers/LocationControllerIntegrationTest.java index 8db3d71..6062fa3 100644 --- a/src/test/java/com/fin/spr/controllers/LocationControllerIntegrationTest.java +++ b/src/test/java/com/fin/spr/controllers/LocationControllerIntegrationTest.java @@ -45,7 +45,8 @@ void getAllLocations_notEmpty() throws Exception { LocationPayload payload = new LocationPayload("slug1", "Test Location"); var createdLocation = locationService.createLocation(payload.slug(), payload.name()); - var mvcResponse = mockMvc.perform(get(uri)) + var mvcResponse = mockMvc.perform(get(uri) + .header("Authorization", userBearerToken)) .andExpectAll( status().isOk(), content().contentType(MediaType.APPLICATION_JSON)) @@ -67,7 +68,8 @@ void getLocationById_success() throws Exception { LocationPayload payload = new LocationPayload("slug1", "Test Location"); var createdLocation = locationService.createLocation(payload.slug(), payload.name()); - var mvcResponse = mockMvc.perform(get(uri + "/" + createdLocation.getId())) + var mvcResponse = mockMvc.perform(get(uri + "/" + createdLocation.getId()) + .header("Authorization", userBearerToken)) .andExpectAll( status().isOk(), content().contentType(MediaType.APPLICATION_JSON)) @@ -81,7 +83,8 @@ void getLocationById_success() throws Exception { @Test void getLocationById_notFound() throws Exception { - mockMvc.perform(get(uri + "/88")) + mockMvc.perform(get(uri + "/88") + .header("Authorization", userBearerToken)) .andExpectAll( status().isNotFound(), content().contentType(MediaType.APPLICATION_PROBLEM_JSON) @@ -93,6 +96,7 @@ void createLocation_success() throws Exception{ LocationPayload payload = new LocationPayload("slug1", "Test Location"); var mvcResponse = mockMvc.perform(post(uri) + .header("Authorization", userBearerToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(payload))) .andExpectAll( @@ -129,6 +133,7 @@ private static Stream invalidLocationPayloads() { @MethodSource("invalidLocationPayloads") void createLocation_badRequest(LocationPayload locationPayload) throws Exception { mockMvc.perform(post(uri) + .header("Authorization", userBearerToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(locationPayload))) .andExpectAll( @@ -142,6 +147,7 @@ public void updateLocation_success() throws Exception{ LocationPayload newPayload = new LocationPayload("slug2", "New Location"); var mvcResponse = mockMvc.perform(put(uri + "/{id}", oldLocation.getId()) + .header("Authorization", userBearerToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(newPayload))) .andExpectAll( @@ -163,6 +169,7 @@ void updateLocation_badRequest(LocationPayload locationPayload) throws Exception var oldLocation = locationService.createLocation("slug1", "Old Location"); mockMvc.perform(put(uri + "/{id}", oldLocation.getId()) + .header("Authorization", userBearerToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(locationPayload))) .andExpectAll( @@ -174,6 +181,7 @@ void updateLocation_notFound() throws Exception { LocationPayload newPayload = new LocationPayload("slug2", "New Location"); var mvcResponse = mockMvc.perform(put(uri + "/88") + .header("Authorization", userBearerToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(newPayload))) .andExpectAll( @@ -185,7 +193,8 @@ void updateLocation_notFound() throws Exception { void deleteLocation_success() throws Exception { var oldLocation = locationService.createLocation("slug1", "Old Location"); - mockMvc.perform(delete(uri + "/" + oldLocation.getId())) + mockMvc.perform(delete(uri + "/" + oldLocation.getId()) + .header("Authorization", userBearerToken)) .andExpect(status().isNoContent()); assertThatThrownBy(() -> locationService.getLocationById(oldLocation.getId())) @@ -195,7 +204,8 @@ void deleteLocation_success() throws Exception { @Test void deleteLocation_notFound() throws Exception { - mockMvc.perform(delete(uri + "/88")) + mockMvc.perform(delete(uri + "/88") + .header("Authorization", userBearerToken)) .andExpectAll( status().isNotFound(), content().contentType(MediaType.APPLICATION_PROBLEM_JSON)); diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index e378f53..4b8296b 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -11,4 +11,5 @@ spring: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect liquibase: + change-log: classpath:db/changelog/db.changelog-test.yaml enabled: true