Skip to content

Commit

Permalink
Allow verifying players
Browse files Browse the repository at this point in the history
Fixes #45
  • Loading branch information
micheljung committed Aug 2, 2018
1 parent a7dd9a5 commit c1d62a1
Show file tree
Hide file tree
Showing 20 changed files with 723 additions and 20 deletions.
2 changes: 1 addition & 1 deletion .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ public SubscribableChannel playerOptionRequest() {
return MessageChannels.direct().get();
}

@Bean(name = ChannelNames.VERIFY_PLAYER_REQUEST)
@SecuredChannel(interceptor = CHANNEL_SECURITY_INTERCEPTOR, sendAccess = ROLE_USER)
public SubscribableChannel verifyPlayerReport() {
return MessageChannels.direct().get();
}

@Bean(name = ChannelNames.CLEAR_SLOT_REQUEST)
@SecuredChannel(interceptor = CHANNEL_SECURITY_INTERCEPTOR, sendAccess = ROLE_USER)
public SubscribableChannel clearSlotRequest() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import com.faforever.server.game.PlayerDefeatedReport;
import com.faforever.server.game.PlayerOptionReport;
import com.faforever.server.game.TeamKillReport;
import com.faforever.server.game.VerifyPlayerReport;
import com.faforever.server.ice.IceMessage;
import com.faforever.server.ice.IceServersRequest;
import com.faforever.server.integration.ChannelNames;
Expand Down Expand Up @@ -180,6 +181,7 @@ private PayloadTypeRouter inboundRouter() {
router.setChannelMapping(GameStateReport.class.getName(), ChannelNames.UPDATE_GAME_STATE_REQUEST);
router.setChannelMapping(GameOptionReport.class.getName(), ChannelNames.GAME_OPTION_REQUEST);
router.setChannelMapping(PlayerOptionReport.class.getName(), ChannelNames.PLAYER_OPTION_REQUEST);
router.setChannelMapping(VerifyPlayerReport.class.getName(), ChannelNames.VERIFY_PLAYER_REQUEST);
router.setChannelMapping(ClearSlotRequest.class.getName(), ChannelNames.CLEAR_SLOT_REQUEST);
router.setChannelMapping(AiOptionReport.class.getName(), ChannelNames.AI_OPTION_REQUEST);
router.setChannelMapping(DesyncReport.class.getName(), ChannelNames.DESYNC_REPORT);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.faforever.server.game.PlayerGameState;
import lombok.Getter;
import lombok.Setter;
import lombok.Value;
import org.jetbrains.annotations.Nullable;

import javax.persistence.Entity;
Expand All @@ -16,7 +17,10 @@
import javax.persistence.OneToOne;
import javax.persistence.Table;
import javax.persistence.Transient;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;

Expand Down Expand Up @@ -44,7 +48,7 @@ public class Player extends Login implements ConnectionAware {
@JoinTable(name = "avatars",
joinColumns = @JoinColumn(name = "idUser", referencedColumnName = "id"),
inverseJoinColumns = @JoinColumn(name = "idAvatar", referencedColumnName = "id"))
private List<AvatarAssociation> availableAvatars;
private List<AvatarAssociation> availableAvatars = new ArrayList<>();

@OneToMany(mappedBy = "player")
private List<ClanMembership> clanMemberships;
Expand All @@ -65,13 +69,20 @@ public class Player extends Login implements ConnectionAware {
@Transient
private ClientConnection clientConnection;

@Transient
private Map<Integer, FraudReport> fraudReportsByReporterId = new HashMap<>();

/**
* The future that will be completed as soon as the player's game entered {@link GameState#OPEN}. A player's game may
* never start if it crashes or the player disconnects.
*/
@Transient
private CompletableFuture<Game> gameFuture;

/** The player's rating for the game he joined, at the time he joined. */
@Transient
private Rating ratingWithinCurrentGame;

public Clan getClan() {
if (getClanMemberships() != null && getClanMemberships().size() == 1) {
return getClanMemberships().get(0).getClan();
Expand All @@ -88,4 +99,16 @@ public void setGameState(PlayerGameState gameState) {
public String toString() {
return "Player(" + getId() + ", " + getLogin() + ")";
}

@Value
public static class FraudReport {
int reporterId;
int gameId;
String name;
int mean;
int deviation;
String country;
String avatarDescription;
String avatarUrl;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ public enum ErrorCode {
UNKNOWN_MAP(122, "Unknown map", "The map ''{0}'' is unknown by the server."),
INSUFFICIENT_MATCH_PARTICIPANTS(123, "Insufficient match participants", "Can't create match with {0,number,#} participants, at least {1,number,#} are required."),
HOST_FAILED_TO_START_GAME(124, "Host failed to start game", "The game ''{0}'' could not be started since its host ''{1}}'' failed to start his game."),
INVALID_FEATURED_MOD(125, "Invalid featured mod", "The featured mod ''{0}'' does not exist.");
INVALID_FEATURED_MOD(125, "Invalid featured mod", "The featured mod ''{0}'' does not exist."),
PLAYER_NOT_ONLINE(126, "Player not online", "The player with the ID {0,number,#} is not online."),
OTHER_PLAYER_NOT_IN_GAME(127, "Player is not in a game", "The player with the ID {0,number,#} is currently not in a game."),
THIS_PLAYER_NOT_IN_GAME(128, "Not in a game", "The action you tried is not available as you are currently not in a game."),
NOT_SAME_GAME(129, "Not the same game", "Verification denied as you are not in the same game as player ''{0}''.");

private final int code;
private final String title;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,19 @@ private void changePlayerGameState(Player player, PlayerGameState newState) {

private void addPlayer(Game game, Player player) {
game.getConnectedPlayers().put(player.getId(), player);

if (modService.isLadder1v1(game.getFeaturedMod())) {
if (player.getLadder1v1Rating() == null) {
ratingService.initLadder1v1Rating(player);
}
player.setRatingWithinCurrentGame(player.getLadder1v1Rating());
} else {
if (player.getGlobalRating() == null) {
ratingService.initGlobalRating(player);
}
player.setRatingWithinCurrentGame(player.getGlobalRating());
}

player.setCurrentGame(game);
player.getGameFuture().complete(game);

Expand Down Expand Up @@ -953,23 +966,15 @@ private void updateGamePlayerStatsRating(GamePlayerStats gamePlayerStats, Player
Game game = player.getCurrentGame();

if (modService.isLadder1v1(game.getFeaturedMod())) {
if (player.getLadder1v1Rating() == null) {
ratingService.initLadder1v1Rating(player);
}
Assert.state(Optional.ofNullable(player.getLadder1v1Rating()).isPresent(),
"Ladder1v1 rating not properly initialized");

Ladder1v1Rating ladder1v1Rating = player.getLadder1v1Rating();
Assert.state(ladder1v1Rating != null, "Expected ladder1v1 rating to be set");

gamePlayerStats.setDeviation(ladder1v1Rating.getDeviation());
gamePlayerStats.setMean(ladder1v1Rating.getMean());
} else {
if (player.getGlobalRating() == null) {
ratingService.initGlobalRating(player);
}
Assert.state(Optional.ofNullable(player.getGlobalRating()).isPresent(),
"Global rating not properly initialized");

GlobalRating globalRating = player.getGlobalRating();
Assert.state(globalRating != null, "Expected global rating to be set");

gamePlayerStats.setDeviation(globalRating.getDeviation());
gamePlayerStats.setMean(globalRating.getMean());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package com.faforever.server.game;

import com.faforever.server.client.ClientService;
import com.faforever.server.entity.Avatar;
import com.faforever.server.entity.AvatarAssociation;
import com.faforever.server.entity.Game;
import com.faforever.server.entity.Player;
import com.faforever.server.entity.Player.FraudReport;
import com.faforever.server.entity.Rating;
import com.faforever.server.error.ErrorCode;
import com.faforever.server.error.Requests;
import com.faforever.server.player.PlayerService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.Collection;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;

/**
* When players joins a game, they send their own information (like name and rating) to other players. This information
* can, therefore, not be trusted. Players can send the information they received from others to this service in order
* to have it verified. If this service receives multiple reports of incorrect data, it will tell all peers to
* disconnect the player who spoofs its data.
*/
@Service
@Slf4j
public class SpoofDetectorService {

private final PlayerService playerService;
private final ClientService clientService;

public SpoofDetectorService(PlayerService playerService, ClientService clientService) {
this.playerService = playerService;
this.clientService = clientService;
}

public void verifyPlayer(Player reporter, int reporteeId, String name, float mean, float deviation, String country, String avatarUrl, String avatarDescription) {
Game reporterGame = reporter.getCurrentGame();
Requests.verify(reporterGame != null, ErrorCode.THIS_PLAYER_NOT_IN_GAME);

Optional<Player> reporteeOptional = playerService.getOnlinePlayer(reporteeId);
Requests.verify(reporteeOptional.isPresent(), ErrorCode.PLAYER_NOT_ONLINE, reporteeId);
Player reportee = reporteeOptional.get();

Game reporteeGame = reportee.getCurrentGame();
Requests.verify(reporteeGame != null, ErrorCode.OTHER_PLAYER_NOT_IN_GAME, reporteeId);

Requests.verify(reporterGame.equals(reporteeGame), ErrorCode.NOT_SAME_GAME, reportee);

boolean passesValidation = verifyName(reporter, name, reportee)
&& verifyRating(reporter, mean, deviation, reportee)
&& verifyCountry(reporter, country, reportee)
&& verifyAvatar(reporter, avatarUrl, avatarDescription, reportee);

if (!passesValidation) {
reportee.getFraudReportsByReporterId().put(
reporter.getId(),
new FraudReport(reporter.getId(), reporterGame.getId(), name, (int) mean, (int) deviation, country, avatarDescription, avatarUrl)
);

int reportedFrauds = reportee.getFraudReportsByReporterId().size();
if (reportedFrauds > 1 && reportedFrauds >= reporterGame.getConnectedPlayers().size() / 2) {
Collection<Player> peers = reporteeGame.getConnectedPlayers().values().stream()
.filter(player -> !Objects.equals(player.getId(), reporteeId))
.collect(Collectors.toList());

clientService.disconnectPlayerFromGame(reporteeId, peers);
}
}
}

private boolean verifyAvatar(Player reporter, String avatarUrl, String avatarDescription, Player reportee) {
Optional<Avatar> optionalAvatar = reportee.getAvailableAvatars().stream()
.filter(AvatarAssociation::isSelected)
.map(AvatarAssociation::getAvatar)
.findFirst();
if (optionalAvatar.isPresent()) {
Avatar avatar = optionalAvatar.get();
if (!Objects.equals(avatarUrl, avatar.getUrl())) {
log.debug("Avatar URL '{}' of player '{}' does not match in-game URL '{}' as reported by player '{}'",
avatarUrl, reportee, avatar.getUrl(), reporter);
return false;
}
if (!Objects.equals(avatarDescription, avatar.getDescription())) {
log.debug("Avatar description '{}' of player '{}' does not match in-game description '{}' as reported by player '{}'",
avatarDescription, reportee, avatar.getDescription(), reporter);
return false;
}
}
return true;
}

private boolean verifyCountry(Player reporter, String country, Player reportee) {
if (!Objects.equals(country, reportee.getCountry())) {
log.debug("Country '{}' of player '{}' does not match in-game country '{}' as reported by player '{}'",
reportee.getCountry(), reportee, country, reporter);
return false;
}
return true;
}

private boolean verifyRating(Player reporter, float mean, float deviation, Player reportee) {
Rating reporteeRating = reportee.getRatingWithinCurrentGame();

if (!Objects.equals(mean, (float) reporteeRating.getMean()) || !Objects.equals(deviation, (float) reporteeRating.getDeviation())) {
log.debug("Rating '{}/{}' of player '{}' does not match in-game rating '{}/{}' as reported by player '{}'",
reporteeRating.getMean(), reporteeRating.getDeviation(), reportee, mean, deviation, reporter);
return false;
}
return true;
}

private boolean verifyName(Player reporter, String name, Player reportee) {
if (!Objects.equals(name, reportee.getLogin())) {
log.debug("Name of player '{}' does not match in-game name '{}' as reported by player '{}'", reportee, name, reporter);
return false;
}
return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.faforever.server.game;

import com.faforever.server.common.ClientMessage;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class VerifyPlayerReport implements ClientMessage {
private int id;
private String name;
private float mean;
private float deviation;
private String country;
private String avatarUrl;
private String avatarDescription;
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import com.faforever.server.game.PlayerDefeatedReport;
import com.faforever.server.game.PlayerOptionReport;
import com.faforever.server.game.TeamKillReport;
import com.faforever.server.game.VerifyPlayerReport;
import com.faforever.server.ice.IceMessage;
import com.faforever.server.ice.IceServersRequest;
import com.faforever.server.integration.legacy.transformer.RestoreGameSessionRequest;
Expand Down Expand Up @@ -133,6 +134,11 @@ public final class ChannelNames {
*/
public static final String PLAYER_OPTION_REQUEST = "playerOptionRequest";

/**
* Channel for {@link VerifyPlayerReport}.
*/
public static final String VERIFY_PLAYER_REQUEST = "verifyPlayerReport";

/**
* Channel for {@link ClearSlotRequest}.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.faforever.server.integration;

import com.faforever.server.entity.Player;
import com.faforever.server.game.SpoofDetectorService;
import com.faforever.server.game.VerifyPlayerReport;
import com.faforever.server.security.FafUserDetails;
import org.springframework.integration.annotation.MessageEndpoint;
import org.springframework.integration.annotation.ServiceActivator;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.security.core.Authentication;

import static com.faforever.server.integration.MessageHeaders.USER_HEADER;

@MessageEndpoint
public class SpoofDetectorServiceActivator {
private final SpoofDetectorService spoofDetectorService;

public SpoofDetectorServiceActivator(SpoofDetectorService spoofDetectorService) {
this.spoofDetectorService = spoofDetectorService;
}

@ServiceActivator(inputChannel = ChannelNames.VERIFY_PLAYER_REQUEST)
public void verifyPlayerReport(VerifyPlayerReport report, @Header(USER_HEADER) Authentication authentication) {
spoofDetectorService.verifyPlayer(
getPlayer(authentication),
report.getId(),
report.getName(),
report.getMean(),
report.getDeviation(),
report.getCountry(),
report.getAvatarUrl(),
report.getAvatarDescription()
);
}

private Player getPlayer(Authentication authentication) {
return ((FafUserDetails) authentication.getPrincipal()).getPlayer();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ public enum LegacyClientMessageType {
DISCONNECTED("Disconnected"),
BOTTLENECK("Bottleneck"),
CHAT("Chat"),
BOTTLENECK_CLEARED("BottleneckCleared");
BOTTLENECK_CLEARED("BottleneckCleared"),
VERIFY_PLAYER("VerifyPlayer");

private static Map<String, LegacyClientMessageType> fromString;

Expand Down
Loading

0 comments on commit c1d62a1

Please sign in to comment.