如何添加使用 Microsoft 或 Google Authenticator 的多因素身份验证






4.70/5 (5投票s)
基于时间的一次性密码算法 (RFC-6238, TOTP - 基于 HMAC 的一次性密码算法) 的令牌,例如由 Microsoft 或 Google Authenticator 移动应用程序实现。
引言
一种基于时间的、一次性密码算法(RFC-6238,TOTP - 基于 HMAC 的一次性密码算法)令牌,由例如 Microsoft 或 Google Authenticator 移动应用程序实现。移动应用程序允许您使用 Microsoft / Google 或任何其他 TOTP 身份验证器应用程序(通过专门生成的 QR 码)注册您的帐户。成功注册后,身份验证器应用程序将每 30 秒生成一个新代码,该代码可用于实现基于 MFA 的登录。为了使其成为完整的 MFA,将 PIN 码作为前缀添加到应用程序生成的代码中。登录密码或有些人称之为密码将是 PIN + 代码。
背景
为了保护对任何 C#、Java 或 C++(Windows 或 Linux)Web 或普通应用程序的访问,MFA 是一个最佳且简单的选择,无需创建您自己的自定义移动应用程序。它完成了情景,即您所知道的和您所拥有的。在这里,您所知道的是您的 PIN 码,而您所拥有的是您的移动应用程序和身份验证器移动应用程序(例如 Microsoft Authenticator)强制执行的生物特征功能。
QR 码的注册
身份验证器应用程序(Microsoft 和 Google)遵循一个标准。但是,只有 Google 定义了注册帐户所需的 URI 和参数。
逻辑上的第一步是生成 QR 码,以便向身份验证器应用程序注册所需的用户。这里的关键成分是 TOTP 种子、公司/Web 应用程序所属的用户以及用户的 UPN 或电子邮件地址。
下面的代码使用 GUID 生成一个种子(我使用 GUID,因为同一 GUID 重新生成的可能性是 20 亿分之一)
/**
* Converts Hex string to Unsigned Bytes (0 to 256)
*/
public static Byte[] HexToByte(string hexStr)
{
byte[] bArray = new byte[hexStr.Length / 2];
for (int i = 0; i < (hexStr.Length / 2); i++)
{
byte firstNibble = Byte.Parse(hexStr.Substring((2 * i), 1), System.Globalization.NumberStyles.HexNumber); // [x,y)
byte secondNibble = Byte.Parse(hexStr.Substring((2 * i) + 1, 1), System.Globalization.NumberStyles.HexNumber);
int finalByte = (secondNibble) | (firstNibble << 4); // bit-operations only with numbers, not bytes.
bArray[i] = (byte)finalByte;
}
return bArray;
}
/*
* Generates GUID as a string and remove brackets
*/
public static string getNewId()
{
string sR = Guid.NewGuid().ToString().ToUpper();
sR = sR.Replace("{", "");
sR = sR.Replace("}", "");
return sR;
}
/*
* Generates the QR code for authenticator as a base64 encoded svg image
* You must use something like
* <img runat="server" id="qrCode" name="qrCode" src="javascript:" alt="Scan this QR code with your mobile application" style="height:300px;width:300px"/>
*/
private void generateQRCode()
{
//create new key based on hash to be used
string seed = getNewId() + getNewId();
seed = seed.Replace("-", "");
seed = seed.Substring(0, 40);
byte[] byteSeed = HexToByte(seed);
//Must save this seed to be able to validate the TOTP
var KeyString = Base32.ToBase32String(byteSeed);
string orgDomain = "elogic.synology.me";
string orgName = "eLogic Builders Inc.";
string userUPN = "Kashif" + '@' + orgDomain;
const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&algorithm=SHA1&digits=6&period=30";
string tokenURI = string.Format(
AuthenticatorUriFormat,
HttpUtility.UrlEncode(orgDomain),
HttpUtility.UrlEncode(userUPN),
KeyString);
var qr = QrCode.EncodeText(tokenURI, QrCode.Ecc.High);
string base64EncodedImage = Convert.ToBase64String(Encoding.UTF8.GetBytes(qr.ToSvgString(4)));
string imageSrc = "data:image/svg+xml;base64," + base64EncodedImage;
//Assign image here in your ASP application
//this.qrCode.Src = imageSrc;
}
KeyString
(TOTP 种子)必须保存并链接到正在进行身份验证的用户。将使用相同的种子来验证用户输入的 TOTP。为了生成 QR 码,我使用了 Net.Codecrete.QrCodeGenerator nuget.org 包。这非常适合在 Windows 和 Linux 上生成 QR 码(使用 Mono 框架)。您可以使用适合您应用程序的其他实现。
以下是我用来发送注册的注册链接的示例
当用户点击该链接时,QR 码生成和注册序列开始。以下是向用户显示的内容
用户使用 Microsoft 或 Google 或任何其他 RFC-6238 兼容的 TOTP 身份验证器应用程序扫描 QR 码。该应用程序应注册种子和用户的 UPN,并应开始生成 TOTP
使用下面的 RFC-6238 兼容的类,您可以验证生成的 TOTP(它是稍微修改过的 Microsoft 代码示例版本)
using System;
using System.Diagnostics;
using System.Net;
using System.Security.Cryptography;
using System.Text;
class SecurityToken
{
private readonly byte[] _data;
public SecurityToken(byte[] data)
{
_data = (byte[])data.Clone();
}
internal byte[] GetDataNoClone()
{
return _data;
}
}
public static class Rfc6238AuthenticationService
{
private static readonly DateTime _unixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static readonly TimeSpan _timestep = TimeSpan.FromMinutes(3);
private static readonly Encoding _encoding = new UTF8Encoding(false, true);
public static int ComputeTotp(HashAlgorithm hashAlgorithm, ulong timestepNumber, string modifier)
{
// # of 0's = length of pin
const int mod = 1000000;
// See https://tools.ietf.org/html/rfc4226
// We can add an optional modifier
var timestepAsBytes = BitConverter.GetBytes(IPAddress.HostToNetworkOrder((long)timestepNumber));
var hash = hashAlgorithm.ComputeHash(ApplyModifier(timestepAsBytes, modifier));
// Generate DT string
var offset = hash[hash.Length - 1] & 0xf;
Debug.Assert(offset + 4 < hash.Length);
var binaryCode = (hash[offset] & 0x7f) << 24
| (hash[offset + 1] & 0xff) << 16
| (hash[offset + 2] & 0xff) << 8
| (hash[offset + 3] & 0xff);
return binaryCode % mod;
}
private static byte[] ApplyModifier(byte[] input, string modifier)
{
if (String.IsNullOrEmpty(modifier))
{
return input;
}
var modifierBytes = _encoding.GetBytes(modifier);
var combined = new byte[checked(input.Length + modifierBytes.Length)];
Buffer.BlockCopy(input, 0, combined, 0, input.Length);
Buffer.BlockCopy(modifierBytes, 0, combined, input.Length, modifierBytes.Length);
return combined;
}
// More info: https://tools.ietf.org/html/rfc6238#section-4
private static ulong GetCurrentTimeStepNumber()
{
var delta = DateTime.UtcNow - _unixEpoch;
return (ulong)(delta.Ticks / _timestep.Ticks);
}
private static int GenerateCode(SecurityToken securityToken, string modifier = null)
{
if (securityToken == null)
{
throw new ArgumentNullException("securityToken");
}
// Allow a variance of no greater than 9 minutes in either direction
var currentTimeStep = GetCurrentTimeStepNumber();
using (var hashAlgorithm = new HMACSHA1(securityToken.GetDataNoClone()))
{
return ComputeTotp(hashAlgorithm, currentTimeStep, modifier);
}
}
private static bool ValidateCode(SecurityToken securityToken, int code, string modifier = null)
{
if (securityToken == null)
{
throw new ArgumentNullException("securityToken");
}
// Allow a variance of no greater than 9 minutes in either direction
var currentTimeStep = GetCurrentTimeStepNumber();
using (var hashAlgorithm = new HMACSHA1(securityToken.GetDataNoClone()))
{
for (var i = -2; i <= 2; i++)
{
var computedTotp = ComputeTotp(hashAlgorithm, (ulong)((long)currentTimeStep + i), modifier);
if (computedTotp == code)
{
return true;
}
}
}
// No match
return false;
}
}
以下是您可用于验证生成的 TOTP 的函数
public bool CheckTimeBasedOTP_Rfc6238(byte[] byteSeed, string incomingOTP)
{
bool bR = false;
int IntIncomingCode = int.Parse(incomingOTP);
var hash = new HMACSHA1(byteSeed);
var unixTimestamp = Convert.ToInt64(Math.Round((DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0)).TotalSeconds));
var timestep = Convert.ToInt64(unixTimestamp / 30);
// Allow codes from 90s in each direction (we could make this configurable?)
for (long i = -2; i <= 2; i++)
{
var expectedCode = Rfc6238AuthenticationService.ComputeTotp(hash, (ulong)(timestep + i), modifier: null);
if (expectedCode == IntIncomingCode)
{
bR = true;
break;
}
}
return bR;
}
byteSeed
是一个字节数组,您可以从 Base32 编码并保存的种子 转换而来。
Base32 编码器/解码器
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;
public static class Base32
{
private static readonly char[] _digits = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".ToCharArray();
private const int _mask = 31;
private const int _shift = 5;
private static int CharToInt(char c)
{
switch (c)
{
case 'A': return 0;
case 'B': return 1;
case 'C': return 2;
case 'D': return 3;
case 'E': return 4;
case 'F': return 5;
case 'G': return 6;
case 'H': return 7;
case 'I': return 8;
case 'J': return 9;
case 'K': return 10;
case 'L': return 11;
case 'M': return 12;
case 'N': return 13;
case 'O': return 14;
case 'P': return 15;
case 'Q': return 16;
case 'R': return 17;
case 'S': return 18;
case 'T': return 19;
case 'U': return 20;
case 'V': return 21;
case 'W': return 22;
case 'X': return 23;
case 'Y': return 24;
case 'Z': return 25;
case '2': return 26;
case '3': return 27;
case '4': return 28;
case '5': return 29;
case '6': return 30;
case '7': return 31;
}
return -1;
}
public static byte[] FromBase32String(string encoded)
{
if (encoded == null)
throw new ArgumentNullException(nameof(encoded));
// Remove whitespace and padding. Note: the padding is used as hint
// to determine how many bits to decode from the last incomplete chunk
// Also, canonicalize to all upper case
encoded = encoded.Trim().TrimEnd('=').ToUpper();
if (encoded.Length == 0)
return new byte[0];
var outLength = encoded.Length * _shift / 8;
var result = new byte[outLength];
var buffer = 0;
var next = 0;
var bitsLeft = 0;
var charValue = 0;
foreach (var c in encoded)
{
charValue = CharToInt(c);
if (charValue < 0)
throw new FormatException("Illegal character: `" + c + "`");
buffer <<= _shift;
buffer |= charValue & _mask;
bitsLeft += _shift;
if (bitsLeft >= 8)
{
result[next++] = (byte)(buffer >> (bitsLeft - 8));
bitsLeft -= 8;
}
}
return result;
}
public static string ToBase32String(byte[] data, bool padOutput = false)
{
return ToBase32String(data, 0, data.Length, padOutput);
}
public static string ToBase32String(byte[] data, int offset, int length, bool padOutput = false)
{
if (data == null)
throw new ArgumentNullException(nameof(data));
if (offset < 0)
throw new ArgumentOutOfRangeException(nameof(offset));
if (length < 0)
throw new ArgumentOutOfRangeException(nameof(length));
if ((offset + length) > data.Length)
throw new ArgumentOutOfRangeException();
if (length == 0)
return "";
// SHIFT is the number of bits per output character, so the length of the
// output is the length of the input multiplied by 8/SHIFT, rounded up.
// The computation below will fail, so don't do it.
if (length >= (1 << 28))
throw new ArgumentOutOfRangeException(nameof(data));
var outputLength = (length * 8 + _shift - 1) / _shift;
var result = new StringBuilder(outputLength);
var last = offset + length;
int buffer = data[offset++];
var bitsLeft = 8;
while (bitsLeft > 0 || offset < last)
{
if (bitsLeft < _shift)
{
if (offset < last)
{
buffer <<= 8;
buffer |= (data[offset++] & 0xff);
bitsLeft += 8;
}
else
{
int pad = _shift - bitsLeft;
buffer <<= pad;
bitsLeft += pad;
}
}
int index = _mask & (buffer >> (bitsLeft - _shift));
bitsLeft -= _shift;
result.Append(_digits[index]);
}
if (padOutput)
{
int padding = 8 - (result.Length % 8);
if (padding > 0) result.Append('=', padding == 8 ? 0 : padding);
}
return result.ToString();
}
}
历史
第一版:2024 年 7 月 8 日