Skip to content

Commit

Permalink
Add lobby access endpoint (#177)
Browse files Browse the repository at this point in the history
  • Loading branch information
Sheikah45 authored Oct 14, 2023
1 parent 9b9fa78 commit bf87572
Show file tree
Hide file tree
Showing 10 changed files with 238 additions and 54 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.faforever.userservice.backend.cloudflare

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)
}

/**
* 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: 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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.faforever.userservice.backend.security
object FafRole {
const val ROLE_PREFIX = "ROLE_"

const val USER = "USER"
const val READ_AUDIT_LOG = "READ_AUDIT_LOG"
const val READ_TEAMKILL_REPORT = "READ_TEAMKILL_REPORT"
const val READ_ACCOUNT_PRIVATE_DETAILS = "READ_ACCOUNT_PRIVATE_DETAILS"
Expand Down
15 changes: 15 additions & 0 deletions src/main/kotlin/com/faforever/userservice/config/FafProperties.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package com.faforever.userservice.config

import io.smallrye.config.ConfigMapping
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.NotNull
import java.net.URI
import java.util.*

@ConfigMapping(prefix = "faf")
Expand All @@ -26,4 +28,17 @@ interface FafProperties {

@NotBlank
fun accountLinkUrl(): String

fun lobby(): Lobby

interface Lobby {
@NotBlank
fun secret(): String

@NotBlank
fun accessParam(): String

@NotNull
fun accessUri(): URI
}
}
40 changes: 40 additions & 0 deletions src/main/kotlin/com/faforever/userservice/web/LobbyController.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.faforever.userservice.web

import com.faforever.userservice.backend.cloudflare.CloudflareService
import com.faforever.userservice.backend.security.FafRole
import com.faforever.userservice.backend.security.OAuthScope
import com.faforever.userservice.config.FafProperties
import io.quarkus.security.PermissionsAllowed
import jakarta.enterprise.context.ApplicationScoped
import jakarta.ws.rs.GET
import jakarta.ws.rs.Path
import jakarta.ws.rs.core.UriBuilder
import java.net.URI

@Path("/lobby")
@ApplicationScoped
class LobbyController(
val cloudflareService: CloudflareService,
val fafProperties: FafProperties,
) {

data class LobbyAccess(
val accessUrl: URI,
)

@GET
@Path("/access")
@PermissionsAllowed("${FafRole.USER}:${OAuthScope.LOBBY}")
fun getLobbyAccess(): LobbyAccess {
val lobby = fafProperties.lobby()
val accessUri = lobby.accessUri()
val token = cloudflareService.generateCloudFlareHmacToken(
accessUri,
lobby.secret(),
)

val accessUrl = UriBuilder.fromUri(accessUri).queryParam(lobby.accessParam(), token).build()

return LobbyAccess(accessUrl)
}
}
4 changes: 4 additions & 0 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ faf:
register-account-url: ${REGISTER_ACCOUNT_URL:https://faforever.com/account/register}
account-link-url: ${ACCOUNT_LINK_URL:https://www.faforever.com/account/link}
hydra-base-url: ${HYDRA_BASE_ADMIN_URL:http://localhost:4445}
lobby:
secret: ${LOBBY_SECRET:banana}
access-uri: ${LOBBY_URL:ws://localhost:8003}
access-param: ${LOBBY_PARAM:verify}

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

import com.faforever.userservice.backend.security.PasswordEncoder
import io.quarkus.test.InjectMock
import io.quarkus.test.junit.QuarkusTest
import io.quarkus.test.junit.mockito.InjectMock
import jakarta.inject.Inject
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.instanceOf
Expand All @@ -18,12 +18,12 @@ import java.time.OffsetDateTime
class LoginServiceTest {

companion object {
private const val username = "someUsername"
private const val email = "[email protected]"
private const val password = "somePassword"
private val ipAddress = IpAddress("127.0.0.1")
private const val USERNAME = "someUsername"
private const val EMAIL = "[email protected]"
private const val PASSWORD = "somePassword"
private val IP_ADDRESS = IpAddress("127.0.0.1")

private val user = User(1, username, password, email, null)
private val USER = User(1, USERNAME, PASSWORD, EMAIL, null)
}

@Inject
Expand All @@ -49,7 +49,7 @@ class LoginServiceTest {

@Test
fun loginWithUnknownUser() {
val result = loginService.login(username, password, ipAddress, false)
val result = loginService.login(USERNAME, PASSWORD, IP_ADDRESS, false)
assertThat(result, instanceOf(LoginResult.RecoverableLoginOrCredentialsMismatch::class.java))
}

Expand All @@ -64,72 +64,72 @@ class LoginServiceTest {
),
)

val result = loginService.login(username, password, ipAddress, false)
val result = loginService.login(USERNAME, PASSWORD, IP_ADDRESS, false)
assertThat(result, instanceOf(LoginResult.ThrottlingActive::class.java))
}

@Test
fun loginWithInvalidPassword() {
whenever(userRepository.findByUsernameOrEmail(any())).thenReturn(user)
whenever(userRepository.findByUsernameOrEmail(any())).thenReturn(USER)
whenever(passwordEncoder.matches(anyString(), anyString())).thenReturn(false)

val result = loginService.login(username, password, ipAddress, false)
val result = loginService.login(USERNAME, PASSWORD, IP_ADDRESS, false)
assertThat(result, instanceOf(LoginResult.RecoverableLoginOrCredentialsMismatch::class.java))
}

@Test
fun loginWithBannedUser() {
whenever(userRepository.findByUsernameOrEmail(anyString())).thenReturn(user)
whenever(userRepository.findByUsernameOrEmail(anyString())).thenReturn(USER)
whenever(passwordEncoder.matches(anyString(), anyString())).thenReturn(true)
whenever(banRepository.findGlobalBansByPlayerId(anyInt())).thenReturn(
listOf(
Ban(1, 1, 100, BanLevel.GLOBAL, "test", OffsetDateTime.MAX, null, null, null, null),
),
)

val result = loginService.login(username, password, ipAddress, false)
val result = loginService.login(USERNAME, PASSWORD, IP_ADDRESS, false)
assertThat(result, instanceOf(LoginResult.UserBanned::class.java))
}

@Test
fun loginWithPermaBannedUser() {
whenever(userRepository.findByUsernameOrEmail(anyString())).thenReturn(user)
whenever(userRepository.findByUsernameOrEmail(anyString())).thenReturn(USER)
whenever(passwordEncoder.matches(anyString(), anyString())).thenReturn(true)
whenever(banRepository.findGlobalBansByPlayerId(anyInt())).thenReturn(
listOf(
Ban(1, 1, 100, BanLevel.GLOBAL, "test", null, null, null, null, null),
),
)

val result = loginService.login(username, password, ipAddress, false)
val result = loginService.login(USERNAME, PASSWORD, IP_ADDRESS, false)
assertThat(result, instanceOf(LoginResult.UserBanned::class.java))
}

@Test
fun loginWithLinkedUserRequireOwnership() {
whenever(userRepository.findByUsernameOrEmail(anyString())).thenReturn(user)
whenever(userRepository.findByUsernameOrEmail(anyString())).thenReturn(USER)
whenever(passwordEncoder.matches(anyString(), anyString())).thenReturn(true)
whenever(accountLinkRepository.hasOwnershipLink(anyInt())).thenReturn(true)

val result = loginService.login(username, password, ipAddress, true)
val result = loginService.login(USERNAME, PASSWORD, IP_ADDRESS, true)
assertThat(result, instanceOf(LoginResult.SuccessfulLogin::class.java))
}

@Test
fun loginWithNonLinkedUserRequireOwnership() {
whenever(userRepository.findByUsernameOrEmail(anyString())).thenReturn(user)
whenever(userRepository.findByUsernameOrEmail(anyString())).thenReturn(USER)
whenever(passwordEncoder.matches(anyString(), anyString())).thenReturn(true)

val result = loginService.login(username, password, ipAddress, true)
val result = loginService.login(USERNAME, PASSWORD, IP_ADDRESS, true)
assertThat(result, instanceOf(LoginResult.UserNoGameOwnership::class.java))
}

@Test
fun loginWithNonLinkedUser() {
whenever(userRepository.findByUsernameOrEmail(anyString())).thenReturn(user)
whenever(userRepository.findByUsernameOrEmail(anyString())).thenReturn(USER)
whenever(passwordEncoder.matches(anyString(), anyString())).thenReturn(true)

val result = loginService.login(username, password, ipAddress, false)
val result = loginService.login(USERNAME, PASSWORD, IP_ADDRESS, false)
assertThat(result, instanceOf(LoginResult.SuccessfulLogin::class.java))
}

Expand All @@ -151,10 +151,10 @@ class LoginServiceTest {
),
),
)
whenever(userRepository.findByUsernameOrEmail(anyString())).thenReturn(user)
whenever(userRepository.findByUsernameOrEmail(anyString())).thenReturn(USER)
whenever(passwordEncoder.matches(anyString(), anyString())).thenReturn(true)

val result = loginService.login(username, password, ipAddress, false)
val result = loginService.login(USERNAME, PASSWORD, IP_ADDRESS, false)
assertThat(result, instanceOf(LoginResult.SuccessfulLogin::class.java))
}

Expand All @@ -176,10 +176,10 @@ class LoginServiceTest {
),
),
)
whenever(userRepository.findByUsernameOrEmail(anyString())).thenReturn(user)
whenever(userRepository.findByUsernameOrEmail(anyString())).thenReturn(USER)
whenever(passwordEncoder.matches(anyString(), anyString())).thenReturn(true)

val result = loginService.login(username, password, ipAddress, false)
val result = loginService.login(USERNAME, PASSWORD, IP_ADDRESS, false)
assertThat(result, instanceOf(LoginResult.SuccessfulLogin::class.java))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import com.faforever.userservice.backend.domain.LoginResult
import com.faforever.userservice.backend.domain.LoginService
import com.faforever.userservice.backend.domain.UserRepository
import com.faforever.userservice.backend.security.OAuthScope
import io.quarkus.test.InjectMock
import io.quarkus.test.junit.QuarkusTest
import io.quarkus.test.junit.mockito.InjectMock
import jakarta.inject.Inject
import org.eclipse.microprofile.rest.client.inject.RestClient
import org.hamcrest.MatcherAssert.assertThat
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.faforever.userservice.web

import com.faforever.userservice.backend.cloudflare.CloudflareService
import com.faforever.userservice.backend.security.FafRole
import com.faforever.userservice.backend.security.OAuthScope
import com.faforever.userservice.config.FafProperties
import com.faforever.userservice.web.util.FafRoleTest
import com.faforever.userservice.web.util.FafScopeTest
import io.quarkus.test.common.http.TestHTTPEndpoint
import io.quarkus.test.junit.QuarkusTest
import io.quarkus.test.security.TestSecurity
import io.restassured.RestAssured
import jakarta.inject.Inject
import org.hamcrest.Matchers.matchesRegex
import org.junit.jupiter.api.Test

@QuarkusTest
@TestHTTPEndpoint(LobbyController::class)
class LobbyControllerTest {

@Inject
private lateinit var fafProperties: FafProperties

@Inject
private lateinit var cloudflareService: CloudflareService

@Test
@TestSecurity(authorizationEnabled = false)
fun canRetrieveAccessUrl() {
val lobby = fafProperties.lobby()
RestAssured.given()
.get("/access")
.then()
.statusCode(200)
.body("accessUrl", matchesRegex("${lobby.accessUri()}\\?${lobby.accessParam()}=\\d{10}-.{43,}"))
}

@Test
@TestSecurity(user = "test")
@FafRoleTest([FafRole.USER])
@FafScopeTest([OAuthScope.LOBBY])
fun canRetrieveAccessUrlWithScopeAndRole() {
RestAssured.given()
.get("/access")
.then()
.statusCode(200)
}

@Test
@TestSecurity(user = "test")
@FafScopeTest([OAuthScope.LOBBY])
fun cannotRetrieveAccessUrlWithOnlyScope() {
RestAssured.get("/access").then().statusCode(403)
}

@Test
@TestSecurity(user = "test")
@FafRoleTest([FafRole.USER])
fun cannotRetrieveAccessUrlWithOnlyRole() {
RestAssured.get("/access").then().statusCode(403)
}

@Test
@TestSecurity(user = "test")
fun cannotRetrieveAccessUrlWithNoScopeAndNoRole() {
RestAssured.get("/access").then().statusCode(403)
}

@Test
fun cannotRetrieveAccessUrlUnAuthenticated() {
RestAssured.get("/access").then().statusCode(401)
}
}
Loading

0 comments on commit bf87572

Please sign in to comment.