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
6 changes: 6 additions & 0 deletions .env-sample
Original file line number Diff line number Diff line change
Expand Up @@ -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
24 changes: 18 additions & 6 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -146,16 +146,28 @@
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.13.0</version>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.13.0</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.13.0</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>

Expand Down
27 changes: 27 additions & 0 deletions src/docs/auth.adoc
Original file line number Diff line number Diff line change
@@ -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']
69 changes: 69 additions & 0 deletions src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java
Original file line number Diff line number Diff line change
@@ -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<DTOs.LoginSuccessResponse> 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<DTOs.RefreshTokenResponse> 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);
}
}

49 changes: 49 additions & 0 deletions src/main/java/org/openpodcastapi/opa/auth/DTOs.java
Original file line number Diff line number Diff line change
@@ -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
) {
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}

This file was deleted.

27 changes: 0 additions & 27 deletions src/main/java/org/openpodcastapi/opa/config/AuthServerConfig.java

This file was deleted.

Loading