Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add secret-less authentication for CI jobs. #131

Merged
merged 7 commits into from
Sep 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ dependencies {
testImplementation("org.mockito.kotlin:mockito-kotlin:4.1.0")
testImplementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.14.1")
testImplementation("com.github.fge:json-schema-validator:2.2.6")
testImplementation("org.mock-server:mockserver-junit-jupiter:5.15.0")

// This dependency is used by the application.
implementation("com.google.guava:guava:31.1-jre")
Expand Down
6 changes: 4 additions & 2 deletions api/app/src/main/kotlin/packit/App.kt
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
package packit

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableScheduling

@EnableScheduling
@SpringBootApplication
@ConfigurationPropertiesScan
class App

fun main()
fun main(args: Array<String>)
{
runApplication<App>()
runApplication<App>(args = args)
}
22 changes: 22 additions & 0 deletions api/app/src/main/kotlin/packit/config/ServiceLoginConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package packit.config

import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.convert.DurationUnit
import java.time.Duration
import java.time.temporal.ChronoUnit

data class ServiceLoginPolicy(
val jwkSetURI: String,
val issuer: String,
val requiredClaims: Map<String, String> = mapOf(),
val grantedPermissions: List<String> = listOf(),

@DurationUnit(ChronoUnit.SECONDS)
val tokenDuration: Duration? = null,
)

@ConfigurationProperties(prefix = "auth.service")
data class ServiceLoginConfig(
val audience: String?,
val policies: List<ServiceLoginPolicy> = listOf()
)
27 changes: 27 additions & 0 deletions api/app/src/main/kotlin/packit/controllers/LoginController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ import packit.model.dto.LoginWithToken
import packit.model.dto.UpdatePassword
import packit.service.BasicLoginService
import packit.service.GithubAPILoginService
import packit.service.ServiceLoginService
import packit.service.UserService

@RestController
@RequestMapping("/auth")
class LoginController(
val gitApiLoginService: GithubAPILoginService,
val basicLoginService: BasicLoginService,
val serviceLoginService: ServiceLoginService,
val config: AppConfig,
val userService: UserService,
)
Expand Down Expand Up @@ -50,6 +52,31 @@ class LoginController(
return ResponseEntity.ok(token)
}

@PostMapping("/login/service")
@ResponseBody
fun loginService(
@RequestBody @Validated user: LoginWithToken,
): ResponseEntity<Map<String, String>>
{
if (!serviceLoginService.isEnabled()) {
throw PackitException("serviceLoginDisabled", HttpStatus.FORBIDDEN)
}

val token = serviceLoginService.authenticateAndIssueToken(user)
return ResponseEntity.ok(token)
}

@GetMapping("/login/service/audience")
@ResponseBody
fun serviceAudience(): ResponseEntity<Map<String, String>>
{
if (!serviceLoginService.isEnabled()) {
throw PackitException("serviceLoginDisabled", HttpStatus.FORBIDDEN)
} else {
return ResponseEntity.ok(mapOf("audience" to serviceLoginService.audience!!))
}
}

@GetMapping("/config")
@ResponseBody
fun authConfig(): ResponseEntity<Map<String, Any>>
Expand Down
76 changes: 64 additions & 12 deletions api/app/src/main/kotlin/packit/security/provider/TokenProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,37 +9,89 @@ import java.time.Duration
import java.time.Instant
import java.time.temporal.ChronoUnit

interface JwtIssuer
interface JwtBuilder
{
fun issue(userPrincipal: UserPrincipal): String
fun withExpiresAt(expiry: Instant): JwtBuilder
fun withDuration(duration: Duration): JwtBuilder
fun withPermissions(permissions: Collection<String>): JwtBuilder
fun issue(): String
}

@Component
class TokenProvider(val config: AppConfig) : JwtIssuer
interface JwtIssuer
{
fun issue(userPrincipal: UserPrincipal): String {
return builder(userPrincipal).issue()
}

// The builder method can be used to customize the returned token.
//
// The builder can only ever be used to weaken a token, eg. by reducing its
// permissions or shorten its lifespan.
fun builder(userPrincipal: UserPrincipal): JwtBuilder
}

class TokenProviderBuilder(val config: AppConfig, val userPrincipal: UserPrincipal) : JwtBuilder {
companion object
{
const val TOKEN_ISSUER = "packit-api"
const val TOKEN_AUDIENCE = "packit"
}

override fun issue(userPrincipal: UserPrincipal): String
{
private var duration: Duration = Duration.of(config.authExpiryDays, ChronoUnit.DAYS)
private var expiry: Instant? = null
private var permissions: MutableSet<String>? = null

override fun withExpiresAt(expiry: Instant): JwtBuilder {
if (this.expiry == null || expiry > this.expiry) {
this.expiry = expiry
}
return this
}

val roles = userPrincipal.authorities.map { it.authority }
override fun withDuration(duration: Duration): JwtBuilder {
if (duration < this.duration) {
this.duration = duration
}
return this
}

val createdDate = Instant.now()
override fun withPermissions(permissions: Collection<String>): JwtBuilder {
if (this.permissions == null) {
this.permissions = permissions.toMutableSet()
} else {
this.permissions!!.retainAll(permissions)
}
return this
}

val expiredDate = createdDate.plus(Duration.of(config.authExpiryDays, ChronoUnit.DAYS))
override fun issue(): String {
val issuedAt = Instant.now()
var expiresAt = issuedAt.plus(duration)
if (expiry != null && expiry!! < expiresAt) {
expiresAt = expiry
}

val permissions = userPrincipal.authorities.map { it.authority }.toMutableList()
if (this.permissions != null) {
permissions.retainAll(this.permissions!!)
}

return JWT.create()
.withAudience(TOKEN_AUDIENCE)
.withIssuer(TOKEN_ISSUER)
.withClaim("userName", userPrincipal.name)
.withClaim("displayName", userPrincipal.displayName)
.withClaim("datetime", createdDate)
.withClaim("au", roles)
.withExpiresAt(expiredDate)
.withClaim("au", permissions)
.withIssuedAt(issuedAt)
.withExpiresAt(expiresAt)
.sign(Algorithm.HMAC256(config.authJWTSecret))
}
}

@Component
class TokenProvider(val config: AppConfig) : JwtIssuer
{
override fun builder(userPrincipal: UserPrincipal): JwtBuilder {
return TokenProviderBuilder(config, userPrincipal)
}
}
12 changes: 1 addition & 11 deletions api/app/src/main/kotlin/packit/service/GithubAPILoginService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import packit.AppConfig
import packit.clients.GithubUserClient
import packit.exceptions.PackitException
import packit.model.dto.LoginWithToken
import packit.security.profile.UserPrincipal
import packit.security.provider.JwtIssuer

@Component
Expand All @@ -15,7 +14,6 @@ class GithubAPILoginService(
val jwtIssuer: JwtIssuer,
val githubUserClient: GithubUserClient,
val userService: UserService,
val roleService: RoleService
)
{
fun authenticateAndIssueToken(loginRequest: LoginWithToken): Map<String, String>
Expand All @@ -30,15 +28,7 @@ class GithubAPILoginService(
val githubUser = githubUserClient.getGithubUser()

var user = userService.saveUserFromGithub(githubUser.login, githubUser.name, githubUser.email)

val token = jwtIssuer.issue(
UserPrincipal(
user.username,
user.displayName,
roleService.getGrantedAuthorities(user.roles),
mutableMapOf()
)
)
val token = jwtIssuer.issue(userService.getUserPrincipal(user))

return mapOf("token" to token)
}
Expand Down
104 changes: 104 additions & 0 deletions api/app/src/main/kotlin/packit/service/ServiceLoginService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package packit.service

import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator
import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.jwt.JwtClaimValidator
import org.springframework.security.oauth2.jwt.JwtException
import org.springframework.security.oauth2.jwt.JwtIssuerValidator
import org.springframework.security.oauth2.jwt.JwtTimestampValidator
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
import org.springframework.stereotype.Component
import org.springframework.web.client.RestOperations
import org.springframework.web.client.RestTemplate
import packit.config.ServiceLoginConfig
import packit.config.ServiceLoginPolicy
import packit.exceptions.PackitException
import packit.model.dto.LoginWithToken
import packit.security.provider.JwtIssuer
import java.util.function.Predicate

data class TokenPolicy(
val decoder: NimbusJwtDecoder,
val config: ServiceLoginPolicy,
)

@Component
class ServiceLoginService(
val jwtIssuer: JwtIssuer,
val userService: UserService,
val serviceLoginConfig: ServiceLoginConfig,
val restOperations: RestOperations = RestTemplate(),
) {
val audience: String? = serviceLoginConfig.audience
fun isEnabled(): Boolean = audience != null && !audience!!.isEmpty()

private val policies: List<TokenPolicy>

init {
if (audience != null) {
val audValidator = JwtClaimValidator<List<String>>(
IdTokenClaimNames.AUD, { claimValue -> claimValue != null && claimValue.contains(audience) }
)
policies = serviceLoginConfig.policies.map { policy ->
val validators = mutableListOf(
JwtTimestampValidator(),
JwtIssuerValidator(policy.issuer),
audValidator,
)

policy.requiredClaims.mapTo(validators, { entry ->
JwtClaimValidator<String>(entry.key, Predicate.isEqual(entry.value))
})

val decoder = NimbusJwtDecoder.withJwkSetUri(policy.jwkSetURI)
.restOperations(restOperations)
.build()

decoder.setJwtValidator(DelegatingOAuth2TokenValidator(validators))
TokenPolicy(decoder, policy)
}
} else {
policies = listOf()
}
}

private fun issueToken(verifiedToken: Jwt, policy: ServiceLoginPolicy): String {
val user = userService.getServiceUser()
val userPrincipal = userService.getUserPrincipal(user)
val tokenBuilder = jwtIssuer.builder(userPrincipal)
tokenBuilder.withPermissions(policy.grantedPermissions)

val expiresAt = verifiedToken.expiresAt
if (expiresAt != null) {
tokenBuilder.withExpiresAt(expiresAt)
}
if (policy.tokenDuration != null) {
tokenBuilder.withDuration(policy.tokenDuration)
}
return tokenBuilder.issue()
}

fun authenticateAndIssueToken(user: LoginWithToken): Map<String, String> {
for (policy in policies) {
val decodedToken = try {
policy.decoder.decode(user.token)
} catch (e: JwtException) {
log.info("JWT policy rejected: {}", e.message)
continue
}
val issuedToken = issueToken(decodedToken, policy.config)

return mapOf("token" to issuedToken)
}

throw PackitException("externalJwtTokenInvalid", HttpStatus.UNAUTHORIZED)
}

companion object
{
private val log = LoggerFactory.getLogger(ServiceLoginService::class.java)
}
}
11 changes: 11 additions & 0 deletions api/app/src/main/kotlin/packit/service/UserService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import packit.model.User
import packit.model.dto.CreateBasicUser
import packit.model.dto.UpdatePassword
import packit.repository.UserRepository
import packit.security.profile.UserPrincipal
import java.time.Instant
import javax.naming.AuthenticationException

Expand All @@ -25,6 +26,7 @@ interface UserService
fun updatePassword(username: String, updatePassword: UpdatePassword)
fun checkAndUpdateLastLoggedIn(username: String)
fun getServiceUser(): User
fun getUserPrincipal(user: User): UserPrincipal
}

@Service
Expand Down Expand Up @@ -166,4 +168,13 @@ class BaseUserService(
return userRepository.findByUsernameAndUserSource("SERVICE", "service")
?: throw PackitException("serviceUserNotFound", HttpStatus.INTERNAL_SERVER_ERROR)
}

override fun getUserPrincipal(user: User): UserPrincipal {
return UserPrincipal(
user.username,
user.displayName,
roleService.getGrantedAuthorities(user.roles),
mutableMapOf()
)
}
}
2 changes: 2 additions & 0 deletions api/app/src/main/resources/errorBundle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ githubTokenInvalid=The supplied GitHub token is invalid.
githubTokenInsufficientPermissions=The supplied GitHub token does not have sufficient permissions to check user credentials.
githubTokenUnexpectedError=Unexpected error when checking user credentials with GitHub.
githubUserRestrictedAccess=User is not in allowed organization or team. Please contact your administrator.
jwtLoginDisabled=JWT login is disabled
externalJwtTokenInvalid=The supplied token is invalid
httpClientError=Http Client error occurred
invalidPassword=Invalid password
insufficientPrivileges=You do not have sufficient privileges for attempted action
Expand Down
7 changes: 3 additions & 4 deletions api/app/src/test/kotlin/packit/integration/IntegrationTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -57,19 +57,18 @@ abstract class IntegrationTest
return HttpEntity(data, headers)
}

protected fun assertSuccess(responseEntity: ResponseEntity<String>)
protected fun <T>assertSuccess(responseEntity: ResponseEntity<T>)
{
assertEquals(responseEntity.statusCode, HttpStatus.OK)
assertEquals(responseEntity.headers.contentType, MediaType.APPLICATION_JSON)
assertThat(responseEntity.body).isNotEmpty
}

protected fun assertUnauthorized(responseEntity: ResponseEntity<String>)
protected fun <T>assertUnauthorized(responseEntity: ResponseEntity<T>)
{
assertEquals(responseEntity.statusCode, HttpStatus.UNAUTHORIZED)
}

protected fun assertForbidden(responseEntity: ResponseEntity<String>)
protected fun <T>assertForbidden(responseEntity: ResponseEntity<T>)
{
assertEquals(responseEntity.statusCode, HttpStatus.FORBIDDEN)
}
Expand Down
Loading
Loading