Skip to content

Commit

Permalink
fixes #142 link to steam
Browse files Browse the repository at this point in the history
  • Loading branch information
Brutus5000 committed Nov 8, 2017
1 parent c7bf63b commit 1d83aac
Show file tree
Hide file tree
Showing 13 changed files with 354 additions and 46 deletions.
75 changes: 73 additions & 2 deletions src/inttest/java/com/faforever/api/user/UserControllerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@


import com.faforever.api.AbstractIntegrationTest;
import com.faforever.api.config.FafApiProperties;
import com.faforever.api.data.domain.User;
import com.faforever.api.email.EmailSender;
import com.faforever.api.error.ErrorCode;
Expand All @@ -21,10 +22,15 @@
import java.time.Duration;

import static junitx.framework.Assert.assertEquals;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.nullValue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
Expand All @@ -41,14 +47,17 @@ public class UserControllerTest extends AbstractIntegrationTest {
@MockBean
private EmailSender emailSender;

@MockBean
private SteamService steamService;

@Autowired
private FafTokenService fafTokenService;

@Autowired
private UserService userService;
private UserRepository userRepository;

@Autowired
private UserRepository userRepository;
private FafApiProperties fafApiProperties;

@Test
@WithAnonymousUser
Expand Down Expand Up @@ -188,4 +197,66 @@ public void confirmPasswordReset() throws Exception {
.andExpect(status().isFound())
.andExpect(redirectedUrl("http://localhost/password_resetted"));
}

@Test
@WithAnonymousUser
public void buildSteamLinkUrlUnauthorized() throws Exception {
mockMvc.perform(
post("/users/buildSteamLinkUrl"))
.andExpect(status().isForbidden());
}

@Test
@WithUserDetails(AUTH_USER)
public void buildSteamLinkUrlWithWrongScope() throws Exception {
mockMvc.perform(
post("/users/buildSteamLinkUrl"))
.andExpect(status().isForbidden());
}

@Test
@WithUserDetails(AUTH_MODERATOR)
public void buildSteamLinkUrlAlreadyLinked() throws Exception {
MvcResult result = mockMvc.perform(
post("/users/buildSteamLinkUrl")
.with(getOAuthToken(OAuthScope._WRITE_ACCOUNT_DATA)))
.andExpect(status().is4xxClientError())
.andReturn();

assertApiError(result, ErrorCode.STEAM_ID_UNCHANGEABLE);
}

@Test
@WithUserDetails(AUTH_USER)
public void buildSteamLinkUrl() throws Exception {
when(steamService.buildLoginUrl(any())).thenReturn("steamUrl");

mockMvc.perform(
post("/users/buildSteamLinkUrl")
.with(getOAuthToken(OAuthScope._WRITE_ACCOUNT_DATA)))
.andExpect(status().isOk());

verify(steamService, times(1)).buildLoginUrl(anyString());
}

@Test
@WithAnonymousUser
public void linkToSteam() throws Exception {
assertThat(userRepository.findOne(1).getSteamId(), nullValue());

when(steamService.parseSteamIdFromLoginRedirect(any())).thenReturn("12345");
when(steamService.ownsForgedAlliance(anyString())).thenReturn(true);

String token = fafTokenService.createToken(
FafTokenType.LINK_TO_STEAM,
Duration.ofSeconds(100),
ImmutableMap.of(UserService.KEY_USER_ID, "1"));

mockMvc.perform(
get(String.format("/users/linkToSteam?token=%s&openid.identity=http://steamcommunity.com/openid/id/12345", token)))
.andExpect(status().isFound())
.andExpect(redirectedUrl(fafApiProperties.getLinkToSteam().getSuccessRedirectUrl()));

assertThat(userRepository.findOne(1).getSteamId(), is("12345"));
}
}
7 changes: 7 additions & 0 deletions src/inttest/resources/config/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,10 @@ faf-api:
html-format: "Integration test registration html body"
from-email: "integration-test@faforever.com"
from-name: "integration-test@faforever.com"
link-to-steam:
steam-redirect-url-format: "http://localhost:8010/users/linkToSteam?token=%s"
success-redirect-url: "http://localhost/linked_to_steam"
error-redirect-url-format: "http://localhost/error?message=%s"
steam:
realm: "http://localhost"
api-key: "banana"
15 changes: 7 additions & 8 deletions src/inttest/resources/sql/prepDefaultUser.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@ DELETE FROM oauth_clients;
DELETE FROM login;

INSERT INTO oauth_clients (id, name, client_secret, client_type, redirect_uris, default_redirect_uri, default_scope)
VALUES ('test', 'test', 'test', 'public', 'http://localhost', 'http://localhost',
'read_events read_achievements upload_map upload_mod write_account_data');
VALUES
('test', 'test', 'test', 'public', 'http://localhost https://www.getpostman.com/oauth2/callback ', 'http://localhost',
'read_events read_achievements upload_map upload_mod write_account_data');

INSERT INTO login (id, login, email, password)
VALUES (1, 'USER', 'user@faforever.com', '92b7b421992ef490f3b75898ec0e511f1a5c02422819d89719b20362b023ee4f');
INSERT INTO login (id, login, email, password)
VALUES (2, 'MODERATOR', 'moderator@faforever.com', '778ac5b81fa251b450f827846378739caee510c31b01cfa9d31822b88bed8441');
INSERT INTO login (id, login, email, password)
VALUES (3, 'ADMIN', 'admin@faforever.com', '835d6dc88b708bc646d6db82c853ef4182fabbd4a8de59c213f2b5ab3ae7d9be');
INSERT INTO login (id, login, email, password, steamid) VALUES
(1, 'USER', 'user@faforever.com', '92b7b421992ef490f3b75898ec0e511f1a5c02422819d89719b20362b023ee4f', NULL),
(2, 'MODERATOR', 'moderator@faforever.com', '778ac5b81fa251b450f827846378739caee510c31b01cfa9d31822b88bed8441', 1234),
(3, 'ADMIN', 'admin@faforever.com', '835d6dc88b708bc646d6db82c853ef4182fabbd4a8de59c213f2b5ab3ae7d9be', NULL);

18 changes: 18 additions & 0 deletions src/main/java/com/faforever/api/config/FafApiProperties.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ public class FafApiProperties {
private Deployment deployment = new Deployment();
private Registration registration = new Registration();
private PasswordReset passwordReset = new PasswordReset();
private LinkToSteam linkToSteam = new LinkToSteam();
private Steam steam = new Steam();
private Mail mail = new Mail();
private Challonge challonge = new Challonge();
private User user = new User();
Expand Down Expand Up @@ -165,6 +167,22 @@ public static class PasswordReset {
private String successRedirectUrl;
}

@Data
public static class LinkToSteam {
private String steamRedirectUrlFormat;
private String successRedirectUrl;
private String errorRedirectUrlFormat;
}

@Data
public static class Steam {
String realm;
String apiKey;
String forgedAllianceAppId = "9420";
String loginUrlFormat = "https://steamcommunity.com/openid/login?%s";
String getOwnedGamesUrlFormat = "https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001?%s";
}

@Data
public static class Challonge {
private String baseUrl = "https://api.challonge.com";
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/com/faforever/api/error/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ public enum ErrorCode {
MOD_VERSION_NOT_A_NUMBER(161, "Mod version is not a number", "The mod version has to be a whole number like 123, but was ''{0}''"),
USERNAME_RESERVED(162, "Invalid account data", "The username ''{0}'' can only be claimed by the original owner within {1} months after it has been freed."),
UNKNOWN_IDENTIFIER(163, "Unable to resolve user", "The identifier does neither match a username nor an email: {0}"),
ALREADY_REGISTERED(164, "Registration failed", "You can't create a new account because you already have one.");
ALREADY_REGISTERED(164, "Registration failed", "You can't create a new account because you already have one."),
STEAM_LINK_NO_FA_GAME(141, "Linking to Steam failed", "You do not own Forged Alliance on Steam or your profile is private.");

private final int code;
private final String title;
Expand Down
57 changes: 34 additions & 23 deletions src/main/java/com/faforever/api/security/FafTokenService.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.faforever.api.error.ApiException;
import com.faforever.api.error.Error;
import com.faforever.api.error.ErrorCode;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
Expand All @@ -14,7 +15,6 @@
import org.springframework.util.Assert;

import javax.validation.constraints.NotNull;
import java.io.IOException;
import java.text.MessageFormat;
import java.time.Instant;
import java.time.temporal.TemporalAmount;
Expand All @@ -28,71 +28,82 @@ public class FafTokenService {
static final String KEY_LIFETIME = "lifetime";

private final ObjectMapper objectMapper;
private final FafApiProperties properties;
private final MacSigner macSigner;

public FafTokenService(ObjectMapper objectMapper, FafApiProperties properties) {
this.objectMapper = objectMapper;
this.properties = properties;
this.macSigner = new MacSigner(properties.getJwt().getSecret());
}

/**
* Creates a signed token with a map of attributes and time-limited validity
* Creates a signed token with a map of attributes and time-limited validity.
*
* @see #resolveToken(FafTokenType, String)
*/
@SneakyThrows
public String createToken(@NotNull FafTokenType type, @NotNull TemporalAmount lifetime, @NotNull Map<String, String> attributes) {
Assert.notNull(attributes, "Attributes map must not be null");
Assert.isTrue(!attributes.containsKey(KEY_ACTION), MessageFormat.format("`{0}` is a protected attributed and must not be used", KEY_ACTION));
Assert.isTrue(!attributes.containsKey(KEY_LIFETIME), MessageFormat.format("`{0}` is a protected attributed and must not be used", KEY_LIFETIME));
Assert.isTrue(!attributes.containsKey(KEY_ACTION), MessageFormat.format("'{0}' is a protected attributed and must not be used", KEY_ACTION));
Assert.isTrue(!attributes.containsKey(KEY_LIFETIME), MessageFormat.format("'{0}' is a protected attributed and must not be used", KEY_LIFETIME));

HashMap<String, String> claims = new HashMap<>(attributes);
Map<String, String> claims = new HashMap<>(attributes);
claims.put(KEY_ACTION, type.toString());
Instant expiresAt = Instant.now().plus(lifetime);
claims.put(KEY_LIFETIME, expiresAt.toString());

log.debug("Creating token of type `{0}` expiring at `{1}` with attributes: {2}", type, expiresAt, attributes);
log.debug("Creating token of type '{}' expiring at '{}' with attributes: {}", type, expiresAt, attributes);

return JwtHelper.encode(objectMapper.writeValueAsString(claims), macSigner).getEncoded();
}

/**
* Verifies a token regarding it's type, lifetime and signature
* Verifies a token regarding its type, lifetime and signature.
*
* @return Map of original attributes
* @see #createToken(FafTokenType, TemporalAmount, Map)
*/
@SneakyThrows
public Map<String, String> resolveToken(@NotNull FafTokenType expectedTokenType, @NotNull String token) {
Map<String, String> claims = null;

try {
claims = objectMapper.readValue(JwtHelper.decodeAndVerify(token, macSigner).getClaims(), new TypeReference<Map<String, String>>() {
});
Assert.notNull(claims, "claims must not be null");
Assert.isTrue(claims.containsKey(KEY_ACTION), "Token does not contain: " + KEY_ACTION);
Assert.isTrue(claims.containsKey(KEY_LIFETIME), "Token does not contain: " + KEY_LIFETIME);
FafTokenType actualTokenType = FafTokenType.valueOf(claims.get(KEY_ACTION));
Assert.state(expectedTokenType == actualTokenType, String.format("Token types do not match, expected: %s, actual: %s", expectedTokenType, actualTokenType));
} catch (IOException | IllegalArgumentException | IllegalStateException e) {
if (claims == null) {
log.warn("Unparseable token of expected type {}: {}", expectedTokenType, token);
} else {
log.warn("Token of expected type `{}` invalid: {}", expectedTokenType, token);
}
} catch (JsonProcessingException | IllegalArgumentException e) {
log.warn("Unparseable token: {}", token);
throw new ApiException(new Error(ErrorCode.TOKEN_INVALID));
}

if (!claims.containsKey(KEY_ACTION)) {
log.warn("Missing key '{}' in token: {}", KEY_ACTION, token);
throw new ApiException(new Error(ErrorCode.TOKEN_INVALID));
}

Instant expiresAt = Instant.parse(claims.get(KEY_LIFETIME));
if (!claims.containsKey(KEY_LIFETIME)) {
log.warn("Missing key '{}' in token: {}", KEY_LIFETIME, token);
throw new ApiException(new Error(ErrorCode.TOKEN_INVALID));
}

FafTokenType actualTokenType;
try {
actualTokenType = FafTokenType.valueOf(claims.get(KEY_ACTION));
} catch (IllegalArgumentException e) {
log.warn("Unknown FAF token type '{}' in token: {}", claims.get(KEY_ACTION), token);
throw new ApiException(new Error(ErrorCode.TOKEN_INVALID));
}

if (expectedTokenType != actualTokenType) {
log.warn("Token types do not match (expected: '{}', actual: '{}') for token: {}", expectedTokenType, actualTokenType, token);
throw new ApiException(new Error(ErrorCode.TOKEN_INVALID));
}

Instant expiresAt = Instant.parse(claims.get(KEY_LIFETIME));
if (expiresAt.isBefore(Instant.now())) {
log.debug("Token of expected type `{}` invalid: {}", expectedTokenType, token);
log.debug("Token of expected type '{}' is invalid: {}", expectedTokenType, token);
throw new ApiException(new Error(ErrorCode.TOKEN_EXPIRED));
}

HashMap<String, String> attributes = new HashMap<>(claims);
Map<String, String> attributes = new HashMap<>(claims);
attributes.remove(KEY_ACTION);
attributes.remove(KEY_LIFETIME);

Expand Down
3 changes: 2 additions & 1 deletion src/main/java/com/faforever/api/security/FafTokenType.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@
@AllArgsConstructor
public enum FafTokenType {
REGISTRATION,
PASSWORD_RESET
PASSWORD_RESET,
LINK_TO_STEAM
}
71 changes: 71 additions & 0 deletions src/main/java/com/faforever/api/user/SteamService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.faforever.api.user;

import com.faforever.api.config.FafApiProperties;
import com.faforever.api.config.FafApiProperties.Steam;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.NameValuePair;
import org.apache.http.client.utils.URLEncodedUtils;
import org.apache.http.message.BasicNameValuePair;
import org.json.JSONObject;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import javax.servlet.http.HttpServletRequest;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

@Service
@Slf4j
public class SteamService {
private final FafApiProperties properties;

public SteamService(FafApiProperties properties) {
this.properties = properties;
}

String buildLoginUrl(String redirectUrl) {
log.trace("Building steam login url for redirect url: {}", redirectUrl);

List<NameValuePair> steamArgs = Arrays.asList(
new BasicNameValuePair("openid.ns", "http://specs.openid.net/auth/2.0"),
new BasicNameValuePair("openid.mode", "checkid_setup"),
new BasicNameValuePair("openid.return_to", redirectUrl),
new BasicNameValuePair("openid.realm", properties.getSteam().getRealm()),
new BasicNameValuePair("openid.identity", "http://specs.openid.net/auth/2.0/identifier_select"),
new BasicNameValuePair("openid.claimed_id", "http://specs.openid.net/auth/2.0/identifier_select")
);
String queryArgs = URLEncodedUtils.format(steamArgs, StandardCharsets.UTF_8);

return String.format(properties.getSteam().getLoginUrlFormat(), queryArgs);
}

String parseSteamIdFromLoginRedirect(HttpServletRequest request) {
log.trace("Parsing steam id from request: {}", request);

String identityUrl = request.getParameter("openid.identity");
return identityUrl.substring(identityUrl.lastIndexOf("/") + 1, identityUrl.length());
}

@SneakyThrows
boolean ownsForgedAlliance(String steamId) {
log.debug("Checking whether steamId owns Forged Alliance: {}", steamId);

Steam steam = properties.getSteam();

RestTemplate restTemplate = new RestTemplate();
List<NameValuePair> steamArgs = new ArrayList<>();
steamArgs.add(new BasicNameValuePair("key", steam.getApiKey()));
steamArgs.add(new BasicNameValuePair("steamid", steamId));
steamArgs.add(new BasicNameValuePair("format", "json"));
steamArgs.add(new BasicNameValuePair("appids_filter[0]", steam.getForgedAllianceAppId()));
String queryArgs = URLEncodedUtils.format(steamArgs, StandardCharsets.UTF_8);

String response = restTemplate.getForObject(String.format(steam.getGetOwnedGamesUrlFormat(), queryArgs), String.class);
JSONObject result = new JSONObject(response);

return result.getJSONObject("response").getInt("game_count") > 0;
}
}
Loading

0 comments on commit 1d83aac

Please sign in to comment.