diff --git a/README.md b/README.md index 73e4bfd..40bafcb 100644 --- a/README.md +++ b/README.md @@ -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 +X-Org-Api-Key: ``` The live systemd unit at `/etc/systemd/system/org-backend.service` is managed diff --git a/src/main/java/org/zaine/app/controller/AuthController.java b/src/main/java/org/zaine/app/controller/AuthController.java index e4e0f52..f110a9c 100644 --- a/src/main/java/org/zaine/app/controller/AuthController.java +++ b/src/main/java/org/zaine/app/controller/AuthController.java @@ -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 { diff --git a/src/main/java/org/zaine/app/controller/CommentsController.java b/src/main/java/org/zaine/app/controller/CommentsController.java index 92dbb3b..7843f07 100644 --- a/src/main/java/org/zaine/app/controller/CommentsController.java +++ b/src/main/java/org/zaine/app/controller/CommentsController.java @@ -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()) { diff --git a/src/main/java/org/zaine/app/security/ApiKeyAuthService.java b/src/main/java/org/zaine/app/security/ApiKeyAuthService.java new file mode 100644 index 0000000..3a4eed6 --- /dev/null +++ b/src/main/java/org/zaine/app/security/ApiKeyAuthService.java @@ -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); + } +} diff --git a/src/main/java/org/zaine/app/security/JwtAuthFilter.java b/src/main/java/org/zaine/app/security/JwtAuthFilter.java index 37d189d..2c3f572 100644 --- a/src/main/java/org/zaine/app/security/JwtAuthFilter.java +++ b/src/main/java/org/zaine/app/security/JwtAuthFilter.java @@ -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"); + 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; } - 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()); - auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(auth); - - filterChain.doFilter(request, response); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, + "Missing or invalid " + API_KEY_HEADER + " header"); } private boolean endpointRequiresAuth(HttpServletRequest request) { diff --git a/src/test/java/org/zaine/app/security/ApiKeyAuthServiceTest.java b/src/test/java/org/zaine/app/security/ApiKeyAuthServiceTest.java new file mode 100644 index 0000000..27d1022 --- /dev/null +++ b/src/test/java/org/zaine/app/security/ApiKeyAuthServiceTest.java @@ -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)); + } +} diff --git a/src/test/java/org/zaine/app/security/JwtAuthFilterTest.java b/src/test/java/org/zaine/app/security/JwtAuthFilterTest.java index 86fb05e..1eeccc6 100644 --- a/src/test/java/org/zaine/app/security/JwtAuthFilterTest.java +++ b/src/test/java/org/zaine/app/security/JwtAuthFilterTest.java @@ -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);