13

我正在使用 Firebase 开发多人游戏。玩家得分在每场比赛结束后记录在 firebase 中,并且 playerTotalScore 字段也会更新为新的总分。我的问题:是否可以仅使用 firebase 安全规则来保护 playerTotalScore 字段免受用户任意操纵?如果是这样,怎么做?

我已经详细阅读了 firebase 网站上的 firebase 安全信息。虽然我知道可以在安全规则中实现一些复杂的逻辑(将数字增加一个给定的数量,例如 this gist,或者使字段仅插入(".write": "!data.exists()"),但在这种情况下,这些信息似乎都没有帮助。增量-only 规则是不够的,因为可以通过多次递增来操纵分数。仅插入似乎是 totalScore 的一个选项,因为每场比赛后都会更新。

更新

根据 Kato 的要求,这里是具体的用例。

我正在开发的游戏是一个问答游戏,玩家回答问题,实时显示玩家分数。

在游戏过程中,该特定游戏的分数在每个问题后通过以下语句更新:

gameRef.child('players').child(UserId).child('score').set(gameScore)

游戏结束后,玩家的总得分(所有玩过的游戏)计算为totalScore=totalScore+gameScore,然后使用以下语句在 Firebase 中更新玩家的总得分:

leaderboardRef.child(userId).setWithPriority({userName:userName, totalScore:totalScore}, totalScore)

Update2:加藤要求的数据结构

这是我目前的具体结构。这不是一成不变的,所以我愿意根据推荐的方法来改变它以保护数据。

用户(玩家)玩的每个游戏的分数存储在以下结构中

<firebase_root>/app/games/<gameId>/players/<userId>/score/

<gameId>是调用 firebase push() 方法后生成的 firebase 密钥。 <UserId>是 firebase simplelogin uid。

每个用户(玩家)的总得分(所有玩过的所有游戏的得分总和)存储在以下数据结构中

<firebase_root>/app/leaderboard/<userId>/totalScore/

totalScore 的排行榜数据是使用 totalScore 作为优先级设置的,用于查询目的

leaderboardRef.child(userId).setWithPriority({userName:userName, totalScore:totalScore}, totalScore)

score 和 totalScore 都是数字整数值。这就是我能想到的当前数据结构的所有细节。

4

3 回答 3

13

从技术上讲,您的问题是如何使用安全规则完成此操作,但由于它有点XY 问题,并且没有排除其他可能性,我也会在这里解决其中的一些问题。

我会做很多假设,因为回答这个问题实际上需要一组完全指定的规则,这些规则需要遵循并且实际上是实现整个应用程序的问题(增加分数是游戏逻辑规则的结果,而不是一个简单的数学问题)。

总分在客户端

也许对这个难题最简单的答案就是没有总分。只需获取玩家列表并手动总计。

当这可能有用时:

  • 玩家名单是数百或更少
  • 玩家数据适当小(不是每个 500k)

怎么做:

var ref = new Firebase(URL);
function getTotalScore(gameId, callback) {
   ref.child('app/games/' + gameId + '/players').once('value', function(playerListSnap) {
      var total = 0;
      playerListSnap.forEach(function(playerSnap) {
         var data = playerSnap.val();
         total += data.totalScore || 0;
      });
      callback(gameId, total);
   });
}

使用特权工作者更新分数

一个非常复杂且简单的方法(因为它只需要将安全规则设置为类似的东西".write": "auth.uid === 'SERVER_PROCESS'")将使用一个服务器进程来简单地监控游戏并累积总数。这可能是最简单且最容易维护的解决方案,但它的缺点是需要另一个工作部件。

当这可能有用时:

  • 您可以启动 Heroku 服务或将 .js 文件部署到 webscript.io
  • 5-30 美元范围内的额外每月订阅不会破坏交易

怎么做:

显然,这涉及大量的应用程序设计,并且需要在不同的层次上完成。让我们只关注结束游戏和计算排行榜,因为这是一个很好的例子。

首先将评分代码拆分为自己的路径,例如

/scores_entries/$gameid/$scoreid = < player: ..., score: ... >
/game_scores/$gameid/$playerid = <integer>

现在监视游戏以查看它们何时关闭:

var rootRef = new Firebase(URL);
var gamesRef = rootRef.child('app/games');
var lbRef = rootRef.child('leaderboards');

gamesRef.on('child_added', watchGame);
gamesRef.child('app/games').on('child_remove', unwatchGame);

function watchGame(snap) {
    snap.ref().child('status').on('value', gameStatusChanged);
}

function unwatchGame(snap) {
    snap.ref().child('status').off('value', gameStatusChanged);
}

function gameStatusChanged(snap) {
    if( snap.val() === 'CLOSED' ) {
        unwatchGame(snap);
        calculateScores(snap.name());
    }
}

function calculateScores(gameId) {
    gamesRef.child(gameId).child('users').once('value', function(snap) {
        var userScores = {};
        snap.forEach(function(ss) {
            var score = ss.val() || 0;
            userScores[ss.name()] = score;
        });
        updateLeaderboards(userScores);
    });
}

function updateLeaderboards(userScores) {
    for(var userId in userScores) {
        var score = userScores[userId];
        lbRef.child(userId).transaction(function(currentValue) {
            return (currentValue||0) + score;
        });
    }
}

使用审计路径和安全规则

当然,这将是可用选择中最复杂和最困难的。

当这可能有用时:

  • 当我们拒绝使用涉及服务器进程的任何其他策略时
  • 当非常担心玩家作弊时
  • 当我们有很多额外的时间可以燃烧时

显然,我对这种方法有偏见。主要是因为它很难做到正确,并且需要大量的能量,而这些能量可以用少量的货币投资来代替。

要做到这一点,需要对每个单独的写入请求进行审查。有几个明显的要点需要保护(可能更多):

  1. 编写任何包含分数增量的游戏事件
  2. 为每个用户编写游戏总数
  3. 将游戏总数写入排行榜
  4. 写入每条审计记录
  5. 确保不会为了提高分数而即时创建和修改多余的游戏

以下是保护这些要点的一些基本原理:

  • 使用用户只能添加(不能更新或删除)条目的审计跟踪
  • 验证每个审计条目的优先级是否等于当前时间戳
  • 根据当前游戏状态验证每个审计条目是否包含有效数据
  • 在尝试增加运行总计时利用审计条目

让我们以安全更新排行榜为例。我们将假设以下内容:

  • 用户在游戏中的分数有效
  • 用户创建了一个审计条目,例如,leaderboard_audit/$userid/$gameid,当前时间戳作为优先级,分数作为值
  • 每个用户记录都提前存在于排行榜中
  • 只有用户可以更新自己的分数

所以这是我们假设的数据结构:

/games/$gameid/users/$userid/score
/leaderboard_audit/$userid/$gameid/score
/leaderboard/$userid = { last_game: $gameid, score: <int> }

下面是我们的逻辑是如何工作的:

  1. 游戏分数设置为/games/$gameid/users/$userid/score
  2. 审核记录创建于/leaderboard_audit/$userid/games_played/$gameid
  3. at 的值/leaderboard_audit/$userid/last_game已更新以匹配$gameid
  4. 排行榜的更新量与last_game的审计记录完全相同

这是实际的规则:

{
    "rules": {
        "leaderboard_audit": {
            "$userid": {
                "$gameid": {
                   // newData.exists() ensures records cannot be deleted
                    ".write": "auth.uid === $userid && newData.exists()",

                    ".validate": "
                        // can only create new records
                        !data.exists()
                        // references a valid game
                        && root.child('games/' + $gameid).exists()
                        // has the correct score as the value
                        && newData.val() === root.child('games/' + $gameid + '/users/' + auth.uid + '/score').val()
                        // has a priority equal to the current timestamp
                        && newData.getPriority() === now
                        // is created after the previous last_game or there isn't a last_game
                        (
                            !root.child('leaderboard/' + auth.uid + '/last_game').exists() || 
                            newData.getPriority() > data.parent().child(root.child('leaderboard/' + auth.uid + '/last_game').val()).getPriority()
                        )

                    "
                }
            }
        },
        "leaderboard": {
            "$userid": {
                ".write": "auth.uid === $userid && newData.exists()",
                ".validate": "newData.hasChildren(['last_game', 'score'])",
                "last_game": {
                    ".validate": "
                        // must match the last_game entry
                        newData.val() === root.child('leaderboard_audit/' + auth.uid + '/last_game').val()
                        // must not be a duplicate
                        newData.val() !== data.val()
                        // must be a game created after the current last_game timestamp
                        (
                            !data.exists() ||
                            root.child('leaderboard_audit/' + auth.uid + '/' + data.val()).getPriority() 
                            < root.child('leaderboard_audit/' + auth.uid + '/' + newData.val()).getPriority()
                        )
                    "
                },
                "score": {
                    ".validate": "
                        // new score is equal to the old score plus the last_game's score
                        newData.val() === data.val() + 
                        root.child('games/' + newData.parent().child('last_game').val() + '/users/' + auth.uid + '/score').val()
                    "
                }
            }
        }
    }
}
于 2014-10-09T20:04:51.793 回答
4

使用规则来防范无效值会很棘手。由于您授予用户写入值的权限,因此他们还可以对您的代码进行逆向工程并写入您不想看到的值。你可以做很多事情来让黑客的工作变得更加困难,但总会有人能够解决它。也就是说:你可以做一些简单的事情来让黑客的事情变得不那么琐碎。

您可以轻松地记录/存储有关游戏玩法的足够信息,以便您以后确定它是否合法。

例如,在我做的一个打字游戏中,我不仅存储了玩家的最终分数,还存储了他们按下的每个键以及按下它的时间。

https://<my>.firebaseio.com/highscores/game_1_time_15/puf
  keystrokes: "[[747,'e'],[827,'i'],[971,'t'],[1036,'h']...[14880,'e']]"
  score: 61

所以在进入游戏的 747 毫秒时,我输入了ethen it等等h,直到最后在 14.8 秒后我按下了e

使用这些值,我可以检查按下的键是否确实导致分数为61. 我也可以重玩游戏,或者对其进行一些分析,看看它是否看起来像一个真正的人在玩按键。如果时间戳是100200300等,你会非常怀疑(尽管我创建了一些在这样的时间间隔精确键入的机器人)。

ref.child('score').set(10000000)当然,这仍然不能保证,但它至少是黑客的第一个绊脚石。

我从 John Resig 的 Deap Leap 中得到了这个想法,但我找不到他描述它的页面。

于 2014-10-08T11:57:33.407 回答
4

我有个主意。- 由于这是一款多人游戏,您将在一个特定游戏中拥有多个玩家。这意味着game over消息后的每个玩家都将更新部分得分和总得分。

在安全规则中,您可以检查对手是否写了关于同一游戏的部分值。- 那将是只读访问。或者您可以检查所有对手的部分值是否给出所需的总数等。

黑客必须想出一些复杂的计划,包括控制多个账户和同步攻击。

编辑:......我可以看到进一步的问题 - 第一个更新的玩家呢?这可以通过意图来完成。因此,首先所有玩家都写出intent to write score部分得分的位置,一旦到处都有一些值,他们就会清楚地写出实际得分。

于 2014-10-09T09:15:45.443 回答