65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 Google Authenticator 在 ASP.NET MVC 中实现双因素身份验证

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (63投票s)

2012年6月13日

MIT

6分钟阅读

viewsIcon

447187

downloadIcon

9851

如何通过双因素身份验证为您的 MVC Web 应用程序添加额外的安全性。

什么是双因素身份验证?

双因素身份验证是一种使用三种有效身份验证因素中的两种来验证用户的方式:用户知道的东西(密码、PIN 等)、用户拥有(智能卡、手机、ATM 卡等)以及用户本身(包括指纹在内的生物识别数据)。在本篇文章中,我们将使用用户知道的东西(密码)和用户拥有的东西(智能手机)。

什么是 Google Authenticator? 

Google Authenticator是一款基于软件的双因素身份验证令牌。它适用于 iOS、Android 和 BlackBerry 操作系统。它提供一个 6 位数字、基于时间或计数器的数字,作为我们双因素身份验证的第二个因素。

 

这里有一个YouTube 视频链接,描述了 Google Authenticator。 

它是如何工作的? 

Google Authenticator 实现RFC 4226RFC 6238中定义的算法。第一个是基于计数器的双因素身份验证实现。第二个是基于时间的实现。首先,服务器和用户就一个用作哈希函数种子值的密钥达成一致。用户可以将其输入 Google Authenticator,或使用 QR 码自动设置您的应用程序。然后 Google Authenticator 使用上述算法之一生成一个代码,该代码在身份验证期间输入。您的服务器将使用相同的算法和密钥来检查该代码。一旦密钥达成一致,在客户端和您的服务器之间传输的唯一数据将是 Google Authenticator 应用程序生成的 6 位数字代码。这些数据绝不会通过 Google 的服务器传输。 

基于计数器的一次性密码生成

要生成一次性密码,我们需要三个信息:密钥、计数器编号以及输出应具有的位数。由于我们使用的是 Google Authenticator,因此我们最多只能使用 6 位数字。 

这是完整的 GeneratePassword 方法

public static string GeneratePassword(string secret, long iterationNumber, int digits = 6)
{
    byte[] counter = BitConverter.GetBytes(iterationNumber);

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

    byte[] key = Encoding.ASCII.GetBytes(secret);

    HMACSHA1 hmac = new HMACSHA1(key, true);

    byte[] hash = hmac.ComputeHash(counter);

    int offset = hash[hash.Length - 1] & 0xf;

    int binary =
        ((hash[offset] & 0x7f) << 24)
        | ((hash[offset + 1] & 0xff) << 16)
        | ((hash[offset + 2] & 0xff) << 8)
        | (hash[offset + 3] & 0xff);

    int password = binary % (int)Math.Pow(10, digits); // 6 digits

    return password.ToString(new string('0', digits));
}

我们来详细了解一下我们在做什么。首先,我们将迭代次数转换为 byte[],然后可以使用HMAC-SHA-1哈希方法对其进行哈希处理。每次身份验证成功后,客户端和服务器都应递增迭代次数。我们使用 System.Security.Cryptography.HMACSHA1 类提供的托管 HMAC-SHA1 哈希方法。接下来,我们计算当前计数器值的哈希。代码的下一部分提取 4 字节整数的二进制值,然后将其缩减到所需的位数。就是这样。整个算法共有 25 行。RFC 4226 Section 5.4对此进行了很好的示例和描述,我将在此复制粘贴

5.4.  Example of HOTP Computation for Digit = 6

   The following code example describes the extraction of a dynamic
   binary code given that hmac_result is a byte array with the HMAC-
   SHA-1 result:

        int offset   =  hmac_result[19] & 0xf ;
        int bin_code = (hmac_result[offset]  & 0x7f) << 24
           | (hmac_result[offset+1] & 0xff) << 16
           | (hmac_result[offset+2] & 0xff) <<  8
           | (hmac_result[offset+3] & 0xff) ;

   SHA-1 HMAC Bytes (Example)

   -------------------------------------------------------------
   | Byte Number                                               |
   -------------------------------------------------------------
   |00|01|02|03|04|05|06|07|08|09|10|11|12|13|14|15|16|17|18|19|
   -------------------------------------------------------------
   | Byte Value                                                |
   -------------------------------------------------------------
   |1f|86|98|69|0e|02|ca|16|61|85|50|ef|7f|19|da|8e|94|5b|55|5a|
   -------------------------------***********----------------++|

   * The last byte (byte 19) has the hex value 0x5a.
   * The value of the lower 4 bits is 0xa (the offset value).
   * The offset value is byte 10 (0xa).
   * The value of the 4 bytes starting at byte 10 is 0x50ef7f19,
     which is the dynamic binary code DBC1.
   * The MSB of DBC1 is 0x50 so DBC2 = DBC1 = 0x50ef7f19 .
   * HOTP = DBC2 modulo 10^6 = 872921.

   We treat the dynamic binary code as a 31-bit, unsigned, big-endian
   integer; the first byte is masked with a 0x7f.

   We then take this number modulo 1,000,000 (10^6) to generate the 6-
   digit HOTP value 872921 decimal.

基于时间的一次性密码生成

RFC 6238定义了一次性密码生成的时间基于实现。基于时间的一次性密码生成建立在上述基于计数器的基础上。它完全相同,只是它根据自Unix 纪元(1970 年 1 月 1 日 00:00 UTC)以来的时间间隔自动定义计数器。严格来说,RFC 允许任何开始日期和时间间隔,但 Google Authenticator 要求使用 Unix 纪元和 30 秒的时间间隔。这意味着我们仅使用密钥就可以获得当前的一次性密码。以下是方法:

public static readonly DateTime UNIX_EPOCH = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);

public static string GetPassword(string secret)
{
    long counter = (long)(DateTime.UtcNow - UNIX_EPOCH).TotalSeconds / 30;

    return HashedOneTimePassword.GeneratePassword(secret, counter);
}

正如您所见,我们只是获取自 Unix 纪元以来的 30 秒间隔数,并将其用作计数器值。这意味着客户端和服务器上的时钟都需要保持同步。这通常使用网络时间协议来完成。

如何使用它?

好了,我们已经介绍了代码的工作原理,下一个问题是如何使用它?我为时间基于的生成创建了一些 GetPassword 方法的额外重载,并添加了一个 IsValid 方法。

public static bool IsValid(string secret, string password, int checkAdjacentIntervals = 1)
{
    if (password == GetPassword(secret))
        return true;

    for (int i = 1; i <= checkAdjacentIntervals; i++)
    {
        if (password == GetPassword(secret, GetCurrentCounter() + i))
            return true;

        if (password == GetPassword(secret, GetCurrentCounter() - i))
            return true;
    }

    return false;
}

IsValid 通过检查密码的相邻间隔来帮助解决一些时钟偏移问题。这可以大大改善用户体验,因为它不需要时钟完全对齐。 

创建 MVC Web 应用程序

使用 Visual Studio 2010 中的“新建项目”向导创建一个新的 MVC 3 Web 应用程序。请确保在向导中选择“Internet”应用程序。这将创建表单身份验证所需的默认 Account 控制器和视图。

创建 TwoFactorProfile 类

接下来,我们创建一个继承自ProfileBase的 Profile 类。这将为给定用户存储双因素密钥。

public class TwoFactorProfile : ProfileBase
{
    public static TwoFactorProfile CurrentUser
    {
        get
        {
            return GetByUserName(Membership.GetUser().UserName);
        }
    }

    public static TwoFactorProfile GetByUserName(string username)
    {
        return (TwoFactorProfile)Create(username);
    }

    public string TwoFactorSecret
    {
        get
        {
            return (string)base["TwoFactorSecret"];
        }
        set
        {
            base["TwoFactorSecret"] = value;
            Save();
        }
    }
}

修改 web.config

修改 <system.web><profile> 元素以继承我们刚刚创建的 TwoFactorProfile

<profile inherits="TwoFactorWeb.TwoFactorProfile">

修改 AccountController

我们需要修改 AccountController 的几个地方。首先,需要修改 Register 操作,将其重定向到 ShowTwoFactorSecret 页面,以便用户设置他们的 Google Authenticator。在 Register 操作中,将 RedirectToAction 修改为:

return RedirectToAction("Index", "Home");

to

return RedirectToAction("ShowTwoFactorSecret", "Account");

接下来,我们创建 ShowTwoFactorSecret 操作

[Authorize]
public ActionResult ShowTwoFactorSecret()
{
    string secret = TwoFactorProfile.CurrentUser.TwoFactorSecret;

    if (string.IsNullOrEmpty(secret))
    {
        byte[] buffer = new byte[9];

        using (RandomNumberGenerator rng = RNGCryptoServiceProvider.Create())
        {
            rng.GetBytes(buffer);
        }

        // Generates a 10 character string of A-Z, a-z, 0-9
        // Don't need to worry about any = padding from the
        // Base64 encoding, since our input buffer is divisible by 3
        TwoFactorProfile.CurrentUser.TwoFactorSecret = Convert.ToBase64String(buffer).Substring(0, 10).Replace('/', '0').Replace('+', '1');

        secret = TwoFactorProfile.CurrentUser.TwoFactorSecret;
    }

    var enc = new Base32Encoder().Encode(Encoding.ASCII.GetBytes(secret));

    return View(new TwoFactorSecret { EncodedSecret = enc });
}

这只是生成一个新的随机 10 位密钥,然后以 Base32 编码格式显示给用户,这是 Google Authenticator 希望用户输入的方式。您可以随心所欲地创建密钥,但它必须至少有 10 个字符,否则 Google Authenticator 会报错。

最后,我们更改 LogOn 操作,以检查用户提供的代码以确保其有效。我们新的 LogOn 操作如下所示:

[HttpPost]
public ActionResult LogOn(LogOnModel model, string returnUrl)
{
    if (ModelState.IsValid)
    {
        if (Membership.ValidateUser(model.UserName, model.Password))
        {
            var profile = TwoFactorProfile.GetByUserName(model.UserName);

            if (profile != null && !string.IsNullOrEmpty(profile.TwoFactorSecret))
            {
                if (TimeBasedOneTimePassword.IsValid(profile.TwoFactorSecret, model.TwoFactorCode))
                {
                    FormsAuthentication.SetAuthCookie(model.UserName, model.RememberMe);
                    if (Url.IsLocalUrl(returnUrl) && returnUrl.Length > 1 && returnUrl.StartsWith("/")
                        && !returnUrl.StartsWith("//") && !returnUrl.StartsWith("/\\"))
                    {
                        return Redirect(returnUrl);
                    }
                    else
                    {
                        return RedirectToAction("Index", "Home");
                    }
                }
                else
                {
                    ModelState.AddModelError("", "The two factor code is incorrect.");
                }
            }
            else
            {
                ModelState.AddModelError("", "The two factor code is incorrect.");
            }
        }
        else
        {
            ModelState.AddModelError("", "The user name or password provided is incorrect.");
        }
    }

    // If we got this far, something failed, redisplay form
    return View(model);
}

修改 AccountModels

为了使新的 LogOnShowTwoFactorSecret 操作正常工作,我们需要在 LogOnModel 类中添加一个字段:

[Required]
[Display(Name = "Google Authenticator Code")]
public string TwoFactorCode { get; set; }

并创建我们新的 TwoFactorSecret 类:

public class TwoFactorSecret
{
    public string EncodedSecret { get; set; }
} 

修改 LogOn.cshtml 视图

现在,我们修改 LogOn 视图以添加用户需要输入的新的 TwoFactorCode 字段:

<div class="editor-label">
    @Html.LabelFor(m => m.TwoFactorCode)
</div>
<div class="editor-field">
    @Html.TextBoxFor(m => m.TwoFactorCode)
    @Html.ValidationMessageFor(m => m.TwoFactorCode)
</div>

创建 ShowTwoFactorSecret 视图

最后,我们创建 ShowTwoFactorSecret 视图: 

@model TwoFactorWeb.Models.TwoFactorSecret
@{
    ViewBag.Title = "ShowTwoFactorSecret";
}

<h2>Show Two Factor Secret</h2>

<p>
    Add the code below to Google Authenticator:
</p>
<p>
    @Html.QRCode(string.Format("otpauth://totp/MY_APP_LABEL?secret={0}", Model.EncodedSecret))
</p>
<p>
    @Model.EncodedSecret
</p>

正如您所见,我们显示了一个用户可以扫描的 QR 码图像,并且我们还显示了 QR 码下方的字符串密钥供用户手动输入。QR 码的格式定义在此

见结果

在 Web 应用程序中注册为新用户后,您应该会看到如下屏幕:

此时,您应该使用 Google Authenticator 应用程序扫描 QR 码,或手动输入 QR 码下方的代码:

 

现在,当您登录时,应该会看到一个新的字段用于输入您的“Google Authenticator 代码”:

只需输入 Google Authenticator 屏幕上您应用程序的当前 6 位数字代码:

 

如果您正确输入了用户名、密码和代码,您应该就可以登录了。 

历史 

  • 2012 年 6 月 13 日 - 首次发布。 
  • 2012 年 6 月 14 日 - 添加了 Web 应用程序和 Google Authenticator 的屏幕截图。感谢 Priyank Bolia 的建议。 
  • 2012 年 8 月 13 日 - 更新以使用更安全的方法生成用户的随机密钥。感谢 rhoffman 指出这一点。 
  • 2012 年 9 月 11 日 - 更新以在给定用户的登录尝试之间添加延迟。这有助于降低攻击者知道密码的情况下遭受暴力破解攻击的风险。更新为直接生成 QR 码,而不是依赖外部服务。  
  • 2012 年 10 月 23 日 - 更新了附加代码以提供先前使用的一次性密码的缓存,以防止它们被使用多次。感谢 MatrixQN 指出此问题。 

完整的源代码可在github上找到。  

© . All rights reserved.