Skip to content

Commit

Permalink
Support self-signed long-lived session tokens
Browse files Browse the repository at this point in the history
Closes #18
  • Loading branch information
Christian Schmidt authored and Brutus5000 committed Dec 14, 2023
1 parent 4376e9f commit abe2d8b
Show file tree
Hide file tree
Showing 7 changed files with 176 additions and 14 deletions.
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dependencies {
implementation("io.quarkus:quarkus-hibernate-orm-panache-kotlin")
implementation("io.quarkus:quarkus-jdbc-mariadb")
implementation("io.quarkus:quarkus-kotlin")
implementation("io.quarkus:quarkus-oidc")
implementation("io.quarkus:quarkus-smallrye-jwt")
implementation("io.quarkus:quarkus-smallrye-health")
implementation("io.quarkus:quarkus-rest-client-reactive-jackson")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import io.smallrye.config.ConfigMapping

@ConfigMapping(prefix = "faf")
interface FafProperties {
fun selfUrl(): String

/**
* Must start with a letter, otherwise potential conflicts in Xirsys!
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.faforever.icebreaker.security

import com.faforever.icebreaker.config.FafProperties
import com.fasterxml.jackson.databind.ObjectMapper
import io.quarkus.oidc.TenantResolver
import io.vertx.core.http.HttpHeaders.AUTHORIZATION
import io.vertx.ext.web.RoutingContext
import jakarta.enterprise.context.ApplicationScoped

/**
* This application supports 2 JWT providers:
*
* * "Official" FAF access token from Ory Hydra (hydra.faforever.com)
* * Self-signed JWTs inside this service that are used for extended session token.
*
* This tenant resolver selects the right provider based on the issuer of the JWT.
*/
@ApplicationScoped
class CustomTenantResolver(
private val fafProperties: FafProperties,
private val objectMapper: ObjectMapper,
) : TenantResolver {

override fun resolve(context: RoutingContext): String? =
context.request().getHeader(AUTHORIZATION)
?.takeIf { it.startsWith("Bearer ") }
?.let {
val rawToken = it.substring(7)
val body = java.util.Base64.getDecoder().decode(rawToken.split(".")[1])
val json = objectMapper.readTree(body)

json["iss"]?.textValue()
}
?.takeIf { it == fafProperties.selfUrl() }
?.let { "self-tenant" }
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import io.quarkus.security.identity.SecurityIdentityAugmentor
import io.quarkus.security.runtime.QuarkusSecurityIdentity
import io.smallrye.mutiny.Uni
import jakarta.enterprise.context.ApplicationScoped
import jakarta.json.JsonNumber
import jakarta.json.JsonString
import org.eclipse.microprofile.jwt.JsonWebToken

Expand Down Expand Up @@ -33,6 +34,10 @@ class FafPermissionsAugmentor : SecurityIdentityAugmentor {
.map { it.map { jsonString -> jsonString.string }.toSet() }
.orElse(setOf())

principal.claim<JsonNumber>("gameId").ifPresent {
builder.addAttribute("gameId", it.longValue())
}

builder.addPermissionChecker { requiredPermission ->
val hasRole = roles.contains(requiredPermission.name)
val hasScopes = requiredPermission.actions.split(",").all { scopes.contains(it) }
Expand Down
33 changes: 33 additions & 0 deletions src/main/kotlin/com/faforever/icebreaker/service/SessionService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ import com.faforever.icebreaker.persistence.IceSessionEntity
import com.faforever.icebreaker.persistence.IceSessionRepository
import com.faforever.icebreaker.util.AsyncRunner
import io.quarkus.scheduler.Scheduled
import io.quarkus.security.ForbiddenException
import io.quarkus.security.UnauthorizedException
import io.quarkus.security.identity.SecurityIdentity
import io.smallrye.jwt.build.Jwt
import jakarta.enterprise.inject.Instance
import jakarta.inject.Singleton
import jakarta.transaction.Transactional
import org.eclipse.microprofile.jwt.JsonWebToken
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.time.Instant
Expand All @@ -20,12 +25,40 @@ class SessionService(
sessionHandlers: Instance<SessionHandler>,
private val fafProperties: FafProperties,
private val iceSessionRepository: IceSessionRepository,
private val securityIdentity: SecurityIdentity,
) {
private val activeSessionHandlers = sessionHandlers.filter { it.active }

fun buildToken(gameId: Long): String {
val userId = when (val principal = securityIdentity?.principal) {
null -> throw UnauthorizedException("No principal available")
is JsonWebToken -> principal.subject.toInt()
else -> throw IllegalStateException("Unexpected principal type: ${principal.javaClass} ($principal)")
}

return Jwt.subject(userId.toString())
.claim("gameId", gameId)
.claim("ext", mapOf("roles" to listOf("USER")))
.claim("scp", listOf("lobby"))
.issuer(fafProperties.selfUrl())
.audience(fafProperties.selfUrl())
.expiresAt(Instant.now().plus(fafProperties.maxSessionLifeTimeHours(), ChronoUnit.HOURS))
.sign()
}

@Deprecated(
message = "Only remaining for java-ice-adapter compatibility",
replaceWith = ReplaceWith("getSession"),
)
fun getServers(): List<Server> = activeSessionHandlers.flatMap { it.getIceServers() }

fun getSession(gameId: Long): Session {
// For compatibility reasons right now we only check on mismatch because general FAF JWT are still allowed
// but have no implicit gameId attached
securityIdentity.attributes["gameId"]?.takeIf { it != gameId }?.run {
throw ForbiddenException("Not authorized to join game $gameId")
}

val sessionId = "game/$gameId"

val servers = activeSessionHandlers.flatMap {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,37 @@ package com.faforever.icebreaker.web

import com.faforever.icebreaker.service.Session
import com.faforever.icebreaker.service.SessionService
import io.quarkus.runtime.annotations.RegisterForReflection
import io.quarkus.security.PermissionsAllowed
import jakarta.inject.Singleton
import jakarta.ws.rs.Consumes
import jakarta.ws.rs.GET
import jakarta.ws.rs.POST
import jakarta.ws.rs.Path
import jakarta.ws.rs.Produces
import jakarta.ws.rs.core.MediaType
import org.jboss.resteasy.reactive.RestPath

@Path("/session")
@Singleton
class SessionController(private val sessionService: SessionService) {
class SessionController(
private val sessionService: SessionService,
) {

@RegisterForReflection
data class TokenRequest(val gameId: Long)

@RegisterForReflection
data class TokenResponse(val jwt: String)

@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Path("/token")
fun buildToken(tokenRequest: TokenRequest): TokenResponse =
sessionService.buildToken(tokenRequest.gameId)
.let { TokenResponse(it) }

@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/game/{gameId}")
Expand Down
91 changes: 78 additions & 13 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,29 @@ quarkus:
physical-naming-strategy: org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy
flyway:
migrate-at-start: true
mp:
jwt:
verify:
issuer: ${HYDRA_TOKEN_ISSUER:http://faf-ory-hydra:4444/}
key-format: JWKS
publickey:
location: ${HYDRA_JWKS_URL:http://localhost:4444/.well-known/jwks.json}
oidc:
auth-server-url: ${HYDRA_URL:http://localhost:4444}
# A tenant for our self-signed JWTs
# (also requires the CustomTenantResolver)
self-tenant:
# There is no .well-known/openid-configuration
auth-server-url: ${SELF_URL:https://ice.faforever.com}
discovery-enabled: false
token:
# Hard coded JWT settings, as there is no JWKS
signature-algorithm: ${JWT_ALGORITHM:RS256}
# No Quarkus, there is really no JWKS! Stop looking for it.
forced-jwk-refresh-interval: 0S
public-key: ${JWT_PUBLIC_KEY_PATH}
http:
auth:
permission:
authenticated:
paths: "/*"
policy: authenticated

faf:
self-url: ${SELF_URL:https://ice.faforever.com}
environment: ${ENVIRONMENT:dev}
token-lifetime-seconds: 86400 # 24h because of lobbies/games/reconnects can happen for a long time
max-session-life-time-hours: 24
Expand All @@ -31,12 +45,63 @@ xirsys:
ident: ${XIRSYS_IDENT}
secret: ${XIRSYS_SECRET}
channel-namespace: "faf"

smallrye:
jwt:
sign:
key: ${JWT_PRIVATE_KEY_PATH}
"%dev":
quarkus:
log:
category:
"org.hibernate.SQL":
smallrye:
jwt:
sign:
# Just a random private key for testing
key: |-
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDXsCsl9W0vnW2k
5GaNOVoZ6LPFYu60Y1Cd4ERRXvt8KzKTm2HHZeLKd77OLeIHR4RvJ2Q76SFwfDBM
35F5eEx1mjPua2ljxbObsgz/bA9yBwO1RugpNOe+GoGUhPyZvmmZwqRnnQsT/SHV
ZvRq7ej6k+KkJf09IIOxfWrGUj8SajW3iEpkuKdNpjp1dRnJdZAZ8mV1LgnwHCAf
osL3t3+PElBSxnRQNW9iYVwB9wQAWK+aivx5warhuCeyKVtDaR0x96bOUTaKL4i/
Uihn0CElGt5ZA907wHa6/N4Z8ssXjY+/vizYB2VYxuAG/MVkkbwWUUTGjzEEX6Ww
h5icvVm1AgMBAAECggEAAZYYGyVc8ja0MbxETNGZKgueFtuNaeI5G5AksHyEWPtw
WcmQxIipTFfpHVcVDHyoKrEdeZtTVaJ0MHyMc1pBJbRGoYBEvCkeEw0SL2a6Dlqi
2lh1KKhs8+b6AP+hY/gUir71upVbGYCJGSqyrX6mcgFYb2CgJizxCwMjH+ZG9Hm0
CkGeh4g0VDOWmx4uCChXSyoaPzD4yTJts/EOpSD61KqS+cNcnRD8PVUxwwSH+4DY
ZSuaAUC/kFvD4qQq2lY/eia2CQi2R1Ff2TCxcbNZ34yW8IR1UBdrOPo3orQK5vSf
iT5++MYJmTZJ8/QxY5M1nZqiyJEjTvaBQNGv8abKWQKBgQDxd/5lJkc13x8jPFJm
EnmPvxrJaYk3MLW3dtxz1HtjHDQAvCmjXy7Ss13WhLJv9nHJDtQlSRr+l+7eNPTP
QtiwDsqv9COfbPbvH2qcNJuNoINQ2YSKYvR0j+QlMz2dHroWEyXL4oyOfXAJ3ZrU
lyWn/a2BD3uiJAj4p8YzJgfgnwKBgQDkqwGC6AMLPbVmhCMnUd+cxFMkYymdi8R4
ZXMkjJiMLAOt8tkp8T0nqxC/zMfD0jnPKw1R9MP7XlM/tonLeAM/P8GUMwJnTCTc
PvP1JxkvMG3do+7y9AbLyJsNZDkbYj1wLzvZYUrXQV/HKU4balDj3QVI6yr+W6ha
idlsMDYBKwKBgBeuF9GdlmAvGGOhN8dwymERcbQM2HsEGN38FxR44vzOOD9WNJMj
83iQRISUENewCGqaPK3HZJFRHwjFkrh8qrlhSflFbPTmf7TllNPqyNJzykz0d+4G
VEjWD56iTsmIyOD/UbaT6grTPFiLVfLBO90koI5GkW5OMF8KPQKpGR6rAoGAU5NQ
1RiZbDVcpKBs/MUG1pRG0wjPP/7Ci0KBB/2/D5RSr/QPfS3nrSTv1ToyVRbz/Az/
LFIqgyghgyrjSBOQFEDoLpNKMJj66+iyX4qvwLiRny14eyHHjhm+2fEkkiagz+zj
kfrmULBbIj6thoWgFPhGIzWYnCjB6n1xkwI36ssCgYACiZNHvqld4Om2IChCjIV6
UPNLUDOvr7V1qsEy+y0dp2RQH9Es121n/v30GYfsUUYmH35CQYR0aOEqU17Qm2V7
1auC1ZD9UeE9dy2LpW635uYf16D5FejAcmxyf/MRSBBnvFauGdS2vZ7Pf05u9Zpw
i8UgZE7+lTYKv7+4ujmgHw==
-----END PRIVATE KEY-----
quarkus:
oidc:
self-tenant:
public-key: |-
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA17ArJfVtL51tpORmjTla
GeizxWLutGNQneBEUV77fCsyk5thx2Xiyne+zi3iB0eEbydkO+khcHwwTN+ReXhM
dZoz7mtpY8Wzm7IM/2wPcgcDtUboKTTnvhqBlIT8mb5pmcKkZ50LE/0h1Wb0au3o
+pPipCX9PSCDsX1qxlI/Emo1t4hKZLinTaY6dXUZyXWQGfJldS4J8BwgH6LC97d/
jxJQUsZ0UDVvYmFcAfcEAFivmor8ecGq4bgnsilbQ2kdMfemzlE2ii+Iv1IoZ9Ah
JRreWQPdO8B2uvzeGfLLF42Pv74s2AdlWMbgBvzFZJG8FlFExo8xBF+lsIeYnL1Z
tQIDAQAB
-----END PUBLIC KEY-----
log:
level: DEBUG
category:
"org.hibernate.SQL":
level: DEBUG
"com.faforever":
level: DEBUG
"com.faforever":
"io.quarkus":
level: DEBUG

0 comments on commit abe2d8b

Please sign in to comment.