Skip to content

Commit bd691a7

Browse files
authored
Check for steam id if lobby scope is requested (#13)
* Add game ownership check * Add logging of rejection for missing game ownership verification
1 parent ccf9f00 commit bd691a7

File tree

9 files changed

+174
-4
lines changed

9 files changed

+174
-4
lines changed

src/main/kotlin/com/faforever/userservice/config/FafConfig.kt

+2
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,6 @@ data class FafProperties(
1313
val passwordResetUrl: String,
1414
@NotBlank
1515
val registerAccountUrl: String,
16+
@NotBlank
17+
val accountLinkUrl: String,
1618
)

src/main/kotlin/com/faforever/userservice/controller/OAuthController.kt

+11
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.faforever.userservice.config.FafProperties
44
import com.faforever.userservice.domain.LoginResult.LoginThrottlingActive
55
import com.faforever.userservice.domain.LoginResult.SuccessfulLogin
66
import com.faforever.userservice.domain.LoginResult.UserBanned
7+
import com.faforever.userservice.domain.LoginResult.UserNoGameOwnership
78
import com.faforever.userservice.domain.LoginResult.UserOrCredentialsMismatch
89
import com.faforever.userservice.domain.UserService
910
import com.faforever.userservice.hydra.HydraService
@@ -86,6 +87,7 @@ class OAuthController(
8687
when (it) {
8788
is SuccessfulLogin -> redirect(response, it.redirectTo)
8889
is UserBanned -> redirect(response, it.redirectTo)
90+
is UserNoGameOwnership -> redirect(response, it.redirectTo)
8991
is LoginThrottlingActive -> redirect(
9092
response,
9193
UriComponentsBuilder.fromUri(request.uri)
@@ -170,4 +172,13 @@ class OAuthController(
170172
model.addAttribute("banExpiration", expiration)
171173
return Mono.just(Rendering.view("banned").build())
172174
}
175+
176+
@GetMapping("/gameVerificationFailed")
177+
fun showSteamLink(
178+
request: ServerHttpRequest,
179+
model: Model,
180+
): Mono<Rendering> {
181+
model.addAttribute("accountLink", fafProperties.accountLinkUrl)
182+
return Mono.just(Rendering.view("gameVerificationFailed").build())
183+
}
173184
}

src/main/kotlin/com/faforever/userservice/domain/UserService.kt

+25-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.faforever.userservice.domain
22

33
import com.faforever.userservice.hydra.HydraService
4+
import com.faforever.userservice.security.OAuthScope
45
import org.slf4j.Logger
56
import org.slf4j.LoggerFactory
67
import org.springframework.boot.context.properties.ConfigurationProperties
@@ -35,6 +36,7 @@ sealed class LoginResult {
3536
object UserOrCredentialsMismatch : LoginResult()
3637
data class SuccessfulLogin(val redirectTo: String) : LoginResult()
3738
data class UserBanned(val redirectTo: String) : LoginResult()
39+
data class UserNoGameOwnership(val redirectTo: String) : LoginResult()
3840
}
3941

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

5356
/**
5457
* The user role is used to distinguish users from technical accounts.
@@ -125,13 +128,34 @@ class UserService(
125128
.map {
126129
LoginResult.UserBanned(
127130
UriComponentsBuilder.fromUriString("/oauth2/banned")
128-
.queryParam("expiration", if (ban.expiresAt != null) ISO_OFFSET_DATE_TIME.format(ban.expiresAt) else null)
131+
.queryParam(
132+
"expiration",
133+
if (ban.expiresAt != null) ISO_OFFSET_DATE_TIME.format(ban.expiresAt) else null
134+
)
129135
.queryParam("reason", UriUtils.encode(ban.reason, StandardCharsets.UTF_8))
130136
.build()
131137
.toUriString()
132138
)
133139
}
134140
}
141+
.switchIfEmpty {
142+
if (user.steamId == null && loginRequest.requestedScope.contains(OAuthScope.LOBBY)) {
143+
LOG.debug("Lobby login blocked for user '$usernameOrEmail' because of missing game ownership verification")
144+
hydraService.rejectLoginRequest(
145+
challenge,
146+
GenericError(HYDRA_ERROR_NO_OWNERSHIP_VERIFICATION)
147+
)
148+
.map {
149+
LoginResult.UserNoGameOwnership(
150+
UriComponentsBuilder.fromUriString("/oauth2/gameVerificationFailed")
151+
.build()
152+
.toUriString()
153+
)
154+
}
155+
} else {
156+
Mono.empty()
157+
}
158+
}
135159
.switchIfEmpty {
136160
LOG.debug("User '$usernameOrEmail' logged in successfully")
137161

src/main/kotlin/com/faforever/userservice/security/FafScope.kt

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ object OAuthScope {
1818
const val WRITE_ACCOUNT_DATA = "write_account_data"
1919
const val EDIT_CLAN_DATA = "edit_clan_data"
2020
const val VOTE = "vote"
21+
const val LOBBY = "lobby"
2122
const val READ_SENSIBLE_USERDATA = "read_sensible_userdata"
2223
const val ADMINISTRATIVE_ACTION = "administrative_actions"
2324
const val MANAGE_VAULT = "manage_vault"

src/main/resources/META-INF/resources/css/style.css

+4
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ input {
3838
margin: 1rem 0;
3939
}
4040

41+
.error-info {
42+
font-weight: normal;
43+
}
44+
4145
header {
4246
background-color: #111111;
4347
color: white;

src/main/resources/application.yml

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
faf:
22
passwordResetUrl: ${PASSWORD_RESET_URL:https://faforever.com/account/password/reset}
33
registerAccountUrl: ${REGISTER_ACCOUNT_URL:https://faforever.com/account/register}
4+
accountLinkUrl: ${ACCOUNT_LINK_URL:https://www.faforever.com/account/link}
45

56
security:
67
failedLoginAccountThreshold: ${FAILED_LOGIN_ACCOUNT_THRESHOLD:5}

src/main/resources/i18n/messages.properties

+3
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ consent.privacyStatement=Privacy Statement
1414
consent.authorize=Authorize
1515
consent.deny=Deny
1616
consent.clientLogo=Client logo
17+
steam.faq=For more information on why ownership verification is required click here
18+
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
19+
steam.title=Game Ownership Verification Missing
1720
oauth2.scope.textMissing=Translation missing! Technical name: ''{0}''
1821
oauth2.scope.openid=Login via OpenID Connect
1922
oauth2.scope.openid.description=Technically required, does not reveal any data
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<html xmlns:th="http://www.thymeleaf.org">
2+
<head>
3+
<meta charset="UTF-8"/>
4+
<title th:text="#{login.title}">FAForever Login</title>
5+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.2/css/all.min.css"
6+
integrity="sha512-HK5fgLBL+xu6dm/Ii3z4xhlSUyZgTT9tuc/hSrtw6uzJOvgRr2a9jyxxT1ely+B+xFAmJKVSTbpM/CuL7qxO8w=="
7+
crossorigin="anonymous"/>
8+
<link rel="stylesheet" href="../META-INF/resources/css/style.css" th:href="@{/css/style.css}" type="text/css"/>
9+
<link rel="preconnect" href="https://fonts.gstatic.com">
10+
<link href="https://fonts.googleapis.com/css2?family=Source+Sans+Pro&display=swap" rel="stylesheet">
11+
<script type="application/javascript" src="../META-INF/resources/js/bg-switcher.js" th:src="@{/js/bg-switcher.js}"></script>
12+
</head>
13+
<body>
14+
<div class="background"></div>
15+
<header>
16+
<div id="leftheader">
17+
<a href="https://www.faforever.com"><img src="https://faforever.com/images/faf-logo.png" alt="FAForever Logo"></a>
18+
<h1>FAForever Login</h1>
19+
</div>
20+
<div id="rightheader">
21+
<!-- <p>You are logged in as [User]</p>-->
22+
</div>
23+
</header>
24+
<div class="main-card">
25+
<div class="main-card-inner">
26+
<div id="form-header">
27+
<div id="form-header-left"><img src="https://faforever.com/images/faf-logo.png" alt="FAForever Logo"></div>
28+
<h2 id="form-header-right" th:text="#{steam.title}">Game Ownership Verification Missing</h2>
29+
</div>
30+
31+
<div class="error">
32+
<div>
33+
<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>
34+
<a href="https://www.faforever.com/account/link" th:text="${accountLink}"> https://www.faforever.com/account/link </a>
35+
</div>
36+
</div>
37+
</div>
38+
39+
<footer>
40+
<a th:text="#{steam.faq}" href="https://forum.faforever.com/topic/252/why-do-i-need-to-link-my-account-to-steam">
41+
For more information on why ownership verification is required click here
42+
</a>
43+
</footer>
44+
</div>
45+
</body>
46+
</html>

src/test/kotlin/com/faforever/userservice/UserServiceApplicationTests.kt

+81-3
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ class UserServiceApplicationTests {
6464
private const val hydraRedirectUrl = "someHydraRedirectUrl"
6565
private val revokeRequest = RevokeRefreshTokensRequest("1", null, true)
6666

67-
private val user = User(1, username, password, email, null, null)
67+
private val user = User(1, username, password, email, null, 0)
6868
private val mockServer = ClientAndServer(mockServerPort)
6969

7070
@JvmStatic
@@ -263,6 +263,84 @@ class UserServiceApplicationTests {
263263
verify(banRepository).findAllByPlayerIdAndLevel(anyLong(), anyOrNull())
264264
}
265265

266+
@Test
267+
fun postLoginWithNonLinkedUserWithLobbyScope() {
268+
val unlinkedUser = User(1, username, password, email, null, null)
269+
`when`(userRepository.findByUsernameOrEmail(username, username)).thenReturn(Mono.just(unlinkedUser))
270+
`when`(passwordEncoder.matches(password, password)).thenReturn(true)
271+
`when`(loginLogRepository.findFailedAttemptsByIp(anyString()))
272+
.thenReturn(Mono.just(FailedAttemptsSummary(null, null, null, null)))
273+
`when`(loginLogRepository.save(anyOrNull()))
274+
.thenAnswer { Mono.just(it.arguments[0]) }
275+
`when`(banRepository.findAllByPlayerIdAndLevel(anyLong(), anyOrNull())).thenReturn(
276+
Flux.empty()
277+
)
278+
279+
mockLoginRequest(scopes = listOf(OAuthScope.LOBBY))
280+
mockLoginReject()
281+
282+
webTestClient
283+
.mutateWith(csrf())
284+
.post()
285+
.uri("/oauth2/login?login_challenge=$challenge")
286+
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
287+
.body(
288+
BodyInserters.fromFormData("login_challenge", challenge)
289+
.with("usernameOrEmail", username)
290+
.with("password", password)
291+
)
292+
.exchange()
293+
.expectStatus().is3xxRedirection
294+
.expectHeader()
295+
.location("/oauth2/gameVerificationFailed")
296+
.expectBody(String::class.java)
297+
298+
verify(userRepository).findByUsernameOrEmail(username, username)
299+
verify(passwordEncoder).matches(password, password)
300+
verify(loginLogRepository).findFailedAttemptsByIp(anyString())
301+
verify(loginLogRepository).save(anyOrNull())
302+
verify(banRepository).findAllByPlayerIdAndLevel(anyLong(), anyOrNull())
303+
}
304+
305+
@Test
306+
fun postLoginWithNonLinkedUserWithoutLobbyScope() {
307+
val unlinkedUser = User(1, username, password, email, null, null)
308+
`when`(userRepository.findByUsernameOrEmail(username, username)).thenReturn(Mono.just(unlinkedUser))
309+
`when`(passwordEncoder.matches(password, password)).thenReturn(true)
310+
`when`(loginLogRepository.findFailedAttemptsByIp(anyString()))
311+
.thenReturn(Mono.just(FailedAttemptsSummary(null, null, null, null)))
312+
`when`(loginLogRepository.save(anyOrNull()))
313+
.thenAnswer { Mono.just(it.arguments[0]) }
314+
`when`(banRepository.findAllByPlayerIdAndLevel(anyLong(), anyOrNull())).thenReturn(
315+
Flux.empty()
316+
)
317+
318+
mockLoginRequest()
319+
mockLoginAccept()
320+
321+
webTestClient
322+
.mutateWith(csrf())
323+
.post()
324+
.uri("/oauth2/login?login_challenge=$challenge")
325+
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
326+
.body(
327+
BodyInserters.fromFormData("login_challenge", challenge)
328+
.with("usernameOrEmail", username)
329+
.with("password", password)
330+
)
331+
.exchange()
332+
.expectStatus().is3xxRedirection
333+
.expectHeader()
334+
.location(hydraRedirectUrl)
335+
.expectBody(String::class.java)
336+
337+
verify(userRepository).findByUsernameOrEmail(username, username)
338+
verify(passwordEncoder).matches(password, password)
339+
verify(loginLogRepository).findFailedAttemptsByIp(anyString())
340+
verify(loginLogRepository).save(anyOrNull())
341+
verify(banRepository).findAllByPlayerIdAndLevel(anyLong(), anyOrNull())
342+
}
343+
266344
@Test
267345
fun postLoginWithUnbannedUser() {
268346
`when`(userRepository.findByUsernameOrEmail(username, username)).thenReturn(Mono.just(user))
@@ -429,7 +507,7 @@ class UserServiceApplicationTests {
429507
.expectBody(String::class.java)
430508
}
431509

432-
private fun mockLoginRequest() {
510+
private fun mockLoginRequest(scopes: List<String> = listOf()) {
433511
mockServer.`when`(
434512
HttpRequest.request()
435513
.withMethod("GET")
@@ -446,7 +524,7 @@ class UserServiceApplicationTests {
446524
"client": {},
447525
"request_url": "someRequestUrl",
448526
"requested_access_token_audience": [],
449-
"requested_scope": [],
527+
"requested_scope": [${scopes.joinToString("\",\"", "\"", "\"")}],
450528
"skip": false,
451529
"subject": "1"
452530
}

0 commit comments

Comments
 (0)