Authentication is one of the most important elements of web applications. This process prevents unauthorized users from reaching unintended pages. You can create your own authentication system using cookies and password hashing. This miniature project will be an excellent test of your skills. As you might have guessed, there is already an extension that can make life a lot easier. Flask-Login is an extension that allows you to easily integrate an authentication system into your Flask application. You can install it using the following command:
(env) [email protected]:~/flask_app$ pip install flask-login
Table of Contents
Create a user model
Right now, information about users who are site administrators or editors is not stored anywhere. The first task is to create a User
model to store user data. Let’s open main2.py
to add a User
model after the Employee
model:
#..
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer(), primary_key=True)
name = db.Column(db.String(100))
username = db.Column(db.String(50), nullable=False, unique=True)
email = db.Column(db.String(100), nullable=False, unique=True)
password_hash = db.Column(db.String(100), nullable=False)
created_on = db.Column(db.DateTime(), default=datetime.utcnow)
updated_on = db.Column(db.DateTime(), default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return "<{}:{}>".format(self.id, self.username)
#...
To update the database, we need to create a new migration. In the terminal to create a new migration script you must enter the following command:
(env) [email protected]:~/flask_app$ python main2.py db migrate -m "Adding users table"
Run the migration using the upgrade
command:
(env) [email protected]:~/flask_app$ python main2.py db upgrade
INFO [alembic.runtime.migration] Context impl MySQLImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.runtime.migration] Running upgrade 6e059688f04e -> 0f0002bf91cc,
Adding users table
(env) [email protected]:~/flask_app$
This will create a users
table in the database.
Hashing passwords
Passwords should never be stored in plain text in a database. If this is done, an intruder who is able to hack the database will be able to find out both passwords and email addresses. People have been known to use the same password for different sites, which means that one combination will give an attacker access to other user accounts. Instead of storing passwords directly in a database, you should store their hashes. A hash is a string of characters that look like they were picked at random.
pbkdf2:sha256:50000$Otfe3YgZ$4fc9f1d2de2b6beb0b888278f21a8c0777e8ff980016e043f3eacea9f48f6dea
A hash is created with a one-way hashing function. It takes the length of a variable and returns a fixed length output, which we call a hash. What makes a hash safe is the fact that it cannot be used to get the original string (that’s why it’s called a one-way function). Nevertheless, for the same input a one-way hash function will return the same result. Here are the processes that are involved in creating the password hash: When the user submits the password (during the login phase), you need to hash it and store the hash in the database. When the user logs on again, the function will re-create the hash and compare it to what’s stored in the database. If they match, the user will have access to the account. Otherwise, an error will occur. Flask comes with the Werkzeug package, which has two auxiliary functions for hashing passwords.
Method | Description |
---|---|
generate_password_hash(password) | Receives a password and returns a hash. By default, uses the one-way function pbkdf2 to generate the hash. |
check_password_hash(password_hash, password) | Takes hash and password in the clear, then compares password and password_hash . If they are the same, it returns True . |
The following code demonstrates how to work with these functions:
>>>
>>> > from werkzeug.security import generate_password_hash, check_password_hash
>>>
>>> hash = generate_password_hash("secret password")
>>>
>>> hash
'pbkdf2:sha256:50000$zB51O5L3$8a43788bc902bca96e01a1eea95a650d9d5320753a2fbd16bea984215cdf97ee'
>>>
>>> check_password_hash(hash, "secret password")
True
>>>
>>> > check_password_hash(hash, "pass")
False
>>>
>>>
Note that when check_password_hash()
is called with a valid password ("secret password")
, it returns True
, and if it is invalid, it returns False
. Next, you need to update the User
model, and add password hashing to it:
#...
from werkzeug.security import generate_password_hash, check_password_hash
#...
#...
class User(db.Model):
#...
updated_on = db.Column(db.DateTime(), default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return "<{}:{}>".format(self.id, self.username)
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
#...
Let’s create users to check the hashing of passwords.
(env) [email protected]:~/flask_app$python main2.py shell
>>>
>>> > from main2 import db, User
>>>
>>> u1 = User(username='spike', email='[email protected]')
>>> u1.set_password("spike")
>>>
>>> u2 = User(username='tyke', email='[email protected]')
>>> u2.set_password("tyke")
>>>
>>> db.session.add_all([u1, u2])
>>> db.session.commit()
>>>
>>> u1, u2
(<1:spike>, <2:tyke>)
>>>
>>>
>>> > u1.check_password("pass")
False
>>> > u1.check_password("spike")
True
>>>
>>> > u2.check_password("foo")
False
>>> > u2.check_password("tyke")
True
>>>
>>>
The output shows that everything is working as it should and there are now two users in the database.
Integrating Flask-Login
To run Flask-Login, we need to import the LoginManager
class from the flask_login
package and create a new LoginManager
instance:
#...
from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import LoginManager
app = Flask(__name__)
app.debug = True
app.config['SECRET_KEY'] = 'a really really really long secret key'
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://root:[email protected]/flask_app_db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['MAIL_SERVER'] = 'smtp.googlemail.com'
app.config['MAIL_PORT'] = 587
app.config['MAIL_USE_TLS'] = True
app.config['MAIL_USERNAME'] = '[email protected]'
app.config['MAIL_DEFAULT_SENDER'] = ' [email protected]'
app.config['MAIL_PASSWORD'] = ' password'
manager = Manager(app)
manager.add_command('db', MigrateCommand)
db = SQLAlchemy(app)
migrate = Migrate(app, db)
mail = Mail(app)
login_manager = LoginManager(app)
#...
Flask-Login requires the addition of several methods to the User
class to verify users. These methods are listed in the following table:
The method | Description |
---|---|
is_authenticated() | Returns True if the user is authenticated (i.e., logged in with the correct password). Otherwise it returns False . |
is_active() | Returns True if the account is not suspended. |
is_anonymous() | Return True for unauthenticated users. |
get_id() | Returns the unique identifier of the User object. |
Flask-Login offers a default implementation of these methods using the UserMixin
class. So, instead of defining them manually, you can set them to be inherited from the UserMixin
class. Let’s open main2.py
to change the User
model header:
#...
from flask_login import LoginManager, UserMixin
#...
class User(db.Model, UserMixin):
__tablename__ = 'users'
#...
All that remains is to add a callback to user_loader
. The corresponding method can be added over the User
model.
#...
@login_manager.user_loader
def load_user(user_id):
return db.session.query(User).get(user_id)
#...
The function taking user_loader
decorator as argument will be called with every request to the server. It loads the user from the user id into the session cookie. Flask-Login makes the loaded user available with the current_user
proxy. To use current_user
, it must be imported from the flask_login
package. It behaves as a global variable and is available in both view functions and templates. At any given time, current_user
refers to either a logged in or an anonymous user. You can distinguish between the two by using the is_authenticated
attribute of the current_user
proxy. For anonymous users, is_authenticated
will return False
. Otherwise it returns True
.
Restricting viewer access
For now there is no administration panel on this site. In this tutorial it will be presented as a normal page. To prevent unauthorized users from accessing protected pages, Flask-Login has a login_required
decorator. Let’s add the following code in the main2.py
file right after the updating_session()
view:
#...
from flask_login import LoginManager, UserMixin, login_required
#...
@app.route('/admin/')
@login_required
def admin():
return render_template('admin.html')
#...
The login_required
decorator ensures that the admin()
view function is only called if the user is authorized. By default, if an anonymous user tries to access a protected page, he will get a 401 “Not authorized” error. You need to start the server and go to https://localhost:5000/login
to see how it works. This will open a page like this: Instead of showing the user a 401 error, it is better to redirect him to the login page. To do this, pass the
login_view
attribute of the LoginManager
instance to the login()
view function:
#...
migrate = Migrate(app, db)
mail = Mail(app)
login_manager = LoginManager(app)
login_manager.login_view = 'login'
class Faker(Command):
'A command to add fake data to the tables'
#...
The login()
function is now defined as follows (but it will need to be changed):
#...
@app.route('/login/', methods=['post', ' get'])
def login():
message = ''
if request.method == 'POST':
print(request.form)
username = request.form.get('username')
password = request.form.get('password')
if username == 'root' and password == 'pass':
message = "Correct username and password"
else:
message = "Wrong username or password"
return render_template('login.html', message=message)
#...
If you now go to https://localhost:5000/admin/,
you will be redirected to the login page: Flask-Login also sets up a popup message when the user is redirected to the login page, but now there is no message because the login template
(template/login.html
) does not display any message. You have to open login.html
and add the following code before the <form>
tag:
#...
{% endif %}
{% for category, message in get_flashed_messages(with_categories=true) %}
<spam class="{{ category }}">{{ message }}</spam>
{% endfor %}
<form action="" method="post">
#...
If you go to https://localhost:5000/admin/
again ,
the page will display a message. To change the content of the message, you must pass the new text to the
login_message
attribute of the LoginManager
instance. At the same time, why not create a template for admin()
view function. Let’s create a new template admin.html
with the following code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h2>Logged in User details</h2>
<ul>
<li>Username: {{ current_user.username }}</li>
<li>Email: {{ current_user.email }}</li>
<li>Created on: {{ current_user.created_on }}</li>
<li>Updated on: {{ current_user.updated_on }}</li>
</ul>
</body>
</html>
Here we use the variable current_user
to display details about the authorized user.
Create Authorization Form
Before authorizing, we will need to create a form. It will have three fields: username, password, and remember me. Let’s open forms.py
to add a LoginForm
class below the ContactForm
class:
#...
from wtforms import StringField, SubmitField, TextAreaField, BooleanField, PasswordField
#...
#...
class LoginForm(FlaskForm):
username = StringField("Username", validators=[DataRequired()])
password = PasswordField("Password", validators=[DataRequired()])
remember = BooleanField("Remember Me")
submit = SubmitField()
User Authorization
Flask-Login provides the login_user()
function for user authorization. It accepts a user object. If successful, it returns True
and sets the session. Otherwise it returns False
. By default, the session set by login_user()
ends when the browser closes. To allow users to stay logged in for longer, you need to pass remember=True
to login_user()
when the user is logged in. Let’s open main2.py
to change the login()
view function:
#...
from forms import ContactForm, LoginForm
#...
from flask_login import LoginManager, UserMixin, login_required, login_user, current_user
#...
@app.route('/login/', methods=['post', 'get'])
def login():
form = LoginForm()
if form.validate_on_submit():
user = db.session.query(User).filter(User.username == form.username.data).first()
if user and user.check_password(form.password.data):
login_user(user, remember=form.remember.data)
return redirect(url_for('admin'))
flash("Invalid username/password", 'error')
return redirect(url_for('login'))
return render_template('login.html', form=form)
#...
Next we need to update login.html
to use the LoginForm()
class. We need to add the following changes to the file:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login</title>
</head>
<body>
{% for category, message in get_flashed_messages(with_categories=true) %}
<spam class="{{ category }}">{{ message }}</spam>
{% endfor %}
<form action="" method="post">
{{ form.csrf_token }}
<p>
{{ form.username.label() }}
{{ form.username() }}
{% if form.username.errors %}
{% for error in form.username.errors %}
{{ error }}
{% endfor %}
{% endif %}
</p>
<p>
{{ form.password.label() }}
{{ form.password() }}
{% if form.password.errors %}
{% for error in form.password.errors %}
{{ error }}
{% endfor %}
{% endif %}
</p>
<p>
{{ form.remember.label() }}
{{ form.remember() }}
</p>
<p>
{{ form.submit() }}
</p>
</form>
</body>
</html>
Now you can login. If you go to https://localhost:5000/admin,
you will be redirected to the login page. Enter the correct username and password and press sumbit. You will be redirected to the admin page, which should look like this.
If you do not click “Remember Me” when logging in, the site will log out of the account when you close your browser. If you click, the login remains. If you enter the wrong username or password, you will be redirected to the login page with a popup message:
Ending user sessions (logging out of accounts)
The logout_user()
function in Flask-Login terminates a user’s session by removing their ID from the session. In the main2.py
file you need to add the following code under login()
view function:
#...
from flask_login import LoginManager, UserMixin, login_required, login_user, current_user, logout_user
#...
@app.route('/logout/')
@login_required
def logout():
logout_user()
flash("You have been logged out.")
return redirect(url_for('login'))
#...
Next, you need to update the admin.html
template to add a link to the logout
route:
#...
<ul>
<li> Username: {{current_user.username }}</li>
<li> Email: {{current_user.email }}</li>
<li> Created on: {{current_user.created_on }}</li>
<li> Updated on: {{current_user.updated_on }} </li>
</ul>
<p><a href="{{ url_for('logout') }}">Logout</a></p>
</body>
</html>
If you go to https://localhost:5000/admin/
now (being logged in), there should be a link at the bottom of the page to log out of your account. If you click it, you will be redirected to the login page
The final touches
There is one small problem with the login page. Right now, if an authorized user goes to https://localhost:5000/login/,
he will see the login page again. There is no point in showing the form to the authorized user. To solve this problem, you need to add the following changes to the login()
view function:
#...
@app.route('/login/', methods=['post', 'get'])
def login():
if current_user.is_authenticated:
return redirect(url_for('admin'))
form = LoginForm()
if form.validate_on_submit():
#...
After these changes, if the authorized user goes to the login page, he will be redirected to the admin page.