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="TwoFactorAuthenticator.GetCurrentPIN" />/>
/// </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="TwoFactorAuthenticator.GetCurrentPIN()" />/>
/// </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分析的结果,请根据实际情况进行判断。
上述代码实现了一个用于生成和验证两步验证(Two-Factor Authentication, 2FA)PIN码的类 TwoFactorAuthenticator。这个类主要用于与 Google Authenticator 等应用程序进行交互,提供安全的身份验证机制。以下是代码的主要功能和组成部分的详细解释:
主要功能
生成设置代码:
GenerateSetupCode方法用于生成一个设置代码,用户可以通过扫描生成的二维码来添加账户到 Google Authenticator 等应用中。该方法接受参数如发行者(issuer)、账户标题、账户密钥等,并生成一个包含二维码的设置代码。
生成二维码:
GenerateQrCodeUrl方法生成一个二维码的 URL,二维码中包含了用户的设置代码。二维码的像素大小可以通过参数qrPixelsPerModule来控制。
生成 PIN 码:
GeneratePINAtInterval方法根据给定的账户密钥和时间间隔生成一个有效的 PIN 码。PIN 码是基于 HMAC-SHA1 算法生成的,通常为 6 位数字。
验证 PIN 码:
ValidateTwoFactorPIN方法用于验证用户输入的 PIN 码是否有效。它会检查当前时间生成的 PIN 码是否与用户输入的 PIN 码匹配,允许一定的时间偏差(clock drift)。
获取当前 PIN 码:
GetCurrentPIN方法用于获取当前时间生成的 PIN 码。可以根据账户密钥和时间生成一个有效的 PIN 码。
获取有效的 PIN 码列表:
GetCurrentPINs方法返回在给定的时间窗口内有效的所有 PIN 码,允许一定的时间偏差。
关键组成部分
时间管理:
- 使用 Unix 纪元(1970年1月1日)来计算时间间隔,默认的时间步长为 30 秒。
Base32 编码:
- 使用 Base32 编码来处理账户密钥,以便与 Google Authenticator 等应用兼容。
异常处理:
- 在生成二维码时,处理可能的异常,确保在像素大小过大时抛出适当的异常。
私有方法:
- 包含一些私有方法,如
ConvertSecretToBytes用于将密钥转换为字节数组,RemoveWhitespace用于去除字符串中的空格等。
- 包含一些私有方法,如
总结
这个 TwoFactorAuthenticator 类提供了一整套用于实现两步验证的功能,包括生成设置代码、二维码、PIN 码以及验证用户输入的 PIN 码。它可以帮助开发者在应用中实现更安全的身份验证机制,保护用户账户的安全。
评论加载中...