equit/totp

PHP TOTP 生成器


README

Conmposer Validation and Unit Tests

PHP 的时间基础一次性密码生成器。

使用符合 RFC 6238 标准的 TOTP,为您的应用程序添加双因素认证,兼容常见的身份验证器应用程序,例如 Google Authenticator、KeePassXC、Microsoft Authenticator 等。

快速入门

  1. 为您的用户生成一个安全、随机的密钥

    $user->totpSecret = Totp::randomSecret()
  2. 通知用户其 TOTP 的详细信息,以便将其导入其身份验证器应用程序

    UrlGenerator::for($user->username)->urlFor(new Totp($user->totpSecret))
  3. 当用户登录时,要求他们提供当前的 TOTP 并进行验证

    (new Totp($user->totpSecret))->verify($inputOtp)

内容

另请参阅

简介

TOTP 由 RFC 6238 规定,并基于 基于 HMAC 的一次性密码 (HOTP, RFC4226),通过计算基于自特定时间点以来经过的时间步数和由授权服务器(您的应用程序)和安全的客户端应用程序(您的用户的身份验证器应用程序)所知的随机密钥的哈希消息认证码(HMAC,RFC 2104)来构建。然后,从 HMAC 中导出一个 31 位的整数,并使用最右侧的(通常是 6 位)十进制数字作为密码(如有必要,用 0 填充)。只要服务器和应用程序就当前时间、参考时间、时间步长的大小和密钥达成一致,它们就会同时计算出相同的密码序列。

php-totp 由三个主要组件组成:一个 Totp 类,它执行计算 TOTP 的大部分工作;一个 UrlGenerator 类,它有助于生成用户设置其身份验证器应用程序所需的信息;以及一组 OTP Renderer 类,它们将 Totp 执行的计算结果转换为实际的一次性密码。(后一类的对象由 Totp 内部使用,除非您正在发明自己的密码创建方案——这被强烈反对——否则您不太可能需要了解它们。)

以下示例使用概念性函数、类和方法来填补 php-totp 库之外的函数。例如,encrypt() 函数用作您应用程序用于加密数据的任何机制的占位符。它们还假设一个标准的 TOTP 配置,如 RFC 6238 中所述——即参考时间为 1970 年 1 月 1 日 00:00:00,时间步长为 30 秒,SHA1 哈希算法生成 6 位密码。有关自定义 TOTP 配置的可能性将在稍后描述。

为用户配置 TOTP

为用户配置 TOTP 包括三个步骤

  1. 为用户生成、加密并存储密钥
  2. 向用户发送一个包含URL、密钥和/或二维码的通知,以便他们可以将其导入到他们的认证应用中。
  3. 通过要求用户输入当前的OTP来验证配置是否成功。

生成密钥

TOTP规范要求密钥随机生成(即不由用户选择)。您可以生成自己的密钥,但类提供了一个方法-Totp::randomSecret(),可以为您生成一个随机密钥,保证其密码学安全性,并且足以支持TOTP支持的所有哈希算法。或者,您也可以不提供密钥而直接实例化一个对象,系统将自动生成一个随机密钥。

一旦生成密钥,就必须安全存储。它必须始终以加密形式存储。

$user->totpSecret = encrypt(Totp::randomSecret());
$user->save();

通常,Base32编码与TOTP密钥一起使用,尤其是在将它们添加到认证应用时。如果您需要Base32格式的密钥,php-totp提供了一个Base32编解码器类来执行转换。

$user->totpSecret = encrypt(Base32::encode(Totp::randomSecret()));
$user->save();

有时也会使用Base64。PHP提供了内置的Base64编码和解码,但为了保持一致性,php-totp也提供了一个Base64编解码器类,其操作方式与Base32类相同,只是使用Base64。

最小化密钥未加密的可用性

您应努力减少共享密钥在RAM中未加密的时间。无论您是用它进行配置还是进行验证,您都应该只在准备好使用它之前才检索它,一旦不再需要,就应立即丢弃它,并确保在丢弃之前将包含密钥的变量安全擦除。如果不这样做,未加密的密钥可能会在不再由您的应用使用的内存中“可见”。\Equit\Totp命名空间中的scrubString()函数可用于实现此功能 - 将包含密钥的字符串变量传递给它,它将用随机字节覆盖该字符串。

php-totp库中所有旨在与TOTP密钥一起使用的代码都以这种方式清理其数据,以帮助防止TOTP密钥意外可见。这包括类、TotpSecret类以及Base32Base64类。您应该一旦不再需要它们就取消设置这些类的实例,并确保您没有保留不必要的引用。

通知用户

用户获取其TOTP密钥详情通常有三种常见方式,并且大多数认证应用至少支持其中一种——许多支持所有三种。

1. 仅密钥

第一种是直接向他们提供密钥。由于密钥是二进制字符串,它需要转换为某种文本安全格式,通常使用Base32。此方法通知用户只有在使用标准TOTP配置的情况下才可行——即6位OTP、SHA1哈希、Unix纪元作为参考时间和30秒作为时间步长。如果您使用的是自定义TOTP配置,您需要向用户提供更多信息,并且他们需要执行更多步骤来配置他们的认证应用。

$user->notify(Base32::encode(decrypt($user->totpSecret)));

请注意,在此示例中,用于编码TOTP密钥的临时Base32对象在使用后立即超出作用域,因此其属性被安全擦除。

otpauth URL

第二种方法是提供一个专门构造的URL,让用户的认证器应用能够读取。URL格式在此描述php-totp 提供了一个 UrlGenerator 类来创建这些URL。

$user->notify(UrlGenerator::from("MyWebApp")->for($user->username)->urlFor(new Totp(decrypt($user->totpSecret)));

同样,Totp 对象是临时的,使用后立即超出范围,因此其密钥是安全擦除的。

默认情况下,UrlGenerator 会将尽可能多的信息插入到生成的URL中,以表示您的TOTP设置。因此,如果您使用SHA512哈希算法,生成的URL将包含 algorithm URL参数;如果您使用默认的SHA1算法,则省略 algorithm URL参数。《UrlGenerator》类提供了一个流畅的接口来配置如何构建URL(例如,您可以通过在 urlFor 方法之前链式调用 withAlgorithm 方法来强制生成 algorithm URL参数,即使您使用的是非默认算法)。

此通知方法支持所有自定义设置,除了那些使用非标准参考时间的设置(因为没有URL参数可以指定它)。许多具有TOTP功能的认证器应用支持此类型的URL,尽管您需要检查您针对用户的目标应用中的支持级别——例如,Google Authenticator 支持URL,但不识别 algorithm 参数,始终使用SHA1算法。

3. 二维码

第三种方法是为用户提供一个认证器应用可以扫描的二维码。这实际上与上述使用URL的方法相同——二维码只是生成URL的表示。

php-totp 目前还没有二维码生成器,但应该很容易使用现有的二维码生成器以及 UrlGenerator 来创建发送给用户的二维码。 bacon/bacon-qr-code 是这样的外部库之一。

验证配置成功

一旦为用户配置了,您需要要求他们从认证器应用中获取OTP以确认它已成功设置。收到用户输入后,验证很简单。

$isVerified = (new Totp(decrypt($user->totpSecret))->verify($inputOtp);

为了避免用户在时间步长快结束时输入OTP时出现的问题,您可以选择接受一小部分以前的密码以及当前密码。向 Totp::verify() 方法的 window 参数提供一个参数,该参数标识验证将回退的最大时间步数以检查匹配的OTP。

$isVerified = (new Totp(decrypt($user->totpSecret))->verify(password: $inputOtp, window: 1);

默认情况下,Totp::verify() 只接受当前的OTP。强烈建议您使用最多1个窗口进行验证。

批量配置用户

您可以使用一个UrlGenerator实例为多个用户配置TOTP,并通知每个用户他们自己的唯一URL。

$generator = UrlGenerator::from("Equit");

foreach ($users as $user) {
   $totp = new Totp(algorithm: Totp::Sha512Algorithm);
   $user->totpSecret = $totp->secret();
   $user->save();
   $user->notify($generator->for($user->username)->urlFor($totp));
}

unset($totp);

身份验证

验证用户的TOTP主要是一个简单的过程,即要求用户提供当前的OTP并验证它。这与验证他们的TOTP应用的初始设置相同。

$isVerified = (new Totp(decrypt($user->totpSecret))->verify($userInput);

或者,使用验证窗口

$isVerified = (new Totp(decrypt($user->totpSecret))->verify(password: $inputOtp, window: 1);

如果 Totp::verify() 返回 false,则用户未提供正确的OTP,不应使用您的应用进行认证;如果它返回 true,则用户已提供了有效的OTP,可以进行认证。

确保 OTP 只使用一次

RFC规定,每个生成的OTP只能使用一次以成功认证——一旦OTP已成功用于认证,该OTP就不应再次使用。

确保每个一次性密码(OTP)不会被重复使用的一种方法是在每次成功认证后记录TOTP计数器。计数器是一个递增整数,表示自参考时间以来经过了多少个时间步。通过记录最高使用过的计数器值,并拒绝验证在相应时间步或之前生成的任何OTP,可以确保没有OTP可以被重复使用。

$totp = new Totp(decrypt($user->totpSecret));

if ($user->highestUsedTotpCounter < $totp->counter()) {
    if ($totp->verify($inputOtp)) {
       $user->highestUsedTotpCounter = $totp->counter();
       $user->save();
       // user is authenticated
    } else {
        // incorrect OTP
    }
} else {
    // OTP has already been used
}

// ensure the secret is shredded
scrubString($inputOtp);
unset($totp);

您还可以在调用Totp::verify()时使用验证窗口,但别忘了调整窗口以避免接受之前使用过的OTP

$totp = new Totp(decrypt($user->totpSecret));
$window = min(1, $totp->counter() - $user->highestUsedTotpCounter - 1);

if (0 <= $window) {
    if ($totp->verify(password: $inputOtp, window: $window)) {
        ...
    }
}

// ensure the secret is shredded
scrubString($inputOtp);
unset($totp);

您需要确保所有使用TOTP密钥的认证路径都受到OTP重复使用保护——例如,如果您有一个移动应用程序和一个Web应用程序,您必须确保用于Web应用程序认证的OTP不能随后用于移动应用程序的认证。《RFC 4226》对这一理由有很好的讨论。

自定义TOTP配置

您可以对TOTP设置进行四项自定义

  1. 哈希算法
  2. 参考时间戳
  3. 时间步长的大小
  4. OTP的位数

自定义TOTP设置应被视为一次性选项。一旦确定设置,就很难更改(您需要重新配置所有用户,并且他们都需要重新配置他们的认证应用程序),因此通常最好在开始之前仔细选择设置。

Totp构造函数和便利的工厂方法Totp::sixDigits()Totp::eightDigits()Totp::integer()都接受参数来自定义TOTP的所有四个方面。所有这些参数都使用TOTP RFC中指定的默认值,除非您显式提供值,这意味着您可以使用PHP的命名参数仅自定义那些非默认的TOTP实例方面。

哈希算法

TOTP支持三种哈希算法——SHA1SHA256SHA512。最强的是SHA512,而RFC中指定的默认值是SHA1(与HOTP兼容)。如上所述,在自定义之前,您应检查针对用户的目标认证应用程序是否支持您打算使用的算法。

Totp类提供表示所有支持的哈希算法的常量,您强烈建议使用这些常量以避免在应用程序中抛出异常。使用这些常量可以使您的应用程序对未来可能更新为使用PHP8.1枚举来指定哈希算法的php-totp具有前瞻性。

要使用SHA256创建Totp实例如下

// when provisioning
$totp = new Totp(hashAlgorithm: Totp::Sha256Algorithm);
// when verifying
$totp = new Totp(secret: decrypt($user->totpSecret), hashAlgorithm: Totp::Sha256Algorithm);

类似地,要使用SHA512

// when provisioning
$totp = new Totp(hashAlgorithm: Totp::Sha512Algorithm);
// when verifying
$totp = new Totp(secret: decrypt($user->totpSecret), hashAlgorithm: Totp::Sha512Algorithm);

参考时间戳和时间步长

TOTP使用的计数器是从参考时间以来经过的时间步数。默认情况下,参考时间是00:00:00 01/01/1970(又称Unix纪元或Unix时间戳0)。默认时间步长大小是30秒。除非您有充分的理由更改它们,否则这些默认值是合理的。如果您选择自定义时间步长,请记住,非常短的时间间隔会让用户更难,因为他们有更少的时间输入正确的OTP。同样,使间隔太大也可能让用户更难,因为如果您在他们只有短暂会话后注销,您可能会有效地将他们锁定一段时间。

要使用60秒的时间步长而不是30秒,创建Totp实例如下

// when provisioning
$totp = new Totp(timeStep: 60);
// when verifying
$totp = new Totp(secret: decrypt($user->totpSecret), timeStep: 60);

您可以使用Unix时间戳来自定义参考时间

// when provisioning
$totp = new Totp(referenceTime: 86400);
// when verifying
$totp = new Totp(secret: decrypt($user->totpSecret), referenceTime: 86400);

DateTime对象

// when provisioning
$totp = new Totp(referenceTime: new DateTime("1970-01-02 00:00:00", new DateTimeZone("UTC")));
// when verifying
$totp = new Totp(secret: decrypt($user->totpSecret), referenceTime: new DateTime("1970-01-02 00:00:00", new DateTimeZone("UTC")));

这两个示例都创建了一个将参考时间设置为1970年1月2日午夜UTC的TOTP。强烈建议在创建DateTime对象时使用UTC时区以避免任何混淆。TOTP算法与始终从00:00:00开始测量的Unix时间戳一起工作,即1970年1月1日UTC。

您可以自定义时间步长和参考时间

// when provisioning
$totp = new Totp(timeStep: 60, referenceTime: new DateTime("1970-01-02 00:00:00", new DateTimeZone("UTC")));
// when verifying
$totp = new Totp(secret: decrypt($user->totpSecret), timeStep: 60, referenceTime: new DateTime("1970-01-02 00:00:00", new DateTimeZone("UTC")));

以及哈希算法

// when provisioning
$totp = new Totp(
    timeStep: 60,
    referenceTime: new DateTime("1970-01-02 00:00:00", new DateTimeZone("UTC")),
    hashAlgorithm: Totp::Sha512Algorithm
);

// when verifying
$totp = new Totp(
    secret: decrypt($user->totpSecret),
    timeStep: 60,
    referenceTime: new DateTime("1970-01-02 00:00:00", new DateTimeZone("UTC")),
    hashAlgorithm: Totp::Sha512Algorithm
);

密码位数

OTP的数字位数默认为6位,但范围可以从6位到9位(含9位)。从技术上讲,使用更多数字位数的理由并不是没有,但除了在OTP左边填充0以外,没有任何好处。

创建8位Totp最简单的方法是使用Totp::eightDigits()便捷工厂方法

// when provisioning
$totp = Totp::eightDigits();
// when verifying
$totp = Totp::eightDigits(decrypt($user->totpSecret));

当然,您仍然可以自定义Totp的其他方面

// when provisioning
$totp = Totp::eightDigits(
    timeStep: 60,
    referenceTime: new DateTime("1970-01-02 00:00:00", new DateTimeZone("UTC")),
    hashAlgorithm: Totp::Sha512Algorithm
);

// when verifying
$totp = Totp::eightDigits(
    secret: decrypt($user->totpSecret),
    timeStep: 60,
    referenceTime: new DateTime("1970-01-02 00:00:00", new DateTimeZone("UTC")),
    hashAlgorithm: Totp::Sha512Algorithm
);

如果您想使用不常见的数字位数,请使用Totp::integer()方法

// when provisioning
$totp = Totp::integer(9);
// when verifying
$totp = Totp::integer(digits: 9, secret: decrypt($user->totpSecret));

并且,还有更多的自定义选项

// when provisioning
$totp = Totp::integer(
    digits: 9,
    timeStep: 60,
    referenceTime: new DateTime("1970-01-02 00:00:00", new DateTimeZone("UTC")),
    hashAlgorithm: Totp::Sha512Algorithm
);

// when verifying
$totp = Totp::integer(
    digits: 9,
    secret: decrypt($user->totpSecret),
    timeStep: 60,
    referenceTime: new DateTime("1970-01-02 00:00:00", new DateTimeZone("UTC")),
    hashAlgorithm: Totp::Sha512Algorithm
);

要控制密码的属性,而不仅仅是数字位数,您可以在构造函数中提供renderer参数。例如,为了使Totp生成与Steam验证器兼容的5位OTP,

// when provisioning
$totp = new Totp(renderer: new Steam());
// when verifying
$totp = new Totp(secret: decrypt($user->totpSecret), renderer: new Steam());

以及其他自定义设置

// when provisioning
$totp = new Totp(
    renderer: new Steam(),
    timeStep: 60,
    referenceTime: new DateTime("1970-01-02 00:00:00", new DateTimeZone("UTC")),
    hashAlgorithm: Totp::Sha512Algorithm
);

// when verifying
$totp = new Totp(
    renderer: new Steam(),
    secret: decrypt($user->totpSecret),
    timeStep: 60,
    referenceTime: new DateTime("1970-01-02 00:00:00", new DateTimeZone("UTC")),
    hashAlgorithm: Totp::Sha512Algorithm
);

Base32/Base64 密钥

如前所述,TOTP通常与以Base32或Base64编码的机密信息一起使用,以便于将其输入到验证器应用程序中。如果您使用这些编码之一存储您的机密信息(例如在数据库中的文本字段),在将它们传递给Totp实例之前,需要对其进行解码(以及解密)。

您可以选择自己进行

$totp = new Totp(Base32::decode(decrypt($user->totpSecret)));
$totp = new Totp(Base64::decode(decrypt($user->totpSecret)));

或者使用TotpSecret实用类

$totp = new Totp(TotpSecret::fromBase32(decrypt($user->totpSecret)));
$totp = new Totp(TotpSecret::fromBase64(decrypt($user->totpSecret)));

Base32/Base64和TotpSecret类都负责清除机密信息的细节,因此机密信息将仅在Totp实例中保留一个副本。如果您使用其他Base32/Base64解码器(例如PHP的base64_decode()函数),您可能无法确保在释放之前正确地从内存中清除机密信息。

RFCs