Authentication

The auth system of dazzler provide components, pages and routes for authentication. Pages marked as require_login will show a login page if not authenticated and additional authorizations can be required on a page basis.

If a user is authenticated, it will be available in bindings via context.user and for regular aiohttp routes in request['user'].

Configuration

Enable and set the authenticator to use, currently only PostgreSQL authenticator is available natively. It’s also possible to customize the authenticator, see Custom Authenticator for details.

[authentication]
enable = true
authenticator = 'dazzler.contrib.postgresql:PostgresAuthenticator'

Attention

Scrypt used for hashing passwords requires OpenSSL 1.1+ installed.

The default backend (how the server knows a request is authenticated) is integrated with the session system and can be changed for a custom implementation.

[authentication]
backend = 'module:AuthBackendImplement'

Warning

The session backend is not suited for Electron applications.

Login & Logout

A user can login either with login page or a with the Login component. To logout, use the Logout component.

Login Page

A default login page is included with the authentication system. Pages that requires_login will be automatically redirected here and upon login, the initial requested page will be redirected to.

../_images/login_page.png
Configuration

The login page can be customized with these configs:

[authentication.login]
# Url to use by default if no next_url is available.
default_redirect = "/"
page_title = "Login" # Title of the login page

# Url for the login page and related routes.
page_url = "/auth"
form_header = "Sign In" # Title of the login form.

Components

The Login and :py:class:~`.dazzler.component.auth.Logout` components can also be added to any existing page.

The authentication routes are under /auth/ and can be used with components: - auth.Login(login_url='/auth/login'). - auth.Logout(logout_url='/auth/logout').

Status

To know if a page binding is authenticated, the user attribute of the context can be checked upon. This is particularly useful to conditionally display a login prompt or a logout.

Here’s an example that either display a logout button if authenticated or login and register links otherwise.

from dazzler.system import Page, BindingContext
from dazzler.components import core, auth

page = Page(
    __name__,
    core.Panel(
        title='User info',
        identity='user-info',
    )
)

@page.bind('title@user-info', once=True)
async def on_user_info(ctx: BindingContext):
    if ctx.user:
        await ctx.set_aspect(
            'user-info',
            content=Container([
                auth.Logout(
                    ctx.auth.logout_url,
                    style={'width': '100%'}
                )
            ])
        )
    else:
        await ctx.set_aspect(
            'user-info',
            content=Container([
                Box(Link(
                    children='Login',
                    page_name=ctx.auth.login_page.name
                )),
                Box(Link(
                    children='Register',
                    page_name=ctx.auth.register_page.name,
                )),
            ])
        )

Tip

Note the use of the context auth attribute to get the name of the page to link to them and the ctx.auth.logout_url for the Logout button. This is useful in case the default names and urls have been changed.

Register

Add a register page to create new users.

Page settings

[authentication.register]
enable = true

# Require the user to provide an email on the register page.
require_email = true
page_name = "register" # Name of the register page
page_url = "/auth/register"
custom_fields = []

Custom fields

Add custom fields to the register page for the user to fill. These fields can be accessed later in the User metadata attribute as a dict containing the values entered.

The custom_fields config is a list of list with the following structure:

[key, label, optional type]

Example:

[authentication.register]
enable = true
require_email = false
custom_fields = [
    ['firstname', 'First name', 'text'],
    ['lastname', 'Last name', 'text'],
]

Result in this register form page:

../_images/register.png

Authorizations

Each page can have it’s own set of authorizations. The default behavior of the authenticator is to compare the user role names to the page list of authorized roles. If you have a page that requires the admin user role, only an admin can access the page, otherwise it will result in a 403 response.

Example

page = Page(
    __name__,
    core.Container(),
    authorizations=['admin']
)

User administration

The user and roles can be managed with a subclass of UserAdminPage. The user list is filterable by username, active status and user roles and allows to delete, toggle active status and add or remove user roles.

The roles tab is used to create new role to use with the application.

../_images/user_admin.png

Example config with PostgreSQL.

[authentication.admin]
enable = true
page_ref = 'dazzler.contrib.postgresql:PostgresUserAdminPage'

Note

The user_admin:UserAdminPage can be implemented for a custom backend.

Attention

The admin module is disable by default.

Custom Authenticator

It is possible to implement a custom Authenticator for other database.

Here’s an example of implementing the Authenticator using hashlib scrypt and sqlite.

Database

Create a sqlite database with the following schema:

CREATE TABLE UserAccount (
    username VARCHAR (64) PRIMARY KEY,
    email    TEXT,
    password BLOB NOT NULL,
    salt     BLOB NOT NULL
);

Note

Run $ sqlite3 db.sqlite to create the database and paste the table statement into the interactive client.

Create a Python db object for sqlite and an asyncio Lock to access the database from multiple endpoints.

For convenience, we can automatically map the fetch results column names to values with the cursor description.

db.py
import asyncio
import sqlite3


class DB:
    database_file = 'db.sqlite'

    def __init__(self):
        self.lock = asyncio.Lock()
        self.conn = sqlite3.connect(self.database_file)

    async def execute(self, statement, *parameters):
        async with self.lock:
            return self.conn.execute(statement, parameters)

    async def fetch(self, statement, *parameters):
        async with self.lock:
            cursor = self.conn.execute(statement, parameters)
            results = cursor.fetchall()
            cursor.close()

            # Map the name of the columns with the values.
            return [
                {d[0]: x[i] for i, d in enumerate(cursor.description)}
                for x in results
            ]


db = DB()

Warning

While the db object will work for fast development, it is not recommended for a production environment. You should use a proper database.

Configurations

Enable the auth module and provide a path to the Authenticator in the configs:

dazzler.toml
...
[authentication]
enable = True
# Path to an instance or subclass of `dazzler.system.auth.Authenticator`
authenticator = "auth:SqliteAuthenticator"
...

Note

Generate a config file with dazzler dump-configs dazzler.toml

Authenticator

Implement the authenticator methods, since they both use the same data, we can extract the user fetch. The get_user() method is only called when the user is saved by the auth backend.

auth.py
import hashlib
import secrets
from dazzler.system.auth import Authenticator, User

from .db import db


class SqliteAuthenticator(Authenticator):

    async def _get_user(self, username):
        users = await db.fetch(
            'SELECT * FROM UserAccount WHERE username=$1', username
        )
        if len(users):
            return users[0]

    async def authenticate(self, username: str, password: str):
        userdata = await self._get_user(username)

        if not userdata:
            return  # User doesn't exist, return None to fail the process

        # Execute in a thread to prevent blocking the server since it takes
        # some time to hash password for a secure process.
        encrypted = await self.app.executor.execute(
            hashlib.scrypt,
            password.encode(),
            n=128, r=64, p=1,
            salt=userdata['salt']
        )
        valid = secrets.compare_digest(
            encrypted, userdata['password']
        )

        if valid:
            # Return a User instance that will be saved by the auth backend.
            return User(username)

    async def get_user(self, username: str) -> User:
        userdata = await self._get_user(username)
        return User(userdata['username'])

Registering users

Implement the authenticator method register_user() to use with the default register page.

For each individual user, we create a unique salt that will be used to hash the password, creating unique hashes for same passphrases.

auth.py
class SqliteAuthenticator(Authenticator):

[...]

async def register_user(self,
    username: str,
    password: str,
    email: str = None,
    fields: dict = None,
):
    data = await request.post()

    salt = secrets.randbits(128).to_bytes(16, sys.byteorder)

    encrypted = await self.app.executor.execute(
        hashlib.scrypt,
        data["password"].encode(),
        n=128, r=64, p=1,
        salt=salt
    )

    try:
        cur = await db.execute(
            "INSERT INTO UserAccount VALUES ($1,$2,$3,$4)",
            data['username'],
            data['email'],
            encrypted,
            salt
        )
        cur.close()
        db.conn.commit()
    except:
        return 'Invalid username'