diff --git a/pom.xml b/pom.xml index 8ca33b4..62d686a 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 3.5.8 + 4.0.0 org.openpodcastapi @@ -36,7 +36,7 @@ 25 1.6.3 - 1.1.3 + 2.0.0 5.1.1 @@ -54,23 +54,30 @@ org.springframework.boot - spring-boot-starter-web + spring-boot-starter-webmvc - io.github.wimdeblauwe - htmx-spring-boot-thymeleaf - 4.0.1 + org.springframework.boot + spring-boot-starter-webmvc-test + test - nz.net.ultraq.thymeleaf - thymeleaf-layout-dialect - 3.4.0 + org.springframework.boot + spring-boot-starter-jackson - org.springframework.session - spring-session-data-redis + org.springframework.boot + spring-boot-starter-flyway + + + org.springframework.boot + spring-boot-starter-session-data-redis + + + org.springframework.boot + spring-boot-configuration-processor + true - org.springframework.boot spring-boot-devtools @@ -84,14 +91,42 @@ true - org.postgresql - postgresql - runtime + org.springframework.boot + spring-boot-starter-test + test org.springframework.boot - spring-boot-configuration-processor - true + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-restdocs + 4.0.0 + test + + + org.springframework.restdocs + spring-restdocs-mockmvc + test + + + org.springframework.session + spring-session-data-redis + + + org.springframework.security + spring-security-test + test + + + org.postgresql + postgresql + runtime org.projectlombok @@ -108,6 +143,11 @@ mapstruct-spring-annotations ${org.mapstruct.extensions.spring.version} + + nz.net.ultraq.thymeleaf + thymeleaf-layout-dialect + 3.4.0 + com.fasterxml.uuid java-uuid-generator @@ -118,29 +158,6 @@ dotenv-java 3.2.0 - - org.springframework.boot - spring-boot-starter-test - test - - - org.springframework.restdocs - spring-restdocs-mockmvc - test - - - org.springframework.boot - spring-boot-starter-validation - - - org.springframework.boot - spring-boot-starter-security - - - org.springframework.security - spring-security-test - test - org.thymeleaf.extras thymeleaf-extras-springsecurity6 @@ -148,6 +165,7 @@ org.flywaydb flyway-database-postgresql + 11.17.1 io.jsonwebtoken @@ -169,6 +187,10 @@ h2 runtime + + org.springframework.boot + spring-boot-starter-security-oauth2-resource-server + @@ -202,7 +224,7 @@ org.asciidoctor asciidoctor-maven-plugin - 2.2.6 + 3.2.0 generate-docs @@ -226,7 +248,7 @@ org.springframework.restdocs spring-restdocs-asciidoctor - 3.0.5 + 4.0.0 diff --git a/src/main/java/org/openpodcastapi/opa/OpenPodcastAPI.java b/src/main/java/org/openpodcastapi/opa/OpenPodcastAPI.java index ef1460d..8b578e3 100644 --- a/src/main/java/org/openpodcastapi/opa/OpenPodcastAPI.java +++ b/src/main/java/org/openpodcastapi/opa/OpenPodcastAPI.java @@ -1,13 +1,21 @@ package org.openpodcastapi.opa; +import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.annotation.EnableScheduling; -@SpringBootApplication() +@SpringBootApplication +@EnableScheduling public class OpenPodcastAPI { static void main(String[] args) { SpringApplication.run(OpenPodcastAPI.class, args); } + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper(); + } } diff --git a/src/main/java/org/openpodcastapi/opa/advice/GlobalExceptionHandler.java b/src/main/java/org/openpodcastapi/opa/advice/GlobalExceptionHandler.java index 9f42bc1..056e37e 100644 --- a/src/main/java/org/openpodcastapi/opa/advice/GlobalExceptionHandler.java +++ b/src/main/java/org/openpodcastapi/opa/advice/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package org.openpodcastapi.opa.advice; import jakarta.persistence.EntityNotFoundException; +import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.openpodcastapi.opa.exceptions.ValidationErrorResponse; @@ -21,25 +22,25 @@ public class GlobalExceptionHandler { @ExceptionHandler(EntityNotFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) - public ResponseEntity handleEntityNotFoundException(EntityNotFoundException error) { + public ResponseEntity<@NonNull String> handleEntityNotFoundException(EntityNotFoundException error) { log.debug("{}", error.getMessage()); return ResponseEntity.notFound().build(); } @ExceptionHandler(DataIntegrityViolationException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) - public ResponseEntity handleDataIntegrityViolationException(DataIntegrityViolationException e) { + public ResponseEntity<@NonNull String> handleDataIntegrityViolationException(DataIntegrityViolationException e) { return ResponseEntity.badRequest().body(e.getMessage()); } @ExceptionHandler(IllegalArgumentException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) - public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e) { + public ResponseEntity<@NonNull String> handleIllegalArgumentException(IllegalArgumentException e) { return ResponseEntity.badRequest().body(e.getMessage()); } @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity handleValidationException(MethodArgumentNotValidException ex) { + public ResponseEntity<@NonNull ValidationErrorResponse> handleValidationException(MethodArgumentNotValidException ex) { List errors = ex.getBindingResult().getFieldErrors().stream() .map(fe -> new ValidationErrorResponse.FieldError(fe.getField(), fe.getDefaultMessage())) .toList(); diff --git a/src/main/java/org/openpodcastapi/opa/auth/ApiBearerTokenAuthenticationConverter.java b/src/main/java/org/openpodcastapi/opa/auth/ApiBearerTokenAuthenticationConverter.java new file mode 100644 index 0000000..b52794d --- /dev/null +++ b/src/main/java/org/openpodcastapi/opa/auth/ApiBearerTokenAuthenticationConverter.java @@ -0,0 +1,34 @@ +package org.openpodcastapi.opa.auth; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationConverter; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.stereotype.Component; + +@Component +public class ApiBearerTokenAuthenticationConverter implements AuthenticationConverter { + + private final BearerTokenAuthenticationConverter delegate = + new BearerTokenAuthenticationConverter(); + + @Override + public Authentication convert(HttpServletRequest request) { + + String path = request.getRequestURI(); + + // Don't authenticate the auth endpoints + if (path.startsWith("/api/auth/")) { + return null; + } + + // If the request has no Bearer token, return null + final var header = request.getHeader("Authorization"); + if (header == null || !header.startsWith("Bearer ")) { + return null; + } + + // Task Spring Boot with handling the request + return delegate.convert(request); + } +} diff --git a/src/main/java/org/openpodcastapi/opa/auth/ApiSecurityHandlers.java b/src/main/java/org/openpodcastapi/opa/auth/ApiSecurityHandlers.java new file mode 100644 index 0000000..6c92d03 --- /dev/null +++ b/src/main/java/org/openpodcastapi/opa/auth/ApiSecurityHandlers.java @@ -0,0 +1,34 @@ +package org.openpodcastapi.opa.auth; + +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.context.annotation.Bean; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +@Component +public class ApiSecurityHandlers { + /// Returns an unauthorized response for unauthenticate API queries + @Bean + public AuthenticationEntryPoint apiAuthenticationEntryPoint() { + return (_, response, authException) -> { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.getWriter().write(""" + {"error": "unauthorized", "message": "%s"} + """.formatted(authException.getMessage())); + }; + } + + /// Returns a forbidden response for API queries + @Bean + public AccessDeniedHandler apiAccessDeniedHandler() { + return (_, response, exception) -> { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json"); + response.getWriter().write(""" + {"error": "forbidden", "message": "%s"} + """.formatted(exception.getMessage())); + }; + } +} diff --git a/src/main/java/org/openpodcastapi/opa/auth/AuthDTO.java b/src/main/java/org/openpodcastapi/opa/auth/AuthDTO.java index 2d70c1a..c820fad 100644 --- a/src/main/java/org/openpodcastapi/opa/auth/AuthDTO.java +++ b/src/main/java/org/openpodcastapi/opa/auth/AuthDTO.java @@ -46,14 +46,4 @@ public record RefreshTokenResponse( @JsonProperty(value = "expiresIn", required = true) @NotNull String expiresIn ) { } - - /// Displays an auth error - /// - /// @param error the error message - /// @param message an additional description of the error - public record ErrorMessageDTO( - @JsonProperty(value = "error", required = true) @NotNull String error, - @JsonProperty(value = "message", required = true) @NotNull String message - ) { - } } diff --git a/src/main/java/org/openpodcastapi/opa/auth/JwtAccessDeniedHandler.java b/src/main/java/org/openpodcastapi/opa/auth/JwtAccessDeniedHandler.java deleted file mode 100644 index b4f01b1..0000000 --- a/src/main/java/org/openpodcastapi/opa/auth/JwtAccessDeniedHandler.java +++ /dev/null @@ -1,34 +0,0 @@ -package org.openpodcastapi.opa.auth; - -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.web.access.AccessDeniedHandler; -import org.springframework.stereotype.Component; - -import java.io.IOException; - -@Component -@RequiredArgsConstructor -public class JwtAccessDeniedHandler implements AccessDeniedHandler { - private final ObjectMapper objectMapper; - - @Override - public void handle(HttpServletRequest request, - HttpServletResponse response, - AccessDeniedException accessDeniedException) throws IOException { - - // If the user doesn't have access to the resource in question, return a 403 - response.setStatus(HttpStatus.FORBIDDEN.value()); - - // Set content type to JSON - response.setContentType("application/json"); - - final var message = new AuthDTO.ErrorMessageDTO("Forbidden", "You do not have permission to access this resource"); - - response.getWriter().write(objectMapper.writeValueAsString(message)); - } -} diff --git a/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationEntryPoint.java b/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationEntryPoint.java deleted file mode 100644 index d829df4..0000000 --- a/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationEntryPoint.java +++ /dev/null @@ -1,32 +0,0 @@ -package org.openpodcastapi.opa.auth; - -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.web.AuthenticationEntryPoint; -import org.springframework.stereotype.Component; - -import java.io.IOException; - -@Component -@RequiredArgsConstructor -public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { - private final ObjectMapper objectMapper; - - /// Returns a 401 when a request is made without a valid bearer token - @Override - public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { - // If the request is being made without a valid bearer token, return a 401. - response.setStatus(HttpStatus.UNAUTHORIZED.value()); - - // Set content type to JSON - response.setContentType("application/json"); - - AuthDTO.ErrorMessageDTO message = new AuthDTO.ErrorMessageDTO("Access denied", "You need to log in to access this resource"); - - response.getWriter().write(objectMapper.writeValueAsString(message)); - } -} diff --git a/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationFilter.java b/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationFilter.java deleted file mode 100644 index 97c7951..0000000 --- a/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationFilter.java +++ /dev/null @@ -1,108 +0,0 @@ -package org.openpodcastapi.opa.auth; - -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.security.Keys; -import jakarta.annotation.Nonnull; -import jakarta.persistence.EntityNotFoundException; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.log4j.Log4j2; -import org.openpodcastapi.opa.service.CustomUserDetails; -import org.openpodcastapi.opa.user.UserEntity; -import org.openpodcastapi.opa.user.UserRepository; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -import javax.crypto.SecretKey; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.UUID; - -@Component -@RequiredArgsConstructor -@Log4j2 -public class JwtAuthenticationFilter extends OncePerRequestFilter { - private final UserRepository repository; - // The JWT secret string set in the env file - @Value("${jwt.secret}") - private String jwtSecret; - - /// Returns an authentication token for a user - /// - /// @param userEntity the [UserEntity] to fetch a token for - /// @return a generated token - /// @throws EntityNotFoundException if no matching user is found - private static UsernamePasswordAuthenticationToken getUsernamePasswordAuthenticationToken(UserEntity userEntity) throws EntityNotFoundException { - // Create a new CustomUserDetails entity with the fetched user - final var userDetails = - new CustomUserDetails(userEntity.getId(), - userEntity.getUuid(), - userEntity.getUsername(), - userEntity.getPassword(), - userEntity.getUserRoles()); - - // Return a token for the user - return new UsernamePasswordAuthenticationToken( - userDetails, - null, - userDetails.getAuthorities() - ); - } - - /// Filter requests by token - /// - /// @param req the HTTP request - /// @param res the HTTP response - /// @param chain the filter chain - /// @throws ServletException if the request can't be served - /// @throws IOException if an I/O issue is encountered - @Override - protected void doFilterInternal(HttpServletRequest req, @Nonnull HttpServletResponse res, @Nonnull FilterChain chain) - throws ServletException, IOException { - - final String header = req.getHeader(HttpHeaders.AUTHORIZATION); - final SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)); - - // If the value is missing or is not a valid bearer token, filter the response - if (header == null || !header.startsWith("Bearer ")) { - chain.doFilter(req, res); - return; - } - - // Check that a valid Bearer token is in the headers - final var token = header.substring(7); - - try { - // Extract the claims from the JWT - final Claims claims = Jwts.parser() - .verifyWith(key) - .build() - .parseSignedClaims(token) - .getPayload(); - - // Extract the user's UUID from the claims - final var parsedUuid = UUID.fromString(claims.getSubject()); - - // Fetch the matching user - final var userEntity = repository.getUserByUuid(parsedUuid).orElseThrow(() -> new EntityNotFoundException("No matching user found")); - - // Create a user - final UsernamePasswordAuthenticationToken authentication = getUsernamePasswordAuthenticationToken(userEntity); - - SecurityContextHolder.getContext().setAuthentication(authentication); - } catch (Exception e) { - log.error("Invalid token passed to endpoint: {}", e.getMessage()); - throw new IllegalArgumentException("Invalid token passed to endpoint"); - } - - chain.doFilter(req, res); - } -} diff --git a/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationProvider.java b/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationProvider.java new file mode 100644 index 0000000..9598f4d --- /dev/null +++ b/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationProvider.java @@ -0,0 +1,70 @@ +package org.openpodcastapi.opa.auth; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.NonNull; +import org.openpodcastapi.opa.service.CustomUserDetails; +import org.openpodcastapi.opa.user.UserRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +@Component +public class JwtAuthenticationProvider implements AuthenticationProvider { + + private final UserRepository repository; + private final SecretKey key; + + public JwtAuthenticationProvider( + UserRepository repository, + @Value("${jwt.secret}") String secret) { + + this.repository = repository; + this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + @Override + public Authentication authenticate(Authentication authentication) + throws AuthenticationException { + + final var token = (String) authentication.getCredentials(); + + try { + final var claims = Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + + final var uuid = UUID.fromString(claims.getSubject()); + + final var user = repository.getUserByUuid(uuid) + .orElseThrow(() -> new BadCredentialsException("User not found")); + + final var details = new CustomUserDetails( + user.getId(), user.getUuid(), user.getUsername(), + user.getPassword(), user.getUserRoles() + ); + + return new UsernamePasswordAuthenticationToken( + details, token, details.getAuthorities()); + } catch (Exception ex) { + throw new BadCredentialsException("Invalid JWT: " + ex.getMessage()); + } + } + + @Override + public boolean supports(@NonNull Class authentication) { + return BearerTokenAuthenticationToken.class.isAssignableFrom(authentication); + } +} + diff --git a/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java b/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java index afad049..8db28d3 100644 --- a/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java +++ b/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java @@ -1,19 +1,25 @@ package org.openpodcastapi.opa.config; import lombok.RequiredArgsConstructor; -import org.openpodcastapi.opa.auth.JwtAccessDeniedHandler; -import org.openpodcastapi.opa.auth.JwtAuthenticationEntryPoint; -import org.openpodcastapi.opa.auth.JwtAuthenticationFilter; +import org.openpodcastapi.opa.auth.ApiBearerTokenAuthenticationConverter; +import org.openpodcastapi.opa.auth.JwtAuthenticationProvider; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; +import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @@ -22,11 +28,7 @@ @EnableMethodSecurity public class SecurityConfig { - private final JwtAuthenticationFilter jwtAuthenticationFilter; - private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; - private final JwtAccessDeniedHandler jwtAccessDeniedHandler; - - private final String[] publicPages = { + private static final String[] PUBLIC_PAGES = { "/", "/login", "/logout-confirm", @@ -39,25 +41,56 @@ public class SecurityConfig { "/favicon.ico", }; - private final String[] publicEndpoints = { - "/api/auth/**" - }; - @Bean - public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { + @Order(1) + public SecurityFilterChain apiSecurity( + HttpSecurity http, + JwtAuthenticationProvider jwtAuthenticationProvider, + AuthenticationEntryPoint entryPoint, + AccessDeniedHandler deniedHandler, + ApiBearerTokenAuthenticationConverter converter + ) { + + AuthenticationManager jwtManager = new ProviderManager(jwtAuthenticationProvider); + + BearerTokenAuthenticationFilter bearerFilter = + new BearerTokenAuthenticationFilter(jwtManager, converter); + + bearerFilter.setAuthenticationFailureHandler( + entryPoint::commence + ); + http - .csrf(csrf -> csrf.ignoringRequestMatchers("/api/**", "/docs", "/docs/**")) - .sessionManagement(sm -> sm - .sessionCreationPolicy(SessionCreationPolicy.ALWAYS)) + .securityMatcher("/api/**") + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/auth/**").permitAll() + .anyRequest().authenticated() + ) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(entryPoint) + .accessDeniedHandler(deniedHandler) + ) + .addFilterBefore(bearerFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + @Order(2) + public SecurityFilterChain webSecurity(HttpSecurity http) { + return http + .csrf(csrf -> csrf + .ignoringRequestMatchers("/docs", "/docs/**") + ) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.ALWAYS)) .authorizeHttpRequests(auth -> auth - .requestMatchers(publicPages).permitAll() - .requestMatchers(publicEndpoints).permitAll() - .requestMatchers("/api/v1/**").authenticated() - .anyRequest().authenticated()) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) - .exceptionHandling(exception -> exception - .authenticationEntryPoint(jwtAuthenticationEntryPoint) - .accessDeniedHandler(jwtAccessDeniedHandler)) + .requestMatchers(PUBLIC_PAGES).permitAll() + .anyRequest().authenticated() + ) .formLogin(login -> login .loginPage("/login") .defaultSuccessUrl("/home", true) @@ -67,8 +100,8 @@ public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws .logoutSuccessUrl("/") .invalidateHttpSession(true) .clearAuthentication(true) - .deleteCookies("JSESSIONID")); - return http.build(); + .deleteCookies("JSESSIONID")) + .build(); } @Bean @@ -77,7 +110,21 @@ public BCryptPasswordEncoder passwordEncoder() { } @Bean - public AuthenticationManager authenticationManager(AuthenticationConfiguration cfg) throws Exception { - return cfg.getAuthenticationManager(); + public DaoAuthenticationProvider daoAuthenticationProvider(UserDetailsService userDetailsService, + BCryptPasswordEncoder passwordEncoder) { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService); + provider.setPasswordEncoder(passwordEncoder); + return provider; + } + + @Bean(name = "jwtAuthManager") + public AuthenticationManager jwtAuthenticationManager(JwtAuthenticationProvider provider) { + return new ProviderManager(provider); + } + + @Bean(name = "apiLoginManager", defaultCandidate = false) + public AuthenticationManager apiLoginAuthenticationManager( + DaoAuthenticationProvider daoProvider) { + return new ProviderManager(daoProvider); } } diff --git a/src/main/java/org/openpodcastapi/opa/controllers/api/AuthController.java b/src/main/java/org/openpodcastapi/opa/controllers/api/AuthController.java index 5716e86..ace8260 100644 --- a/src/main/java/org/openpodcastapi/opa/controllers/api/AuthController.java +++ b/src/main/java/org/openpodcastapi/opa/controllers/api/AuthController.java @@ -2,12 +2,13 @@ import jakarta.persistence.EntityNotFoundException; import jakarta.validation.constraints.NotNull; -import lombok.RequiredArgsConstructor; +import lombok.NonNull; import lombok.extern.log4j.Log4j2; import org.openpodcastapi.opa.auth.AuthDTO; import org.openpodcastapi.opa.security.TokenService; import org.openpodcastapi.opa.user.UserEntity; import org.openpodcastapi.opa.user.UserRepository; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -18,16 +19,25 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequiredArgsConstructor @Log4j2 public class AuthController { private final TokenService tokenService; private final UserRepository userRepository; private final AuthenticationManager authenticationManager; + public AuthController( + TokenService tokenService, + UserRepository userRepository, + @Qualifier("apiLoginManager") AuthenticationManager authenticationManager + ) { + this.tokenService = tokenService; + this.userRepository = userRepository; + this.authenticationManager = authenticationManager; + } + // === Login endpoint === @PostMapping("/api/auth/login") - public ResponseEntity login(@RequestBody @NotNull AuthDTO.LoginRequest loginRequest) { + public ResponseEntity login(@RequestBody @NotNull AuthDTO.LoginRequest loginRequest) { // Set the authentication using the provided details Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(loginRequest.username(), loginRequest.password()) @@ -51,7 +61,7 @@ public ResponseEntity login(@RequestBody @NotNull // === Refresh token endpoint === @PostMapping("/api/auth/refresh") - public ResponseEntity getRefreshToken(@RequestBody @NotNull AuthDTO.RefreshTokenRequest refreshTokenRequest) { + public ResponseEntity getRefreshToken(@RequestBody @NotNull AuthDTO.RefreshTokenRequest refreshTokenRequest) { final var targetUserEntity = userRepository.findByUsername(refreshTokenRequest.username()).orElseThrow(() -> new EntityNotFoundException("No user with username " + refreshTokenRequest.username() + " found")); // Validate the existing refresh token diff --git a/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java b/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java index 299b7f6..8ec12aa 100644 --- a/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java +++ b/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java @@ -1,12 +1,16 @@ package org.openpodcastapi.opa.security; +import lombok.NonNull; import org.openpodcastapi.opa.user.UserEntity; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.time.Instant; import java.util.List; @Repository -public interface RefreshTokenRepository extends JpaRepository { +public interface RefreshTokenRepository extends JpaRepository<@NonNull RefreshTokenEntity, @NonNull Long> { List findAllByUser(UserEntity userEntity); + + int deleteAllByExpiresAtBefore(Instant timestamp); } diff --git a/src/main/java/org/openpodcastapi/opa/service/CustomUserDetails.java b/src/main/java/org/openpodcastapi/opa/service/CustomUserDetails.java index 2fdfdce..6a61cbd 100644 --- a/src/main/java/org/openpodcastapi/opa/service/CustomUserDetails.java +++ b/src/main/java/org/openpodcastapi/opa/service/CustomUserDetails.java @@ -1,5 +1,6 @@ package org.openpodcastapi.opa.service; +import lombok.NonNull; import org.openpodcastapi.opa.user.UserRoles; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -15,7 +16,7 @@ public record CustomUserDetails(Long id, UUID uuid, String username, String pass Set roles) implements UserDetails { @Override - public String getUsername() { + public @NonNull String getUsername() { return username; } @@ -25,29 +26,9 @@ public String getPassword() { } @Override - public Collection getAuthorities() { + public @NonNull Collection getAuthorities() { return roles.stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role.name())) .collect(Collectors.toSet()); } - - @Override - public boolean isAccountNonExpired() { - return true; - } - - @Override - public boolean isAccountNonLocked() { - return true; - } - - @Override - public boolean isCredentialsNonExpired() { - return true; - } - - @Override - public boolean isEnabled() { - return true; - } } diff --git a/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java b/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java index 20bcdb6..33d6996 100644 --- a/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java +++ b/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java @@ -1,5 +1,6 @@ package org.openpodcastapi.opa.service; +import lombok.NonNull; import lombok.RequiredArgsConstructor; import org.openpodcastapi.opa.user.UserEntity; import org.openpodcastapi.opa.user.UserRepository; @@ -19,7 +20,7 @@ public class CustomUserDetailsService implements UserDetailsService { /// @param username the username to map /// @throws UsernameNotFoundException if user is not matched by username @Override - public UserDetails loadUserByUsername(String username) { + public @NonNull UserDetails loadUserByUsername(@NonNull String username) { return userRepository.getUserByUsername(username) .map(this::mapToUserDetails) .orElseThrow(() -> new UsernameNotFoundException("UserEntity not found")); diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java index 324c3bd..0e5853f 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.NotNull; +import lombok.NonNull; import org.hibernate.validator.constraints.URL; import org.hibernate.validator.constraints.UUID; import org.springframework.data.domain.Page; @@ -78,7 +79,7 @@ public record SubscriptionPageDTO( int numberOfElements, int size ) { - public static SubscriptionPageDTO fromPage(Page page) { + public static SubscriptionPageDTO fromPage(Page<@NonNull UserSubscriptionDTO> page) { return new SubscriptionPageDTO( page.getContent(), page.isFirst(), diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRepository.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRepository.java index bdb30db..5df66b8 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRepository.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRepository.java @@ -1,5 +1,6 @@ package org.openpodcastapi.opa.subscription; +import lombok.NonNull; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -7,6 +8,6 @@ import java.util.UUID; @Repository -public interface SubscriptionRepository extends JpaRepository { +public interface SubscriptionRepository extends JpaRepository<@NonNull SubscriptionEntity, @NonNull Long> { Optional findByUuid(UUID uuid); } diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java index 74716ef..0862178 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java @@ -1,6 +1,7 @@ package org.openpodcastapi.opa.subscription; import jakarta.persistence.EntityNotFoundException; +import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.openpodcastapi.opa.service.CustomUserDetails; @@ -31,9 +32,9 @@ public class SubscriptionRestController { @GetMapping @ResponseStatus(HttpStatus.OK) @PreAuthorize("hasRole('USER')") - public ResponseEntity getAllSubscriptionsForUser(@AuthenticationPrincipal CustomUserDetails user, Pageable pageable, @RequestParam(defaultValue = "false") boolean includeUnsubscribed) { + public ResponseEntity getAllSubscriptionsForUser(@AuthenticationPrincipal CustomUserDetails user, Pageable pageable, @RequestParam(defaultValue = "false") boolean includeUnsubscribed) { log.info("{}", user.getAuthorities()); - Page dto; + Page dto; if (includeUnsubscribed) { dto = service.getAllSubscriptionsForUser(user.id(), pageable); @@ -55,7 +56,7 @@ public ResponseEntity getAllSubscriptionsFo @GetMapping("/{uuid}") @ResponseStatus(HttpStatus.OK) @PreAuthorize("hasRole('USER')") - public ResponseEntity getSubscriptionByUuid(@PathVariable String uuid, @AuthenticationPrincipal CustomUserDetails user) throws EntityNotFoundException { + public ResponseEntity getSubscriptionByUuid(@PathVariable String uuid, @AuthenticationPrincipal CustomUserDetails user) throws EntityNotFoundException { // Attempt to validate the UUID value from the provided string // If the value is invalid, the GlobalExceptionHandler will throw a 400. final var uuidValue = UUID.fromString(uuid); @@ -76,7 +77,7 @@ public ResponseEntity getSubscriptionByUuid @PostMapping("/{uuid}/unsubscribe") @ResponseStatus(HttpStatus.OK) @PreAuthorize("hasRole('USER')") - public ResponseEntity unsubscribeUserFromFeed(@PathVariable String uuid, @AuthenticationPrincipal CustomUserDetails user) { + public ResponseEntity unsubscribeUserFromFeed(@PathVariable String uuid, @AuthenticationPrincipal CustomUserDetails user) { // Attempt to validate the UUID value from the provided string // If the value is invalid, the GlobalExceptionHandler will throw a 400. final var uuidValue = UUID.fromString(uuid); @@ -92,7 +93,7 @@ public ResponseEntity unsubscribeUserFromFe /// @return a [ResponseEntity] containing a [SubscriptionDTO.BulkSubscriptionResponseDTO] object @PostMapping @PreAuthorize("hasRole('USER')") - public ResponseEntity createUserSubscriptions(@RequestBody List request, @AuthenticationPrincipal CustomUserDetails user) { + public ResponseEntity createUserSubscriptions(@RequestBody List request, @AuthenticationPrincipal CustomUserDetails user) { final var response = service.addSubscriptions(request, user.id()); if (response.success().isEmpty() && !response.failure().isEmpty()) { diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java index 73e6236..9268771 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java @@ -1,6 +1,7 @@ package org.openpodcastapi.opa.subscription; import jakarta.persistence.EntityNotFoundException; +import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.openpodcastapi.opa.user.UserRepository; @@ -58,7 +59,7 @@ public SubscriptionDTO.UserSubscriptionDTO getUserSubscriptionBySubscriptionUuid /// @param userId the database ID of the authenticated userEntity /// @return a paginated set of [SubscriptionDTO.UserSubscriptionDTO] objects @Transactional(readOnly = true) - public Page getAllSubscriptionsForUser(Long userId, Pageable pageable) { + public Page getAllSubscriptionsForUser(Long userId, Pageable pageable) { log.debug("Fetching subscriptions for {}", userId); return userSubscriptionRepository .findAllByUserId(userId, pageable) @@ -70,7 +71,7 @@ public Page getAllSubscriptionsForUser(Long /// @param userId the database ID of the authenticated user /// @return a paginated set of [SubscriptionDTO.UserSubscriptionDTO] objects @Transactional(readOnly = true) - public Page getAllActiveSubscriptionsForUser(Long userId, Pageable pageable) { + public Page getAllActiveSubscriptionsForUser(Long userId, Pageable pageable) { log.debug("Fetching all active subscriptions for {}", userId); return userSubscriptionRepository.findAllByUserIdAndIsSubscribedTrue(userId, pageable).map(userSubscriptionMapper::toDto); } diff --git a/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionRepository.java b/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionRepository.java index 7f9c0d5..081eb76 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionRepository.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionRepository.java @@ -1,5 +1,6 @@ package org.openpodcastapi.opa.subscription; +import lombok.NonNull; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,10 +10,10 @@ import java.util.UUID; @Repository -public interface UserSubscriptionRepository extends JpaRepository { +public interface UserSubscriptionRepository extends JpaRepository<@NonNull UserSubscriptionEntity, @NonNull Long> { Optional findByUserIdAndSubscriptionUuid(Long userId, UUID subscriptionUuid); - Page findAllByUserId(Long userId, Pageable pageable); + Page<@NonNull UserSubscriptionEntity> findAllByUserId(Long userId, Pageable pageable); - Page findAllByUserIdAndIsSubscribedTrue(Long userId, Pageable pageable); + Page<@NonNull UserSubscriptionEntity> findAllByUserIdAndIsSubscribedTrue(Long userId, Pageable pageable); } diff --git a/src/main/java/org/openpodcastapi/opa/user/UserDTO.java b/src/main/java/org/openpodcastapi/opa/user/UserDTO.java index 63272f4..a23874c 100644 --- a/src/main/java/org/openpodcastapi/opa/user/UserDTO.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserDTO.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotNull; +import lombok.NonNull; import org.springframework.data.domain.Page; import java.time.Instant; @@ -46,7 +47,7 @@ public record UserPageDTO( int numberOfElements, int size ) { - public static UserPageDTO fromPage(Page page) { + public static UserPageDTO fromPage(Page<@NonNull UserResponseDTO> page) { return new UserPageDTO( page.getContent(), page.isFirst(), diff --git a/src/main/java/org/openpodcastapi/opa/user/UserEntity.java b/src/main/java/org/openpodcastapi/opa/user/UserEntity.java index cd470dc..b4bf184 100644 --- a/src/main/java/org/openpodcastapi/opa/user/UserEntity.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserEntity.java @@ -4,6 +4,7 @@ import lombok.*; import org.openpodcastapi.opa.subscription.UserSubscriptionEntity; +import java.io.Serializable; import java.time.Instant; import java.util.Collections; import java.util.HashSet; @@ -17,7 +18,7 @@ @Setter @NoArgsConstructor @AllArgsConstructor -public class UserEntity { +public class UserEntity implements Serializable { @Id @Generated @@ -37,7 +38,7 @@ public class UserEntity { private String email; @OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE) - private Set subscriptions; + private transient Set subscriptions; @ElementCollection(fetch = FetchType.EAGER) @Builder.Default diff --git a/src/main/java/org/openpodcastapi/opa/user/UserRepository.java b/src/main/java/org/openpodcastapi/opa/user/UserRepository.java index b56f70b..247983e 100644 --- a/src/main/java/org/openpodcastapi/opa/user/UserRepository.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserRepository.java @@ -1,5 +1,6 @@ package org.openpodcastapi.opa.user; +import lombok.NonNull; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -7,12 +8,12 @@ import java.util.UUID; @Repository -public interface UserRepository extends JpaRepository { +public interface UserRepository extends JpaRepository<@NonNull UserEntity, @NonNull Long> { Optional getUserByUuid(UUID uuid); Optional getUserByUsername(String username); - Boolean existsUserByEmailOrUsername(String email, String username); + boolean existsUserByEmailOrUsername(String email, String username); Optional findByUsername(String username); } diff --git a/src/main/java/org/openpodcastapi/opa/user/UserRestController.java b/src/main/java/org/openpodcastapi/opa/user/UserRestController.java index c7fd577..0a655ed 100644 --- a/src/main/java/org/openpodcastapi/opa/user/UserRestController.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserRestController.java @@ -1,5 +1,6 @@ package org.openpodcastapi.opa.user; +import lombok.NonNull; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; @@ -23,7 +24,7 @@ public class UserRestController { @GetMapping @ResponseStatus(HttpStatus.OK) @PreAuthorize("hasRole('ADMIN')") - public ResponseEntity getAllUsers(Pageable pageable) { + public ResponseEntity getAllUsers(Pageable pageable) { final var paginatedUserResponse = service.getAllUsers(pageable); return new ResponseEntity<>(UserDTO.UserPageDTO.fromPage(paginatedUserResponse), HttpStatus.OK); @@ -35,7 +36,7 @@ public ResponseEntity getAllUsers(Pageable pageable) { /// @return a [ResponseEntity] containing [UserDTO.UserResponseDTO] objects @PostMapping @ResponseStatus(HttpStatus.CREATED) - public ResponseEntity createUser(@RequestBody @Validated UserDTO.CreateUserDTO request) { + public ResponseEntity createUser(@RequestBody @Validated UserDTO.CreateUserDTO request) { // Create and persist the user final var userResponseDTO = service.createAndPersistUser(request); @@ -49,7 +50,7 @@ public ResponseEntity createUser(@RequestBody @Validate /// @return a [ResponseEntity] containing a summary of the action @DeleteMapping("/{uuid}") @PreAuthorize("hasRole('ADMIN') or #uuid == principal.uuid") - public ResponseEntity deleteUser(@PathVariable String uuid) { + public ResponseEntity<@NonNull String> deleteUser(@PathVariable String uuid) { // Attempt to validate the UUID value from the provided string // If the value is invalid, the GlobalExceptionHandler will throw a 400. final var uuidValue = UUID.fromString(uuid); diff --git a/src/main/java/org/openpodcastapi/opa/user/UserService.java b/src/main/java/org/openpodcastapi/opa/user/UserService.java index d658f1c..6e792b8 100644 --- a/src/main/java/org/openpodcastapi/opa/user/UserService.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserService.java @@ -1,6 +1,7 @@ package org.openpodcastapi.opa.user; import jakarta.persistence.EntityNotFoundException; +import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.springframework.dao.DataIntegrityViolationException; @@ -45,7 +46,7 @@ public UserDTO.UserResponseDTO createAndPersistUser(UserDTO.CreateUserDTO dto) t } @Transactional(readOnly = true) - public Page getAllUsers(Pageable pageable) { + public Page getAllUsers(Pageable pageable) { final var paginatedUserDTO = repository.findAll(pageable); log.debug("returning {} users", paginatedUserDTO.getTotalElements()); diff --git a/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java b/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java index 657e598..5a3d5c8 100644 --- a/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java +++ b/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java @@ -1,5 +1,6 @@ package org.openpodcastapi.opa.util; +import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.openpodcastapi.opa.user.UserEntity; @@ -31,7 +32,7 @@ public class AdminUserInitializer implements ApplicationRunner { /// /// @param args the application arguments @Override - public void run(ApplicationArguments args) { + public void run(@NonNull ApplicationArguments args) { if (userRepository.getUserByUsername(username).isEmpty()) { final var adminUserEntity = new UserEntity(); adminUserEntity.setUsername(username); diff --git a/src/main/java/org/openpodcastapi/opa/util/RefreshTokenCleanup.java b/src/main/java/org/openpodcastapi/opa/util/RefreshTokenCleanup.java new file mode 100644 index 0000000..d1dc368 --- /dev/null +++ b/src/main/java/org/openpodcastapi/opa/util/RefreshTokenCleanup.java @@ -0,0 +1,26 @@ +package org.openpodcastapi.opa.util; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.openpodcastapi.opa.security.RefreshTokenRepository; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +@Component +@RequiredArgsConstructor +@Log4j2 +public class RefreshTokenCleanup { + + private final RefreshTokenRepository repository; + + /// Runs a task every hour to clean up expired refresh tokens + @Scheduled(cron = "0 0 * * * ?") + @Transactional + public void deleteExpiredTokens() { + final int deleted = repository.deleteAllByExpiresAtBefore(Instant.now()); + log.info("Deleted {} expired refresh tokens", deleted); + } +} \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 8120227..183a9c5 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -28,13 +28,11 @@ spring: port: "${REDIS_PORT}" username: "${REDIS_USERNAME:redis}" password: "${REDIS_PASSWORD:changeme}" - cache: - type: redis session: timeout: 7d - redis: - namespace: "spring:session" - flush-mode: on_save + data: + redis: + flush-mode: on_save server: port: 8080 diff --git a/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java b/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java index 454ff32..87ebf10 100644 --- a/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java +++ b/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java @@ -5,12 +5,13 @@ import org.openpodcastapi.opa.security.RefreshTokenRepository; import org.openpodcastapi.opa.security.TokenService; import org.openpodcastapi.opa.user.UserEntity; -import org.openpodcastapi.opa.user.UserRoles; import org.openpodcastapi.opa.user.UserRepository; +import org.openpodcastapi.opa.user.UserRoles; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.restdocs.test.autoconfigure.AutoConfigureRestDocs; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.http.MediaType; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -49,6 +50,7 @@ class AuthApiTest { private UserRepository userRepository; @MockitoBean + @Qualifier("apiLoginManager") private AuthenticationManager authenticationManager; @MockitoBean diff --git a/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java b/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java index b135c72..a9da859 100644 --- a/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java +++ b/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.persistence.EntityNotFoundException; +import lombok.NonNull; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.openpodcastapi.opa.security.TokenService; @@ -11,9 +12,9 @@ import org.openpodcastapi.opa.user.UserRepository; import org.openpodcastapi.opa.user.UserRoles; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.restdocs.test.autoconfigure.AutoConfigureRestDocs; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; @@ -98,7 +99,7 @@ void getAllSubscriptionsForAnonymous_shouldReturn401() throws Exception { void getAllSubscriptionsForUser_shouldReturnSubscriptions() throws Exception { SubscriptionDTO.UserSubscriptionDTO sub1 = new SubscriptionDTO.UserSubscriptionDTO(UUID.randomUUID(), "test.com/feed1", Instant.now(), Instant.now(), true); SubscriptionDTO.UserSubscriptionDTO sub2 = new SubscriptionDTO.UserSubscriptionDTO(UUID.randomUUID(), "test.com/feed2", Instant.now(), Instant.now(), true); - Page page = new PageImpl<>(List.of(sub1, sub2)); + Page page = new PageImpl<>(List.of(sub1, sub2)); when(subscriptionService.getAllActiveSubscriptionsForUser(eq(mockUser.getId()), any(Pageable.class))) .thenReturn(page); @@ -144,7 +145,7 @@ void getAllSubscriptionsForUser_shouldReturnSubscriptions() throws Exception { void getAllSubscriptionsForUser_shouldIncludeUnsubscribedWhenRequested() throws Exception { SubscriptionDTO.UserSubscriptionDTO sub1 = new SubscriptionDTO.UserSubscriptionDTO(UUID.randomUUID(), "test.com/feed1", Instant.now(), Instant.now(), true); SubscriptionDTO.UserSubscriptionDTO sub2 = new SubscriptionDTO.UserSubscriptionDTO(UUID.randomUUID(), "test.com/feed2", Instant.now(), Instant.now(), false); - Page page = new PageImpl<>(List.of(sub1, sub2)); + Page page = new PageImpl<>(List.of(sub1, sub2)); when(subscriptionService.getAllSubscriptionsForUser(eq(mockUser.getId()), any(Pageable.class))) .thenReturn(page); diff --git a/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java b/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java index 218c612..e25e06a 100644 --- a/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java +++ b/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java @@ -1,12 +1,13 @@ package org.openpodcastapi.opa.user; +import lombok.NonNull; import lombok.extern.log4j.Log4j2; import org.junit.jupiter.api.Test; import org.openpodcastapi.opa.security.TokenService; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.restdocs.test.autoconfigure.AutoConfigureRestDocs; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.http.MediaType; @@ -98,7 +99,7 @@ void getAllUsers_shouldReturn200_andList() throws Exception { ); // Mock the service call to return users - PageImpl page = new PageImpl<>(List.of(user1, user2), PageRequest.of(0, 2), 2); + PageImpl page = new PageImpl<>(List.of(user1, user2), PageRequest.of(0, 2), 2); when(userService.getAllUsers(any())).thenReturn(page); // Perform the test for the admin role diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 2b1edae..f12a78f 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -8,22 +8,14 @@ spring: database-platform: org.hibernate.dialect.H2Dialect hibernate: ddl-auto: create-drop - h2: - console: - enabled: true flyway: enabled: false data: redis: - host: "${REDIS_HOST:127.0.0.1}" - port: "${REDIS_PORT:6379}" + host: "${REDIS_HOST:localhost}" + port: "${REDIS_PORT:0}" username: "${REDIS_USERNAME:redis}" password: "${REDIS_PASSWORD:changeme}" - cache: - type: redis - session: - timeout: 7d - jwt: secret: "a-very-long-value-used-only-to-run-tests"