diff --git a/build.gradle.kts b/build.gradle.kts index 0869e1c..9ff6087 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.2.1") 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..8534b6a 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_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 0f4782b..12f6b4f 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_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 77f0d0d..1a536c2 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_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 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..adcc3f2 --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/TimeResource.kt @@ -0,0 +1,27 @@ +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 +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. + * + * 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().toString() +} 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..7d00392 --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/hmppstemplatepackagename/config/WebClientConfiguration.kt @@ -0,0 +1,49 @@ +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.web.reactive.function.client.WebClient +import uk.gov.justice.hmpps.kotlin.auth.healthWebClient +import java.time.Duration + +@Configuration +class WebClientConfiguration( + @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 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 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) + * ``` + * Though if you are using a reactive web server the corresponding builder functions should be `reactiveHealthWebClient` and `reactiveAuthorisedWebClient`. + */ +} 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 8c4fe7d..d8c2ce6 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.hmpps-auth}/.well-known/jwks.json + + client: + provider: + hmpps-auth: + 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: +# 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..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 @@ -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.hmppsAuth.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.hmppsAuth.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..cbe0a95 --- /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) """{"status":"UP"}""" else """{"status":"DOWN"}""") + .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..bbf75fe 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: + hmpps-auth: http://localhost:8090/auth \ No newline at end of file