@@ -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
|
||||
overwrite it.
|
||||
|
||||
Use `/api/auth/register` to create users and `/api/auth/login` to receive a JWT.
|
||||
Protected API routes send that token as:
|
||||
Protected API routes require the shared API key from `ORG_BACKEND_API_KEY`.
|
||||
Clients send it as:
|
||||
|
||||
```text
|
||||
Authorization: Bearer <token>
|
||||
X-Org-Api-Key: <key>
|
||||
```
|
||||
|
||||
The live systemd unit at `/etc/systemd/system/org-backend.service` is managed
|
||||
|
||||
@@ -4,6 +4,7 @@ package org.zaine.app.controller;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.zaine.app.security.JwtUtil;
|
||||
import org.zaine.app.security.RequiresAuth;
|
||||
import org.zaine.app.user.User;
|
||||
import org.zaine.app.user.UserRepository;
|
||||
import org.springframework.http.*;
|
||||
@@ -16,6 +17,7 @@ import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@RequiresAuth
|
||||
@Tag(name = "Authentication")
|
||||
public class AuthController {
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/comments")
|
||||
@RequiresAuth
|
||||
@Tag(name = "Comments", description = "Endpoints for managing comments")
|
||||
public class CommentsController {
|
||||
|
||||
@@ -87,6 +86,7 @@ public class CommentsController {
|
||||
value = "",
|
||||
consumes = "application/json"
|
||||
)
|
||||
@RequiresAuth
|
||||
public void addComment(@RequestBody CreateCommentDTO dto) {
|
||||
|
||||
if (dto.getContent() == null || dto.getContent().trim().isEmpty()) {
|
||||
|
||||
31
src/main/java/org/zaine/app/security/ApiKeyAuthService.java
Normal file
31
src/main/java/org/zaine/app/security/ApiKeyAuthService.java
Normal file
@@ -0,0 +1,31 @@
|
||||
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,11 +19,14 @@ import java.util.List;
|
||||
@Component
|
||||
public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtUtil jwtUtil;
|
||||
public static final String API_KEY_HEADER = "X-Org-Api-Key";
|
||||
|
||||
private final ApiKeyAuthService apiKeyAuthService;
|
||||
private final RequestMappingHandlerMapping requestMappingHandlerMapping;
|
||||
|
||||
public JwtAuthFilter(JwtUtil jwtUtil, RequestMappingHandlerMapping requestMappingHandlerMapping) {
|
||||
this.jwtUtil = jwtUtil;
|
||||
public JwtAuthFilter(ApiKeyAuthService apiKeyAuthService,
|
||||
RequestMappingHandlerMapping requestMappingHandlerMapping) {
|
||||
this.apiKeyAuthService = apiKeyAuthService;
|
||||
this.requestMappingHandlerMapping = requestMappingHandlerMapping;
|
||||
}
|
||||
|
||||
@@ -36,26 +39,17 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
return;
|
||||
}
|
||||
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
|
||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing or invalid Authorization header");
|
||||
return;
|
||||
}
|
||||
|
||||
String token = authHeader.substring(7);
|
||||
|
||||
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());
|
||||
String apiKey = request.getHeader(API_KEY_HEADER);
|
||||
if (apiKeyAuthService.isValid(apiKey)) {
|
||||
var auth = new UsernamePasswordAuthenticationToken("api-key", null, List.of());
|
||||
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
response.sendError(HttpServletResponse.SC_UNAUTHORIZED,
|
||||
"Missing or invalid " + API_KEY_HEADER + " header");
|
||||
}
|
||||
|
||||
private boolean endpointRequiresAuth(HttpServletRequest request) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
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,7 +6,6 @@ import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
|
||||
|
||||
@@ -24,14 +23,11 @@ class JwtAuthFilterTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
handlerMapping = new TestHandlerMapping();
|
||||
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);
|
||||
filter = new JwtAuthFilter(new ApiKeyAuthService("secret-key"), handlerMapping);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsProtectedEndpointWithoutBearerToken() throws Exception {
|
||||
void rejectsProtectedEndpointWithoutApiKey() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/secure");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
AtomicBoolean chainCalled = new AtomicBoolean(false);
|
||||
@@ -44,12 +40,9 @@ class JwtAuthFilterTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowsProtectedEndpointWithValidBearerToken() throws Exception {
|
||||
void allowsProtectedEndpointWithValidApiKey() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/secure");
|
||||
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"));
|
||||
request.addHeader(JwtAuthFilter.API_KEY_HEADER, "secret-key");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
AtomicBoolean chainCalled = new AtomicBoolean(false);
|
||||
handlerMapping.setHandler(handlerFor("secure"));
|
||||
@@ -61,7 +54,7 @@ class JwtAuthFilterTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowsPublicEndpointWithoutBearerToken() throws Exception {
|
||||
void allowsPublicEndpointWithoutApiKey() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/public");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
AtomicBoolean chainCalled = new AtomicBoolean(false);
|
||||
|
||||
Reference in New Issue
Block a user