Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

<!-- JWT bearer-token authentication: validates tokens issued by UserAuth (issue #149). -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down
84 changes: 84 additions & 0 deletions src/main/java/preponderous/viron/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -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).
*
* <p>Validates tokens issued by the <strong>UserAuth</strong> 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.
*
* <p>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<Jwt> validator = JwtValidators.createDefaultWithIssuer(issuer);
decoder.setJwtValidator(validator);
return decoder;
}
}
9 changes: 8 additions & 1 deletion src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,11 @@ database.dbUsername=postgres
database.dbPassword=postgres

service.vironHost=http://localhost
service.vironPort=9999
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}
44 changes: 44 additions & 0 deletions src/test/java/preponderous/viron/config/SecurityConfigTest.java
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -33,6 +34,7 @@

@SpringBootTest
@AutoConfigureMockMvc
@WithMockUser
@DirtiesContext
class DebugControllerTest {

Expand Down Expand Up @@ -204,4 +206,4 @@ private List<Location> buildLocationGrid(int rows, int columns) {
}
return locations;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,6 +30,7 @@

@SpringBootTest
@AutoConfigureMockMvc
@WithMockUser
@DirtiesContext
class EntityControllerTest {

Expand Down Expand Up @@ -318,4 +320,4 @@ void updateEntityName_RepositoryThrowsException() throws Exception {
.andExpect(jsonPath("$.status").value(500))
.andExpect(jsonPath("$.message").isString());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -30,6 +31,7 @@

@SpringBootTest
@AutoConfigureMockMvc
@WithMockUser
@DirtiesContext
class EnvironmentControllerTest {

Expand Down Expand Up @@ -340,4 +342,4 @@ void updateEnvironmentName_BlankName_ReturnsBadRequest() throws Exception {

verify(environmentRepository, never()).updateName(anyInt(), anyString());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,6 +26,7 @@

@SpringBootTest
@AutoConfigureMockMvc
@WithMockUser
@DirtiesContext
class GridControllerTest {

Expand Down Expand Up @@ -199,4 +201,4 @@ void getGridOfEntity_RepositoryThrowsException() throws Exception {
.andExpect(jsonPath("$.status").value(500))
.andExpect(jsonPath("$.message").isString());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,6 +26,7 @@

@SpringBootTest
@AutoConfigureMockMvc
@WithMockUser
class LocationControllerTest {

@Autowired
Expand Down Expand Up @@ -340,4 +342,4 @@ void removeEntityFromCurrentLocation_RepositoryThrowsException() throws Exceptio
.andExpect(jsonPath("$.status").value(500))
.andExpect(jsonPath("$.message").isString());
}
}
}
20 changes: 20 additions & 0 deletions src/test/resources/application.properties
Original file line number Diff line number Diff line change
@@ -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
Loading