Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MFA using an Authenticator app #292

Merged
merged 104 commits into from
Sep 9, 2024
Merged
Show file tree
Hide file tree
Changes from 70 commits
Commits
Show all changes
104 commits
Select commit Hold shift + click to select a range
8162527
MFA prototype
dantownsend May 21, 2024
58e382a
add mfa_providers abstract property
dantownsend May 21, 2024
7e7c2ae
Merge branch 'master' into mfa-prototype
dantownsend May 21, 2024
d1ab032
flesh out email provider a bit more
dantownsend May 22, 2024
491e216
wip
dantownsend May 30, 2024
5988198
flesh out email some more, and add authenticator
dantownsend Aug 7, 2024
5bf4a2d
flesh out `AuthenticatorSeed` some more
dantownsend Aug 7, 2024
ed2d26c
add method for fetching pyotp
dantownsend Aug 7, 2024
034d181
add `pyotp` to requirements
dantownsend Aug 7, 2024
08144ab
add proper auth methods for pyotp
dantownsend Aug 7, 2024
ecc9dce
start adding tests
dantownsend Aug 7, 2024
17a8854
bump minimum Piccolo version
dantownsend Aug 8, 2024
6d1e7f2
test `create_new` method
dantownsend Aug 8, 2024
9bcaf1f
add qrcode logic from @sinisaos PR
dantownsend Aug 8, 2024
f67d543
flesh out auth methods
dantownsend Aug 8, 2024
f4c8bf0
lazy load `qrcode`
dantownsend Aug 8, 2024
71f9aee
fleshing out logic some more in session login endpoint
dantownsend Aug 8, 2024
33dfbf9
change imports
dantownsend Aug 8, 2024
0f4f1fc
make error messages consistent
dantownsend Aug 8, 2024
c3f4ca3
add TODO
dantownsend Aug 8, 2024
5926327
Merge branch 'master' into mfa-prototype
dantownsend Aug 8, 2024
785de93
adding example app for testing
dantownsend Aug 8, 2024
38bd3bf
add `AuthenticatorProvider` to example app
dantownsend Aug 8, 2024
7424132
added `get_registration_html` to provider
dantownsend Aug 8, 2024
a2d7719
rename `seed_table` to `secret_table`
dantownsend Aug 9, 2024
1084b7f
change params in `send_code` for authenticator
dantownsend Aug 9, 2024
d59540a
Create README.md
dantownsend Aug 9, 2024
15b9c8c
add `issuer_name` to `AuthenticatorProvider`
dantownsend Aug 9, 2024
62e1bbf
fix typo in docstring
dantownsend Aug 9, 2024
77b67a1
add `get_registration_json`
dantownsend Aug 9, 2024
9162b9a
add `get_registration_json` to base class
dantownsend Aug 9, 2024
e45be09
try embedding QR code image in HTML response
dantownsend Aug 9, 2024
c1b1c05
flesh out `MFARegisterEndpoint` endpoint
dantownsend Aug 9, 2024
cdc5d47
add todo about primary key
dantownsend Aug 9, 2024
d7bd02e
fix bugs in register endpoint
dantownsend Aug 9, 2024
cabf6cd
fix error - was returning html instead of json
dantownsend Aug 9, 2024
ec69778
show MFA code input on login page
dantownsend Aug 9, 2024
b4dd816
Update README.md
dantownsend Aug 9, 2024
923e3ef
Update tests.yaml
dantownsend Aug 9, 2024
4df3da9
fix some linter errors
dantownsend Aug 9, 2024
6a5e948
add `device_name` column to `AuthenticatorSecret`
dantownsend Aug 9, 2024
e33a0e4
make sure each provider has a custom token name
dantownsend Aug 9, 2024
3345e34
make each MFA Provider have a unique token name
dantownsend Aug 9, 2024
67dfb6e
If the user reused a code make sure auth fails (could be a replay att…
dantownsend Aug 9, 2024
ccb3648
add auth test for replay attacks
dantownsend Aug 9, 2024
e314118
add `generate_recovery_code`
dantownsend Aug 13, 2024
24be31a
store recovery codes, and return recovery codes in endpoints (taken f…
dantownsend Aug 13, 2024
63e70fd
make sure recovery codes can be used to login
dantownsend Aug 13, 2024
a53f51a
ignore mypy warnings for now
dantownsend Aug 13, 2024
2d714e1
install pyotp in CI
dantownsend Aug 13, 2024
95e6754
endpoint test WIP
dantownsend Aug 13, 2024
64ef81b
update `TestMFARegisterEndpoint`
dantownsend Aug 13, 2024
c3a3029
remove todo
dantownsend Aug 13, 2024
2efd8c9
encrypt secret in db
dantownsend Aug 14, 2024
1e6dc9b
use a proper template for MFA sign up
dantownsend Aug 14, 2024
47744dd
add links for where to download the authenticator app, and add JS for…
dantownsend Aug 14, 2024
ff806e9
also test HTML register endpoint
dantownsend Aug 14, 2024
605d09b
add playwright tests
dantownsend Aug 15, 2024
1f05a1f
require a password to enable MFA
dantownsend Aug 15, 2024
f525ef3
fix test
dantownsend Aug 15, 2024
033c986
create separate template for cancelling MFA
dantownsend Aug 15, 2024
1fd4de9
initial docs
dantownsend Aug 16, 2024
78fd06f
improve docs for AuthenticatorProvider params
dantownsend Aug 16, 2024
d79087b
add docs for tables
dantownsend Aug 16, 2024
2902e45
rename `mfa_register_endpoint` to `mfa_setup`
dantownsend Aug 16, 2024
28a3ddb
improve docs, and rename from register to setup
dantownsend Aug 16, 2024
b20662a
remove debugging
dantownsend Aug 16, 2024
3833250
improve template when MFA is disabled
dantownsend Aug 16, 2024
01e785a
add re-enabled link on disabled template
dantownsend Aug 16, 2024
8e94d28
fix linter errors
dantownsend Aug 16, 2024
f997bbd
use `self._auth_table`
dantownsend Aug 18, 2024
47e6508
render cancel template in GET endpoint if user is already enrolled
dantownsend Aug 18, 2024
d288cd4
remove email for now
dantownsend Aug 30, 2024
8c6e95d
remove email from README
dantownsend Aug 30, 2024
d4007a1
start moving encryption into its own file
dantownsend Aug 30, 2024
b7bbf86
update code to use encryption provider
dantownsend Aug 30, 2024
ea1dd57
improve the docstring for `mfa_setup` - mention rate limiting
dantownsend Aug 30, 2024
a910d17
improve docstrings
dantownsend Aug 30, 2024
3ff1778
add params to docstrings
dantownsend Aug 30, 2024
96ca0ae
remove `device_name` - not currently used
dantownsend Sep 5, 2024
210bfa3
change `revoke_all` to `revoke`
dantownsend Sep 5, 2024
1a21746
add `XChaCha20Provider`
dantownsend Sep 5, 2024
8226e6a
make sure pynacl is installed in tests
dantownsend Sep 5, 2024
eb5bfa5
make sure pynacl is installed in tests (continued)
dantownsend Sep 5, 2024
5379bb0
improve coverage
dantownsend Sep 5, 2024
3e673dd
add `TestRevoke`
dantownsend Sep 5, 2024
780d7ad
add a test to make sure auth works
dantownsend Sep 5, 2024
27f5658
remove unused import
dantownsend Sep 5, 2024
bd5829e
add tests for recovery codes
dantownsend Sep 5, 2024
81b09e4
remove breakpoint
dantownsend Sep 6, 2024
c8d679c
fix bug with prefix
dantownsend Sep 6, 2024
9bc3939
simplify encoding
dantownsend Sep 7, 2024
5bcb5cd
changed login logic for multiple MFA providers
dantownsend Sep 8, 2024
78de9a9
make `mfa_provider_name` param optional if there's only a single MFA …
dantownsend Sep 8, 2024
4e4a90f
add `help_text` to `revoked_at`
dantownsend Sep 8, 2024
01b7b36
add `valid_window` argument to `AuthenticatorProvider`
dantownsend Sep 8, 2024
fa727aa
tell the user whether we sent them a code
dantownsend Sep 8, 2024
2fa9962
increase coverage for `AuthenticatorSecret`
dantownsend Sep 8, 2024
fad9ef2
remove TODO in endpoint test
dantownsend Sep 8, 2024
97c8572
add tests for generating recovery codes
dantownsend Sep 8, 2024
4f38884
fix path to `AuthenticatorProvider` in docstring
dantownsend Sep 8, 2024
98953be
add docs for encryption
dantownsend Sep 8, 2024
d4f4b5b
remove imports
dantownsend Sep 9, 2024
5974031
add docstring and type annotations to `get_b64encoded_qr_image`
dantownsend Sep 9, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ 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

- name: Lint
run: ./scripts/lint.sh

Expand Down Expand Up @@ -59,6 +61,7 @@ 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
- name: Test with pytest, Postgres
run: ./scripts/test-postgres.sh
env:
Expand Down Expand Up @@ -86,6 +89,7 @@ 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
- 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/
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,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()
29 changes: 29 additions & 0 deletions e2e/test_mfa.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from playwright.async_api import Page

from .pages import LoginPage, MFASetupPage, RegisterPage


def test_mfa_signup(page: Page, mfa_app):
"""
Make sure we create an account and sign up for MFA.
"""
register_page = RegisterPage(page=page)
register_page.reset()
register_page.login()

login_page = LoginPage(page=page)
login_page.reset()
login_page.login()

mfa_setup_page = MFASetupPage(page=page)
mfa_setup_page.reset()

# Test an incorrect password
# TODO - assert response code is correct
mfa_setup_page.register(password="fake_password_123")

# Test the correct password
# TODO - make sure it navigated to the right page
mfa_setup_page.register()

mfa_setup_page.reset()
Empty file added example_projects/__init__.py
Empty file.
28 changes: 28 additions & 0 deletions example_projects/mfa_demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# MFA demo

This project demos how to use the MFA with the `session_login` endpoint.

## Setup

### Install requirements

```bash
pip install -r requirements.txt
```

### Create database

Make sure a Postgres database exists, called 'piccolo_api_mfa'. See
`piccolo_conf.py` for the full details.

### Run migrations

```
piccolo migrations forwards all
```

## Run the app

```bash
python main.py
```
Loading
Loading