diff --git a/.env-sample b/.env-sample
index 22c6291..6a11381 100644
--- a/.env-sample
+++ b/.env-sample
@@ -11,3 +11,9 @@ REDIS_PORT=6379
ADMIN_USERNAME=admin
ADMIN_EMAIL=
ADMIN_PASSWORD=
+
+# Used to generate JWT keys. Run `openssl rand -base64 45` to generate
+JWT_SECRET=
+JWT_EXPIRATION_MINUTES=15
+JWT_REFRESH_DAYS=7
+JWT_TTL=3600000
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 9f5b3d8..b7500c4 100644
--- a/pom.xml
+++ b/pom.xml
@@ -146,16 +146,28 @@
thymeleaf-extras-springsecurity6
- org.springframework.boot
- spring-boot-starter-oauth2-authorization-server
+ org.flywaydb
+ flyway-database-postgresql
- org.springframework.boot
- spring-boot-starter-oauth2-client
+ io.jsonwebtoken
+ jjwt-api
+ 0.13.0
- org.flywaydb
- flyway-database-postgresql
+ io.jsonwebtoken
+ jjwt-impl
+ 0.13.0
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.13.0
+
+
+ com.h2database
+ h2
+ runtime
diff --git a/src/docs/auth.adoc b/src/docs/auth.adoc
new file mode 100644
index 0000000..788ef79
--- /dev/null
+++ b/src/docs/auth.adoc
@@ -0,0 +1,27 @@
+= Auth endpoint
+:doctype: book
+:sectlinks:
+
+The `auth` endpoint exposes operations for authenticating against the API.
+
+[[actions-auth]]
+== Actions
+
+[[actions-login]]
+=== Log in
+
+[source,httprequest]
+----
+POST /api/auth/login
+----
+
+Authenticates a user with `username` and `password`. These values must match the values of the user's account.
+
+operation::auth-token[snippets='request-fields,curl-request,response-fields,http-response']
+
+[[actions-refresh]]
+=== Request a new access token
+
+You can request a new access token by passing a valid refresh value to the API.
+
+operation::auth-refresh[snippets='request-fields,curl-request,response-fields,http-response']
diff --git a/src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java b/src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java
new file mode 100644
index 0000000..909dc71
--- /dev/null
+++ b/src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java
@@ -0,0 +1,69 @@
+package org.openpodcastapi.opa.auth;
+
+import jakarta.persistence.EntityNotFoundException;
+import jakarta.validation.constraints.NotNull;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.openpodcastapi.opa.config.JwtService;
+import org.openpodcastapi.opa.security.TokenService;
+import org.openpodcastapi.opa.user.model.User;
+import org.openpodcastapi.opa.user.repository.UserRepository;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequiredArgsConstructor
+@Log4j2
+public class ApiAuthController {
+
+ private final JwtService jwtService;
+ private final TokenService tokenService;
+ private final UserRepository userRepository;
+ private final AuthenticationManager authenticationManager;
+
+ @PostMapping("/api/auth/login")
+ public ResponseEntity login(@RequestBody @NotNull DTOs.LoginRequest loginRequest) {
+ // Set the authentication using the provided details
+ Authentication authentication = authenticationManager.authenticate(
+ new UsernamePasswordAuthenticationToken(loginRequest.username(), loginRequest.password())
+ );
+
+ // Set the security context holder to the authenticated user
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+
+ // Fetch the user record from the database
+ User user = userRepository.findByUsername(loginRequest.username()).orElseThrow(() -> new EntityNotFoundException("No user with username " + loginRequest.username() + " found"));
+
+ // Generate the access and refresh tokens for the user
+ String accessToken = tokenService.generateAccessToken(user);
+ String refreshToken = tokenService.generateRefreshToken(user);
+
+ // Format the tokens and expiration time into a DTO
+ DTOs.LoginSuccessResponse response = new DTOs.LoginSuccessResponse(accessToken, refreshToken, String.valueOf(jwtService.getExpirationTime()));
+
+ return ResponseEntity.ok(response);
+ }
+
+ @PostMapping("/api/auth/refresh")
+ public ResponseEntity getRefreshToken(@RequestBody @NotNull DTOs.RefreshTokenRequest refreshTokenRequest) {
+ User targetUser = userRepository.findByUsername(refreshTokenRequest.username()).orElseThrow(() -> new EntityNotFoundException("No user with username " + refreshTokenRequest.username() + " found"));
+
+ // Validate the existing refresh token
+ User user = tokenService.validateRefreshToken(refreshTokenRequest.refreshToken(), targetUser);
+
+ // Generate new access token
+ String newAccessToken = tokenService.generateAccessToken(user);
+
+ // Format the token and expiration time into a DTO
+ DTOs.RefreshTokenResponse response = new DTOs.RefreshTokenResponse(newAccessToken, String.valueOf(jwtService.getExpirationTime()));
+
+ return ResponseEntity.ok(response);
+ }
+}
+
diff --git a/src/main/java/org/openpodcastapi/opa/auth/DTOs.java b/src/main/java/org/openpodcastapi/opa/auth/DTOs.java
new file mode 100644
index 0000000..5acf7f8
--- /dev/null
+++ b/src/main/java/org/openpodcastapi/opa/auth/DTOs.java
@@ -0,0 +1,49 @@
+package org.openpodcastapi.opa.auth;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import jakarta.validation.constraints.NotNull;
+
+/// All DTOs for auth methods
+public class DTOs {
+ /// A DTO representing an API login request
+ ///
+ /// @param username the user's username
+ /// @param password the user's password
+ public record LoginRequest(
+ @JsonProperty(value = "username", required = true) @NotNull String username,
+ @JsonProperty(value = "password", required = true) @NotNull String password
+ ) {
+ }
+
+ /// A DTO representing a successful API authentication attempt
+ ///
+ /// @param accessToken the access token to be used to authenticate
+ /// @param expiresIn the TTL of the access token (in seconds)
+ /// @param refreshToken the refresh token to be used to request new access tokens
+ public record LoginSuccessResponse(
+ @JsonProperty(value = "accessToken", required = true) @NotNull String accessToken,
+ @JsonProperty(value = "refreshToken", required = true) @NotNull String refreshToken,
+ @JsonProperty(value = "expiresIn", required = true) @NotNull String expiresIn
+ ) {
+ }
+
+ /// A DTO representing a refresh token request
+ ///
+ /// @param username the username of the requesting user
+ /// @param refreshToken the refresh token used to issue a new token
+ public record RefreshTokenRequest(
+ @JsonProperty(value = "username", required = true) @NotNull String username,
+ @JsonProperty(value = "refreshToken", required = true) @NotNull String refreshToken
+ ) {
+ }
+
+ /// A DTO representing an updated access token from the refresh endpoint
+ ///
+ /// @param accessToken the newly generated access token
+ /// @param expiresIn the TTL of the token (in seconds)
+ public record RefreshTokenResponse(
+ @JsonProperty(value = "accessToken", required = true) @NotNull String accessToken,
+ @JsonProperty(value = "expiresIn", required = true) @NotNull String expiresIn
+ ) {
+ }
+}
diff --git a/src/main/java/org/openpodcastapi/opa/auth/JwtAccessDeniedHandler.java b/src/main/java/org/openpodcastapi/opa/auth/JwtAccessDeniedHandler.java
new file mode 100644
index 0000000..0b37643
--- /dev/null
+++ b/src/main/java/org/openpodcastapi/opa/auth/JwtAccessDeniedHandler.java
@@ -0,0 +1,35 @@
+package org.openpodcastapi.opa.auth;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+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
+public class JwtAccessDeniedHandler implements AccessDeniedHandler {
+
+ @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");
+
+ String body = """
+ {
+ "error": "Forbidden",
+ "message": "You do not have permission to access this resource."
+ }
+ """;
+
+ response.getWriter().write(body);
+ }
+}
diff --git a/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationEntryPoint.java b/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationEntryPoint.java
new file mode 100644
index 0000000..438de2b
--- /dev/null
+++ b/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationEntryPoint.java
@@ -0,0 +1,35 @@
+package org.openpodcastapi.opa.auth;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.extern.log4j.Log4j2;
+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
+@Log4j2
+public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
+ /// 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");
+
+ // Return a simple JSON error message
+ String body = """
+ {
+ "error": "Unauthorized",
+ "message": "You need to log in to access this resource."
+ }
+ """;
+
+ response.getWriter().write(body);
+ }
+}
diff --git a/src/main/java/org/openpodcastapi/opa/client/CustomRegisteredClientRepository.java b/src/main/java/org/openpodcastapi/opa/client/CustomRegisteredClientRepository.java
deleted file mode 100644
index 50ec40f..0000000
--- a/src/main/java/org/openpodcastapi/opa/client/CustomRegisteredClientRepository.java
+++ /dev/null
@@ -1,37 +0,0 @@
-package org.openpodcastapi.opa.client;
-
-import lombok.extern.log4j.Log4j2;
-import org.springframework.jdbc.core.JdbcTemplate;
-import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
-import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
-import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
-import org.springframework.stereotype.Repository;
-
-@Log4j2
-@Repository
-public class CustomRegisteredClientRepository extends JdbcRegisteredClientRepository {
-
- public CustomRegisteredClientRepository(JdbcTemplate jdbcTemplate) {
- super(jdbcTemplate);
- }
-
- @Override
- public void save(RegisteredClient client) {
- client.getRedirectUris().forEach(uri -> {
- if (!uri.startsWith("https://") && !uri.startsWith("myapp://")) {
- throw new IllegalArgumentException("Invalid redirect URI: " + uri);
- }
- });
-
- // Add defaults if missing
- var modified = RegisteredClient.from(client)
- .clientSettings(ClientSettings.builder()
- .requireProofKey(true)
- .requireAuthorizationConsent(true)
- .build())
- .build();
-
- log.info("Registering new OAuth client: {}", modified.getClientId());
- super.save(modified);
- }
-}
diff --git a/src/main/java/org/openpodcastapi/opa/config/AuthServerConfig.java b/src/main/java/org/openpodcastapi/opa/config/AuthServerConfig.java
deleted file mode 100644
index b6f5a8b..0000000
--- a/src/main/java/org/openpodcastapi/opa/config/AuthServerConfig.java
+++ /dev/null
@@ -1,27 +0,0 @@
-package org.openpodcastapi.opa.config;
-
-import lombok.RequiredArgsConstructor;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.security.config.Customizer;
-import org.springframework.security.config.annotation.web.builders.HttpSecurity;
-import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
-import org.springframework.security.web.SecurityFilterChain;
-
-@Configuration
-@RequiredArgsConstructor
-public class AuthServerConfig {
-
- @Bean
- public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
- var authorizationServerConfigurer = new OAuth2AuthorizationServerConfigurer();
-
- http.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
- .with(authorizationServerConfigurer, config ->
- config.oidc(oidc ->
- oidc.clientRegistrationEndpoint(Customizer.withDefaults())))
- .formLogin(Customizer.withDefaults());
-
- return http.build();
- }
-}
diff --git a/src/main/java/org/openpodcastapi/opa/config/JwtAuthenticationFilter.java b/src/main/java/org/openpodcastapi/opa/config/JwtAuthenticationFilter.java
new file mode 100644
index 0000000..4b02aea
--- /dev/null
+++ b/src/main/java/org/openpodcastapi/opa/config/JwtAuthenticationFilter.java
@@ -0,0 +1,115 @@
+package org.openpodcastapi.opa.config;
+
+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.model.User;
+import org.openpodcastapi.opa.user.repository.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 user the [User] to fetch a token for
+ /// @return a generated token
+ /// @throws EntityNotFoundException if no matching user is found
+ private static UsernamePasswordAuthenticationToken getUsernamePasswordAuthenticationToken(User user) throws EntityNotFoundException {
+ // Create a new CustomUserDetails entity with the fetched user
+ CustomUserDetails userDetails =
+ new CustomUserDetails(user.getId(),
+ user.getUuid(),
+ user.getUsername(),
+ user.getPassword(),
+ user.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 {
+
+ // Don't apply the check on the auth endpoints
+ if (req.getRequestURI().startsWith("/api/auth/")) {
+ chain.doFilter(req, res);
+ return;
+ }
+
+ String header = req.getHeader(HttpHeaders.AUTHORIZATION);
+ 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
+ String token = header.substring(7);
+
+ try {
+ // Extract the claims from the JWT
+ Claims claims = Jwts.parser()
+ .verifyWith(key)
+ .build()
+ .parseSignedClaims(token)
+ .getPayload();
+
+ // Extract the user's UUID from the claims
+ String userUuid = claims.getSubject();
+ UUID parsedUuid = UUID.fromString(userUuid);
+
+ // Fetch the matching user
+ User user = repository.getUserByUuid(parsedUuid).orElseThrow(() -> new EntityNotFoundException("No matching user found"));
+
+ // Create a user
+ UsernamePasswordAuthenticationToken authentication = getUsernamePasswordAuthenticationToken(user);
+
+ 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/config/JwtService.java b/src/main/java/org/openpodcastapi/opa/config/JwtService.java
new file mode 100644
index 0000000..43ff4d0
--- /dev/null
+++ b/src/main/java/org/openpodcastapi/opa/config/JwtService.java
@@ -0,0 +1,14 @@
+package org.openpodcastapi.opa.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+@Service
+public class JwtService {
+ @Value("${jwt.ttl}")
+ private String jwtExpiration;
+
+ public long getExpirationTime() {
+ return Long.parseLong(jwtExpiration);
+ }
+}
diff --git a/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java b/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java
index e4363f1..f916d91 100644
--- a/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java
+++ b/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java
@@ -1,39 +1,54 @@
package org.openpodcastapi.opa.config;
import lombok.RequiredArgsConstructor;
+import org.openpodcastapi.opa.auth.JwtAccessDeniedHandler;
+import org.openpodcastapi.opa.auth.JwtAuthenticationEntryPoint;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
-import org.springframework.security.config.Customizer;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
+import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
+@EnableMethodSecurity
public class SecurityConfig {
+ private final JwtAuthenticationFilter jwtAuthenticationFilter;
+ private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
+ private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
+
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
+ .csrf(csrf -> csrf.ignoringRequestMatchers("/api/**"))
+ .sessionManagement(sm -> sm
+ .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // Stateless session
.authorizeHttpRequests(auth -> auth
- .requestMatchers("/", "/login", "/logout-confirm", "/register", "/docs", "/css/**", "/js/**", "/images/**", "/favicon.ico").permitAll()
- .requestMatchers("/admin/**").hasRole("ADMIN")
+ .requestMatchers("/", "/login", "/logout-confirm", "/register", "/docs", "/css/**", "/js/**", "/images/**", "/favicon.ico", "/api/auth/login", "/api/auth/refresh").permitAll()
+ .requestMatchers("/api/v1/**").authenticated()
.anyRequest().authenticated())
+ .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
+ .exceptionHandling(exception -> exception
+ .authenticationEntryPoint(jwtAuthenticationEntryPoint)
+ .accessDeniedHandler(jwtAccessDeniedHandler))
.formLogin(login -> login
.loginPage("/login")
.defaultSuccessUrl("/home", true)
- .failureUrl("/login?error=true")
- )
+ .failureUrl("/login?error=true"))
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/")
.invalidateHttpSession(true)
.clearAuthentication(true)
- .deleteCookies("JSESSIONID")
- )
- .csrf(Customizer.withDefaults());
+ .deleteCookies("JSESSIONID"));
return http.build();
}
@@ -42,4 +57,8 @@ public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
+ @Bean
+ public AuthenticationManager authenticationManager(AuthenticationConfiguration cfg) throws Exception {
+ return cfg.getAuthenticationManager();
+ }
}
diff --git a/src/main/java/org/openpodcastapi/opa/security/RefreshToken.java b/src/main/java/org/openpodcastapi/opa/security/RefreshToken.java
new file mode 100644
index 0000000..ecbd85d
--- /dev/null
+++ b/src/main/java/org/openpodcastapi/opa/security/RefreshToken.java
@@ -0,0 +1,38 @@
+package org.openpodcastapi.opa.security;
+
+import jakarta.persistence.*;
+import lombok.*;
+import org.openpodcastapi.opa.user.model.User;
+
+import java.time.Instant;
+
+@Entity
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class RefreshToken {
+ @Id
+ @Generated
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false, unique = true)
+ private String tokenHash;
+
+ @ManyToOne(optional = false, fetch = FetchType.LAZY)
+ private User user;
+
+ @Column(nullable = false)
+ private Instant expiresAt;
+
+ @Column(nullable = false)
+ private Instant createdAt;
+
+ @PrePersist
+ public void prePersist() {
+ this.setCreatedAt(Instant.now());
+ }
+}
+
diff --git a/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java b/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java
new file mode 100644
index 0000000..56406e4
--- /dev/null
+++ b/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java
@@ -0,0 +1,12 @@
+package org.openpodcastapi.opa.security;
+
+import org.openpodcastapi.opa.user.model.User;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface RefreshTokenRepository extends JpaRepository {
+ List findAllByUser(User user);
+}
diff --git a/src/main/java/org/openpodcastapi/opa/security/TokenService.java b/src/main/java/org/openpodcastapi/opa/security/TokenService.java
new file mode 100644
index 0000000..3cb7b31
--- /dev/null
+++ b/src/main/java/org/openpodcastapi/opa/security/TokenService.java
@@ -0,0 +1,97 @@
+package org.openpodcastapi.opa.security;
+
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.security.Keys;
+import lombok.RequiredArgsConstructor;
+import org.openpodcastapi.opa.user.model.User;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.stereotype.Service;
+
+import javax.crypto.SecretKey;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.Date;
+import java.util.UUID;
+
+@Service
+@RequiredArgsConstructor
+public class TokenService {
+
+ private final RefreshTokenRepository repository;
+ private final BCryptPasswordEncoder passwordEncoder;
+
+ // The secret string used to generate secret keys
+ @Value("${jwt.secret}")
+ private String secret;
+
+ // The TTL for each JWT, in minutes
+ @Value("${jwt.expiration-minutes:15}")
+ private long accessTokenMinutes;
+
+ // The TTL for each refresh token, in days
+ @Value("${jwt.refresh-days:7}")
+ private long refreshTokenDays;
+
+ // The calculated secret key
+ private SecretKey key() {
+ return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
+ }
+
+ /// Generates an access token for a given user
+ ///
+ /// @param user the [User] to generate a token for
+ /// @return the generated token
+ public String generateAccessToken(User user) {
+ Instant now = Instant.now();
+ return Jwts.builder()
+ .subject(user.getUuid().toString())
+ .claim("username", user.getUsername())
+ .issuedAt(Date.from(now))
+ .expiration(Date.from(now.plusSeconds(accessTokenMinutes * 60)))
+ .signWith(key())
+ .compact();
+ }
+
+ /// Generates a refresh token for a given user
+ ///
+ /// @param user the [User] to generate a refresh token for
+ /// @return the generated refresh token
+ public String generateRefreshToken(User user) {
+ String raw = UUID.randomUUID().toString() + UUID.randomUUID();
+ String hash = passwordEncoder.encode(raw);
+
+ RefreshToken token = RefreshToken.builder()
+ .tokenHash(hash)
+ .user(user)
+ .createdAt(Instant.now())
+ .expiresAt(Instant.now().plusSeconds(refreshTokenDays * 24 * 3600))
+ .build();
+
+ repository.save(token);
+ return raw;
+ }
+
+ /// Validates the refresh token for a user and updates its expiry time
+ ///
+ /// @param rawToken the raw token to validate
+ /// @param user the [User] to validate the token for
+ /// @return the validated [User]
+ public User validateRefreshToken(String rawToken, User user) {
+ // Only fetch refresh tokens for the requesting user
+ for (RefreshToken token : repository.findAllByUser(user)) {
+ // Check that the raw token and the token hash match and the token is not expired
+ if (passwordEncoder.matches(rawToken, token.getTokenHash()) &&
+ token.getExpiresAt().isAfter(Instant.now())) {
+ // Update the expiry date on the refresh token
+ token.setExpiresAt(Instant.now().plusSeconds(refreshTokenDays * 24 * 3600));
+ RefreshToken updatedToken = repository.save(token);
+
+ // Return the user to confirm the token is valid
+ return updatedToken.getUser();
+ }
+ }
+ throw new IllegalArgumentException("Invalid refresh token");
+ }
+}
+
diff --git a/src/main/java/org/openpodcastapi/opa/service/CustomUserDetails.java b/src/main/java/org/openpodcastapi/opa/service/CustomUserDetails.java
index ac1b99d..daa6b5b 100644
--- a/src/main/java/org/openpodcastapi/opa/service/CustomUserDetails.java
+++ b/src/main/java/org/openpodcastapi/opa/service/CustomUserDetails.java
@@ -1,14 +1,18 @@
package org.openpodcastapi.opa.service;
+import org.openpodcastapi.opa.user.model.UserRoles;
import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
+import java.util.Set;
import java.util.UUID;
+import java.util.stream.Collectors;
/// Implements a custom user details service to expose UUID information
public record CustomUserDetails(Long id, UUID uuid, String username, String password,
- Collection extends GrantedAuthority> authorities) implements UserDetails {
+ Set roles) implements UserDetails {
@Override
public String getUsername() {
@@ -22,7 +26,9 @@ public String getPassword() {
@Override
public Collection extends GrantedAuthority> getAuthorities() {
- return authorities;
+ return roles.stream()
+ .map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
+ .collect(Collectors.toSet());
}
@Override
diff --git a/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java b/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java
index 087261a..eb4cdcb 100644
--- a/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java
+++ b/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java
@@ -3,7 +3,6 @@
import lombok.RequiredArgsConstructor;
import org.openpodcastapi.opa.user.model.User;
import org.openpodcastapi.opa.user.repository.UserRepository;
-import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
@@ -35,9 +34,7 @@ private CustomUserDetails mapToUserDetails(User user) {
user.getUuid(),
user.getUsername(),
user.getPassword(),
- user.getUserRoles().stream()
- .map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
- .toList()
+ user.getUserRoles()
);
}
diff --git a/src/main/java/org/openpodcastapi/opa/subscription/controller/SubscriptionRestController.java b/src/main/java/org/openpodcastapi/opa/subscription/controller/SubscriptionRestController.java
index 23ed62c..aea2b35 100644
--- a/src/main/java/org/openpodcastapi/opa/subscription/controller/SubscriptionRestController.java
+++ b/src/main/java/org/openpodcastapi/opa/subscription/controller/SubscriptionRestController.java
@@ -13,6 +13,7 @@
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@@ -34,7 +35,9 @@ public class SubscriptionRestController {
/// @return a paginated list of subscriptions
@GetMapping
@ResponseStatus(HttpStatus.OK)
+ @PreAuthorize("hasRole('USER')")
public ResponseEntity getAllSubscriptionsForUser(@AuthenticationPrincipal CustomUserDetails user, Pageable pageable, @RequestParam(defaultValue = "false") boolean includeUnsubscribed) {
+ log.info("{}", user.getAuthorities());
Page dto;
if (includeUnsubscribed) {
@@ -56,6 +59,7 @@ public ResponseEntity getAllSubscriptionsForUser(@Authentic
/// @throws IllegalArgumentException if the UUID is improperly formatted
@GetMapping("/{uuid}")
@ResponseStatus(HttpStatus.OK)
+ @PreAuthorize("hasRole('USER')")
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.
@@ -76,6 +80,7 @@ public ResponseEntity getSubscriptionByUuid(@PathVariable S
/// @throws IllegalArgumentException if the UUID is improperly formatted
@PostMapping("/{uuid}/unsubscribe")
@ResponseStatus(HttpStatus.OK)
+ @PreAuthorize("hasRole('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.
@@ -91,6 +96,7 @@ public ResponseEntity unsubscribeUserFromFeed(@PathVariable
/// @param request a list of [SubscriptionCreateDto] objects
/// @return a [BulkSubscriptionResponse] object
@PostMapping
+ @PreAuthorize("hasRole('USER')")
public ResponseEntity createUserSubscriptions(@RequestBody List request, @AuthenticationPrincipal CustomUserDetails user) {
BulkSubscriptionResponse response = service.addSubscriptions(request, user.id());
diff --git a/src/main/java/org/openpodcastapi/opa/subscription/mapper/SubscriptionMapper.java b/src/main/java/org/openpodcastapi/opa/subscription/mapper/SubscriptionMapper.java
index 75af5be..14d606d 100644
--- a/src/main/java/org/openpodcastapi/opa/subscription/mapper/SubscriptionMapper.java
+++ b/src/main/java/org/openpodcastapi/opa/subscription/mapper/SubscriptionMapper.java
@@ -9,6 +9,7 @@
@Mapper(componentModel = "spring")
public interface SubscriptionMapper {
+ @Mapping(target = "id", ignore = true)
@Mapping(target = "uuid", source = "uuid")
@Mapping(target = "subscribers", ignore = true)
@Mapping(target = "createdAt", ignore = true)
diff --git a/src/main/java/org/openpodcastapi/opa/ui/controller/AuthController.java b/src/main/java/org/openpodcastapi/opa/ui/controller/UiAuthController.java
similarity index 98%
rename from src/main/java/org/openpodcastapi/opa/ui/controller/AuthController.java
rename to src/main/java/org/openpodcastapi/opa/ui/controller/UiAuthController.java
index 90cde9a..8e678c4 100644
--- a/src/main/java/org/openpodcastapi/opa/ui/controller/AuthController.java
+++ b/src/main/java/org/openpodcastapi/opa/ui/controller/UiAuthController.java
@@ -17,7 +17,7 @@
@Controller
@Log4j2
@RequiredArgsConstructor
-public class AuthController {
+public class UiAuthController {
private static final String USER_REQUEST_ATTRIBUTE = "createUserRequest";
private static final String REGISTER_TEMPLATE = "auth/register";
private final UserService userService;
diff --git a/src/main/java/org/openpodcastapi/opa/user/controller/UserRestController.java b/src/main/java/org/openpodcastapi/opa/user/controller/UserRestController.java
index dff8928..a80c61e 100644
--- a/src/main/java/org/openpodcastapi/opa/user/controller/UserRestController.java
+++ b/src/main/java/org/openpodcastapi/opa/user/controller/UserRestController.java
@@ -23,6 +23,7 @@ public class UserRestController {
@GetMapping
@ResponseStatus(HttpStatus.OK)
+ @PreAuthorize("hasRole('ADMIN')")
public ResponseEntity getAllUsers(Pageable pageable) {
Page users = service.getAllUsers(pageable);
diff --git a/src/main/java/org/openpodcastapi/opa/user/repository/UserRepository.java b/src/main/java/org/openpodcastapi/opa/user/repository/UserRepository.java
index 30d8512..43019fb 100644
--- a/src/main/java/org/openpodcastapi/opa/user/repository/UserRepository.java
+++ b/src/main/java/org/openpodcastapi/opa/user/repository/UserRepository.java
@@ -16,4 +16,6 @@ public interface UserRepository extends JpaRepository {
Boolean existsUserByUsername(String username);
Boolean existsUserByEmail(String email);
+
+ Optional findByUsername(String username);
}
diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml
index e2beeb0..8de3ae7 100644
--- a/src/main/resources/application.yaml
+++ b/src/main/resources/application.yaml
@@ -7,13 +7,6 @@ spring:
resources:
static-locations: classpath:/static/docs
add-mappings: true
- security:
- oauth2:
- authorizationserver:
- issuer: http://localhost:8080
- resourceserver:
- jwt:
- issuer-uri: http://localhost:8080
datasource:
url: "jdbc:postgresql://${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}"
username: "${POSTGRES_USER}"
@@ -29,6 +22,7 @@ spring:
schemas: "${POSTGRES_DB}"
locations: filesystem:db/migration
url: "jdbc:postgresql://${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}"
+ enabled: false
data:
redis:
host: "${REDIS_HOST}"
@@ -43,7 +37,13 @@ spring:
server:
port: 8080
+jwt:
+ secret: "${JWT_SECRET}"
+ ttl: "${JWT_TTL}"
+ expiration-minutes: "${JWT_EXPIRATION_MINUTES}"
+ refresh-days: "${JWT_REFRESH_DAYS}"
+
admin:
username: "${ADMIN_USERNAME:admin}"
password: "${ADMIN_PASSWORD:changeme}"
- email: "${ADMIN_EMAIL:admin@example.com}"
\ No newline at end of file
+ email: "${ADMIN_EMAIL:admin@example.com}"
diff --git a/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java b/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java
new file mode 100644
index 0000000..c7fdcff
--- /dev/null
+++ b/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java
@@ -0,0 +1,152 @@
+package org.openpodcastapi.opa.auth;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.openpodcastapi.opa.security.RefreshTokenRepository;
+import org.openpodcastapi.opa.security.TokenService;
+import org.openpodcastapi.opa.user.model.User;
+import org.openpodcastapi.opa.user.model.UserRoles;
+import org.openpodcastapi.opa.user.repository.UserRepository;
+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.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.time.Instant;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
+import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;
+import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
+import static org.springframework.restdocs.payload.PayloadDocumentation.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+@AutoConfigureRestDocs(outputDir = "target/generated-snippets")
+class AuthApiTest {
+
+ @Autowired
+ MockMvc mockMvc;
+
+ @MockitoBean
+ private BCryptPasswordEncoder passwordEncoder;
+
+ @MockitoBean
+ private UserRepository userRepository;
+
+ @MockitoBean
+ private AuthenticationManager authenticationManager;
+
+ @MockitoBean
+ private RefreshTokenRepository refreshTokenRepository;
+
+ @MockitoBean
+ private TokenService tokenService;
+
+ @BeforeEach
+ void setup() {
+ // Mock the user lookup
+ User mockUser = User.builder()
+ .id(2L)
+ .uuid(UUID.randomUUID())
+ .email("test@test.test")
+ .password("password")
+ .username("test_user")
+ .createdAt(Instant.now())
+ .updatedAt(Instant.now())
+ .userRoles(Set.of(UserRoles.USER))
+ .build();
+
+ // Mock repository behavior for finding user by username
+ when(userRepository.findByUsername("test_user")).thenReturn(Optional.of(mockUser));
+
+ // Mock the refresh token validation to return the mock user
+ when(tokenService.validateRefreshToken(anyString(), any(User.class)))
+ .thenReturn(mockUser);
+
+ // Mock the access token generation
+ when(tokenService.generateAccessToken(any(User.class))).thenReturn("eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiI2MmJjZjczZC0xNGVjLTRkZmMtOGY5ZS1hMDQ0YjE4YjJiYTUiLCJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNzYzODQzMzEwLCJleHAiOjE3NjM4NDQyMTB9.B9aj5DoVpNe6HTxXm8iTHj5XaqFCcR1ZHRZq6xiqY28YvGGStVkPpedDVZfc02-B");
+
+ // Mock the refresh token generation
+ when(tokenService.generateRefreshToken(any(User.class))).thenReturn("8be54fc2-70ec-48ef-a8ff-4548fd8932b8e947a7ab-99b5-4cfb-b546-ac37eafa6c98");
+ }
+
+ @Test
+ void authenticate_and_get_tokens() throws Exception {
+ mockMvc.perform(post("/api/auth/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ { "username": "test_user", "password": "password" }
+ """))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.accessToken").exists())
+ .andExpect(jsonPath("$.refreshToken").exists())
+ .andExpect(jsonPath("$.expiresIn").exists())
+ .andDo(document("auth-token",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ requestFields(
+ fieldWithPath("username").description("The user's username"),
+ fieldWithPath("password").description("The user's password")
+ ),
+ responseFields(
+ fieldWithPath("accessToken").description("A JWT access token"),
+ fieldWithPath("refreshToken").description("A JWT refresh token"),
+ fieldWithPath("expiresIn").description("The number of milliseconds until the access token expires")
+ )
+ ));
+ }
+
+ @Test
+ void refresh_token_flow() throws Exception {
+ String json = mockMvc.perform(post("/api/auth/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ { "username": "test_user", "password": "password" }
+ """))
+ .andReturn()
+ .getResponse()
+ .getContentAsString();
+
+ String refresh = com.jayway.jsonpath.JsonPath.read(json, "$.refreshToken");
+
+ mockMvc.perform(post("/api/auth/refresh")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {
+ "username": "%s",
+ "refreshToken": "%s"
+ }
+ """.formatted("test_user", refresh)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.accessToken").exists())
+ .andExpect(jsonPath("$.expiresIn").exists())
+ .andDo(document("auth-refresh",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ requestFields(
+ fieldWithPath("username").description("The username of the user requesting the refresh"),
+ fieldWithPath("refreshToken").description("A valid refresh token")
+ ),
+ responseFields(
+ fieldWithPath("accessToken").description("New JWT access token"),
+ fieldWithPath("expiresIn").description("The number of milliseconds until the access token expires")
+ )
+ ));
+ }
+}
+
diff --git a/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionRestControllerTest.java b/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionRestControllerTest.java
index c92fddc..6c0c8fe 100644
--- a/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionRestControllerTest.java
+++ b/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionRestControllerTest.java
@@ -3,16 +3,16 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.openpodcastapi.opa.service.CustomUserDetails;
-import org.openpodcastapi.opa.subscription.controller.SubscriptionRestController;
import org.openpodcastapi.opa.subscription.dto.BulkSubscriptionResponse;
import org.openpodcastapi.opa.subscription.dto.SubscriptionCreateDto;
import org.openpodcastapi.opa.subscription.dto.SubscriptionFailureDto;
import org.openpodcastapi.opa.subscription.dto.UserSubscriptionDto;
import org.openpodcastapi.opa.subscription.service.UserSubscriptionService;
+import org.openpodcastapi.opa.user.model.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.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
@@ -20,17 +20,20 @@
import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
-import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.util.List;
+import java.util.Set;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.when;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
+import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
import static org.springframework.restdocs.payload.PayloadDocumentation.*;
@@ -40,7 +43,8 @@
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-@WebMvcTest(SubscriptionRestController.class)
+@SpringBootTest
+@ActiveProfiles("test")
@AutoConfigureMockMvc
@AutoConfigureRestDocs(outputDir = "target/generated-snippets")
class SubscriptionRestControllerTest {
@@ -54,8 +58,9 @@ class SubscriptionRestControllerTest {
private UserSubscriptionService subscriptionService;
@Test
+ @WithMockUser(username = "alice")
void getAllSubscriptionsForUser_shouldReturnSubscriptions() throws Exception {
- CustomUserDetails user = new CustomUserDetails(1L, UUID.randomUUID(), "alice", "alice@test.com", List.of(new SimpleGrantedAuthority("ROLE_USER")));
+ CustomUserDetails user = new CustomUserDetails(1L, UUID.randomUUID(), "alice", "alice@test.com", Set.of(UserRoles.USER));
UserSubscriptionDto sub1 = new UserSubscriptionDto(UUID.randomUUID(), "test.com/feed1", Instant.now(), Instant.now(), true);
UserSubscriptionDto sub2 = new UserSubscriptionDto(UUID.randomUUID(), "test.com/feed2", Instant.now(), Instant.now(), true);
@@ -64,7 +69,7 @@ void getAllSubscriptionsForUser_shouldReturnSubscriptions() throws Exception {
when(subscriptionService.getAllActiveSubscriptionsForUser(eq(user.id()), any(Pageable.class)))
.thenReturn(page);
- mockMvc.perform(RestDocumentationRequestBuilders.get("/api/v1/subscriptions")
+ mockMvc.perform(get("/api/v1/subscriptions")
.with(authentication(new UsernamePasswordAuthenticationToken(user, "password", user.getAuthorities())))
.param("page", "0")
.param("size", "20"))
@@ -98,10 +103,11 @@ void getAllSubscriptionsForUser_shouldReturnSubscriptions() throws Exception {
}
@Test
+ @WithMockUser(username = "alice")
void getAllSubscriptionsForUser_shouldIncludeUnsubscribedWhenRequested() throws Exception {
CustomUserDetails user = new CustomUserDetails(
1L, UUID.randomUUID(), "alice", "alice@test.com",
- List.of(new SimpleGrantedAuthority("ROLE_USER"))
+ Set.of(UserRoles.USER)
);
UserSubscriptionDto sub1 = new UserSubscriptionDto(UUID.randomUUID(), "test.com/feed1", Instant.now(), Instant.now(), true);
@@ -111,7 +117,7 @@ void getAllSubscriptionsForUser_shouldIncludeUnsubscribedWhenRequested() throws
when(subscriptionService.getAllSubscriptionsForUser(eq(user.id()), any(Pageable.class)))
.thenReturn(page);
- mockMvc.perform(RestDocumentationRequestBuilders.get("/api/v1/subscriptions")
+ mockMvc.perform(get("/api/v1/subscriptions")
.with(authentication(new UsernamePasswordAuthenticationToken(user, "password", user.getAuthorities())))
.param("includeUnsubscribed", "true"))
.andExpect(status().isOk())
@@ -122,15 +128,16 @@ void getAllSubscriptionsForUser_shouldIncludeUnsubscribedWhenRequested() throws
@Test
+ @WithMockUser(username = "alice")
void getSubscriptionByUuid_shouldReturnSubscription() throws Exception {
- CustomUserDetails user = new CustomUserDetails(1L, UUID.randomUUID(), "alice", "alice@test.com", List.of(new SimpleGrantedAuthority("ROLE_USER")));
+ CustomUserDetails user = new CustomUserDetails(1L, UUID.randomUUID(), "alice", "alice@test.com", Set.of(UserRoles.USER));
UUID subscriptionUuid = UUID.randomUUID();
UserSubscriptionDto sub = new UserSubscriptionDto(subscriptionUuid, "test.com/feed1", Instant.now(), Instant.now(), true);
when(subscriptionService.getUserSubscriptionBySubscriptionUuid(subscriptionUuid, user.id()))
.thenReturn(sub);
- mockMvc.perform(RestDocumentationRequestBuilders.get("/api/v1/subscriptions/{uuid}", subscriptionUuid)
+ mockMvc.perform(get("/api/v1/subscriptions/{uuid}", subscriptionUuid)
.with(authentication(new UsernamePasswordAuthenticationToken(user, "password", user.getAuthorities()))))
.andExpect(status().isOk())
.andDo(document("subscription-get",
@@ -150,8 +157,9 @@ void getSubscriptionByUuid_shouldReturnSubscription() throws Exception {
}
@Test
+ @WithMockUser(username = "testuser")
void createUserSubscriptions_shouldReturnMixedResponse() throws Exception {
- final CustomUserDetails user = new CustomUserDetails(1L, UUID.randomUUID(), "testuser", "test@test.com", List.of(new SimpleGrantedAuthority("ROLE_USER")));
+ final CustomUserDetails user = new CustomUserDetails(1L, UUID.randomUUID(), "testuser", "test@test.com", Set.of(UserRoles.USER));
final Instant timestamp = Instant.now();
final UUID goodFeedUUID = UUID.randomUUID();
@@ -197,8 +205,9 @@ void createUserSubscriptions_shouldReturnMixedResponse() throws Exception {
}
@Test
+ @WithMockUser(username = "testuser")
void createUserSubscription_shouldReturnSuccess() throws Exception {
- final CustomUserDetails user = new CustomUserDetails(1L, UUID.randomUUID(), "testuser", "test@test.com", List.of(new SimpleGrantedAuthority("ROLE_USER")));
+ final CustomUserDetails user = new CustomUserDetails(1L, UUID.randomUUID(), "testuser", "test@test.com", Set.of(UserRoles.USER));
final UUID goodFeedUUID = UUID.randomUUID();
final Instant timestamp = Instant.now();
@@ -237,8 +246,9 @@ void createUserSubscription_shouldReturnSuccess() throws Exception {
}
@Test
+ @WithMockUser(username = "testuser")
void createUserSubscription_shouldReturnFailure() throws Exception {
- final CustomUserDetails user = new CustomUserDetails(1L, UUID.randomUUID(), "testuser", "test@test.com", List.of(new SimpleGrantedAuthority("ROLE_USER")));
+ final CustomUserDetails user = new CustomUserDetails(1L, UUID.randomUUID(), "testuser", "test@test.com", Set.of(UserRoles.USER));
final String BAD_UUID = "62ad30ce-aac0-4f0a-a811";
@@ -271,13 +281,14 @@ void createUserSubscription_shouldReturnFailure() throws Exception {
}
@Test
+ @WithMockUser(username = "alice")
void updateSubscriptionStatus_shouldReturnUpdatedSubscription() throws Exception {
CustomUserDetails user = new CustomUserDetails(
1L,
UUID.randomUUID(),
"alice",
"alice@test.com",
- List.of(new SimpleGrantedAuthority("ROLE_USER"))
+ Set.of(UserRoles.USER)
);
UUID subscriptionUuid = UUID.randomUUID();
diff --git a/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionMapperTest.java b/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionMapperTest.java
index 30baf63..c8b04a0 100644
--- a/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionMapperTest.java
+++ b/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionMapperTest.java
@@ -2,6 +2,7 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.openpodcastapi.opa.config.JwtAuthenticationFilter;
import org.openpodcastapi.opa.subscription.dto.UserSubscriptionDto;
import org.openpodcastapi.opa.subscription.mapper.UserSubscriptionMapper;
import org.openpodcastapi.opa.subscription.mapper.UserSubscriptionMapperImpl;
@@ -28,6 +29,9 @@ class UserSubscriptionMapperTest {
@MockitoBean
private UserSubscriptionRepository userSubscriptionRepository;
+ @MockitoBean
+ private JwtAuthenticationFilter filter;
+
/// Tests that a [UserSubscription] entity maps to a [UserSubscriptionDto] representation
@Test
void testToDto() {
diff --git a/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java b/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java
index cec5101..2d102e4 100644
--- a/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java
+++ b/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java
@@ -1,22 +1,19 @@
package org.openpodcastapi.opa.user;
-import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.Test;
-import org.mockito.ArgumentMatchers;
-import org.openpodcastapi.opa.user.controller.UserRestController;
-import org.openpodcastapi.opa.user.dto.CreateUserDto;
import org.openpodcastapi.opa.user.dto.UserDto;
import org.openpodcastapi.opa.user.service.UserService;
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.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.MediaType;
-import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
@@ -24,76 +21,32 @@
import java.util.List;
import java.util.UUID;
+import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
+import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
-import static org.springframework.restdocs.payload.PayloadDocumentation.*;
+import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
+import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.queryParameters;
-import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-@WebMvcTest(UserRestController.class)
-@AutoConfigureMockMvc(addFilters = false)
+@SpringBootTest
+@ActiveProfiles("test")
+@AutoConfigureMockMvc
@AutoConfigureRestDocs(outputDir = "target/generated-snippets")
+@Log4j2
class UserRestControllerTest {
- @Autowired
- private MockMvc mockMvc;
@Autowired
- private ObjectMapper objectMapper;
+ private MockMvc mockMvc;
@MockitoBean
private UserService userService;
@Test
- void createUser_shouldReturn201() throws Exception {
- final Instant createdDate = Instant.now();
-
- final CreateUserDto request = new CreateUserDto(
- "alice",
- "aliceTest",
- "alice@test.com"
- );
-
- final UserDto response = new UserDto(
- UUID.randomUUID(),
- "alice",
- "alice@test.com",
- createdDate,
- createdDate
- );
-
- when(userService.createAndPersistUser(ArgumentMatchers.any())).thenReturn(response);
-
- mockMvc.perform(post("/api/v1/users")
- .with(csrf().asHeader())
- .contentType(MediaType.APPLICATION_JSON)
- .content(objectMapper.writeValueAsString(request)))
- .andExpect(status().isCreated())
- .andExpect(jsonPath("$.username").value("alice"))
- .andDo(document("users-create",
- preprocessRequest(prettyPrint(), modifyHeaders().remove("X-CSRF-TOKEN")),
- preprocessResponse(prettyPrint()),
- requestFields(
- fieldWithPath("username").description("Desired username").type(JsonFieldType.STRING),
- fieldWithPath("email").description("User email address").type(JsonFieldType.STRING),
- fieldWithPath("password").description("Plaintext password for the new account").type(JsonFieldType.STRING)
- ),
- responseFields(
- fieldWithPath("uuid").description("Generated user UUID").type(JsonFieldType.STRING),
- fieldWithPath("username").description("The user's username").type(JsonFieldType.STRING),
- fieldWithPath("email").description("The user's email").type(JsonFieldType.STRING),
- fieldWithPath("createdAt").description("The date at which the user was created").type(JsonFieldType.STRING),
- fieldWithPath("updatedAt").description("The date at which the user was last updated").type(JsonFieldType.STRING)
- )
- ));
- }
-
- @Test
- @WithMockUser(roles = "ADMIN")
+ @WithMockUser(roles = {"USER", "ADMIN"})
void getAllUsers_shouldReturn200_andList() throws Exception {
final Instant createdDate = Instant.now();
@@ -113,14 +66,16 @@ void getAllUsers_shouldReturn200_andList() throws Exception {
createdDate
);
- var page = new PageImpl<>(List.of(user1, user2), PageRequest.of(0, 2), 2);
- when(userService.getAllUsers(ArgumentMatchers.any())).thenReturn(page);
+ // Mock the service call to return users
+ PageImpl page = new PageImpl<>(List.of(user1, user2), PageRequest.of(0, 2), 2);
+ when(userService.getAllUsers(any())).thenReturn(page);
- mockMvc.perform(RestDocumentationRequestBuilders.get("/api/v1/users")
+ // Perform the test for the admin role
+ mockMvc.perform(get("/api/v1/users")
.accept(MediaType.APPLICATION_JSON)
.param("page", "0")
.param("size", "20"))
- .andExpect(status().isOk())
+ .andExpect(status().isOk()) // Expect 200 for admin role
.andDo(document("users-list",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
@@ -146,32 +101,26 @@ void getAllUsers_shouldReturn200_andList() throws Exception {
}
@Test
- void createInvalidUser_shouldReturn400() throws Exception {
- final CreateUserDto request = new CreateUserDto(
- "alice",
- "aliceTest",
- "alice" // invalid email should throw validation error
- );
-
- mockMvc.perform(post("/api/v1/users")
- .with(csrf())
- .contentType(MediaType.APPLICATION_JSON)
- .content(objectMapper.writeValueAsString(request)))
- .andExpect(status().isBadRequest())
- .andDo(document("users-create-bad-request",
+ @WithMockUser(roles = "USER")
+ // Mock the user with a "USER" role
+ void getAllUsers_shouldReturn403_forUserRole() throws Exception {
+ mockMvc.perform(get("/api/v1/users")
+ .accept(MediaType.APPLICATION_JSON)
+ .param("page", "0")
+ .param("size", "20"))
+ .andExpect(status().isForbidden()) // Expect 403 for the user role
+ .andDo(document("users-list",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
- requestFields(
- fieldWithPath("username").description("Desired username").type(JsonFieldType.STRING),
- fieldWithPath("email").description("User email address").type(JsonFieldType.STRING),
- fieldWithPath("password").description("Plaintext password for the new account").type(JsonFieldType.STRING)
+ queryParameters(
+ parameterWithName("page").description("The page number to fetch").optional(),
+ parameterWithName("size").description("The number of results to include on each page").optional()
),
responseFields(
- fieldWithPath("timestamp").description("Time of the error").type(JsonFieldType.STRING),
- fieldWithPath("status").description("HTTP status code").type(JsonFieldType.NUMBER),
- fieldWithPath("errors[].field").description("Field that caused the validation error").type(JsonFieldType.STRING),
- fieldWithPath("errors[].message").description("Validation error message").type(JsonFieldType.STRING)
+ fieldWithPath("error").description("Error message").type(JsonFieldType.STRING),
+ fieldWithPath("message").description("The specific error message").type(JsonFieldType.STRING)
)
));
}
}
+
diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml
new file mode 100644
index 0000000..2b1edae
--- /dev/null
+++ b/src/test/resources/application-test.yaml
@@ -0,0 +1,38 @@
+spring:
+ datasource:
+ url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
+ driver-class-name: org.h2.Driver
+ username: sa
+ password: password
+ jpa:
+ 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}"
+ 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"
+ ttl: "3600000"
+ expiration-minutes: "15"
+ refresh-days: "7"
+
+admin:
+ username: "${ADMIN_USERNAME:admin}"
+ password: "${ADMIN_PASSWORD:changeme}"
+ email: "${ADMIN_EMAIL:admin@example.com}"
+debug: true