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:
hsiegeln
2026-04-27 15:36:28 +02:00
parent 73e06d8164
commit 433155ae0c
6 changed files with 173 additions and 0 deletions

View File

@@ -1,10 +1,14 @@
package com.cameleer.server.app.security; package com.cameleer.server.app.security;
import com.cameleer.server.app.web.ArtifactDownloadTokenSigner;
import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import java.time.Clock;
/** /**
* Configuration class that creates security service beans and validates * Configuration class that creates security service beans and validates
* that required security properties are set. * that required security properties are set.
@@ -34,6 +38,12 @@ public class SecurityBeanConfig {
return new BootstrapTokenValidator(properties); return new BootstrapTokenValidator(properties);
} }
@Bean
public ArtifactDownloadTokenSigner artifactDownloadTokenSigner(
@Value("${cameleer.server.security.jwtsecret}") String jwtSecret) {
return new ArtifactDownloadTokenSigner(jwtSecret, Clock.systemUTC());
}
@Bean @Bean
public InitializingBean bootstrapTokenValidation(SecurityProperties properties) { public InitializingBean bootstrapTokenValidation(SecurityProperties properties) {
return () -> { return () -> {

View File

@@ -90,6 +90,8 @@ public class SecurityConfig {
"/api/v1/agents/register", "/api/v1/agents/register",
"/api/v1/agents/*/refresh", "/api/v1/agents/*/refresh",
"/api/v1/auth/**", "/api/v1/auth/**",
// HMAC URL signature is the auth — see ArtifactDownloadController
"/api/v1/artifacts/**",
"/api/v1/api-docs/**", "/api/v1/api-docs/**",
"/api/v1/swagger-ui/**", "/api/v1/swagger-ui/**",
"/swagger-ui/**", "/swagger-ui/**",

View File

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

View File

@@ -26,6 +26,9 @@ public class ArtifactDownloadTokenSigner {
private final Clock clock; private final Clock clock;
public ArtifactDownloadTokenSigner(String jwtSecret, 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.key = deriveKey(jwtSecret);
this.clock = clock; this.clock = clock;
} }

View File

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

View File

@@ -8,6 +8,7 @@ import java.time.ZoneOffset;
import java.util.UUID; import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ArtifactDownloadTokenSignerTest { class ArtifactDownloadTokenSignerTest {
@@ -48,6 +49,16 @@ class ArtifactDownloadTokenSignerTest {
assertThat(signer.verify(id, pastExp, sig)).isFalse(); 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 — /** 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. */ * exercises {@code MessageDigest.isEqual}'s byte-by-byte path, not the length short-circuit. */
@Test @Test