This commit is contained in:
@@ -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: <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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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<String> 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<String> 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<BuildStatus> 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<String> 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<String> 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<BuildStatus> 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<String> 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<String> 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<BuildStatus> 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<String> 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<String> 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<BuildStatus> 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<Map<String, Object>> getLastRuns() {
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"web", Map.of("lastRun", nullSafe(webLastRun), "exitCode", nullSafe(webLastExitCode)),
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
97
src/test/java/org/zaine/app/security/JwtAuthFilterTest.java
Normal file
97
src/test/java/org/zaine/app/security/JwtAuthFilterTest.java
Normal file
@@ -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() {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user