Skip to content

Commit 7ada576

Browse files
Enable linking to GOG.com (#500)
Co-authored-by: Brutus5000 <Brutus5000@gmx.net>
1 parent adb52a1 commit 7ada576

12 files changed

Lines changed: 496 additions & 22 deletions

File tree

build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,8 @@ dependencies {
243243
implementation("org.apache.commons:commons-compress:${commonsCompressVersion}")
244244
implementation("org.json:json:${jsonVersion}")
245245

246+
implementation("org.jsoup:jsoup:${jsoupVersion}")
247+
246248
implementation("com.github.jasminb:jsonapi-converter:${jsonapiConverterVersion}")
247249
runtimeOnly("mysql:mysql-connector-java:${mysqlConnectorVersion}")
248250

gradle.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ jsonapiConverterVersion=0.11
2525
codacyCoverageReporterVersion=6.2.0
2626
jsonVersion=20210307
2727
testContainersVersion=1.15.2
28+
jsoupVersion=1.14.2

src/inttest/java/com/faforever/api/user/UsersControllerTest.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import static org.mockito.Mockito.when;
3535
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
3636
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
37+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
3738
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
3839
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrlPattern;
3940
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -52,6 +53,9 @@ public class UsersControllerTest extends AbstractIntegrationTest {
5253
@MockBean
5354
private SteamService steamService;
5455

56+
@MockBean
57+
private GogService gogService;
58+
5559
@Autowired
5660
private FafTokenService fafTokenService;
5761

@@ -497,4 +501,48 @@ public void resyncAccountSuccess() throws Exception {
497501
.with(getOAuthTokenWithoutUser(OAuthScope._WRITE_ACCOUNT_DATA)))
498502
.andExpect(status().isOk());
499503
}
504+
505+
@Test
506+
@WithUserDetails(AUTH_USER)
507+
public void buildGogProfileToken() throws Exception {
508+
MultiValueMap<String, String> params = new HttpHeaders();
509+
params.add("gogUsername", "someUsername");
510+
when(gogService.buildGogToken(any())).thenReturn("theToken");
511+
512+
mockMvc.perform(
513+
get("/users/buildGogProfileToken")
514+
.with(getOAuthTokenWithoutUser(OAuthScope._WRITE_ACCOUNT_DATA))
515+
.params(params))
516+
.andExpect(status().isOk())
517+
.andExpect(jsonPath("$.gogToken", is("theToken")));
518+
519+
verify(gogService).buildGogToken(any());
520+
}
521+
522+
@Test
523+
@WithUserDetails(AUTH_USER)
524+
public void linkToGogWithoutOAuthScopeFails() throws Exception {
525+
MultiValueMap<String, String> params = new HttpHeaders();
526+
params.add("gogUsername", "someUsername");
527+
528+
mockMvc.perform(
529+
post("/users/linkToGog")
530+
.params(params))
531+
.andExpect(status().isForbidden());
532+
}
533+
534+
@Test
535+
@WithUserDetails(AUTH_USER)
536+
public void linkToGogWithoutSuccess() throws Exception {
537+
MultiValueMap<String, String> params = new HttpHeaders();
538+
params.add("gogUsername", "someUsername");
539+
540+
when(gogService.buildGogToken(any())).thenReturn("theToken");
541+
542+
mockMvc.perform(
543+
post("/users/linkToGog")
544+
.with(getOAuthTokenWithoutUser(OAuthScope._WRITE_ACCOUNT_DATA))
545+
.params(params))
546+
.andExpect(status().isOk());
547+
}
500548
}

src/main/java/com/faforever/api/config/FafApiProperties.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public class FafApiProperties {
2929
private Registration registration = new Registration();
3030
private PasswordReset passwordReset = new PasswordReset();
3131
private Steam steam = new Steam();
32+
private Gog gog = new Gog();
3233
private Mail mail = new Mail();
3334
private Challonge challonge = new Challonge();
3435
private User user = new User();
@@ -201,6 +202,13 @@ public static class Steam {
201202
private String steamPasswordResetRedirectUrlFormat;
202203
}
203204

205+
@Data
206+
public static class Gog {
207+
private String tokenFormat;
208+
private String profilePageUrl;
209+
private String gamesListUrl;
210+
}
211+
204212
@Data
205213
public static class Challonge {
206214
private String baseUrl = "https://api.challonge.com";

src/main/java/com/faforever/api/error/ErrorCode.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ public enum ErrorCode {
8989
CAN_NOT_REVEAL_RESULTS_WHEN_VOTING_IS_NOT_FINISHED(179, "Vote still ongoing", "You can reveal results when voting is ongoing. Please set reveal results only after voting finished."),
9090
VOTING_SUBJECT_DOES_NOT_EXIST(180, "Voting subject does not exist", "There is no voting subject with the ID ''{0}''."),
9191
VOTING_CHOICE_DOES_NOT_EXIST(181, "Invalid choice", "There is no voting choice with the ID ''{0}''."),
92-
STEAM_ID_ALREADY_LINKED(182, " Steam account already linked to a FAF account", "You linked this account already to user with name ''{0}''."),
92+
STEAM_ID_ALREADY_LINKED(182, "Steam account already linked to a FAF account", "You linked this account already to user with name ''{0}''."),
9393
MAP_NAME_INVALID_CHARACTER(183, "Map name invalid", "Only latin characters, numbers, blanks and the minus are allowed."),
9494
MOD_NAME_INVALID(184, "Mod name invalid", "The name of the mod in the scenario file can only contain printable ASCII characters and blanks."),
9595
MAP_NAME_INVALID_MINUS_OCCURENCE(185, "Map name invalid", "Only a maximum of {0} minus characters are allowed."),
@@ -101,7 +101,16 @@ public enum ErrorCode {
101101
INVALID_FEATURED_MOD(191, "Invalid featured mod name", "The featured mod name ''{0}'' is not allowed in this context."),
102102
API_KEY_INVALID(192, "Api key is invalid", "The api key is invalid."),
103103
UNKNOWN_STEAM_ID(193, "Unable to resolve steam id", "The Steam ID ''{0}'' does not match any account."),
104-
RECAPTCHA_VALIDATION_FAILED(194, "Recaptcha validation failed", "The response code from the recaptcha did not pass the verification.");
104+
RECAPTCHA_VALIDATION_FAILED(194, "Recaptcha validation failed", "The response code from the recaptcha did not pass the verification."),
105+
GOG_ID_UNCHANGEABLE(195, "Linking to GOG failed", "Your Account is already linked to GOG. Just login with our client and start playing."),
106+
GOG_LINK_GAMES_NOT_PUBLIC(196, "Linking to GOG failed", "Your games list is not public."),
107+
GOG_LINK_PROFILE_NOT_PUBLIC(197, "Linking to GOG failed", "Your profile is not public."),
108+
GOG_LINK_PROFILE_TOKEN_NOT_SET(198, "Linking to GOG failed", "The link token couldn't be found on your profile. Make sure to set your status."),
109+
GOG_LINK_NO_FA_GAME(199, "Linking to GOG failed", "You do not own Forged Alliance on GOG."),
110+
GOG_LINK_USER_NOT_FOUND(200, "Linking to GOG failed", "GOG user account not found."),
111+
GOG_LINK_INVALID_USERNAME(201, "Linking to GOG failed", "The specified username is invalid."),
112+
GOG_LINK_INTERNAL_SERVER_ERROR(202, "Linking to GOG failed", "An internal server error occured while attempting to link accounts. Please contact our tech support."),
113+
GOG_ID_ALREADY_LINKED(203, "GOG account already linked to a FAF account", "You linked this account already to user with name ''{0}''.");
105114

106115

107116
private final int code;
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package com.faforever.api.user;
2+
3+
import com.faforever.api.config.FafApiProperties;
4+
import com.faforever.api.data.domain.User;
5+
import com.faforever.api.error.ApiException;
6+
import com.faforever.api.error.ErrorCode;
7+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
8+
import com.fasterxml.jackson.annotation.JsonProperty;
9+
import lombok.RequiredArgsConstructor;
10+
import lombok.extern.slf4j.Slf4j;
11+
import org.jetbrains.annotations.NotNull;
12+
import org.jsoup.Jsoup;
13+
import org.jsoup.nodes.Document;
14+
import org.jsoup.nodes.Element;
15+
import org.springframework.http.HttpStatus;
16+
import org.springframework.stereotype.Service;
17+
import org.springframework.web.client.HttpClientErrorException;
18+
import org.springframework.web.client.RestTemplate;
19+
20+
import java.util.List;
21+
import java.util.Objects;
22+
import java.util.regex.Matcher;
23+
import java.util.regex.Pattern;
24+
25+
@Service
26+
@Slf4j
27+
@RequiredArgsConstructor
28+
public class GogService {
29+
private static final Pattern GOG_USERNAME_PATTERN = Pattern.compile("[a-zA-Z\\d-_!?.]+");
30+
private static final Pattern PROFILE_USER_STATUS_PATTERN = Pattern.compile("window\\.profilesData\\.profileUserPreferences\\s=\\s\\{\"bio\":\"(.*?)\"");
31+
static final String GOG_FA_GAME_ID = "1444785261";
32+
33+
private final FafApiProperties properties;
34+
private final RestTemplate restTemplate;
35+
36+
public String buildGogToken(User user) {
37+
return String.format(properties.getGog().getTokenFormat(), user.getId());
38+
}
39+
40+
void verifyGogUsername(@NotNull String username) {
41+
boolean isValid = username.length() <= 30 &&
42+
username.length() >= 3 &&
43+
GOG_USERNAME_PATTERN.matcher(username).matches();
44+
45+
if(!isValid) {
46+
throw ApiException.of(ErrorCode.GOG_LINK_INVALID_USERNAME);
47+
}
48+
}
49+
50+
void verifyProfileToken(String gogUsername, User user, String targetToken) {
51+
String profileStatus = getProfileStatus(gogUsername);
52+
53+
if(profileStatus.length() > 100 || !Objects.equals(targetToken, profileStatus.trim())) {
54+
throw ApiException.of(ErrorCode.GOG_LINK_PROFILE_TOKEN_NOT_SET);
55+
}
56+
}
57+
58+
private String getProfileStatus(String gogUsername) {
59+
String profilePageUrl = String.format(properties.getGog().getProfilePageUrl(), gogUsername);
60+
String profilePageHtml;
61+
62+
try {
63+
profilePageHtml = restTemplate.getForObject(profilePageUrl, String.class);
64+
} catch (HttpClientErrorException e) {
65+
if (e.getStatusCode() == HttpStatus.NOT_FOUND) {
66+
throw ApiException.of(ErrorCode.GOG_LINK_PROFILE_NOT_PUBLIC);
67+
} else {
68+
log.error("Couldn't retrieve gog profile page for {}", profilePageUrl);
69+
throw ApiException.of(ErrorCode.GOG_LINK_INTERNAL_SERVER_ERROR);
70+
}
71+
}
72+
73+
Document document = Jsoup.parse(profilePageHtml);
74+
for (Element element : document.body().getElementsByTag("script")) {
75+
String scriptText = element.data();
76+
Matcher matcher = PROFILE_USER_STATUS_PATTERN.matcher(scriptText);
77+
78+
if (matcher.find()) {
79+
return matcher.group(1);
80+
}
81+
}
82+
83+
throw ApiException.of(ErrorCode.GOG_LINK_PROFILE_NOT_PUBLIC);
84+
}
85+
86+
void verifyGameOwnership(String gogUsername) {
87+
int numberOfPages = 1;
88+
89+
for (int i = 1; i <= numberOfPages; i++) {
90+
GogGamesListPage nextPage = getGamesListPage(gogUsername, i);
91+
boolean gameFound = nextPage.embeddedGames().items().stream()
92+
.anyMatch(game -> Objects.equals(game.game.id, GOG_FA_GAME_ID));
93+
94+
if (gameFound) {
95+
return;
96+
}
97+
98+
numberOfPages = nextPage.pages();
99+
}
100+
101+
throw ApiException.of(ErrorCode.GOG_LINK_NO_FA_GAME);
102+
}
103+
104+
105+
private GogGamesListPage getGamesListPage(String gogUsername, int page) {
106+
String gamesListUrl = String.format(properties.getGog().getGamesListUrl(), gogUsername, page);
107+
108+
try {
109+
return restTemplate.getForObject(gamesListUrl, GogGamesListPage.class);
110+
} catch (HttpClientErrorException e) {
111+
throw switch (e.getStatusCode()) {
112+
case FORBIDDEN -> ApiException.of(ErrorCode.GOG_LINK_GAMES_NOT_PUBLIC);
113+
case NOT_FOUND -> ApiException.of(ErrorCode.GOG_LINK_USER_NOT_FOUND);
114+
default -> e;
115+
};
116+
}
117+
}
118+
119+
@JsonIgnoreProperties(ignoreUnknown = true)
120+
static record GogGamesListPage(int pages, @JsonProperty("_embedded") GogGamesListEmbeddedList embeddedGames) {
121+
}
122+
123+
@JsonIgnoreProperties(ignoreUnknown = true)
124+
static record GogGamesListEmbeddedList(List<GogGamesListEntry> items) {
125+
}
126+
127+
@JsonIgnoreProperties(ignoreUnknown = true)
128+
static record GogGamesListEntry(GogGamesListEntryGameDetails game) {
129+
130+
}
131+
132+
@JsonIgnoreProperties(ignoreUnknown = true)
133+
static record GogGamesListEntryGameDetails(String id) {
134+
}
135+
136+
}

src/main/java/com/faforever/api/user/UserRepository.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ public interface UserRepository extends JpaRepository<User, Integer> {
1616

1717
Optional<User> findOneByLoginOrEmail(String login, String email);
1818

19+
Optional<User> findOneByGogId(String gogId);
20+
1921
boolean existsByEmail(String email);
2022

2123
boolean existsByLogin(String login);

src/main/java/com/faforever/api/user/UserService.java

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
import com.google.common.hash.Hashing;
2020
import io.micrometer.core.instrument.Counter;
2121
import io.micrometer.core.instrument.MeterRegistry;
22-
import lombok.Value;
2322
import lombok.extern.slf4j.Slf4j;
23+
import org.jetbrains.annotations.NotNull;
2424
import org.springframework.context.ApplicationEventPublisher;
2525
import org.springframework.security.core.Authentication;
2626
import org.springframework.stereotype.Service;
@@ -45,7 +45,6 @@ public class UserService {
4545
static final String KEY_EMAIL = "email";
4646
static final String KEY_USER_ID = "id";
4747
static final String KEY_STEAM_CALLBACK_URL = "callbackUrl";
48-
static final String KEY_PASSWORD = "password";
4948

5049
private static final Pattern USERNAME_PATTERN = Pattern.compile("[A-Za-z][A-Za-z0-9_-]{2,15}$");
5150
private static final String USER_REGISTRATIONS_COUNT = "user.registrations.count";
@@ -62,6 +61,7 @@ public class UserService {
6261
private final AnopeUserRepository anopeUserRepository;
6362
private final FafTokenService fafTokenService;
6463
private final SteamService steamService;
64+
private final GogService gogService;
6565
private final GlobalRatingRepository globalRatingRepository;
6666
private final Ladder1v1RatingRepository ladder1v1RatingRepository;
6767
private final MeterRegistry meterRegistry;
@@ -87,6 +87,7 @@ public UserService(EmailService emailService,
8787
AnopeUserRepository anopeUserRepository,
8888
FafTokenService fafTokenService,
8989
SteamService steamService,
90+
GogService gogService,
9091
GlobalRatingRepository globalRatingRepository,
9192
Ladder1v1RatingRepository ladder1v1RatingRepository,
9293
MeterRegistry meterRegistry,
@@ -99,6 +100,7 @@ public UserService(EmailService emailService,
99100
this.anopeUserRepository = anopeUserRepository;
100101
this.fafTokenService = fafTokenService;
101102
this.steamService = steamService;
103+
this.gogService = gogService;
102104
this.globalRatingRepository = globalRatingRepository;
103105
this.ladder1v1RatingRepository = ladder1v1RatingRepository;
104106
this.eventPublisher = eventPublisher;
@@ -205,7 +207,7 @@ void activate(String registrationToken, String password, String ipAddress) {
205207
.setDeviation(deviation));
206208
// <<<
207209

208-
log.debug("User has been activated: {}", user);
210+
log.info("User has been activated: {}", user);
209211

210212
broadcastUserChange(user);
211213
userActivationCounter.increment();
@@ -248,7 +250,7 @@ private void internalChangeLogin(String newLogin, User user, String ipAddress, b
248250
});
249251

250252
}
251-
log.debug("Changing username for user ''{}'' to ''{}'', forced:''{}''", user.getLogin(), newLogin, force);
253+
log.info("Changing username for user ''{}'' to ''{}'', forced:''{}''", user.getLogin(), newLogin, force);
252254
NameRecord nameRecord = new NameRecord()
253255
.setName(user.getLogin())
254256
.setPlayer(playerRepository.getOne(user.getId()));
@@ -423,9 +425,29 @@ public CallbackResult linkToSteam(String token, String steamId) {
423425
return new CallbackResult(callbackUrl, errors);
424426
}
425427

426-
@Value
427-
static class CallbackResult {
428-
String callbackUrl;
429-
List<Error> errors;
428+
@Transactional
429+
public void linkToGogAccount(@NotNull String gogUsername, @NotNull User user) {
430+
log.debug("Verifying and attempting to link user {} to gog account {}", user.getId(), gogUsername);
431+
432+
if (user.getGogId() != null) {
433+
throw ApiException.of(ErrorCode.GOG_ID_UNCHANGEABLE);
434+
}
435+
436+
gogService.verifyGogUsername(gogUsername);
437+
gogService.verifyProfileToken(gogUsername, user, gogService.buildGogToken(user));
438+
gogService.verifyGameOwnership(gogUsername);
439+
440+
userRepository.findOneByGogId(gogUsername)
441+
.ifPresent(userWithSameId -> {
442+
throw ApiException.of(ErrorCode.GOG_ID_ALREADY_LINKED, userWithSameId.getLogin());
443+
});
444+
445+
user.setGogId(gogUsername);
446+
userRepository.save(user);
447+
448+
log.info("Successfully linked user {} to gog account {}", user.getId(), gogUsername);
449+
}
450+
451+
record CallbackResult(String callbackUrl, List<Error> errors) {
430452
}
431453
}

0 commit comments

Comments
 (0)