Skip to content

Commit

Permalink
Use hmac token for ergochat authentication due to irc 510 message cha…
Browse files Browse the repository at this point in the history
…racter limit (#178)
  • Loading branch information
Sheikah45 authored Oct 15, 2023
1 parent eacbac9 commit d86cede
Show file tree
Hide file tree
Showing 11 changed files with 322 additions and 107 deletions.
Original file line number Diff line number Diff line change
@@ -1,30 +1,13 @@
package com.faforever.userservice.backend.cloudflare

import com.faforever.userservice.backend.security.HmacService
import jakarta.enterprise.context.ApplicationScoped
import java.net.URI
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.time.Instant
import java.util.*
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec

@ApplicationScoped
class CloudflareService {

companion object {
private const val HMAC_SHA256 = "HmacSHA256"
}

/**
* Builds hmac token for cloudflare firewall verification as specified
* [here](https://support.cloudflare.com/hc/en-us/articles/115001376488-Configuring-Token-Authentication)
* @param uri uri to generate the hmac token for
* @return string representing the hmac token formatted as {timestamp}-{hashedContent}
*/
fun generateCloudFlareHmacToken(uri: String, secret: String): String {
return generateCloudFlareHmacToken(URI.create(uri), secret)
}
class CloudflareService(
private val hmacService: HmacService,
) {

/**
* Builds hmac token for cloudflare firewall verification as specified
Expand All @@ -33,16 +16,7 @@ class CloudflareService {
* @return string representing the hmac token formatted as {timestamp}-{hashedContent}
*/
fun generateCloudFlareHmacToken(uri: URI, secret: String): String {
val mac = Mac.getInstance(HMAC_SHA256)
mac.init(SecretKeySpec(secret.toByteArray(StandardCharsets.UTF_8), HMAC_SHA256))

val timeStamp = Instant.now().epochSecond
val path = if (uri.path.startsWith("/")) uri.path else "/" + uri.path
val macMessage = (path + timeStamp).toByteArray(StandardCharsets.UTF_8)
val hmacEncoded = URLEncoder.encode(
String(Base64.getEncoder().encode(mac.doFinal(macMessage)), StandardCharsets.UTF_8),
StandardCharsets.UTF_8,
)
return "$timeStamp-$hmacEncoded"
val message = if (uri.path.startsWith("/")) uri.path else "/" + uri.path
return hmacService.generateHmacToken(message, secret)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ class FafPermissionsAugmentor : SecurityIdentityAugmentor {
val hasScopes = requiredPermission.actions.split(",").all { scopes.contains(it) }
Uni.createFrom().item(hasRole && hasScopes)
}

builder.addRoles(roles)
}
}
builder.build()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.faforever.userservice.backend.security

import jakarta.enterprise.context.ApplicationScoped
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.time.Instant
import java.util.*
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec

@ApplicationScoped
class HmacService {

companion object {
private const val HMAC_SHA256 = "HmacSHA256"
}

fun generateHmacToken(message: String, secret: String): String {
val timeStamp = Instant.now().epochSecond
val hmacEncoded = generateEncodedMessage(message, secret, timeStamp)
return "$timeStamp-$hmacEncoded"
}

private fun generateEncodedMessage(message: String, secret: String, epochSecond: Long): String {
val mac = Mac.getInstance(HMAC_SHA256)
mac.init(SecretKeySpec(secret.toByteArray(StandardCharsets.UTF_8), HMAC_SHA256))
val macMessage = (message + epochSecond).toByteArray(StandardCharsets.UTF_8)

return URLEncoder.encode(
String(Base64.getEncoder().encode(mac.doFinal(macMessage)), StandardCharsets.UTF_8),
StandardCharsets.UTF_8,
)
}

fun isValidHmacToken(token: String, expectedMessage: String, secret: String, ttl: Long): Boolean {
val (epochSecond, encodedMessage) = token.split("-").let {
if (it.size != 2) {
return false
}

it[0].toLongOrNull() to it[1]
}

if (epochSecond == null) {
return false
}

return Instant.now().isBefore(Instant.ofEpochSecond(epochSecond).plusSeconds(ttl)) &&
generateEncodedMessage(expectedMessage, secret, epochSecond) == encodedMessage
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ interface FafProperties {
@WithName("fixed.users")
fun fixedUsers(): Map<String, String>

interface UserCredentials
fun secret(): String

fun tokenTtl(): Long
}
}
118 changes: 75 additions & 43 deletions src/main/kotlin/com/faforever/userservice/web/ErgochatController.kt
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
package com.faforever.userservice.web

import com.faforever.userservice.backend.hydra.HydraClient
import com.faforever.userservice.backend.security.FafRole
import com.faforever.userservice.backend.security.HmacService
import com.faforever.userservice.config.FafProperties
import jakarta.annotation.security.RolesAllowed
import jakarta.enterprise.context.ApplicationScoped
import jakarta.json.JsonString
import jakarta.ws.rs.GET
import jakarta.ws.rs.POST
import jakarta.ws.rs.Path
import org.eclipse.microprofile.rest.client.inject.RestClient
import jakarta.ws.rs.core.Context
import jakarta.ws.rs.core.Response
import jakarta.ws.rs.core.SecurityContext
import org.eclipse.microprofile.jwt.JsonWebToken
import java.util.*

@Path("/irc/ergochat")
@ApplicationScoped
class ErgochatController(
private val properties: FafProperties,
@RestClient private val hydraClient: HydraClient,
private val hmacService: HmacService,
) {
data class LoginRequest(
val accountName: String,
Expand All @@ -25,6 +33,10 @@ class ErgochatController(
val error: String? = null,
)

data class IrcToken(
val value: String,
)

@POST
@Path("/login")
fun authenticateChatUser(loginData: LoginRequest): LoginResponse {
Expand All @@ -41,51 +53,71 @@ class ErgochatController(
}

return when (authenticationType) {
"oauth" -> {
val tokenIntrospection = hydraClient.introspectToken(authenticationValue, null)
if (!tokenIntrospection.active) {
LoginResponse(
success = false,
accountName = loginData.accountName,
error = "Invalid token",
)
}
"token" -> authenticateToken(authenticationValue, loginData.accountName)
"static" -> authenticateStatic(authenticationValue, loginData.accountName)
else -> LoginResponse(
success = false,
accountName = loginData.accountName,
error = "unknown authentication type $authenticationType",
)
}
}

val success = (tokenIntrospection.ext as Map<*, *>?)?.get("username") == loginData.accountName
LoginResponse(
success = success,
accountName = loginData.accountName,
error = if (success) {
null
} else {
"Invalid token for user ${loginData.accountName}"
},
)
}
private fun authenticateStatic(
authenticationValue: String,
accountName: String,
): LoginResponse {
val success = properties.irc().fixedUsers().any { (user, password) ->
user.equals(accountName, ignoreCase = true) && password == authenticationValue
}

"static" -> {
val success = properties.irc().fixedUsers()
.any { (user, password) ->
user.equals(loginData.accountName, ignoreCase = true) &&
password == authenticationValue
}
return LoginResponse(
success = success,
accountName = accountName,
error = if (success) {
null
} else {
"Username or password does not match for static user $accountName"
},
)
}

LoginResponse(
success = success,
accountName = loginData.accountName,
error = if (success) {
null
} else {
"Username or password does not match for static user ${loginData.accountName}"
},
)
private fun authenticateToken(
authenticationValue: String,
accountName: String,
): LoginResponse {
val isValidHmac = hmacService.isValidHmacToken(
authenticationValue,
accountName.lowercase(Locale.ROOT),
properties.irc().secret(),
properties.irc().tokenTtl(),
)

return LoginResponse(
success = isValidHmac,
accountName = accountName,
error = if (isValidHmac) {
null
} else {
"Invalid token"
},
)
}

@GET
@Path("/token")
@RolesAllowed(FafRole.USER)
fun getIrcToken(@Context context: SecurityContext): Response {
return when (val principal = context.userPrincipal) {
is JsonWebToken -> {
return principal.claim<Map<String, Any>>("ext")
.map { (it["username"] as JsonString).string.lowercase(Locale.ROOT) }
.map { hmacService.generateHmacToken(it, properties.irc().secret()) }
.map { Response.ok(IrcToken(it)).build() }
.orElse(Response.status(Response.Status.UNAUTHORIZED).build())
}

else -> LoginResponse(
success = false,
accountName = loginData.accountName,
error = "Unknown authentication type $authenticationType",
)
else -> Response.status(Response.Status.UNAUTHORIZED).build()
}
}
}
3 changes: 3 additions & 0 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ faf:
secret: ${LOBBY_SECRET:banana}
access-uri: ${LOBBY_URL:ws://localhost:8003}
access-param: ${LOBBY_PARAM:verify}
irc:
secret: ${IRC_SECRET:banana}
token-ttl: ${IRC_TOKEN_TTL:300}

security:
failed-login-account-threshold: ${FAILED_LOGIN_ACCOUNT_THRESHOLD:5}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.faforever.userservice.backend.cloudflare

import com.faforever.userservice.backend.security.HmacService
import io.quarkus.test.junit.QuarkusTest
import jakarta.inject.Inject
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.net.URI

@QuarkusTest
class CloudflareServiceTest {

@Inject
private lateinit var cloudflareService: CloudflareService

@Inject
private lateinit var hmacService: HmacService

@Test
fun testCloudFlareToken() {
val secret = "secret"
val token = cloudflareService.generateCloudFlareHmacToken(URI.create("http://localhost/test"), secret)
assertTrue(hmacService.isValidHmacToken(token, "/test", secret, 1))
}

@Test
fun testCloudFlareTokenEmptyPath() {
val secret = "secret"
val token = cloudflareService.generateCloudFlareHmacToken(URI.create("http://localhost"), secret)
assertTrue(hmacService.isValidHmacToken(token, "/", secret, 1))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.faforever.userservice.backend.security

import io.quarkus.test.junit.QuarkusTest
import jakarta.inject.Inject
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

@QuarkusTest
class HmacServiceTest {

@Inject
private lateinit var hmacService: HmacService

@Test
fun testHmacGenerationAndValidation() {
val message = "message"
val secret = "secret"
val token = hmacService.generateHmacToken(message, secret)
assertTrue(hmacService.isValidHmacToken(token, message, secret, 1))
}

@Test
fun testHmacGenerationAndValidationExpires() {
val message = "message"
val secret = "secret"
val token = hmacService.generateHmacToken(message, secret)

Thread.sleep(1000)

assertFalse(hmacService.isValidHmacToken(token, message, secret, 1))
}

@Test
fun testHmacGenerationAndValidationDifferentMessage() {
val message = "message"
val secret = "secret"
val token = hmacService.generateHmacToken(message, secret)

Thread.sleep(1000)

assertFalse(hmacService.isValidHmacToken(token, "differentMessage", secret, 1))
}

@Test
fun testHmacGenerationAndValidationMisformattedToken() {
val message = "message"
val secret = "secret"
val token = hmacService.generateHmacToken(message, secret)

assertFalse(hmacService.isValidHmacToken(token.replace("-", ""), "differentMessage", secret, 1))
}

@Test
fun testHmacGenerationAndValidationBadTimestamp() {
assertFalse(hmacService.isValidHmacToken("a-b", "differentMessage", "secret", 1))
}
}
Loading

0 comments on commit d86cede

Please sign in to comment.