Authentication and Authorization¶
Flask Unchained currently has one officially supported bundle for securing your app. It’s a refactored and cleaned up fork of the Flask Security project, and includes support for session and token authentication. (All of the core security logic remains unchanged.) Adding support for JWT authentication is on the roadmap, but isn’t implemented yet.
Install Security Bundle¶
pip install "flask-unchained[security]"
Let’s update our test fixtures configuration file to include the fixtures provided by the Security Bundle:
# tests/conftest.py
from flask_unchained.bundles.sqlalchemy.pytest import *
from flask_unchained.bundles.security.pytest import * # add this line
The security bundle overrides the client
and api_client
test fixtures to add support for logging in and logging out.
Now we can enable the Security Bundle by adding it to unchained_config.py
:
# unchained_config.py
BUNDLES = [
# ...
'flask_unchained.bundles.security',
'app',
]
Database Models and Migrations¶
Let’s start with configuring our database models, because the views will be broken until we implement our database models and create tables in the database for them. The security bundle includes default model implementations that, for now, will be sufficient for our needs:
# flask_unchained/bundles/security/models/user.py
from flask_unchained.bundles.sqlalchemy import db
from flask_unchained import unchained, injectable, lazy_gettext as _
from flask_unchained.bundles.security.models.user_role import UserRole
from flask_unchained.bundles.security.validators import EmailValidator
MIN_PASSWORD_LENGTH = 8
class User(db.Model):
"""
Base :class:`User` model. Includes :attr:`email`, :attr:`password`, :attr:`is_active`,
and :attr:`confirmed_at` columns, and a many-to-many relationship to the
:class:`Role` model via the intermediary :class:`UserRole` join table.
"""
class Meta:
lazy_mapped = True
repr = ('id', 'email', 'is_active')
email = db.Column(db.String(64), unique=True, index=True, info=dict(
required=_('flask_unchained.bundles.security:email_required'),
validators=[EmailValidator]))
_password = db.Column('password', db.String, info=dict(
required=_('flask_unchained.bundles.security:password_required')))
is_active = db.Column(db.Boolean(name='is_active'), default=False)
confirmed_at = db.Column(db.DateTime(), nullable=True)
user_roles = db.relationship('UserRole', back_populates='user',
cascade='all, delete-orphan')
roles = db.association_proxy('user_roles', 'role',
creator=lambda role: UserRole(role=role))
@db.hybrid_property
def password(self):
return self._password
@password.setter
@unchained.inject('security_utils_service')
def password(self, password, security_utils_service=injectable):
self._password = security_utils_service.hash_password(password)
@classmethod
def validate_password(cls, password):
if password and len(password) < MIN_PASSWORD_LENGTH:
raise db.ValidationError(f'Password must be at least '
f'{MIN_PASSWORD_LENGTH} characters long.')
@unchained.inject('security_utils_service')
def get_auth_token(self, security_utils_service=injectable):
"""
Returns the user's authentication token.
"""
return security_utils_service.get_auth_token(self)
def has_role(self, role):
"""
Returns `True` if the user identifies with the specified role.
:param role: A role name or :class:`Role` instance
"""
if isinstance(role, str):
return role in (role.name for role in self.roles)
else:
return role in self.roles
@property
def is_authenticated(self):
return True
@property
def is_anonymous(self):
return False
# flask_unchained/bundles/security/models/role.py
from flask_unchained.bundles.sqlalchemy import db
from flask_unchained.bundles.security.models.user_role import UserRole
class Role(db.Model):
"""
Base :class`Role` model. Includes an :attr:`name` column and a many-to-many
relationship with the :class:`User` model via the intermediary :class:`UserRole`
join table.
"""
class Meta:
lazy_mapped = True
repr = ('id', 'name')
name = db.Column(db.String(64), unique=True, index=True)
role_users = db.relationship('UserRole', back_populates='role',
cascade='all, delete-orphan')
users = db.association_proxy('role_users', 'user',
creator=lambda user: UserRole(user=user))
def __hash__(self):
return hash(self.name)
# flask_unchained/bundles/security/models/user_role.py
from flask_unchained.bundles.sqlalchemy import db
class UserRole(db.Model):
"""
Join table between the :class:`User` and :class:`Role` models.
"""
class Meta:
lazy_mapped = True
pk = None
repr = ('user_id', 'role_id')
user_id = db.foreign_key('User', primary_key=True)
user = db.relationship('User', back_populates='user_roles')
role_id = db.foreign_key('Role', primary_key=True)
role = db.relationship('Role', back_populates='role_users')
def __init__(self, user=None, role=None, **kwargs):
super().__init__(**kwargs)
if user:
self.user = user
if role:
self.role = role
We’re going to leave them as-is for now, but in preparation for later customizations, let’s subclass User
and Role
in our app bundle:
touch app/models.py
# app/models.py
from flask_unchained.bundles.security import User as BaseUser, Role as BaseRole, UserRole
class User(BaseUser):
pass
class Role(BaseRole):
pass
Time to generate some migrations:
flask db migrate -m 'add security bundle models'
And review them to make sure it’s going to do what we want:
# db/migrations/versions/[hash]_add_security_bundle_models.py
"""add security bundle models
Revision ID: 839865db0b53
Revises: eb0448e9a537
Create Date: 2018-08-07 16:55:40.180962
"""
from alembic import op
import sqlalchemy as sa
import flask_unchained.bundles.sqlalchemy.sqla.types as sqla_bundle
# revision identifiers, used by Alembic.
revision = '839865db0b53'
down_revision = 'eb0448e9a537'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('role',
sa.Column('name', sa.String(length=64), nullable=False),
sa.Column('id', sqla_bundle.BigInteger(), nullable=False),
sa.Column('created_at', sqla_bundle.DateTime(timezone=True),
server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('updated_at', sqla_bundle.DateTime(timezone=True),
server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_role'))
)
op.create_index(op.f('ix_role_name'), 'role', ['name'], unique=True)
op.create_table('user',
sa.Column('email', sa.String(length=64), nullable=False),
sa.Column('password', sa.String(), nullable=False),
sa.Column('is_active', sa.Boolean(name='is_active'), nullable=False),
sa.Column('confirmed_at', sqla_bundle.DateTime(timezone=True), nullable=True),
sa.Column('id', sqla_bundle.BigInteger(), nullable=False),
sa.Column('created_at', sqla_bundle.DateTime(timezone=True),
server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('updated_at', sqla_bundle.DateTime(timezone=True),
server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_user'))
)
op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True)
op.create_table('user_role',
sa.Column('user_id', sqla_bundle.BigInteger(), nullable=False),
sa.Column('role_id', sqla_bundle.BigInteger(), nullable=False),
sa.Column('created_at', sqla_bundle.DateTime(timezone=True),
server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('updated_at', sqla_bundle.DateTime(timezone=True),
server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.ForeignKeyConstraint(['role_id'], ['role.id'], name=op.f(
'fk_user_role_role_id_role')),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f(
'fk_user_role_user_id_user')),
sa.PrimaryKeyConstraint('user_id', 'role_id', name=op.f('pk_user_role'))
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('user_role')
op.drop_index(op.f('ix_user_email'), table_name='user')
op.drop_table('user')
op.drop_index(op.f('ix_role_name'), table_name='role')
op.drop_table('role')
# ### end Alembic commands ###
Looks good.
flask db upgrade
Seeding the Database¶
There is of course the manual method of creating users, either via the command line interface using flask users create
, or via the register endpoint (which we’ll set up just after this). But the problem with those methods is that they’re not reproducible. Database fixtures are one common solution to this problem, and the SQLAlchemy Bundle includes support for them.
First we need to create our fixtures directory and files. The file names must match the class name of the model each fixture corresponds to (Role
and User
in our case):
mkdir db/fixtures && touch db/fixtures/Role.yaml db/fixtures/User.yaml
# db/fixtures/Role.yaml
ROLE_USER:
name: ROLE_USER
ROLE_ADMIN:
name: ROLE_ADMIN
# db/fixtures/User.yaml
admin:
email: your_email@somewhere.com
password: 'a secure password'
is_active: True
confirmed_at: utcnow
roles: ['Role(ROLE_ADMIN, ROLE_USER)']
user:
email: user@flaskr.com
password: password
is_active: True
confirmed_at: utcnow
roles: ['Role(ROLE_USER)']
The keys in the yaml files, admin
, user
, ROLE_USER
and ROLE_ADMIN
, must each be unique across all of your fixtures. This is because they are used to specify relationships. The syntax there is 'ModelClassName(key1, Optional[key2, ...])'
. If the relationship is on the many side, as it is in our case, then the relationship specifier must also be surrounded by []
square brackets (yaml syntax to specify it’s a list).
It’s not shown above, but the fixture files are actually Jinja2 templates that generate yaml. Fixtures also have access to the excellent faker library to generate random data, for example we could have written email: {{ faker.free_email() }}
in the user
fixture. Between access to faker and the power of Jinja2, it’s quite easy to build up a bunch of fake content when you need to quickly.
Running the fixtures should create two users and two roles in our dev db:
flask db import-fixtures
Loading fixtures from `db/fixtures` directory
Created ROLE_USER: Role(id=1, name='ROLE_USER')
Created ROLE_ADMIN: Role(id=2, name='ROLE_ADMIN')
Created admin: User(id=1, email='your_email@somewhere.com', is_active=True)
Created user: User(id=2, email='user@flaskr.com', is_active=True)
Finished adding fixtures
Sweet. Let’s set up our views so we can actually login to our site!
Configuring and Customizing Views¶
The first thing we need to do is to include the SecurityController
in our routes.py
:
# app/routes.py
from flask_unchained import (controller, resource, func, include, prefix,
get, delete, post, patch, put, rule)
from flask_unchained.bundles.security import SecurityController
from .views import SiteController
routes = lambda: [
controller(SiteController),
controller(SecurityController),
]
By default, Security Bundle only comes with the login and logout endpoints enabled. Let’s confirm:
flask urls
Method(s) Rule Endpoint View Options
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
GET /static/<path:filename> static flask.helpers :: send_static_file strict_slashes
GET / site_controller.index app.views :: SiteController.index strict_slashes
GET /hello site_controller.hello app.views :: SiteController.hello strict_slashes
GET, POST /login security_controller.login flask_unchained.bundles.security.views.security_controller :: SecurityController.login strict_slashes
GET /logout security_controller.logout flask_unchained.bundles.security.views.security_controller :: SecurityController.logout strict_slashes
The security bundle comes with optional support for registration, required email confirmation, change password functionality, and last but not least, forgot password functionality. For now, let’s just enable registration:
# app/config.py
from flask_unchained import BundleConfig
class Config(BundleConfig):
# ...
SECURITY_REGISTERABLE = True
Rerunning flask urls
, you should see the following line added:
Method(s) Rule Endpoint View Options
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
GET, POST /register security_controller.register flask_unchained.bundles.security.views.security_controller :: SecurityController.register strict_slashes
Let’s add these routes to our navbar:
{# templates/_navbar.html #}
<div class="collapse navbar-collapse" id="navbarCollapse">
<ul class="navbar-nav mr-auto">
{{ nav_link('Home', endpoint='site_controller.index') }}
{{ nav_link('Hello', endpoint='site_controller.hello') }}
</ul>
<ul class="navbar-nav">
{% if not current_user.is_authenticated %}
{{ nav_link('Login', endpoint='security_controller.login') }}
{{ nav_link('Register', endpoint='security_controller.register') }}
{% else %}
{{ nav_link('Logout', endpoint='security_controller.logout') }}
{% endif %}
</ul>
</div>
Cool. You should now be able to login with the credentials you created in the User.yaml
fixture. If you take a look at the login and/or register views, however, you’ll notice that things aren’t rendering “the bootstrap way.” Luckily all the default templates in the security bundle extend the security/layout.html
template, so we can override just this template to fix integrating the layout of all security views into our site.
We’re going to completely override the layout template. In order to make sure the layout works correctly, we need to wrap the content block with a row and a column. Therefore, our version looks like this:
mkdir -p app/templates/security \
&& touch app/templates/security/layout.html \
&& touch app/templates/security/_macros.html
{# app/templates/security/layout.html #}
{% extends 'layout.html' %}
{% block body %}
<div class="container">
{% include '_flashes.html' %}
<div class="row">
<div class="col">
{% block content %}
{% endblock content %}
</div>
</div>
</div>
{% endblock body %}
But even after this change, our forms are still using the browser’s default form styling. Once again, the security bundle makes it easy to fix this, by overriding the render_form
macro in the security/_macros.html
template. You’ll note we’ve already written this macro, so all we need to do is the following:
{# app/templates/security/_macros.html #}
{% from '_macros.html' import render_form as _render_form %}
{# the above is *only* an import, and Jinja doesn't re-export it, so we #}
{# work around that by proxying to the original macro under the same name #}
{% macro render_form(form) %}
{{ _render_form(form, **kwargs) }}
{% endmacro %}
Testing the Security Views¶
Unlike all of our earlier tests, testing the security bundle views requires that we have valid users in the database. Perhaps the most powerful way to accomplish this is by using Factory Boy, which Flask Unchained comes integrated with out of the box. If you aren’t familiar with Factory Boy, I recommend you read more about how it works in the official docs. The short version is, it makes it incredibly easy to dynamically create and customize models on-the-fly.
pip install factory_boy
# tests/conftest.py
import pytest
from flask_unchained.bundles.sqlalchemy.pytest import *
from flask_unchained.bundles.security.pytest import *
from datetime import datetime, timezone
from app.models import User, Role, UserRole
class UserFactory(ModelFactory):
class Meta:
model = User
email = 'user@example.com'
password = 'password'
is_active = True
confirmed_at = datetime.now(timezone.utc)
class RoleFactory(ModelFactory):
class Meta:
model = Role
name = 'ROLE_USER'
class UserRoleFactory(ModelFactory):
class Meta:
model = UserRole
user = factory.SubFactory(UserFactory)
role = factory.SubFactory(RoleFactory)
class UserWithRoleFactory(UserFactory):
user_role = factory.RelatedFactory(UserRoleFactory, 'user')
@pytest.fixture()
def user(request):
kwargs = getattr(request.node.get_closest_marker('user'), 'kwargs', {})
return UserWithRoleFactory(**kwargs)
@pytest.fixture()
def role(request):
kwargs = getattr(request.node.get_closest_marker('role'), 'kwargs', {})
return RoleFactory(**kwargs)
The ModelFactory
subclasses define the default values, and the user
and role
fixtures at the bottom make it possible to customize the values by marking the test, for example:
@pytest.mark.user(email='foo@bar.com')
def test_something(user):
assert user.email == 'foo@bar.com'
And our tests look like this:
# tests/app/test_security_controller.py
import pytest
from flask_unchained.bundles.security import AnonymousUser, current_user
from flask_unchained import url_for
class TestSecurityController:
def test_login_get(self, client, templates):
r = client.get('security_controller.login')
assert r.status_code == 200
assert templates[0].template.name == 'security/login.html'
@pytest.mark.user(password='password')
def test_login_post(self, client, user, templates):
r = client.post('security_controller.login', data=dict(
email=user.email,
password='password'))
assert r.status_code == 302
assert r.path == url_for('site_controller.index')
assert current_user == user
r = client.follow_redirects(r)
assert r.status_code == 200
assert templates[0].template.name == 'site/index.html'
def test_logout(self, client, user):
client.login_user()
assert current_user == user
r = client.get('security_controller.logout')
assert r.status_code == 302
assert r.path == url_for('site_controller.index')
assert isinstance(current_user._get_current_object(), AnonymousUser)
def test_register_get(self, client, templates):
r = client.get('security_controller.register')
assert r.status_code == 200
assert templates[0].template.name == 'security/register.html'
def test_register_post_errors(self, client, templates):
r = client.post('security_controller.register')
assert r.status_code == 200
assert templates[0].template.name == 'security/register.html'
assert 'Email is required.' in r.html
assert 'Password is required.' in r.html
def test_register_post(self, client, registrations, user_manager):
r = client.post('security_controller.register', data=dict(
email='a@a.com',
password='password',
password_confirm='password'))
assert r.status_code == 302
assert r.path == url_for('site_controller.index')
assert len(registrations) == 1
user = user_manager.get_by(email='a@a.com')
assert registrations[0]['user'] == user
Running them should pass:
pytest --maxfail=1
================================== test session starts ===================================
platform linux -- Python 3.6.6, pytest-3.7.1, py-1.5.4, pluggy-0.7.1
rootdir: /home/user/dev/hello-flask-unchained, inifile:
plugins: flask-0.10.0, Flask-Unchained-0.8.0, Flask-Security-Bundle-0.3.0
collected 11 items
tests/app/test_views.py ..... [ 45%]
tests/security/test_security_controller.py ...... [100%]
=============================== 11 passed in 0.74 seconds ================================
You can learn more about how to use all of the features the security bundle supports in its documentation.
Let’s commit our changes:
git add .
git status
git commit -m 'install and configure security bundle'