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;
|
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 () -> {
|
||||||
|
|||||||
@@ -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/**",
|
||||||
|
|||||||
@@ -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;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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
|
||||||
|
|||||||
Reference in New Issue
Block a user