From 8d84d38d3b4e1815452cecf3874c6e25c15ef158 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Sat, 13 Jun 2026 21:28:41 -0600 Subject: [PATCH] feat(security): require JWT authentication on the Viron API (#149) Viron previously exposed every endpoint unauthenticated over plain HTTP, contradicting the architecture (RFC 0001), which specifies JWT auth for all services. Add JWT bearer-token authentication that validates tokens issued by the UserAuth service. - SecurityConfig: stateless filter chain requiring a valid bearer token on all endpoints except actuator health and the OpenAPI/Swagger docs. The JwtDecoder mirrors UserAuth's JwtConfig (HMAC HS256/384/512 with a shared secret, issuer validation), so UserAuth-minted tokens validate here with no network call. - New config: app.jwt.secret (${JWT_SECRET}, required, >= 32 bytes for HS256), app.jwt.issuer (default userauth), app.jwt.algorithm (default HS256). - Tests: new SecurityConfigTest (unauthenticated -> 401, health public); the five controller tests run as @WithMockUser. Full suite: 228 passing under JDK 21 (./mvnw test). BREAKING: callers must now send `Authorization: Bearer `; the app requires JWT_SECRET (matching UserAuth's) to start. HTTPS is expected at the gateway, as for the other services. Closes #149 Co-Authored-By: Claude Opus 4.8 (1M context) --- pom.xml | 12 +++ .../viron/config/SecurityConfig.java | 84 +++++++++++++++++++ src/main/resources/application.properties | 9 +- .../viron/config/SecurityConfigTest.java | 44 ++++++++++ .../controllers/DebugControllerTest.java | 4 +- .../controllers/EntityControllerTest.java | 4 +- .../EnvironmentControllerTest.java | 4 +- .../viron/controllers/GridControllerTest.java | 4 +- .../controllers/LocationControllerTest.java | 4 +- src/test/resources/application.properties | 20 +++++ 10 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 src/main/java/preponderous/viron/config/SecurityConfig.java create mode 100644 src/test/java/preponderous/viron/config/SecurityConfigTest.java create mode 100644 src/test/resources/application.properties diff --git a/pom.xml b/pom.xml index 660a9c6..a4e34ab 100644 --- a/pom.xml +++ b/pom.xml @@ -70,6 +70,18 @@ org.springframework.boot spring-boot-starter-actuator + + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + + org.springframework.security + spring-security-test + test + diff --git a/src/main/java/preponderous/viron/config/SecurityConfig.java b/src/main/java/preponderous/viron/config/SecurityConfig.java new file mode 100644 index 0000000..6255080 --- /dev/null +++ b/src/main/java/preponderous/viron/config/SecurityConfig.java @@ -0,0 +1,84 @@ +// Copyright (c) 2024 Preponderous Software +// MIT License + +package preponderous.viron.config; + +import java.nio.charset.StandardCharsets; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.web.SecurityFilterChain; + +/** + * Secures the Viron REST API with JWT bearer-token authentication (issue #149). + * + *

Validates tokens issued by the UserAuth service: HMAC-signed + * (HS256/HS384/HS512) with a shared secret supplied via the {@code JWT_SECRET} + * environment variable, and an {@code iss} claim matching {@code app.jwt.issuer} + * (default {@code userauth}). The decoder configuration mirrors UserAuth's own, so a + * token minted there validates here without a network call. + * + *

All endpoints require a valid bearer token except actuator health and the + * OpenAPI/Swagger documentation. Sessions are stateless. + */ +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.GET, "/actuator/health", "/actuator/health/**") + .permitAll() + .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html") + .permitAll() + .anyRequest().authenticated()) + .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); + return http.build(); + } + + /** + * Builds a decoder for UserAuth's HMAC-signed JWTs. Mirrors UserAuth's + * {@code JwtConfig}: same secret-to-key derivation, the same MAC algorithm, and + * rejection of any token whose issuer does not match the configured value. + */ + @Bean + JwtDecoder jwtDecoder( + @Value("${app.jwt.secret}") String secret, + @Value("${app.jwt.algorithm:HS256}") String algorithm, + @Value("${app.jwt.issuer:userauth}") String issuer) { + MacAlgorithm macAlgorithm = MacAlgorithm.from(algorithm); + if (macAlgorithm == null) { + throw new IllegalStateException( + "Unsupported app.jwt.algorithm '" + algorithm + + "'. Supported HMAC algorithms are: HS256, HS384, HS512."); + } + // MacAlgorithm "HS256" -> JCA name "HmacSHA256" + String jcaName = "HmacSHA" + macAlgorithm.getName().substring(2); + SecretKey key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), jcaName); + NimbusJwtDecoder decoder = + NimbusJwtDecoder.withSecretKey(key).macAlgorithm(macAlgorithm).build(); + // Validate the issuer in addition to the default validators (signature, expiry). + OAuth2TokenValidator validator = JwtValidators.createDefaultWithIssuer(issuer); + decoder.setJwtValidator(validator); + return decoder; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 04bccee..d38d021 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -9,4 +9,11 @@ database.dbUsername=postgres database.dbPassword=postgres service.vironHost=http://localhost -service.vironPort=9999 \ No newline at end of file +service.vironPort=9999 + +# --- JWT authentication (validates tokens issued by the UserAuth service) --- +# Shared HMAC secret; REQUIRED (no default — the app fails to start without it). +# For HS256 it must be at least 32 bytes. Must match UserAuth's JWT_SECRET. +app.jwt.secret=${JWT_SECRET} +app.jwt.issuer=${JWT_ISSUER:userauth} +app.jwt.algorithm=${JWT_ALGORITHM:HS256} \ No newline at end of file diff --git a/src/test/java/preponderous/viron/config/SecurityConfigTest.java b/src/test/java/preponderous/viron/config/SecurityConfigTest.java new file mode 100644 index 0000000..d56b561 --- /dev/null +++ b/src/test/java/preponderous/viron/config/SecurityConfigTest.java @@ -0,0 +1,44 @@ +// Copyright (c) 2024 Preponderous Software +// MIT License + +package preponderous.viron.config; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; + +import preponderous.viron.database.DbInteractions; + +/** + * Verifies the JWT authentication added for issue #149: API endpoints reject + * unauthenticated requests, while actuator health stays public. (Authenticated + * access is exercised by the {@code @WithMockUser} controller tests.) + */ +@SpringBootTest +@AutoConfigureMockMvc +class SecurityConfigTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private DbInteractions dbInteractions; + + @Test + void apiRequestWithoutTokenIsUnauthorized() throws Exception { + mockMvc.perform(get("/api/v1/environments")) + .andExpect(status().isUnauthorized()); + } + + @Test + void actuatorHealthIsPublic() throws Exception { + mockMvc.perform(get("/actuator/health")) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/preponderous/viron/controllers/DebugControllerTest.java b/src/test/java/preponderous/viron/controllers/DebugControllerTest.java index 8b9c09a..d3f24e9 100644 --- a/src/test/java/preponderous/viron/controllers/DebugControllerTest.java +++ b/src/test/java/preponderous/viron/controllers/DebugControllerTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.security.test.context.support.WithMockUser; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.annotation.DirtiesContext; @@ -33,6 +34,7 @@ @SpringBootTest @AutoConfigureMockMvc +@WithMockUser @DirtiesContext class DebugControllerTest { @@ -204,4 +206,4 @@ private List buildLocationGrid(int rows, int columns) { } return locations; } -} \ No newline at end of file +} diff --git a/src/test/java/preponderous/viron/controllers/EntityControllerTest.java b/src/test/java/preponderous/viron/controllers/EntityControllerTest.java index fdeee4b..aa7608d 100644 --- a/src/test/java/preponderous/viron/controllers/EntityControllerTest.java +++ b/src/test/java/preponderous/viron/controllers/EntityControllerTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.security.test.context.support.WithMockUser; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.annotation.DirtiesContext; import org.springframework.boot.test.mock.mockito.MockBean; @@ -29,6 +30,7 @@ @SpringBootTest @AutoConfigureMockMvc +@WithMockUser @DirtiesContext class EntityControllerTest { @@ -318,4 +320,4 @@ void updateEntityName_RepositoryThrowsException() throws Exception { .andExpect(jsonPath("$.status").value(500)) .andExpect(jsonPath("$.message").isString()); } -} \ No newline at end of file +} diff --git a/src/test/java/preponderous/viron/controllers/EnvironmentControllerTest.java b/src/test/java/preponderous/viron/controllers/EnvironmentControllerTest.java index ce4621d..a805dc5 100644 --- a/src/test/java/preponderous/viron/controllers/EnvironmentControllerTest.java +++ b/src/test/java/preponderous/viron/controllers/EnvironmentControllerTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.security.test.context.support.WithMockUser; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; @@ -30,6 +31,7 @@ @SpringBootTest @AutoConfigureMockMvc +@WithMockUser @DirtiesContext class EnvironmentControllerTest { @@ -340,4 +342,4 @@ void updateEnvironmentName_BlankName_ReturnsBadRequest() throws Exception { verify(environmentRepository, never()).updateName(anyInt(), anyString()); } -} \ No newline at end of file +} diff --git a/src/test/java/preponderous/viron/controllers/GridControllerTest.java b/src/test/java/preponderous/viron/controllers/GridControllerTest.java index 47ae6c9..5159842 100644 --- a/src/test/java/preponderous/viron/controllers/GridControllerTest.java +++ b/src/test/java/preponderous/viron/controllers/GridControllerTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.security.test.context.support.WithMockUser; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.annotation.DirtiesContext; @@ -25,6 +26,7 @@ @SpringBootTest @AutoConfigureMockMvc +@WithMockUser @DirtiesContext class GridControllerTest { @@ -199,4 +201,4 @@ void getGridOfEntity_RepositoryThrowsException() throws Exception { .andExpect(jsonPath("$.status").value(500)) .andExpect(jsonPath("$.message").isString()); } -} \ No newline at end of file +} diff --git a/src/test/java/preponderous/viron/controllers/LocationControllerTest.java b/src/test/java/preponderous/viron/controllers/LocationControllerTest.java index eaf5d0f..b82dcaf 100644 --- a/src/test/java/preponderous/viron/controllers/LocationControllerTest.java +++ b/src/test/java/preponderous/viron/controllers/LocationControllerTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.security.test.context.support.WithMockUser; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; @@ -25,6 +26,7 @@ @SpringBootTest @AutoConfigureMockMvc +@WithMockUser class LocationControllerTest { @Autowired @@ -340,4 +342,4 @@ void removeEntityFromCurrentLocation_RepositoryThrowsException() throws Exceptio .andExpect(jsonPath("$.status").value(500)) .andExpect(jsonPath("$.message").isString()); } -} \ No newline at end of file +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..513b7e5 --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,20 @@ +# Copyright (c) 2024 Preponderous Software +# MIT License +# +# Test configuration. Mirrors the runtime properties and supplies a fixed JWT secret +# so the Spring context (which now requires app.jwt.secret) starts in tests. + +spring.application.name=viron +server.port=9999 + +database.dbUrl=jdbc:postgresql://localhost:5432/postgres +database.dbUsername=postgres +database.dbPassword=postgres + +service.vironHost=http://localhost +service.vironPort=9999 + +# Fixed test secret (>= 32 bytes for HS256). Not a real credential. +app.jwt.secret=viron-test-jwt-secret-0123456789-abcdefghij +app.jwt.issuer=userauth +app.jwt.algorithm=HS256