安全密码身份验证简单解释






4.84/5 (31投票s)
简单的代码和解释,实现安全的密码认证。
引言
我简直不敢相信,我仍然遇到一些网站会自己存储密码,并且你看到诸如“密码最多 16 个字符”或“密码**不得**包含“<>&*amp; 等……”之类的规则。就在昨晚,我遇到了一个政府网站,其密码最大长度为 12 个字符,并且虽然允许某些特殊字符,但其他字符却被禁止。这类规则听起来就像该网站以明文形式存储密码,或者更糟的是,在存储和验证数据库中的密码时没有使用参数。
经过这么多年的密码历史,我本以为大多数程序员都会明白这些道理。在许多情况下,在您自己的应用程序或网站中存储密码是毫无意义的;有许多替代方案。
- 与您的网络安全集成,例如 ActiveDirectory
- OpenID
本文适用于那些必须自己存储用户名和密码的人。在本示例中,我将使用 C#、MS Access 和 ASP.NET;尽管其原理显然可以用于您喜欢的任何应用程序堆栈。
背景
已经写过其他文章涵盖了这些信息,但我想让这篇文章非常直观。我不会深入研究密码学,也不会介绍如何让用户保持登录状态,或验证最低复杂度,或其他类似的方面。我的目的是只提供安全存储密码所需的信息和代码。出于本文的目的,我的目标非常简单。
- 允许密码长度不限,并且用户可以使用任何喜欢的字符。
- 能够高效地将密码存储在数据库中。
- 防止任何 SQL 注入攻击。
- 防止数据迁移攻击。
所以,让我们从前两项开始。我认为密码永远不应该被“读取”或“恢复”。唯一的例外是如果我们确实需要保留其他应用程序的密码,即使那样,也只有在别无选择的情况下,例如将所有相关应用程序转换为 OpenID。如果密码丢失或泄露,应该有一个“重置”机制,这种理念意味着我无需以明文或可解密的形式存储密码,至少不是用于身份验证。因此,解决这两个问题的最简单方法是使用单向哈希,这是一种无法解密的加密。MD5、SHA-1 等哈希算法可用于创建固定长度的值序列,它更像是数据的唯一签名,而不是加密数据。
例如,“test”的 MD5 哈希是“098f6bcd4621d373cade4e832627b4f6”。这些签名的优点是,无论数据有多大,返回的数据大小始终相同。这就是为什么您会看到 MD5 和 SHA-1 签名用于验证大型文件,如 Linux DVD ISO 映像。不同的算法将具有不同长度的输出签名。
- MD5 的长度为 128 位,即 16 字节。有趣的是,这与 UUID/GUID 的长度相同。这意味着,如果您存储的内容与安全性无关,您可以将数据存储在数据库的 UUID/GUID 字段中。
- SHA-1 的长度为 160 位,即 20 字节。
- SHA-256 的长度为 256 位,即 32 字节。
- SHA-384 的长度为 384 位,即 48 字节。
- SHA-512 的长度为 512 位,即 64 字节。
现在,如果我们使用签名来存储密码,我们就可以一次性满足我的三个要求。首先,由于签名不包含任何可用于 SQL 注入的内容,或者我们无需担心编码,因此用户可以自由使用任何字符作为密码。其次,我们可以将任意大小的密码存储在固定长度的字段中,例如 64 字节。
在继续之前,我们需要注意 MD5 现在很容易被破解。事实上,您可以在诸如 此网站等网站上解密许多 MD5 签名。SHA-1 也显示出被削弱的迹象,并且很快也会过时。那么,如何“加固”签名呢?
首先,我们在代码库中使用我们可用的最强的哈希算法。显然,我们可以编写自己的更强的实现,但最好使用经过考验的加密代码,而不是自己编写。
我们可以做的第二件事是“加盐”我们的密码;简而言之,这意味着创建一个随机值附加到密码的末尾,使其更独特。这可以是一系列短字节,但我们可以使用合理数量的数据。当然,“加盐”密码的另一个原因是防止对密码进行分析。如果您只做哈希密码,那么“test”的密码始终是“098f6bcd4621d373cade4e832627b4f6”。这意味着,如果密码列表泄露,拥有相同密码的所有人都会拥有相同的哈希。通过使用随机盐值,即使使用相同的密码,存储的哈希也会是唯一的。
关于盐值最常见的问题是“如果盐值是随机的,那么在每次进行验证时如何可靠地生成相同的盐值?”答案很简单,您不需要。您将盐值与密码哈希分开存储。
存储数据相当简单,但由于我们使用的是字节数组(Access 中的 OLE 对象),纯文本 SQL 将不起作用。相反,我们需要使用参数,这很好,因为这也有助于防止 SQL 注入。
由于我希望本文涵盖的内容不仅仅是重新讨论其他地方已经讨论过的内容,因此我想让这个示例更具可移植性,适用于其他数据库。有关更多详细信息,请参阅我之前的 文章。
因此,我将从一个静态 DB 类开始,该类读取配置文件并根据提供程序创建适当的类型,这显然不是像 Web 这样的应用程序的绝佳解决方案,但本文并非关于制作一个真正好的数据库包装器。
public class DB
{
private string _connectionString = null;
private DbProviderFactory _factory = null;
private string _quotePrefix = string.Empty;
private string _quoteSuffix = string.Empty;
public DbProviderFactory Factory
{
get
{
if (_factory == null)
{
ConnectionStringSettings connectionSettings = ConfigurationManager.ConnectionStrings["DSN"];
_factory = DbProviderFactories.GetFactory(connectionSettings.ProviderName);
_connectionString = connectionSettings.ConnectionString;
}
return _factory;
}
}
public string ConnectionString
{
get
{
return _connectionString;
}
}
public string QuotePrefix
{
get
{
if (string.IsNullOrEmpty(_quotePrefix))
{
FillQuotes();
}
return _quotePrefix;
}
}
public string QuoteSuffix
{
get
{
if (string.IsNullOrEmpty(_quoteSuffix))
{
FillQuotes();
}
return _quoteSuffix;
}
}
//this function gets the proper characters to wrap
//database, table, and column names.
private void FillQuotes()
{
DbCommandBuilder cb = Factory.CreateCommandBuilder();
if (!string.IsNullOrEmpty(cb.QuotePrefix))
{
_quoteSuffix = cb.QuoteSuffix;
_quotePrefix = cb.QuotePrefix;
return;
}
using (DbConnection conn = GetConnection())
{
using (DbCommand cmd = conn.CreateCommand())
{
//test to see if we can wrap names in square brackets.
cmd.CommandText = "SELECT '1' as [default]";
try
{
using (DbDataReader dr = cmd.ExecuteReader())
{
while (dr.Read())
{
}
}
_quotePrefix = "[";
_quoteSuffix = "]";
}
catch
{
try
{
//square brackets failed, try double quotes.
cmd.CommandText = "SELECT '1' as \"default\"";
using (DbDataReader dr = cmd.ExecuteReader())
{
while (dr.Read())
{
}
}
_quotePrefix = _quoteSuffix = "\"";
}
catch
{
//no characters appear to work
}
}
}
}
}
private DbConnection GetConnection()
{
DbConnection conn = Factory.CreateConnection();
conn.ConnectionString = ConnectionString;
conn.Open();
return conn;
}
public int ExecuteNonQuery(string sql, IEnumerable<DbParameter> parameters)
{
using (DbConnection conn = GetConnection())
{
DbCommand cmd = null;
try
{
cmd = conn.CreateCommand();
cmd.CommandText = sql;
foreach (DbParameter parameter in parameters)
{
cmd.Parameters.Add(parameter);
}
return cmd.ExecuteNonQuery();
}
finally
{
if (cmd != null)
{
cmd.Parameters.Clear();
cmd.Dispose();
}
cmd = null;
}
}
}
public DbDataReader ExecuteReader(string sql, IEnumerable<DbParameter> parameters)
{
DbConnection conn = GetConnection();
DbCommand cmd = null;
try
{
cmd = conn.CreateCommand();
cmd.CommandText = sql;
foreach (DbParameter parameter in parameters)
{
cmd.Parameters.Add(parameter);
}
return cmd.ExecuteReader(CommandBehavior.CloseConnection);
}
finally
{
if (cmd != null)
{
cmd.Parameters.Clear();
cmd.Dispose();
}
cmd = null;
}
}
}
此类**不**完整;因此,不适合在生产代码中使用。它缺乏使用多个连接字符串的能力,更重要的是,它不支持事务。可能最大的缺点是完全缺乏错误处理。但是,它确实有一些非常好的功能;它充分利用了连接池,并公开了提供程序的 Quote
字符。我已将其设为非静态,因为静态类对于 Web 应用程序来说非常糟糕。
此示例仅使用一个表:“Users”。
用户 | ||
唯一约束 | ID |
UUID /GUID |
主键 | 用户 |
varchar(255) |
密码 |
byte[64] |
我故意使列名与 SQL 关键字冲突,以展示包装列名和表名的功能。因此,您必须正确地包装列名。
使用代码
在我们可以验证用户之前,我们必须注册他们。要注册用户,我们必须执行以下操作:
- 获取用户名
- 获取密码
- 生成随机盐
- 创建密码哈希
- 将用户名、哈希和盐存储在数据库中
令人惊讶的是,这归结为一小段代码。您会在以下代码中看到两件奇怪的事情。我没有使用任何硬编码的提供程序类型,并且我使用了 RNGCryptoServiceProvider
。.NET 的 Random
对象提供伪随机数;这没问题,除了每次创建新对象时它们都会重复。要解决此问题,Microsoft 建议您使用 RNGCryptoServiceProvider
或仅创建一个所有代码都用于生成随机数的单个静态 Random
对象。
bool successful = false;
try
{
string insertUserSQL =
string.Format("INSERT INTO {0}Users{1} ({0}user{1},{0}ID{1},{0}password{1}) VALUES (?,?,?)",
DatabaseContext.QuotePrefix, DatabaseContext.QuoteSuffix);
List<DbParameter> parameters = new List<DbParameter>();
HashAlgorithm hashAlgorithm = SHA512.Create();
byte[] b = new byte[32];
RNGCryptoServiceProvider rng = new RNGCryptoServiceProvider();
rng.GetBytes(b);
//create all 3 parameters, the order is critical since
//these are positional parameters.
//user
DbParameter p = DatabaseContext.Factory.CreateParameter();
p.DbType = DbType.String;
p.Value = txtUserName.Text;
parameters.Add(p);
//ID and salt
p = DatabaseContext.Factory.CreateParameter();
p.DbType = DbType.Binary;
p.Value = b;
parameters.Add(p);
//password
p = DatabaseContext.Factory.CreateParameter();
p.DbType = DbType.Binary;
p.Value = b;
parameters.Add(p);
string s = txtPassword.Text;
List<byte> pass = new List<byte>(Encoding.Unicode.GetBytes(s));
pass.AddRange(b);
p.Value = hashAlgorithm.ComputeHash(pass.ToArray());
DatabaseContext.ExecuteNonQuery(insertUserSQL, parameters);
successful = true;
}
catch (Exception ex)
{
Debug.WriteLine(ex.ToString());
}
Label3.Text = "Registration " + (successful ? "successful" : "failed");
上面的代码尽可能简单。它应该包括对数据库中已存在用户名的检查以及更多内容。
接下来是响应登录请求。同样,这是一段令人惊讶地简单的代码。我们的待办事项列表是:
- 等待 2 秒钟“拖延”攻击者,从而大大减慢暴力破解攻击的速度。
- 从用户处获取用户名和密码。
- 从数据库中获取正确的记录。
- 使用数据库中的盐值,通过盐值和尝试的密码创建哈希。
- 将生成的哈希与数据库中存储的密码哈希进行比较。
- 返回“登录失败”或“登录成功”;我们不想显示“用户未找到”或“密码错误”,因为这会给攻击者提供太多信息。
这是执行所有这些操作的代码
Thread.Sleep(2000); //tarpit
bool successful = false;
HashAlgorithm hashAlgorithm = SHA512.Create();
string retrieveUser =
string.Format(
"SELECT {0}ID{1} FROM {0}Users{1} WHERE {0}user{1}=?",
DatabaseContext.QuotePrefix,
DatabaseContext.QuoteSuffix);
List<DbParameter> parameters = new List<DbParameter>();
try
{
DbParameter p = DatabaseContext.Factory.CreateParameter();
p.DbType = DbType.String;
p.Value = txtUserName.Text;
parameters.Add(p);
byte[] computedHash = null;
using (DbDataReader dr = DatabaseContext.ExecuteReader(retrieveUser, parameters))
{
if (dr.Read())
{
byte[] salt = (byte[])dr.GetValue(0);
List<byte> buffer =
new List<byte>(Encoding.Unicode.GetBytes(txtPassword.Text));
buffer.AddRange(salt);
computedHash = hashAlgorithm.ComputeHash(buffer.ToArray());
}
}
if (computedHash != null)
{
DbParameter p2 = DatabaseContext.Factory.CreateParameter();
p2.DbType = DbType.String;
p2.Value = computedHash;
parameters.Add(p2);
retrieveUser =
string.Format(
"SELECT COUNT(*) FROM {0}Users{1} WHERE {0}user{1}=? AND {0}password{1}=?",
DatabaseContext.QuotePrefix,
DatabaseContext.QuoteSuffix);
using (DbDataReader dr = DatabaseContext.ExecuteReader(retrieveUser, parameters))
{
if (dr.Read())
{
successful = Convert.ToInt32(dr.GetValue(0)) == 1;
}
}
}
}
catch (Exception ex)
{
Debug.WriteLine(ex.ToString());
}
Label3.Text = "Login " +
(successful ? "successful" : "failed");
关注点
如上所述,MD5 哈希与 GUID 的大小相同,因此我使用 ID 作为我的盐。如果 ID 字段是可预测的,例如伪随机数或更糟的自动增量数字,那么这将是一个糟糕的主意。
由于我使用参数来传递值,因此我可以使用我喜欢的任何值作为用户名,无论是电子邮件地址,还是包含通常不允许的字符(如撇号,例如“O'Brian”)的内容。
请记住,使用 SSL 来保护任何涉及敏感信息的页面,尤其是密码,因为人们倾向于重复使用密码。成本不是借口。虽然 Verisign 和 Thawte 等网站收取超过 100 美元(美国)购买 SSL 证书,但您可以在 GoDaddy 以约 30 美元(美国)的价格购买廉价证书,或者从 StartSSL 获得**免费**证书,并且与 Verisign 和 Thawte 一样,它们在所有主要浏览器中都有效。在 CAcert 社区开始回复之前,我不推荐 CA,因为它要求所有使用您的网站的人手动插入根证书。
此代码存在一些不足之处。例如,我们不是简单地使用 2 秒的拖延,而是可以“旋转密码”,多次哈希哈希,从而增加使用彩虹表等工具进行暴力破解密码的复杂性。此外,我也没有涉及强制执行密码强度,或保留历史记录,或存储身份验证令牌或其他类似内容。尽管如此,这是一个非常好的起点。
如果包含用户名,哈希也可以更强。
历史
- 2014-04-01:重写后修复了缓冲区大小,guid 必须是 32 字节
- 2014-03-31:更改了文章以使用 ID 列作为盐,以防止数据迁移攻击。还略微修改了代码。
- 2010-01-25:添加了“记住 SSL”的注意事项。