diff --git a/libs/commons/src/main/kotlin/uk/gov/justice/digital/hmpps/ldap/LdapTemplateExtensions.kt b/libs/commons/src/main/kotlin/uk/gov/justice/digital/hmpps/ldap/LdapTemplateExtensions.kt index f0b90fc8cc..19d596f43a 100644 --- a/libs/commons/src/main/kotlin/uk/gov/justice/digital/hmpps/ldap/LdapTemplateExtensions.kt +++ b/libs/commons/src/main/kotlin/uk/gov/justice/digital/hmpps/ldap/LdapTemplateExtensions.kt @@ -4,17 +4,15 @@ import io.opentelemetry.instrumentation.annotations.SpanAttribute import io.opentelemetry.instrumentation.annotations.WithSpan import org.springframework.ldap.core.AttributesMapper import org.springframework.ldap.core.LdapTemplate +import org.springframework.ldap.query.LdapQuery +import org.springframework.ldap.query.LdapQueryBuilder import org.springframework.ldap.query.LdapQueryBuilder.query import org.springframework.ldap.query.SearchScope +fun LdapQueryBuilder.byUsername(username: String): LdapQuery = base("ou=Users").searchScope(SearchScope.ONELEVEL).where("cn").`is`(username) + @WithSpan -inline fun LdapTemplate.findByUsername(@SpanAttribute username: String) = find( - query() - .base("ou=Users") - .searchScope(SearchScope.ONELEVEL) - .where("cn").`is`(username), - T::class.java -).singleOrNull() +inline fun LdapTemplate.findByUsername(@SpanAttribute username: String) = find(query().byUsername(username), T::class.java).singleOrNull() @WithSpan fun LdapTemplate.findEmailByUsername(@SpanAttribute username: String) = search( diff --git a/projects/hmpps-auth-and-delius/build.gradle.kts b/projects/hmpps-auth-and-delius/build.gradle.kts index 687f79a7a0..91f16dd147 100644 --- a/projects/hmpps-auth-and-delius/build.gradle.kts +++ b/projects/hmpps-auth-and-delius/build.gradle.kts @@ -10,6 +10,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-data-ldap") implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-web") @@ -21,6 +22,7 @@ dependencies { dev(project(":libs:dev-tools")) dev("com.h2database:h2") dev("org.testcontainers:oracle-xe") + dev("com.unboundid:unboundid-ldapsdk") runtimeOnly("com.oracle.database.jdbc:ojdbc11") diff --git a/projects/hmpps-auth-and-delius/deploy/values.yaml b/projects/hmpps-auth-and-delius/deploy/values.yaml index f24d9badf8..9e2eb1ddbc 100644 --- a/projects/hmpps-auth-and-delius/deploy/values.yaml +++ b/projects/hmpps-auth-and-delius/deploy/values.yaml @@ -11,6 +11,9 @@ generic-service: namespace_secrets: common: SPRING_DATASOURCE_URL: DB_URL + SPRING_LDAP_URLS: LDAP_URL + SPRING_LDAP_USERNAME: LDAP_USERNAME + SPRING_LDAP_PASSWORD: LDAP_PASSWORD hmpps-auth-and-delius-database: SPRING_DATASOURCE_USERNAME: DB_USERNAME SPRING_DATASOURCE_PASSWORD: DB_PASSWORD diff --git a/projects/hmpps-auth-and-delius/src/dev/resources/local-public-key.pub b/projects/hmpps-auth-and-delius/src/dev/resources/local-public-key.pub index c0b70f3172..2e35c3c5b9 100644 --- a/projects/hmpps-auth-and-delius/src/dev/resources/local-public-key.pub +++ b/projects/hmpps-auth-and-delius/src/dev/resources/local-public-key.pub @@ -1,6 +1,6 @@ -----BEGIN PUBLIC KEY----- -MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDo3hw1/oChbttEOxEH4NUDrH+Y -n2x0DavAmDjMbhcSiQ6+/t8Nz/N03BauWzFOGBtftnQrHfnF+O7RAKj8zMjcbIq4 -QrYeXEpnaFCGEwTtOBpxvSEWPrLEpr1gCarBQZDp67ag+SYqrDgkn2Vme/dMvMUQ -xUO3DT6jg9921J6TlwIDAQAB +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQChGeUcaJnFldtX2x3eqCZVXvXC +eXVzoka/DR1D52HvhYdmDc/gx1kyg+S+A1ppdEw1K3qGrUUu2VCgI9aQogZE61NY +CBFblMsHfgQyB076ghz/KdzVF+y5KVu61/alFzDkZ7q/z+k3Mn2xaWrcHP/MPReu +7r9FF41O5uPWza/7aQIDAQAB -----END PUBLIC KEY----- \ No newline at end of file diff --git a/projects/hmpps-auth-and-delius/src/dev/resources/schema.ldif b/projects/hmpps-auth-and-delius/src/dev/resources/schema.ldif new file mode 100644 index 0000000000..7445a42f90 --- /dev/null +++ b/projects/hmpps-auth-and-delius/src/dev/resources/schema.ldif @@ -0,0 +1,33 @@ +dn: dc=moj,dc=com +dc: moj +objectclass: top +objectclass: domain + +dn: ou=Users,dc=moj,dc=com +objectclass: top +objectclass: organizationalUnit +ou: Users + +dn: cn=test.user,ou=Users,dc=moj,dc=com +cn: test.user +objectclass: NDUser +objectclass: inetOrgPerson +objectclass: top +givenName: Test +sn: User +userHomeArea: N01 +userSector: public +mail: test.user@example.com +userPassword: secret + +dn: cn=ABC001,cn=test.user,ou=Users,dc=moj,dc=com +cn: ABC001 +description: Test role 1 +objectclass: top +objectClass: NDRoleAssociation + +dn: cn=ABC002,cn=test.user,ou=Users,dc=moj,dc=com +cn: ABC002 +description: Test role 2 +objectclass: top +objectClass: NDRoleAssociation diff --git a/projects/hmpps-auth-and-delius/src/dev/resources/simulations/__files/hmpps-auth-token-body.json b/projects/hmpps-auth-and-delius/src/dev/resources/simulations/__files/hmpps-auth-token-body.json index 33e1aa358c..c36f67b184 100644 --- a/projects/hmpps-auth-and-delius/src/dev/resources/simulations/__files/hmpps-auth-token-body.json +++ b/projects/hmpps-auth-and-delius/src/dev/resources/simulations/__files/hmpps-auth-token-body.json @@ -1,5 +1,5 @@ { - "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJwcm9iYXRpb24taW50ZWdyYXRpb24tZGV2IiwiZ3JhbnRfdHlwZSI6ImNsaWVudF9jcmVkZW50aWFscyIsInVzZXJfbmFtZSI6InByb2JhdGlvbi1pbnRlZ3JhdGlvbi1kZXYiLCJzY29wZSI6WyJyZWFkIiwid3JpdGUiXSwiYXV0aF9zb3VyY2UiOiJub25lIiwiaXNzIjoiaHR0cHM6Ly9zaWduLWluLWRldi5obXBwcy5zZXJ2aWNlLmp1c3RpY2UuZ292LnVrL2F1dGgvaXNzdWVyIiwiZXhwIjo5OTk5OTk5OTk5LCJhdXRob3JpdGllcyI6WyJST0xFX0VYQU1QTEUiLCJST0xFX1dPUktMT0FEX1JFQUQiLCJST0xFX0FMTE9DQVRJT05fQ09OVEVYVCJdLCJqdGkiOiIyNUR1Um4xLWh5SFpld0xjZEpKeHdWTDAzS1UiLCJjbGllbnRfaWQiOiJwcm9iYXRpb24taW50ZWdyYXRpb24tZGV2IiwiaWF0IjoxNjYzNzU3MzExfQ.5FTCUjA7QZMPxO_EMzkGNSM-IkPk2hfPXyzuNiAa7uuqYva_yCducrC5FdetAiC1W6XpUB7wfoMNDmbW2xepj5oRhcxDx18r92aLPYnKkxaA68hLQF90euMtTzfBzOPg-rKDTNIJKrUC-YoQlFKuCauw0Z5cw1XT6R9GIfi5Yx4", + "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJwcm9iYXRpb24taW50ZWdyYXRpb24tZGV2IiwiZ3JhbnRfdHlwZSI6ImNsaWVudF9jcmVkZW50aWFscyIsInVzZXJfbmFtZSI6InByb2JhdGlvbi1pbnRlZ3JhdGlvbi1kZXYiLCJzY29wZSI6WyJyZWFkIiwid3JpdGUiXSwiYXV0aF9zb3VyY2UiOiJub25lIiwiaXNzIjoiaHR0cHM6Ly9zaWduLWluLWRldi5obXBwcy5zZXJ2aWNlLmp1c3RpY2UuZ292LnVrL2F1dGgvaXNzdWVyIiwiZXhwIjo5OTk5OTk5OTk5LCJhdXRob3JpdGllcyI6WyJST0xFX0RFTElVU19VU0VSX0FVVEgiXSwianRpIjoiMjVEdVJuMS1oeUhaZXdMY2RKSnh3VkwwM0tVIiwiY2xpZW50X2lkIjoicHJvYmF0aW9uLWludGVncmF0aW9uLWRldiIsImlhdCI6MTY2Mzc1NzMxMX0.Xt-xLPTsU6mmADsJKJ8TPVW8-ZC2P3Srw6SbxmnPW53dd_yH-RjX3EFiX9FzHUFW6Was6dWdyRfyPWTva-9SD3y1hPTdwjVU6aRNvG71UH4THz3yXDJQ43amKXi-OLE3B1CaIrPxt3v5jGzGoRcMmQt_OjrEC2nVFx5jr1IS0zc", "token_type": "bearer", "expires_in": 9999999999, "scope": "read write", diff --git a/projects/hmpps-auth-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/AuthenticationIntegrationTest.kt b/projects/hmpps-auth-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/AuthenticationIntegrationTest.kt new file mode 100644 index 0000000000..fa3ae9d84c --- /dev/null +++ b/projects/hmpps-auth-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/AuthenticationIntegrationTest.kt @@ -0,0 +1,83 @@ +package uk.gov.justice.digital.hmpps + +import com.github.tomakehurst.wiremock.WireMockServer +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT +import org.springframework.http.MediaType +import org.springframework.ldap.core.AttributesMapper +import org.springframework.ldap.core.LdapTemplate +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import uk.gov.justice.digital.hmpps.security.withOAuth2Token + +@AutoConfigureMockMvc +@SpringBootTest(webEnvironment = RANDOM_PORT) +internal class AuthenticationIntegrationTest { + @Autowired + lateinit var mockMvc: MockMvc + + @Autowired + lateinit var wireMockServer: WireMockServer + + @Autowired + lateinit var ldapTemplate: LdapTemplate + + @Test + fun `successful authentication`() { + mockMvc.perform(authenticate("""{"username": "test.user", "password": "secret"}""")) + .andExpect(status().isOk) + } + + @Test + fun `failed authentication`() { + mockMvc.perform(authenticate("""{"username": "test.user", "password": "incorrect"}""")) + .andExpect(status().isUnauthorized) + } + + @Test + fun `invalid authentication request`() { + mockMvc.perform(authenticate("""{"username": "", "password": " "}""")) + .andExpect(status().isBadRequest) + } + + @Test + @DirtiesContext + fun `successful password change`() { + assertThat(currentPassword(), equalTo("secret")) + mockMvc.perform(changePassword("test.user", """{"password": "new"}""")) + .andExpect(status().isOk) + assertThat(currentPassword(), equalTo("new")) + } + + @Test + fun `attempt to change password for non-existent user`() { + mockMvc.perform(changePassword("some user", """{"password": "new"}""")) + .andExpect(status().isNotFound) + } + + @Test + fun `invalid password change request`() { + mockMvc.perform(changePassword("some user", """{}""")) + .andExpect(status().isBadRequest) + } + + private fun authenticate(json: String) = post("/authenticate").withOAuth2Token(wireMockServer) + .contentType(MediaType.APPLICATION_JSON) + .content(json) + + private fun changePassword(username: String, json: String) = + post("/user/$username/password").withOAuth2Token(wireMockServer) + .contentType(MediaType.APPLICATION_JSON) + .content(json) + + private fun currentPassword() = + ldapTemplate.search("ou=Users", "cn=test.user", AttributesMapper { String(it["userPassword"].get() as ByteArray) }) + .toList().single() +} diff --git a/projects/hmpps-auth-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/IntegrationTest.kt b/projects/hmpps-auth-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/IntegrationTest.kt deleted file mode 100644 index d2382a469a..0000000000 --- a/projects/hmpps-auth-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/IntegrationTest.kt +++ /dev/null @@ -1,31 +0,0 @@ -package uk.gov.justice.digital.hmpps - -import com.github.tomakehurst.wiremock.WireMockServer -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT -import org.springframework.boot.test.mock.mockito.MockBean -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import uk.gov.justice.digital.hmpps.security.withOAuth2Token -import uk.gov.justice.digital.hmpps.telemetry.TelemetryService - -@AutoConfigureMockMvc -@SpringBootTest(webEnvironment = RANDOM_PORT) -internal class IntegrationTest { - @Autowired lateinit var mockMvc: MockMvc - - @Autowired lateinit var wireMockServer: WireMockServer - - @MockBean lateinit var telemetryService: TelemetryService - - @Test - fun `API call retuns a success response`() { - mockMvc - .perform(get("/example/123").withOAuth2Token(wireMockServer)) - .andExpect(status().is2xxSuccessful) - } -} diff --git a/projects/hmpps-auth-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/UserDetailsIntegrationTest.kt b/projects/hmpps-auth-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/UserDetailsIntegrationTest.kt new file mode 100644 index 0000000000..4b345b2662 --- /dev/null +++ b/projects/hmpps-auth-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/UserDetailsIntegrationTest.kt @@ -0,0 +1,71 @@ +package uk.gov.justice.digital.hmpps + +import com.fasterxml.jackson.databind.ObjectMapper +import com.github.tomakehurst.wiremock.WireMockServer +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import uk.gov.justice.digital.hmpps.model.UserDetails +import uk.gov.justice.digital.hmpps.security.withOAuth2Token + +@AutoConfigureMockMvc +@SpringBootTest(webEnvironment = RANDOM_PORT) +internal class UserDetailsIntegrationTest { + @Autowired + lateinit var mockMvc: MockMvc + + @Autowired + lateinit var wireMockServer: WireMockServer + + @Autowired + lateinit var objectMapper: ObjectMapper + + @Test + fun `missing user returns 404`() { + mockMvc.perform(get("/user/does.not.exist").withOAuth2Token(wireMockServer)) + .andExpect(status().isNotFound) + } + + @Test + fun `get user`() { + mockMvc.perform(get("/user/test.user").withOAuth2Token(wireMockServer)) + .andExpect(status().isOk) + .andExpectJson( + UserDetails( + username = "test.user", + firstName = "Test", + surname = "User", + email = "test.user@example.com", + enabled = true, + roles = listOf("ABC001", "ABC002") + ) + ) + } + + @Test + fun `search by email`() { + mockMvc.perform(get("/user?email=test.user@example.com").withOAuth2Token(wireMockServer)) + .andExpect(status().isOk) + .andExpectJson( + listOf( + UserDetails( + username = "test.user", + firstName = "Test", + surname = "User", + email = "test.user@example.com", + enabled = true, + roles = listOf("ABC001", "ABC002") + ) + ) + ) + } + + fun ResultActions.andExpectJson(obj: T) = this.andExpect(content().json(objectMapper.writeValueAsString(obj))) +} diff --git a/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/App.kt b/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/App.kt index c7faac5b26..2c6b3789ed 100644 --- a/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/App.kt +++ b/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/App.kt @@ -1,9 +1,10 @@ package uk.gov.justice.digital.hmpps import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.data.ldap.LdapRepositoriesAutoConfiguration import org.springframework.boot.runApplication -@SpringBootApplication +@SpringBootApplication(exclude = [LdapRepositoriesAutoConfiguration::class]) class App fun main(args: Array) { diff --git a/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/controller/ApiController.kt b/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/controller/ApiController.kt deleted file mode 100644 index e5f139965c..0000000000 --- a/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/controller/ApiController.kt +++ /dev/null @@ -1,17 +0,0 @@ -package uk.gov.justice.digital.hmpps.controller - -import org.springframework.security.access.prepost.PreAuthorize -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.RestController - -@RestController -class ApiController { - @PreAuthorize("hasRole('ROLE_EXAMPLE')") - @GetMapping(value = ["/example/{inputId}"]) - fun handle( - @PathVariable("inputId") inputId: String - ) { - // TODO Not yet implemented - } -} diff --git a/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/controller/AuthenticationController.kt b/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/controller/AuthenticationController.kt new file mode 100644 index 0000000000..04acae6f29 --- /dev/null +++ b/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/controller/AuthenticationController.kt @@ -0,0 +1,39 @@ +package uk.gov.justice.digital.hmpps.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.ldap.core.LdapTemplate +import org.springframework.ldap.query.LdapQueryBuilder.query +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RestController +import uk.gov.justice.digital.hmpps.ldap.byUsername +import uk.gov.justice.digital.hmpps.model.AuthenticationRequest + +@Validated +@RestController +@Tag(name = "Authentication") +class AuthenticationController(private val ldapTemplate: LdapTemplate) { + companion object { + private val log = LoggerFactory.getLogger(this::class.java) + } + + @PostMapping("/authenticate") + @PreAuthorize("hasRole('ROLE_DELIUS_USER_AUTH')") + @Operation(description = "Authenticate a Delius username and password. Requires ROLE_DELIUS_USER_AUTH.") + fun authenticate( + @Valid @RequestBody + request: AuthenticationRequest + ) = try { + ldapTemplate.authenticate(query().byUsername(request.username), request.password) + ResponseEntity.ok().build() + } catch (e: Exception) { + log.error("Authentication failed for '${request.username}'", e) + ResponseEntity.status(401).body("Authentication failed for '${request.username}'") + } +} diff --git a/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/controller/UserController.kt b/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/controller/UserController.kt new file mode 100644 index 0000000000..043f14e517 --- /dev/null +++ b/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/controller/UserController.kt @@ -0,0 +1,42 @@ +package uk.gov.justice.digital.hmpps.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import uk.gov.justice.digital.hmpps.exception.NotFoundException +import uk.gov.justice.digital.hmpps.model.PasswordChangeRequest +import uk.gov.justice.digital.hmpps.service.UserService + +@Validated +@RestController +@Tag(name = "User details") +class UserController(private val userService: UserService) { + @GetMapping(value = ["/user/{username}"]) + @PreAuthorize("hasAnyRole('ROLE_DELIUS_USER_AUTH')") + @Operation(description = "Get user details. Requires ROLE_DELIUS_USER_AUTH.") + fun getUserDetails(@PathVariable username: String) = userService.getUserDetails(username) + ?: throw NotFoundException("User", "username", username) + + @GetMapping(value = ["/user"]) + @PreAuthorize("hasAnyRole('ROLE_DELIUS_USER_AUTH')") + @Operation(description = "Get users by email. Requires ROLE_DELIUS_USER_AUTH.") + fun getUsersByEmail(@RequestParam email: String) = userService.getUsersByEmail(email) + + @PostMapping("/user/{username}/password") + @PreAuthorize("hasRole('ROLE_DELIUS_USER_AUTH')") + @Operation(description = "Change a Delius user's password. Requires ROLE_DELIUS_USER_AUTH.") + fun changePassword( + @PathVariable("username") @NotBlank username: String, + @Valid @RequestBody + request: PasswordChangeRequest + ) = userService.changePassword(username, request.password) +} diff --git a/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/entity/LdapUser.kt b/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/entity/LdapUser.kt new file mode 100644 index 0000000000..49d2d9de78 --- /dev/null +++ b/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/entity/LdapUser.kt @@ -0,0 +1,42 @@ +package uk.gov.justice.digital.hmpps.entity + +import org.springframework.ldap.odm.annotations.Attribute +import org.springframework.ldap.odm.annotations.DnAttribute +import org.springframework.ldap.odm.annotations.Entry +import org.springframework.ldap.odm.annotations.Id +import org.springframework.ldap.odm.annotations.Transient +import java.time.LocalDate +import java.time.LocalDate.now +import java.time.format.DateTimeFormatter.ofPattern +import javax.naming.Name + +@Entry(objectClasses = ["inetOrgPerson", "top"], base = "ou=Users") +class LdapUser( + @Id + val dn: Name, + + @Attribute(name = "cn") + @DnAttribute(value = "cn", index = 1) + val username: String, + + @Attribute(name = "givenName") + val forename: String, + + @Attribute(name = "sn") + val surname: String, + + @Attribute(name = "userHomeArea") + val homeArea: String?, + + @Attribute(name = "mail") + val email: String?, + + @Attribute(name = "endDate") + val endDate: String?, + + @Transient + var roles: List +) { + val enabled: Boolean + get() = endDate == null || LocalDate.parse(endDate.substring(0, 8), ofPattern("yyyyMMdd")).isAfter(now()) +} diff --git a/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/model/AuthenticationRequest.kt b/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/model/AuthenticationRequest.kt new file mode 100644 index 0000000000..3071a6d927 --- /dev/null +++ b/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/model/AuthenticationRequest.kt @@ -0,0 +1,8 @@ +package uk.gov.justice.digital.hmpps.model + +import jakarta.validation.constraints.NotBlank + +data class AuthenticationRequest( + @field:NotBlank val username: String, + @field:NotBlank val password: String +) diff --git a/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/model/PasswordChangeRequest.kt b/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/model/PasswordChangeRequest.kt new file mode 100644 index 0000000000..b412c45618 --- /dev/null +++ b/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/model/PasswordChangeRequest.kt @@ -0,0 +1,7 @@ +package uk.gov.justice.digital.hmpps.model + +import jakarta.validation.constraints.NotBlank + +data class PasswordChangeRequest( + @field:NotBlank val password: String +) diff --git a/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/model/UserDetails.kt b/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/model/UserDetails.kt new file mode 100644 index 0000000000..7a0cd4b9db --- /dev/null +++ b/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/model/UserDetails.kt @@ -0,0 +1,10 @@ +package uk.gov.justice.digital.hmpps.model + +data class UserDetails( + val username: String, + val firstName: String, + val surname: String, + val email: String?, + val enabled: Boolean, + val roles: List +) diff --git a/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/UserService.kt b/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/UserService.kt new file mode 100644 index 0000000000..a0addf7a73 --- /dev/null +++ b/projects/hmpps-auth-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/UserService.kt @@ -0,0 +1,54 @@ +package uk.gov.justice.digital.hmpps.service + +import org.springframework.dao.IncorrectResultSizeDataAccessException +import org.springframework.ldap.core.AttributesMapper +import org.springframework.ldap.core.LdapTemplate +import org.springframework.ldap.query.LdapQueryBuilder.query +import org.springframework.ldap.query.SearchScope +import org.springframework.stereotype.Service +import uk.gov.justice.digital.hmpps.entity.LdapUser +import uk.gov.justice.digital.hmpps.exception.NotFoundException +import uk.gov.justice.digital.hmpps.ldap.byUsername +import uk.gov.justice.digital.hmpps.ldap.findByUsername +import uk.gov.justice.digital.hmpps.model.UserDetails +import javax.naming.Name + +@Service +class UserService(private val ldapTemplate: LdapTemplate) { + + fun getUserDetails(username: String) = ldapTemplate.findByUsername(username)?.toUserDetails() + + fun getUsersByEmail(email: String) = ldapTemplate.find( + query() + .base("ou=Users") + .searchScope(SearchScope.ONELEVEL) + .where("mail").`is`(email), + LdapUser::class.java + )?.map { it.toUserDetails() } ?: emptyList() + + fun changePassword(username: String, password: String) = try { + val context = ldapTemplate.searchForContext(query().byUsername(username)) + context.setAttributeValue("userPassword", password) + ldapTemplate.modifyAttributes(context) + } catch (e: IncorrectResultSizeDataAccessException) { + if (e.actualSize == 0) throw NotFoundException("User", "username", username) + throw e + } + + private fun getUserRoles(name: Name): List = ldapTemplate.search( + query() + .base(name) + .searchScope(SearchScope.ONELEVEL) + .filter("(|(objectclass=NDRole)(objectclass=NDRoleAssociation))"), + AttributesMapper { it["cn"].get().toString() } + ) + + private fun LdapUser.toUserDetails() = UserDetails( + username = username, + firstName = forename, + surname = surname, + email = email, + enabled = enabled, + roles = getUserRoles(dn) + ) +} diff --git a/projects/hmpps-auth-and-delius/src/main/resources/application.yml b/projects/hmpps-auth-and-delius/src/main/resources/application.yml index 9b219923e7..35ea668d36 100644 --- a/projects/hmpps-auth-and-delius/src/main/resources/application.yml +++ b/projects/hmpps-auth-and-delius/src/main/resources/application.yml @@ -16,6 +16,10 @@ spring: global_temporary: create_tables: false drop_tables: false + ldap: + base: dc=moj,dc=com + base-environment: + java.naming.ldap.derefAliases: never security.oauth2.client: registration: hmpps-auth-and-delius: @@ -41,6 +45,9 @@ spring.config.activate.on-profile: [ "dev", "integration-test" ] spring: datasource.url: jdbc:h2:file:./dev;MODE=Oracle;DEFAULT_NULL_ORDERING=HIGH;AUTO_SERVER=true;AUTO_SERVER_PORT=9092 jpa.hibernate.ddl-auto: create-drop + ldap.embedded: + base-dn: ${spring.ldap.base} + validation.enabled: false security.oauth2.resourceserver.jwt.public-key-location: classpath:local-public-key.pub seed.database: true