From 9fc5b2768a523bd06f0186dc4b98eee0b66f292f Mon Sep 17 00:00:00 2001 From: Mike Halma Date: Fri, 23 Feb 2024 16:40:21 +0000 Subject: [PATCH 1/3] SDIT-1336 Add in Spring Security by including the library hmpps-kotlin-spring-boot-starter --- build.gradle.kts | 7 +- helm_deploy/values-dev.yaml | 1 + helm_deploy/values-preprod.yaml | 1 + helm_deploy/values-prod.yaml | 3 + .../hmppstemplatepackagename/TimeResource.kt | 16 ++++ .../HmppsTemplateKotlinExceptionHandler.kt | 25 ++---- .../config/WebClientConfiguration.kt | 82 +++++++++++++++++++ .../health/HealthCheck.kt | 27 ++++++ src/main/resources/application.yml | 20 +++++ .../helper/JwtAuthHelper.kt | 60 ++++++++++++++ .../integration/IntegrationTestBase.kt | 12 ++- .../integration/NotFoundTest.kt | 1 + .../integration/TimeResourceIntTest.kt | 50 +++++++++++ .../integration/health/HealthCheckTest.kt | 27 ++++++ .../wiremock/HmppsAuthMockServer.kt | 65 +++++++++++++++ src/test/resources/application-test.yml | 3 + 16 files changed, 381 insertions(+), 19 deletions(-) create mode 100644 src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/TimeResource.kt create mode 100644 src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/config/WebClientConfiguration.kt create mode 100644 src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/health/HealthCheck.kt create mode 100644 src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/helper/JwtAuthHelper.kt create mode 100644 src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/TimeResourceIntTest.kt create mode 100644 src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/wiremock/HmppsAuthMockServer.kt diff --git a/build.gradle.kts b/build.gradle.kts index 0869e1c..b17125e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("uk.gov.justice.hmpps.gradle-spring-boot") version "5.15.2" + id("uk.gov.justice.hmpps.gradle-spring-boot") version "5.15.3" kotlin("plugin.spring") version "1.9.22" } @@ -8,7 +8,12 @@ configurations { } dependencies { + implementation("uk.gov.justice.service.hmpps:hmpps-kotlin-spring-boot-starter:0.1.2") implementation("org.springframework.boot:spring-boot-starter-webflux") + + testImplementation("org.wiremock:wiremock-standalone:3.4.0") + testImplementation("io.jsonwebtoken:jjwt-impl:0.12.5") + testImplementation("io.jsonwebtoken:jjwt-jackson:0.12.5") } kotlin { diff --git a/helm_deploy/values-dev.yaml b/helm_deploy/values-dev.yaml index 53d8399..1ae6003 100644 --- a/helm_deploy/values-dev.yaml +++ b/helm_deploy/values-dev.yaml @@ -9,6 +9,7 @@ generic-service: env: APPLICATIONINSIGHTS_CONFIGURATION_FILE: applicationinsights.dev.json + API_BASE_URL_OAUTH: "https://sign-in-dev.hmpps.service.justice.gov.uk/auth" # CloudPlatform AlertManager receiver to route prometheus alerts to slack # See https://user-guide.cloud-platform.service.justice.gov.uk/documentation/monitoring-an-app/how-to-create-alarms.html#creating-your-own-custom-alerts diff --git a/helm_deploy/values-preprod.yaml b/helm_deploy/values-preprod.yaml index 0f4782b..9e21122 100644 --- a/helm_deploy/values-preprod.yaml +++ b/helm_deploy/values-preprod.yaml @@ -9,6 +9,7 @@ generic-service: env: APPLICATIONINSIGHTS_CONFIGURATION_FILE: applicationinsights.dev.json + API_BASE_URL_OAUTH: "https://sign-in-preprod.hmpps.service.justice.gov.uk/auth" # CloudPlatform AlertManager receiver to route prometheus alerts to slack # See https://user-guide.cloud-platform.service.justice.gov.uk/documentation/monitoring-an-app/how-to-create-alarms.html#creating-your-own-custom-alerts diff --git a/helm_deploy/values-prod.yaml b/helm_deploy/values-prod.yaml index 77f0d0d..a6bd821 100644 --- a/helm_deploy/values-prod.yaml +++ b/helm_deploy/values-prod.yaml @@ -5,6 +5,9 @@ generic-service: ingress: host: hmpps-template-kotlin.hmpps.service.justice.gov.uk + env: + API_BASE_URL_OAUTH: "https://sign-in.hmpps.service.justice.gov.uk/auth" + # CloudPlatform AlertManager receiver to route prometheus alerts to slack # See https://user-guide.cloud-platform.service.justice.gov.uk/documentation/monitoring-an-app/how-to-create-alarms.html#creating-your-own-custom-alerts generic-prometheus-alerts: diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/TimeResource.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/TimeResource.kt new file mode 100644 index 0000000..162c633 --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/TimeResource.kt @@ -0,0 +1,16 @@ +package uk.gov.justice.digital.hmpps.hmppstemplatepackagename + +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.time.LocalDateTime + +@RestController +@RequestMapping("/time") +class TimeResource { + + @PreAuthorize("hasRole('TEMPLATE_EXAMPLE')") + @GetMapping + fun getTime() = "${LocalDateTime.now()}" +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/config/HmppsTemplateKotlinExceptionHandler.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/config/HmppsTemplateKotlinExceptionHandler.kt index 5350496..58c1ffc 100644 --- a/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/config/HmppsTemplateKotlinExceptionHandler.kt +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/config/HmppsTemplateKotlinExceptionHandler.kt @@ -7,12 +7,20 @@ import org.springframework.http.HttpStatus.BAD_REQUEST import org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR import org.springframework.http.HttpStatus.NOT_FOUND import org.springframework.http.ResponseEntity +import org.springframework.security.access.AccessDeniedException import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.servlet.resource.NoResourceFoundException +import uk.gov.justice.hmpps.kotlin.common.ErrorResponse @RestControllerAdvice class HmppsTemplateKotlinExceptionHandler { + @ExceptionHandler(AccessDeniedException::class) + fun handleAccessDeniedException(e: AccessDeniedException): ResponseEntity = ResponseEntity + .status(HttpStatus.FORBIDDEN) + .body(ErrorResponse(status = HttpStatus.FORBIDDEN.value())) + .also { log.debug("Forbidden (403) returned", e) } + @ExceptionHandler(ValidationException::class) fun handleValidationException(e: ValidationException): ResponseEntity = ResponseEntity .status(BAD_REQUEST) @@ -50,20 +58,3 @@ class HmppsTemplateKotlinExceptionHandler { private val log = LoggerFactory.getLogger(this::class.java) } } - -data class ErrorResponse( - val status: Int, - val errorCode: Int? = null, - val userMessage: String? = null, - val developerMessage: String? = null, - val moreInfo: String? = null, -) { - constructor( - status: HttpStatus, - errorCode: Int? = null, - userMessage: String? = null, - developerMessage: String? = null, - moreInfo: String? = null, - ) : - this(status.value(), errorCode, userMessage, developerMessage, moreInfo) -} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/config/WebClientConfiguration.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/config/WebClientConfiguration.kt new file mode 100644 index 0000000..5569a84 --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/config/WebClientConfiguration.kt @@ -0,0 +1,82 @@ +package uk.gov.justice.digital.hmpps.hmppstemplatepackagename.config + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.client.reactive.ReactorClientHttpConnector +import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository +import org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction +import org.springframework.web.reactive.function.client.WebClient +import reactor.netty.http.client.HttpClient +import java.time.Duration +import kotlin.apply as kotlinApply + +@Configuration +class WebClientConfiguration( + @Value("\${api.base.url.oauth}") val oauthApiBaseUri: String, + @Value("\${api.health-timeout:2s}") val healthTimeout: Duration, + @Value("\${api.timeout:90s}") val timeout: Duration, +) { + @Bean + fun oauthApiHealthWebClient(builder: WebClient.Builder): WebClient = builder.healthWebClient(oauthApiBaseUri, healthTimeout) + + /** + * TODO + * Once you have a client registration defined in properties `spring.security.client.registration` then you'll + * need to uncomment this @Bean and create both a health and authorized web client. + * + * e.g. if your client registration config looks like this (registrationId is `prison-api`): + * ``` + * spring: + * security: + * client: + * registration: + * prison-api: + * provider: hmpps-auth + * client-id: ${prison-api.client.id} + * client-secret: ${prison-api.client.secret} + * authorization-grant-type: client_credentials + * scope: read + * ``` + * Then you need to create web clients in this class as follows: + * ``` + * @Bean + * fun prisonApiHealthWebClient(builder: WebClient.Builder): WebClient = builder.healthWebClient(prisonApiBaseUri, healthTimeout) + * + * @Bean + * fun prisonApiWebClient(authorizedClientManager: OAuth2AuthorizedClientManager, builder: WebClient.Builder): WebClient = + * builder.authorisedWebClient(authorizedClientManager, registrationId = "prison-api", url = prisonApiBaseUri, timeout) + * ``` + */ + // @Bean + fun authorizedClientManager( + clientRegistrationRepository: ClientRegistrationRepository, + oAuth2AuthorizedClientService: OAuth2AuthorizedClientService, + ): OAuth2AuthorizedClientManager { + val authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder.builder().clientCredentials().build() + return AuthorizedClientServiceOAuth2AuthorizedClientManager( + clientRegistrationRepository, + oAuth2AuthorizedClientService, + ).kotlinApply { setAuthorizedClientProvider(authorizedClientProvider) } + } +} + +fun WebClient.Builder.authorisedWebClient(authorizedClientManager: OAuth2AuthorizedClientManager, registrationId: String, url: String, timeout: Duration): WebClient { + val oauth2Client = ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager).kotlinApply { + setDefaultClientRegistrationId(registrationId) + } + + return baseUrl(url) + .clientConnector(ReactorClientHttpConnector(HttpClient.create().responseTimeout(timeout))) + .filter(oauth2Client) + .build() +} + +fun WebClient.Builder.healthWebClient(url: String, healthTimeout: Duration): WebClient = + baseUrl(url) + .clientConnector(ReactorClientHttpConnector(HttpClient.create().responseTimeout(healthTimeout))) + .build() diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/health/HealthCheck.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/health/HealthCheck.kt new file mode 100644 index 0000000..ab5d34f --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/health/HealthCheck.kt @@ -0,0 +1,27 @@ +package uk.gov.justice.digital.hmpps.hmppstemplatepackagename.health + +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.boot.actuate.health.Health +import org.springframework.boot.actuate.health.ReactiveHealthIndicator +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.WebClientResponseException +import reactor.core.publisher.Mono + +abstract class HealthCheck(private val webClient: WebClient) : ReactiveHealthIndicator { + + override fun health(): Mono = webClient.get() + .uri("/health/ping") + .retrieve() + .toEntity(String::class.java) + .flatMap { Mono.just(Health.up().withDetail("HttpStatus", it?.statusCode).build()) } + .onErrorResume(WebClientResponseException::class.java) { + Mono.just( + Health.down(it).withDetail("body", it.responseBodyAsString).withDetail("HttpStatus", it.statusCode).build(), + ) + } + .onErrorResume(Exception::class.java) { Mono.just(Health.down(it).build()) } +} + +@Component("hmppsAuthApi") +class OAuthApiHealth(@Qualifier("oauthApiHealthWebClient") webClient: WebClient) : HealthCheck(webClient) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8c4fe7d..7f8beb5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -13,6 +13,26 @@ spring: serialization: WRITE_DATES_AS_TIMESTAMPS: false + security: + oauth2: + resourceserver: + jwt: + jwk-set-uri: ${api.base.url.oauth}/.well-known/jwks.json + + client: + provider: + hmpps-auth: + token-uri: ${api.base.url.oauth}/oauth/token +# TODO this provides a template for registering client credentials - only add prison-api if you need it +# registration: +# prison-api: +# provider: hmpps-auth +# client-id: ${prison-api.client.id} +# client-secret: ${prison-api.client.secret} +# authorization-grant-type: client_credentials +# scope: read + + server: port: 8080 servlet: diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/helper/JwtAuthHelper.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/helper/JwtAuthHelper.kt new file mode 100644 index 0000000..fea75a6 --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/helper/JwtAuthHelper.kt @@ -0,0 +1,60 @@ +package uk.gov.justice.digital.hmpps.hmppstemplatepackagename.helper + +import io.jsonwebtoken.Jwts +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Primary +import org.springframework.http.HttpHeaders +import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder +import org.springframework.stereotype.Component +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.interfaces.RSAPublicKey +import java.time.Duration +import java.util.Date +import java.util.UUID + +@Component +class JwtAuthHelper { + private val keyPair: KeyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + + @Bean + @Primary + fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withPublicKey(keyPair.public as RSAPublicKey).build() + + fun setAuthorisation( + user: String = "AUTH_ADM", + roles: List = listOf(), + scopes: List = listOf(), + ): (HttpHeaders) -> Unit { + val token = createJwt( + subject = user, + scope = scopes, + expiryTime = Duration.ofHours(1L), + roles = roles, + ) + return { it.set(HttpHeaders.AUTHORIZATION, "Bearer $token") } + } + + internal fun createJwt( + subject: String?, + scope: List? = listOf(), + roles: List? = listOf(), + expiryTime: Duration = Duration.ofHours(1), + jwtId: String = UUID.randomUUID().toString(), + ): String = + mutableMapOf() + .also { subject?.let { subject -> it["user_name"] = subject } } + .also { it["client_id"] = "test-app" } + .also { roles?.let { roles -> it["authorities"] = roles } } + .also { scope?.let { scope -> it["scope"] = scope } } + .let { + Jwts.builder() + .id(jwtId) + .subject(subject) + .claims(it.toMap()) + .expiration(Date(System.currentTimeMillis() + expiryTime.toMillis())) + .signWith(keyPair.private, Jwts.SIG.RS256) + .compact() + } +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/IntegrationTestBase.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/IntegrationTestBase.kt index 0657e15..0f69bc0 100644 --- a/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/IntegrationTestBase.kt +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/IntegrationTestBase.kt @@ -3,14 +3,24 @@ package uk.gov.justice.digital.hmpps.hmppstemplatepackagename.integration import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT +import org.springframework.http.HttpHeaders import org.springframework.test.context.ActiveProfiles import org.springframework.test.web.reactive.server.WebTestClient +import uk.gov.justice.digital.hmpps.hmppstemplatepackagename.helper.JwtAuthHelper @SpringBootTest(webEnvironment = RANDOM_PORT) @ActiveProfiles("test") abstract class IntegrationTestBase { - @Suppress("SpringJavaInjectionPointsAutowiringInspection") @Autowired lateinit var webTestClient: WebTestClient + + @Autowired + protected lateinit var jwtAuthHelper: JwtAuthHelper + + internal fun setAuthorisation( + user: String = "AUTH_ADM", + roles: List = listOf(), + scopes: List = listOf("read"), + ): (HttpHeaders) -> Unit = jwtAuthHelper.setAuthorisation(user, roles, scopes) } diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/NotFoundTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/NotFoundTest.kt index b047e0d..5d61c12 100644 --- a/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/NotFoundTest.kt +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/NotFoundTest.kt @@ -7,6 +7,7 @@ class NotFoundTest : IntegrationTestBase() { @Test fun `Resources that aren't found should return 404 - test of the exception handler`() { webTestClient.get().uri("/some-url-not-found") + .headers(setAuthorisation(roles = listOf())) .exchange() .expectStatus().isNotFound } diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/TimeResourceIntTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/TimeResourceIntTest.kt new file mode 100644 index 0000000..a565567 --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/TimeResourceIntTest.kt @@ -0,0 +1,50 @@ +package uk.gov.justice.digital.hmpps.hmppstemplatepackagename.integration + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.time.LocalDate + +class TimeResourceIntTest : IntegrationTestBase() { + + @Test + fun `should return unauthorized if no token`() { + webTestClient.get() + .uri("/time") + .exchange() + .expectStatus().isUnauthorized + } + + @Test + fun `should return forbidden if no role`() { + webTestClient.get() + .uri("/time") + .headers(setAuthorisation(roles = listOf())) + .exchange() + .expectStatus().isForbidden + .expectBody().jsonPath("status").isEqualTo(403) + } + + @Test + fun `should return forbidden if wrong role`() { + webTestClient.get() + .uri("/time") + .headers(setAuthorisation(roles = listOf("ROLE_WRONG"))) + .exchange() + .expectStatus().isForbidden + .expectBody().jsonPath("status").isEqualTo(403) + } + + @Test + fun `should return OK`() { + webTestClient.get() + .uri("/time") + .headers(setAuthorisation(roles = listOf("ROLE_TEMPLATE_EXAMPLE"))) + .exchange() + .expectStatus() + .isOk + .expectBody() + .jsonPath("$").value { + assertThat(it).startsWith("${LocalDate.now()}") + } + } +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/health/HealthCheckTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/health/HealthCheckTest.kt index 05abc2c..b33add4 100644 --- a/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/health/HealthCheckTest.kt +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/health/HealthCheckTest.kt @@ -2,15 +2,22 @@ package uk.gov.justice.digital.hmpps.hmppstemplatepackagename.integration.health import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith import uk.gov.justice.digital.hmpps.hmppstemplatepackagename.integration.IntegrationTestBase +import uk.gov.justice.digital.hmpps.hmppstemplatepackagename.integration.wiremock.HmppsAuthApiExtension import java.time.LocalDateTime import java.time.format.DateTimeFormatter import java.util.function.Consumer +@ExtendWith( + HmppsAuthApiExtension::class, +) class HealthCheckTest : IntegrationTestBase() { @Test fun `Health page reports ok`() { + stubPingWithResponse(200) + webTestClient.get() .uri("/health") .exchange() @@ -18,10 +25,26 @@ class HealthCheckTest : IntegrationTestBase() { .isOk .expectBody() .jsonPath("status").isEqualTo("UP") + .jsonPath("components.hmppsAuthApi.status").isEqualTo("UP") + } + + @Test + fun `Health page reports down`() { + stubPingWithResponse(404) + + webTestClient.get() + .uri("/health") + .exchange() + .expectStatus().is5xxServerError + .expectBody() + .jsonPath("status").isEqualTo("DOWN") + .jsonPath("components.hmppsAuthApi.status").isEqualTo("DOWN") } @Test fun `Health info reports version`() { + stubPingWithResponse(200) + webTestClient.get().uri("/health") .exchange() .expectStatus().isOk @@ -64,4 +87,8 @@ class HealthCheckTest : IntegrationTestBase() { .expectBody() .jsonPath("status").isEqualTo("UP") } + + private fun stubPingWithResponse(status: Int) { + HmppsAuthApiExtension.hmppsAuth.stubHealthPing(status) + } } diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/wiremock/HmppsAuthMockServer.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/wiremock/HmppsAuthMockServer.kt new file mode 100644 index 0000000..005812a --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/wiremock/HmppsAuthMockServer.kt @@ -0,0 +1,65 @@ +package uk.gov.justice.digital.hmpps.hmppstemplatepackagename.integration.wiremock + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock +import com.github.tomakehurst.wiremock.client.WireMock.aResponse +import com.github.tomakehurst.wiremock.client.WireMock.post +import com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo +import com.github.tomakehurst.wiremock.http.HttpHeader +import com.github.tomakehurst.wiremock.http.HttpHeaders +import org.junit.jupiter.api.extension.AfterAllCallback +import org.junit.jupiter.api.extension.BeforeAllCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +class HmppsAuthMockServer : WireMockServer(8090) { + fun stubHealthPing(status: Int) { + stubFor( + WireMock.get("/auth/health/ping").willReturn( + aResponse() + .withHeader("Content-Type", "application/json") + .withBody(if (status == 200) "pong" else "some error") + .withStatus(status), + ), + ) + } + + fun stubGrantToken() { + stubFor( + post(urlEqualTo("/auth/oauth/token")) + .willReturn( + aResponse() + .withHeaders(HttpHeaders(HttpHeader("Content-Type", "application/json"))) + .withBody( + """ + { + "access_token": "ABCDE", + "token_type": "bearer" + } + """.trimIndent(), + ), + ), + ) + } +} + +class HmppsAuthApiExtension : BeforeAllCallback, AfterAllCallback, BeforeEachCallback { + companion object { + @JvmField + val hmppsAuth = HmppsAuthMockServer() + } + + override fun beforeAll(context: ExtensionContext) { + hmppsAuth.start() + hmppsAuth.stubGrantToken() + } + + override fun beforeEach(context: ExtensionContext) { + hmppsAuth.resetAll() + hmppsAuth.stubGrantToken() + } + + override fun afterAll(context: ExtensionContext) { + hmppsAuth.stop() + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index ffd550e..6dd1b0b 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -4,3 +4,6 @@ server: management.endpoint: health.cache.time-to-live: 0 info.cache.time-to-live: 0 + +api.base.url: + oauth: http://localhost:8090/auth \ No newline at end of file From be3e39a2af0618db0b5a068a702c4e8fab55f030 Mon Sep 17 00:00:00 2001 From: Mike Halma Date: Fri, 23 Feb 2024 16:54:49 +0000 Subject: [PATCH 2/3] SDIT-1336 Add in Spring Security by including the library hmpps-kotlin-spring-boot-starter --- .../digital/hmpps/hmppstemplatepackagename/TimeResource.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/TimeResource.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/TimeResource.kt index 162c633..f3418ab 100644 --- a/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/TimeResource.kt +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/TimeResource.kt @@ -6,6 +6,11 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import java.time.LocalDateTime +/** + * TODO + * This is just an example of what a secured endpoint might look like. + * Remove this class and associated tests in [TimeResourceIntTest] and replace with your own implementation. + */ @RestController @RequestMapping("/time") class TimeResource { From 4eb7fb72e9e00015c91446df7cb94b1cc787d113 Mon Sep 17 00:00:00 2001 From: Mike Halma Date: Tue, 27 Feb 2024 14:38:34 +0000 Subject: [PATCH 3/3] SDIT-1336 Use web client and health ping helpers from the kotlin lib --- build.gradle.kts | 2 +- helm_deploy/values-dev.yaml | 2 +- helm_deploy/values-preprod.yaml | 2 +- helm_deploy/values-prod.yaml | 2 +- .../hmppstemplatepackagename/TimeResource.kt | 8 +++- .../config/WebClientConfiguration.kt | 45 +++---------------- .../health/HealthCheck.kt | 27 ----------- .../health/HealthPingCheck.kt | 15 +++++++ src/main/resources/application.yml | 4 +- .../integration/health/HealthCheckTest.kt | 4 +- .../wiremock/HmppsAuthMockServer.kt | 2 +- src/test/resources/application-test.yml | 2 +- 12 files changed, 38 insertions(+), 77 deletions(-) delete mode 100644 src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/health/HealthCheck.kt create mode 100644 src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/health/HealthPingCheck.kt diff --git a/build.gradle.kts b/build.gradle.kts index b17125e..9ff6087 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,7 +8,7 @@ configurations { } dependencies { - implementation("uk.gov.justice.service.hmpps:hmpps-kotlin-spring-boot-starter:0.1.2") + implementation("uk.gov.justice.service.hmpps:hmpps-kotlin-spring-boot-starter:0.2.1") implementation("org.springframework.boot:spring-boot-starter-webflux") testImplementation("org.wiremock:wiremock-standalone:3.4.0") diff --git a/helm_deploy/values-dev.yaml b/helm_deploy/values-dev.yaml index 1ae6003..8534b6a 100644 --- a/helm_deploy/values-dev.yaml +++ b/helm_deploy/values-dev.yaml @@ -9,7 +9,7 @@ generic-service: env: APPLICATIONINSIGHTS_CONFIGURATION_FILE: applicationinsights.dev.json - API_BASE_URL_OAUTH: "https://sign-in-dev.hmpps.service.justice.gov.uk/auth" + API_BASE_URL_HMPPS_AUTH: "https://sign-in-dev.hmpps.service.justice.gov.uk/auth" # CloudPlatform AlertManager receiver to route prometheus alerts to slack # See https://user-guide.cloud-platform.service.justice.gov.uk/documentation/monitoring-an-app/how-to-create-alarms.html#creating-your-own-custom-alerts diff --git a/helm_deploy/values-preprod.yaml b/helm_deploy/values-preprod.yaml index 9e21122..12f6b4f 100644 --- a/helm_deploy/values-preprod.yaml +++ b/helm_deploy/values-preprod.yaml @@ -9,7 +9,7 @@ generic-service: env: APPLICATIONINSIGHTS_CONFIGURATION_FILE: applicationinsights.dev.json - API_BASE_URL_OAUTH: "https://sign-in-preprod.hmpps.service.justice.gov.uk/auth" + API_BASE_URL_HMPPS_AUTH: "https://sign-in-preprod.hmpps.service.justice.gov.uk/auth" # CloudPlatform AlertManager receiver to route prometheus alerts to slack # See https://user-guide.cloud-platform.service.justice.gov.uk/documentation/monitoring-an-app/how-to-create-alarms.html#creating-your-own-custom-alerts diff --git a/helm_deploy/values-prod.yaml b/helm_deploy/values-prod.yaml index a6bd821..1a536c2 100644 --- a/helm_deploy/values-prod.yaml +++ b/helm_deploy/values-prod.yaml @@ -6,7 +6,7 @@ generic-service: host: hmpps-template-kotlin.hmpps.service.justice.gov.uk env: - API_BASE_URL_OAUTH: "https://sign-in.hmpps.service.justice.gov.uk/auth" + API_BASE_URL_HMPPS_AUTH: "https://sign-in.hmpps.service.justice.gov.uk/auth" # CloudPlatform AlertManager receiver to route prometheus alerts to slack # See https://user-guide.cloud-platform.service.justice.gov.uk/documentation/monitoring-an-app/how-to-create-alarms.html#creating-your-own-custom-alerts diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/TimeResource.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/TimeResource.kt index f3418ab..adcc3f2 100644 --- a/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/TimeResource.kt +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/TimeResource.kt @@ -1,5 +1,6 @@ package uk.gov.justice.digital.hmpps.hmppstemplatepackagename +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping @@ -10,12 +11,17 @@ import java.time.LocalDateTime * TODO * This is just an example of what a secured endpoint might look like. * Remove this class and associated tests in [TimeResourceIntTest] and replace with your own implementation. + * + * Note that the `@ConditionalOnProperty` annotation is used to ensure that this endpoint is only enabled when the template + * is deployed to the dev environment and in tests. Just in case you forget to remove this class after bootstrapping. This + * isn't a pattern you should use in your own code. */ +@ConditionalOnExpression("'\${api.base.url.hmpps-auth}' == 'https://sign-in-dev.hmpps.service.justice.gov.uk/auth' OR '\${api.base.url.hmpps-auth}' == 'http://localhost:8090/auth'") @RestController @RequestMapping("/time") class TimeResource { @PreAuthorize("hasRole('TEMPLATE_EXAMPLE')") @GetMapping - fun getTime() = "${LocalDateTime.now()}" + fun getTime() = LocalDateTime.now().toString() } diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/config/WebClientConfiguration.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/config/WebClientConfiguration.kt index 5569a84..7d00392 100644 --- a/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/config/WebClientConfiguration.kt +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/config/WebClientConfiguration.kt @@ -3,31 +3,24 @@ package uk.gov.justice.digital.hmpps.hmppstemplatepackagename.config import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.http.client.reactive.ReactorClientHttpConnector -import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager -import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager -import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder -import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService -import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository -import org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction import org.springframework.web.reactive.function.client.WebClient -import reactor.netty.http.client.HttpClient +import uk.gov.justice.hmpps.kotlin.auth.healthWebClient import java.time.Duration -import kotlin.apply as kotlinApply @Configuration class WebClientConfiguration( - @Value("\${api.base.url.oauth}") val oauthApiBaseUri: String, + @Value("\${api.base.url.hmpps-auth}") val hmppsAuthBaseUri: String, @Value("\${api.health-timeout:2s}") val healthTimeout: Duration, @Value("\${api.timeout:90s}") val timeout: Duration, ) { @Bean - fun oauthApiHealthWebClient(builder: WebClient.Builder): WebClient = builder.healthWebClient(oauthApiBaseUri, healthTimeout) + fun hmppsAuthHealthWebClient(builder: WebClient.Builder): WebClient = + builder.healthWebClient(hmppsAuthBaseUri, healthTimeout) /** * TODO * Once you have a client registration defined in properties `spring.security.client.registration` then you'll - * need to uncomment this @Bean and create both a health and authorized web client. + * need to create both a health and authorized web client. * * e.g. if your client registration config looks like this (registrationId is `prison-api`): * ``` @@ -51,32 +44,6 @@ class WebClientConfiguration( * fun prisonApiWebClient(authorizedClientManager: OAuth2AuthorizedClientManager, builder: WebClient.Builder): WebClient = * builder.authorisedWebClient(authorizedClientManager, registrationId = "prison-api", url = prisonApiBaseUri, timeout) * ``` + * Though if you are using a reactive web server the corresponding builder functions should be `reactiveHealthWebClient` and `reactiveAuthorisedWebClient`. */ - // @Bean - fun authorizedClientManager( - clientRegistrationRepository: ClientRegistrationRepository, - oAuth2AuthorizedClientService: OAuth2AuthorizedClientService, - ): OAuth2AuthorizedClientManager { - val authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder.builder().clientCredentials().build() - return AuthorizedClientServiceOAuth2AuthorizedClientManager( - clientRegistrationRepository, - oAuth2AuthorizedClientService, - ).kotlinApply { setAuthorizedClientProvider(authorizedClientProvider) } - } } - -fun WebClient.Builder.authorisedWebClient(authorizedClientManager: OAuth2AuthorizedClientManager, registrationId: String, url: String, timeout: Duration): WebClient { - val oauth2Client = ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager).kotlinApply { - setDefaultClientRegistrationId(registrationId) - } - - return baseUrl(url) - .clientConnector(ReactorClientHttpConnector(HttpClient.create().responseTimeout(timeout))) - .filter(oauth2Client) - .build() -} - -fun WebClient.Builder.healthWebClient(url: String, healthTimeout: Duration): WebClient = - baseUrl(url) - .clientConnector(ReactorClientHttpConnector(HttpClient.create().responseTimeout(healthTimeout))) - .build() diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/health/HealthCheck.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/health/HealthCheck.kt deleted file mode 100644 index ab5d34f..0000000 --- a/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/health/HealthCheck.kt +++ /dev/null @@ -1,27 +0,0 @@ -package uk.gov.justice.digital.hmpps.hmppstemplatepackagename.health - -import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.boot.actuate.health.Health -import org.springframework.boot.actuate.health.ReactiveHealthIndicator -import org.springframework.stereotype.Component -import org.springframework.web.reactive.function.client.WebClient -import org.springframework.web.reactive.function.client.WebClientResponseException -import reactor.core.publisher.Mono - -abstract class HealthCheck(private val webClient: WebClient) : ReactiveHealthIndicator { - - override fun health(): Mono = webClient.get() - .uri("/health/ping") - .retrieve() - .toEntity(String::class.java) - .flatMap { Mono.just(Health.up().withDetail("HttpStatus", it?.statusCode).build()) } - .onErrorResume(WebClientResponseException::class.java) { - Mono.just( - Health.down(it).withDetail("body", it.responseBodyAsString).withDetail("HttpStatus", it.statusCode).build(), - ) - } - .onErrorResume(Exception::class.java) { Mono.just(Health.down(it).build()) } -} - -@Component("hmppsAuthApi") -class OAuthApiHealth(@Qualifier("oauthApiHealthWebClient") webClient: WebClient) : HealthCheck(webClient) diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/health/HealthPingCheck.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/health/HealthPingCheck.kt new file mode 100644 index 0000000..a5ecb1d --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/health/HealthPingCheck.kt @@ -0,0 +1,15 @@ +@file:Suppress("ktlint:standard:filename") + +package uk.gov.justice.digital.hmpps.hmppstemplatepackagename.health + +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import uk.gov.justice.hmpps.kotlin.health.HealthPingCheck + +@Component("hmppsAuth") +class HmppsAuthHealthPingCheck(@Qualifier("hmppsAuthHealthWebClient") webClient: WebClient) : HealthPingCheck(webClient) + +// TODO add this back in if you create a bean called `prisonApiHealthWebClient` +// @Component("prisonApi") +// class PrisonApiHealthPingCheck(@Qualifier("prisonApiHealthWebClient") webClient: WebClient) : HealthPingCheck(webClient) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7f8beb5..d8c2ce6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -17,12 +17,12 @@ spring: oauth2: resourceserver: jwt: - jwk-set-uri: ${api.base.url.oauth}/.well-known/jwks.json + jwk-set-uri: ${api.base.url.hmpps-auth}/.well-known/jwks.json client: provider: hmpps-auth: - token-uri: ${api.base.url.oauth}/oauth/token + token-uri: ${api.base.url.hmpps-auth}/oauth/token # TODO this provides a template for registering client credentials - only add prison-api if you need it # registration: # prison-api: diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/health/HealthCheckTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/health/HealthCheckTest.kt index b33add4..53ecd74 100644 --- a/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/health/HealthCheckTest.kt +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/health/HealthCheckTest.kt @@ -25,7 +25,7 @@ class HealthCheckTest : IntegrationTestBase() { .isOk .expectBody() .jsonPath("status").isEqualTo("UP") - .jsonPath("components.hmppsAuthApi.status").isEqualTo("UP") + .jsonPath("components.hmppsAuth.status").isEqualTo("UP") } @Test @@ -38,7 +38,7 @@ class HealthCheckTest : IntegrationTestBase() { .expectStatus().is5xxServerError .expectBody() .jsonPath("status").isEqualTo("DOWN") - .jsonPath("components.hmppsAuthApi.status").isEqualTo("DOWN") + .jsonPath("components.hmppsAuth.status").isEqualTo("DOWN") } @Test diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/wiremock/HmppsAuthMockServer.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/wiremock/HmppsAuthMockServer.kt index 005812a..cbe0a95 100644 --- a/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/wiremock/HmppsAuthMockServer.kt +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/integration/wiremock/HmppsAuthMockServer.kt @@ -18,7 +18,7 @@ class HmppsAuthMockServer : WireMockServer(8090) { WireMock.get("/auth/health/ping").willReturn( aResponse() .withHeader("Content-Type", "application/json") - .withBody(if (status == 200) "pong" else "some error") + .withBody(if (status == 200) """{"status":"UP"}""" else """{"status":"DOWN"}""") .withStatus(status), ), ) diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 6dd1b0b..bbf75fe 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -6,4 +6,4 @@ management.endpoint: info.cache.time-to-live: 0 api.base.url: - oauth: http://localhost:8090/auth \ No newline at end of file + hmpps-auth: http://localhost:8090/auth \ No newline at end of file