使用SQLAlchemy存储和验证密码

翻译原文:Storing and verifying passwords with SQLAlchemy


我真的很喜欢抽象!!!

抽象是编程的生命线。抽象采取复杂的操作,从而通过可操作的接口轻松工作。本文我们将讨论如何在基于 SQLAlchemy 的(web)应用程序中存储和验证密码(或其加密散列)的方式。并且基于这种方式,我们可以升级密码的安全性,因为很可能会发生旧的加密方案被破坏或被证明有不足,此时就要有新的加密方案被引入。

验证密码的方法

虽然有许多不同的方法来处理密码验证,但总结起来,可以分为三大类:

临时验证

这种方法通常用于一次性脚本或简单的应用程序,这些应用程序具有单次验证并且很少能作为可重用组件使用。下面的代码就是一个非常基本的用户模型和密码验证操作:

import bcrypt
from sqlalchemy import Column, Integer, Text

class User(Base):
    __tablename__ = 'user'
    id = Column(Integer, primary_key=True)
    name = Column(Text)
    password = Column(Text)

pwhash = bcrypt.hashpw(login_data['password'], user.password)
if user.password == pwhash:
    print 'Access granted'

上面的代码片段使用 Python bcrypt 包作为密码加密组件,你可能会使用其他的加密组件例如:PBKDF2scrypt。不管怎样,只有可以肯定的是:它不应该有一个固定的盐值,它也应该绝对不会是一轮优化速度的 SHA1SHA2 功能。其实就是应该使用目前业界通用的历经考验的算法。

假设散列算法是安全的,那么这段代码对于达到我们的目的是完美的。但是,大多数应用程序都是琐碎的,我们会发现大多数的一次性脚本往往会被 CV 重复使用。随着时间的推移,你发现自己面临着越来越多的重复代码,仅仅用来验证用户的密码。所以,是时候做些什么事情了,该是重构的时候了。

委派密码验证

您的应用程序已经增长,现在有很多地方需要验证用户的密码。有登录页面,有更改敏感设置,或者更改用户的密码。。。在整个应用程序中重复的代码块是以后需要浪费时间的维护问题,因此验证需要被委派。

在这种情况下,它的委托给一个类 User。我们给 User 类添加了一种方法,将当前密码的哈希值和需要尝试计算的值作为参数传递进去,并返回它们是否相同:

import bcrypt
from sqlalchemy import Column, Integer, Text

class User(Base):
    __tablename__ = 'user'
    id = Column(Integer, primary_key=True)
    name = Column(Text)
    password = Column(Text)

    def verify_password(self, password):
        pwhash = bcrypt.hashpw(password, self.password)
        return self.password == pwhash

if user.verify_password(login_data['password']):
    print 'Access granted'

对用户使用此验证方法,所有密码检查都可以委派给一个方法。如果需要对密码存储或验证进行任何更改,则只需在一个位置执行一次。问题解决了?大多情况下,虽然这个方法确实有效,并且是对临时解决方案的重大改进,但仍然存在一些问题:

  1. 多个模型可能需要密码验证; 您的应用程序可能有常规和管理用户的单独模型,或包括需要密码的其他原语。这些都需要自己的验证方法。一个解决方案是抽象出一个单一的 mixin 类,用来处理密码验证。
  2. 密码设置仍需要处理。这可以用 @validates 这个装饰器来装饰需要创建哈希的字段。为了使其可重用,将它添加到上面提到的 mixin 类将是一个好主意。
  3. 更重要的,我认为这是一个不必要的抽象,不必要暴露实现细节(bcrypt),应该有一个更直观的接口。

使密码哈希智能

更直观的接口应该是比较符,而不是函数调用。我们想要实现的是检查提供的密码是否等于我们所拥有的密码(即使我们只能真正地比较明文与散列)。为此,我们可以写一个相当简单的类,它需要一个 bcrypt 哈希,它提供了自己的 equal 比较器。

import bcrypt

class PasswordHash(object):
    def __init__(self, hash_):
        assert len(hash_) == 60, 'bcrypt hash should be 60 chars.'
        assert hash_.count('$'), 'bcrypt hash should have 3x "$".'
        self.hash = str(hash_)
        self.rounds = int(self.hash.split('$')[2])

    def __eq__(self, candidate):
        """Hashes the candidate string and compares it to the stored hash."""
        if isinstance(candidate, basestring):
            if isinstance(candidate, unicode):
                candidate = candidate.encode('utf8')
            return bcrypt.hashpw(candidate, self.hash) == self.hash
        return False

    def __repr__(self):
        """Simple object representation."""
        return '<{}>'.format(type(self).__name__)

    @classmethod
    def new(cls, password, rounds):
        """Creates a PasswordHash from the given password."""
        if isinstance(password, unicode):
            password = password.encode('utf8')
        return cls(bcrypt.hashpw(password, bcrypt.gensalt(rounds)))

创建新 PasswordHash 可以从一个明文密码来实现类方法,或只需实例化一个现有的哈希值。将现有的哈希与明文密码进行比较是非常简单明了的:

if user.password == login_data['password']:
    print 'Access granted'

在SQLAlchemy模型中使用PasswordHash

密码哈希本质上是一个简单的字符串。我们要做的是确保在我们的 PasswordHash 中封装的哈希值存储在数据库中,并且当我们从数据库读取一个哈希值时返回一个 PasswordHash 对象。为此,SQLAlchemy 为我们提供了 TypeDecorators,它允许对我们新的密码类型的增加新的功能。

使用 TypeDecorator 构造块,我们构造一个新的密码类型,我们可以在列规范中使用。有一些事情,我们需要关注:

  1. 选择要扩展的数据库类型。在这个例子中我使用的是 Text,但根据底层数据库的不同可能有更好的类型。
  2. 一种将 PasswordHash 对象转换为适合实现器类型的值的方法。这是 process_bind_param() 方法。
  3. 一种将数据库值转换为我们想要在 Python 运行时中使用的 PasswordHash 的方法。这是 process_result_value() 方法。
from sqlalchemy import Column, Integer, Text, TypeDecorator
from sqlalchemy.orm import validates

class Password(TypeDecorator):
    """Allows storing and retrieving password hashes using PasswordHash."""
    impl = Text

    def __init__(self, rounds=12, **kwds):
        self.rounds = rounds
        super(Password, self).__init__(**kwds)

    def process_bind_param(self, value, dialect):
        """Ensure the value is a PasswordHash and then return its hash."""
        return self._convert(value).hash

    def process_result_value(self, value, dialect):
        """Convert the hash to a PasswordHash, if it's non-NULL."""
        if value is not None:
            return PasswordHash(value)

    def validator(self, password):
        """Provides a validator/converter for @validates usage."""
        return self._convert(password)

    def _convert(self, value):
        """Returns a PasswordHash from the given string.

        PasswordHash instances or None values will return unchanged.
        Strings will be hashed and the resulting PasswordHash returned.
        Any other input will result in a TypeError.
        """
        if isinstance(value, PasswordHash):
            return value
        elif isinstance(value, basestring):
            return PasswordHash.new(value, self.rounds)
        elif value is not None:
            raise TypeError(
                'Cannot convert {} to a PasswordHash'.format(type(value)))

class User(Base):
    __tablename__ = 'user'
    id = Column(Integer, primary_key=True)
    name = Column(Text)
    password = Column(Password)
    # Or specify a cost factor other than the default 12
    # password = Column(Password(rounds=10))

    @validates('password')
    def _validate_password(self, key, password):
        return getattr(type(self), key).type.validator(password)

@validates 装饰器是可选的,但我们需要确保密码被赋值时会转换为 PasswordHash,并且保存到 session 中是透明的。这其实就是说是在赋值之后,刷新到数据库之间完成的 Hash转换。这也意味着不会有一个纯文本值存储在用户对象上,这也就意味着它不会意外泄漏,这绝对是一个惊喜。

关于密码类型的另一个要注意的是,它允许配置密码加密的复杂度。这样,我们可以准确得控制我们的密码加密的效率和安全。较高的数值将极大增加比较的成本,因此如何取舍将取决于密码的使用常见和它们更新访问的频率。

HasPassword混合

当之前列出缺点时,我提到了一些代码重用可以用一个 mixin 类建立。这仍然可以用上面的解决方案来实现,利用 SQLAlchemy 支持混入列的特性。

以下代码段定义了这样一个 mixin,然后被两个模型 UserProtectedFile 使用。这两个模型都将有一个密码列属性,包括将字符串转换为正确的加密字段。

class HasPassword(object):
    password = Column(Password)

    @validates('password')
    def _validate_password(self, key, password):
        return getattr(type(self), key).type.validator(password)

class User(HasPassword, Base):
    __tablename__ = 'user'
    id = Column(Integer, primary_key=True)
    name = Column(Text)

class ProtectedFile(HasPassword, Base):
    __tablename__ = 'protected_file'
    id = Column(Integer, primary_key=True)
    filename = Column(Text)

支持可升级的密钥强度

由于计算机每年显着更快,并且访问大型计算机集群也变得越来越容易,因此有强大的动机能够升级我们的密码哈希复杂性。五年前的一个舒适的回合数,今天用暴力攻击更容易破解。有选择升级我们的哈希的复杂性对于任何时间长度的任何系统是至关重要的。

该密码在模型定义类型可以被赋予不同的(更高)轮参数。然而,这只会确保新的口令与此增加的复杂性创造,它什么都不为现有的哈希值。我们可以做的是一个系统,其中当验证并且证明是正确的时,哈希值被升级。由于加密散列的单向性质,我们不能容易地升级它们,而不知道首先创建散列的明文。

在验证密码之后更新内部散列是正确的,但是它不会导致数据库本身被更新。这是因为SQLAlchemy在默认情况下仅监视对记录的列属性的分配。当已分配的值在内部更改时,将不会选择此值,SQLAlchemy将不会在刷新或提交时更新数据库。跟踪和的内部变化标记是通过使用延伸的类型成为可能可变扩展。

一个可变的PasswordHash

使我们的一个可变类型让我们将其标记为已经改变,当我们更新内部哈希。为此,我们需要进行一些更改:

class PasswordHash(Mutable):
    def __init__(self, hash_, rounds=None):
        assert len(hash_) == 60, 'bcrypt hash should be 60 chars.'
        assert hash_.count('$'), 'bcrypt hash should have 3x "$".'
        self.hash = str(hash_)
        self.rounds = int(self.hash.split('$')[2])
        self.desired_rounds = rounds or self.rounds

    def __eq__(self, candidate):
        """Hashes the candidate string and compares it to the stored hash.

        If the current and desired number of rounds differ, the password is
        re-hashed with the desired number of rounds and updated with the results.
        This will also mark the object as having changed (and thus need updating).
        """
        if isinstance(candidate, basestring):
            if isinstance(candidate, unicode):
                candidate = candidate.encode('utf8')
            if self.hash == bcrypt.hashpw(candidate, self.hash):
                if self.rounds < self.desired_rounds:
                    self._rehash(candidate)
                return True
        return False

    def __repr__(self):
        """Simple object representation."""
        return '<{}>'.format(type(self).__name__)

    @classmethod
    def coerce(cls, key, value):
        """Ensure that loaded values are PasswordHashes."""
        if isinstance(value, PasswordHash):
            return value
        return super(PasswordHash, cls).coerce(key, value)

    @classmethod
    def new(cls, password, rounds):
        """Returns a new PasswordHash object for the given password and rounds."""
        if isinstance(password, unicode):
            password = password.encode('utf8')
        return cls(cls._new(password, rounds))

    @staticmethod
    def _new(password, rounds):
        """Returns a new bcrypt hash for the given password and rounds."""
        return bcrypt.hashpw(password, bcrypt.gensalt(rounds))

    def _rehash(self, password):
        """Recreates the internal hash and marks the object as changed."""
        self.hash = self._new(password, self.desired_rounds)
        self.rounds = self.desired_rounds
        self.changed()

一些事情改变了:

  1. 继承可变允许一个需要持久化状态的内部变化的信号。
  2. 知道是否升级,所需的复杂性需要设置和下存储到哈希的当前的复杂性。
  3. 当提供的密码正确时,根据当前情况检查所需的复杂性。如果当前复杂度太低,我们重新刷新密码,更新复杂性并标记更改。
  4. 所述裹胁()方法是可变的所需的接口的一部分。它不会为这个类做太多,但仍然需要。
  5. 重用代码,_new()现在是负责从纯文本和复杂的参数创建一个新的bcrypt哈希值。

更改密码

在SQLAlchemy中只需要一个小的变化与新的可变工作PasswordHash。每当从数据库加载密码哈希时,需要提供所需的复杂性,从而导致以下小的更改:

class Password(TypeDecorator):
    """Allows storing and retrieving password hashes using PasswordHash."""
    impl = Text

    def __init__(self, rounds=12, **kwds):
        self.rounds = rounds
        super(Password, self).__init__(**kwds)

    def process_bind_param(self, value, dialect):
        """Ensure the value is a PasswordHash and then return its hash."""
        return self._convert(value).hash

    def process_result_value(self, value, dialect):
        """Convert the hash to a PasswordHash, if it's non-NULL."""
        if value is not None:
            return PasswordHash(value, rounds=self.rounds)

    def validator(self, password):
        """Provides a validator/converter for @validates usage."""
        return self._convert(password)

    def _convert(self, value):
        """Returns a PasswordHash from the given string.

        PasswordHash instances or None values will return unchanged.
        Strings will be hashed and the resulting PasswordHash returned.
        Any other input will result in a TypeError.
        """
        if isinstance(value, PasswordHash):
            return value
        elif isinstance(value, basestring):
            return PasswordHash.new(value, self.rounds)
        elif value is not None:
            raise TypeError(
                'Cannot convert {} to a PasswordHash'.format(type(value)))

升级散列强度

要升级密钥派生的复杂性,所有我们现在需要做的就是提供一个升级的回合参数。这将随着时间的推移升级活动用户的密码散列,而不需要每次迁移都需要特殊的代码。

class User(Base):
    __tablename__ = 'user'
    id = Column(Integer, primary_key=True)
    name = Column(Text)
    password = Column(Password(rounds=13))

    @validates('password')
    def _validate_password(self, key, password):
        return getattr(type(self), key).type.validator(password)

# Create plain user with default key complexity
john = User(name='John', password='flatten-shallow-ideal')

# Create an admin user with higher key derivation complexity
administrator = User(
    name='Simon',
    password=PasswordHash.new('working-as-designed', 15))

因为在创建示例中的“管理员”用户所示,密码类型也允许在个人基础上做强哈希值。这工作,因为在当前的复杂性时,才会执行密码的换汤不换药下设定的阈值。具有比配置的下限更高的复杂度的哈希值保持不变。

这种方法增加了帐户的密码设置的复杂性,但是可以用于选择性地增加比较的成本。在这种情况下增加的复杂性使任何比较额外的四倍慢(给定bcrypt的指数成本缩放)。虽然这会减慢密码验证步骤,但会对尝试破解密码的攻击者施加相同的费用。一个大约一百毫秒的验证几乎不会被注意到,但减慢了暴力攻击蜗牛的步伐。

进一步改进

在后续文章中,我们将介绍一个更灵活的密码升级解决方案。一个同时支持bcrypt和例如单次迭代盐渍SHA1,并升级他们,因为他们被访问,允许所有活跃用户顺利迁移。

脚注

这不是一个新的关注,但随着比特币和衍生品的越来越流行,打破哈希正在以指数级越来越便宜和更快。参见:http://www.matasano.com/log/958/enough-with-the-rainbow-tables-what-you-need-to-know-about-secure-password-schemes/

Related Posts:

  1. Mutation tracking in nested JSON structures using SQLAlchemy
  2. Creating a JSON column type for SQLAlchemy
  3. Mordac the (query) preventer
  4. Fixing bad value "X-UA-Compatible" with Pyramid

· EOF ·

最近爬虫很是凶猛,标注下文章的原文地址: https://liuliqiang.info/post/storing-and-verifying-passwords-with-sqlalchem/