Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SDIT-1336 Add in Spring Security by including the library hmpps-kotlin-spring-boot-starter #196

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
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
7 changes: 6 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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"
}

Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions helm_deploy/values-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions helm_deploy/values-preprod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions helm_deploy/values-prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wondering if you could do something like only enabling for local / dev/ test based on a bean property so it doesn't get deployed to other people's instances if they don't remove it?

@RequestMapping("/time")
class TimeResource {

@PreAuthorize("hasRole('TEMPLATE_EXAMPLE')")
@GetMapping
fun getTime() = LocalDateTime.now().toString()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment on lines 16 to 17
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a prime candidate for moving to the library hmpps-kotlin-spring-boot-starter, as long we make it easy to extend.

@ExceptionHandler(AccessDeniedException::class)
fun handleAccessDeniedException(e: AccessDeniedException): ResponseEntity<ErrorResponse> = 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<ErrorResponse> = ResponseEntity
.status(BAD_REQUEST)
Expand Down Expand Up @@ -50,20 +58,3 @@ class HmppsTemplateKotlinExceptionHandler {
private val log = LoggerFactory.getLogger(this::class.java)
}
}

data class ErrorResponse(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now comes from the library hmpps-kotlin-spring-boot-starter

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)
}
Original file line number Diff line number Diff line change
@@ -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`.
*/
}
Original file line number Diff line number Diff line change
@@ -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)
20 changes: 20 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> = listOf(),
scopes: List<String> = 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<String>? = listOf(),
roles: List<String>? = listOf(),
expiryTime: Duration = Duration.ofHours(1),
jwtId: String = UUID.randomUUID().toString(),
): String =
mutableMapOf<String, Any>()
.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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = listOf(),
scopes: List<String> = listOf("read"),
): (HttpHeaders) -> Unit = jwtAuthHelper.setAuthorisation(user, roles, scopes)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> {
assertThat(it).startsWith("${LocalDate.now()}")
}
}
}
Loading