diff --git a/src/main/kotlin/com/faforever/userservice/backend/cloudflare/CloudflareService.kt b/src/main/kotlin/com/faforever/userservice/backend/cloudflare/CloudflareService.kt index e9fd108..52173dc 100644 --- a/src/main/kotlin/com/faforever/userservice/backend/cloudflare/CloudflareService.kt +++ b/src/main/kotlin/com/faforever/userservice/backend/cloudflare/CloudflareService.kt @@ -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 @@ -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) } } diff --git a/src/main/kotlin/com/faforever/userservice/backend/security/FafPermissionsAugmentor.kt b/src/main/kotlin/com/faforever/userservice/backend/security/FafPermissionsAugmentor.kt index 69168fc..94e9064 100644 --- a/src/main/kotlin/com/faforever/userservice/backend/security/FafPermissionsAugmentor.kt +++ b/src/main/kotlin/com/faforever/userservice/backend/security/FafPermissionsAugmentor.kt @@ -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() diff --git a/src/main/kotlin/com/faforever/userservice/backend/security/HmacService.kt b/src/main/kotlin/com/faforever/userservice/backend/security/HmacService.kt new file mode 100644 index 0000000..99a2073 --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/backend/security/HmacService.kt @@ -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 + } +} diff --git a/src/main/kotlin/com/faforever/userservice/config/FafProperties.kt b/src/main/kotlin/com/faforever/userservice/config/FafProperties.kt index 673af6d..c62e308 100644 --- a/src/main/kotlin/com/faforever/userservice/config/FafProperties.kt +++ b/src/main/kotlin/com/faforever/userservice/config/FafProperties.kt @@ -49,6 +49,8 @@ interface FafProperties { @WithName("fixed.users") fun fixedUsers(): Map - interface UserCredentials + fun secret(): String + + fun tokenTtl(): Long } } diff --git a/src/main/kotlin/com/faforever/userservice/web/ErgochatController.kt b/src/main/kotlin/com/faforever/userservice/web/ErgochatController.kt index ec16832..640075f 100644 --- a/src/main/kotlin/com/faforever/userservice/web/ErgochatController.kt +++ b/src/main/kotlin/com/faforever/userservice/web/ErgochatController.kt @@ -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, @@ -25,6 +33,10 @@ class ErgochatController( val error: String? = null, ) + data class IrcToken( + val value: String, + ) + @POST @Path("/login") fun authenticateChatUser(loginData: LoginRequest): LoginResponse { @@ -41,51 +53,73 @@ 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>("ext") + .map { it["username"] as JsonString } + .map { it.string } + .map { it.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() } } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index b276e9e..8d3a9ea 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -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} diff --git a/src/test/kotlin/com/faforever/userservice/backend/cloudflare/CloudflareServiceTest.kt b/src/test/kotlin/com/faforever/userservice/backend/cloudflare/CloudflareServiceTest.kt new file mode 100644 index 0000000..e646252 --- /dev/null +++ b/src/test/kotlin/com/faforever/userservice/backend/cloudflare/CloudflareServiceTest.kt @@ -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)) + } +} diff --git a/src/test/kotlin/com/faforever/userservice/backend/security/HmacServiceTest.kt b/src/test/kotlin/com/faforever/userservice/backend/security/HmacServiceTest.kt new file mode 100644 index 0000000..8737cd3 --- /dev/null +++ b/src/test/kotlin/com/faforever/userservice/backend/security/HmacServiceTest.kt @@ -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)) + } +} diff --git a/src/test/kotlin/com/faforever/userservice/web/ErgoChatControllerTest.kt b/src/test/kotlin/com/faforever/userservice/web/ErgoChatControllerTest.kt index 528096a..10c4baa 100644 --- a/src/test/kotlin/com/faforever/userservice/web/ErgoChatControllerTest.kt +++ b/src/test/kotlin/com/faforever/userservice/web/ErgoChatControllerTest.kt @@ -1,34 +1,31 @@ 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 io.quarkus.test.InjectMock +import com.faforever.userservice.web.util.FafRoleTest import io.quarkus.test.common.http.TestHTTPEndpoint import io.quarkus.test.junit.QuarkusTest +import io.quarkus.test.security.TestSecurity import io.restassured.RestAssured import io.restassured.http.ContentType import jakarta.inject.Inject -import org.eclipse.microprofile.rest.client.inject.RestClient import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.matchesRegex import org.hamcrest.Matchers.notNullValue import org.hamcrest.Matchers.nullValue import org.junit.jupiter.api.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.whenever -import sh.ory.hydra.model.OAuth2TokenIntrospection @QuarkusTest @TestHTTPEndpoint(ErgochatController::class) class ErgoChatControllerTest { - @InjectMock - @RestClient - private lateinit var hydraClient: HydraClient - @Inject private lateinit var properties: FafProperties + @Inject + private lateinit var hmacService: HmacService + @Test fun authenticateUnknownType() { val loginRequest = @@ -90,10 +87,30 @@ class ErgoChatControllerTest { } @Test - fun authenticateOAuth() { - whenever(hydraClient.introspectToken(any(), anyOrNull())).thenReturn(createActiveTokenForUsername("test-user")) + @TestSecurity(user = "test-user") + fun requestTokenWithoutRoleFails() { + RestAssured.given() + .get("/token") + .then() + .statusCode(403) + } + + @Test + @TestSecurity(user = "test-user") + @FafRoleTest([FafRole.USER]) + fun requestAndAuthenticateIrcToken() { + val token: String = RestAssured.given() + .get("/token") + .then() + .statusCode(200) + .body("value", matchesRegex("\\d{10}-.{43,}")) + .extract() + .body() + .path("value") + val loginRequest = - ErgochatController.LoginRequest(accountName = "test-user", passphrase = "oauth:token", ip = "127.0.0.1") + ErgochatController.LoginRequest(accountName = "test-user", passphrase = "token:$token", ip = "127.0.0.1") + RestAssured.given() .body(loginRequest) .contentType(ContentType.JSON) @@ -106,10 +123,23 @@ class ErgoChatControllerTest { } @Test - fun authenticateOAuthInactive() { - whenever(hydraClient.introspectToken(any(), anyOrNull())).thenReturn(createInactiveToken()) + @TestSecurity(user = "test-user") + @FafRoleTest([FafRole.USER]) + fun requestAndAuthenticateIrcTokenExpired() { + val token: String = RestAssured.given() + .get("/token") + .then() + .statusCode(200) + .body("value", matchesRegex("\\d{10}-.{43,}")) + .extract() + .body() + .path("value") + + Thread.sleep(1000) + val loginRequest = - ErgochatController.LoginRequest(accountName = "test-user", passphrase = "oauth:token", ip = "127.0.0.1") + ErgochatController.LoginRequest(accountName = "test-user", passphrase = "token:$token", ip = "127.0.0.1") + RestAssured.given() .body(loginRequest) .contentType(ContentType.JSON) @@ -122,10 +152,21 @@ class ErgoChatControllerTest { } @Test - fun authenticateOAuthUserMismatch() { - whenever(hydraClient.introspectToken(any(), anyOrNull())).thenReturn(createActiveTokenForUsername("test")) + @TestSecurity(user = "test-user") + @FafRoleTest([FafRole.USER]) + fun requestAndAuthenticateIrcTokenUserMismatch() { + val token: String = RestAssured.given() + .get("/token") + .then() + .statusCode(200) + .body("value", matchesRegex("\\d{10}-.{43,}")) + .extract() + .body() + .path("value") + val loginRequest = - ErgochatController.LoginRequest(accountName = "test-user", passphrase = "oauth:token", ip = "127.0.0.1") + ErgochatController.LoginRequest(accountName = "test", passphrase = "token:$token", ip = "127.0.0.1") + RestAssured.given() .body(loginRequest) .contentType(ContentType.JSON) @@ -133,13 +174,7 @@ class ErgoChatControllerTest { .then() .statusCode(200) .body("success", equalTo(false)) - .body("accountName", equalTo("test-user")) + .body("accountName", equalTo("test")) .body("error", notNullValue()) } - - private fun createActiveTokenForUsername(username: String): OAuth2TokenIntrospection = - OAuth2TokenIntrospection(active = true, ext = mapOf("username" to username)) - - private fun createInactiveToken(): OAuth2TokenIntrospection = - OAuth2TokenIntrospection(active = false) } diff --git a/src/test/kotlin/com/faforever/userservice/web/util/TestSecurityAugmentor.kt b/src/test/kotlin/com/faforever/userservice/web/util/TestSecurityAugmentor.kt index 58643b2..97e7962 100644 --- a/src/test/kotlin/com/faforever/userservice/web/util/TestSecurityAugmentor.kt +++ b/src/test/kotlin/com/faforever/userservice/web/util/TestSecurityAugmentor.kt @@ -3,9 +3,12 @@ package com.faforever.userservice.web.util import io.quarkus.arc.Unremovable import io.quarkus.security.identity.SecurityIdentity import io.quarkus.security.runtime.QuarkusSecurityIdentity +import io.quarkus.test.security.TestSecurity import io.quarkus.test.security.TestSecurityIdentityAugmentor import io.smallrye.mutiny.Uni import jakarta.enterprise.context.ApplicationScoped +import jakarta.json.Json +import org.eclipse.microprofile.jwt.JsonWebToken annotation class FafRoleTest(val value: Array) annotation class FafScopeTest(val value: Array) @@ -13,9 +16,10 @@ annotation class FafScopeTest(val value: Array) @ApplicationScoped @Unremovable class TestFafSecurityAugmentor : TestSecurityIdentityAugmentor { - override fun augment(identity: SecurityIdentity?, annotations: Array?): SecurityIdentity { - val scopes = annotations?.firstOrNull { it is FafScopeTest }?.let { it as FafScopeTest }?.value - val roles = annotations?.firstOrNull { it is FafRoleTest }?.let { it as FafRoleTest }?.value + override fun augment(identity: SecurityIdentity, annotations: Array): SecurityIdentity { + val scopes = annotations.firstOrNull { it is FafScopeTest }?.let { it as FafScopeTest }?.value + val roles = annotations.firstOrNull { it is FafRoleTest }?.let { it as FafRoleTest }?.value + val username = annotations.firstOrNull { it is TestSecurity }?.let { it as TestSecurity }?.user val builder = QuarkusSecurityIdentity.builder(identity) builder.addPermissionChecker { requiredPermission -> @@ -23,6 +27,27 @@ class TestFafSecurityAugmentor : TestSecurityIdentityAugmentor { val hasScopes = requiredPermission.actions.split(",").all { scopes?.contains(it) == true } Uni.createFrom().item(hasRole && hasScopes) } + + roles?.let { builder.addRoles(setOf(*it)) } + + builder.setPrincipal(object : JsonWebToken { + override fun getName(): String { + return identity.principal.name + } + + @Suppress("UNCHECKED_CAST") + override fun getClaim(claimName: String): T? { + if (claimName == "ext") { + return mapOf("username" to Json.createValue(username)) as T + } + return null + } + + override fun getClaimNames(): Set { + return setOf("ext") + } + }) + return builder.build() } } diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index ab91aa3..7118b71 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -2,4 +2,5 @@ faf: irc: fixed: users: - test-user: banana \ No newline at end of file + test-user: banana + token-ttl: 1 \ No newline at end of file