This commit is contained in:
@@ -31,11 +31,11 @@ Create that file once on the server with the production values required by the
|
|||||||
app. The Gitea deploy step verifies that it exists, but it does not create or
|
app. The Gitea deploy step verifies that it exists, but it does not create or
|
||||||
overwrite it.
|
overwrite it.
|
||||||
|
|
||||||
Protected API routes require the shared API key from `ORG_BACKEND_API_KEY`.
|
Use `/api/auth/register` to create users and `/api/auth/login` to receive a JWT.
|
||||||
Clients send it as:
|
Protected API routes send that token as:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
X-Org-Api-Key: <key>
|
Authorization: Bearer <token>
|
||||||
```
|
```
|
||||||
|
|
||||||
The live systemd unit at `/etc/systemd/system/org-backend.service` is managed
|
The live systemd unit at `/etc/systemd/system/org-backend.service` is managed
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ package org.zaine.app.controller;
|
|||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import org.zaine.app.security.JwtUtil;
|
import org.zaine.app.security.JwtUtil;
|
||||||
import org.zaine.app.security.RequiresAuth;
|
|
||||||
import org.zaine.app.user.User;
|
import org.zaine.app.user.User;
|
||||||
import org.zaine.app.user.UserRepository;
|
import org.zaine.app.user.UserRepository;
|
||||||
import org.springframework.http.*;
|
import org.springframework.http.*;
|
||||||
@@ -17,7 +16,6 @@ import java.util.Map;
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/auth")
|
@RequestMapping("/api/auth")
|
||||||
@RequiresAuth
|
|
||||||
@Tag(name = "Authentication")
|
@Tag(name = "Authentication")
|
||||||
public class AuthController {
|
public class AuthController {
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/comments")
|
@RequestMapping("/api/comments")
|
||||||
|
@RequiresAuth
|
||||||
@Tag(name = "Comments", description = "Endpoints for managing comments")
|
@Tag(name = "Comments", description = "Endpoints for managing comments")
|
||||||
public class CommentsController {
|
public class CommentsController {
|
||||||
|
|
||||||
@@ -86,7 +87,6 @@ public class CommentsController {
|
|||||||
value = "",
|
value = "",
|
||||||
consumes = "application/json"
|
consumes = "application/json"
|
||||||
)
|
)
|
||||||
@RequiresAuth
|
|
||||||
public void addComment(@RequestBody CreateCommentDTO dto) {
|
public void addComment(@RequestBody CreateCommentDTO dto) {
|
||||||
|
|
||||||
if (dto.getContent() == null || dto.getContent().trim().isEmpty()) {
|
if (dto.getContent() == null || dto.getContent().trim().isEmpty()) {
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
package org.zaine.app.security;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
public class ApiKeyAuthService {
|
|
||||||
|
|
||||||
private final String apiKey;
|
|
||||||
|
|
||||||
public ApiKeyAuthService(@Value("${org.auth.api-key:}") String apiKey) {
|
|
||||||
this.apiKey = apiKey == null ? "" : apiKey.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isConfigured() {
|
|
||||||
return !apiKey.isBlank();
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isValid(String candidate) {
|
|
||||||
if (!isConfigured() || candidate == null || candidate.isBlank()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] expected = apiKey.getBytes(StandardCharsets.UTF_8);
|
|
||||||
byte[] provided = candidate.trim().getBytes(StandardCharsets.UTF_8);
|
|
||||||
return MessageDigest.isEqual(expected, provided);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,14 +19,11 @@ import java.util.List;
|
|||||||
@Component
|
@Component
|
||||||
public class JwtAuthFilter extends OncePerRequestFilter {
|
public class JwtAuthFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
public static final String API_KEY_HEADER = "X-Org-Api-Key";
|
private final JwtUtil jwtUtil;
|
||||||
|
|
||||||
private final ApiKeyAuthService apiKeyAuthService;
|
|
||||||
private final RequestMappingHandlerMapping requestMappingHandlerMapping;
|
private final RequestMappingHandlerMapping requestMappingHandlerMapping;
|
||||||
|
|
||||||
public JwtAuthFilter(ApiKeyAuthService apiKeyAuthService,
|
public JwtAuthFilter(JwtUtil jwtUtil, RequestMappingHandlerMapping requestMappingHandlerMapping) {
|
||||||
RequestMappingHandlerMapping requestMappingHandlerMapping) {
|
this.jwtUtil = jwtUtil;
|
||||||
this.apiKeyAuthService = apiKeyAuthService;
|
|
||||||
this.requestMappingHandlerMapping = requestMappingHandlerMapping;
|
this.requestMappingHandlerMapping = requestMappingHandlerMapping;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,17 +36,26 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String apiKey = request.getHeader(API_KEY_HEADER);
|
String authHeader = request.getHeader("Authorization");
|
||||||
if (apiKeyAuthService.isValid(apiKey)) {
|
|
||||||
var auth = new UsernamePasswordAuthenticationToken("api-key", null, List.of());
|
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||||
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing or invalid Authorization header");
|
||||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
|
||||||
filterChain.doFilter(request, response);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
response.sendError(HttpServletResponse.SC_UNAUTHORIZED,
|
String token = authHeader.substring(7);
|
||||||
"Missing or invalid " + API_KEY_HEADER + " header");
|
|
||||||
|
if (!jwtUtil.isTokenValid(token)) {
|
||||||
|
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid or expired token");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String username = jwtUtil.extractUsername(token);
|
||||||
|
var auth = new UsernamePasswordAuthenticationToken(username, null, List.of());
|
||||||
|
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||||
|
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean endpointRequiresAuth(HttpServletRequest request) {
|
private boolean endpointRequiresAuth(HttpServletRequest request) {
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
package org.zaine.app.security;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
class ApiKeyAuthServiceTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rejectsAllKeysWhenNoApiKeyIsConfigured() {
|
|
||||||
ApiKeyAuthService service = new ApiKeyAuthService("");
|
|
||||||
|
|
||||||
assertFalse(service.isConfigured());
|
|
||||||
assertFalse(service.isValid("anything"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void acceptsOnlyConfiguredApiKey() {
|
|
||||||
ApiKeyAuthService service = new ApiKeyAuthService("secret-key");
|
|
||||||
|
|
||||||
assertTrue(service.isConfigured());
|
|
||||||
assertTrue(service.isValid("secret-key"));
|
|
||||||
assertFalse(service.isValid("wrong-key"));
|
|
||||||
assertFalse(service.isValid(null));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,7 @@ import org.junit.jupiter.api.BeforeEach;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.mock.web.MockHttpServletRequest;
|
import org.springframework.mock.web.MockHttpServletRequest;
|
||||||
import org.springframework.mock.web.MockHttpServletResponse;
|
import org.springframework.mock.web.MockHttpServletResponse;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
import org.springframework.web.method.HandlerMethod;
|
import org.springframework.web.method.HandlerMethod;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
|
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
|
||||||
|
|
||||||
@@ -23,11 +24,14 @@ class JwtAuthFilterTest {
|
|||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
handlerMapping = new TestHandlerMapping();
|
handlerMapping = new TestHandlerMapping();
|
||||||
filter = new JwtAuthFilter(new ApiKeyAuthService("secret-key"), handlerMapping);
|
JwtUtil jwtUtil = new JwtUtil();
|
||||||
|
ReflectionTestUtils.setField(jwtUtil, "secret", "test-secret-key-that-is-at-least-32-characters");
|
||||||
|
ReflectionTestUtils.setField(jwtUtil, "expirationMs", 86_400_000L);
|
||||||
|
filter = new JwtAuthFilter(jwtUtil, handlerMapping);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsProtectedEndpointWithoutApiKey() throws Exception {
|
void rejectsProtectedEndpointWithoutBearerToken() throws Exception {
|
||||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/secure");
|
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/secure");
|
||||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
AtomicBoolean chainCalled = new AtomicBoolean(false);
|
AtomicBoolean chainCalled = new AtomicBoolean(false);
|
||||||
@@ -40,9 +44,12 @@ class JwtAuthFilterTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void allowsProtectedEndpointWithValidApiKey() throws Exception {
|
void allowsProtectedEndpointWithValidBearerToken() throws Exception {
|
||||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/secure");
|
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/secure");
|
||||||
request.addHeader(JwtAuthFilter.API_KEY_HEADER, "secret-key");
|
JwtUtil jwtUtil = new JwtUtil();
|
||||||
|
ReflectionTestUtils.setField(jwtUtil, "secret", "test-secret-key-that-is-at-least-32-characters");
|
||||||
|
ReflectionTestUtils.setField(jwtUtil, "expirationMs", 86_400_000L);
|
||||||
|
request.addHeader("Authorization", "Bearer " + jwtUtil.generateToken("zaine"));
|
||||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
AtomicBoolean chainCalled = new AtomicBoolean(false);
|
AtomicBoolean chainCalled = new AtomicBoolean(false);
|
||||||
handlerMapping.setHandler(handlerFor("secure"));
|
handlerMapping.setHandler(handlerFor("secure"));
|
||||||
@@ -54,7 +61,7 @@ class JwtAuthFilterTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void allowsPublicEndpointWithoutApiKey() throws Exception {
|
void allowsPublicEndpointWithoutBearerToken() throws Exception {
|
||||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/public");
|
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/public");
|
||||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
AtomicBoolean chainCalled = new AtomicBoolean(false);
|
AtomicBoolean chainCalled = new AtomicBoolean(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user