-
Notifications
You must be signed in to change notification settings - Fork 27
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
MFA using an Authenticator app (#292)
* 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
1 parent
cd4a8f4
commit 48e2e90
Showing
58 changed files
with
2,318 additions
and
9 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,3 +14,6 @@ docs/source/_build/ | |
example_projects/token_auth/ | ||
.env/ | ||
.venv/ | ||
|
||
# Playwright | ||
videos/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
Encryption | ||
========== | ||
|
||
.. toctree:: | ||
:maxdepth: 1 | ||
|
||
./introduction | ||
./providers |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>`. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
Oops, something went wrong.