【问题标题】:Combine SQLAlchemy hybrid_property with native property construction将 SQLAlchemy hybrid_property 与原生属性构造相结合
【发布时间】:2018-07-19 21:10:23
【问题描述】:

我在 SQLAlchemy 中有一个用户类。我希望能够在数据库中加密用户的电子邮件地址属性,但仍然可以通过过滤查询进行搜索。

我的问题是,如果我使用@hybrid_property,我的查询理论上有效,但我的构造无效,如果我使用@property,我的构造有效,但我的查询无效

from cryptography.fernet import Fernet  # <- pip install cryptography
from werkzeug.security import generate_password_hash
class User(db.Model):
    __tablename__ = 'users'
    id = db.Column(db.Integer, primary_key=True)
    email_hash = db.Column(db.String(184), unique=True, nullable=False)
    password_hash = db.Column(db.String(128))

    # @property       # <- Consider this as option 2...
    @hybrid_property  # <- Consider this as option 1...
    def email(self):
        f = Fernet('SOME_ENC_KEY')
        value = f.decrypt(self.email_hash.encode('utf-8'))
        return value
    @email.setter
    def email(self, email):
        f = Fernet('SOME_ENC_KEY')
        self.email_hash = f.encrypt(email.encode('utf-8'))

    @property
    def password(self):
        raise AttributeError('password is not a readable attribute.')
    @password.setter
    def password(self, password):
        self.password_hash = generate_password_hash(password)

    def __init__(self, **kwargs):
        super(User, self).__init__(**kwargs)
        # other checks and modifiers

对于选项 1:当我尝试使用 User(email='a@example.com',password='secret') 构建用户时,我会收到回溯,

~/models.py in __init__(self, **kwargs)
    431     # Established role assignment by default class initiation
    432     def __init__(self, **kwargs):
--> 433         super(User, self).__init__(**kwargs)
    434         if self.role is None:
    435             _default_role = Role.query.filter_by(default=True).first()

~/lib/python3.6/site-packages/sqlalchemy/ext/declarative/base.py in _declarative_constructor(self, **kwargs)
    697             raise TypeError(
    698                 "%r is an invalid keyword argument for %s" %
--> 699                 (k, cls_.__name__))
    700         setattr(self, k, kwargs[k])
    701 _declarative_constructor.__name__ = '__init__'
TypeError: 'email' is an invalid keyword argument for User

对于选项 2:如果我将 @hybrid_property 更改为 @property,则构造很好,但我的查询 User.query.filter_by(email=form.email.data.lower()).first() 失败并返回 None

我应该进行哪些更改才能使其按要求工作?

==============

注意我应该说我已经尽量避免使用双重属性,因为我不想对底层代码库进行大量编辑。所以我明确地试图避免将创建与User(email_input='a@a.com', password='secret')User.query.filter_by(email='a@a.com').first() 的查询分开:

class User(db.Model):
    __tablename__ = 'users'
    id = db.Column(db.Integer, primary_key=True)
    email_hash = db.Column(db.String(184), unique=True, nullable=False)
    password_hash = db.Column(db.String(128))

    @hybrid_property
    def email(self):
        f = Fernet('SOME_ENC_KEY')
        value = f.decrypt(self.email_hash.encode('utf-8'))
        return value
    @property
    def email_input(self):
        raise AttributeError('email_input is not a readable attribute.')
    @email_input.setter
    def email_input(self, email):
        f = Fernet('SOME_ENC_KEY')
        self.email_hash = f.encrypt(email.encode('utf-8'))

    @property
    def password(self):
        raise AttributeError('password is not a readable attribute.')
    @password.setter
    def password(self, password):
        self.password_hash = generate_password_hash(password)

    def __init__(self, **kwargs):
        super(User, self).__init__(**kwargs)
        # other checks and modifiers

【问题讨论】:

    标签: properties sqlalchemy flask-sqlalchemy getter-setter


    【解决方案1】:

    在您的hybrid_propertyemail 中,如果self.email_hashstr 类型,则self.f.decrypt(self.email_hash.encode('utf-8')) 行很好,但是,因为emailhybrid_property,当SQLAlchemy 使用它来生成SQL 时self.email_hash 实际上是一个sqlalchemy.orm.attributes.InstrumentedAttribute 类型。

    来自docs 关于混合属性:

    在许多情况下,Python 内函数的构造和 SQLAlchemy SQL 表达式有足够的差异,两个分开 应该定义 Python 表达式。

    因此您可以定义一个 hybrid_property.expression 方法,这是 SQLAlchemy 将用来生成 sql 的方法,允许您在 hybrid_property 方法中保持字符串处理的完整性。

    根据您的示例,这是我最终得到的对我有用的代码。为了简单起见,我已经从您的User 模型中删除了很多内容,但所有重要部分都在那里。我还必须为您的代码中调用但未提供的其他函数/类编写实现(请参阅MCVE):

    class Fernet:
        def __init__(self, k):
            self.k = k
    
        def encrypt(self, s):
            return s
    
        def decrypt(self, s):
            return s
    
    def get_env_variable(s):
        return s
    
    def generate_password_hash(s):
        return s
    
    class User(db.Model):
        __tablename__ = 'users'
        id = db.Column(db.Integer, primary_key=True)
        email_hash = db.Column(db.String(184), unique=True, nullable=False)
    
        f = Fernet(get_env_variable('FERNET_KEY'))
    
        @hybrid_property
        def email(self):
            return self.f.decrypt(self.email_hash.encode('utf-8'))
    
        @email.expression
        def email(cls):
            return cls.f.decrypt(cls.email_hash)
    
        @email.setter
        def email(self, email):
            self.email_hash = self.f.encrypt(email.encode('utf-8'))
    
    
    
    if __name__ == '__main__':
        db.drop_all()
        db.create_all()
        u = User(email='a@example.com')
        db.session.add(u)
        db.session.commit()
        print(User.query.filter_by(email='a@example.com').first())
        # <User 1> 
    

    不幸的是,上面的代码之所以有效,是因为模拟 Fernet.decrypt 方法返回了传入的确切对象。存储用户电子邮件地址的 Fernet 编码散列的问题是 Fernet.encrypt 不会返回相同的 @ 987654339@ 从一次执行到下一次执行,即使使用相同的密钥。例如:

    >>> from cryptography.fernet import Fernet
    >>> f = Fernet(Fernet.generate_key())
    >>> f.encrypt('a@example.com'.encode('utf-8')) == f.encrypt('a@example.com'.encode('utf-8'))
    False
    

    因此,您想查询数据库中的一条记录,但无法知道查询时实际查询的字段的存储值是什么。您可以构建一个classmethod 来查询整个users 表并遍历每条记录,解密其存储的哈希并将其与明文电子邮件进行比较。或者,您可以构建一个始终返回相同值的散列函数,使用该函数散列新用户电子邮件,并使用电子邮件字符串的散列直接查询 email_hash 字段。其中,考虑到大量用户,第一个效率非常低。

    Fernet.encrypt 函数是:

    def encrypt(self, data):
        current_time = int(time.time())
        iv = os.urandom(16)
        return self._encrypt_from_parts(data, current_time, iv)
    

    因此,您可以定义current_timeiv 的静态值,然后自己直接调用Fermat._encrypt_from_parts。或者你可以使用python内置的hashjust set a fixed seed,这样它就具有确定性。然后,您可以对要查询的电子邮件字符串进行哈希处理,然后首先直接查询Users.email_hash。只要您没有对密码字段执行上述任何操作!

    【讨论】:

    • 我认为这在您的示例中有效,因为您的加密和解密功能没有区别。但是,我尝试了这种方法,现在得到了新的错误:TypeError("token must be bytes"),在这条线上return f.decrypt(cls.email_hash),这是由于解密功能造成的。而且我根本无法将.encode('utf-8') 放在上面,因为正如您指出的那样,它是InstrumentedAttribute 类型。 注意我修改了我的代码以包含精确的库
    • 是的,我还意识到 Fernet 加密中固有的强制随机随机数/盐是有问题的。所以我将它切换到 pyCryptodome 库中具有恒定初始化向量的静态 AES。这样加密和解密给出相同的值!我仍然不确定这个解决方案是否能解决问题,但它仍然是有益的讨论,我很感激你的时间。经过多一点测试后将接受答案!
    • 是的,您仍然会遇到同样的问题,您将无法在明文电子邮件地址上查询数据库,但您实际上不必这样做。您现在只需查询email_hash 并创建一个名为email 的常规python @property,如果您需要,它可以返回实例化用户的解密电子邮件。很高兴我能提供帮助!
    • @Attack68 你能成功吗?我也面临类似的问题。我试过同时使用propertyhybrid_property,但无法正常工作。能否请您分享代码sn-p。
    • @SuyashSoni 抱歉,我从来没有想出一个按我最初预期工作的解决方案。如果您发布新问题,请在此处链接
    猜你喜欢
    • 1970-01-01
    • 2011-10-19
    • 2014-11-25
    • 2016-03-07
    • 2016-08-30
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多