From c8937357e50e039c8ec92467efd680bf07f9bf17 Mon Sep 17 00:00:00 2001 From: Zaine Arch Date: Sat, 9 May 2026 00:26:41 +0100 Subject: [PATCH] auth --- README.md | 7 ++ .../zaine/app/controller/AuthController.java | 4 +- .../app/controller/CalibreController.java | 4 +- .../app/controller/CommentsController.java | 2 + .../controller/CompetenciesController.java | 2 + .../zaine/app/controller/NotesController.java | 3 +- .../zaine/app/controller/WirdController.java | 2 + .../app/controller/zone/BuildController.java | 20 +++- .../zaine/app/security/ApiKeyAuthService.java | 31 ++++++ .../org/zaine/app/security/JwtAuthFilter.java | 36 +++---- .../app/security/ApiKeyAuthServiceTest.java | 27 ++++++ .../zaine/app/security/JwtAuthFilterTest.java | 97 +++++++++++++++++++ 12 files changed, 210 insertions(+), 25 deletions(-) create mode 100644 src/main/java/org/zaine/app/security/ApiKeyAuthService.java create mode 100644 src/test/java/org/zaine/app/security/ApiKeyAuthServiceTest.java create mode 100644 src/test/java/org/zaine/app/security/JwtAuthFilterTest.java diff --git a/README.md b/README.md index cf998de..40bafcb 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,13 @@ 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. +Protected API routes require the shared API key from `ORG_BACKEND_API_KEY`. +Clients send it as: + +```text +X-Org-Api-Key: +``` + The live systemd unit at `/etc/systemd/system/org-backend.service` is managed as a symlink to `misc/org-backend.service`, so the project copy is the source of truth. diff --git a/src/main/java/org/zaine/app/controller/AuthController.java b/src/main/java/org/zaine/app/controller/AuthController.java index 41144ce..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 { @@ -74,4 +76,4 @@ public class AuthController { public record LoginRequest(String username, String password) {} public record RegisterRequest(String username, String password) {} -} \ No newline at end of file +} diff --git a/src/main/java/org/zaine/app/controller/CalibreController.java b/src/main/java/org/zaine/app/controller/CalibreController.java index 724a894..33c7160 100644 --- a/src/main/java/org/zaine/app/controller/CalibreController.java +++ b/src/main/java/org/zaine/app/controller/CalibreController.java @@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.zaine.app.dto.CalibreBookDTO; +import org.zaine.app.security.RequiresAuth; import org.zaine.app.service.CalibreService; import io.swagger.v3.oas.annotations.Operation; @@ -16,6 +17,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; @RestController @RequestMapping("/api/calibre") +@RequiresAuth @Tag(name = "Calibre", description = "Calibre API") public class CalibreController { @@ -32,4 +34,4 @@ public class CalibreController { public List getBooks() { return calibreService.getAllBooks(); } -} \ No newline at end of file +} diff --git a/src/main/java/org/zaine/app/controller/CommentsController.java b/src/main/java/org/zaine/app/controller/CommentsController.java index 22e3cc4..7843f07 100644 --- a/src/main/java/org/zaine/app/controller/CommentsController.java +++ b/src/main/java/org/zaine/app/controller/CommentsController.java @@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.zaine.app.dto.CreateCommentDTO; import org.zaine.app.model.Comments; +import org.zaine.app.security.RequiresAuth; import org.zaine.app.service.CommentsService; import io.swagger.v3.oas.annotations.Operation; @@ -85,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/controller/CompetenciesController.java b/src/main/java/org/zaine/app/controller/CompetenciesController.java index 37bbb4b..6984ec2 100644 --- a/src/main/java/org/zaine/app/controller/CompetenciesController.java +++ b/src/main/java/org/zaine/app/controller/CompetenciesController.java @@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.zaine.app.dto.CompetenciesDTO; import org.zaine.app.model.Competencies; +import org.zaine.app.security.RequiresAuth; import org.zaine.app.service.CompetenciesService; import io.swagger.v3.oas.annotations.Operation; @@ -20,6 +21,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; @RestController @RequestMapping("/api/competencies") +@RequiresAuth @Tag(name = "Competencies", description = "Operations about competencies") public class CompetenciesController { diff --git a/src/main/java/org/zaine/app/controller/NotesController.java b/src/main/java/org/zaine/app/controller/NotesController.java index a5ebf90..6155b4d 100644 --- a/src/main/java/org/zaine/app/controller/NotesController.java +++ b/src/main/java/org/zaine/app/controller/NotesController.java @@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.zaine.app.dto.CreateNoteDTO; import org.zaine.app.model.Notes; +import org.zaine.app.security.RequiresAuth; import org.zaine.app.service.NotesService; import io.swagger.v3.oas.annotations.Operation; @@ -19,6 +20,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; @RestController @RequestMapping("/api") +@RequiresAuth @Tag(name = "Notes", description = "Operations related to notes") public class NotesController { @Autowired @@ -59,4 +61,3 @@ public class NotesController { return notesService.createNote(note); } } - diff --git a/src/main/java/org/zaine/app/controller/WirdController.java b/src/main/java/org/zaine/app/controller/WirdController.java index 9d90e31..68b6a44 100644 --- a/src/main/java/org/zaine/app/controller/WirdController.java +++ b/src/main/java/org/zaine/app/controller/WirdController.java @@ -19,6 +19,7 @@ import org.zaine.app.dto.MotalahSessionDTO; import org.zaine.app.dto.WirdEntryDTO; import org.zaine.app.model.MotalahSession; import org.zaine.app.model.WirdEntry; +import org.zaine.app.security.RequiresAuth; import org.zaine.app.service.MotalahService; import org.zaine.app.service.WirdService; @@ -26,6 +27,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; @RestController @RequestMapping("/api/wird") +@RequiresAuth @Tag(name = "Wird", description = "Wird API") public class WirdController { diff --git a/src/main/java/org/zaine/app/controller/zone/BuildController.java b/src/main/java/org/zaine/app/controller/zone/BuildController.java index 20ead76..de8ef19 100644 --- a/src/main/java/org/zaine/app/controller/zone/BuildController.java +++ b/src/main/java/org/zaine/app/controller/zone/BuildController.java @@ -28,6 +28,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import org.zaine.app.security.RequiresAuth; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -142,6 +143,7 @@ public class BuildController { @ApiResponse(responseCode = "500", description = "Internal server error") }) @PostMapping("/build-web") + @RequiresAuth public ResponseEntity triggerWebBuild() { return startBuild(webBuildDirectory, webBuildLogFile, webBuildRunning, webProcess, true); } @@ -155,6 +157,7 @@ public class BuildController { @ApiResponse(responseCode = "500", description = "Internal server error") }) @DeleteMapping("/build-web") + @RequiresAuth public ResponseEntity cancelWebBuild() { return killProcess(webProcess, webBuildRunning, "web build"); } @@ -168,6 +171,7 @@ public class BuildController { @ApiResponse(responseCode = "500", description = "Internal server error") }) @GetMapping("/build-web/status") + @RequiresAuth public ResponseEntity getWebBuildStatus() { return ResponseEntity.ok(new BuildStatus(webBuildRunning.get(), webLastRun, webLastExitCode)); } @@ -181,6 +185,7 @@ public class BuildController { @ApiResponse(responseCode = "500", description = "Internal server error") }) @GetMapping("/build-web/logs") + @RequiresAuth public SseEmitter streamWebLogs() { return streamLogs(webBuildLogFile); } @@ -198,6 +203,7 @@ public class BuildController { @ApiResponse(responseCode = "500", description = "Internal server error") }) @PostMapping("/build-roam") + @RequiresAuth public ResponseEntity triggerRoamBuild() { return startBuild(roamBuildDirectory, roamBuildLogFile, roamBuildRunning, roamProcess, false); } @@ -211,6 +217,7 @@ public class BuildController { @ApiResponse(responseCode = "500", description = "Internal server error") }) @DeleteMapping("/build-roam") + @RequiresAuth public ResponseEntity cancelRoamBuild() { return killProcess(roamProcess, roamBuildRunning, "roam build"); } @@ -224,6 +231,7 @@ public class BuildController { @ApiResponse(responseCode = "500", description = "Internal server error") }) @GetMapping("/build-roam/status") + @RequiresAuth public ResponseEntity getRoamBuildStatus() { return ResponseEntity.ok(new BuildStatus(roamBuildRunning.get(), roamLastRun, roamLastExitCode)); } @@ -237,6 +245,7 @@ public class BuildController { @ApiResponse(responseCode = "500", description = "Internal server error") }) @GetMapping("/build-roam/logs") + @RequiresAuth public SseEmitter streamRoamLogs() { return streamLogs(roamBuildLogFile); } @@ -254,6 +263,7 @@ public class BuildController { @ApiResponse(responseCode = "500", description = "Internal server error") }) @PostMapping("/rerun-emacs") + @RequiresAuth public ResponseEntity triggerEmacs() { log.info("Triggering Emacs command"); return startCommand(emacsRunDirectory, emacsRunLogFile, emacsRunning, emacsProcess); @@ -268,6 +278,7 @@ public class BuildController { @ApiResponse(responseCode = "500", description = "Internal server error") }) @DeleteMapping("/rerun-emacs") + @RequiresAuth public ResponseEntity cancelEmacs() { return killProcess(emacsProcess, emacsRunning, "emacs"); } @@ -281,6 +292,7 @@ public class BuildController { @ApiResponse(responseCode = "500", description = "Internal server error") }) @GetMapping("/rerun-emacs/status") + @RequiresAuth public ResponseEntity getEmacsStatus() { return ResponseEntity.ok(new BuildStatus(emacsRunning.get(), emacsLastRun, emacsLastExitCode)); } @@ -294,6 +306,7 @@ public class BuildController { @ApiResponse(responseCode = "500", description = "Internal server error") }) @GetMapping("/rerun-emacs/logs") + @RequiresAuth public SseEmitter streamEmacsLogs() { return streamLogs(emacsRunLogFile); } @@ -311,6 +324,7 @@ public class BuildController { @ApiResponse(responseCode = "500", description = "Internal server error") }) @PostMapping("/run-combined") + @RequiresAuth public ResponseEntity triggerCombined() { if (!combinedRunning.compareAndSet(false, true)) { return ResponseEntity.status(HttpStatus.CONFLICT).body("Combined run already in progress"); @@ -389,6 +403,7 @@ public class BuildController { @ApiResponse(responseCode = "500", description = "Internal server error") }) @DeleteMapping("/run-combined") + @RequiresAuth public ResponseEntity cancelCombined() { return killProcess(combinedProcess, combinedRunning, "combined run"); } @@ -402,6 +417,7 @@ public class BuildController { @ApiResponse(responseCode = "500", description = "Internal server error") }) @GetMapping("/run-combined/status") + @RequiresAuth public ResponseEntity getCombinedStatus() { return ResponseEntity.ok(new BuildStatus(combinedRunning.get(), combinedLastRun, combinedLastExitCode)); } @@ -415,6 +431,7 @@ public class BuildController { @ApiResponse(responseCode = "500", description = "Internal server error") }) @GetMapping("/run-combined/logs") + @RequiresAuth public SseEmitter streamCombinedLogs() { return streamLogs(combinedRunLogFile); } @@ -432,6 +449,7 @@ public class BuildController { @ApiResponse(responseCode = "500", description = "Internal server error") }) @GetMapping("/last-runs") + @RequiresAuth public ResponseEntity> getLastRuns() { return ResponseEntity.ok(Map.of( "web", Map.of("lastRun", nullSafe(webLastRun), "exitCode", nullSafe(webLastExitCode)), @@ -776,4 +794,4 @@ public class BuildController { this.lastExitCode = lastExitCode; } } -} \ No newline at end of file +} 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 9b5d4d1..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) { @@ -71,4 +65,4 @@ public class JwtAuthFilter extends OncePerRequestFilter { return false; } } -} \ No newline at end of file +} 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 new file mode 100644 index 0000000..1eeccc6 --- /dev/null +++ b/src/test/java/org/zaine/app/security/JwtAuthFilterTest.java @@ -0,0 +1,97 @@ +package org.zaine.app.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +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.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class JwtAuthFilterTest { + + private TestHandlerMapping handlerMapping; + private JwtAuthFilter filter; + + @BeforeEach + void setUp() { + handlerMapping = new TestHandlerMapping(); + filter = new JwtAuthFilter(new ApiKeyAuthService("secret-key"), handlerMapping); + } + + @Test + void rejectsProtectedEndpointWithoutApiKey() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/secure"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicBoolean chainCalled = new AtomicBoolean(false); + handlerMapping.setHandler(handlerFor("secure")); + + filter.doFilter(request, response, chain(chainCalled)); + + assertEquals(401, response.getStatus()); + assertFalse(chainCalled.get()); + } + + @Test + void allowsProtectedEndpointWithValidApiKey() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/secure"); + request.addHeader(JwtAuthFilter.API_KEY_HEADER, "secret-key"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicBoolean chainCalled = new AtomicBoolean(false); + handlerMapping.setHandler(handlerFor("secure")); + + filter.doFilter(request, response, chain(chainCalled)); + + assertEquals(200, response.getStatus()); + assertTrue(chainCalled.get()); + } + + @Test + void allowsPublicEndpointWithoutApiKey() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/public"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicBoolean chainCalled = new AtomicBoolean(false); + handlerMapping.setHandler(handlerFor("open")); + + filter.doFilter(request, response, chain(chainCalled)); + + assertEquals(200, response.getStatus()); + assertTrue(chainCalled.get()); + } + + private HandlerMethod handlerFor(String methodName) throws NoSuchMethodException { + DemoController controller = new DemoController(); + return new HandlerMethod(controller, DemoController.class.getMethod(methodName)); + } + + private FilterChain chain(AtomicBoolean called) { + return (request, response) -> called.set(true); + } + + static class TestHandlerMapping extends RequestMappingHandlerMapping { + private HandlerMethod handler; + + void setHandler(HandlerMethod handler) { + this.handler = handler; + } + + @Override + protected HandlerMethod getHandlerInternal(HttpServletRequest request) { + return handler; + } + } + + static class DemoController { + @RequiresAuth + public void secure() {} + + public void open() {} + } +}