Skip to content

Commit

Permalink
CID-2732: improve implementation, refactor, optimise dependencies and…
Browse files Browse the repository at this point in the history
… fix Snyk vulnerabilities
  • Loading branch information
mohamedlajmileanix committed Jun 27, 2024
1 parent 6a786c0 commit 53894be
Show file tree
Hide file tree
Showing 9 changed files with 282 additions and 43 deletions.
22 changes: 15 additions & 7 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,24 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter")
implementation("org.springframework.cloud:spring-cloud-starter-openfeign")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("io.jsonwebtoken:jjwt-api:0.11.2")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.bouncycastle:bcprov-jdk18on:1.78")
implementation("org.bouncycastle:bcpkix-jdk18on:1.78")
implementation("com.fasterxml.jackson.core:jackson-annotations:2.17.1")

// Dependencies for generating JWT token
implementation("io.jsonwebtoken:jjwt-impl:0.11.2")
implementation("io.jsonwebtoken:jjwt-jackson:0.11.2")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.junit.platform:junit-platform-launcher")
testImplementation("io.mockk:mockk:1.12.0")
}

configurations.all {
resolutionStrategy {
eachDependency {
when (requested.module.toString()) {
"org.bouncycastle:bcprov-jdk18on" -> useVersion("1.78")
}
}
}
}

detekt {
Expand Down Expand Up @@ -74,4 +82,4 @@ tasks.jacocoTestReport {
xml.required.set(true)
xml.outputLocation.set(File("${projectDir}/build/jacocoXml/jacocoTestReport.xml"))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@ import com.fasterxml.jackson.annotation.JsonProperty

@JsonIgnoreProperties(ignoreUnknown = true)
data class GithubAppResponse(
@JsonProperty("name") val name: String
@JsonProperty("name") val name: String,
@JsonProperty("permissions") val permissions: Map<String, String>,
@JsonProperty("events") val events: List<String>
)
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ package net.leanix.githubagent.exceptions
class GithubEnterpriseConfigurationMissingException(properties: String) : RuntimeException(
"Github Enterprise properties '$properties' are not set"
)
class AuthenticationFailedException(message: String) : RuntimeException(message)
class ConnectingToGithubEnterpriseFailedException(message: String) : RuntimeException(message)
class GithubAppInsufficientPermissionsException(message: String) : RuntimeException(message)
class FailedToCreateJWTException(message: String) : RuntimeException(message)
class UnableToConnectToGithubEnterpriseException(message: String) : RuntimeException(message)
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package net.leanix.githubagent.services

import net.leanix.githubagent.client.GithubClient
import net.leanix.githubagent.dto.GithubAppResponse
import net.leanix.githubagent.exceptions.GithubAppInsufficientPermissionsException
import net.leanix.githubagent.exceptions.UnableToConnectToGithubEnterpriseException
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service

@Service
class GitHubEnterpriseService(private val githubClient: GithubClient) {

companion object {
val expectedPermissions = listOf("administration", "contents", "metadata")
val expectedEvents = listOf("label", "public", "repository")
}
private val logger = LoggerFactory.getLogger(GitHubEnterpriseService::class.java)

fun verifyJwt(jwt: String) {
runCatching {
val githubApp = githubClient.getApp("Bearer $jwt")
validateGithubAppResponse(githubApp)
logger.info("Authenticated as GitHub App: '${githubApp.name}'")
}.onFailure {
when (it) {
is GithubAppInsufficientPermissionsException -> throw it
else -> throw UnableToConnectToGithubEnterpriseException("Failed to verify JWT token")
}
}
}

fun validateGithubAppResponse(response: GithubAppResponse) {
val missingPermissions = expectedPermissions.filterNot { response.permissions.containsKey(it) }
val missingEvents = expectedEvents.filterNot { response.events.contains(it) }

if (missingPermissions.isNotEmpty() || missingEvents.isNotEmpty()) {
var message = "GitHub App is missing the following "
if (missingPermissions.isNotEmpty()) {
message = message.plus("permissions: $missingPermissions")
}
if (missingEvents.isNotEmpty()) {
if (missingPermissions.isNotEmpty()) {
message = message.plus(", and the following")
}
message = message.plus("events: $missingEvents")
}
throw GithubAppInsufficientPermissionsException(message)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@ package net.leanix.githubagent.services

import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import net.leanix.githubagent.client.GithubClient
import net.leanix.githubagent.config.GithubEnterpriseProperties
import net.leanix.githubagent.exceptions.AuthenticationFailedException
import net.leanix.githubagent.exceptions.ConnectingToGithubEnterpriseFailedException
import net.leanix.githubagent.exceptions.FailedToCreateJWTException
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.slf4j.LoggerFactory
import org.springframework.core.io.ResourceLoader
Expand All @@ -17,65 +15,70 @@ import java.nio.file.Files
import java.security.KeyFactory
import java.security.PrivateKey
import java.security.Security
import java.security.spec.InvalidKeySpecException
import java.security.spec.PKCS8EncodedKeySpec
import java.util.*

@Service
class GithubAuthenticationService(
private val cachingService: CachingService,
private val githubClient: GithubClient,
private val githubEnterpriseProperties: GithubEnterpriseProperties,
private val resourceLoader: ResourceLoader
private val resourceLoader: ResourceLoader,
private val gitHubEnterpriseService: GitHubEnterpriseService
) {

companion object {
private const val JWT_EXPIRATION_DURATION = 600000L
private const val pemPrefix = "-----BEGIN RSA PRIVATE KEY-----"
private const val pemSuffix = "-----END RSA PRIVATE KEY-----"
private val logger = LoggerFactory.getLogger(GithubAuthenticationService::class.java)
}

fun generateJwtToken() {
runCatching {
logger.info("Generating JWT token")
Security.addProvider(BouncyCastleProvider())
val rsaPrivateKey: String = readPrivateKey(loadPemFile())
val rsaPrivateKey: String = readPrivateKey()
val keySpec = PKCS8EncodedKeySpec(Base64.getDecoder().decode(rsaPrivateKey))
val privateKey = KeyFactory.getInstance("RSA").generatePrivate(keySpec)
createJwtToken(privateKey)?.also {
cachingService.set("jwtToken", it)
verifyJwt(it)
}
val jwt = createJwtToken(privateKey)
cachingService.set("jwtToken", jwt.getOrThrow())
gitHubEnterpriseService.verifyJwt(jwt.getOrThrow())
}.onFailure {
logger.error("Failed to generate/validate JWT token", it)
throw AuthenticationFailedException("Failed to generate a valid JWT token")
if (it is InvalidKeySpecException) {
throw IllegalArgumentException("The provided private key is not in a valid PKCS8 format.", it)
} else {
throw it
}
}
}

private fun createJwtToken(privateKey: PrivateKey): String? {
return Jwts.builder()
.setIssuedAt(Date())
.setExpiration(Date(System.currentTimeMillis() + JWT_EXPIRATION_DURATION))
.setIssuer(cachingService.get("githubAppId"))
.signWith(privateKey, SignatureAlgorithm.RS256)
.compact()
private fun createJwtToken(privateKey: PrivateKey): Result<String> {
return runCatching {
Jwts.builder()
.setIssuedAt(Date())
.setExpiration(Date(System.currentTimeMillis() + JWT_EXPIRATION_DURATION))
.setIssuer(cachingService.get("githubAppId"))
.signWith(privateKey, SignatureAlgorithm.RS256)
.compact()
}.onFailure {
throw FailedToCreateJWTException("Failed to generate a valid JWT token")
}
}

@Throws(IOException::class)
private fun readPrivateKey(file: File): String {
return String(Files.readAllBytes(file.toPath()), Charset.defaultCharset())
.replace("-----BEGIN RSA PRIVATE KEY-----", "")
.replace(System.lineSeparator().toRegex(), "")
.replace("-----END RSA PRIVATE KEY-----", "")
}
private fun readPrivateKey(): String {
val pemFile = File(resourceLoader.getResource("file:${githubEnterpriseProperties.pemFile}").uri)
val fileContent = String(Files.readAllBytes(pemFile.toPath()), Charset.defaultCharset()).trim()

private fun verifyJwt(jwt: String) {
runCatching {
val githubApp = githubClient.getApp("Bearer $jwt")
logger.info("Authenticated as GitHub App: ${githubApp.name}")
}.onFailure {
throw ConnectingToGithubEnterpriseFailedException("Failed to verify JWT token")
require(fileContent.startsWith(pemPrefix) && fileContent.endsWith(pemSuffix)) {
"The provided file is not a valid PEM file."
}
}

private fun loadPemFile() =
File(resourceLoader.getResource("file:${githubEnterpriseProperties.pemFile}").uri)
return fileContent
.replace(pemPrefix, "")
.replace(System.lineSeparator().toRegex(), "")
.replace(pemSuffix, "")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import io.mockk.every
import io.mockk.mockk
import net.leanix.githubagent.client.GithubClient
import net.leanix.githubagent.dto.GithubAppResponse
import net.leanix.githubagent.exceptions.GithubAppInsufficientPermissionsException
import net.leanix.githubagent.exceptions.UnableToConnectToGithubEnterpriseException
import net.leanix.githubagent.services.GitHubEnterpriseService
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertDoesNotThrow

class GitHubEnterpriseServiceTest {

private val githubClient = mockk<GithubClient>()
private val service = GitHubEnterpriseService(githubClient)

@Test
fun `verifyJwt with valid jwt should not throw exception`() {
val jwt = "validJwt"
val githubApp = GithubAppResponse(
name = "validApp",
permissions = mapOf("administration" to "read", "contents" to "read", "metadata" to "read"),
events = listOf("label", "public", "repository")
)
every { githubClient.getApp(any()) } returns githubApp

assertDoesNotThrow { service.verifyJwt(jwt) }
}

@Test
fun `verifyJwt with invalid jwt should throw exception`() {
val jwt = "invalidJwt"
every { githubClient.getApp(any()) } throws Exception()

assertThrows(UnableToConnectToGithubEnterpriseException::class.java) { service.verifyJwt(jwt) }
}

@Test
fun `validateGithubAppResponse with correct permissions should not throw exception`() {
val response = GithubAppResponse(
name = "validApp",
permissions = mapOf("administration" to "read", "contents" to "read", "metadata" to "read"),
events = listOf("label", "public", "repository")
)

assertDoesNotThrow { service.validateGithubAppResponse(response) }
}

@Test
fun `validateGithubAppResponse with missing permissions should throw exception`() {
val response = GithubAppResponse(
name = "validApp",
permissions = mapOf("administration" to "read", "contents" to "read"),
events = listOf("label", "public", "repository")
)

assertThrows(
GithubAppInsufficientPermissionsException::class.java
) { service.validateGithubAppResponse(response) }
}

@Test
fun `validateGithubAppResponse with missing events should throw exception`() {
val response = GithubAppResponse(
name = "validApp",
permissions = mapOf("administration" to "read", "contents" to "read", "metadata" to "read"),
events = listOf("label", "public")
)

assertThrows(
GithubAppInsufficientPermissionsException::class.java
) { service.validateGithubAppResponse(response) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import io.mockk.every
import io.mockk.mockk
import net.leanix.githubagent.config.GithubEnterpriseProperties
import net.leanix.githubagent.services.CachingService
import net.leanix.githubagent.services.GitHubEnterpriseService
import net.leanix.githubagent.services.GithubAuthenticationService
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertDoesNotThrow
import org.springframework.core.io.ClassPathResource
import org.springframework.core.io.ResourceLoader

class GithubAuthenticationServiceTest {

private val cachingService = mockk<CachingService>()
private val githubEnterpriseProperties = mockk<GithubEnterpriseProperties>()
private val resourceLoader = mockk<ResourceLoader>()
private val gitHubEnterpriseService = mockk<GitHubEnterpriseService>()
private val githubAuthenticationService = GithubAuthenticationService(
cachingService,
githubEnterpriseProperties,
resourceLoader,
gitHubEnterpriseService
)

@Test
fun `generateJwtToken with valid data should not throw exception`() {
every { cachingService.get(any()) } returns "dummy-value"
every { cachingService.set(any(), any()) } returns Unit
every { githubEnterpriseProperties.pemFile } returns "valid-private-key.pem"
every { resourceLoader.getResource(any()) } returns ClassPathResource("valid-private-key.pem")
every { gitHubEnterpriseService.verifyJwt(any()) } returns Unit

assertDoesNotThrow { githubAuthenticationService.generateJwtToken() }
assertNotNull(cachingService.get("jwtToken"))
}

@Test
fun `generateJwtToken with invalid data should throw exception`() {
every { cachingService.get(any()) } returns "dummy-value"
every { githubEnterpriseProperties.pemFile } returns "invalid-private-key.pem"
every { resourceLoader.getResource(any()) } returns ClassPathResource("invalid-private-key.pem")

assertThrows(IllegalArgumentException::class.java) { githubAuthenticationService.generateJwtToken() }
}
}
27 changes: 27 additions & 0 deletions src/test/resources/invalid-private-key.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@

MIIEpAIBAAKCAQEAwqjwjl1IJ2Mo4TMtNmAoZl+lnRP88D2ocMrj1QgKYzHTnsAW
UudOX909Mxbjj9ZKpuDpggL3/X+h/pCaD7yhS1OgYo1pl8TmWwmDq8ok0VJYlfxi
3oH76kexyQZ+SYT7YqZ7Xy67Q/kcjDyVK708vnKdhEaGFCVdIxbUfzsIynq6xbKx
PETEMlW1dBHQSrwIYgGTAwKTrvqmpNxZw7yIFA6qASddQmpbm0ycoMXYVrz+Nac6
RrR93YVY3Jc+0c13bSenCqlRtMEHLfmGTuKboKiQWRgS16CEgfg2b29310OtLC6T
PSk9Dtv1knrVjpnWVaMq3w28ky3I1aeoKZCkPQIDAQABAoIBAQCWEJ0ac0k7rBMI
wWY6hBjBCz1mgdE995qSEadgRImVfQUSXi0Xjl/6QVl7uEqISYBVdBAv/U/m6m0d
DabnONjzdC2xrCjaKp4XUpdiaTzG7f+C6QXjWTu2mbyyJ8JVtSIDJCr57tHJDhN2
/QFWrdVVUJCkN6YHg+JwOZpp1z3osSldnRCYUJ7NcPfNYCj/n0Gq5fQ3MUmk17ch
O5+XOxa8GBFj9hCqqFB97qnYSkRDTv0YoLdlIdnnVQeKYYMFCdKa++vgHX/7Pu8B
KFr34Fm1BFjkoIYjOtYbeUf2lWG+dzwEwLUu5DUcYS+YyUBCogUDLtROHScPSSFU
5hHin6S1AoGBAPHde46hvPmBNR6DGkds1twavbvEynlKiKdpgWn+ycBaLOPXO/hb
xdjAohZNIYwE72ggYWnMhHy1OnhytUMopMsT/xbDu+v5iwF+/9x9C7gdBj8drEzx
4E86O+lQ7ROh1PoAPwTqFUY0rEmsJRvfTY8oUp9LuiPWuO5Mc1tGIjJXAoGBAM4J
OYVKqc5Rzt4pSWzy3wzxekE1XVN7SRdcdYyjqOiYRLmc1jSx5nuTotluSd/trtZw
5Sf65e9YkO2zx5Ou4/TWdnGurWP8BgBAT2bDCDKjetiJTHSB68Hcz0zfH99C9h+E
8vn8Lpn57fFG+TOiADBPAYNEEkBxBJyGn4d+r8mLAoGBAISRIhT2f46+DDByKWg2
trmjipUtctDyUl54TK+dMFXW1z32je891f5M70qL8jQ9zD7laJ9FsuRrrOWx8boi
v9hzWGDQ3eKkP1WNl43xmAfNGMxlZjgyZwDl6UqjyZ32GLcChYgbCZgWbMxgp2JU
jb1Gm6qmJhtYqLosexnvIfU3AoGAR5znNFAmQ0MmDwv0rHyiUIJiRuYAgTK5zffi
F7cOz4GVaZp8zaYEAXHoSYDPBpk7iueEjufjIdT70tMJDGjebMxaMNtRAw6nG1E/
B+3EHK271iWqwFgkFKbmGsb28gf5Oi1gsskXfYdkT9emaG7nd+MOGI0BdwqRWsJk
EplTCk8CgYATcdreHFdXBCbRLszoiPPpvNTi0lBUdor+PzVrewAdByOY9dajBbap
2Fbuu2fkhBPEP8BL+3fJmbXsVVxOf9Nzy/IusekfuC5ZGnc41aCtaC6hplaXs131
UvAdbhohImJi8D/p6uXPvrwrApBvoDpEu3Sq36VMCPeSv3YmTngLXw==
-----END RSA PRIVATE KEY-----
27 changes: 27 additions & 0 deletions src/test/resources/valid-private-key.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEAwqjwjl1IJ2Mo4TMtNmAoZl+lnRP88D2ocMrj1QgKYzHTnsAW
UudOX909Mxbjj9ZKpuDpggL3/X+h/pCaD7yhS1OgYo1pl8TmWwmDq8ok0VJYlfxi
3oH76kexyQZ+SYT7YqZ7Xy67Q/kcjDyVK708vnKdhEaGFCVdIxbUfzsIynq6xbKx
PETEMlW1dBHQSrwIYgGTAwKTrvqmpNxZw7yIFA6qASddQmpbm0ycoMXYVrz+Nac6
RrR93YVY3Jc+0c13bSenCqlRtMEHLfmGTuKboKiQWRgS16CEgfg2b29310OtLC6T
PSk9Dtv1knrVjpnWVaMq3w28ky3I1aeoKZCkPQIDAQABAoIBAQCWEJ0ac0k7rBMI
wWY6hBjBCz1mgdE995qSEadgRImVfQUSXi0Xjl/6QVl7uEqISYBVdBAv/U/m6m0d
DabnONjzdC2xrCjaKp4XUpdiaTzG7f+C6QXjWTu2mbyyJ8JVtSIDJCr57tHJDhN2
/QFWrdVVUJCkN6YHg+JwOZpp1z3osSldnRCYUJ7NcPfNYCj/n0Gq5fQ3MUmk17ch
O5+XOxa8GBFj9hCqqFB97qnYSkRDTv0YoLdlIdnnVQeKYYMFCdKa++vgHX/7Pu8B
KFr34Fm1BFjkoIYjOtYbeUf2lWG+dzwEwLUu5DUcYS+YyUBCogUDLtROHScPSSFU
5hHin6S1AoGBAPHde46hvPmBNR6DGkds1twavbvEynlKiKdpgWn+ycBaLOPXO/hb
xdjAohZNIYwE72ggYWnMhHy1OnhytUMopMsT/xbDu+v5iwF+/9x9C7gdBj8drEzx
4E86O+lQ7ROh1PoAPwTqFUY0rEmsJRvfTY8oUp9LuiPWuO5Mc1tGIjJXAoGBAM4J
OYVKqc5Rzt4pSWzy3wzxekE1XVN7SRdcdYyjqOiYRLmc1jSx5nuTotluSd/trtZw
5Sf65e9YkO2zx5Ou4/TWdnGurWP8BgBAT2bDCDKjetiJTHSB68Hcz0zfH99C9h+E
8vn8Lpn57fFG+TOiADBPAYNEEkBxBJyGn4d+r8mLAoGBAISRIhT2f46+DDByKWg2
trmjipUtctDyUl54TK+dMFXW1z32je891f5M70qL8jQ9zD7laJ9FsuRrrOWx8boi
v9hzWGDQ3eKkP1WNl43xmAfNGMxlZjgyZwDl6UqjyZ32GLcChYgbCZgWbMxgp2JU
jb1Gm6qmJhtYqLosexnvIfU3AoGAR5znNFAmQ0MmDwv0rHyiUIJiRuYAgTK5zffi
F7cOz4GVaZp8zaYEAXHoSYDPBpk7iueEjufjIdT70tMJDGjebMxaMNtRAw6nG1E/
B+3EHK271iWqwFgkFKbmGsb28gf5Oi1gsskXfYdkT9emaG7nd+MOGI0BdwqRWsJk
EplTCk8CgYATcdreHFdXBCbRLszoiPPpvNTi0lBUdor+PzVrewAdByOY9dajBbap
2Fbuu2fkhBPEP8BL+3fJmbXsVVxOf9Nzy/IusekfuC5ZGnc41aCtaC6hplaXs131
UvAdbhohImJi8D/p6uXPvrwrApBvoDpEu3Sq36VMCPeSv3YmTngLXw==
-----END RSA PRIVATE KEY-----

0 comments on commit 53894be

Please sign in to comment.