生成忘记密码随机令牌的最佳实践

101

我想为忘记密码生成标识符。我读过,可以使用时间戳和mt_rand()来实现,但有些人说时间戳可能每次都不唯一。所以我有点困惑。我可以使用时间戳来实现吗?

问题
生成自定义长度的随机/唯一令牌的最佳做法是什么?

我知道这里有很多相关问题,但在阅读不同人的不同意见后,我变得更加困惑了。


@AlmaDoMundo:一台计算机无法无限地分配时间。 - juergen d
@juergend - 抱歉,我不明白。 - Alma Do
如果您在一纳秒内调用它,则会获得相同的时间戳。例如,某些时间函数只能以100ns步长返回时间,而另一些则只能以秒为步长返回时间。 - juergen d
@juergend 啊,没错。我提到了只有秒的“经典”时间戳。但是如果按照你说的做-是的(那只留下了使用时间机器获取非唯一时间戳的选项)。 - Alma Do
1
注意,被接受的答案没有利用CSPRNG - Scott Arciszewski
显示剩余2条评论
5个回答

157
在PHP中使用random_bytes()。原因是:您正在寻找获取密码提醒令牌的方法,如果这是一次性登录凭证,则实际上有需要保护的数据(即 - 整个用户帐户)。
所以,代码应该如下所示:
//$length = 78 etc
$token = bin2hex(random_bytes($length));
更新: 之前的版本提到了uniqid(),如果涉及到安全问题并不只是唯一性,则是不正确的。实际上,uniqid()本质上只是带有一些编码的microtime()。您可以通过简单的方法来获取服务器上microtime()的准确预测值。攻击者可以发出密码重置请求,然后尝试几个可能的令牌。如果使用more_entropy,这也是可能的,因为额外的熵同样很弱。感谢@NikiC@ScottArciszewski指出这一点。

有关更多详细信息,请参见:


21
请注意,random_bytes() 仅适用于 PHP7 及以上版本。对于旧版本,@yesitsme的答案似乎是最佳选项。 - Gerald Schneider
3
@GeraldSchneider或random_compat是这些功能的填充程序,已经接受了最多的同行评审;) - Scott Arciszewski
1
我在我的SQL数据库中创建了一个varchar(64)字段来存储此令牌。我将$length设置为64,但返回的字符串长度为128个字符。如何获得固定大小的字符串(这里是64)? - gordie
3
将长度设置为32,每个字节为2个十六进制字符。 - JohnHoulderUK
$length 应该是什么?用户的 ID 吗?还是其他什么? - stack
@stack $length=20 应该足够了 :) (大约有 1461501637330902918203684832716283019655932542976 种不同的可能令牌。我甚至可以说 $length=10; 就足够了。10 将给你 1208925819614629174706176 种可能的令牌;顺便说一下,这个数学是 2**(10*8),你可以用 https://www.grc.com/haystack.htm 进行一些猜测难度计算) - hanshenrik

73

这是针对“最佳随机”的请求的答案:

Security.StackExchange上Adi的回答1提供了一个解决方案:

确保您有OpenSSL支持,然后您永远不会错过这个一行命令。

$token = bin2hex(openssl_random_pseudo_bytes(16));
1. Adi, Mon Nov 12 2018, Celeritas, "生成一个不可预测的令牌用于确认电子邮件", Sep 20 '13 at 7:06, https://security.stackexchange.com/a/40314/

26
openssl_random_pseudo_bytes($length) - 支持:PHP 5 >= 5.3.0,..........................................................(对于 PHP 7 及以上版本,请使用 random_bytes($length)).......................................... (对于 PHP 版本低于 5.3 的情况,请不要使用) - jave.web

58

先前的被接受答案版本(md5(uniqid(mt_rand(), true)))不安全,只提供约2^60种可能性--对于低预算攻击者而言,在大约一周的时间内进行暴力搜索是可以的:

由于56位DES密钥在大约24小时内就可以被暴力破解,平均情况下的熵为约59位,因此我们可以计算2^59 / 2^56 = 约8天。根据这个令牌验证的实现方式,可能会实际泄漏时间信息并推断出有效重置令牌的前N个字节

由于问题涉及到“最佳实践”,并且以...

我想生成用于忘记密码的标识符

...我们可以推断出该令牌具有隐含的安全要求。当向随机数生成器添加安全要求时,最佳做法是始终使用密码学安全的伪随机数生成器(缩写为CSPRNG)。


使用CSPRNG

在PHP 7中,您可以使用bin2hex(random_bytes($n))(其中$n是大于15的整数)。

在PHP 5中,您可以使用random_compat公开相同的API。

或者,如果您已安装了ext/mcrypt,则可以使用bin2hex(mcrypt_create_iv($n, MCRYPT_DEV_URANDOM))。另一个好的一行代码是bin2hex(openssl_random_pseudo_bytes($n))

将查找与验证分离

从我的先前关于PHP中安全的“记住我”cookie的工作中,减轻上述时间泄漏(通常由数据库查询引入)的唯一有效方法是将查找与验证分离。

如果您的表看起来像这样(MySQL):

CREATE TABLE account_recovery (
    id INTEGER(11) UNSIGNED NOT NULL AUTO_INCREMENT 
    userid INTEGER(11) UNSIGNED NOT NULL,
    token CHAR(64),
    expires DATETIME,
    PRIMARY KEY(id)
);

...你需要再添加一列,selector,就像这样:

CREATE TABLE account_recovery (
    id INTEGER(11) UNSIGNED NOT NULL AUTO_INCREMENT 
    userid INTEGER(11) UNSIGNED NOT NULL,
    selector CHAR(16),
    token CHAR(64),
    expires DATETIME,
    PRIMARY KEY(id),
    KEY(selector)
);

在发出密码重置令牌时,请使用CSPRNG将两个值发送给用户,将选择器和随机令牌的SHA-256哈希存储在数据库中。 使用选择器获取哈希和用户ID,在使用hash_equals()函数计算用户提供的令牌与存储在数据库中的令牌的SHA-256哈希。

示例代码

使用PDO在PHP 7(或带有random_compat的5.6)中生成重置令牌:

$selector = bin2hex(random_bytes(8));
$token = random_bytes(32);

$urlToEmail = 'http://example.com/reset.php?'.http_build_query([
    'selector' => $selector,
    'validator' => bin2hex($token)
]);

$expires = new DateTime('NOW');
$expires->add(new DateInterval('PT01H')); // 1 hour

$stmt = $pdo->prepare("INSERT INTO account_recovery (userid, selector, token, expires) VALUES (:userid, :selector, :token, :expires);");
$stmt->execute([
    'userid' => $userId, // define this elsewhere!
    'selector' => $selector,
    'token' => hash('sha256', $token),
    'expires' => $expires->format('Y-m-d\TH:i:s')
]);

验证用户提供的重置令牌:

$stmt = $pdo->prepare("SELECT * FROM account_recovery WHERE selector = ? AND expires >= NOW()");
$stmt->execute([$selector]);
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($results)) {
    $calc = hash('sha256', hex2bin($validator));
    if (hash_equals($calc, $results[0]['token'])) {
        // The reset token is valid. Authenticate the user.
    }
    // Remove the token from the DB regardless of success or failure.
}

这些代码片段并非完整的解决方案(我没有进行输入验证和框架集成),但它们应该作为如何操作的示例。


当您验证用户提供的重置令牌时,为什么要使用随机令牌的二进制表示?您认为以下做法是否可行(并且安全):1)使用hash('sha256', bin2hex($token))将令牌的哈希十六进制值存储在数据库中,2)使用if (hash_equals(hash('sha256', $validator), $results[0]['token'])) {...进行验证?谢谢! - Guicara
是的,比较十六进制字符串也是安全的。这只是个人偏好问题。我更喜欢在原始二进制上执行所有加密操作,并且仅在传输或存储时转换为十六进制/ base64。 - Scott Arciszewski
嗨,斯科特,这基本上不仅是一个问题,而且是关于“记住我”功能的整篇文章。为什么不使用唯一的id作为选择器呢?我的意思是,account_recovery表的主键。我们不需要为选择器添加额外的安全层,对吧?谢谢! - Andre Polykanine
id:secret 是可以的。selector:secret 也是可以的。但是 secret 本身不行。目标是将数据库查询(可能会泄露时间信息)与认证协议(应该是恒定时间的)分离开来。 - Scott Arciszewski
OpenSSL的随机数生成器是有害的。对于PHP 5.6,请改用paragonie/random_compat - Scott Arciszewski
显示剩余2条评论

7

您也可以使用DEV_RANDOM,其中128 = 生成的令牌长度的1/2。下面的代码生成256个令牌。

$token = bin2hex(mcrypt_create_iv(128, MCRYPT_DEV_RANDOM));

5
我建议使用MCRYPT_DEV_URANDOM而不是MCRYPT_DEV_RANDOM - Scott Arciszewski

2

当您需要一个非常随机的令牌时,这可能会有所帮助。

<?php
   echo mb_strtoupper(strval(bin2hex(openssl_random_pseudo_bytes(16))));
?>

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接