Quickstart#

Requirements#

  • Python 3.7+

  • Sanic 22.6+

  • Sanic-Ext 22.6+ provides CORS

  • cryptography 37+ for encrypting stuff

  • Any async Mail plugin, providing mail() and Mailer(), similar to async-sender

Note on Requirements#

Older versions of Sanic may work, but are not supported. Stay current.

The examples mainly utilize Tortoise-ORM, a couple also show support of uMongo and beanie, but none are required, or even installed by default (except if you install from poetry with the -D flag). Any async ORM can be utilized.

Optional Requirements#

If you would like to generate TOTP QR codes, you will also need to install segno::

pip install segno

If you would like your PBKDF2 hashing to be quick, you should really install fastpbkdf2::

pip install fastpbkdf2

Installation#

Note

sanic-beskar does not support distutils or setuptools because the original author, as well as this maintainer, have very strong feelings about python packaging and the role pip plays in taking us into a bright new future of standardized and usable python packaging

Install from pypi#

This will install the latest release of sanic-beskar from pypi via pip:

$ pip install sanic-beskar

Install latest version from github#

If you would like a version other than the latest published on pypi, you may do so by cloning the git repository:

$ git clone https://github.com/pahrohfit/sanic-beskar.git

Next, checkout the branch or tag that you wish to use:

$ cd sanic-beskar
$ git checkout master

Finally, use poetry to install from the local directory:

$ poetry install

Example#

Several simple examples of sanic_beskar doing various aspects of the software can be found in the example/ directory:

Sanic-Beskar Examples#

File Name

Description

examples/basic.py

Simple example of most basic usage (see below)

examples/basic_with_tortoise_mixin.py

Same simple example, using the provided umongo_user_mixins

examples/basic_with_umongo_mixin.py

Same simple example, using the provided tortoise_user_mixins

examples/basic_with_beanie_mixin.py

Same simple example, using the provided beanie_user_mixins

examples/blacklist.py

Simple example utilizing the blacklist functionality

examples/custom_claims.py

Simple example utilizing custom claims in the token

examples/refresh.py

Simple example showing token expirataion and refresh

examples/register.py

Simple example showing email based registration validation

examples/basic_with_rbac.py

Simple example showing RBAC based usage and rbac_populate_hook

The most basic utilization of the sanic_beskar decorators is included:

import secrets
import string

import sanic_beskar
from async_sender import Mail  # type: ignore
from sanic import Sanic, json
from sanic_beskar import Beskar
from tortoise import fields
from tortoise.contrib.sanic import register_tortoise
from tortoise.exceptions import DoesNotExist
from tortoise.models import Model

_guard = Beskar()
_mail = Mail()


# A generic user model that might be used by an app powered by sanic-beskar
class User(Model):
    """
    Provides a basic user model for use in the tests
    """

    class Meta:
        table = "User"

    id = fields.IntField(pk=True)
    username = fields.CharField(unique=True, max_length=255)
    password = fields.CharField(max_length=255)
    email = fields.CharField(max_length=255, unique=True)
    roles = fields.CharField(max_length=255, default="")
    is_active = fields.BooleanField(default=True)

    def __str__(self):
        """repr"""
        return f"User {self.id}: {self.username}"

    @property
    def rolenames(self):
        """
        *Required Attribute or Property*

        sanic-beskar requires that the user class has a :py:meth:``rolenames``
        instance attribute or property that provides a list of strings that
        describe the roles attached to the user instance.

        This can be a separate table (probably sane), so long as this attribute
        or property properly returns the associated values for the user as a
        list of strings.
        """
        try:
            return self.roles.split(",")
        except Exception:
            return []

    @classmethod
    async def lookup(cls, username=None, email=None):
        """
        *Required Method*

        sanic-beskaruires that the user class implements a :py:meth:``lookup()``
        class method that takes a single ``username`` or ``email`` argument and
        returns a user instance if there is one that matches or ``None`` if
        there is not.
        """
        try:
            if username:
                return await cls.filter(username=username).get()
            elif email:
                return await cls.filter(email=email).get()
            else:
                return None
        except DoesNotExist:
            return None

    @classmethod
    async def identify(cls, id):
        """
        *Required Attribute or Property*

        sanic-beskar requires that the user class implements an :py:meth:``identify()``
        class method that takes a single ``id`` argument and returns user instance if
        there is one that matches or ``None`` if there is not.
        """
        try:
            return await cls.filter(id=id).get()
        except DoesNotExist:
            return None

    @property
    def identity(self):
        """
        *Required Attribute or Property*

        sanic-beskar requires that the user class has an :py:meth:``identity``
        instance attribute or property that provides the unique id of the user
        instance
        """
        return self.id


def create_app() -> Sanic:
    """
    Initializes the sanic app for the test suite. Also prepares a set of routes
    to use in testing with varying levels of protections
    """
    sanic_app = Sanic("sanic-testing")
    # In order to process more requests after initializing the app,
    # we have to set degug to false so that it will not check to see if there
    # has already been a request before a setup function
    sanic_app.config.FALLBACK_ERROR_FORMAT = "json"

    # sanic-beskar config
    sanic_app.config.SECRET_KEY = "".join(secrets.choice(string.ascii_letters) for i in range(15))
    sanic_app.config["TOKEN_ACCESS_LIFESPAN"] = {"hours": 24}
    sanic_app.config["TOKEN_REFRESH_LIFESPAN"] = {"days": 30}

    _guard.init_app(sanic_app, User)
    sanic_app.ctx.mail = _mail

    register_tortoise(
        sanic_app,
        db_url="sqlite://:memory:",
        modules={"models": ["__main__"]},
        generate_schemas=True,
    )

    # Add users for the example
    @sanic_app.listener("before_server_start")
    async def populate_db(*args):
        """Create a bunch of test users for examples"""
        await User.create(
            username="the_dude",
            email="the_dude@beskar.test.io",
            password=_guard.hash_password("abides"),
        )

        await User.create(
            username="Walter",
            email="walter@beskar.test.io",
            password=_guard.hash_password("calmerthanyouare"),
            roles="admin",
        )

        await User.create(
            username="Donnie",
            email="donnie@beskar.test.io",
            password=_guard.hash_password("iamthewalrus"),
            roles="operator",
        )

        await User.create(
            username="Maude",
            password=_guard.hash_password("andthorough"),
            email="maude@beskar.test.io",
            roles="operator,admin",
        )

    # Set up some routes for the example
    @sanic_app.route("/login", methods=["POST"])
    async def login(request):
        """
        Logs a user in by parsing a POST request containing user credentials and
        issuing a token.
        .. example::
           $ curl localhost:8000/login -X POST \
             -d '{"username":"Walter","password":"calmerthanyouare"}'
        """
        req = request.json
        username = req.get("username", None)
        password = req.get("password", None)
        user = await _guard.authenticate(username, password)
        ret = {"access_token": await _guard.encode_token(user)}
        return json(ret, status=200)

    @sanic_app.route("/protected")
    @sanic_beskar.auth_required
    async def protected(*args):
        """
        A protected endpoint. The auth_required decorator will require a header
        containing a valid token
        .. example::
           $ curl localhost:8000/protected -X GET \
             -H "Authorization: Bearer <your_token>"
        """
        user = await sanic_beskar.current_user()
        return json({"message": f"protected endpoint (allowed user {user.username})"})

    @sanic_app.route("/protected_admin_required")
    @sanic_beskar.roles_required(["admin"])
    async def protected_admin_required(*args):
        """
        A protected endpoint that requires a role. The roles_required decorator
        will require that the supplied token includes the required roles
        .. example::
           $ curl localhost:8000/protected_admin_required -X GET \
              -H "Authorization: Bearer <your_token>"
        """
        user = await sanic_beskar.current_user()
        return json(
            {"message": f"protected_admin_required endpoint (allowed user {user.username})"}
        )

    @sanic_app.route("/protected_operator_accepted")
    @sanic_beskar.roles_accepted(["operator", "admin"])
    async def protected_operator_accepted(*args):
        """
        A protected endpoint that accepts any of the listed roles. The
        roles_accepted decorator will require that the supplied token includes at
        least one of the accepted roles
        .. example::
           $ curl localhost/protected_operator_accepted -X GET \
             -H "Authorization: Bearer <your_token>"
        """
        user = await sanic_beskar.current_user()
        return json(
            {"message": f"protected_operator_accepted endpoint (allowed usr {user.username}"}
        )

    return sanic_app


app = create_app()

# Run the example
if __name__ == "__main__":
    """main load point"""
    app.run(host="127.0.0.1", port=8000, workers=1, debug=True)