Skip to content

Commit

Permalink
feat: jwt auth
Browse files Browse the repository at this point in the history
  • Loading branch information
pooriamehregan committed Oct 26, 2023
1 parent 170570f commit 6fb7666
Show file tree
Hide file tree
Showing 14 changed files with 331 additions and 16 deletions.
5 changes: 5 additions & 0 deletions deploy/staging/env.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 10 additions & 6 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

<dependency>
<groupId>net.logstash.logback</groupId>
Expand Down Expand Up @@ -86,12 +90,6 @@
<version>${kotlin.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock</artifactId>
<version>3.0.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
Expand All @@ -108,6 +106,12 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock</artifactId>
<version>3.2.0</version>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down
2 changes: 2 additions & 0 deletions src/main/kotlin/no/digdir/service_catalog/Application.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<Service>> =
ResponseEntity(serviceRepository.findAll(), HttpStatus.OK)

fun getAllServices(@AuthenticationPrincipal jwt: Jwt, @PathVariable catalogId: String): ResponseEntity<List<Service>> =
if (endpointPermissions.hasOrgReadPermission(jwt, catalogId)) {
ResponseEntity(serviceRepository.findAll(), HttpStatus.OK)
} else {
ResponseEntity(HttpStatus.FORBIDDEN)
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String> -> aud.contains("service-catalog") }
)
)
return jwtDecoder
}

}
18 changes: 17 additions & 1 deletion src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,19 +18,32 @@ 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() {
private val mapper = jacksonObjectMapper()

@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<Service> = 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"])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
14 changes: 14 additions & 0 deletions src/test/kotlin/no/digdir/service_catalog/utils/ApiTestContext.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}
}
}
45 changes: 44 additions & 1 deletion src/test/kotlin/no/digdir/service_catalog/utils/TestUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -35,4 +36,46 @@ fun apiGet(port: Int, endpoint: String, acceptHeader: String?): Map<String,Any>

private fun isOK(response: Int?): Boolean =
if(response == null) false
else HttpStatus.resolve(response)?.is2xxSuccessful == true
else HttpStatus.resolve(response)?.is2xxSuccessful == true

fun apiAuthorizedRequest(path: String, port: Int, body: String?, token: String?, method: String): Map<String, Any> {
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 " "
)
}
}
27 changes: 27 additions & 0 deletions src/test/kotlin/no/digdir/service_catalog/utils/WireMock.kt
Original file line number Diff line number Diff line change
@@ -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()

}
Loading

0 comments on commit 6fb7666

Please sign in to comment.