Skip to content
10 changes: 4 additions & 6 deletions src/docs/auth.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,21 @@

The `auth` endpoint exposes operations for authenticating against the API.

[[actions-auth]]
== Actions

[[actions-login]]
=== Log in
== 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.
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
== Request a new access token

You can request a new access token by passing a valid refresh value to the API.

Expand Down
3 changes: 2 additions & 1 deletion src/docs/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
:icons: font
:source-highlighter: highlightjs
:toc: right
:toclevels: 2
:toclevels: 1
:sectlinks:

This server implements the https://openpodcastapi.org[Open Podcast API].

include::auth.adoc[]
include::users.adoc[]
include::subscriptions.adoc[]
43 changes: 23 additions & 20 deletions src/docs/subscriptions.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,26 @@
:doctype: book
:sectlinks:

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

1. A podcast feed
2. The relationship between a user and a podcast feed

[[actions-subscriptions]]
== Actions

[source,httprequest]
----
POST /api/v1/users
----

[[actions-subscriptions-create]]
=== Create subscriptions
== Create subscriptions

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.
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.

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

==== Responses
=== Responses

If all feeds are valid and no problems are encountered, the server responds with an array of `success` objects and an empty array of `failure` objects.

Expand All @@ -37,26 +36,30 @@ If the server receives a mix of responses, both arrays are populated.
include::{snippets}/subscriptions-bulk-create-mixed/http-response.adoc[]

[[actions-subscriptions-list]]
=== List subscriptions
== 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, their own subscriptions are returned.
The subscriptions of other users are not returned.

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

==== Include unsubscribed
=== Include unsubscribed

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

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

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.
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.

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

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

Unsubscribes the authenticated user from a feed. This action updates the user subscription record to mark the subscription as inactive. It does not delete the subscription record.
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.

operation::subscription-unsubscribe[snippets='path-parameters,curl-request,response-fields,http-response']
operation::subscriptionEntity-unsubscribe[snippets='request-headers,path-parameters,curl-request,response-fields,http-response']
29 changes: 5 additions & 24 deletions src/docs/users.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,18 @@
:doctype: book
:sectlinks:

The `users` endpoint exposes operations taken on user accounts. Users may update and delete their own user record, but only admins may update and alter the records of other users.

[[actions-users]]
== Actions

[[actions-users-create]]
=== Create a user

[source,httprequest]
----
POST /api/v1/users
----

Creates a new user in the system.

operation::users-create[snippets='request-fields,curl-request,response-fields,http-response']

==== Invalid fields

Passing an invalid field (such as an improperly formatted email address) throws a validation error.

operation::users-create-bad-request[snippets='curl-request,http-response']
The `users` endpoint exposes operations taken on user accounts.
Users may update and delete their own user record, but only admins may update and alter the records of other users.

[[actions-users-get]]
=== Get all users
== Get all users

[source,httprequest]
----
GET /api/v1/users
----

Fetches a paginated list of users from the system.
This action is restricted to users with `ADMIN` permissions.

operation::users-list[snippets='curl-request,response-fields,http-response']
operation::users-list[snippets='request-headers,query-parameters,curl-request,response-fields,http-response']
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@
public class GlobalExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ResponseEntity<String> handleEntityNotFoundException(EntityNotFoundException e) {
return ResponseEntity.badRequest().body(e.getMessage());
public ResponseEntity<String> handleEntityNotFoundException(EntityNotFoundException error) {
log.debug("{}", error.getMessage());
return ResponseEntity.notFound().build();
}

@ExceptionHandler(DataIntegrityViolationException.class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
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
/// All data transfer objects for auth methods
public class AuthDTO {
/// A DTO representing an api login request
///
/// @param username the user's username
/// @param password the user's password
Expand All @@ -15,7 +15,7 @@ public record LoginRequest(
) {
}

/// A DTO representing a successful API authentication attempt
/// 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)
Expand Down Expand Up @@ -46,4 +46,14 @@ public record RefreshTokenResponse(
@JsonProperty(value = "expiresIn", required = true) @NotNull String expiresIn
) {
}

/// Displays an auth error
///
/// @param error the error message
/// @param message an additional description of the error
public record ErrorMessageDTO(
@JsonProperty(value = "error", required = true) @NotNull String error,
@JsonProperty(value = "message", required = true) @NotNull String message
) {
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package org.openpodcastapi.opa.auth;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
Expand All @@ -10,7 +12,9 @@
import java.io.IOException;

@Component
@RequiredArgsConstructor
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper;

@Override
public void handle(HttpServletRequest request,
Expand All @@ -23,13 +27,8 @@ public void handle(HttpServletRequest request,
// Set content type to JSON
response.setContentType("application/json");

String body = """
{
"error": "Forbidden",
"message": "You do not have permission to access this resource."
}
""";
AuthDTO.ErrorMessageDTO message = new AuthDTO.ErrorMessageDTO("Forbidden", "You do not have permission to access this resource");

response.getWriter().write(body);
response.getWriter().write(objectMapper.writeValueAsString(message));
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package org.openpodcastapi.opa.auth;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
Expand All @@ -11,8 +12,10 @@
import java.io.IOException;

@Component
@Log4j2
@RequiredArgsConstructor
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;

/// Returns a 401 when a request is made without a valid bearer token
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
Expand All @@ -22,14 +25,8 @@ public void commence(HttpServletRequest request, HttpServletResponse response, A
// 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."
}
""";
AuthDTO.ErrorMessageDTO message = new AuthDTO.ErrorMessageDTO("Access denied", "You need to log in to access this resource");

response.getWriter().write(body);
response.getWriter().write(objectMapper.writeValueAsString(message));
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package org.openpodcastapi.opa.config;
package org.openpodcastapi.opa.auth;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
Expand All @@ -12,8 +12,8 @@
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.openpodcastapi.opa.user.UserEntity;
import org.openpodcastapi.opa.user.UserRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
Expand All @@ -37,17 +37,17 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {

/// Returns an authentication token for a user
///
/// @param user the [User] to fetch a token for
/// @param userEntity the [UserEntity] to fetch a token for
/// @return a generated token
/// @throws EntityNotFoundException if no matching user is found
private static UsernamePasswordAuthenticationToken getUsernamePasswordAuthenticationToken(User user) throws EntityNotFoundException {
private static UsernamePasswordAuthenticationToken getUsernamePasswordAuthenticationToken(UserEntity userEntity) 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());
new CustomUserDetails(userEntity.getId(),
userEntity.getUuid(),
userEntity.getUsername(),
userEntity.getPassword(),
userEntity.getUserRoles());

// Return a token for the user
return new UsernamePasswordAuthenticationToken(
Expand All @@ -68,12 +68,6 @@ private static UsernamePasswordAuthenticationToken getUsernamePasswordAuthentica
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));

Expand All @@ -99,10 +93,10 @@ protected void doFilterInternal(HttpServletRequest req, @Nonnull HttpServletResp
UUID parsedUuid = UUID.fromString(userUuid);

// Fetch the matching user
User user = repository.getUserByUuid(parsedUuid).orElseThrow(() -> new EntityNotFoundException("No matching user found"));
UserEntity userEntity = repository.getUserByUuid(parsedUuid).orElseThrow(() -> new EntityNotFoundException("No matching user found"));

// Create a user
UsernamePasswordAuthenticationToken authentication = getUsernamePasswordAuthenticationToken(user);
UsernamePasswordAuthenticationToken authentication = getUsernamePasswordAuthenticationToken(userEntity);

SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (Exception e) {
Expand Down
14 changes: 0 additions & 14 deletions src/main/java/org/openpodcastapi/opa/config/JwtService.java

This file was deleted.

Loading