Skip to content

Commit

Permalink
Check for steam id if lobby scope is requested (#13)
Browse files Browse the repository at this point in the history
* Add game ownership check

* Add logging of rejection for missing game ownership verification
  • Loading branch information
Sheikah45 committed Aug 27, 2021
1 parent ccf9f00 commit bd691a7
Show file tree
Hide file tree
Showing 9 changed files with 174 additions and 4 deletions.
2 changes: 2 additions & 0 deletions src/main/kotlin/com/faforever/userservice/config/FafConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ data class FafProperties(
val passwordResetUrl: String,
@NotBlank
val registerAccountUrl: String,
@NotBlank
val accountLinkUrl: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.faforever.userservice.config.FafProperties
import com.faforever.userservice.domain.LoginResult.LoginThrottlingActive
import com.faforever.userservice.domain.LoginResult.SuccessfulLogin
import com.faforever.userservice.domain.LoginResult.UserBanned
import com.faforever.userservice.domain.LoginResult.UserNoGameOwnership
import com.faforever.userservice.domain.LoginResult.UserOrCredentialsMismatch
import com.faforever.userservice.domain.UserService
import com.faforever.userservice.hydra.HydraService
Expand Down Expand Up @@ -86,6 +87,7 @@ class OAuthController(
when (it) {
is SuccessfulLogin -> redirect(response, it.redirectTo)
is UserBanned -> redirect(response, it.redirectTo)
is UserNoGameOwnership -> redirect(response, it.redirectTo)
is LoginThrottlingActive -> redirect(
response,
UriComponentsBuilder.fromUri(request.uri)
Expand Down Expand Up @@ -170,4 +172,13 @@ class OAuthController(
model.addAttribute("banExpiration", expiration)
return Mono.just(Rendering.view("banned").build())
}

@GetMapping("/gameVerificationFailed")
fun showSteamLink(
request: ServerHttpRequest,
model: Model,
): Mono<Rendering> {
model.addAttribute("accountLink", fafProperties.accountLinkUrl)
return Mono.just(Rendering.view("gameVerificationFailed").build())
}
}
26 changes: 25 additions & 1 deletion src/main/kotlin/com/faforever/userservice/domain/UserService.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.faforever.userservice.domain

import com.faforever.userservice.hydra.HydraService
import com.faforever.userservice.security.OAuthScope
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.boot.context.properties.ConfigurationProperties
Expand Down Expand Up @@ -35,6 +36,7 @@ sealed class LoginResult {
object UserOrCredentialsMismatch : LoginResult()
data class SuccessfulLogin(val redirectTo: String) : LoginResult()
data class UserBanned(val redirectTo: String) : LoginResult()
data class UserNoGameOwnership(val redirectTo: String) : LoginResult()
}

@Component
Expand All @@ -49,6 +51,7 @@ class UserService(
companion object {
private val LOG: Logger = LoggerFactory.getLogger(UserService::class.java)
private const val HYDRA_ERROR_USER_BANNED = "user_banned"
private const val HYDRA_ERROR_NO_OWNERSHIP_VERIFICATION = "ownership_not_verified"

/**
* The user role is used to distinguish users from technical accounts.
Expand Down Expand Up @@ -125,13 +128,34 @@ class UserService(
.map {
LoginResult.UserBanned(
UriComponentsBuilder.fromUriString("/oauth2/banned")
.queryParam("expiration", if (ban.expiresAt != null) ISO_OFFSET_DATE_TIME.format(ban.expiresAt) else null)
.queryParam(
"expiration",
if (ban.expiresAt != null) ISO_OFFSET_DATE_TIME.format(ban.expiresAt) else null
)
.queryParam("reason", UriUtils.encode(ban.reason, StandardCharsets.UTF_8))
.build()
.toUriString()
)
}
}
.switchIfEmpty {
if (user.steamId == null && loginRequest.requestedScope.contains(OAuthScope.LOBBY)) {
LOG.debug("Lobby login blocked for user '$usernameOrEmail' because of missing game ownership verification")
hydraService.rejectLoginRequest(
challenge,
GenericError(HYDRA_ERROR_NO_OWNERSHIP_VERIFICATION)
)
.map {
LoginResult.UserNoGameOwnership(
UriComponentsBuilder.fromUriString("/oauth2/gameVerificationFailed")
.build()
.toUriString()
)
}
} else {
Mono.empty()
}
}
.switchIfEmpty {
LOG.debug("User '$usernameOrEmail' logged in successfully")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ object OAuthScope {
const val WRITE_ACCOUNT_DATA = "write_account_data"
const val EDIT_CLAN_DATA = "edit_clan_data"
const val VOTE = "vote"
const val LOBBY = "lobby"
const val READ_SENSIBLE_USERDATA = "read_sensible_userdata"
const val ADMINISTRATIVE_ACTION = "administrative_actions"
const val MANAGE_VAULT = "manage_vault"
Expand Down
4 changes: 4 additions & 0 deletions src/main/resources/META-INF/resources/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ input {
margin: 1rem 0;
}

.error-info {
font-weight: normal;
}

header {
background-color: #111111;
color: white;
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
faf:
passwordResetUrl: ${PASSWORD_RESET_URL:https://faforever.com/account/password/reset}
registerAccountUrl: ${REGISTER_ACCOUNT_URL:https://faforever.com/account/register}
accountLinkUrl: ${ACCOUNT_LINK_URL:https://www.faforever.com/account/link}

security:
failedLoginAccountThreshold: ${FAILED_LOGIN_ACCOUNT_THRESHOLD:5}
Expand Down
3 changes: 3 additions & 0 deletions src/main/resources/i18n/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ consent.privacyStatement=Privacy Statement
consent.authorize=Authorize
consent.deny=Deny
consent.clientLogo=Client logo
steam.faq=For more information on why ownership verification is required click here
steam.reason=In order to play on FAForever, we need to verify that you own a legal copy of Supreme Commander Forged Alliance. You can verify ownership on the website at
steam.title=Game Ownership Verification Missing
oauth2.scope.textMissing=Translation missing! Technical name: ''{0}''
oauth2.scope.openid=Login via OpenID Connect
oauth2.scope.openid.description=Technically required, does not reveal any data
Expand Down
46 changes: 46 additions & 0 deletions src/main/resources/templates/gameVerificationFailed.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8"/>
<title th:text="#{login.title}">FAForever Login</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.2/css/all.min.css"
integrity="sha512-HK5fgLBL+xu6dm/Ii3z4xhlSUyZgTT9tuc/hSrtw6uzJOvgRr2a9jyxxT1ely+B+xFAmJKVSTbpM/CuL7qxO8w=="
crossorigin="anonymous"/>
<link rel="stylesheet" href="../META-INF/resources/css/style.css" th:href="@{/css/style.css}" type="text/css"/>
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css2?family=Source+Sans+Pro&display=swap" rel="stylesheet">
<script type="application/javascript" src="../META-INF/resources/js/bg-switcher.js" th:src="@{/js/bg-switcher.js}"></script>
</head>
<body>
<div class="background"></div>
<header>
<div id="leftheader">
<a href="https://www.faforever.com"><img src="https://faforever.com/images/faf-logo.png" alt="FAForever Logo"></a>
<h1>FAForever Login</h1>
</div>
<div id="rightheader">
<!-- <p>You are logged in as [User]</p>-->
</div>
</header>
<div class="main-card">
<div class="main-card-inner">
<div id="form-header">
<div id="form-header-left"><img src="https://faforever.com/images/faf-logo.png" alt="FAForever Logo"></div>
<h2 id="form-header-right" th:text="#{steam.title}">Game Ownership Verification Missing</h2>
</div>

<div class="error">
<div>
<div class="error-info" th:text="#{steam.reason}">In order to play on FAForever, we need to verify that you own a legal copy of Supreme Commander Forged Alliance. You can verify ownership on the website at</div>
<a href="https://www.faforever.com/account/link" th:text="${accountLink}"> https://www.faforever.com/account/link </a>
</div>
</div>
</div>

<footer>
<a th:text="#{steam.faq}" href="https://forum.faforever.com/topic/252/why-do-i-need-to-link-my-account-to-steam">
For more information on why ownership verification is required click here
</a>
</footer>
</div>
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ class UserServiceApplicationTests {
private const val hydraRedirectUrl = "someHydraRedirectUrl"
private val revokeRequest = RevokeRefreshTokensRequest("1", null, true)

private val user = User(1, username, password, email, null, null)
private val user = User(1, username, password, email, null, 0)
private val mockServer = ClientAndServer(mockServerPort)

@JvmStatic
Expand Down Expand Up @@ -263,6 +263,84 @@ class UserServiceApplicationTests {
verify(banRepository).findAllByPlayerIdAndLevel(anyLong(), anyOrNull())
}

@Test
fun postLoginWithNonLinkedUserWithLobbyScope() {
val unlinkedUser = User(1, username, password, email, null, null)
`when`(userRepository.findByUsernameOrEmail(username, username)).thenReturn(Mono.just(unlinkedUser))
`when`(passwordEncoder.matches(password, password)).thenReturn(true)
`when`(loginLogRepository.findFailedAttemptsByIp(anyString()))
.thenReturn(Mono.just(FailedAttemptsSummary(null, null, null, null)))
`when`(loginLogRepository.save(anyOrNull()))
.thenAnswer { Mono.just(it.arguments[0]) }
`when`(banRepository.findAllByPlayerIdAndLevel(anyLong(), anyOrNull())).thenReturn(
Flux.empty()
)

mockLoginRequest(scopes = listOf(OAuthScope.LOBBY))
mockLoginReject()

webTestClient
.mutateWith(csrf())
.post()
.uri("/oauth2/login?login_challenge=$challenge")
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.body(
BodyInserters.fromFormData("login_challenge", challenge)
.with("usernameOrEmail", username)
.with("password", password)
)
.exchange()
.expectStatus().is3xxRedirection
.expectHeader()
.location("/oauth2/gameVerificationFailed")
.expectBody(String::class.java)

verify(userRepository).findByUsernameOrEmail(username, username)
verify(passwordEncoder).matches(password, password)
verify(loginLogRepository).findFailedAttemptsByIp(anyString())
verify(loginLogRepository).save(anyOrNull())
verify(banRepository).findAllByPlayerIdAndLevel(anyLong(), anyOrNull())
}

@Test
fun postLoginWithNonLinkedUserWithoutLobbyScope() {
val unlinkedUser = User(1, username, password, email, null, null)
`when`(userRepository.findByUsernameOrEmail(username, username)).thenReturn(Mono.just(unlinkedUser))
`when`(passwordEncoder.matches(password, password)).thenReturn(true)
`when`(loginLogRepository.findFailedAttemptsByIp(anyString()))
.thenReturn(Mono.just(FailedAttemptsSummary(null, null, null, null)))
`when`(loginLogRepository.save(anyOrNull()))
.thenAnswer { Mono.just(it.arguments[0]) }
`when`(banRepository.findAllByPlayerIdAndLevel(anyLong(), anyOrNull())).thenReturn(
Flux.empty()
)

mockLoginRequest()
mockLoginAccept()

webTestClient
.mutateWith(csrf())
.post()
.uri("/oauth2/login?login_challenge=$challenge")
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.body(
BodyInserters.fromFormData("login_challenge", challenge)
.with("usernameOrEmail", username)
.with("password", password)
)
.exchange()
.expectStatus().is3xxRedirection
.expectHeader()
.location(hydraRedirectUrl)
.expectBody(String::class.java)

verify(userRepository).findByUsernameOrEmail(username, username)
verify(passwordEncoder).matches(password, password)
verify(loginLogRepository).findFailedAttemptsByIp(anyString())
verify(loginLogRepository).save(anyOrNull())
verify(banRepository).findAllByPlayerIdAndLevel(anyLong(), anyOrNull())
}

@Test
fun postLoginWithUnbannedUser() {
`when`(userRepository.findByUsernameOrEmail(username, username)).thenReturn(Mono.just(user))
Expand Down Expand Up @@ -429,7 +507,7 @@ class UserServiceApplicationTests {
.expectBody(String::class.java)
}

private fun mockLoginRequest() {
private fun mockLoginRequest(scopes: List<String> = listOf()) {
mockServer.`when`(
HttpRequest.request()
.withMethod("GET")
Expand All @@ -446,7 +524,7 @@ class UserServiceApplicationTests {
"client": {},
"request_url": "someRequestUrl",
"requested_access_token_audience": [],
"requested_scope": [],
"requested_scope": [${scopes.joinToString("\",\"", "\"", "\"")}],
"skip": false,
"subject": "1"
}
Expand Down

0 comments on commit bd691a7

Please sign in to comment.