diff --git a/README.md b/README.md index f2c39f3..ecb27b6 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,17 @@ This service is written in Java with Spring Boot. It provides simple API to retr ## Configuration The service scans environment for variables: + - `JWT_SECRET` - secret value for JWT token processing. Must be the same amongst all components. - `SERVER_PORT` - the port the service takes. +- `OTEL_TRACES_SAMPLER_ARG` - trace sampling probability; defaults to `1.0`. +- `ZIPKIN_URL` - Zipkin v2 spans endpoint. +- `USERS_API_FEATURE_VERBOSE_SECURITY_ERRORS` - exposes JWT validation details when `true`; defaults to `false`. + +Public operational endpoints are `/health/startup`, `/health/readiness`, +`/health/liveness`, `/health`, and `/prometheus`. Health responses never expose +component details. API responses return the accepted or generated +`X-Request-Id` correlation identifier. ## Building @@ -30,4 +39,4 @@ where `$token` is the response you get from [Auth API](/auth-api). Here you can find the software required to run this microservice, as well as the version we have tested. | Dependency | Version | |-------------|----------| -| Java | openJDK8 | \ No newline at end of file +| Java | OpenJDK 21 | diff --git a/contracts/openapi.yaml b/contracts/openapi.yaml index d0b6f91..ce3bfd9 100644 --- a/contracts/openapi.yaml +++ b/contracts/openapi.yaml @@ -4,8 +4,9 @@ info: version: 1.0.0 description: > JWT-protected user directory backed by an in-process datastore. A caller may - read only the user named in its own token. Contract-first source of truth - (spec 007 / T009). + read only the user named in its own token. Requests accept and responses + return X-Request-Id for correlation. Contract-first source of truth (spec + 007 / T009 and spec 009 / T081). contact: name: MicroTodoSuite url: https://github.com/MicroTodoSuite @@ -69,28 +70,44 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /health/startup: + get: + operationId: getStartupHealth + tags: [observability] + summary: Actuator startup health group (public). + description: Reports whether the application context has completed startup. + responses: + '200': + $ref: '#/components/responses/Health' + /health/readiness: + get: + operationId: getReadinessHealth + tags: [observability] + summary: Actuator readiness health group (public). + description: Reports whether the API and its database dependency can serve traffic. + responses: + '200': + $ref: '#/components/responses/Health' + '503': + $ref: '#/components/responses/Health' + /health/liveness: + get: + operationId: getLivenessHealth + tags: [observability] + summary: Actuator liveness health group (public). + description: Reports whether the running process is alive. + responses: + '200': + $ref: '#/components/responses/Health' /health: get: - operationId: getHealth + operationId: getAggregateHealth tags: [observability] - summary: Actuator health (public). - description: Spring Boot actuator health endpoint. + summary: Aggregate Actuator health (public). + description: Returns the aggregate status without exposing component details. responses: '200': - description: Health status. - content: - application/vnd.spring-boot.actuator.v3+json: - schema: - type: object - properties: - status: - type: string - application/json: - schema: - type: object - properties: - status: - type: string + $ref: '#/components/responses/Health' /prometheus: get: operationId: getPrometheus @@ -105,12 +122,31 @@ paths: schema: type: string components: + headers: + CorrelationId: + description: Correlation identifier adopted or generated for this request. + schema: + type: string + minLength: 1 + maxLength: 128 securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT responses: + Health: + description: Actuator health status. + headers: + X-Request-Id: + $ref: '#/components/headers/CorrelationId' + content: + application/vnd.spring-boot.actuator.v3+json: + schema: + $ref: '#/components/schemas/Health' + application/json: + schema: + $ref: '#/components/schemas/Health' Unauthorized: description: Missing or invalid bearer token. content: @@ -118,6 +154,13 @@ components: schema: $ref: '#/components/schemas/Error' schemas: + Health: + type: object + required: [status] + properties: + status: + type: string + enum: [UP, DOWN, OUT_OF_SERVICE, UNKNOWN] User: type: object required: [username] diff --git a/pom.xml b/pom.xml index ea84efd..ebcfebe 100644 --- a/pom.xml +++ b/pom.xml @@ -44,11 +44,11 @@ io.micrometer - micrometer-tracing-bridge-brave + micrometer-tracing-bridge-otel - io.zipkin.reporter2 - zipkin-reporter-brave + io.opentelemetry + opentelemetry-exporter-zipkin com.h2database diff --git a/src/main/java/com/elgris/usersapi/UsersApiApplication.java b/src/main/java/com/elgris/usersapi/UsersApiApplication.java index 3cce300..e784300 100644 --- a/src/main/java/com/elgris/usersapi/UsersApiApplication.java +++ b/src/main/java/com/elgris/usersapi/UsersApiApplication.java @@ -1,7 +1,13 @@ package com.elgris.usersapi; +import com.elgris.usersapi.configuration.OperationalProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.context.annotation.Bean; /** * The entry point for the Users API application. @@ -11,8 +17,11 @@ *

*/ @SpringBootApplication +@ConfigurationPropertiesScan public class UsersApiApplication { + private static final Logger LOGGER = LoggerFactory.getLogger(UsersApiApplication.class); + /** * The main method that starts the Spring Boot application. *

@@ -22,7 +31,11 @@ public class UsersApiApplication { * @param args Command-line arguments passed to the application. */ public static void main(String[] args) { - // Run the Spring Boot application SpringApplication.run(UsersApiApplication.class, args); - } + } + + @Bean + ApplicationRunner logOperationalConfiguration(OperationalProperties properties) { + return arguments -> LOGGER.info("Operational configuration loaded: {}", properties); + } } diff --git a/src/main/java/com/elgris/usersapi/api/CounterController.java b/src/main/java/com/elgris/usersapi/api/CounterController.java index d6c55ed..39c0be1 100644 --- a/src/main/java/com/elgris/usersapi/api/CounterController.java +++ b/src/main/java/com/elgris/usersapi/api/CounterController.java @@ -3,12 +3,15 @@ import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; import java.util.concurrent.atomic.AtomicInteger; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class CounterController { + private static final Logger LOGGER = LoggerFactory.getLogger(CounterController.class); private final Counter requests; private final AtomicInteger count = new AtomicInteger(); @@ -21,6 +24,8 @@ public CounterController(MeterRegistry meterRegistry) { @GetMapping("/count") public int count() { requests.increment(); - return count.incrementAndGet(); + int current = count.incrementAndGet(); + LOGGER.info("Request counter incremented to {}", current); + return current; } } diff --git a/src/main/java/com/elgris/usersapi/api/UsersController.java b/src/main/java/com/elgris/usersapi/api/UsersController.java index 2fc0fe8..b67bea6 100644 --- a/src/main/java/com/elgris/usersapi/api/UsersController.java +++ b/src/main/java/com/elgris/usersapi/api/UsersController.java @@ -7,6 +7,8 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.security.access.AccessDeniedException; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -17,6 +19,7 @@ @RequestMapping("/users") public class UsersController { + private static final Logger LOGGER = LoggerFactory.getLogger(UsersController.class); private final UserRepository userRepository; public UsersController(UserRepository userRepository) { @@ -28,6 +31,7 @@ public UsersController(UserRepository userRepository) { public List getUsers() { List response = new LinkedList<>(); userRepository.findAll().forEach(response::add); + LOGGER.info("User directory listing completed with {} entries", response.size()); return response; } @@ -41,6 +45,8 @@ public User getUser(HttpServletRequest request, @PathVariable String username) { if (!username.equalsIgnoreCase(String.valueOf(claims.get("username")))) { throw new AccessDeniedException("No access for requested entity"); } - return userRepository.findOneByUsername(username); + User user = userRepository.findOneByUsername(username); + LOGGER.info("User lookup completed"); + return user; } } diff --git a/src/main/java/com/elgris/usersapi/configuration/CorrelationIdFilter.java b/src/main/java/com/elgris/usersapi/configuration/CorrelationIdFilter.java new file mode 100644 index 0000000..6f20d6b --- /dev/null +++ b/src/main/java/com/elgris/usersapi/configuration/CorrelationIdFilter.java @@ -0,0 +1,40 @@ +package com.elgris.usersapi.configuration; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.UUID; +import java.util.regex.Pattern; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +/** Adopts or creates the request identifier shared by logs and callers. */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class CorrelationIdFilter extends OncePerRequestFilter { + + static final String HEADER = "X-Request-Id"; + private static final Pattern SAFE_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}"); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + String candidate = request.getHeader(HEADER); + String correlationId = candidate != null && SAFE_ID.matcher(candidate).matches() + ? candidate + : UUID.randomUUID().toString(); + + response.setHeader(HEADER, correlationId); + MDC.put("correlationId", correlationId); + try { + chain.doFilter(request, response); + } finally { + MDC.remove("correlationId"); + } + } +} diff --git a/src/main/java/com/elgris/usersapi/configuration/OperationalProperties.java b/src/main/java/com/elgris/usersapi/configuration/OperationalProperties.java new file mode 100644 index 0000000..e6b73df --- /dev/null +++ b/src/main/java/com/elgris/usersapi/configuration/OperationalProperties.java @@ -0,0 +1,18 @@ +package com.elgris.usersapi.configuration; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Non-secret runtime configuration controlled independently from the image. + * JWT material deliberately does not belong to this loggable object. + */ +@ConfigurationProperties(prefix = "users.operational") +public record OperationalProperties(Features features) { + + public OperationalProperties { + features = features == null ? new Features(false) : features; + } + + public record Features(boolean verboseSecurityErrors) { + } +} diff --git a/src/main/java/com/elgris/usersapi/configuration/SecurityConfiguration.java b/src/main/java/com/elgris/usersapi/configuration/SecurityConfiguration.java index 745fc6c..4aa44c0 100644 --- a/src/main/java/com/elgris/usersapi/configuration/SecurityConfiguration.java +++ b/src/main/java/com/elgris/usersapi/configuration/SecurityConfiguration.java @@ -5,6 +5,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @@ -16,6 +17,7 @@ SecurityFilterChain apiSecurity(HttpSecurity http, JwtAuthenticationFilter jwtAu throws Exception { return http .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll()) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .build(); diff --git a/src/main/java/com/elgris/usersapi/security/JwtAuthenticationFilter.java b/src/main/java/com/elgris/usersapi/security/JwtAuthenticationFilter.java index 1f564cc..eebe8e0 100644 --- a/src/main/java/com/elgris/usersapi/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/elgris/usersapi/security/JwtAuthenticationFilter.java @@ -1,5 +1,6 @@ package com.elgris.usersapi.security; +import com.elgris.usersapi.configuration.OperationalProperties; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.FilterChain; @@ -26,10 +27,15 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final byte[] jwtSecret; private final ObjectMapper objectMapper; + private final OperationalProperties operationalProperties; - public JwtAuthenticationFilter(@Value("${jwt.secret}") String jwtSecret, ObjectMapper objectMapper) { + public JwtAuthenticationFilter( + @Value("${jwt.secret}") String jwtSecret, + ObjectMapper objectMapper, + OperationalProperties operationalProperties) { this.jwtSecret = jwtSecret.getBytes(StandardCharsets.UTF_8); this.objectMapper = objectMapper; + this.operationalProperties = operationalProperties; } @Override @@ -49,8 +55,11 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse try { request.setAttribute("claims", verify(authHeader.substring(7))); chain.doFilter(request, response); - } catch (IllegalArgumentException | GeneralSecurityException exception) { - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token"); + } catch (IllegalArgumentException | GeneralSecurityException | IOException exception) { + String message = operationalProperties.features().verboseSecurityErrors() + ? "Invalid token: " + exception.getMessage() + : "Invalid token"; + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, message); } } @@ -59,6 +68,7 @@ private boolean isPublicPath(HttpServletRequest request) { return "/metrics".equals(path) || "/prometheus".equals(path) || "/health".equals(path) + || path.startsWith("/health/") || path.startsWith("/actuator"); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index cb0e8b5..e8578f9 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,16 +1,26 @@ jwt.secret=${JWT_SECRET:myfancysecret} server.port=${SERVER_PORT:8083} spring.application.name=${SPRING_APPLICATION_NAME:users-api} +spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration spring.jpa.defer-datasource-initialization=true spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.open-in-view=false spring.sql.init.mode=always management.endpoints.web.base-path=/ management.endpoints.web.exposure.include=health,prometheus -management.endpoint.health.show-details=always -management.tracing.sampling.probability=${SLEUTH_SAMPLER_PROBABILITY:1.0} +management.endpoint.health.show-details=never +management.endpoint.health.probes.enabled=true +management.endpoint.health.group.startup.include=livenessState +management.endpoint.health.group.readiness.include=readinessState,db +management.endpoint.health.group.liveness.include=livenessState +management.tracing.sampling.probability=${OTEL_TRACES_SAMPLER_ARG:${SLEUTH_SAMPLER_PROBABILITY:1.0}} management.zipkin.tracing.endpoint=${ZIPKIN_URL:http://zipkin:9411/api/v2/spans} +management.opentelemetry.resource-attributes.service.name=${spring.application.name} + +server.shutdown=graceful +users.operational.features.verbose-security-errors=${USERS_API_FEATURE_VERBOSE_SECURITY_ERRORS:false} # Without this, http.server.requests is a Micrometer Summary (count+sum+max # only); the golden-signal latency dashboard needs real histogram buckets diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index ed03eae..bcbcffa 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -1,4 +1,7 @@ + + diff --git a/src/test/java/com/elgris/usersapi/UsersApiApplicationTests.java b/src/test/java/com/elgris/usersapi/UsersApiApplicationTests.java index 0259e7e..0ec702a 100644 --- a/src/test/java/com/elgris/usersapi/UsersApiApplicationTests.java +++ b/src/test/java/com/elgris/usersapi/UsersApiApplicationTests.java @@ -137,6 +137,13 @@ void jwtSubjectCannotReadAnotherUser() throws Exception { .andExpect(status().isForbidden()); } + @Test + void malformedSignedJwtIsRejectedInsteadOfEscapingAsServerError() throws Exception { + mockMvc.perform(get("/users/admin") + .header("Authorization", "Bearer " + signedTokenWithPayload("not-json"))) + .andExpect(status().isUnauthorized()); + } + @Test void serviceOwnedAndHttpMetricsAreExported() throws Exception { mockMvc.perform(get("/count") @@ -150,12 +157,16 @@ void serviceOwnedAndHttpMetricsAreExported() throws Exception { } private String tokenFor(String username) throws Exception { - Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding(); - String header = encoder.encodeToString(objectMapper.writeValueAsBytes(Map.of("alg", "HS256", "typ", "JWT"))); - String payload = encoder.encodeToString(objectMapper.writeValueAsBytes(Map.of( + return signedTokenWithPayload(objectMapper.writeValueAsString(Map.of( "username", username, "scope", "read", "exp", Instant.now().plusSeconds(300).getEpochSecond()))); + } + + private String signedTokenWithPayload(String payloadJson) throws Exception { + Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding(); + String header = encoder.encodeToString(objectMapper.writeValueAsBytes(Map.of("alg", "HS256", "typ", "JWT"))); + String payload = encoder.encodeToString(payloadJson.getBytes(StandardCharsets.UTF_8)); Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec("unit-test-secret".getBytes(StandardCharsets.UTF_8), "HmacSHA256")); String signature = encoder.encodeToString(mac.doFinal((header + "." + payload).getBytes(StandardCharsets.US_ASCII)));