Skip to content

Commit ad80e5b

Browse files
authored
Add basic JWT-based auth (#12)
1 parent 7401c08 commit ad80e5b

28 files changed

+823
-192
lines changed

.env-sample

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,9 @@ REDIS_PORT=6379
1111
ADMIN_USERNAME=admin
1212
ADMIN_EMAIL=
1313
ADMIN_PASSWORD=
14+
15+
# Used to generate JWT keys. Run `openssl rand -base64 45` to generate
16+
JWT_SECRET=
17+
JWT_EXPIRATION_MINUTES=15
18+
JWT_REFRESH_DAYS=7
19+
JWT_TTL=3600000

pom.xml

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -146,16 +146,28 @@
146146
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
147147
</dependency>
148148
<dependency>
149-
<groupId>org.springframework.boot</groupId>
150-
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
149+
<groupId>org.flywaydb</groupId>
150+
<artifactId>flyway-database-postgresql</artifactId>
151151
</dependency>
152152
<dependency>
153-
<groupId>org.springframework.boot</groupId>
154-
<artifactId>spring-boot-starter-oauth2-client</artifactId>
153+
<groupId>io.jsonwebtoken</groupId>
154+
<artifactId>jjwt-api</artifactId>
155+
<version>0.13.0</version>
155156
</dependency>
156157
<dependency>
157-
<groupId>org.flywaydb</groupId>
158-
<artifactId>flyway-database-postgresql</artifactId>
158+
<groupId>io.jsonwebtoken</groupId>
159+
<artifactId>jjwt-impl</artifactId>
160+
<version>0.13.0</version>
161+
</dependency>
162+
<dependency>
163+
<groupId>io.jsonwebtoken</groupId>
164+
<artifactId>jjwt-jackson</artifactId>
165+
<version>0.13.0</version>
166+
</dependency>
167+
<dependency>
168+
<groupId>com.h2database</groupId>
169+
<artifactId>h2</artifactId>
170+
<scope>runtime</scope>
159171
</dependency>
160172
</dependencies>
161173

src/docs/auth.adoc

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
= Auth endpoint
2+
:doctype: book
3+
:sectlinks:
4+
5+
The `auth` endpoint exposes operations for authenticating against the API.
6+
7+
[[actions-auth]]
8+
== Actions
9+
10+
[[actions-login]]
11+
=== Log in
12+
13+
[source,httprequest]
14+
----
15+
POST /api/auth/login
16+
----
17+
18+
Authenticates a user with `username` and `password`. These values must match the values of the user's account.
19+
20+
operation::auth-token[snippets='request-fields,curl-request,response-fields,http-response']
21+
22+
[[actions-refresh]]
23+
=== Request a new access token
24+
25+
You can request a new access token by passing a valid refresh value to the API.
26+
27+
operation::auth-refresh[snippets='request-fields,curl-request,response-fields,http-response']
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package org.openpodcastapi.opa.auth;
2+
3+
import jakarta.persistence.EntityNotFoundException;
4+
import jakarta.validation.constraints.NotNull;
5+
import lombok.RequiredArgsConstructor;
6+
import lombok.extern.log4j.Log4j2;
7+
import org.openpodcastapi.opa.config.JwtService;
8+
import org.openpodcastapi.opa.security.TokenService;
9+
import org.openpodcastapi.opa.user.model.User;
10+
import org.openpodcastapi.opa.user.repository.UserRepository;
11+
import org.springframework.http.ResponseEntity;
12+
import org.springframework.security.authentication.AuthenticationManager;
13+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
14+
import org.springframework.security.core.Authentication;
15+
import org.springframework.security.core.context.SecurityContextHolder;
16+
import org.springframework.web.bind.annotation.PostMapping;
17+
import org.springframework.web.bind.annotation.RequestBody;
18+
import org.springframework.web.bind.annotation.RestController;
19+
20+
@RestController
21+
@RequiredArgsConstructor
22+
@Log4j2
23+
public class ApiAuthController {
24+
25+
private final JwtService jwtService;
26+
private final TokenService tokenService;
27+
private final UserRepository userRepository;
28+
private final AuthenticationManager authenticationManager;
29+
30+
@PostMapping("/api/auth/login")
31+
public ResponseEntity<DTOs.LoginSuccessResponse> login(@RequestBody @NotNull DTOs.LoginRequest loginRequest) {
32+
// Set the authentication using the provided details
33+
Authentication authentication = authenticationManager.authenticate(
34+
new UsernamePasswordAuthenticationToken(loginRequest.username(), loginRequest.password())
35+
);
36+
37+
// Set the security context holder to the authenticated user
38+
SecurityContextHolder.getContext().setAuthentication(authentication);
39+
40+
// Fetch the user record from the database
41+
User user = userRepository.findByUsername(loginRequest.username()).orElseThrow(() -> new EntityNotFoundException("No user with username " + loginRequest.username() + " found"));
42+
43+
// Generate the access and refresh tokens for the user
44+
String accessToken = tokenService.generateAccessToken(user);
45+
String refreshToken = tokenService.generateRefreshToken(user);
46+
47+
// Format the tokens and expiration time into a DTO
48+
DTOs.LoginSuccessResponse response = new DTOs.LoginSuccessResponse(accessToken, refreshToken, String.valueOf(jwtService.getExpirationTime()));
49+
50+
return ResponseEntity.ok(response);
51+
}
52+
53+
@PostMapping("/api/auth/refresh")
54+
public ResponseEntity<DTOs.RefreshTokenResponse> getRefreshToken(@RequestBody @NotNull DTOs.RefreshTokenRequest refreshTokenRequest) {
55+
User targetUser = userRepository.findByUsername(refreshTokenRequest.username()).orElseThrow(() -> new EntityNotFoundException("No user with username " + refreshTokenRequest.username() + " found"));
56+
57+
// Validate the existing refresh token
58+
User user = tokenService.validateRefreshToken(refreshTokenRequest.refreshToken(), targetUser);
59+
60+
// Generate new access token
61+
String newAccessToken = tokenService.generateAccessToken(user);
62+
63+
// Format the token and expiration time into a DTO
64+
DTOs.RefreshTokenResponse response = new DTOs.RefreshTokenResponse(newAccessToken, String.valueOf(jwtService.getExpirationTime()));
65+
66+
return ResponseEntity.ok(response);
67+
}
68+
}
69+
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package org.openpodcastapi.opa.auth;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import jakarta.validation.constraints.NotNull;
5+
6+
/// All DTOs for auth methods
7+
public class DTOs {
8+
/// A DTO representing an API login request
9+
///
10+
/// @param username the user's username
11+
/// @param password the user's password
12+
public record LoginRequest(
13+
@JsonProperty(value = "username", required = true) @NotNull String username,
14+
@JsonProperty(value = "password", required = true) @NotNull String password
15+
) {
16+
}
17+
18+
/// A DTO representing a successful API authentication attempt
19+
///
20+
/// @param accessToken the access token to be used to authenticate
21+
/// @param expiresIn the TTL of the access token (in seconds)
22+
/// @param refreshToken the refresh token to be used to request new access tokens
23+
public record LoginSuccessResponse(
24+
@JsonProperty(value = "accessToken", required = true) @NotNull String accessToken,
25+
@JsonProperty(value = "refreshToken", required = true) @NotNull String refreshToken,
26+
@JsonProperty(value = "expiresIn", required = true) @NotNull String expiresIn
27+
) {
28+
}
29+
30+
/// A DTO representing a refresh token request
31+
///
32+
/// @param username the username of the requesting user
33+
/// @param refreshToken the refresh token used to issue a new token
34+
public record RefreshTokenRequest(
35+
@JsonProperty(value = "username", required = true) @NotNull String username,
36+
@JsonProperty(value = "refreshToken", required = true) @NotNull String refreshToken
37+
) {
38+
}
39+
40+
/// A DTO representing an updated access token from the refresh endpoint
41+
///
42+
/// @param accessToken the newly generated access token
43+
/// @param expiresIn the TTL of the token (in seconds)
44+
public record RefreshTokenResponse(
45+
@JsonProperty(value = "accessToken", required = true) @NotNull String accessToken,
46+
@JsonProperty(value = "expiresIn", required = true) @NotNull String expiresIn
47+
) {
48+
}
49+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package org.openpodcastapi.opa.auth;
2+
3+
import jakarta.servlet.http.HttpServletRequest;
4+
import jakarta.servlet.http.HttpServletResponse;
5+
import org.springframework.http.HttpStatus;
6+
import org.springframework.security.access.AccessDeniedException;
7+
import org.springframework.security.web.access.AccessDeniedHandler;
8+
import org.springframework.stereotype.Component;
9+
10+
import java.io.IOException;
11+
12+
@Component
13+
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
14+
15+
@Override
16+
public void handle(HttpServletRequest request,
17+
HttpServletResponse response,
18+
AccessDeniedException accessDeniedException) throws IOException {
19+
20+
// If the user doesn't have access to the resource in question, return a 403
21+
response.setStatus(HttpStatus.FORBIDDEN.value());
22+
23+
// Set content type to JSON
24+
response.setContentType("application/json");
25+
26+
String body = """
27+
{
28+
"error": "Forbidden",
29+
"message": "You do not have permission to access this resource."
30+
}
31+
""";
32+
33+
response.getWriter().write(body);
34+
}
35+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package org.openpodcastapi.opa.auth;
2+
3+
import jakarta.servlet.http.HttpServletRequest;
4+
import jakarta.servlet.http.HttpServletResponse;
5+
import lombok.extern.log4j.Log4j2;
6+
import org.springframework.http.HttpStatus;
7+
import org.springframework.security.core.AuthenticationException;
8+
import org.springframework.security.web.AuthenticationEntryPoint;
9+
import org.springframework.stereotype.Component;
10+
11+
import java.io.IOException;
12+
13+
@Component
14+
@Log4j2
15+
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
16+
/// Returns a 401 when a request is made without a valid bearer token
17+
@Override
18+
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
19+
// If the request is being made without a valid bearer token, return a 401.
20+
response.setStatus(HttpStatus.UNAUTHORIZED.value());
21+
22+
// Set content type to JSON
23+
response.setContentType("application/json");
24+
25+
// Return a simple JSON error message
26+
String body = """
27+
{
28+
"error": "Unauthorized",
29+
"message": "You need to log in to access this resource."
30+
}
31+
""";
32+
33+
response.getWriter().write(body);
34+
}
35+
}

src/main/java/org/openpodcastapi/opa/client/CustomRegisteredClientRepository.java

Lines changed: 0 additions & 37 deletions
This file was deleted.

src/main/java/org/openpodcastapi/opa/config/AuthServerConfig.java

Lines changed: 0 additions & 27 deletions
This file was deleted.

0 commit comments

Comments
 (0)