Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 11 additions & 12 deletions src/docs/subscriptions.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
:sectlinks:

The `subscriptions` endpoint exposes operations taken on subscriptions.
A subscriptionEntity represents two things:
A subscription represents two things:

1. A podcast feed
2. The relationship between a user and a podcast feed
Expand All @@ -16,8 +16,8 @@ POST /api/v1/users
[[actions-subscriptions-create]]
== Create subscriptions

When a user adds a subscription to the system, a corresponding `subscriptionEntity` object is fetched or created depending on whether a matching subscriptionEntity is present.
A link is then created between the user and the subscriptionEntity.
When a user adds a subscription to the system, a corresponding `subscription` object is fetched or created depending on whether a matching subscription is present.
A link is then created between the user and the subscription.

operation::subscriptions-bulk-create-mixed[snippets='request-headers,request-fields,curl-request,response-fields,http-response']

Expand All @@ -38,28 +38,27 @@ include::{snippets}/subscriptions-bulk-create-mixed/http-response.adoc[]
[[actions-subscriptions-list]]
== List subscriptions

When a user fetches a list of subscriptions, their own subscriptions are returned.
The subscriptions of other users are not returned.
When a user fetches a list of subscriptions, only their own subscriptions are returned.

operation::subscriptions-list[snippets='request-headers,query-parameters,curl-request,response-fields,http-response']

=== Include unsubscribed

operation::subscriptions-list-with-unsubscribed[snippets='curl-request,http-response']

[[actions-subscriptionEntity-fetch]]
== Fetch a single subscriptionEntity
[[actions-subscription-fetch]]
== Fetch a single subscription

Returns the details of a single subscriptionEntity for the authenticated user.
Returns `404` if the user has no subscriptionEntity entry for the feed in question.
Returns the details of a single subscription for the authenticated user.
Returns `404` if the user has no subscription entry for the feed in question.

operation::subscription-get[snippets='request-headers,path-parameters,curl-request,response-fields,http-response']

[[actions-subscriptionEntity-update]]
[[actions-subscription-update]]
== Unsubscribe from a feed

Unsubscribes the authenticated user from a feed.
This action updates the user subscriptionEntity record to mark the subscriptionEntity as inactive.
It does not delete the subscriptionEntity record.
This action updates the user subscription record to mark the subscription as inactive.
It does not delete the subscription record.

operation::subscription-unsubscribe[snippets='request-headers,path-parameters,curl-request,response-fields,http-response']
7 changes: 0 additions & 7 deletions src/main/java/org/openpodcastapi/opa/OpenPodcastAPI.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
package org.openpodcastapi.opa;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
Expand All @@ -13,9 +11,4 @@ public class OpenPodcastAPI {
static void main(String[] args) {
SpringApplication.run(OpenPodcastAPI.class, args);
}

@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,32 +16,53 @@
import java.time.Instant;
import java.util.List;

/// A global handler for common exceptions thrown by the application.
///
/// Where possible, controllers should throw their own exceptions.
/// However, for common exceptions such as invalid parameters and
/// not found entities, a global exception handler can be added.
@RestControllerAdvice
@RequiredArgsConstructor
@Log4j2
public class GlobalExceptionHandler {
/// Returns a 404 if a database entity is not found
///
/// @param exception the thrown [EntityNotFoundException]
/// @return a [ResponseEntity] containing the error message
@ExceptionHandler(EntityNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ResponseEntity<@NonNull String> handleEntityNotFoundException(EntityNotFoundException error) {
log.debug("{}", error.getMessage());
public ResponseEntity<@NonNull String> handleEntityNotFoundException(EntityNotFoundException exception) {
log.debug("{}", exception.getMessage());
return ResponseEntity.notFound().build();
}

/// Returns a 400 error when conflicting data is entered
///
/// @param exception the thrown [DataIntegrityViolationException]
/// @return a [ResponseEntity] containing the error message
@ExceptionHandler(DataIntegrityViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ResponseEntity<@NonNull String> handleDataIntegrityViolationException(DataIntegrityViolationException e) {
return ResponseEntity.badRequest().body(e.getMessage());
public ResponseEntity<@NonNull String> handleDataIntegrityViolationException(DataIntegrityViolationException exception) {
return ResponseEntity.badRequest().body(exception.getMessage());
}

/// Returns a 400 error when illegal arguments are passed
///
/// @param exception the thrown [IllegalArgumentException]
/// @return a [ResponseEntity] containing the error message
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ResponseEntity<@NonNull String> handleIllegalArgumentException(IllegalArgumentException e) {
return ResponseEntity.badRequest().body(e.getMessage());
public ResponseEntity<@NonNull String> handleIllegalArgumentException(IllegalArgumentException exception) {
return ResponseEntity.badRequest().body(exception.getMessage());
}

/// Returns a 400 error when invalid arguments are passed to an endpoint
///
/// @param exception the thrown [MethodArgumentNotValidException]
/// @return a [ResponseEntity] containing the error message
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<@NonNull ValidationErrorResponse> handleValidationException(MethodArgumentNotValidException ex) {
List<ValidationErrorResponse.FieldError> errors = ex.getBindingResult().getFieldErrors().stream()
public ResponseEntity<@NonNull ValidationErrorResponse> handleValidationException(MethodArgumentNotValidException exception) {
List<ValidationErrorResponse.FieldError> errors = exception.getBindingResult().getFieldErrors().stream()
.map(fe -> new ValidationErrorResponse.FieldError(fe.getField(), fe.getDefaultMessage()))
.toList();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,19 @@

import java.security.Principal;

/// A helper class for adding user information to requests.
///
/// This class is used to populate user details in templates
/// and to ensure that a user is authenticated when viewing
/// web pages.
@Log4j2
@ControllerAdvice
public class GlobalModelAttributeAdvice {

/// Adds a boolean `isAuthenticated` property to the request model based on
/// whether the user is logged-in.
///
/// @param model the [Model] attached to the request
@ModelAttribute
public void addAuthenticationFlag(Model model) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Expand All @@ -21,6 +30,10 @@ public void addAuthenticationFlag(Model model) {
model.addAttribute("isAuthenticated", isAuthenticated);
}

/// Adds user details to the request model.
///
/// @param principal the [Principal] representing the user
/// @param model the [Model] attached to the request
@ModelAttribute
public void addUserDetails(Principal principal, Model model) {
var username = principal != null ? principal.getName() : "Guest";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
import org.springframework.security.web.authentication.AuthenticationConverter;
import org.springframework.stereotype.Component;

/// A converter that handles JWT-based auth for API requests.
///
/// This converter targets only the API endpoints at `/api`.
/// Auth for the frontend is handled by Spring's form login.
@Component
public class ApiBearerTokenAuthenticationConverter implements AuthenticationConverter {

Expand All @@ -15,7 +19,7 @@ public class ApiBearerTokenAuthenticationConverter implements AuthenticationConv
@Override
public Authentication convert(HttpServletRequest request) {

String path = request.getRequestURI();
final var path = request.getRequestURI();

// Don't authenticate the auth endpoints
if (path.startsWith("/api/auth/")) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

/// Contains handlers for token-related errors on API endpoints
@Component
public class ApiSecurityHandlers {
/// Returns an unauthorized response for unauthenticate API queries
///
/// @return an unauthorized response with a JSON-formatted error message
@Bean
public AuthenticationEntryPoint apiAuthenticationEntryPoint() {
return (_, response, authException) -> {
Expand All @@ -21,6 +24,8 @@ public AuthenticationEntryPoint apiAuthenticationEntryPoint() {
}

/// Returns a forbidden response for API queries
///
/// @return a forbidden response with a JSON-formatted error message
@Bean
public AccessDeniedHandler apiAccessDeniedHandler() {
return (_, response, exception) -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,18 @@
import java.nio.charset.StandardCharsets;
import java.util.UUID;

/// Handles provisioning and authenticating JWTs for API requests
@Component
public class JwtAuthenticationProvider implements AuthenticationProvider {

private final UserRepository repository;
private final SecretKey key;

/// Constructor with secret value provided in `.env` file
/// or environment variables.
///
/// @param repository the [UserRepository] interface for user entities
/// @param secret the secret value used to generate JWT values
public JwtAuthenticationProvider(
UserRepository repository,
@Value("${jwt.secret}") String secret) {
Expand All @@ -36,25 +42,31 @@ public JwtAuthenticationProvider(
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {

// Get the JWT token from the authentication header
final var token = (String) authentication.getCredentials();

try {
// Parse the JWT claims
final var claims = Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();

// Get the user's UUID from the claims subject
final var uuid = UUID.fromString(claims.getSubject());

final var user = repository.getUserByUuid(uuid)
// Find the user entity
final var user = repository.findUserByUuid(uuid)
.orElseThrow(() -> new BadCredentialsException("User not found"));

// Configure the user details for the authenticated user
final var details = new CustomUserDetails(
user.getId(), user.getUuid(), user.getUsername(),
user.getPassword(), user.getUserRoles()
);

// Return the parsed token
return new UsernamePasswordAuthenticationToken(
details, token, details.getAuthorities());
} catch (Exception ex) {
Expand Down
30 changes: 30 additions & 0 deletions src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

/// Security configuration for the Spring application
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
Expand All @@ -41,6 +42,14 @@ public class SecurityConfig {
"/favicon.ico",
};

/// API-related security configuration
///
/// @param http the [HttpSecurity] object to be configured
/// @param jwtAuthenticationProvider the [JwtAuthenticationProvider] used to handle JWT auth
/// @param entryPoint the entrypoint that commences the JWT auth
/// @param deniedHandler the [AccessDeniedHandler] that handles auth failures
/// @param converter the [ApiBearerTokenAuthenticationConverter] that manages JWT validation
/// @return the configured [HttpSecurity] object
@Bean
@Order(1)
public SecurityFilterChain apiSecurity(
Expand Down Expand Up @@ -79,6 +88,10 @@ public SecurityFilterChain apiSecurity(
return http.build();
}

/// Web-related security configuration
///
/// @param http the [HttpSecurity] object to be configured
/// @return the configured [HttpSecurity] object
@Bean
@Order(2)
public SecurityFilterChain webSecurity(HttpSecurity http) {
Expand All @@ -104,11 +117,19 @@ public SecurityFilterChain webSecurity(HttpSecurity http) {
.build();
}

/// The default password encoder used for hashing and encoding user passwords and JWTs
///
/// @return a configured [BCryptPasswordEncoder]
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

/// An authentication provider for password-based authentication
///
/// @param userDetailsService the [UserDetailsService] for loading user data
/// @param passwordEncoder the default password encoder
/// @return the configured [DaoAuthenticationProvider]
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider(UserDetailsService userDetailsService,
BCryptPasswordEncoder passwordEncoder) {
Expand All @@ -117,11 +138,20 @@ public DaoAuthenticationProvider daoAuthenticationProvider(UserDetailsService us
return provider;
}

/// An authentication provider for JWT-based authentication
///
/// @param provider a configured [JwtAuthenticationProvider]
/// @return a configured [ProviderManager] that uses the JWT auth provider
/// @see JwtAuthenticationProvider for provider details
@Bean(name = "jwtAuthManager")
public AuthenticationManager jwtAuthenticationManager(JwtAuthenticationProvider provider) {
return new ProviderManager(provider);
}

/// An authentication provider for API POST login
///
/// @param daoProvider a configured [DaoAuthenticationProvider]
/// @return a configured [ProviderManager] that uses basic username/password auth
@Bean(name = "apiLoginManager", defaultCandidate = false)
public AuthenticationManager apiLoginAuthenticationManager(
DaoAuthenticationProvider daoProvider) {
Expand Down
9 changes: 9 additions & 0 deletions src/main/java/org/openpodcastapi/opa/config/WebConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,32 @@
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/// Configuration for the web interface
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// Additional CSS storage
registry
.addResourceHandler("/css/**")
.addResourceLocations("classpath:/static/css/");

// Additional JavaScript storage
registry
.addResourceHandler("/js/**")
.addResourceLocations("classpath:/static/js/");

// The hosted documentation
registry
.addResourceHandler("/docs/**")
.addResourceLocations("classpath:/static/docs/");
}

/// Informs Spring to use Thymeleaf Layout Dialect for composing Thymeleaf templates
///
/// See [Thymeleaf Layout Dialect](https://ultraq.github.io/thymeleaf-layout-dialect/) for more information
///
/// @return the configured [LayoutDialect]
@Bean
public LayoutDialect layoutDialect() {
return new LayoutDialect();
Expand Down
Loading