Flask Login
Flask provides an extension to manage the user, tracking whether a person has been authenticated within the session. This post will introduce that concept.
Flask provides an extension to manage the user, tracking whether a person has been authenticated within the session. This post will introduce that concept.
Flask-Login is able to work with any number of authentication schemes. It is not necessary to specify one yet. I am going to postpone deciding what the implementation will be. For now, I will just extend the existing functionality of the Anonymous Agile Estimator to use this extension.
Flask-Login provides management of the user sessions, making a current_user
available in the model used by the template and views. This is stored in the user session, but abstracts us away from worrying about the implementation.
Install and Configure
First we must install the plug-in with pip install flask-login
and then add it to the estimator module __init__
file so that it can be initialised with the create_app
function.
First import the manager class:
from flask_login import LoginManager
Then create an instance. Note that this points to a controller function called login
in the Blueprint web
that has not been created yet. None of the pages have flagged as requiring authentication yet, so this will not break anything at the moment.
login_manager = LoginManager()
login_manager.login_view = 'web.login'
The last step of including this plug-in is to initialise it with the application within the create_app
function. This is similar to the way that both Bootstrap and SqlAlchemy plug-ins were added to the application.
login_manager.init_app(app)
To test this I temporarily add the following block of HTML to the index.html
page. Before any of the above code, an error was thrown but when everything is wired up correctly, I see the text in the second part of the if statement.
{% if current_user.is_authenticated %}
<p> authenticated user</p>
{% else %}
<p> non-authenticated user but the field current_user is being accessible</p>
{% endif %}
Any page can now check `current_user.is_authenticated` to at least verify that the user has successfully logged in.
## Logging In
Not sure where the best place to put the next piece of code. In *Flask Web Development*, Miguel Grinberg suggests that this belongs in with the database model module. I am placing it into the controller for now, as it has access to the `login_manager`. The file is going to need re-factoring soon to make it smaller and move some functionality into services, but will do for now.
The application needs to know how to load a user from the database. We can use a decorator associated with the `login_manager` created above to mark a function as the loader.
```python
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
Now the functions that require it can be marked with a decorator to indicate that the user should be logged on. For example:
@web.route('/creategroup', methods=['POST'])
@login_required
def create_group():
…
I am going to need to create a login view and template that responds to both GET and POST requests. This will replace the existing accept_nickname
controller function. Note that there is no real verification going on. Only a nickname is requested, but this is where any authentication will be done eventually.
One further change is that the user needs to be created at this point. Previously the nickname was only stored in the DB when it was first needed, for example, after creating a group.
def find_or_create_user(nickname):
user = User.query.filter_by(nickname=nickname).first()
if user == None:
user = User(nickname)
db.session.add(user)
db.session.commit()
return user
I’ve placed the login_required
decorator on the create_group
view, which only accepts a POST request. By default, the redirect will give the client a response code of 302. That will cause the client to send a GET on the same address, which means loading the page will not work.
To get the posting of a new group to work, I would need to redirect and tell the client browser to resend the same information. This can be done by returning a 307 response code rather than 302, indicating to clients to resend the same data. However, because the login page is being shown following an initial redirect from the login_required
decorator which uses a 302, I cannot get this to work at the moment.
To work around this, I will add a GET version of any POST only views and make sure that the view returns the user back to a reasonable place. In the case of create_group
, the form data will not validate and it will return the home page and thus presenting the page from which groups can be created. The declaration becomes:
@web.route('/creategroup', methods=['GET',POST'])
@login_required
def create_group():
…
In reality, this is not a problem that should affect most users. It is unlikely that they would have access to the form in order to send an unauthenticated POST to an endpoint protected by login_required
. This situation only really exists in testing a partially protected application during development. Or using Postman or curl to connect to the website.
To log the user into the application, I’ve replaced the accept_nickname
function with the following.
@web.route('/login', methods=['GET', 'POST'])
def login():
form = NickNameForm()
if form.validate_on_submit():
nickname = form.name.data
# this is where real authentication should be done
user = find_or_create_user(nickname)
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=next), 400
return render_template('login.html', form=form)
Note on line 8 the call to login_user(user)
which is a new import from flask_login. This is what marks the client as authenticated and logged in. There are additional parameters available for this to allow the setting of things such as staying logged in with or without an expiry. For now, it is sufficient to simply login.
The template needs to change also. Originally accept_nickname
was responding to POST requests at the root of the application. It now needs to send the nickname form to /login
.
{{ wtf.quick_form(form, action="/login") }}
Logging out is easy also. Another import is required: logout_user
and this call needs to be made from the logging out function. The session.pop('nickname')
is obsolete and has been removed.
@web.route('/confirm-logout', methods=['GET', 'POST'])
def confirm_logout():
if request.method == 'GET':
return render_template('confirm-logout.html')
logout_user()
return redirect(url_for('web.index'))
Any part of the code that uses the nickname
session variable need to change to use the current_user
field. The model object this represents is the User
as defined above, with extra functions available through the extension of UserMixin
. So to access the nickname, use current_user.nickname
.
For example the navigation bar code, held in a base template needs to change as follows. The bar should only show certain options if the user is logged in.
{% if current_user.is_authenticated %}
<li><a href="/">My Groups</a></li>
<li><a href="/confirm-logout">Sign Out {{ current_user }}</a></li>
{% endif %}
Note that here I did not reference the nickname attribute of the object. The object has defined the __repr__
function to return the nickname field.
More on UserMixin
The key thing that I like about this approach is that the User model that was already in use is enhanced by extending the UserMixin
class from Flask-Login, with little or no impact to its functionality. Here is a brief summary of what is possible.
First up is that everything that is on the model is available as normal by simply accessing the property. No more loading the user from the database; simply import current_user
and access it anywhere.
If the user has not been authenticated, then you cannot, obviously, access any of the fields. Instead you can use one of these additional properties to verify that the user has authenticated.
is_authenticated
will return true if the user has logged in and been authenticatedis_active
can be used to identify users that are not active. Perhaps they have no validated an email address, or their account has been suspendedis_anonymous
should be the opposite ofis_authenticated
and is the base state before the user logs in
To take advantage of this plug-in, practically no change had to be made to the underlying user model, simply adding the UserMixin
super class and add the small amount of configuration shown above. This means it can slot in nicely with most use cases.
One thing to ask is that since the application no longer puts a session variable containing the user nickname, what is stored in the user’s browser cookies? Will any sensitive data from the user object be saved? Flask cookies are not a good place to store sensitive data.
Let’s look at the cookie to see what’s stored in it. There are a number of ways to do this depending on the browser. In Safari, you can use the Inspect Element option from the context menu. On the Storage tab the cookie is visible and can be copied.
This one is base 64 encoded, but the leading period indicates that it is also compressed.
session .eJwlz0lqAzEQQNG7aO2FVIOq5Ms0pRpICCTQba9C7m5DDvDh_d921JnXR7s_zmfe2vEZ7d6WWy4CIZEVC6t8IvAKAUgvR02XNCJxtKiR7l2rck_APXJJjY2BHXNsJiHmTQqqQAtKVcWVFw3eSsFRBQBqfUvvPbYDWLs1v846Hj9f-f328PRSDjNliM4TZS4u7CUaZIjImpi2393zyvN_gtrfC9P5Pug.XPu6ng.fBY63VfAHY0QlV8dFX-1a-6GD6U localhost / Session 266 B ✓
Taking everything from the first period up to but not including the second period, and adding three padding characters (needed for the base64 encoding), you can decode this with the following in the Python shell.
>>> import base64
>>> import zlib
>>> zlib.decompress(base64.urlsafe_b64decode('.eJwlz0lqAzEQQNG7aO2FVIOq5Ms0pRpICCTQba9C7m5DDvDh_d921JnXR7s_zmfe2vEZ7d6WWy4CIZEVC6t8IvAKAUgvR02XNCJxtKiR7l2rck_APXJJjY2BHXNsJiHmTQqqQAtKVcWVFw3eSsFRBQBqfUvvPbYDWLs1v846Hj9f-f328PRSDjNliM4TZS4u7CUaZIjImpi2393zyvN_gtrfC9P5Pug==='))
Which yields the following JSON that only contains the id of the user.
{
"_fresh": true,
"_id": "9cae94274779d93ffc63259d722ecfc38ec7ea447c3adf1ecc08ffeb623b1e97f1b3d303e1b547455b482882492f8887c859415b84d5dff2228a0b7000dbc22a",
"csrf_token": "56cf85daa852d05637695f30f78d4a33358e3eab",
"user_id": "4"
}
Flask-Login uses the function decorated with login_manager.user_loader
to load the user object from the database whenever it needs to be refreshed reducing the amount of database code you need to write.
Conclusion
Flask-Login just manages the user within the browser session for you. It is agnostic on how the user is authenticated, allowing you to use any method you like to provide this. It is simple to add and does not impact the code by imposing a particular structure.