auth
All checks were successful
Build Org Backend / build (push) Successful in 12s

This commit is contained in:
2026-05-09 00:36:49 +01:00
parent c8937357e5
commit aa31cc37d9
7 changed files with 36 additions and 83 deletions

View File

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

View File

@@ -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 {

View File

@@ -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()) {

View File

@@ -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);
}
}

View File

@@ -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) {

View File

@@ -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));
}
}

View File

@@ -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);