将SQLAlchemy hybrid_property与本机属性构造结合起来

时间:2018-07-19 09:47:22

标签: properties sqlalchemy flask-sqlalchemy getter-setter

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

我的问题是,如果我使用@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

1 个答案:

答案 0 :(得分:0)

在您的hybrid_property email中,如果self.f.decrypt(self.email_hash.encode('utf-8'))self.email_hash类型,则行str很好,但是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不能即使使用相同的键,也从一个执行返回下一个相同的fernet token。例如:

>>> 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。或者,您可以使用hashjust set a fixed seed内置的python来确定性。然后,您可以对要查询的电子邮件字符串进行哈希处理,然后首先直接查询Users.email_hash。只要您没有对密码字段执行上述任何操作!