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 extends GrantedAuthority> getAuthorities() {
+ public @NonNull Collection extends GrantedAuthority> 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"