From 6fb7666f87e19559da9d5f88fd40c779e04556e9 Mon Sep 17 00:00:00 2001 From: Pooria Mehregan Date: Thu, 26 Oct 2023 12:01:51 +0200 Subject: [PATCH] feat: jwt auth --- deploy/staging/env.yaml | 5 ++ pom.xml | 16 +++-- .../no/digdir/service_catalog/Application.kt | 2 + .../controller/ServiceController.kt | 17 +++-- .../security/EndpointPermissions.kt | 36 +++++++++++ .../security/SecurityConfig.kt | 39 ++++++++++++ src/main/resources/application.yaml | 18 +++++- .../integration/GetServices.kt | 20 +++++- .../service_catalog/integration/HealthTest.kt | 4 +- .../service_catalog/utils/ApiTestContext.kt | 14 +++++ .../digdir/service_catalog/utils/TestUtils.kt | 45 ++++++++++++- .../digdir/service_catalog/utils/WireMock.kt | 27 ++++++++ .../service_catalog/utils/jwt/JwtToken.kt | 41 ++++++++++++ .../service_catalog/utils/jwt/JwtUtils.kt | 63 +++++++++++++++++++ 14 files changed, 331 insertions(+), 16 deletions(-) create mode 100644 src/main/kotlin/no/digdir/service_catalog/security/EndpointPermissions.kt create mode 100644 src/main/kotlin/no/digdir/service_catalog/security/SecurityConfig.kt create mode 100644 src/test/kotlin/no/digdir/service_catalog/utils/WireMock.kt create mode 100644 src/test/kotlin/no/digdir/service_catalog/utils/jwt/JwtToken.kt create mode 100644 src/test/kotlin/no/digdir/service_catalog/utils/jwt/JwtUtils.kt diff --git a/deploy/staging/env.yaml b/deploy/staging/env.yaml index 0917a93..850b1e4 100644 --- a/deploy/staging/env.yaml +++ b/deploy/staging/env.yaml @@ -23,3 +23,8 @@ spec: key: MONGO_PASSWORD - name: MONGO_SERVICE value: staging-catalog-mongodb-headless + - name: SSO_HOST + valueFrom: + secretKeyRef: + name: commonurl-staging + key: SSO_BASE_URI diff --git a/pom.xml b/pom.xml index f203d57..160dea2 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,10 @@ org.springframework.boot spring-boot-starter-data-mongodb + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + net.logstash.logback @@ -86,12 +90,6 @@ ${kotlin.version} test - - org.wiremock - wiremock - 3.0.2 - test - org.testcontainers testcontainers @@ -108,6 +106,12 @@ org.springframework.boot spring-boot-starter-actuator + + org.wiremock + wiremock + 3.2.0 + test + diff --git a/src/main/kotlin/no/digdir/service_catalog/Application.kt b/src/main/kotlin/no/digdir/service_catalog/Application.kt index 300b655..ed9a5c5 100644 --- a/src/main/kotlin/no/digdir/service_catalog/Application.kt +++ b/src/main/kotlin/no/digdir/service_catalog/Application.kt @@ -2,7 +2,9 @@ package no.digdir.service_catalog import org.springframework.boot.SpringApplication import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +@EnableWebSecurity @SpringBootApplication open class Application diff --git a/src/main/kotlin/no/digdir/service_catalog/controller/ServiceController.kt b/src/main/kotlin/no/digdir/service_catalog/controller/ServiceController.kt index 4f7019c..d9e43d5 100644 --- a/src/main/kotlin/no/digdir/service_catalog/controller/ServiceController.kt +++ b/src/main/kotlin/no/digdir/service_catalog/controller/ServiceController.kt @@ -2,20 +2,27 @@ package no.digdir.service_catalog.controller import no.digdir.service_catalog.model.Service import no.digdir.service_catalog.mongodb.ServiceRepository +import no.digdir.service_catalog.security.EndpointPermissions import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.oauth2.jwt.Jwt import org.springframework.stereotype.Controller import org.springframework.web.bind.annotation.CrossOrigin import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping @Controller @CrossOrigin -@RequestMapping(value = ["/services"]) -class ServiceController(private val serviceRepository: ServiceRepository) { +@RequestMapping(value = ["/catalogs/{catalogId}/services"]) +class ServiceController(private val serviceRepository: ServiceRepository, private val endpointPermissions: EndpointPermissions) { @GetMapping - fun getAllServices(): ResponseEntity> = - ResponseEntity(serviceRepository.findAll(), HttpStatus.OK) - + fun getAllServices(@AuthenticationPrincipal jwt: Jwt, @PathVariable catalogId: String): ResponseEntity> = + if (endpointPermissions.hasOrgReadPermission(jwt, catalogId)) { + ResponseEntity(serviceRepository.findAll(), HttpStatus.OK) + } else { + ResponseEntity(HttpStatus.FORBIDDEN) + } } diff --git a/src/main/kotlin/no/digdir/service_catalog/security/EndpointPermissions.kt b/src/main/kotlin/no/digdir/service_catalog/security/EndpointPermissions.kt new file mode 100644 index 0000000..11b4b92 --- /dev/null +++ b/src/main/kotlin/no/digdir/service_catalog/security/EndpointPermissions.kt @@ -0,0 +1,36 @@ +package no.digdir.service_catalog.security + +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.stereotype.Service + +private const val ROLE_ROOT_ADMIN = "system:root:admin" +private fun roleOrgAdmin(orgnr: String) = "organization:$orgnr:admin" +private fun roleOrgWrite(orgnr: String) = "organization:$orgnr:write" +private fun roleOrgRead(orgnr: String) = "organization:$orgnr:read" + +@Service +class EndpointPermissions { + fun hasOrgReadPermission(jwt: Jwt, orgnr: String): Boolean { + val authorities: String? = jwt.claims["authorities"] as? String + + return when { + authorities == null -> false + authorities.contains(roleOrgAdmin(orgnr)) -> true + authorities.contains(roleOrgWrite(orgnr)) -> true + authorities.contains(roleOrgRead(orgnr)) -> true + authorities.contains(ROLE_ROOT_ADMIN) -> true + else -> false + } + } + + fun hasOrgWritePermission(jwt: Jwt, orgnr: String): Boolean { + val authorities: String? = jwt.claims["authorities"] as? String + + return when { + authorities == null -> false + authorities.contains(roleOrgAdmin(orgnr)) -> true + authorities.contains(roleOrgWrite(orgnr)) -> true + else -> false + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/no/digdir/service_catalog/security/SecurityConfig.kt b/src/main/kotlin/no/digdir/service_catalog/security/SecurityConfig.kt new file mode 100644 index 0000000..3250e62 --- /dev/null +++ b/src/main/kotlin/no/digdir/service_catalog/security/SecurityConfig.kt @@ -0,0 +1,39 @@ +package no.digdir.service_catalog.security + +import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpMethod +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator +import org.springframework.security.oauth2.jwt.* +import org.springframework.security.oauth2.jwt.JwtClaimNames.AUD +import org.springframework.security.web.SecurityFilterChain + +@Configuration +open class SecurityConfig { + @Bean + open fun filterChain(http: HttpSecurity): SecurityFilterChain { + http.authorizeHttpRequests { authorize -> + authorize.requestMatchers(HttpMethod.OPTIONS).permitAll() + .requestMatchers(HttpMethod.GET, "/actuator/health/readiness").permitAll() + .requestMatchers(HttpMethod.GET, "/actuator/health/liveness").permitAll() + .anyRequest().authenticated() } + .oauth2ResourceServer { resourceServer -> resourceServer.jwt() } + return http.build() + } + + @Bean + open fun jwtDecoder(properties: OAuth2ResourceServerProperties): JwtDecoder { + val jwtDecoder = NimbusJwtDecoder.withJwkSetUri(properties.jwt.jwkSetUri).build() + jwtDecoder.setJwtValidator( + DelegatingOAuth2TokenValidator( + JwtTimestampValidator(), + JwtIssuerValidator(properties.jwt.issuerUri), + JwtClaimValidator(AUD) { aud: List -> aud.contains("service-catalog") } + ) + ) + return jwtDecoder + } + +} \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 45aebc8..d5c5c12 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -10,4 +10,20 @@ management: health: livenessState.enabled: true readinessState.enabled: true -spring.data.mongodb.uri: mongodb://${MONGO_USERNAME}:${MONGO_PASSWORD}@${MONGO_SERVICE}/serviceCatalog?authSource=admin +spring: + data.mongodb.uri: mongodb://${MONGO_USERNAME}:${MONGO_PASSWORD}@${MONGO_SERVICE}/serviceCatalog?authSource=admin + security: + oauth2: + resourceserver: + jwt: + issuer-uri: ${SSO_HOST}/auth/realms/fdk + jwk-set-uri: ${SSO_HOST}/auth/realms/fdk/protocol/openid-connect/certs +--- +spring: + config.activate.on-profile: test + security: + oauth2: + resourceserver: + jwt: + issuer-uri: http://localhost:5050/auth/realms/fdk + jwk-set-uri: http://localhost:5050/auth/realms/fdk/protocol/openid-connect/certs diff --git a/src/test/kotlin/no/digdir/service_catalog/integration/GetServices.kt b/src/test/kotlin/no/digdir/service_catalog/integration/GetServices.kt index 29ae451..b7f516b 100644 --- a/src/test/kotlin/no/digdir/service_catalog/integration/GetServices.kt +++ b/src/test/kotlin/no/digdir/service_catalog/integration/GetServices.kt @@ -5,7 +5,10 @@ import com.fasterxml.jackson.module.kotlin.readValue import no.digdir.service_catalog.model.Service import no.digdir.service_catalog.utils.ApiTestContext import no.digdir.service_catalog.utils.SERVICES +import no.digdir.service_catalog.utils.apiAuthorizedRequest import no.digdir.service_catalog.utils.apiGet +import no.digdir.service_catalog.utils.jwt.Access +import no.digdir.service_catalog.utils.jwt.JwtToken import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test @@ -15,7 +18,9 @@ import org.springframework.http.HttpStatus import org.springframework.test.context.ContextConfiguration @TestInstance(TestInstance.Lifecycle.PER_CLASS) -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = ["spring.profiles.active=test"]) @ContextConfiguration(initializers = [ApiTestContext.Initializer::class]) @Tag("integration") class GetServices: ApiTestContext() { @@ -23,11 +28,22 @@ class GetServices: ApiTestContext() { @Test fun `able to get all services`() { - val response = apiGet(port, "/services", null) + val response = apiAuthorizedRequest("/catalogs/910244132/services", port, null, JwtToken(Access.ORG_READ).toString(), "GET") Assertions.assertEquals(HttpStatus.OK.value(), response["status"]) val result: List = mapper.readValue(response["body"] as String) Assertions.assertEquals(SERVICES, result) } + @Test + fun `unauthorized when missing token`() { + val response = apiAuthorizedRequest("/catalogs/910244132/services", port, null, null, "GET") + Assertions.assertEquals(HttpStatus.UNAUTHORIZED.value(), response["status"]) + } + + @Test + fun `forbidden when authorized for other catalog`() { + val response = apiAuthorizedRequest("/catalogs/910244132/services", port, null, JwtToken(Access.WRONG_ORG_READ).toString(), "GET") + Assertions.assertEquals(HttpStatus.FORBIDDEN.value(), response["status"]) + } } diff --git a/src/test/kotlin/no/digdir/service_catalog/integration/HealthTest.kt b/src/test/kotlin/no/digdir/service_catalog/integration/HealthTest.kt index 058ab44..38c971d 100644 --- a/src/test/kotlin/no/digdir/service_catalog/integration/HealthTest.kt +++ b/src/test/kotlin/no/digdir/service_catalog/integration/HealthTest.kt @@ -11,7 +11,9 @@ import org.springframework.http.HttpStatus import org.springframework.test.context.ContextConfiguration @TestInstance(TestInstance.Lifecycle.PER_CLASS) -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = ["spring.profiles.active=test"]) @ContextConfiguration(initializers = [ApiTestContext.Initializer::class]) @Tag("integration") class HealthTest: ApiTestContext() { diff --git a/src/test/kotlin/no/digdir/service_catalog/utils/ApiTestContext.kt b/src/test/kotlin/no/digdir/service_catalog/utils/ApiTestContext.kt index 3572f73..4278025 100644 --- a/src/test/kotlin/no/digdir/service_catalog/utils/ApiTestContext.kt +++ b/src/test/kotlin/no/digdir/service_catalog/utils/ApiTestContext.kt @@ -9,6 +9,8 @@ import org.springframework.context.ApplicationContextInitializer import org.springframework.context.ConfigurableApplicationContext import org.testcontainers.containers.GenericContainer import org.testcontainers.containers.wait.strategy.Wait +import java.net.HttpURLConnection +import java.net.URL abstract class ApiTestContext { @LocalServerPort @@ -37,7 +39,19 @@ abstract class ApiTestContext { .waitingFor(Wait.forListeningPort()) init { + startMockServer() mongoContainer.start() + + try { + val con = URL("http://localhost:5050/ping").openConnection() as HttpURLConnection + con.connect() + if (con.responseCode != 200) { + stopMockServer() + } + } catch (e: Exception) { + e.printStackTrace() + stopMockServer() + } } } } diff --git a/src/test/kotlin/no/digdir/service_catalog/utils/TestUtils.kt b/src/test/kotlin/no/digdir/service_catalog/utils/TestUtils.kt index fd70dd9..8278ce3 100644 --- a/src/test/kotlin/no/digdir/service_catalog/utils/TestUtils.kt +++ b/src/test/kotlin/no/digdir/service_catalog/utils/TestUtils.kt @@ -2,6 +2,7 @@ package no.digdir.service_catalog.utils import org.springframework.http.HttpStatus import java.io.BufferedReader +import java.io.OutputStreamWriter import java.net.HttpURLConnection import java.net.URL @@ -35,4 +36,46 @@ fun apiGet(port: Int, endpoint: String, acceptHeader: String?): Map private fun isOK(response: Int?): Boolean = if(response == null) false - else HttpStatus.resolve(response)?.is2xxSuccessful == true \ No newline at end of file + else HttpStatus.resolve(response)?.is2xxSuccessful == true + +fun apiAuthorizedRequest(path: String, port: Int, body: String?, token: String?, method: String): Map { + val connection = URL("http://localhost:$port$path").openConnection() as HttpURLConnection + connection.requestMethod = method + connection.setRequestProperty("Content-type", "application/json") + connection.setRequestProperty("Accept", "application/json") + + if(!token.isNullOrEmpty()) { + connection.setRequestProperty("Authorization", "Bearer $token") + } + + return try { + connection.doOutput = true + connection.connect() + + if(body != null) { + val writer = OutputStreamWriter(connection.outputStream) + writer.write(body) + writer.close() + } + + if(isOK(connection.responseCode)){ + mapOf( + "body" to connection.inputStream.bufferedReader().use(BufferedReader :: readText), + "header" to connection.headerFields.toString(), + "status" to connection.responseCode + ) + } else { + mapOf( + "status" to connection.responseCode, + "header" to " ", + "body" to " " + ) + } + } catch (e: Exception) { + mapOf( + "status" to e.toString(), + "header" to " ", + "body" to " " + ) + } +} \ No newline at end of file diff --git a/src/test/kotlin/no/digdir/service_catalog/utils/WireMock.kt b/src/test/kotlin/no/digdir/service_catalog/utils/WireMock.kt new file mode 100644 index 0000000..8c11f0b --- /dev/null +++ b/src/test/kotlin/no/digdir/service_catalog/utils/WireMock.kt @@ -0,0 +1,27 @@ +package no.digdir.service_catalog.utils + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock.* +import no.digdir.service_catalog.utils.jwt.JwkStore + +private val mockserver = WireMockServer(5050) + +fun startMockServer() { + if(!mockserver.isRunning) { + mockserver.stubFor(get(urlEqualTo("/ping")) + .willReturn(aResponse() + .withStatus(200)) + ) + + mockserver.stubFor(get(urlEqualTo("/auth/realms/fdk/protocol/openid-connect/certs")) + .willReturn(okJson(JwkStore.get()))) + + mockserver.start() + } +} + +fun stopMockServer() { + + if (mockserver.isRunning) mockserver.stop() + +} \ No newline at end of file diff --git a/src/test/kotlin/no/digdir/service_catalog/utils/jwt/JwtToken.kt b/src/test/kotlin/no/digdir/service_catalog/utils/jwt/JwtToken.kt new file mode 100644 index 0000000..29b9e69 --- /dev/null +++ b/src/test/kotlin/no/digdir/service_catalog/utils/jwt/JwtToken.kt @@ -0,0 +1,41 @@ +package no.digdir.service_catalog.utils.jwt + +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.SignedJWT +import java.util.* + +class JwtToken (private val access: Access) { + private val exp = Date().time + 120 * 1000 + private val aud = listOf("service-catalog") + + private fun buildToken() : String{ + val claimset = JWTClaimsSet.Builder() + .audience(aud) + .expirationTime(Date(exp)) + .claim("user_name","1924782563") + .claim("name", "TEST USER") + .claim("given_name", "TEST") + .claim("family_name", "USER") + .claim("iss", "http://localhost:5050/auth/realms/fdk") + .claim("authorities", access.authorities) + .build() + + val signed = SignedJWT(JwkStore.jwtHeader(), claimset) + signed.sign(JwkStore.signer()) + + return signed.serialize() + } + + override fun toString(): String { + return buildToken() + } + +} + +enum class Access(val authorities: String) { + ORG_READ("organization:910244132:read"), + ORG_WRITE("organization:910244132:write"), + ORG_ADMIN("organization:910244132:admin"), + ROOT("system:root:admin"), + WRONG_ORG_READ("organization:123456789:read"), +} \ No newline at end of file diff --git a/src/test/kotlin/no/digdir/service_catalog/utils/jwt/JwtUtils.kt b/src/test/kotlin/no/digdir/service_catalog/utils/jwt/JwtUtils.kt new file mode 100644 index 0000000..726a0eb --- /dev/null +++ b/src/test/kotlin/no/digdir/service_catalog/utils/jwt/JwtUtils.kt @@ -0,0 +1,63 @@ +package no.digdir.service_catalog.utils.jwt + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.crypto.RSASSASigner +import com.nimbusds.jose.jwk.KeyUse +import com.nimbusds.jose.jwk.RSAKey +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator +import java.util.* + +object JwkStore{ + private val jwk = createJwk() + + private fun createJwk(): RSAKey = + RSAKeyGenerator(2048) + .algorithm(JWSAlgorithm.RS256) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .generate() + + fun get(): String { + val token : JwkToken = jacksonObjectMapper() + .readValue(jwk.toJSONString()) + return token.toString() + } + + fun jwtHeader() = + JWSHeader.Builder(JWSAlgorithm.RS256) + .keyID(jwk.keyID) + .build() + + fun signer() = + RSASSASigner(jwk) + +} + +@JsonIgnoreProperties(ignoreUnknown = true) +class JwkToken( + private val kid : String, + private val kty :String, + private val use : String, + private val n : String, + private val e : String +){ + + override fun toString(): String = + """{ + "keys": [ + { + "kid": "$kid", + "kty": "$kty", + "alg": "RS256", + "use": "$use", + "n": "$n", + "e": "$e" + } + ] + }""".trimIndent() + +} \ No newline at end of file