Password Management with Flask
The next enhancement to Agile Anonymous Estimator application is to add passwords. This will enable users to log on and to be securely authenticated.
The next enhancement to Agile Anonymous Estimator application is to add passwords. This will enable users to log on and to be securely authenticated.
Passwords should not be stored directly in the database, rather we will store only the cryptographic hash. We can use functionality provided by the Werkzeug package, already in the requirements, to generate this hash.
The unique identifier for the users will also change from nickname to email address. The application won’t be sending emails at this stage, even to reset passwords, so I am not going to add email confirmation now. For more information on that see chapter 8 in Flask Web Development, 2nd Edition by Miguel Grinberg. The examples here draw on that source.
First step is to add the additional fields within the database model. I need new fields to store the email address and the password hash. The following is added to the User
class in database/models.py
:
email = db.Column(db.String(128))
password_hash = db.Column(db.String(128))
The setting and verification of the password will use functions in the werkzeug.security
module, so that needs to be imported:
from werkzeug.security import generate_password_hash, check_password_hash
I want to be able to store a password as if setting a property, but it should not be possible to read it back. Note the following getter function is marked with the @property
decorator and throws an exception. The setter is also decorated to indicate it is the setter for the password
property. It uses the Werkzeug module to hash the password, storing the result into password_hash
created above.
@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)
The final change is to add a verification for the password. This can be called by the login function controller once the password form has been submitted.
def verify_password(self, password):
return check_password_hash(self.password_hash, password)
The login form needs to change to allow the users to enter their email address and password. We will use the built-in WTForms Email
validator to ensure that a correct email address is entered. It now looks like this:
class LoginForm(FlaskForm):
email = StringField('Enter your username (email address)', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired()])
submit = SubmitField('Login')
The controller method changes a little also. Rather than simply accepting the nickname, the password supplied must be checked against the one in the database, assuming the user has created an account.
@web.route('/login', methods=['GET', 'POST'])
def login():
form = LoginForm()
if form.validate_on_submit():
email = form.email.data
# this is where real authentication should be done
user = find_user(email)
if user == None:
error_message = 'Unable to log you in, please check email and password carefully.'
return render_template('generic-error.html', error_message=error_message, back_url=url_for('web.login')), 401
nickname = user.nickname
if user.verify_password(form.password.data):
login_user(user)
next = request.args.get('next')
if next is None or not next.startswith('/'):
next = url_for('web.index')
return redirect(next)
error_message = 'Unable to log you in.'
return render_template('generic-error.html', error_message=error_message, back_url=url_for('web.login')), 400
return render_template('login.html', form=form)
On line 12, the user.verify_password
function is called to check that the hashed password matches the hash stored in the database.
The change to the database model generated a migration file, however, we need to take action to enter default email addresses and passwords for the existing accounts.
Note that when I finish the initial version of the application, I will collapse the migration scripts into a single file rather than these interim hops. For the time being, to ensure that all the existing users can log into the system I will give them an email address that is the nickname at test.com and a default password.
for user in User.query.all():
user.email = f'{user.nickname}@test.com'
user.password = 'password'
db.session.add(user)
db.session.commit()
Updating the tests was easy since I had taken care to put the login functionality into a helper method. The Bob user that is used in many of the tests can no longer simply login without having a row in the User
table, so the conftest.py
needs to add it:
user = User('bob', 'password')
user.email = '[email protected]'
db.session.add(user)
db.session.commit()
Then it is just a matter of logging in with the email address and password instead of just a nickname.
def login(client, nickname):
return client.post('/login', data=dict(
email=f'{nickname}@test.com', password='password'
), follow_redirects=True)
What’s Next?
I had hesitated to add passwords for a couple of reasons. First is that I did not want to manage email addresses and passwords. There is increased pressure to make sure that the system is fully secure. Even though users should never repeat passwords between different applications, unfortunately it is a common practice. Should this application get hacked, those that use it might be vulnerable.
The code I write needs to be rock solid, with no security mistakes. I can use some penetration testing techniques to test against security holes, but it is not just as simple as that. The libraries that I depend on may develop security problems. There will be a pressure on me to patch vulnerabilities as soon as they are flagged if I have to protect sensitive data.
The other problem is that a number other functions become required when you take on the responsibility of managing email and passwords. A registration process is needed, the ability to change passwords, password recovery, email verification etc. all now need to be added and that will probably be the focus of changes for the next few weeks.
An alternative to this is to use a third party to provide the authentication process. Users would be redirected to another site to provide their credentials, such as LinkedIn, and the third party would issue a token that can be used to access resources on my site. Using this technique I reduce the impact of the site being hacked as no personal data is stored here.