From 6895b141a07d85294ee3a139a36ad18f0f0b9266 Mon Sep 17 00:00:00 2001 From: AudunSorheim Date: Thu, 7 Dec 2023 14:32:53 +0100 Subject: [PATCH] Add kontaktinfo API --- .github/workflows/deploy-redis.yaml | 28 +++++ build.gradle.kts | 6 +- gradle.properties | 1 + nais/nais-dev.yaml | 36 ++++++ nais/nais-prod.yaml | 36 ++++++ nais/redis-dev.yaml | 11 ++ nais/redis-prod.yaml | 11 ++ src/main/kotlin/no/nav/syfo/Log.kt | 8 ++ .../OppfolgingsplanBackendApplication.kt | 4 +- .../no/nav/syfo/auth/azure/AzureAdToken.kt | 10 ++ .../nav/syfo/auth/azure/AzureAdTokenClient.kt | 62 ++++++++++ .../syfo/auth/azure/AzureAdTokenResponse.kt | 20 ++++ .../no/nav/syfo/auth/oidc/OIDCIssuer.kt | 5 + .../kotlin/no/nav/syfo/auth/oidc/TokenUtil.kt | 15 +++ .../no/nav/syfo/auth/tokenx/TokenXResponse.kt | 22 ++++ .../no/nav/syfo/auth/tokenx/TokenXToken.kt | 10 ++ .../no/nav/syfo/auth/tokenx/TokenXUtil.kt | 38 +++++++ .../tokenx/tokendings/TokenDingsConsumer.kt | 107 ++++++++++++++++++ .../nav/syfo/brukertilgang/BrukerTilgang.kt | 5 + .../brukertilgang/BrukerTilgangController.kt | 57 ++++++++++ .../syfo/brukertilgang/BrukertilgangClient.kt | 85 ++++++++++++++ .../brukertilgang/BrukertilgangService.kt | 21 ++++ .../no/nav/syfo/brukertilgang/RSTilgang.kt | 6 + .../RequestUnauthorizedException.kt | 7 ++ .../kotlin/no/nav/syfo/config/RedisConfig.kt | 34 ++++++ .../no/nav/syfo/domain/Fodselsnummer.kt | 9 ++ .../syfo/exception/GlobalExceptionHandler.kt | 58 ++++++++++ .../syfo/kontaktinfo/DigitalKontaktinfo.kt | 48 ++++++++ .../no/nav/syfo/kontaktinfo/Kontaktinfo.kt | 8 ++ .../syfo/kontaktinfo/KontaktinfoController.kt | 57 ++++++++++ .../no/nav/syfo/kontaktinfo/KrrClient.kt | 72 ++++++++++++ .../kontaktinfo/KrrRequestFailedException.kt | 9 ++ src/main/kotlin/no/nav/syfo/metric/Metrikk.kt | 35 ++++++ .../kotlin/no/nav/syfo/util/CredentialUtil.kt | 5 + .../kotlin/no/nav/syfo/util/RequestUtil.kt | 12 ++ .../kotlin/no/nav/syfo/util/StringUtil.kt | 7 ++ src/main/resources/application.yaml | 31 +++++ .../BrukerTilgangControllerTest.kt | 60 ++++++++++ .../kontaktinfo/KontaktinfoControllerTest.kt | 57 ++++++++++ src/test/resources/application.yaml | 31 ++++- 40 files changed, 1137 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/deploy-redis.yaml create mode 100644 gradle.properties create mode 100644 nais/redis-dev.yaml create mode 100644 nais/redis-prod.yaml create mode 100644 src/main/kotlin/no/nav/syfo/Log.kt rename src/main/kotlin/no/nav/syfo/{oppfolgingsplanbackend => }/OppfolgingsplanBackendApplication.kt (70%) create mode 100644 src/main/kotlin/no/nav/syfo/auth/azure/AzureAdToken.kt create mode 100644 src/main/kotlin/no/nav/syfo/auth/azure/AzureAdTokenClient.kt create mode 100644 src/main/kotlin/no/nav/syfo/auth/azure/AzureAdTokenResponse.kt create mode 100644 src/main/kotlin/no/nav/syfo/auth/oidc/OIDCIssuer.kt create mode 100644 src/main/kotlin/no/nav/syfo/auth/oidc/TokenUtil.kt create mode 100644 src/main/kotlin/no/nav/syfo/auth/tokenx/TokenXResponse.kt create mode 100644 src/main/kotlin/no/nav/syfo/auth/tokenx/TokenXToken.kt create mode 100644 src/main/kotlin/no/nav/syfo/auth/tokenx/TokenXUtil.kt create mode 100644 src/main/kotlin/no/nav/syfo/auth/tokenx/tokendings/TokenDingsConsumer.kt create mode 100644 src/main/kotlin/no/nav/syfo/brukertilgang/BrukerTilgang.kt create mode 100644 src/main/kotlin/no/nav/syfo/brukertilgang/BrukerTilgangController.kt create mode 100644 src/main/kotlin/no/nav/syfo/brukertilgang/BrukertilgangClient.kt create mode 100644 src/main/kotlin/no/nav/syfo/brukertilgang/BrukertilgangService.kt create mode 100644 src/main/kotlin/no/nav/syfo/brukertilgang/RSTilgang.kt create mode 100644 src/main/kotlin/no/nav/syfo/brukertilgang/RequestUnauthorizedException.kt create mode 100644 src/main/kotlin/no/nav/syfo/config/RedisConfig.kt create mode 100644 src/main/kotlin/no/nav/syfo/domain/Fodselsnummer.kt create mode 100644 src/main/kotlin/no/nav/syfo/exception/GlobalExceptionHandler.kt create mode 100644 src/main/kotlin/no/nav/syfo/kontaktinfo/DigitalKontaktinfo.kt create mode 100644 src/main/kotlin/no/nav/syfo/kontaktinfo/Kontaktinfo.kt create mode 100644 src/main/kotlin/no/nav/syfo/kontaktinfo/KontaktinfoController.kt create mode 100644 src/main/kotlin/no/nav/syfo/kontaktinfo/KrrClient.kt create mode 100644 src/main/kotlin/no/nav/syfo/kontaktinfo/KrrRequestFailedException.kt create mode 100644 src/main/kotlin/no/nav/syfo/metric/Metrikk.kt create mode 100644 src/main/kotlin/no/nav/syfo/util/CredentialUtil.kt create mode 100644 src/main/kotlin/no/nav/syfo/util/RequestUtil.kt create mode 100644 src/main/kotlin/no/nav/syfo/util/StringUtil.kt create mode 100644 src/test/kotlin/no/nav/syfo/brukertilgang/BrukerTilgangControllerTest.kt create mode 100644 src/test/kotlin/no/nav/syfo/kontaktinfo/KontaktinfoControllerTest.kt diff --git a/.github/workflows/deploy-redis.yaml b/.github/workflows/deploy-redis.yaml new file mode 100644 index 0000000..1956b33 --- /dev/null +++ b/.github/workflows/deploy-redis.yaml @@ -0,0 +1,28 @@ +name: Deploy Redis +on: + push: + paths: ['nais/redis-dev.yaml', 'nais/redis-prod.yaml'] + workflow_dispatch: + +jobs: + deploy-redis-dev: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: nais/deploy/actions/deploy@v1 + env: + APIKEY: ${{ secrets.NAIS_DEPLOY_APIKEY }} + CLUSTER: dev-gcp + RESOURCE: nais/redis-dev.yaml + + deploy-redis-prod: + if: github.ref == 'refs/heads/main' + needs: deploy-redis-dev + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: nais/deploy/actions/deploy@v1 + env: + APIKEY: ${{ secrets.NAIS_DEPLOY_APIKEY }} + CLUSTER: prod-gcp + RESOURCE: nais/redis-prod.yaml diff --git a/build.gradle.kts b/build.gradle.kts index 16d2272..5a4d72a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,12 +37,13 @@ val logstashLogbackEncoderVersion = "7.4" dependencies { implementation("org.springframework.boot:spring-boot-starter") implementation("org.springframework.boot:spring-boot-starter-jdbc") - implementation("org.springframework.boot:spring-boot-starter-oauth2-client") - implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-cache") + implementation("org.springframework.boot:spring-boot-starter-data-redis") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("io.micrometer:micrometer-registry-prometheus") + implementation("org.hibernate.validator:hibernate-validator") implementation("org.postgresql:postgresql") implementation("org.flywaydb:flyway-core") implementation("org.jetbrains.kotlin:kotlin-reflect") @@ -54,7 +55,6 @@ dependencies { developmentOnly("org.springframework.boot:spring-boot-devtools") annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") testImplementation("org.springframework.boot:spring-boot-starter-test") - testImplementation("org.springframework.security:spring-security-test") testImplementation("io.mockk:mockk:$mockkVersion") testImplementation("io.kotest:kotest-runner-junit5-jvm:$kotestVersion") testImplementation("io.kotest:kotest-assertions-core:$kotestVersion") diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..7de497a --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options="-Xmx2048M" -Dfile.encoding=UTF-8 diff --git a/nais/nais-dev.yaml b/nais/nais-dev.yaml index 6250b5a..8cda3f2 100644 --- a/nais/nais-dev.yaml +++ b/nais/nais-dev.yaml @@ -38,6 +38,28 @@ spec: pool: nav-dev tokenx: enabled: true + accessPolicy: + inbound: + rules: + - application: oppfolgingsplan-frontend + outbound: + rules: + - application: digdir-krr-proxy + namespace: team-rocket + cluster: dev-gcp + - application: syfobrukertilgang + namespace: team-esyfo + cluster: dev-gcp + azure: + application: + allowAllUsers: true + enabled: true + tenant: trygdeetaten.no + replyURLs: + - "https://oppfolgingsplan-backend.intern.dev.nav.no/oauth2/callback" + claims: + extra: + - "NAVident" gcp: sqlInstances: - type: POSTGRES_14 @@ -59,3 +81,17 @@ spec: flags: - name: cloudsql.logical_decoding value: "on" + env: + - name: KRR_URL + value: https://digdir-krr-proxy.intern.dev.nav.no/rest/v1/person + - name: KRR_SCOPE + value: dev-gcp.team-rocket.digdir-krr-proxy + - name: SYFOBRUKERTILGANG_URL + value: http://syfobrukertilgang + - name: SYFOBRUKERTILGANG_ID + value: dev-gcp:team-esyfo:syfobrukertilgang + - name: OPPFOLGINGSPLAN_FRONTEND_CLIENT_ID + value: dev-gcp:team-esyfo:oppfolgingsplan-frontend + redis: + - instance: oppfolgingsplan + access: readwrite diff --git a/nais/nais-prod.yaml b/nais/nais-prod.yaml index 3be772d..c53f9a6 100644 --- a/nais/nais-prod.yaml +++ b/nais/nais-prod.yaml @@ -38,6 +38,28 @@ spec: pool: nav-prod tokenx: enabled: true + accessPolicy: + inbound: + rules: + - application: oppfolgingsplan-frontend + outbound: + rules: + - application: digdir-krr-proxy + namespace: team-rocket + cluster: prod-gcp + - application: syfobrukertilgang + namespace: team-esyfo + cluster: prod-gcp + azure: + application: + allowAllUsers: true + enabled: true + tenant: nav.no + replyURLs: + - "https://oppfolgingsplan-backend.intern.nav.no/oauth2/callback" + claims: + extra: + - "NAVident" gcp: sqlInstances: - type: POSTGRES_14 @@ -59,3 +81,17 @@ spec: flags: - name: cloudsql.logical_decoding value: "on" + env: + - name: KRR_URL + value: https://digdir-krr-proxy.intern.nav.no/rest/v1/person + - name: KRR_SCOPE + value: prod-gcp.team-rocket.digdir-krr-proxy + - name: SYFOBRUKERTILGANG_URL + value: http://syfobrukertilgang + - name: SYFOBRUKERTILGANG_ID + value: prod-gcp:team-esyfo:syfobrukertilgang + - name: OPPFOLGINGSPLAN_FRONTEND_CLIENT_ID + value: prod-gcp:team-esyfo:oppfolgingsplan-frontend + redis: + - instance: oppfolgingsplan + access: readwrite diff --git a/nais/redis-dev.yaml b/nais/redis-dev.yaml new file mode 100644 index 0000000..d6dd3a1 --- /dev/null +++ b/nais/redis-dev.yaml @@ -0,0 +1,11 @@ +apiVersion: aiven.io/v1alpha1 +kind: Redis +metadata: + labels: + app: oppfolgingsplan-redis + team: team-esyfo + name: redis-team-esyfo-oppfolgingsplan + namespace: team-esyfo +spec: + plan: startup-4 + project: nav-dev diff --git a/nais/redis-prod.yaml b/nais/redis-prod.yaml new file mode 100644 index 0000000..6674759 --- /dev/null +++ b/nais/redis-prod.yaml @@ -0,0 +1,11 @@ +apiVersion: aiven.io/v1alpha1 +kind: Redis +metadata: + labels: + app: oppfolgingsplan-redis + team: team-esyfo + name: redis-team-esyfo-oppfolgingsplan + namespace: team-esyfo +spec: + plan: startup-4 + project: nav-prod diff --git a/src/main/kotlin/no/nav/syfo/Log.kt b/src/main/kotlin/no/nav/syfo/Log.kt new file mode 100644 index 0000000..52a12a5 --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/Log.kt @@ -0,0 +1,8 @@ +package no.nav.syfo + +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +inline fun T.logger(): Logger { + return LoggerFactory.getLogger(T::class.java) +} diff --git a/src/main/kotlin/no/nav/syfo/oppfolgingsplanbackend/OppfolgingsplanBackendApplication.kt b/src/main/kotlin/no/nav/syfo/OppfolgingsplanBackendApplication.kt similarity index 70% rename from src/main/kotlin/no/nav/syfo/oppfolgingsplanbackend/OppfolgingsplanBackendApplication.kt rename to src/main/kotlin/no/nav/syfo/OppfolgingsplanBackendApplication.kt index 51ac443..116cad2 100644 --- a/src/main/kotlin/no/nav/syfo/oppfolgingsplanbackend/OppfolgingsplanBackendApplication.kt +++ b/src/main/kotlin/no/nav/syfo/OppfolgingsplanBackendApplication.kt @@ -1,9 +1,11 @@ -package no.nav.syfo.oppfolgingsplanbackend +package no.nav.syfo +import no.nav.security.token.support.spring.api.EnableJwtTokenValidation import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication @SpringBootApplication +@EnableJwtTokenValidation class OppfolgingsplanBackendApplication fun main(args: Array) { diff --git a/src/main/kotlin/no/nav/syfo/auth/azure/AzureAdToken.kt b/src/main/kotlin/no/nav/syfo/auth/azure/AzureAdToken.kt new file mode 100644 index 0000000..4a1dcf2 --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/auth/azure/AzureAdToken.kt @@ -0,0 +1,10 @@ +package no.nav.syfo.auth.azure + +import java.io.Serializable +import java.time.LocalDateTime + +@SuppressWarnings("SerialVersionUIDInSerializableClass") +data class AzureAdToken( + val accessToken: String, + val expires: LocalDateTime +) : Serializable diff --git a/src/main/kotlin/no/nav/syfo/auth/azure/AzureAdTokenClient.kt b/src/main/kotlin/no/nav/syfo/auth/azure/AzureAdTokenClient.kt new file mode 100644 index 0000000..3cf5565 --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/auth/azure/AzureAdTokenClient.kt @@ -0,0 +1,62 @@ +package no.nav.syfo.auth.azure + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.util.LinkedMultiValueMap +import org.springframework.util.MultiValueMap +import org.springframework.web.client.RestClientResponseException +import org.springframework.web.client.RestTemplate +import java.nio.charset.Charset + +@Component +class AzureAdTokenClient @Autowired constructor( + @Value("\${azure.app.client.id}") private val azureAppClientId: String, + @Value("\${azure.app.client.secret}") private val azureAppClientSecret: String, + @Value("\${azure.openid.config.token.endpoint}") private val azureTokenEndpoint: String +) { + fun getOnBehalfOfToken(scopeClientId: String, token: String): String { + return getToken(requestEntity(scopeClientId, token)) + } + + fun getSystemToken(scopeClientId: String): String { + return getToken(systemTokenRequestEntity(scopeClientId)) + } + + private fun getToken(requestEntity: HttpEntity>): String { + val response = RestTemplate().postForEntity(azureTokenEndpoint, requestEntity, AzureAdTokenResponse::class.java) + return response.body?.toAzureAdToken()?.accessToken ?: throw RestClientResponseException( + "Failed to get token", + HttpStatus.INTERNAL_SERVER_ERROR.value(), + "", + HttpHeaders(), + ByteArray(0), + Charset.defaultCharset() + ) + } + + private fun requestEntity(scopeClientId: String, token: String): HttpEntity> { + val body = LinkedMultiValueMap() + body.add("client_id", azureAppClientId) + body.add("client_secret", azureAppClientSecret) + body.add("client_assertion_type", "urn:ietf:params:oauth:grant-type:jwt-bearer") + body.add("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer") + body.add("assertion", token) + body.add("scope", "api://$scopeClientId/.default") + body.add("requested_token_use", "on_behalf_of") + return HttpEntity(body, HttpHeaders().apply { contentType = MediaType.MULTIPART_FORM_DATA }) + } + + private fun systemTokenRequestEntity(scopeClientId: String): HttpEntity> { + val body = LinkedMultiValueMap() + body.add("client_id", azureAppClientId) + body.add("scope", "api://$scopeClientId/.default") + body.add("grant_type", "client_credentials") + body.add("client_secret", azureAppClientSecret) + return HttpEntity(body, HttpHeaders().apply { contentType = MediaType.MULTIPART_FORM_DATA }) + } +} diff --git a/src/main/kotlin/no/nav/syfo/auth/azure/AzureAdTokenResponse.kt b/src/main/kotlin/no/nav/syfo/auth/azure/AzureAdTokenResponse.kt new file mode 100644 index 0000000..ff8cbcd --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/auth/azure/AzureAdTokenResponse.kt @@ -0,0 +1,20 @@ +package no.nav.syfo.auth.azure + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import java.io.Serializable +import java.time.LocalDateTime + +@SuppressWarnings("SerialVersionUIDInSerializableClass", "ConstructorParameterNaming") +@JsonIgnoreProperties(ignoreUnknown = true) +data class AzureAdTokenResponse( + val access_token: String, + val expires_in: Long +) : Serializable + +fun AzureAdTokenResponse.toAzureAdToken(): AzureAdToken { + val expiresOn = LocalDateTime.now().plusSeconds(this.expires_in) + return AzureAdToken( + accessToken = this.access_token, + expires = expiresOn + ) +} diff --git a/src/main/kotlin/no/nav/syfo/auth/oidc/OIDCIssuer.kt b/src/main/kotlin/no/nav/syfo/auth/oidc/OIDCIssuer.kt new file mode 100644 index 0000000..09483fa --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/auth/oidc/OIDCIssuer.kt @@ -0,0 +1,5 @@ +package no.nav.syfo.auth.oidc + +object OIDCIssuer { + const val INTERN_AZUREAD_V2 = "internazureadv2" +} diff --git a/src/main/kotlin/no/nav/syfo/auth/oidc/TokenUtil.kt b/src/main/kotlin/no/nav/syfo/auth/oidc/TokenUtil.kt new file mode 100644 index 0000000..8631a58 --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/auth/oidc/TokenUtil.kt @@ -0,0 +1,15 @@ +package no.nav.syfo.auth.oidc + +import no.nav.security.token.support.core.context.TokenValidationContextHolder + +object TokenUtil { + + @JvmStatic + fun getIssuerToken(contextHolder: TokenValidationContextHolder, issuer: String): String { + val context = contextHolder.tokenValidationContext + return context.getJwtToken(issuer)?.tokenAsString + ?: throw TokenValidationException("Klarte ikke hente token fra issuer: $issuer") + } + + class TokenValidationException(message: String) : RuntimeException(message) +} diff --git a/src/main/kotlin/no/nav/syfo/auth/tokenx/TokenXResponse.kt b/src/main/kotlin/no/nav/syfo/auth/tokenx/TokenXResponse.kt new file mode 100644 index 0000000..b804f03 --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/auth/tokenx/TokenXResponse.kt @@ -0,0 +1,22 @@ +package no.nav.syfo.auth.tokenx + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import java.io.Serializable +import java.time.LocalDateTime + +@SuppressWarnings("SerialVersionUIDInSerializableClass", "ConstructorParameterNaming") +@JsonIgnoreProperties(ignoreUnknown = true) +data class TokenXResponse( + val access_token: String, + val issued_token_type: String, + val token_type: String, + val expires_in: Long +) : Serializable + +fun TokenXResponse.toTokenXToken(): TokenXToken { + val expiresOn = LocalDateTime.now().plusSeconds(this.expires_in) + return TokenXToken( + accessToken = this.access_token, + expires = expiresOn + ) +} diff --git a/src/main/kotlin/no/nav/syfo/auth/tokenx/TokenXToken.kt b/src/main/kotlin/no/nav/syfo/auth/tokenx/TokenXToken.kt new file mode 100644 index 0000000..627e8e1 --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/auth/tokenx/TokenXToken.kt @@ -0,0 +1,10 @@ +package no.nav.syfo.auth.tokenx + +import java.io.Serializable +import java.time.LocalDateTime + +@SuppressWarnings("SerialVersionUIDInSerializableClass") +data class TokenXToken( + val accessToken: String, + val expires: LocalDateTime +) : Serializable diff --git a/src/main/kotlin/no/nav/syfo/auth/tokenx/TokenXUtil.kt b/src/main/kotlin/no/nav/syfo/auth/tokenx/TokenXUtil.kt new file mode 100644 index 0000000..1c5426e --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/auth/tokenx/TokenXUtil.kt @@ -0,0 +1,38 @@ +package no.nav.syfo.auth.tokenx + +import no.nav.security.token.support.core.context.TokenValidationContextHolder +import no.nav.security.token.support.core.jwt.JwtTokenClaims +import no.nav.syfo.domain.Fodselsnummer +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException + +object TokenXUtil { + @Throws(ResponseStatusException::class) + fun validateTokenXClaims( + contextHolder: TokenValidationContextHolder, + vararg requestedClientId: String, + ): JwtTokenClaims { + val context = contextHolder.tokenValidationContext + val claims = context.getClaims(TokenXIssuer.TOKENX) + val clientId = claims.getStringClaim("client_id") + + if (!requestedClientId.toList().contains(clientId)) { + throw ResponseStatusException(HttpStatus.FORBIDDEN, "Uventet client id $clientId") + } + return claims + } + + fun JwtTokenClaims.fnrFromIdportenTokenX(): Fodselsnummer { + return Fodselsnummer(this.getStringClaim("pid")) + } + + fun fnrFromIdportenTokenX(contextHolder: TokenValidationContextHolder): Fodselsnummer { + val context = contextHolder.tokenValidationContext + val claims = context.getClaims(TokenXIssuer.TOKENX) + return Fodselsnummer(claims.getStringClaim("pid")) + } + + object TokenXIssuer { + const val TOKENX = "tokenx" + } +} diff --git a/src/main/kotlin/no/nav/syfo/auth/tokenx/tokendings/TokenDingsConsumer.kt b/src/main/kotlin/no/nav/syfo/auth/tokenx/tokendings/TokenDingsConsumer.kt new file mode 100644 index 0000000..40ad8ce --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/auth/tokenx/tokendings/TokenDingsConsumer.kt @@ -0,0 +1,107 @@ +package no.nav.syfo.auth.tokenx.tokendings + +import com.nimbusds.jose.JOSEObjectType +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.crypto.RSASSASigner +import com.nimbusds.jose.jwk.RSAKey +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.SignedJWT +import no.nav.syfo.auth.tokenx.TokenXResponse +import no.nav.syfo.auth.tokenx.toTokenXToken +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.util.LinkedMultiValueMap +import org.springframework.util.MultiValueMap +import org.springframework.web.client.RestClientResponseException +import org.springframework.web.client.RestTemplate +import java.time.Instant +import java.util.* + +const val JWT_EXPIRATION_SECONDS = 60L + +@Component +class TokenDingsConsumer @Autowired constructor( + @Value("\${token.x.client.id}") private val clientId: String, + @Value("\${token.x.private.jwk}") private val privateJwk: String, + @Value("\${token.x.token.endpoint}") private val tokenxEndpoint: String, +) { + + fun exchangeToken( + subjectToken: String, + targetApp: String, + ): String { + val requestEntity = requestEntity(subjectToken, tokenxEndpoint, targetApp) + + try { + val response = RestTemplate().exchange( + tokenxEndpoint, + HttpMethod.POST, + requestEntity, + TokenXResponse::class.java, + ) + val tokenX = response.body!!.toTokenXToken() + + return tokenX.accessToken + } catch (e: RestClientResponseException) { + log.error( + "Call to get TokenX failed with status: ${e.statusCode} and message: ${e.responseBodyAsString}", + e, + ) + throw e + } + } + + private fun requestEntity( + subjectToken: String, + tokenEndpoint: String, + targetApp: String, + ): HttpEntity> { + val headers = HttpHeaders() + headers.contentType = MediaType.APPLICATION_FORM_URLENCODED + val body: MultiValueMap = LinkedMultiValueMap() + body.add("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") + body.add("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") + body.add("client_assertion", getClientAssertion(tokenEndpoint)) + body.add("subject_token_type", "urn:ietf:params:oauth:token-type:jwt") + body.add("subject_token", subjectToken) + body.add("audience", targetApp) + return HttpEntity(body, headers) + } + + fun getClientAssertion(tokenEndpoint: String): String { + val rsaKey = RSAKey.parse(privateJwk) + val now = Date.from(Instant.now()) + return JWTClaimsSet.Builder() + .issuer(clientId) + .subject(clientId) + .audience(tokenEndpoint) + .issueTime(now) + .expirationTime(Date.from(Instant.now().plusSeconds(JWT_EXPIRATION_SECONDS))) + .jwtID(UUID.randomUUID().toString()) + .notBeforeTime(now) + .build() + .sign(rsaKey) + .serialize() + } + + companion object { + private val log = LoggerFactory.getLogger(TokenDingsConsumer::class.java) + } +} + +internal fun JWTClaimsSet.sign(rsaKey: RSAKey): SignedJWT = + SignedJWT( + JWSHeader.Builder(JWSAlgorithm.RS256) + .keyID(rsaKey.keyID) + .type(JOSEObjectType.JWT).build(), + this, + ).apply { + sign(RSASSASigner(rsaKey.toPrivateKey())) + } diff --git a/src/main/kotlin/no/nav/syfo/brukertilgang/BrukerTilgang.kt b/src/main/kotlin/no/nav/syfo/brukertilgang/BrukerTilgang.kt new file mode 100644 index 0000000..14f1b47 --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/brukertilgang/BrukerTilgang.kt @@ -0,0 +1,5 @@ +package no.nav.syfo.brukertilgang + +data class BrukerTilgang( + val tilgang: Boolean +) diff --git a/src/main/kotlin/no/nav/syfo/brukertilgang/BrukerTilgangController.kt b/src/main/kotlin/no/nav/syfo/brukertilgang/BrukerTilgangController.kt new file mode 100644 index 0000000..4269384 --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/brukertilgang/BrukerTilgangController.kt @@ -0,0 +1,57 @@ +package no.nav.syfo.brukertilgang + +import no.nav.security.token.support.core.api.ProtectedWithClaims +import no.nav.security.token.support.core.context.TokenValidationContextHolder +import no.nav.syfo.auth.tokenx.TokenXUtil +import no.nav.syfo.auth.tokenx.TokenXUtil.TokenXIssuer.TOKENX +import no.nav.syfo.auth.tokenx.TokenXUtil.fnrFromIdportenTokenX +import no.nav.syfo.metric.Metrikk +import no.nav.syfo.util.NAV_PERSONIDENT_HEADER +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseBody +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException + +@RestController +@ProtectedWithClaims(issuer = TOKENX, claimMap = ["acr=Level4", "acr=idporten-loa-high"], combineWithOr = true) +@RequestMapping(value = ["/api/v1/brukertilgang"]) +class BrukerTilgangController( + private val contextHolder: TokenValidationContextHolder, + private val brukertilgangClient: BrukertilgangClient, + private val brukertilgangService: BrukertilgangService, + private val metrikk: Metrikk, + @Value("\${OPPFOLGINGSPLAN_FRONTEND_CLIENT_ID}") + private val oppfolgingsplanClientId: String, +) { + @GetMapping + fun harTilgang(@RequestHeader(NAV_PERSONIDENT_HEADER) fnr: String): RSTilgang { + val innloggetIdent = TokenXUtil.validateTokenXClaims(contextHolder, oppfolgingsplanClientId) + .fnrFromIdportenTokenX() + .value + if (!brukertilgangService.tilgangTilOppslattIdent(innloggetIdent, fnr)) { + LOG.error("Ikke tilgang: Bruker spør om noen andre enn seg selv eller egne ansatte") + throw ResponseStatusException(HttpStatus.FORBIDDEN) + } + metrikk.tellHendelse("sjekk_brukertilgang") + return RSTilgang(true) + } + + @GetMapping(path = ["/ansatt"]) + @ResponseBody + fun accessToAnsatt(@RequestHeader(NAV_PERSONIDENT_HEADER) fnr: String): BrukerTilgang { + TokenXUtil.validateTokenXClaims(contextHolder, oppfolgingsplanClientId) + + metrikk.tellHendelse("accessToIdent") + + return BrukerTilgang(brukertilgangClient.hasAccessToAnsatt(fnr)) + } + + companion object { + private val LOG = LoggerFactory.getLogger(BrukerTilgangController::class.java) + } +} diff --git a/src/main/kotlin/no/nav/syfo/brukertilgang/BrukertilgangClient.kt b/src/main/kotlin/no/nav/syfo/brukertilgang/BrukertilgangClient.kt new file mode 100644 index 0000000..2908069 --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/brukertilgang/BrukertilgangClient.kt @@ -0,0 +1,85 @@ +package no.nav.syfo.brukertilgang + +import no.nav.security.token.support.core.context.TokenValidationContextHolder +import no.nav.syfo.metric.Metrikk +import no.nav.syfo.auth.oidc.TokenUtil.getIssuerToken +import no.nav.syfo.auth.tokenx.TokenXUtil.TokenXIssuer.TOKENX +import no.nav.syfo.auth.tokenx.tokendings.TokenDingsConsumer +import no.nav.syfo.util.APP_CONSUMER_ID +import no.nav.syfo.util.NAV_CALL_ID_HEADER +import no.nav.syfo.util.NAV_CONSUMER_ID_HEADER +import no.nav.syfo.util.bearerHeader +import no.nav.syfo.util.createCallId +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Service +import org.springframework.web.client.RestClientResponseException +import org.springframework.web.client.RestTemplate + +@Service +class BrukertilgangClient( + private val contextHolder: TokenValidationContextHolder, + private val metrikk: Metrikk, + private val tokenDingsConsumer: TokenDingsConsumer, + @Value("\${syfobrukertilgang.url}") private val baseUrl: String, + @Value("\${syfobrukertilgang.id}") private var targetApp: String, +) { + fun hasAccessToAnsatt(ansattFnr: String): Boolean { + val issuerToken = getIssuerToken(contextHolder, TOKENX) + val exchangedToken = tokenDingsConsumer.exchangeToken(issuerToken, targetApp) + val httpEntity = createHttpEntity(exchangedToken) + + return try { + val response = getResponse(httpEntity, ansattFnr) + metrikk.countOutgoingReponses(METRIC_CALL_BRUKERTILGANG, response.statusCode.value()) + response.body!! + } catch (e: RestClientResponseException) { + handleException(e, httpEntity) + } + } + + private fun createHttpEntity(exchangedToken: String): HttpEntity<*> { + val headers = HttpHeaders() + headers.add(HttpHeaders.AUTHORIZATION, bearerHeader(exchangedToken)) + headers.add(NAV_CALL_ID_HEADER, createCallId()) + headers.add(NAV_CONSUMER_ID_HEADER, APP_CONSUMER_ID) + return HttpEntity(headers) + } + + private fun getResponse(httpEntity: HttpEntity<*>, ansattFnr: String): ResponseEntity { + return RestTemplate().exchange( + "$baseUrl/api/v2/tilgang/ansatt/{ansattFnr}", + HttpMethod.GET, + httpEntity, + Boolean::class.java, + ansattFnr, + ) + } + + private fun handleException(e: RestClientResponseException, httpEntity: HttpEntity<*>): Nothing { + metrikk.countOutgoingReponses(METRIC_CALL_BRUKERTILGANG, e.statusCode.value()) + if (e.statusCode.isSameCodeAs(HttpStatus.UNAUTHORIZED)) { + throw RequestUnauthorizedException( + "Unauthorized request to get access to Ansatt from Syfobrukertilgang" + ) + } else { + LOG.error( + "Error requesting ansatt access from syfobrukertilgang with callId {}: ", + httpEntity.headers[NAV_CALL_ID_HEADER], + e + ) + throw e + } + } + + companion object { + private val LOG = LoggerFactory.getLogger(BrukertilgangClient::class.java) + + const val METRIC_CALL_BRUKERTILGANG = "call_syfobrukertilgang" + } +} diff --git a/src/main/kotlin/no/nav/syfo/brukertilgang/BrukertilgangService.kt b/src/main/kotlin/no/nav/syfo/brukertilgang/BrukertilgangService.kt new file mode 100644 index 0000000..69286a8 --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/brukertilgang/BrukertilgangService.kt @@ -0,0 +1,21 @@ +package no.nav.syfo.brukertilgang + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.cache.annotation.Cacheable +import org.springframework.stereotype.Service + +@Service +class BrukertilgangService @Autowired constructor( + private var brukertilgangClient: BrukertilgangClient +) { + + @Cacheable( + cacheNames = ["tilgangtilident"], + key = "#innloggetIdent.concat(#oppslaattFnr)", + condition = "#innloggetIdent != null && #oppslaattFnr != null" + ) + fun tilgangTilOppslattIdent(innloggetIdent: String, oppslaattFnr: String): Boolean { + return oppslaattFnr == innloggetIdent || brukertilgangClient.hasAccessToAnsatt(oppslaattFnr) + } +} + diff --git a/src/main/kotlin/no/nav/syfo/brukertilgang/RSTilgang.kt b/src/main/kotlin/no/nav/syfo/brukertilgang/RSTilgang.kt new file mode 100644 index 0000000..3b975f1 --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/brukertilgang/RSTilgang.kt @@ -0,0 +1,6 @@ +package no.nav.syfo.brukertilgang + +data class RSTilgang( + val harTilgang: Boolean, + val ikkeTilgangGrunn: String? = null +) diff --git a/src/main/kotlin/no/nav/syfo/brukertilgang/RequestUnauthorizedException.kt b/src/main/kotlin/no/nav/syfo/brukertilgang/RequestUnauthorizedException.kt new file mode 100644 index 0000000..ae65d39 --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/brukertilgang/RequestUnauthorizedException.kt @@ -0,0 +1,7 @@ +package no.nav.syfo.brukertilgang + +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.ResponseStatus + +@ResponseStatus(code = HttpStatus.UNAUTHORIZED) +class RequestUnauthorizedException(message: String) : RuntimeException(message) diff --git a/src/main/kotlin/no/nav/syfo/config/RedisConfig.kt b/src/main/kotlin/no/nav/syfo/config/RedisConfig.kt new file mode 100644 index 0000000..4a3b39b --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/config/RedisConfig.kt @@ -0,0 +1,34 @@ +package no.nav.syfo.config + +import io.lettuce.core.RedisURI +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.connection.RedisStandaloneConfiguration +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory +import java.net.URI + + +@Configuration +class RedisConfig { + @Bean + fun redisConnectionFactory( + @Value("\${spring.data.redis.url}") url: URI, + @Value("\${spring.data.redis.password}") password: String, + @Value("\${spring.data.redis.username}") username: String, + ): LettuceConnectionFactory { + val redisURI = RedisURI.create(url) + return LettuceConnectionFactory( + RedisStandaloneConfiguration(url.host, url.port).apply { + setUsername(username) + setPassword(password) + database = redisURI.database + }, LettuceClientConfiguration.builder().apply { + if (redisURI.isSsl) { + useSsl() + } + }.build() + ) + } +} diff --git a/src/main/kotlin/no/nav/syfo/domain/Fodselsnummer.kt b/src/main/kotlin/no/nav/syfo/domain/Fodselsnummer.kt new file mode 100644 index 0000000..76b03e8 --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/domain/Fodselsnummer.kt @@ -0,0 +1,9 @@ +package no.nav.syfo.domain + +data class Fodselsnummer(val value: String) { + private val elevenDigits = Regex("\\d{11}") + + init { + require(elevenDigits.matches(value)) { "Fodselsnummer must be 11 digits" } + } +} diff --git a/src/main/kotlin/no/nav/syfo/exception/GlobalExceptionHandler.kt b/src/main/kotlin/no/nav/syfo/exception/GlobalExceptionHandler.kt new file mode 100644 index 0000000..b354ba9 --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/exception/GlobalExceptionHandler.kt @@ -0,0 +1,58 @@ +package no.nav.syfo.exception + +import jakarta.servlet.http.HttpServletRequest +import no.nav.security.token.support.core.exceptions.JwtTokenInvalidClaimException +import no.nav.security.token.support.spring.validation.interceptor.JwtTokenUnauthorizedException +import no.nav.syfo.logger +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.HttpMediaTypeNotAcceptableException +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler + +@ControllerAdvice +class GlobalExceptionHandler { + + private val log = logger() + + @ExceptionHandler(Exception::class) + fun handleException(ex: Exception, request: HttpServletRequest): ResponseEntity { + val status = when (ex) { + is AbstractApiError -> { + logError(ex) + ex.httpStatus + } + + is JwtTokenInvalidClaimException, is JwtTokenUnauthorizedException -> HttpStatus.UNAUTHORIZED + is HttpMediaTypeNotAcceptableException -> HttpStatus.NOT_ACCEPTABLE + else -> { + log.error("Internal server error - ${ex.message} - ${request.method}: ${request.requestURI}", ex) + HttpStatus.INTERNAL_SERVER_ERROR + } + } + return ResponseEntity(ApiError(status.reasonPhrase), status) + } + + private fun logError(ex: AbstractApiError) { + when (ex.loglevel) { + LogLevel.WARN -> log.warn(ex.message, ex) + LogLevel.ERROR -> log.error(ex.message, ex) + LogLevel.OFF -> { + } + } + } +} + +private data class ApiError(val reason: String) + +abstract class AbstractApiError( + message: String, + val httpStatus: HttpStatus, + val reason: String, + val loglevel: LogLevel, + grunn: Throwable? = null +) : RuntimeException(message, grunn) + +enum class LogLevel { + WARN, ERROR, OFF +} diff --git a/src/main/kotlin/no/nav/syfo/kontaktinfo/DigitalKontaktinfo.kt b/src/main/kotlin/no/nav/syfo/kontaktinfo/DigitalKontaktinfo.kt new file mode 100644 index 0000000..1f2c42a --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/kontaktinfo/DigitalKontaktinfo.kt @@ -0,0 +1,48 @@ +package no.nav.syfo.kontaktinfo + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import java.io.Serializable + +@Suppress("SerialVersionUIDInSerializableClass") +data class DigitalKontaktinfo( + val kanVarsles: Boolean, + val reservert: Boolean, + val mobiltelefonnummer: String?, + val epostadresse: String?, +) : Serializable + +fun DigitalKontaktinfo.toKontaktinfo(fnr: String): Kontaktinfo { + return Kontaktinfo( + fnr = fnr, + epost = this.epostadresse, + tlf = this.mobiltelefonnummer, + skalHaVarsel = !this.reservert && this.kanVarsles, + ) +} + +object KontaktinfoMapper { + + private val objectMapper: ObjectMapper = ObjectMapper().apply { + registerKotlinModule() + registerModule(JavaTimeModule()) + configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) + } + + fun mapPerson(json: String): DigitalKontaktinfo { + val jsonNode = objectMapper.readTree(json) + + jsonNode.let { + return DigitalKontaktinfo( + it["kanVarsles"]?.asBoolean() ?: false, + it["reservert"]?.asBoolean() ?: true, + it["mobiltelefonnummer"]?.asText(), + it["epostadresse"]?.asText() + ) + } + } +} diff --git a/src/main/kotlin/no/nav/syfo/kontaktinfo/Kontaktinfo.kt b/src/main/kotlin/no/nav/syfo/kontaktinfo/Kontaktinfo.kt new file mode 100644 index 0000000..c84948f --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/kontaktinfo/Kontaktinfo.kt @@ -0,0 +1,8 @@ +package no.nav.syfo.kontaktinfo + +data class Kontaktinfo( + val fnr: String, + val epost: String? = null, + val tlf: String? = null, + val skalHaVarsel: Boolean, +) diff --git a/src/main/kotlin/no/nav/syfo/kontaktinfo/KontaktinfoController.kt b/src/main/kotlin/no/nav/syfo/kontaktinfo/KontaktinfoController.kt new file mode 100644 index 0000000..1ff3b0b --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/kontaktinfo/KontaktinfoController.kt @@ -0,0 +1,57 @@ +package no.nav.syfo.kontaktinfo + +import no.nav.security.token.support.core.api.ProtectedWithClaims +import no.nav.security.token.support.core.context.TokenValidationContextHolder +import no.nav.syfo.brukertilgang.BrukertilgangService +import no.nav.syfo.auth.tokenx.TokenXUtil +import no.nav.syfo.auth.tokenx.TokenXUtil.TokenXIssuer.TOKENX +import no.nav.syfo.auth.tokenx.TokenXUtil.fnrFromIdportenTokenX +import no.nav.syfo.util.NAV_PERSONIDENT_HEADER +import no.nav.syfo.util.fodselsnummerInvalid +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType.APPLICATION_JSON_VALUE +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseBody +import org.springframework.web.bind.annotation.RestController + +@RestController +@ProtectedWithClaims(issuer = TOKENX, claimMap = ["acr=Level4", "acr=idporten-loa-high"], combineWithOr = true) +@RequestMapping(value = ["/api/v1/kontaktinfo"]) +class KontaktinfoController( + private val contextHolder: TokenValidationContextHolder, + private val brukertilgangService: BrukertilgangService, + private val krrClient: KrrClient, + @Value("\${OPPFOLGINGSPLAN_FRONTEND_CLIENT_ID}") + private val oppfolgingsplanClientId: String, +) { + private val log = LoggerFactory.getLogger(KontaktinfoController::class.java) + + @ResponseBody + @GetMapping(produces = [APPLICATION_JSON_VALUE]) + fun getKontaktinfo(@RequestHeader(NAV_PERSONIDENT_HEADER) fnr: String): ResponseEntity { + val innloggetFnr = + TokenXUtil.validateTokenXClaims(contextHolder, oppfolgingsplanClientId).fnrFromIdportenTokenX().value + + return when { + fodselsnummerInvalid(fnr) -> { + log.error("Ugyldig fnr ved henting av kontaktinfo") + ResponseEntity.status(HttpStatus.FORBIDDEN).build() + } + + !brukertilgangService.tilgangTilOppslattIdent(innloggetFnr, fnr) -> { + log.error("Ikke tilgang til kontaktinfo: Bruker spør om noen andre enn seg selv eller egne ansatte") + ResponseEntity.status(HttpStatus.FORBIDDEN).build() + } + + else -> { + val kontaktinfo = krrClient.kontaktinformasjon(fnr) + ResponseEntity.ok(kontaktinfo.toKontaktinfo(fnr)) + } + } + } +} diff --git a/src/main/kotlin/no/nav/syfo/kontaktinfo/KrrClient.kt b/src/main/kotlin/no/nav/syfo/kontaktinfo/KrrClient.kt new file mode 100644 index 0000000..af2cf7b --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/kontaktinfo/KrrClient.kt @@ -0,0 +1,72 @@ +package no.nav.syfo.kontaktinfo + +import no.nav.syfo.auth.azure.AzureAdTokenClient +import no.nav.syfo.metric.Metrikk +import no.nav.syfo.util.NAV_CALL_ID_HEADER +import no.nav.syfo.util.NAV_PERSONIDENT_HEADER +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.cache.annotation.Cacheable +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Service +import org.springframework.web.client.RestTemplate +import java.util.* + +@Service +class KrrClient @Autowired constructor( + private val azureAdTokenConsumer: AzureAdTokenClient, + private val metric: Metrikk, + @Value("\${krr.scope}") private val krrScope: String, + @Value("\${krr.url}") val krrUrl: String, +) { + @Cacheable(cacheNames = ["krr_fnr"], key = "#fnr", condition = "#fnr != null") + fun kontaktinformasjon(fnr: String): DigitalKontaktinfo { + val accessToken = "Bearer ${azureAdTokenConsumer.getSystemToken(krrScope)}" + val response = RestTemplate().exchange( + krrUrl, + HttpMethod.GET, + entity(fnr, accessToken), + String::class.java + ) + + if (response.statusCode != HttpStatus.OK) { + logAndThrowError(response, "Received response with status code: ${response.statusCode}") + } + + return response.body?.let { + metric.countOutgoingReponses(METRIC_CALL_KRR, response.statusCode.value()) + KontaktinfoMapper.mapPerson(it) + } ?: logAndThrowError(response, "ResponseBody is null") + } + + private fun logAndThrowError(response: ResponseEntity, message: String): Nothing { + log.error(message) + metric.countOutgoingReponses(METRIC_CALL_KRR, response.statusCode.value()) + throw KrrRequestFailedException(message) + } + + private fun entity(fnr: String, accessToken: String): HttpEntity { + val headers = HttpHeaders() + headers.contentType = MediaType.APPLICATION_JSON + headers[HttpHeaders.AUTHORIZATION] = accessToken + headers[NAV_PERSONIDENT_HEADER] = fnr + headers[NAV_CALL_ID_HEADER] = createCallId() + return HttpEntity(headers) + } + + companion object { + private val log = LoggerFactory.getLogger(KrrClient::class.java) + const val METRIC_CALL_KRR = "call_krr" + + private fun createCallId(): String { + val randomUUID = UUID.randomUUID().toString() + return "oppfolgingsplan-backend-$randomUUID" + } + } +} diff --git a/src/main/kotlin/no/nav/syfo/kontaktinfo/KrrRequestFailedException.kt b/src/main/kotlin/no/nav/syfo/kontaktinfo/KrrRequestFailedException.kt new file mode 100644 index 0000000..47e7686 --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/kontaktinfo/KrrRequestFailedException.kt @@ -0,0 +1,9 @@ +package no.nav.syfo.kontaktinfo + +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.ResponseStatus + +@ResponseStatus(code = HttpStatus.INTERNAL_SERVER_ERROR) +class KrrRequestFailedException( + message: String = "" +) : RuntimeException("Request to get Kontaktinformasjon from KRR Failed: $message") diff --git a/src/main/kotlin/no/nav/syfo/metric/Metrikk.kt b/src/main/kotlin/no/nav/syfo/metric/Metrikk.kt new file mode 100644 index 0000000..32974fb --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/metric/Metrikk.kt @@ -0,0 +1,35 @@ +package no.nav.syfo.metric + +import io.micrometer.core.instrument.MeterRegistry +import io.micrometer.core.instrument.Tags +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Component + +@Component +class Metrikk @Autowired constructor( + private val registry: MeterRegistry +) { + fun countOutgoingReponses(navn: String, statusCode: Int) { + registry.counter( + addPrefix(navn), + Tags.of( + "type", + "info", + "status", + statusCode.toString() + ) + ).increment() + } + + fun tellHendelse(navn: String) { + registry.counter( + addPrefix(navn), + Tags.of("type", "info") + ).increment() + } + + private fun addPrefix(navn: String): String { + val metricPrefix = "oppfolgingsplan_backend_" + return metricPrefix + navn + } +} diff --git a/src/main/kotlin/no/nav/syfo/util/CredentialUtil.kt b/src/main/kotlin/no/nav/syfo/util/CredentialUtil.kt new file mode 100644 index 0000000..78f09b2 --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/util/CredentialUtil.kt @@ -0,0 +1,5 @@ +package no.nav.syfo.util + +fun bearerHeader(token: String?): String { + return "Bearer $token" +} diff --git a/src/main/kotlin/no/nav/syfo/util/RequestUtil.kt b/src/main/kotlin/no/nav/syfo/util/RequestUtil.kt new file mode 100644 index 0000000..7685899 --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/util/RequestUtil.kt @@ -0,0 +1,12 @@ +package no.nav.syfo.util + +import java.util.* + +const val NAV_CONSUMER_ID_HEADER = "Nav-Consumer-Id" +const val APP_CONSUMER_ID = "srvoppfolgingsplanbackend" +const val NAV_CALL_ID_HEADER = "Nav-Call-Id" + +const val NAV_PERSONIDENT_HEADER = "nav-personident" + +fun createCallId(): String = UUID.randomUUID().toString() + diff --git a/src/main/kotlin/no/nav/syfo/util/StringUtil.kt b/src/main/kotlin/no/nav/syfo/util/StringUtil.kt new file mode 100644 index 0000000..276b150 --- /dev/null +++ b/src/main/kotlin/no/nav/syfo/util/StringUtil.kt @@ -0,0 +1,7 @@ +package no.nav.syfo.util + +import java.util.regex.Pattern + +fun fodselsnummerValid(fnr: String): Boolean = Pattern.compile("\\d{11}").matcher(fnr).matches() + +fun fodselsnummerInvalid(fnr: String): Boolean = !fodselsnummerValid(fnr) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 38d2c03..a170271 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -18,6 +18,22 @@ spring: hikari: minimum-idle: 1 maximum-pool-size: 5 + data: + redis: + url: ${REDIS_URI_OPPFOLGINGSPLAN:} + username: ${REDIS_USERNAME_OPPFOLGINGSPLAN:} + password: ${REDIS_PASSWORD_OPPFOLGINGSPLAN:} + timeout: 2000 + lettuce: + pool: + max-active: 16 + min-idle: 8 + enabled: true + time-between-eviction-runs: 10000 + cache: + redis: + time-to-live: 3600 + enable-statistics: true management: endpoint: @@ -32,10 +48,25 @@ management: readinessState.enabled: true logging.config: "classpath:logback.xml" + nais.cluster: ${NAIS_CLUSTER_NAME} +azure: + app: + client: + id: "1345678" + secret: "secret" + openid: + config: + token: + endpoint: "https://login.microsoftonline.com/id/oauth2/v2.0/token" + no.nav.security.jwt: issuer: + internazureadv2: + discoveryurl: ${azure.app.well.known.url} + accepted_audience: ${azure.app.client.id} + cookiename: ID_token tokenx: discoveryurl: ${TOKEN_X_WELL_KNOWN_URL} accepted_audience: ${TOKEN_X_CLIENT_ID} diff --git a/src/test/kotlin/no/nav/syfo/brukertilgang/BrukerTilgangControllerTest.kt b/src/test/kotlin/no/nav/syfo/brukertilgang/BrukerTilgangControllerTest.kt new file mode 100644 index 0000000..53d04cf --- /dev/null +++ b/src/test/kotlin/no/nav/syfo/brukertilgang/BrukerTilgangControllerTest.kt @@ -0,0 +1,60 @@ +package no.nav.syfo.brukertilgang + +import io.kotest.assertions.throwables.shouldThrowExactly +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import no.nav.security.token.support.core.context.TokenValidationContext +import no.nav.security.token.support.core.context.TokenValidationContextHolder +import no.nav.security.token.support.core.jwt.JwtTokenClaims +import no.nav.syfo.auth.tokenx.TokenXUtil +import no.nav.syfo.metric.Metrikk +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException + +class BrukerTilgangControllerTest : FunSpec({ + val contextHolder = mockk() + val mockTokenValidationContext = mockk() + val mockJwtTokenClaims = mockk() + val brukertilgangConsumer = mockk() + val brukertilgangService = mockk() + val metrikk = mockk(relaxed = true) + val controller = + BrukerTilgangController(contextHolder, brukertilgangConsumer, brukertilgangService, metrikk, "clientId") + + val validFnr = "12345678910" + val invalidFnr = "123" + + beforeTest { + every { contextHolder.tokenValidationContext } returns mockTokenValidationContext + every { mockTokenValidationContext.getClaims(TokenXUtil.TokenXIssuer.TOKENX) } returns mockJwtTokenClaims + every { mockJwtTokenClaims.getStringClaim("pid") } returns validFnr + every { mockJwtTokenClaims.getStringClaim("client_id") } returns "clientId" + } + + test("harTilgang returns no access if brukertilgang returns false") { + every { brukertilgangService.tilgangTilOppslattIdent(any(), any()) } returns false + shouldThrowExactly { + controller.harTilgang(invalidFnr) + }.statusCode shouldBe HttpStatus.FORBIDDEN + } + + test("harTilgang returns access if brukertilgang returns true") { + every { brukertilgangService.tilgangTilOppslattIdent(any(), any()) } returns true + val response = controller.harTilgang(validFnr) + response.harTilgang shouldBe true + } + + test("accessToAnsatt returns no access if brukertilgang returns false") { + every { brukertilgangConsumer.hasAccessToAnsatt(any()) } returns false + val response = controller.accessToAnsatt(invalidFnr) + response.tilgang shouldBe false + } + + test("accessToAnsatt returns access if brukertilgang returns true") { + every { brukertilgangConsumer.hasAccessToAnsatt(any()) } returns true + val response = controller.accessToAnsatt(validFnr) + response.tilgang shouldBe true + } +}) diff --git a/src/test/kotlin/no/nav/syfo/kontaktinfo/KontaktinfoControllerTest.kt b/src/test/kotlin/no/nav/syfo/kontaktinfo/KontaktinfoControllerTest.kt new file mode 100644 index 0000000..0ab24be --- /dev/null +++ b/src/test/kotlin/no/nav/syfo/kontaktinfo/KontaktinfoControllerTest.kt @@ -0,0 +1,57 @@ +package no.nav.syfo.kontaktinfo + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import no.nav.security.token.support.core.context.TokenValidationContext +import no.nav.security.token.support.core.context.TokenValidationContextHolder +import no.nav.security.token.support.core.jwt.JwtTokenClaims +import no.nav.syfo.auth.tokenx.TokenXUtil.TokenXIssuer.TOKENX +import no.nav.syfo.brukertilgang.BrukertilgangService +import org.springframework.http.HttpStatus + +class KontaktinfoControllerTest : FunSpec({ + val contextHolder = mockk() + val mockTokenValidationContext = mockk() + val mockJwtTokenClaims = mockk() + val brukertilgangService = mockk() + val krrClient = mockk() + val controller = KontaktinfoController(contextHolder, brukertilgangService, krrClient, "clientId") + + val validFnr = "12345678910" + val invalidFnr = "123" + + beforeTest { + every { contextHolder.tokenValidationContext } returns mockTokenValidationContext + every { mockTokenValidationContext.getClaims(TOKENX) } returns mockJwtTokenClaims + every { mockJwtTokenClaims.getStringClaim("pid") } returns validFnr + every { mockJwtTokenClaims.getStringClaim("client_id") } returns "clientId" + } + + test("Invalid fnr returns forbidden") { + every { brukertilgangService.tilgangTilOppslattIdent(any(), any()) } returns true + val response = controller.getKontaktinfo(invalidFnr) + response.statusCode shouldBe HttpStatus.FORBIDDEN + } + + test("No access from KRR returns forbidden") { + every { brukertilgangService.tilgangTilOppslattIdent(any(), any()) } returns false + val response = controller.getKontaktinfo(validFnr) + response.statusCode shouldBe HttpStatus.FORBIDDEN + } + + test("Valid fnr and access from KRR returns kontaktinfo") { + every { brukertilgangService.tilgangTilOppslattIdent(any(), any()) } returns true + val krrResponse = DigitalKontaktinfo( + kanVarsles = true, + reservert = false, + epostadresse = "test@nav.no", + mobiltelefonnummer = "12345678" + ) + every { krrClient.kontaktinformasjon(validFnr) } returns krrResponse + val controllerResponse = controller.getKontaktinfo(validFnr) + controllerResponse.statusCode shouldBe HttpStatus.OK + controllerResponse.body shouldBe krrResponse.toKontaktinfo(validFnr) + } +}) diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index 9eda59a..ca68b73 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -4,9 +4,7 @@ spring: profiles: active: test flyway: - enabled: true - baselineOnMigrate: true - locations: 'classpath:db/migration/common' + enabled: false datasource: url: 'jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false' username: SA @@ -33,3 +31,30 @@ no.nav.security.jwt: issuer: tokenx: discoveryurl: http://localhost:${mock-oauth2-server.port}/tokenx/.well-known/openid-configuration + +azure: + app: + client: + id: "1345678" + secret: "secret" + openid: + config: + token: + endpoint: "https://login.microsoftonline.com/id/oauth2/v2.0/token" + +token.x.client.id: "tokenx-client-id" +token.x.private.jwk: "tokenx-jwk" +token.x.token.endpoint: "https://tokenx-endpoint" + +oppfolgingsplan.frontend.client.id: "localhost:team-esyfo:oppfolgingsplan-frontend" + +nais.cluster.name: 'local' +environment.name: 'dev' + +azure.openid.config.token.endpoint: "http://azure" +azure.app.client.id: 'client.id' +azure.app.client.secret: 'client.secret' +security.token.service.rest.url: "http://security-token-service.url" + +krr.url: "http://krr.url" +krr.scope: "krr.scope"