-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
10 changed files
with
238 additions
and
54 deletions.
There are no files selected for viewing
48 changes: 48 additions & 0 deletions
48
src/main/kotlin/com/faforever/userservice/backend/cloudflare/CloudflareService.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
40 changes: 40 additions & 0 deletions
40
src/main/kotlin/com/faforever/userservice/web/LobbyController.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
|
@@ -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 | ||
|
@@ -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)) | ||
} | ||
|
||
|
@@ -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)) | ||
} | ||
|
||
|
@@ -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)) | ||
} | ||
|
||
|
@@ -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)) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
73 changes: 73 additions & 0 deletions
73
src/test/kotlin/com/faforever/userservice/web/LobbyControllerTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
Oops, something went wrong.