Skip to content

Commit

Permalink
SDIT-1336 Add in Spring Security by including the library hmpps-kotli…
Browse files Browse the repository at this point in the history
…n-spring-boot-starter
  • Loading branch information
mikehalmamoj committed Feb 23, 2024
1 parent 3252f67 commit 56e464e
Show file tree
Hide file tree
Showing 16 changed files with 381 additions and 19 deletions.
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.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 {
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_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
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_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
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_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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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()}"
}
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 {
@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(
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,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()
Original file line number Diff line number Diff line change
@@ -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<Health> = 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)
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.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:
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

0 comments on commit 56e464e

Please sign in to comment.