Flask Admin - Automatically create password hash when creating a new user?
Asked Answered
F

5

5

Say I have a user model like:

class User(db.Model, UserMixin):
    id = db.Column(db.Integer, primary_key=True)
    first_name = db.Column(db.String(255))
    last_name = db.Column(db.String(255))
    email = db.Column(db.String(255), unique=True)
    password = db.Column(db.String(255))
    registered_on = db.Column(db.DateTime, nullable=True)
    roles = db.relationship('Role', secondary=roles_users,
                            backref=db.backref('users', lazy='dynamic'))

And an admin view:

class UserView(MyModelView):

    form_columns = (
        'roles',
        'first_name',
        'last_name',
        'email',
        'password',
        'registered_on',
    )

    form_args = dict(
                registered_on=dict(default=datetime.now())
            )

When I create a new user how to I automatically generate a password hash with something like bcrypt?

Filipe answered 13/3, 2018 at 20:37 Comment(0)
M
7

I think by automatic you mean that it should automatically convert a manually-supplied plain-text password into a hash, but I am not certain. I am answering as if this assumption is correct.

We don't expose our password fields in Flask-Admin, but we handle the basic question of how and when passwords get hashed by defining password as a property with a setter responsible for computing the hash itself and tucking it away in user._password.

A naive swing at just trying to add password as a column in the admin view didn't work, but if you use sqlalchemy's hybrid_property instead of property, it looks like this works fine with "password" in your User view's list of form_columns (as you already have):

# models.py
from sqlalchemy.ext.hybrid import hybrid_property

class User(sql.Model):
    # ...

    _password = sql.Column(sql.Binary)
    _salt = sql.Column(sql.Binary, default=lambda: os.urandom(512))

    # ... 

    @hybrid_property
    def password(self):
        """Return the hashed user password."""
        return self._password

    @password.setter
    def password(self, new_pass):
        """Salt/Hash and save the user's new password."""
        new_password_hash = compute_new_password_hash(new_pass, self._salt)
        self._password = new_password_hash

# admin.py
class UserView(MyModelView):
    # ...

    form_columns = ("email", "password", ...)

The editing experience isn't exactly polished--you'd probably need to override the field to refine it. Here are some screenshots:

Creating a new user with a pre-set password:

Creating a new user with password

The edit view after creation:

User created, password hashed

Updating the user's password:

Saving a new password

The edit view after saving the new password:

User updated with new hash

Metacenter answered 15/3, 2018 at 16:36 Comment(5)
What does the _ that precedes password mean?Filipe
One other question, for your salt is there any advantages over calc on the application end or the db server end? ie _salt = sql.Column(sql.Binary, default=func.rand(512))Filipe
@Filipe Unlike some languages with strong access restrictions (defining variables and methods as public, private, protected, etc.), Python is very permissive. In place of those concepts, it has some soft naming conventions. Conventionally, an underscore prefix on a function or variable indicates something "like" it being private or protected. It's a warning that the writer sees this element as an internal implementation detail that external users of the library should avoid relying on.Metacenter
@Filipe Because of these soft conventions, some Python software will treat things that follow these conventions a little differently. So, in this case, Flask-Admin will infer that, by default, it shouldn't include model columns that start with an underscore in the column list or edit forms.Metacenter
@Filipe I haven't directly looked into using the database to salt before, but one consequence is that you'll be at the mercy of how your database implements it. The Python docs state that os.urandom is suitable for cryptographic purposes, and the docs for postgres note that the basic random() function is not suitable for cryptographic purposes; it also doesn't accept any parameter; you're just getting a random float back (though there is a salt-generating function in pgcrypto).Metacenter
I
15

The easiest option I've found is to add an SQLAlchemy attribute event on User.password and hash the password there if changed. This way, anytime a user password is changed from anywhere, it'll be automatically hashed.

from sqlalchemy import event
from werkzeug.security import generate_password_hash


@event.listens_for(User.password, 'set', retval=True)
def hash_user_password(target, value, oldvalue, initiator):
    if value != oldvalue:
        return generate_password_hash(value)
    return value
Inborn answered 18/7, 2019 at 18:25 Comment(2)
Amazingly helpful! Thanks a lotSteinbok
This was best solution to the issue. I just put this under my users model.Context
M
7

I think by automatic you mean that it should automatically convert a manually-supplied plain-text password into a hash, but I am not certain. I am answering as if this assumption is correct.

We don't expose our password fields in Flask-Admin, but we handle the basic question of how and when passwords get hashed by defining password as a property with a setter responsible for computing the hash itself and tucking it away in user._password.

A naive swing at just trying to add password as a column in the admin view didn't work, but if you use sqlalchemy's hybrid_property instead of property, it looks like this works fine with "password" in your User view's list of form_columns (as you already have):

# models.py
from sqlalchemy.ext.hybrid import hybrid_property

class User(sql.Model):
    # ...

    _password = sql.Column(sql.Binary)
    _salt = sql.Column(sql.Binary, default=lambda: os.urandom(512))

    # ... 

    @hybrid_property
    def password(self):
        """Return the hashed user password."""
        return self._password

    @password.setter
    def password(self, new_pass):
        """Salt/Hash and save the user's new password."""
        new_password_hash = compute_new_password_hash(new_pass, self._salt)
        self._password = new_password_hash

# admin.py
class UserView(MyModelView):
    # ...

    form_columns = ("email", "password", ...)

The editing experience isn't exactly polished--you'd probably need to override the field to refine it. Here are some screenshots:

Creating a new user with a pre-set password:

Creating a new user with password

The edit view after creation:

User created, password hashed

Updating the user's password:

Saving a new password

The edit view after saving the new password:

User updated with new hash

Metacenter answered 15/3, 2018 at 16:36 Comment(5)
What does the _ that precedes password mean?Filipe
One other question, for your salt is there any advantages over calc on the application end or the db server end? ie _salt = sql.Column(sql.Binary, default=func.rand(512))Filipe
@Filipe Unlike some languages with strong access restrictions (defining variables and methods as public, private, protected, etc.), Python is very permissive. In place of those concepts, it has some soft naming conventions. Conventionally, an underscore prefix on a function or variable indicates something "like" it being private or protected. It's a warning that the writer sees this element as an internal implementation detail that external users of the library should avoid relying on.Metacenter
@Filipe Because of these soft conventions, some Python software will treat things that follow these conventions a little differently. So, in this case, Flask-Admin will infer that, by default, it shouldn't include model columns that start with an underscore in the column list or edit forms.Metacenter
@Filipe I haven't directly looked into using the database to salt before, but one consequence is that you'll be at the mercy of how your database implements it. The Python docs state that os.urandom is suitable for cryptographic purposes, and the docs for postgres note that the basic random() function is not suitable for cryptographic purposes; it also doesn't accept any parameter; you're just getting a random float back (though there is a salt-generating function in pgcrypto).Metacenter
A
2

You can set a PasswordField widget for the password field, and override the process_formdata method to set de hash.


from flask_security.utils import hash_password
from wtforms import PasswordField

class AdminPasswordField(PasswordField):

    def process_formdata(self, valuelist):
        if valuelist and valuelist[0] != '':
            self.data = hash_password(valuelist[0])
        elif self.data is None:
            self.data = ''

class UserView(MyModelView):

    form_columns = (
        'roles',
        'first_name',
        'last_name',
        'email',
        'password',
        'registered_on',
    )

    form_args = dict(
                registered_on=dict(default=datetime.now())
            )

    form_overrides = {
        'password': AdminPasswordField,
    }

Alanalana answered 11/7, 2020 at 3:58 Comment(0)
J
1

Internally, Flask-Admin calls WTF's populate_obj to set the data from the form. So we can take advantage of that as shown below:

class CustomPasswordField(PasswordField): # If you don't want hide the password you can use a StringField

    def populate_obj(self, obj, name):
        setattr(obj, name, hash_password(self.data)) # Password function

Then we can use it in our ModelView as follows:

class UserView(ModelView):
    form_extra_fields = {
        'password': CustomPasswordField('Password', validators=[InputRequired()])
    }

The password will be hashed the moment the form is saved.

Josephina answered 24/3, 2020 at 16:26 Comment(0)
F
0

You can use Flask-Admin's on_model-change and give it logic before it commits to the database.

from flask_admin.contrib import sqla
from flask_security import utils

class UserAdmin(sqla.ModelView):

    def on_model_change(self, form, model, is_created):
        model.password = utils.encrypt_password(model.password)
        # as another example
        if is_created:
            model.registered_on = datetime.now()  
Filipe answered 14/3, 2018 at 12:50 Comment(5)
There are a few related concerns that come with splitting this logic up like this. I'll address each in a separate comment for ease-of-reading.Metacenter
1.) This only catches updates by flask-admin itself. If other views (like user registration/change password/forgot password pages) need to set the password, each one will need to do this correctly. If one doesn't do it correctly, there's some non-zero chance you might end up with plaintext passwords or incompatible hashes in the database.Metacenter
2.) It increases the chance any future developer using this codebase could mis-interpret the code. There's nothing about the password property or the User object that communicates this value should actually be a hash, and it won't be obvious where to look for more on how the value is computed unless you already know the admin view plays a role.Metacenter
Awesome insight. Quick question, should I capture registered_on at on the User class level or leave it on the UserAdmin class as I have it?Filipe
We handle this at the User class level with something like _created_at = sql.Column(sql.DateTime, default=datetime.datetime.now()), but I guess the "should" part depends on whether you'd want that property set at creation for every user account.Metacenter

© 2022 - 2024 — McMap. All rights reserved.