feat(web): add ArtifactDownloadController with HMAC URL auth
New permitAll endpoint GET /api/v1/artifacts/{appVersionId}?exp&sig that
the cameleer-runtime-loader init container hits to stream the deployed
JAR. Auth is the HMAC-signed URL (sig + exp) — no JWT, no bootstrap
token — so SecurityConfig permits the path and the controller does the
verification itself.
Also hardens ArtifactDownloadTokenSigner to reject null/blank jwtSecret
at construction (Task 6 review feedback I-3).
Wires the ArtifactDownloadTokenSigner bean in SecurityBeanConfig from
${cameleer.server.security.jwtsecret}, the same property the rest of
the security stack uses.
Test coverage: 200/401/404 paths via standalone-MockMvc unit test
(avoids dragging in WebConfig's audit + usage interceptors that pull
the full bean graph) plus the existing signer suite extended with a
null/blank-secret guard test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
package com.cameleer.server.app.security;
|
||||
|
||||
import com.cameleer.server.app.web.ArtifactDownloadTokenSigner;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.time.Clock;
|
||||
|
||||
/**
|
||||
* Configuration class that creates security service beans and validates
|
||||
* that required security properties are set.
|
||||
@@ -34,6 +38,12 @@ public class SecurityBeanConfig {
|
||||
return new BootstrapTokenValidator(properties);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ArtifactDownloadTokenSigner artifactDownloadTokenSigner(
|
||||
@Value("${cameleer.server.security.jwtsecret}") String jwtSecret) {
|
||||
return new ArtifactDownloadTokenSigner(jwtSecret, Clock.systemUTC());
|
||||
}
|
||||
|
||||
@Bean
|
||||
public InitializingBean bootstrapTokenValidation(SecurityProperties properties) {
|
||||
return () -> {
|
||||
|
||||
@@ -90,6 +90,8 @@ public class SecurityConfig {
|
||||
"/api/v1/agents/register",
|
||||
"/api/v1/agents/*/refresh",
|
||||
"/api/v1/auth/**",
|
||||
// HMAC URL signature is the auth — see ArtifactDownloadController
|
||||
"/api/v1/artifacts/**",
|
||||
"/api/v1/api-docs/**",
|
||||
"/api/v1/swagger-ui/**",
|
||||
"/swagger-ui/**",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.cameleer.server.app.web;
|
||||
|
||||
import com.cameleer.server.core.runtime.AppService;
|
||||
import com.cameleer.server.core.runtime.AppVersion;
|
||||
import com.cameleer.server.core.storage.ArtifactStore;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Token-validated artifact download endpoint hit by the cameleer-runtime-loader
|
||||
* init container. Auth is the HMAC sig + exp on the URL — NOT JWT/bootstrap
|
||||
* token. permitAll'd in SecurityConfig because all auth is in the controller.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/artifacts")
|
||||
public class ArtifactDownloadController {
|
||||
|
||||
private final AppService appService;
|
||||
private final ArtifactStore artifactStore;
|
||||
private final ArtifactDownloadTokenSigner signer;
|
||||
|
||||
public ArtifactDownloadController(AppService appService,
|
||||
ArtifactStore artifactStore,
|
||||
ArtifactDownloadTokenSigner signer) {
|
||||
this.appService = appService;
|
||||
this.artifactStore = artifactStore;
|
||||
this.signer = signer;
|
||||
}
|
||||
|
||||
@GetMapping("/{appVersionId}")
|
||||
public ResponseEntity<InputStreamResource> download(@PathVariable UUID appVersionId,
|
||||
@RequestParam("exp") long exp,
|
||||
@RequestParam("sig") String sig) throws IOException {
|
||||
if (!signer.verify(appVersionId, exp, sig)) {
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
AppVersion version;
|
||||
try {
|
||||
version = appService.getVersion(appVersionId);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
InputStream in = artifactStore.get(appService.coordinatesFor(version));
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType("application/java-archive"))
|
||||
.contentLength(version.jarSizeBytes())
|
||||
.body(new InputStreamResource(in));
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,9 @@ public class ArtifactDownloadTokenSigner {
|
||||
private final Clock clock;
|
||||
|
||||
public ArtifactDownloadTokenSigner(String jwtSecret, Clock clock) {
|
||||
if (jwtSecret == null || jwtSecret.isBlank()) {
|
||||
throw new IllegalArgumentException("jwtSecret must not be null or blank");
|
||||
}
|
||||
this.key = deriveKey(jwtSecret);
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.cameleer.server.app.web;
|
||||
|
||||
import com.cameleer.server.core.runtime.AppService;
|
||||
import com.cameleer.server.core.runtime.AppVersion;
|
||||
import com.cameleer.server.core.storage.ArtifactCoordinates;
|
||||
import com.cameleer.server.core.storage.ArtifactStore;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* Unit-level test for {@link ArtifactDownloadController} using
|
||||
* {@link MockMvcBuilders#standaloneSetup}. The auth check IS the controller —
|
||||
* see the controller class doc and {@code SecurityConfig} permitAll for
|
||||
* {@code /api/v1/artifacts/**} — so there is no security context to load.
|
||||
* Standalone setup also avoids dragging in {@code WebConfig}'s interceptors
|
||||
* (audit / usage tracking), which otherwise pull the full bean graph.
|
||||
*/
|
||||
class ArtifactDownloadControllerTest {
|
||||
|
||||
private AppService appService;
|
||||
private ArtifactStore artifactStore;
|
||||
private ArtifactDownloadTokenSigner signer;
|
||||
private MockMvc mvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
appService = mock(AppService.class);
|
||||
artifactStore = mock(ArtifactStore.class);
|
||||
signer = mock(ArtifactDownloadTokenSigner.class);
|
||||
ArtifactDownloadController controller =
|
||||
new ArtifactDownloadController(appService, artifactStore, signer);
|
||||
mvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamsBytesWhenSignatureValid() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(signer.verify(eq(id), eq(123L), eq("good"))).thenReturn(true);
|
||||
AppVersion v = new AppVersion(id, UUID.randomUUID(), 1, "loc", "h", "a.jar", 5L, null, null, null);
|
||||
when(appService.getVersion(id)).thenReturn(v);
|
||||
when(appService.coordinatesFor(v)).thenReturn(new ArtifactCoordinates("default", v.appId(), 1));
|
||||
when(artifactStore.get(any())).thenReturn(new ByteArrayInputStream("hello".getBytes()));
|
||||
|
||||
mvc.perform(get("/api/v1/artifacts/{id}", id).param("exp", "123").param("sig", "good"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string("Content-Type", "application/java-archive"))
|
||||
.andExpect(content().bytes("hello".getBytes()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBadSignature() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(signer.verify(any(), anyLong(), any())).thenReturn(false);
|
||||
|
||||
mvc.perform(get("/api/v1/artifacts/{id}", id).param("exp", "123").param("sig", "bad"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
|
||||
// Defence-in-depth: bad sig must short-circuit before any data lookup.
|
||||
verify(appService, never()).getVersion(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns404WhenArtifactMissing() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(signer.verify(any(), anyLong(), any())).thenReturn(true);
|
||||
when(appService.getVersion(id)).thenThrow(new IllegalArgumentException("AppVersion not found"));
|
||||
|
||||
mvc.perform(get("/api/v1/artifacts/{id}", id).param("exp", "123").param("sig", "ok"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import java.time.ZoneOffset;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class ArtifactDownloadTokenSignerTest {
|
||||
|
||||
@@ -48,6 +49,16 @@ class ArtifactDownloadTokenSignerTest {
|
||||
assertThat(signer.verify(id, pastExp, sig)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNullOrBlankSecret() {
|
||||
assertThatThrownBy(() -> new ArtifactDownloadTokenSigner(null, clock))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new ArtifactDownloadTokenSigner("", clock))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new ArtifactDownloadTokenSigner(" ", clock))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
/** Forces the constant-time-compare branch by flipping one char at the same length —
|
||||
* exercises {@code MessageDigest.isEqual}'s byte-by-byte path, not the length short-circuit. */
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user