Skip to content

Commit

Permalink
MFA using an Authenticator app (#292)
Browse files Browse the repository at this point in the history
* MFA prototype

* add mfa_providers abstract property

* flesh out email provider a bit more

* wip

* flesh out email some more, and add authenticator

* flesh out `AuthenticatorSeed` some more

* add method for fetching pyotp

* add `pyotp` to requirements

* add proper auth methods for pyotp

* start adding tests

* bump minimum Piccolo version

* test `create_new` method

* add qrcode logic from @sinisaos PR

* flesh out auth methods

* lazy load `qrcode`

* fleshing out logic some more in session login endpoint

* change imports

* make error messages consistent

* add TODO

* adding example app for testing

* add `AuthenticatorProvider` to example app

* added `get_registration_html` to provider

* rename `seed_table` to `secret_table`

* change params in `send_code` for authenticator

* Create README.md

* add `issuer_name` to `AuthenticatorProvider`

* fix typo in docstring

* add `get_registration_json`

* add `get_registration_json` to base class

* try embedding QR code image in HTML response

* flesh out `MFARegisterEndpoint` endpoint

* add todo about primary key

* fix bugs in register endpoint

* fix error - was returning html instead of json

* show MFA code input on login page

* Update README.md

* Update tests.yaml

* fix some linter errors

* add `device_name` column to `AuthenticatorSecret`

* make sure each provider has a custom token name

* make each MFA Provider have a unique token name

This means we can potentially put several MFA providers on the login page

* If the user reused a code make sure auth fails (could be a replay attack)

* add auth test for replay attacks

* add `generate_recovery_code`

* store recovery codes, and return recovery codes in endpoints (taken from @sinisaos example)

* make sure recovery codes can be used to login

* ignore mypy warnings for now

* install pyotp in CI

* endpoint test WIP

* update `TestMFARegisterEndpoint`

* remove todo

* encrypt secret in db

* use a proper template for MFA sign up

* add links for where to download the authenticator app, and add JS for copying to clipboard

* also test HTML register endpoint

* add playwright tests

* require a password to enable MFA

* fix test

* create separate template for cancelling MFA

* initial docs

* improve docs for AuthenticatorProvider  params

* add docs for tables

* rename `mfa_register_endpoint` to `mfa_setup`

So the name is more consistent with other endpoints in `piccolo_api`

* improve docs, and rename from register to setup

* remove debugging

* improve template when MFA is disabled

* add re-enabled link on disabled template

* fix linter errors

* use `self._auth_table`

* render cancel template in GET endpoint if user is already enrolled

* remove email for now

* remove email from README

* start moving encryption into its own file

* update code to use encryption provider

* improve the docstring for `mfa_setup` - mention rate limiting

* improve docstrings

* add params to docstrings

* remove `device_name` - not currently used

* change `revoke_all` to `revoke`

The current design assumes a single device per user

* add `XChaCha20Provider`

* make sure pynacl is installed in tests

* make sure pynacl is installed in tests (continued)

* improve coverage

* add `TestRevoke`

* add a test to make sure auth works

* remove unused import

* add tests for recovery codes

* remove breakpoint

* fix bug with prefix

* simplify encoding

* changed login logic for multiple MFA providers

* make `mfa_provider_name` param optional if there's only a single MFA provider

* add `help_text` to `revoked_at`

* add `valid_window` argument to `AuthenticatorProvider`

* tell the user whether we sent them a code

* increase coverage for `AuthenticatorSecret`

* remove TODO in endpoint test

* add tests for generating recovery codes

* fix path to `AuthenticatorProvider` in docstring

* add docs for encryption

* remove imports

* add docstring and type annotations to `get_b64encoded_qr_image`
  • Loading branch information
dantownsend authored Sep 9, 2024
1 parent cd4a8f4 commit 48e2e90
Show file tree
Hide file tree
Showing 58 changed files with 2,318 additions and 9 deletions.
7 changes: 7 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ jobs:
pip install -r requirements/requirements.txt
pip install -r requirements/dev-requirements.txt
pip install -r requirements/test-requirements.txt
pip install -r requirements/extras/authenticator.txt
pip install -r requirements/extras/pynacl.txt
- name: Lint
run: ./scripts/lint.sh

Expand Down Expand Up @@ -59,6 +62,8 @@ jobs:
python -m pip install --upgrade pip
pip install -r requirements/requirements.txt
pip install -r requirements/test-requirements.txt
pip install -r requirements/extras/authenticator.txt
pip install -r requirements/extras/pynacl.txt
- name: Test with pytest, Postgres
run: ./scripts/test-postgres.sh
env:
Expand Down Expand Up @@ -86,6 +91,8 @@ jobs:
python -m pip install --upgrade pip
pip install -r requirements/requirements.txt
pip install -r requirements/test-requirements.txt
pip install -r requirements/extras/authenticator.txt
pip install -r requirements/extras/pynacl.txt
- name: Test with pytest, SQLite
run: ./scripts/test-sqlite.sh
- name: Upload coverage
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ docs/source/_build/
example_projects/token_auth/
.env/
.venv/

# Playwright
videos/
8 changes: 8 additions & 0 deletions docs/source/encryption/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Encryption
==========

.. toctree::
:maxdepth: 1

./introduction
./providers
6 changes: 6 additions & 0 deletions docs/source/encryption/introduction.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Introduction
============

Piccolo API provides some wrappers around popular encryption libraries.

These are current used by :ref:`Multifactor Authentication <MFA>`.
69 changes: 69 additions & 0 deletions docs/source/encryption/providers.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
Providers
=========

.. currentmodule:: piccolo_api.encryption.providers

``EncryptionProvider``
----------------------

.. autoclass:: EncryptionProvider

``FernetProvider``
------------------

.. autoclass:: FernetProvider

``PlainTextProvider``
---------------------

.. autoclass:: PlainTextProvider

``XChaCha20Provider``
---------------------

.. autoclass:: XChaCha20Provider

-------------------------------------------------------------------------------

Dependencies
------------

When first using some of the providers, you will be prompted to install the
underlying encryption library.

For example, with ``XChaCha20Provider``, you need to install ``pynacl`` as
follows:

.. code-block:: bash
pip install piccolo_api[pynacl]
-------------------------------------------------------------------------------

Example usage
-------------

All of the providers work the same (except their parameters may be different).

Here's an example using ``XChaCha20Provider``:

.. code-block:: python
>>> from piccolo_api.encryption.providers import XChaCha20Provider
>>> encryption_key = XChaCha20Provider.get_new_key()
>>> provider = XChaCha20Provider(encryption_key=encryption_key)
>>> encrypted = provider.encrypt("hello world")
>>> print(provider.decrypt(encrypted))
"hello world"
-------------------------------------------------------------------------------

Which provider to use?
----------------------

``XChaCha20Provider`` is the most secure.

You may decide to use ``FernetProvider`` if you already have the Python
``cryptography`` library as a dependency in your project.
2 changes: 2 additions & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ ASGI app, covering authentication, security, and more.

./csp/index
./csrf/index
./encryption/index
./rate_limiting/index

.. toctree::
Expand All @@ -35,6 +36,7 @@ ASGI app, covering authentication, security, and more.
./which_authentication/index
./jwt/index
./session_auth/index
./mfa/index
./token_auth/index
./register/index
./change_password/index
Expand Down
21 changes: 21 additions & 0 deletions docs/source/mfa/endpoints.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Endpoints
=========

You must mount these ASGI endpoints in your app.

.. currentmodule:: piccolo_api.mfa.endpoints

``mfa_setup``
-------------------------

.. autofunction:: mfa_setup

.. image:: images/mfa_register_endpoint.jpg


``session_login``
-----------------

Make sure you pass the ``mfa_providers`` argument to
:func:`session_login <piccolo_api.session_auth.endpoints.session_login>`,
so it knows to look for an MFA token.
13 changes: 13 additions & 0 deletions docs/source/mfa/example.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Full Example
============

Let's look at what an entire app looks like, which uses session auth, along
with MFA (using the Authenticator provider).

-------------------------------------------------------------------------------

Starlette
---------

.. include:: ../../../example_projects/mfa_demo/app.py
:code: python
Binary file added docs/source/mfa/images/mfa_register_endpoint.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions docs/source/mfa/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.. _MFA:

Multi-Factor Authentication
===========================

.. toctree::
:maxdepth: 1

./introduction
./endpoints
./providers
./tables
./example
13 changes: 13 additions & 0 deletions docs/source/mfa/introduction.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Introduction
============

What is Multi-Factor Authentication (MFA)?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

MFA provides additional security to :ref:`SessionAuth`.

As well as needing a username and password to login, the user must provide an
additional piece of information.

One of the most popular ways of doing this is by providing a code generated by
an authenticator app on the user's phone.
23 changes: 23 additions & 0 deletions docs/source/mfa/providers.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
Providers
=========

Most of the MFA code is fairly generic, but ``Providers`` implement the logic
which is specific to its particular authentication mechanism.

For example, ``AuthenticatorProvider`` knows how to authenticate tokens which
come from an authenticator app on a user's phone, and knows how to generate new
secrets which allow users to enable MFA.

.. currentmodule:: piccolo_api.mfa.provider

``MFAProvider``
---------------

.. autoclass:: MFAProvider

.. currentmodule:: piccolo_api.mfa.authenticator.provider

``AuthenticatorProvider``
-------------------------

.. autoclass:: AuthenticatorProvider
35 changes: 35 additions & 0 deletions docs/source/mfa/tables.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
Tables
======

``AuthenticatorSecret``
-----------------------

This is required by :class:`AuthenticatorProvider <piccolo_api.mfa.authenticator.provider.AuthenticatorProvider>`.

To create this table, you can using Piccolo's migrations.

Add ``piccolo_api.mfa.authenticator.piccolo_app`` to ``APP_REGISTRY`` in
``piccolo_conf.py``:

.. code-block:: python
APP_REGISTRY = AppRegistry(
apps=[
"piccolo_api.mfa.authenticator.piccolo_app",
...
]
)
Then run the migrations:

.. code-block:: bash
piccolo migrations forwards mfa_authenticator
Alternatively, if not using Piccolo migrations, you can create the table
manually:

.. code-block:: pycon
>>> from piccolo_api.mfa.authenticator.table import AuthenticatorProvider
>>> AuthenticatorProvider.create_table().run_sync()
Empty file added e2e/__init__.py
Empty file.
62 changes: 62 additions & 0 deletions e2e/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import os
import time
from http.client import HTTPConnection
from subprocess import Popen

import pytest

HOST = "localhost"
PORT = 8000
BASE_URL = f"http://{HOST}:{PORT}"


@pytest.fixture
def browser_context_args():
return {"record_video_dir": "videos/"}


@pytest.fixture
def context(context):
# We don't need a really long timeout.
# The timeout determines how long Playwright waits for a HTML element to
# become available.
# By default it's 30 seconds, which is way too long when testing an app
# locally.
context.set_default_timeout(10000)
yield context


@pytest.fixture
def mfa_app():
"""
Running dev server and Playwright test in parallel.
More info https://til.simonwillison.net/pytest/playwright-pytest
"""
path = os.path.join(
os.path.dirname(os.path.dirname(__file__)),
"example_projects",
"mfa_demo",
)

process = Popen(
["python", "-m", "main", "--reset-db"],
cwd=path,
)
retries = 5
while retries > 0:
conn = HTTPConnection(f"{HOST}:{PORT}")
try:
conn.request("HEAD", "/")
response = conn.getresponse()
if response is not None:
yield process
break
except ConnectionRefusedError:
time.sleep(1)
retries -= 1

if not retries:
raise RuntimeError("Failed to start http server")
else:
process.terminate()
process.wait()
66 changes: 66 additions & 0 deletions e2e/pages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""
By using pages we can make out test more scalable.
https://playwright.dev/docs/pom
"""

from playwright.sync_api import Page

USERNAME = "piccolo"
PASSWORD = "piccolo123"


class LoginPage:
url = "http://localhost:8000/login/"

def __init__(self, page: Page):
self.page = page
self.username_input = page.locator('input[name="username"]')
self.password_input = page.locator('input[name="password"]')
self.button = page.locator("button")

def reset(self):
self.page.goto(self.url)

def login(self, username: str = USERNAME, password: str = PASSWORD):
self.username_input.fill(username)
self.password_input.fill(password)
self.button.click()


class RegisterPage:
url = "http://localhost:8000/register/"

def __init__(self, page: Page):
self.page = page
self.username_input = page.locator("[name=username]")
self.email_input = page.locator("[name=email]")
self.password_input = page.locator("[name=password]")
self.confirm_password_input = page.locator("[name=confirm_password]")
self.button = page.locator("button")

def reset(self):
self.page.goto(self.url)

def login(self, username: str = USERNAME, password: str = PASSWORD):
self.username_input.fill(username)
self.email_input.fill("[email protected]")
self.password_input.fill(password)
self.confirm_password_input.fill(password)
self.button.click()


class MFASetupPage:
url = "http://localhost:8000/private/mfa-setup/"

def __init__(self, page: Page):
self.page = page
self.password_input = page.locator("[name=password]")
self.button = page.locator("button")

def reset(self):
self.page.goto(self.url)

def register(self, password: str = PASSWORD):
self.password_input.fill(password)
self.button.click()
Loading

0 comments on commit 48e2e90

Please sign in to comment.