From 70cd1ed9e47b03c0fe379ecc1e35c4d975ffedb6 Mon Sep 17 00:00:00 2001 From: mohamedlajmileanix Date: Tue, 25 Jun 2024 17:54:35 +0200 Subject: [PATCH] CID-2729: authenticate to GHE --- build.gradle.kts | 16 ++++ .../githubagent/GithubAgentApplication.kt | 4 + .../leanix/githubagent/client/GithubClient.kt | 15 ++++ .../config/AgentSetupValidation.kt | 30 +++++++ .../config/GithubEnterpriseProperties.kt | 10 +++ .../githubagent/dto/GithubAppResponse.kt | 9 ++ .../githubagent/exceptions/Exceptions.kt | 7 ++ .../githubagent/runners/PostStartupRunner.kt | 14 ++++ .../githubagent/services/CachingService.kt | 26 ++++++ .../services/GithubAuthenticationService.kt | 83 +++++++++++++++++++ src/main/resources/application.yaml | 4 + src/test/resources/application.yaml | 4 + 12 files changed, 222 insertions(+) create mode 100644 src/main/kotlin/net/leanix/githubagent/client/GithubClient.kt create mode 100644 src/main/kotlin/net/leanix/githubagent/config/AgentSetupValidation.kt create mode 100644 src/main/kotlin/net/leanix/githubagent/config/GithubEnterpriseProperties.kt create mode 100644 src/main/kotlin/net/leanix/githubagent/dto/GithubAppResponse.kt create mode 100644 src/main/kotlin/net/leanix/githubagent/exceptions/Exceptions.kt create mode 100644 src/main/kotlin/net/leanix/githubagent/runners/PostStartupRunner.kt create mode 100644 src/main/kotlin/net/leanix/githubagent/services/CachingService.kt create mode 100644 src/main/kotlin/net/leanix/githubagent/services/GithubAuthenticationService.kt create mode 100644 src/main/resources/application.yaml create mode 100644 src/test/resources/application.yaml diff --git a/build.gradle.kts b/build.gradle.kts index 3d9e1d9..015845e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,9 +20,25 @@ repositories { mavenCentral() } +extra["springCloudVersion"] = "2023.0.1" + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}") + } +} + 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") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.2") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.2") + testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/src/main/kotlin/net/leanix/githubagent/GithubAgentApplication.kt b/src/main/kotlin/net/leanix/githubagent/GithubAgentApplication.kt index 58a5e51..54ca4dc 100644 --- a/src/main/kotlin/net/leanix/githubagent/GithubAgentApplication.kt +++ b/src/main/kotlin/net/leanix/githubagent/GithubAgentApplication.kt @@ -1,9 +1,13 @@ package net.leanix.githubagent import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.runApplication +import org.springframework.cloud.openfeign.EnableFeignClients @SpringBootApplication +@EnableFeignClients(value = ["net.leanix.githubagent.client"]) +@EnableConfigurationProperties(value = [net.leanix.githubagent.config.GithubEnterpriseProperties::class]) class GithubAgentApplication fun main() { diff --git a/src/main/kotlin/net/leanix/githubagent/client/GithubClient.kt b/src/main/kotlin/net/leanix/githubagent/client/GithubClient.kt new file mode 100644 index 0000000..e32aa3a --- /dev/null +++ b/src/main/kotlin/net/leanix/githubagent/client/GithubClient.kt @@ -0,0 +1,15 @@ +package net.leanix.githubagent.client + +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestHeader + +@FeignClient(name = "githubClient", url = "\${github-enterprise.baseUrl}") +interface GithubClient { + + @GetMapping("/api/v3/app") + fun getApp( + @RequestHeader("Authorization") jwt: String, + @RequestHeader("Accept") accept: String = "application/vnd.github.v3+json" + ): String +} diff --git a/src/main/kotlin/net/leanix/githubagent/config/AgentSetupValidation.kt b/src/main/kotlin/net/leanix/githubagent/config/AgentSetupValidation.kt new file mode 100644 index 0000000..f10e5c9 --- /dev/null +++ b/src/main/kotlin/net/leanix/githubagent/config/AgentSetupValidation.kt @@ -0,0 +1,30 @@ +package net.leanix.githubagent.config + +import jakarta.annotation.PostConstruct +import net.leanix.githubagent.exceptions.GithubEnterpriseConfigurationMissingException +import org.springframework.stereotype.Component + +@Component +class AgentSetupValidation( + private val githubEnterpriseProperties: GithubEnterpriseProperties +) { + + @PostConstruct + fun validateConfiguration() { + val missingProperties = mutableListOf() + + if (githubEnterpriseProperties.baseUrl.isBlank()) { + missingProperties.add("GITHUB_ENTERPRISE_BASE_URL") + } + if (githubEnterpriseProperties.githubAppId.isBlank()) { + missingProperties.add("GITHUB_ENTERPRISE_GITHUB_APP_ID") + } + if (githubEnterpriseProperties.pemFile.isBlank()) { + missingProperties.add("GITHUB_ENTERPRISE_PEM_FILE") + } + + if (missingProperties.isNotEmpty()) { + throw GithubEnterpriseConfigurationMissingException(missingProperties.joinToString(", ")) + } + } +} diff --git a/src/main/kotlin/net/leanix/githubagent/config/GithubEnterpriseProperties.kt b/src/main/kotlin/net/leanix/githubagent/config/GithubEnterpriseProperties.kt new file mode 100644 index 0000000..6dae6e3 --- /dev/null +++ b/src/main/kotlin/net/leanix/githubagent/config/GithubEnterpriseProperties.kt @@ -0,0 +1,10 @@ +package net.leanix.githubagent.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "github-enterprise") +data class GithubEnterpriseProperties( + val baseUrl: String, + val githubAppId: String, + val pemFile: String, +) diff --git a/src/main/kotlin/net/leanix/githubagent/dto/GithubAppResponse.kt b/src/main/kotlin/net/leanix/githubagent/dto/GithubAppResponse.kt new file mode 100644 index 0000000..b75588f --- /dev/null +++ b/src/main/kotlin/net/leanix/githubagent/dto/GithubAppResponse.kt @@ -0,0 +1,9 @@ +package net.leanix.githubagent.dto + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty + +@JsonIgnoreProperties(ignoreUnknown = true) +data class GithubAppResponse( + @JsonProperty("name") val name: String +) diff --git a/src/main/kotlin/net/leanix/githubagent/exceptions/Exceptions.kt b/src/main/kotlin/net/leanix/githubagent/exceptions/Exceptions.kt new file mode 100644 index 0000000..0313795 --- /dev/null +++ b/src/main/kotlin/net/leanix/githubagent/exceptions/Exceptions.kt @@ -0,0 +1,7 @@ +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) diff --git a/src/main/kotlin/net/leanix/githubagent/runners/PostStartupRunner.kt b/src/main/kotlin/net/leanix/githubagent/runners/PostStartupRunner.kt new file mode 100644 index 0000000..d6c3a89 --- /dev/null +++ b/src/main/kotlin/net/leanix/githubagent/runners/PostStartupRunner.kt @@ -0,0 +1,14 @@ +package net.leanix.githubagent.runners + +import net.leanix.githubagent.services.GithubAuthenticationService +import org.springframework.boot.ApplicationArguments +import org.springframework.boot.ApplicationRunner +import org.springframework.stereotype.Component + +@Component +class PostStartupRunner(private val githubAuthenticationService: GithubAuthenticationService) : ApplicationRunner { + + override fun run(args: ApplicationArguments?) { + githubAuthenticationService.generateJwtToken() + } +} diff --git a/src/main/kotlin/net/leanix/githubagent/services/CachingService.kt b/src/main/kotlin/net/leanix/githubagent/services/CachingService.kt new file mode 100644 index 0000000..24f9c3b --- /dev/null +++ b/src/main/kotlin/net/leanix/githubagent/services/CachingService.kt @@ -0,0 +1,26 @@ +package net.leanix.githubagent.services + +import jakarta.annotation.PostConstruct +import net.leanix.githubagent.config.GithubEnterpriseProperties +import org.springframework.stereotype.Service + +@Service +class CachingService( + private val githubEnterpriseProperties: GithubEnterpriseProperties +) { + private val cache = HashMap() + + @PostConstruct + private fun init() { + cache["baseUrl"] = githubEnterpriseProperties.baseUrl + cache["githubAppId"] = githubEnterpriseProperties.githubAppId + } + + fun set(key: String, value: String) { + cache[key] = value + } + + fun get(key: String): String? { + return cache[key] + } +} diff --git a/src/main/kotlin/net/leanix/githubagent/services/GithubAuthenticationService.kt b/src/main/kotlin/net/leanix/githubagent/services/GithubAuthenticationService.kt new file mode 100644 index 0000000..3db7ee8 --- /dev/null +++ b/src/main/kotlin/net/leanix/githubagent/services/GithubAuthenticationService.kt @@ -0,0 +1,83 @@ +package net.leanix.githubagent.services + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.SignatureAlgorithm +import net.leanix.githubagent.client.GithubClient +import net.leanix.githubagent.config.GithubEnterpriseProperties +import net.leanix.githubagent.dto.GithubAppResponse +import net.leanix.githubagent.exceptions.AuthenticationFailedException +import net.leanix.githubagent.exceptions.ConnectingToGithubEnterpriseFailedException +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.slf4j.LoggerFactory +import org.springframework.core.io.ResourceLoader +import org.springframework.stereotype.Service +import java.io.File +import java.io.IOException +import java.nio.charset.Charset +import java.nio.file.Files +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.Security +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 +) { + + companion object { + private const val JWT_EXPIRATION_DURATION = 600000L + 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 keySpec = PKCS8EncodedKeySpec(Base64.getDecoder().decode(rsaPrivateKey)) + val privateKey = KeyFactory.getInstance("RSA").generatePrivate(keySpec) + createJwtToken(privateKey)?.also { + cachingService.set("jwtToken", it) + verifyJwt(it) + } ?: throw AuthenticationFailedException("Failed to generate a valid JWT token") + } + } + + 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() + } + + @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 verifyJwt(jwt: String) { + runCatching { + val githubApp = jacksonObjectMapper().readValue( + githubClient.getApp("Bearer $jwt"), + GithubAppResponse::class.java + ) + logger.info("Authenticated as GitHub App: ${githubApp.name}") + }.onFailure { + throw ConnectingToGithubEnterpriseFailedException("Failed to verify JWT token") + } + } + + private fun loadPemFile() = + File(resourceLoader.getResource("file:${githubEnterpriseProperties.pemFile}").uri) +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..40240bd --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,4 @@ +github-enterprise: + baseUrl: ${GITHUB_ENTERPRISE_BASE_URL:} + githubAppId: ${GITHUB_APP_ID:} + pemFile: ${PEM_FILE:} \ No newline at end of file diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml new file mode 100644 index 0000000..8573e17 --- /dev/null +++ b/src/test/resources/application.yaml @@ -0,0 +1,4 @@ +github-enterprise: + baseUrl: ${GITHUB_ENTERPRISE_BASE_URL:dummy} + githubAppId: ${GITHUB_APP_ID:dummy} + pemFile: ${PEM_FILE:dummy} \ No newline at end of file