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 1 commit
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.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"
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 we could have a better variable now for HMPPS Auth instead


# 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
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()}"
Copy link
Contributor Author

@mikehalmamoj mikehalmamoj Feb 23, 2024

Choose a reason for hiding this comment

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

This is the secure endpoint we were asked to add by @rj-adams. The plan is to call this from the Typescript template so it has an example of calling a secured endpoint.

I like it because it's simple and there's no harm if somebody forgets to remove it - it doesn't poolute the API model. On the other hand we're going to have to add some kind of model if when we get onto the OpenAPI docs ticket as an example of an OpenAPI specification.

Copy link
Contributor

@petergphillips petergphillips Feb 26, 2024

Choose a reason for hiding this comment

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

Suggested change
fun getTime() = "${LocalDateTime.now()}"
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,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,
Copy link
Contributor

Choose a reason for hiding this comment

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

could this be hmppsAuthBaseUri instead?

@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.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've got an idea around generating all web clients in the library hmpps-kotlin-spring-boot-starter by reading the client registrations from configuration and automatically creating web clients for each. But this will have to do for now.

*
* 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 {
Copy link
Contributor Author

@mikehalmamoj mikehalmamoj Feb 23, 2024

Choose a reason for hiding this comment

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

If you don't have any client registrations then Spring doesn't create a ClientRegistrationRepository, hence the @Bean annotation is commented out.

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 {
Copy link
Contributor

Choose a reason for hiding this comment

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

should this be extending the Reactive version?


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