using System.Security.Cryptography;
using System.Text;
using QRCoder;

namespace Dpz.Core.Authenticator;

/// <summary>
/// modified from
/// http://brandonpotter.com/2014/09/07/implementing-free-two-factor-authentication-in-net-using-google-authenticator/
/// https://github.com/brandonpotter/GoogleAuthenticator
/// With elements borrowed from https://github.com/stephenlawuk/GoogleAuthenticator
/// </summary>
public class TwoFactorAuthenticator
{
    private static readonly DateTime Epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);

    private readonly TimeSpan _defaultClockDriftTolerance;

    private readonly HashType _hashType;

    private readonly int _timeStep;

    public TwoFactorAuthenticator()
        : this(HashType.SHA1) { }

    public TwoFactorAuthenticator(int timeStep)
        : this(HashType.SHA1, timeStep) { }

    /// <summary>
    /// Initializes a new instance of the <see cref="TwoFactorAuthenticator"/> class.
    /// </summary>
    /// <param name="hashType">The type of Hash to generate (default is SHA1)</param>
    /// <param name="timeStep">The length of the "time step" - i.e. how often the code changes. Default is 30.</param>
    public TwoFactorAuthenticator(HashType hashType, int timeStep = 30)
    {
        _hashType = hashType;
        _defaultClockDriftTolerance = TimeSpan.FromMinutes(5);
        this._timeStep = timeStep;
    }

    /// <summary>
    /// Generate a setup code for a Google Authenticator user to scan
    /// </summary>
    /// <param name="issuer">Issuer ID (the name of the system, i.e. 'MyApp'),
    /// can be omitted but not recommended https://github.com/google/google-authenticator/wiki/Key-Uri-Format
    /// </param>
    /// <param name="accountTitleNoSpaces">Account Title (no spaces)</param>
    /// <param name="accountSecretKey">Account Secret Key</param>
    /// <param name="secretIsBase32">Flag saying if accountSecretKey is in Base32 format or original secret</param>
    /// <param name="qrPixelsPerModule">Number of pixels per QR Module (2 pixels give ~ 100x100px QRCode,
    /// should be 10 or less)</param>
    /// <returns>SetupCode object</returns>
    public SetupCode GenerateSetupCode(
        string issuer,
        string accountTitleNoSpaces,
        string accountSecretKey,
        bool secretIsBase32,
        int qrPixelsPerModule = 3
    ) =>
        GenerateSetupCode(
            issuer,
            accountTitleNoSpaces,
            ConvertSecretToBytes(accountSecretKey, secretIsBase32),
            qrPixelsPerModule
        );

    /// <summary>
    /// Generate a setup code for a Google Authenticator user to scan
    /// </summary>
    /// <param name="issuer">Issuer ID (the name of the system, i.e. 'MyApp'), can be omitted but not
    /// recommended https://github.com/google/google-authenticator/wiki/Key-Uri-Format </param>
    /// <param name="accountTitleNoSpaces">Account Title (no spaces)</param>
    /// <param name="accountSecretKey">Account Secret Key as byte[]</param>
    /// <param name="qrPixelsPerModule">Number of pixels per QR Module
    /// (2 = ~120x120px QRCode, should be 10 or less)</param>
    /// <param name="generateQrCode"></param>
    /// <returns>SetupCode object</returns>
    public SetupCode GenerateSetupCode(
        string issuer,
        string accountTitleNoSpaces,
        byte[] accountSecretKey,
        int qrPixelsPerModule = 3,
        bool generateQrCode = true
    )
    {
        if (string.IsNullOrWhiteSpace(accountTitleNoSpaces))
        {
            throw new NullReferenceException("Account Title is null");
        }

        accountTitleNoSpaces = RemoveWhitespace(Uri.EscapeDataString(accountTitleNoSpaces));

        var encodedSecretKey = Base32Encoding.ToString(accountSecretKey);

        var provisionUrl = string.IsNullOrWhiteSpace(issuer)
            ? $"otpauth://totp/{accountTitleNoSpaces}?secret={encodedSecretKey.Trim('=')}{(_hashType == HashType.SHA1 ? "" : $"&algorithm={_hashType}")}"
            //  https://github.com/google/google-authenticator/wiki/Conflicting-Accounts
            // Added additional prefix to account otpauth://totp/Company:joe_example@gmail.com
            // for backwards compatibility
            : $"otpauth://totp/{UrlEncode(issuer)}:{accountTitleNoSpaces}?secret={encodedSecretKey.Trim('=')}&issuer={UrlEncode(issuer)}{(_hashType == HashType.SHA1 ? "" : $"&algorithm={_hashType}")}";

        return new SetupCode(
            accountTitleNoSpaces,
            encodedSecretKey.Trim('='),
            generateQrCode ? GenerateQrCodeUrl(qrPixelsPerModule, provisionUrl) : "",
            provisionUrl
        );
    }

    private static string GenerateQrCodeUrl(int qrPixelsPerModule, string provisionUrl)
    {
        string qrCodeUrl;
        try
        {
            using (var qrGenerator = new QRCodeGenerator())
            using (
                var qrCodeData = qrGenerator.CreateQrCode(provisionUrl, QRCodeGenerator.ECCLevel.Q)
            )
            using (var qrCode = new PngByteQRCode(qrCodeData))
            {
                var qrCodeImage = qrCode.GetGraphic(qrPixelsPerModule);
                qrCodeUrl = $"data:image/png;base64,{Convert.ToBase64String(qrCodeImage)}";
            }
        }
        catch (System.Runtime.InteropServices.ExternalException e)
        {
            if (e.Message.Contains("GDI+") && qrPixelsPerModule > 10)
            {
                throw new QrException(
                    $"There was a problem generating a QR code. The value of {nameof(qrPixelsPerModule)}"
                        + " should be set to a value of 10 or less for optimal results.",
                    e
                );
            }
            else
            {
                throw;
            }
        }

        return qrCodeUrl;
    }

    private static string RemoveWhitespace(string str) =>
        new string(str.Where(c => !char.IsWhiteSpace(c)).ToArray());

    private string UrlEncode(string value)
    {
        return Uri.EscapeDataString(value);
    }

    /// <summary>
    /// This method is generally called via <see cref="GetCurrentPIN(string, bool)" />
    /// </summary>
    /// <param name="accountSecretKey">The acount secret key as a string</param>
    /// <param name="counter">The number of 30-second (by default) intervals since the unix epoch</param>
    /// <param name="digits">The desired length of the returned PIN</param>
    /// <param name="secretIsBase32">Flag saying if accountSecretKey is in Base32 format or original secret</param>
    /// <returns>A 'PIN' that is valid for the specified time interval</returns>
    public string GeneratePINAtInterval(
        string accountSecretKey,
        long counter,
        int digits = 6,
        bool secretIsBase32 = false
    ) =>
        GeneratePINAtInterval(
            ConvertSecretToBytes(accountSecretKey, secretIsBase32),
            counter,
            digits
        );

    /// <summary>
    /// This method is generally called via <see cref="GetCurrentPIN(byte[])" />
    /// </summary>
    /// <param name="accountSecretKey">The acount secret key as a byte array</param>
    /// <param name="counter">The number of 30-second (by default) intervals since the unix epoch</param>
    /// <param name="digits">The desired length of the returned PIN</param>
    /// <returns>A 'PIN' that is valid for the specified time interval</returns>
    public string GeneratePINAtInterval(byte[] accountSecretKey, long counter, int digits = 6) =>
        GenerateHashedCode(accountSecretKey, counter, digits);

    private string GenerateHashedCode(byte[] key, long iterationNumber, int digits = 6)
    {
        var counter = BitConverter.GetBytes(iterationNumber);

        if (BitConverter.IsLittleEndian)
        {
            Array.Reverse(counter);
        }

        HMAC hmac;
        if (_hashType == HashType.SHA256)
        {
            hmac = new HMACSHA256(key);
        }
        else if (_hashType == HashType.SHA512)
        {
            hmac = new HMACSHA512(key);
        }
        else
        {
            hmac = new HMACSHA1(key);
        }

        var hash = hmac.ComputeHash(counter);
        var offset = hash[^1] & 0xf;

        // Convert the 4 bytes into an integer, ignoring the sign.
        var binary =
            ((hash[offset] & 0x7f) << 24)
            | (hash[offset + 1] << 16)
            | (hash[offset + 2] << 8)
            | hash[offset + 3];

        var password = binary % (int)Math.Pow(10, digits);
        return password.ToString(new string('0', digits));
    }

    private long GetCurrentCounter() => GetCurrentCounter(DateTime.UtcNow, Epoch);

    private long GetCurrentCounter(DateTime now, DateTime epoch) =>
        (long)(now - epoch).TotalSeconds / _timeStep;

    /// <summary>
    /// Given a PIN from a client, check if it is valid at the current time.
    /// </summary>
    /// <param name="accountSecretKey">Account Secret Key</param>
    /// <param name="twoFactorCodeFromClient">The PIN from the client</param>
    /// <param name="secretIsBase32">Flag saying if accountSecretKey is in Base32 format or original secret</param>
    /// <returns>True if PIN is currently valid</returns>
    public bool ValidateTwoFactorPIN(
        string accountSecretKey,
        string twoFactorCodeFromClient,
        bool secretIsBase32 = false
    ) =>
        ValidateTwoFactorPIN(
            accountSecretKey,
            twoFactorCodeFromClient,
            _defaultClockDriftTolerance,
            secretIsBase32
        );

    /// <summary>
    /// Given a PIN from a client, check if it is valid at the current time.
    /// </summary>
    /// <param name="accountSecretKey">Account Secret Key</param>
    /// <param name="twoFactorCodeFromClient">The PIN from the client</param>
    /// <param name="timeTolerance">The time window within which to check to allow for clock drift between devices.</param>
    /// <param name="secretIsBase32">Flag saying if accountSecretKey is in Base32 format or original secret</param>
    /// <returns>True if PIN is currently valid</returns>
    public bool ValidateTwoFactorPIN(
        string accountSecretKey,
        string twoFactorCodeFromClient,
        TimeSpan timeTolerance,
        bool secretIsBase32 = false
    ) =>
        ValidateTwoFactorPIN(
            ConvertSecretToBytes(accountSecretKey, secretIsBase32),
            twoFactorCodeFromClient,
            timeTolerance
        );

    /// <summary>
    /// Given a PIN from a client, check if it is valid at the current time.
    /// </summary>
    /// <param name="accountSecretKey">Account Secret Key</param>
    /// <param name="twoFactorCodeFromClient">The PIN from the client</param>
    /// <returns>True if PIN is currently valid</returns>
    public bool ValidateTwoFactorPIN(byte[] accountSecretKey, string twoFactorCodeFromClient) =>
        ValidateTwoFactorPIN(
            accountSecretKey,
            twoFactorCodeFromClient,
            _defaultClockDriftTolerance
        );

    /// <summary>
    /// Given a PIN from a client, check if it is valid at the current time.
    /// </summary>
    /// <param name="accountSecretKey">Account Secret Key</param>
    /// <param name="twoFactorCodeFromClient">The PIN from the client</param>
    /// <param name="timeTolerance">The time window within which to check to allow for clock drift between devices.</param>
    /// <returns>True if PIN is currently valid</returns>
    public bool ValidateTwoFactorPIN(
        byte[] accountSecretKey,
        string twoFactorCodeFromClient,
        TimeSpan timeTolerance
    ) => GetCurrentPINs(accountSecretKey, timeTolerance).Any(c => c == twoFactorCodeFromClient);

    /// <summary>
    /// Given a PIN from a client, check if it is valid at the current time.
    /// </summary>
    /// <param name="accountSecretKey">Account Secret Key</param>
    /// <param name="twoFactorCodeFromClient">The PIN from the client</param>
    /// <param name="iterationOffset">The counter window within which to check to allow for clock drift between devices.</param>
    /// <param name="secretIsBase32">Flag saying if accountSecretKey is in Base32 format or original secret</param>
    /// <returns>True if PIN is currently valid</returns>
    public bool ValidateTwoFactorPIN(
        string accountSecretKey,
        string twoFactorCodeFromClient,
        int iterationOffset,
        bool secretIsBase32 = false
    ) =>
        ValidateTwoFactorPIN(
            ConvertSecretToBytes(accountSecretKey, secretIsBase32),
            twoFactorCodeFromClient,
            iterationOffset
        );

    /// <summary>
    /// Given a PIN from a client, check if it is valid at the current time.
    /// </summary>
    /// <param name="accountSecretKey">Account Secret Key</param>
    /// <param name="twoFactorCodeFromClient">The PIN from the client</param>
    /// <param name="iterationOffset">The counter window within which to check to allow for clock drift between devices.</param>
    /// <returns>True if PIN is currently valid</returns>
    public bool ValidateTwoFactorPIN(
        byte[] accountSecretKey,
        string twoFactorCodeFromClient,
        int iterationOffset
    ) => GetCurrentPINs(accountSecretKey, iterationOffset).Any(c => c == twoFactorCodeFromClient);

    /// <summary>
    /// Get the PIN for current time; the same code that a 2FA app would generate for the current time.
    /// Do not validate directly against this as clockdrift may cause a a different PIN to be generated than one you did a second ago.
    /// </summary>
    /// <param name="accountSecretKey">Account Secret Key</param>
    /// <param name="secretIsBase32">Flag saying if accountSecretKey is in Base32 format or original secret</param>
    /// <returns>A 6-digit PIN</returns>
    public string GetCurrentPIN(string accountSecretKey, bool secretIsBase32 = false) =>
        GeneratePINAtInterval(
            accountSecretKey,
            GetCurrentCounter(),
            secretIsBase32: secretIsBase32
        );

    /// <summary>
    /// Get the PIN for current time; the same code that a 2FA app would generate for the current time.
    /// Do not validate directly against this as clockdrift may cause a a different PIN to be generated than one you did a second ago.
    /// </summary>
    /// <param name="accountSecretKey">Account Secret Key</param>
    /// <param name="now">The time you wish to generate the pin for</param>
    /// <param name="secretIsBase32">Flag saying if accountSecretKey is in Base32 format or original secret</param>
    /// <returns>A 6-digit PIN</returns>
    public string GetCurrentPIN(
        string accountSecretKey,
        DateTime now,
        bool secretIsBase32 = false
    ) =>
        GeneratePINAtInterval(
            accountSecretKey,
            GetCurrentCounter(now, Epoch),
            secretIsBase32: secretIsBase32
        );

    /// <summary>
    /// Get the PIN for current time; the same code that a 2FA app would generate for the current time.
    /// Do not validate directly against this as clockdrift may cause a a different PIN to be generated.
    /// </summary>
    /// <param name="accountSecretKey">Account Secret Key</param>
    /// <returns>A 6-digit PIN</returns>
    public string GetCurrentPIN(byte[] accountSecretKey) =>
        GeneratePINAtInterval(accountSecretKey, GetCurrentCounter());

    /// <summary>
    /// Get the PIN for current time; the same code that a 2FA app would generate for the current time.
    /// Do not validate directly against this as clockdrift may cause a a different PIN to be generated.
    /// </summary>
    /// <param name="accountSecretKey">Account Secret Key</param>
    /// <param name="now">The time you wish to generate the pin for</param>
    /// <returns>A 6-digit PIN</returns>
    public string GetCurrentPIN(byte[] accountSecretKey, DateTime now) =>
        GeneratePINAtInterval(accountSecretKey, GetCurrentCounter(now, Epoch));

    /// <summary>
    /// Get all the PINs that would be valid within the time window allowed for by the default clock drift.
    /// </summary>
    /// <param name="accountSecretKey">Account Secret Key</param>
    /// <param name="secretIsBase32">Flag saying if accountSecretKey is in Base32 format or original secret</param>
    /// <returns></returns>
    public string[] GetCurrentPINs(string accountSecretKey, bool secretIsBase32 = false) =>
        GetCurrentPINs(accountSecretKey, _defaultClockDriftTolerance, secretIsBase32);

    /// <summary>
    /// Get all the PINs that would be valid within the time window allowed for by the specified clock drift.
    /// </summary>
    /// <param name="accountSecretKey">Account Secret Key</param>
    /// <param name="timeTolerance">The clock drift size you want to generate PINs for</param>
    /// <param name="secretIsBase32">Flag saying if accountSecretKey is in Base32 format or original secret</param>
    /// <returns></returns>
    public string[] GetCurrentPINs(
        string accountSecretKey,
        TimeSpan timeTolerance,
        bool secretIsBase32 = false
    ) => GetCurrentPINs(ConvertSecretToBytes(accountSecretKey, secretIsBase32), timeTolerance);

    /// <summary>
    /// Get all the PINs that would be valid within the time window allowed for by the default clock drift.
    /// </summary>
    /// <param name="accountSecretKey">Account Secret Key</param>
    /// <returns></returns>
    public string[] GetCurrentPINs(byte[] accountSecretKey) =>
        GetCurrentPINs(accountSecretKey, _defaultClockDriftTolerance);

    /// <summary>
    /// Get all the PINs that would be valid within the time window allowed for by the specified clock drift.
    /// </summary>
    /// <param name="accountSecretKey">Account Secret Key</param>
    /// <param name="timeTolerance">The clock drift size you want to generate PINs for</param>
    /// <returns></returns>
    public string[] GetCurrentPINs(byte[] accountSecretKey, TimeSpan timeTolerance)
    {
        var iterationOffset = 0;

        if (timeTolerance.TotalSeconds >= _timeStep)
        {
            iterationOffset = Convert.ToInt32(timeTolerance.TotalSeconds / _timeStep);
        }

        return GetCurrentPINs(accountSecretKey, iterationOffset);
    }

    /// <summary>
    /// Get all the PINs that would be valid within the time window allowed for by the specified clock drift.
    /// </summary>
    /// <param name="accountSecretKey">Account Secret Key</param>
    /// <param name="iterationOffset">The counter drift size you want to generate PINs for</param>
    /// <returns></returns>
    public string[] GetCurrentPINs(byte[] accountSecretKey, int iterationOffset)
    {
        var codes = new List<string>();
        var iterationCounter = GetCurrentCounter();

        var iterationStart = iterationCounter - iterationOffset;
        var iterationEnd = iterationCounter + iterationOffset;

        for (var counter = iterationStart; counter <= iterationEnd; counter++)
        {
            codes.Add(GeneratePINAtInterval(accountSecretKey, counter));
        }

        return codes.ToArray();
    }

    private static byte[] ConvertSecretToBytes(string secret, bool secretIsBase32) =>
        secretIsBase32 ? Base32Encoding.ToBytes(secret) : Encoding.UTF8.GetBytes(secret);
}
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

这段代码实现了一个用于时间一次性密码(TOTP,Google Authenticator 风格)两步验证的辅助类 TwoFactorAuthenticator。它封装了生成密钥二维码(供手机端扫码注册)、生成当前验证码、以及校验用户提交验证码的常见功能。下面按要点解释各部分功能与工作原理。

总体说明

  • 目的:实现 RFC 6238/TOTP 风格的一次性验证码生成与验证(支持 SHA1/SHA256/SHA512),并能生成 otpauth:// URI 和二维码图片(data:image/png;base64,…),方便手机端(如 Google Authenticator、Authy)扫码注册。
  • 可配置项:哈希算法(HashType,默认 SHA1),时间步长 timeStep(默认 30 秒),默认允许的时钟漂移容忍时间为 5 分钟(_defaultClockDriftTolerance)。
  • 依赖:Base32Encoding(用于 Base32 编码/解码)、QRCoder(用于生成二维码)。还有 SetupCode、HashType、QrException 等外部类型。

关键字段与常量

  • Epoch:Unix 纪元(1970-01-01 UTC)。
  • _timeStep:时间步长(秒),默认 30。
  • _hashType:用于 HMAC 的哈希算法(SHA1/SHA256/SHA512)。
  • _defaultClockDriftTolerance:默认允许的时钟漂移(5 分钟)。

主要方法与功能

  1. 生成注册信息和二维码
  • GenerateSetupCode(...)(重载多种签名):
    • 输入:issuer(发起方名称,可选但推荐),accountTitleNoSpaces(账户名,不应含空格),accountSecretKey(字符串或字节数组),secretIsBase32(如果传入字符串是否已是 Base32),qrPixelsPerModule(二维码像素,每个模块的像素数),generateQrCode(是否生成二维码数据URI)。
    • 功能:
      • 将密钥转为 Base32 编码(encodedSecretKey)。
      • 构造 otpauth://totp/… 的 URI(provisionUrl),符合 Google Authenticator Key URI Format(为手机 app 提供配置信息)。uri 中会包含 secret 以及 issuer(如果提供)。
      • 若 generateQrCode 为 true,调用 GenerateQrCodeUrl 生成 PNG 的 base64 data-uri(使用 QRCoder)。
      • 返回 SetupCode(包含账号标题、手动录入的密钥、二维码 data-uri、provisionUrl)。
  • GenerateQrCodeUrl(...):使用 QRCoder 创建二维码 PNG 并返回 data:image/png;base64,… 字符串;对 GDI+ 异常(当 qrPixelsPerModule 太大时)有友好提示。
  1. 生成指定时间间隔的验证码
  • GeneratePINAtInterval(accountSecretKey, counter, digits = 6, secretIsBase32 = false):
    • 将密钥字符串转换为字节数组(如果是 Base32 则解码)。
    • 调用 GenerateHashedCode 生成验证码。
  • GenerateHashedCode(byte[] key, long iterationNumber, int digits):
    • 将 iterationNumber(即时间计数器)转换为 8 字节大端序列(RFC 要求)。
    • 使用 _hashType 对 counter 做 HMAC(HMACSHA1/HMACSHA256/HMACSHA512)。
    • 使用动态截断(dynamic truncation):取 hash 最后一个字节的低 4 位作为 offset,从 offset 位置取 4 个字节,按 RFC 4226 规则生成 31 位正整数 binary。
    • 取 binary % 10^digits 并用前导 0 填充,返回 digits 位字符串(通常 6 位)。
  1. 计算当前计数器(时间)
  • GetCurrentCounter():以 UTC 时间和 Epoch 计算自纪元以来经过的秒数除以 timeStep,得到当前“时间计数器”(counter)。
  1. 获取当前验证码与窗口内的所有验证码
  • GetCurrentPIN(...)(多重重载):返回当前时间(或指定时间)的验证码(6 位,或指定 digits),可传入字节数组或字符串形式的秘钥。
  • GetCurrentPINs(...)/GetCurrentPINs(byte[] accountSecretKey, int iterationOffset):返回从当前计数器减去 iterationOffset 到加上 iterationOffset 范围内的所有验证码,方便验证时容忍时钟漂移。
  • GetCurrentPINs(..., TimeSpan timeTolerance):将 timeTolerance 转换为 iterationOffset(timeTolerance.TotalSeconds / timeStep)。
  1. 验证用户提交的验证码
  • ValidateTwoFactorPIN(...)(多重重载):
    • 支持传入字符串或字节数组形式的密钥,支持直接传入允许的 TimeSpan 或指定 iterationOffset。
    • 实现:生成允许窗口内的所有验证码(GetCurrentPINs)并检查是否有任意一项与用户提供的代码相等。返回 bool(是否匹配)。
    • 默认情况下使用 _defaultClockDriftTolerance(5 分钟)作为时间窗口。

辅助函数

  • ConvertSecretToBytes(string secret, bool secretIsBase32):如果 secretIsBase32,则调用 Base32Encoding.ToBytes,否则使用 UTF8.GetBytes(把字符串直接转换为字节)。
  • RemoveWhitespace:移除字符串中的空白字符(用于清理 accountTitle)。
  • UrlEncode:对 issuer 等进行 URL 编码(Uri.EscapeDataString)。
  • 其它:GenerateQrCodeUrl 中对 QRCoder 的使用和异常处理。

使用示例(伪代码)

  • 生成注册信息并给用户显示二维码:
    • var tfa = new TwoFactorAuthenticator();
    • var setup = tfa.GenerateSetupCode("MyApp", "user@example.com", secretBytes, qrPixelsPerModule: 3);
    • 前端展示 setup.QrCodeSetupImageUrl(data-uri)或手动密钥 setup.ManualEntryKey。
  • 验证用户提交的 2FA 代码:
    • var valid = tfa.ValidateTwoFactorPIN(secretString, userCode, secretIsBase32: true);
  • 生成当前 PIN(仅用于调试/显示,不建议用于认证直接比较):
    • var code = tfa.GetCurrentPIN(secretString, secretIsBase32: true);

注意与改进点

  • 变体与合规:此实现遵循常见的 TOTP(HMAC-based)方式,动态截断与大端计数器符合 RFC 要求。
  • 安全性/细节:
    • GenerateHashedCode 中创建 HMAC 对象后未显式 Dispose(HMAC 实现了 IDisposable),可改进以确保资源及时释放(例如使用 using)。
    • Validate 的字符串比较不是恒时比较,但对 TOTP 验证器而言通常不是主要攻击面;若担心泄露时间可使用恒时比较函数。
    • 使用 UTF8.GetBytes 作为非 Base32 分支意味着“原始密钥字符串”会按 UTF-8 编码为字节;实际使用中通常把秘钥以 Base32 存储并传入 secretIsBase32 = true。
    • 当生成 otpauth URI 时对 secret 做了 Trim('='),这符合 URI 格式(去掉 padding)。

总结

  • TwoFactorAuthenticator 是一个便捷的 TOTP(Google Authenticator 风格)生成与验证工具,提供生成 otpauth URI 与二维码、生成指定时间段/当前时间的验证码,以及在给定时间容忍窗内验证验证码的功能,支持多种 HMAC 哈希算法和可配置的时间步长。
评论加载中...