From 278584cb5221389f598b94436f9a9dbf13a3c99f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Ainsworth?= Date: Sat, 22 Nov 2025 21:37:14 +0100 Subject: [PATCH] Add basic JWT-based auth --- .env-sample | 6 + pom.xml | 24 ++- src/docs/auth.adoc | 27 ++++ .../opa/auth/ApiAuthController.java | 69 ++++++++ .../org/openpodcastapi/opa/auth/DTOs.java | 49 ++++++ .../opa/auth/JwtAccessDeniedHandler.java | 35 ++++ .../opa/auth/JwtAuthenticationEntryPoint.java | 35 ++++ .../CustomRegisteredClientRepository.java | 37 ----- .../opa/config/AuthServerConfig.java | 27 ---- .../opa/config/JwtAuthenticationFilter.java | 115 +++++++++++++ .../openpodcastapi/opa/config/JwtService.java | 14 ++ .../opa/config/SecurityConfig.java | 35 +++- .../opa/security/RefreshToken.java | 38 +++++ .../opa/security/RefreshTokenRepository.java | 12 ++ .../opa/security/TokenService.java | 97 +++++++++++ .../opa/service/CustomUserDetails.java | 10 +- .../opa/service/CustomUserDetailsService.java | 5 +- .../SubscriptionRestController.java | 6 + .../mapper/SubscriptionMapper.java | 1 + ...hController.java => UiAuthController.java} | 2 +- .../user/controller/UserRestController.java | 1 + .../opa/user/repository/UserRepository.java | 2 + src/main/resources/application.yaml | 16 +- .../openpodcastapi/opa/auth/AuthApiTest.java | 152 ++++++++++++++++++ .../SubscriptionRestControllerTest.java | 39 +++-- .../UserSubscriptionMapperTest.java | 4 + .../opa/user/UserRestControllerTest.java | 119 ++++---------- src/test/resources/application-test.yaml | 38 +++++ 28 files changed, 823 insertions(+), 192 deletions(-) create mode 100644 src/docs/auth.adoc create mode 100644 src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java create mode 100644 src/main/java/org/openpodcastapi/opa/auth/DTOs.java create mode 100644 src/main/java/org/openpodcastapi/opa/auth/JwtAccessDeniedHandler.java create mode 100644 src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationEntryPoint.java delete mode 100644 src/main/java/org/openpodcastapi/opa/client/CustomRegisteredClientRepository.java delete mode 100644 src/main/java/org/openpodcastapi/opa/config/AuthServerConfig.java create mode 100644 src/main/java/org/openpodcastapi/opa/config/JwtAuthenticationFilter.java create mode 100644 src/main/java/org/openpodcastapi/opa/config/JwtService.java create mode 100644 src/main/java/org/openpodcastapi/opa/security/RefreshToken.java create mode 100644 src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java create mode 100644 src/main/java/org/openpodcastapi/opa/security/TokenService.java rename src/main/java/org/openpodcastapi/opa/ui/controller/{AuthController.java => UiAuthController.java} (98%) create mode 100644 src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java create mode 100644 src/test/resources/application-test.yaml 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 authorities) implements UserDetails { + Set roles) implements UserDetails { @Override public String getUsername() { @@ -22,7 +26,9 @@ public String getPassword() { @Override public Collection 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