From 8162527ea42a40d6dbffb7b00708d0d72d1665dd Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Tue, 21 May 2024 22:05:03 +0100 Subject: [PATCH 001/102] MFA prototype --- piccolo_api/mfa/__init__.py | 0 piccolo_api/mfa/core.py | 2 ++ piccolo_api/mfa/email/__init__.py | 0 piccolo_api/mfa/email/piccolo_app.py | 24 +++++++++++++++++++ .../mfa/email/piccolo_migrations/__init__.py | 0 piccolo_api/mfa/email/tables.py | 9 +++++++ piccolo_api/session_auth/endpoints.py | 5 ++++ 7 files changed, 40 insertions(+) create mode 100644 piccolo_api/mfa/__init__.py create mode 100644 piccolo_api/mfa/core.py create mode 100644 piccolo_api/mfa/email/__init__.py create mode 100644 piccolo_api/mfa/email/piccolo_app.py create mode 100644 piccolo_api/mfa/email/piccolo_migrations/__init__.py create mode 100644 piccolo_api/mfa/email/tables.py diff --git a/piccolo_api/mfa/__init__.py b/piccolo_api/mfa/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/piccolo_api/mfa/core.py b/piccolo_api/mfa/core.py new file mode 100644 index 00000000..c8d769e5 --- /dev/null +++ b/piccolo_api/mfa/core.py @@ -0,0 +1,2 @@ +class MFAProvider: + pass diff --git a/piccolo_api/mfa/email/__init__.py b/piccolo_api/mfa/email/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/piccolo_api/mfa/email/piccolo_app.py b/piccolo_api/mfa/email/piccolo_app.py new file mode 100644 index 00000000..01ffd90a --- /dev/null +++ b/piccolo_api/mfa/email/piccolo_app.py @@ -0,0 +1,24 @@ +""" +Import all of the Tables subclasses in your app here, and register them with +the APP_CONFIG. +""" + +import os + +from piccolo.conf.apps import AppConfig, table_finder + + +CURRENT_DIRECTORY = os.path.dirname(os.path.abspath(__file__)) + + +APP_CONFIG = AppConfig( + app_name="mfa_email", + migrations_folder_path=os.path.join( + CURRENT_DIRECTORY, "piccolo_migrations" + ), + table_classes=table_finder( + modules=["mfa_email.tables"], exclude_imported=True + ), + migration_dependencies=[], + commands=[], +) diff --git a/piccolo_api/mfa/email/piccolo_migrations/__init__.py b/piccolo_api/mfa/email/piccolo_migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/piccolo_api/mfa/email/tables.py b/piccolo_api/mfa/email/tables.py new file mode 100644 index 00000000..dade4231 --- /dev/null +++ b/piccolo_api/mfa/email/tables.py @@ -0,0 +1,9 @@ +from piccolo.columns import Email, Timestamptz, Varchar +from piccolo.table import Table + + +class EmailCode(Table): + email = Email() + code = Varchar() + created_at = Timestamptz() + used_at = Timestamptz(null=True, default=None) diff --git a/piccolo_api/session_auth/endpoints.py b/piccolo_api/session_auth/endpoints.py index bde21ffd..6023ae4a 100644 --- a/piccolo_api/session_auth/endpoints.py +++ b/piccolo_api/session_auth/endpoints.py @@ -19,6 +19,7 @@ ) from starlette.status import HTTP_303_SEE_OTHER +from piccolo_api.mfa.core import MFAProvider from piccolo_api.session_auth.tables import SessionsBase from piccolo_api.shared.auth.hooks import LoginHooks from piccolo_api.shared.auth.styles import Styles @@ -333,6 +334,7 @@ def session_login( hooks: t.Optional[LoginHooks] = None, captcha: t.Optional[Captcha] = None, styles: t.Optional[Styles] = None, + mfa_providers: t.Optional[t.Sequence[MFAProvider]] = None, ) -> t.Type[SessionLoginEndpoint]: """ An endpoint for creating a user session. @@ -372,6 +374,9 @@ def session_login( See :class:`Captcha `. :param styles: Modify the appearance of the HTML template using CSS. + :param mfa_providers: + Add additional security to the login process usin Multi-Factor + Authentication. """ # noqa: E501 template_path = ( From 58e382a6a06d870f5ee993707fa5d5a577547874 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Tue, 21 May 2024 22:10:20 +0100 Subject: [PATCH 002/102] add mfa_providers abstract property --- piccolo_api/session_auth/endpoints.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/piccolo_api/session_auth/endpoints.py b/piccolo_api/session_auth/endpoints.py index 6023ae4a..aa71e7e5 100644 --- a/piccolo_api/session_auth/endpoints.py +++ b/piccolo_api/session_auth/endpoints.py @@ -3,7 +3,7 @@ import os import typing as t import warnings -from abc import ABCMeta, abstractproperty +from abc import ABCMeta, abstractmethod, abstractproperty from datetime import datetime, timedelta from json import JSONDecodeError @@ -153,6 +153,11 @@ def _captcha(self) -> t.Optional[Captcha]: def _styles(self) -> t.Optional[Styles]: raise NotImplementedError + @property + @abstractmethod + def _mfa_providers(self) -> t.Optional[t.Sequence[MFAProvider]]: + raise NotImplementedError + def _render_template( self, request: Request, From d1ab0324b81cdbd14f5304ba299f5dd08d28f599 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Wed, 22 May 2024 22:22:54 +0100 Subject: [PATCH 003/102] flesh out email provider a bit more --- piccolo_api/mfa/email/provider.py | 8 ++++++++ piccolo_api/mfa/email/tables.py | 11 ++++++++++- piccolo_api/mfa/{core.py => provider.py} | 0 piccolo_api/session_auth/endpoints.py | 6 ++++++ 4 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 piccolo_api/mfa/email/provider.py rename piccolo_api/mfa/{core.py => provider.py} (100%) diff --git a/piccolo_api/mfa/email/provider.py b/piccolo_api/mfa/email/provider.py new file mode 100644 index 00000000..450fa6cc --- /dev/null +++ b/piccolo_api/mfa/email/provider.py @@ -0,0 +1,8 @@ +from piccolo_api.mfa.provider import MFAProvider + +from .tables import EmailCode + + +class EmailProvider(MFAProvider): + async def authenticate(self, email: str): + return await EmailCode.create_new(email=email) diff --git a/piccolo_api/mfa/email/tables.py b/piccolo_api/mfa/email/tables.py index dade4231..0a95f420 100644 --- a/piccolo_api/mfa/email/tables.py +++ b/piccolo_api/mfa/email/tables.py @@ -1,9 +1,18 @@ +from __future__ import annotations + from piccolo.columns import Email, Timestamptz, Varchar from piccolo.table import Table class EmailCode(Table): email = Email() - code = Varchar() + code = Varchar() # TODO - look how best to generate the codes created_at = Timestamptz() used_at = Timestamptz(null=True, default=None) + + @classmethod + async def create_new(cls, email: str) -> EmailCode: + # TODO - generate proper code + instance = cls({cls.email: email, cls.code: "ABC123"}) + await instance.save() + return instance diff --git a/piccolo_api/mfa/core.py b/piccolo_api/mfa/provider.py similarity index 100% rename from piccolo_api/mfa/core.py rename to piccolo_api/mfa/provider.py diff --git a/piccolo_api/session_auth/endpoints.py b/piccolo_api/session_auth/endpoints.py index bd20b91d..768cfabb 100644 --- a/piccolo_api/session_auth/endpoints.py +++ b/piccolo_api/session_auth/endpoints.py @@ -269,6 +269,11 @@ async def post(self, request: Request) -> Response: username=username, password=password ) + # Apply MFA + if self._mfa_providers: + # TODO - call authentication method on providers + pass + if user_id: # Run login_success hooks if self._hooks and self._hooks.login_success: @@ -422,6 +427,7 @@ class _SessionLoginEndpoint(SessionLoginEndpoint): _hooks = hooks _captcha = captcha _styles = styles or Styles() + _mfa_providers = mfa_providers return _SessionLoginEndpoint From 491e216d79dfe75b3e49f75ae3078cafb562ce49 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 30 May 2024 11:46:44 +0100 Subject: [PATCH 004/102] wip --- piccolo_api/session_auth/endpoints.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/piccolo_api/session_auth/endpoints.py b/piccolo_api/session_auth/endpoints.py index 768cfabb..5bd050b8 100644 --- a/piccolo_api/session_auth/endpoints.py +++ b/piccolo_api/session_auth/endpoints.py @@ -20,6 +20,7 @@ from starlette.status import HTTP_303_SEE_OTHER from piccolo_api.mfa.core import MFAProvider +from piccolo_api.mfa.email.provider import EmailProvider from piccolo_api.session_auth.tables import SessionsBase from piccolo_api.shared.auth.hooks import LoginHooks from piccolo_api.shared.auth.styles import Styles @@ -269,12 +270,20 @@ async def post(self, request: Request) -> Response: username=username, password=password ) - # Apply MFA - if self._mfa_providers: - # TODO - call authentication method on providers - pass - if user_id: + # Apply MFA + if self._mfa_providers: + for mfa_provider in self._mfa_providers: + if isinstance(mfa_provider, EmailProvider): + email = ( + await self._auth_table.select( + self._auth_table.email + ) + .where(self._auth_table.id == user_id) + .first() + )["email"] + await EmailProvider.authenticate(email=email) + # Run login_success hooks if self._hooks and self._hooks.login_success: hooks_response = await self._hooks.run_login_success( From 5988198b87a4d3e6ced97b4dbaae0b86d616ffb2 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Wed, 7 Aug 2024 17:58:25 +0100 Subject: [PATCH 005/102] flesh out email some more, and add authenticator --- piccolo_api/mfa/authenticator/__init__.py | 0 piccolo_api/mfa/authenticator/piccolo_app.py | 23 +++++++++++++++++++ .../piccolo_migrations/__init__.py | 0 piccolo_api/mfa/authenticator/provider.py | 5 ++++ piccolo_api/mfa/authenticator/tables.py | 9 ++++++++ piccolo_api/mfa/email/piccolo_app.py | 3 +-- piccolo_api/mfa/email/provider.py | 10 ++++++-- piccolo_api/mfa/email/tables.py | 15 ++++++++++++ piccolo_api/session_auth/endpoints.py | 4 ++-- 9 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 piccolo_api/mfa/authenticator/__init__.py create mode 100644 piccolo_api/mfa/authenticator/piccolo_app.py create mode 100644 piccolo_api/mfa/authenticator/piccolo_migrations/__init__.py create mode 100644 piccolo_api/mfa/authenticator/provider.py create mode 100644 piccolo_api/mfa/authenticator/tables.py diff --git a/piccolo_api/mfa/authenticator/__init__.py b/piccolo_api/mfa/authenticator/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/piccolo_api/mfa/authenticator/piccolo_app.py b/piccolo_api/mfa/authenticator/piccolo_app.py new file mode 100644 index 00000000..285e0993 --- /dev/null +++ b/piccolo_api/mfa/authenticator/piccolo_app.py @@ -0,0 +1,23 @@ +""" +Import all of the Tables subclasses in your app here, and register them with +the APP_CONFIG. +""" + +import os + +from piccolo.conf.apps import AppConfig, table_finder + +CURRENT_DIRECTORY = os.path.dirname(os.path.abspath(__file__)) + + +APP_CONFIG = AppConfig( + app_name="mfa_authenticator", + migrations_folder_path=os.path.join( + CURRENT_DIRECTORY, "piccolo_migrations" + ), + table_classes=table_finder( + modules=["authenticator.tables"], exclude_imported=True + ), + migration_dependencies=[], + commands=[], +) diff --git a/piccolo_api/mfa/authenticator/piccolo_migrations/__init__.py b/piccolo_api/mfa/authenticator/piccolo_migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py new file mode 100644 index 00000000..35a58b0f --- /dev/null +++ b/piccolo_api/mfa/authenticator/provider.py @@ -0,0 +1,5 @@ +from piccolo_api.mfa.provider import MFAProvider + + +class AuthenticatorMFAProvider(MFAProvider): + pass diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py new file mode 100644 index 00000000..7c987f7b --- /dev/null +++ b/piccolo_api/mfa/authenticator/tables.py @@ -0,0 +1,9 @@ +from piccolo.columns import Integer, Serial, Text, Timestamptz +from piccolo.table import Table + + +class AuthenticatorSeed(Table): + id: Serial + user_id = Integer(null=False) + code = Text() + created_at = Timestamptz() diff --git a/piccolo_api/mfa/email/piccolo_app.py b/piccolo_api/mfa/email/piccolo_app.py index 01ffd90a..bdd42d5e 100644 --- a/piccolo_api/mfa/email/piccolo_app.py +++ b/piccolo_api/mfa/email/piccolo_app.py @@ -7,7 +7,6 @@ from piccolo.conf.apps import AppConfig, table_finder - CURRENT_DIRECTORY = os.path.dirname(os.path.abspath(__file__)) @@ -17,7 +16,7 @@ CURRENT_DIRECTORY, "piccolo_migrations" ), table_classes=table_finder( - modules=["mfa_email.tables"], exclude_imported=True + modules=["email.tables"], exclude_imported=True ), migration_dependencies=[], commands=[], diff --git a/piccolo_api/mfa/email/provider.py b/piccolo_api/mfa/email/provider.py index 450fa6cc..853127f7 100644 --- a/piccolo_api/mfa/email/provider.py +++ b/piccolo_api/mfa/email/provider.py @@ -1,8 +1,14 @@ +from piccolo.apps.user.tables import BaseUser + from piccolo_api.mfa.provider import MFAProvider from .tables import EmailCode class EmailProvider(MFAProvider): - async def authenticate(self, email: str): - return await EmailCode.create_new(email=email) + + async def register(self, user: BaseUser): + return await EmailCode.create_new(email=user.email) + + async def authenticate(self, user: BaseUser, code: str) -> bool: + return await EmailCode.authenticate(email=user.email, code=code) diff --git a/piccolo_api/mfa/email/tables.py b/piccolo_api/mfa/email/tables.py index 0a95f420..cee6d511 100644 --- a/piccolo_api/mfa/email/tables.py +++ b/piccolo_api/mfa/email/tables.py @@ -1,5 +1,7 @@ from __future__ import annotations +import datetime + from piccolo.columns import Email, Timestamptz, Varchar from piccolo.table import Table @@ -10,9 +12,22 @@ class EmailCode(Table): created_at = Timestamptz() used_at = Timestamptz(null=True, default=None) + _expiry_time = datetime.timedelta(minutes=5) + @classmethod async def create_new(cls, email: str) -> EmailCode: # TODO - generate proper code instance = cls({cls.email: email, cls.code: "ABC123"}) await instance.save() return instance + + @classmethod + async def authenticate(cls, email: str, code: str) -> bool: + now = datetime.datetime.now(tz=datetime.timezone.utc) + + return cls.exists().where( + cls.email == email, + cls.code == code, + cls.used_at.is_null(), + cls.created_at >= now - cls._expiry_time, + ) diff --git a/piccolo_api/session_auth/endpoints.py b/piccolo_api/session_auth/endpoints.py index 5bd050b8..c06f597a 100644 --- a/piccolo_api/session_auth/endpoints.py +++ b/piccolo_api/session_auth/endpoints.py @@ -19,8 +19,8 @@ ) from starlette.status import HTTP_303_SEE_OTHER -from piccolo_api.mfa.core import MFAProvider from piccolo_api.mfa.email.provider import EmailProvider +from piccolo_api.mfa.provider import MFAProvider from piccolo_api.session_auth.tables import SessionsBase from piccolo_api.shared.auth.hooks import LoginHooks from piccolo_api.shared.auth.styles import Styles @@ -410,7 +410,7 @@ def session_login( :param styles: Modify the appearance of the HTML template using CSS. :param mfa_providers: - Add additional security to the login process usin Multi-Factor + Add additional security to the login process using Multi-Factor Authentication. """ # noqa: E501 From 5bf4a2d1aaab8e6dcb80fe7297e1c3f2049a2714 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Wed, 7 Aug 2024 18:18:05 +0100 Subject: [PATCH 006/102] flesh out `AuthenticatorSeed` some more --- piccolo_api/mfa/authenticator/tables.py | 35 +++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index 7c987f7b..72b3cbb4 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +import datetime + from piccolo.columns import Integer, Serial, Text, Timestamptz from piccolo.table import Table @@ -6,4 +10,35 @@ class AuthenticatorSeed(Table): id: Serial user_id = Integer(null=False) code = Text() + revoked_at = Timestamptz(null=True, default=None) created_at = Timestamptz() + last_used_at = Timestamptz() + + @classmethod + async def create_new(cls, user_id: int) -> AuthenticatorSeed: + # TODO - generate proper code + instance = cls({cls.user_id: user_id, cls.code: "ABC123"}) + await instance.save() + return instance + + @classmethod + async def authenticate(cls, user_id: int, code: str) -> bool: + seeds = cls.objects().where( + cls.user_id == user_id, + cls.revoked_at.is_null(), + ) + + # We check all seeds - a user is allowed multiple seeds (i.e. if they + # have multiple devices). + + for seed in seeds: + # TODO - add the proper code checking + if seed.code == "abc123": + seed.last_used_at = datetime.datetime.now( + tz=datetime.timezone.utc + ) + await seed.save(columns=[cls.last_used_at]) + + return True + + return False From ed2d26c9899b8b770d286cbad63d2f04680d7602 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Wed, 7 Aug 2024 18:21:47 +0100 Subject: [PATCH 007/102] add method for fetching pyotp --- piccolo_api/mfa/authenticator/tables.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index 72b3cbb4..535310e5 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -6,6 +6,19 @@ from piccolo.table import Table +def get_pyotp(): + try: + import pyotp + except ImportError as e: + print( + "Install pip install piccolo_api[authenticator] to use this " + "feature." + ) + raise e + + return pyotp + + class AuthenticatorSeed(Table): id: Serial user_id = Integer(null=False) From 034d1817729cd2390ec54788aa3c1dce2fbe4361 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Wed, 7 Aug 2024 18:22:50 +0100 Subject: [PATCH 008/102] add `pyotp` to requirements --- requirements/extras/authenticator.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 requirements/extras/authenticator.txt diff --git a/requirements/extras/authenticator.txt b/requirements/extras/authenticator.txt new file mode 100644 index 00000000..862384fc --- /dev/null +++ b/requirements/extras/authenticator.txt @@ -0,0 +1 @@ +pyotp==2.9.0 From 08144abe7b6118c9196bb23ca2f75497dd0ae370 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Wed, 7 Aug 2024 19:49:05 +0100 Subject: [PATCH 009/102] add proper auth methods for pyotp --- piccolo_api/mfa/authenticator/tables.py | 39 ++++++++++++++++++++----- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index 535310e5..d5c06080 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -1,12 +1,16 @@ from __future__ import annotations import datetime +import typing as t from piccolo.columns import Integer, Serial, Text, Timestamptz from piccolo.table import Table +if t.TYPE_CHECKING: + import pyotp -def get_pyotp(): + +def get_pyotp() -> pyotp: try: import pyotp except ImportError as e: @@ -22,15 +26,28 @@ def get_pyotp(): class AuthenticatorSeed(Table): id: Serial user_id = Integer(null=False) - code = Text() + secret = Text(secret=True) revoked_at = Timestamptz(null=True, default=None) created_at = Timestamptz() last_used_at = Timestamptz() + last_used_code = Text( + null=True, + default=None, + help_text=( + "We store the last used code, to guard against replay attacks." + ), + ) + + @classmethod + def generate_secret(cls) -> str: + pyotp = get_pyotp() + return pyotp.random_base32() @classmethod async def create_new(cls, user_id: int) -> AuthenticatorSeed: - # TODO - generate proper code - instance = cls({cls.user_id: user_id, cls.code: "ABC123"}) + instance = cls( + {cls.user_id: user_id, cls.secret: cls.generate_secret()} + ) await instance.save() return instance @@ -41,16 +58,22 @@ async def authenticate(cls, user_id: int, code: str) -> bool: cls.revoked_at.is_null(), ) + if not seeds: + return False + + pyotp = get_pyotp() + # We check all seeds - a user is allowed multiple seeds (i.e. if they # have multiple devices). - for seed in seeds: - # TODO - add the proper code checking - if seed.code == "abc123": + totp = pyotp.TOTP(seed.secret) + + if totp.verify(code): seed.last_used_at = datetime.datetime.now( tz=datetime.timezone.utc ) - await seed.save(columns=[cls.last_used_at]) + seed.last_used_code = code + await seed.save(columns=[cls.last_used_at, cls.last_used_code]) return True From ecc9dce63c116d596846ff9df3d98dc1d00c2570 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Wed, 7 Aug 2024 22:09:09 +0100 Subject: [PATCH 010/102] start adding tests --- tests/mfa/authenticator/test_tables.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tests/mfa/authenticator/test_tables.py diff --git a/tests/mfa/authenticator/test_tables.py b/tests/mfa/authenticator/test_tables.py new file mode 100644 index 00000000..cae2ba6f --- /dev/null +++ b/tests/mfa/authenticator/test_tables.py @@ -0,0 +1,19 @@ +from unittest import TestCase + +from piccolo_api.mfa.authenticator.tables import AuthenticatorSeed +from piccolo.ta + + +class TestGenerateSecret(TestCase): + + def test_generate_secret(self): + """ + Make sure secrets are generated correctly. + """ + secret_1 = AuthenticatorSeed.generate_secret() + secret_2 = AuthenticatorSeed.generate_secret() + + self.assertIsInstance(secret_1, str) + self.assertNotEqual(secret_1, secret_2) + self.assertEqual(len(secret_1), 32) + From 17a885486371b5e5e0f9b1fe6b7cd003e40330aa Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 8 Aug 2024 16:48:48 +0100 Subject: [PATCH 011/102] bump minimum Piccolo version --- requirements/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/requirements.txt b/requirements/requirements.txt index c18d9c93..af69a186 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -1,5 +1,5 @@ Jinja2>=2.11.0 -piccolo[postgres]>=1.5 +piccolo[postgres]>=1.16.0 pydantic[email]>=2.0 python-multipart>=0.0.5 fastapi>=0.100.0 From 6d1e7f2a514e82df54d8fd4d23388dfbf0032ddb Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 8 Aug 2024 17:58:53 +0100 Subject: [PATCH 012/102] test `create_new` method --- piccolo_api/mfa/authenticator/tables.py | 4 ++-- tests/mfa/authenticator/test_tables.py | 23 ++++++++++++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index d5c06080..48c891d3 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -27,9 +27,9 @@ class AuthenticatorSeed(Table): id: Serial user_id = Integer(null=False) secret = Text(secret=True) - revoked_at = Timestamptz(null=True, default=None) created_at = Timestamptz() - last_used_at = Timestamptz() + revoked_at = Timestamptz(null=True, default=None) + last_used_at = Timestamptz(null=True, default=None) last_used_code = Text( null=True, default=None, diff --git a/tests/mfa/authenticator/test_tables.py b/tests/mfa/authenticator/test_tables.py index cae2ba6f..f1d29f27 100644 --- a/tests/mfa/authenticator/test_tables.py +++ b/tests/mfa/authenticator/test_tables.py @@ -1,7 +1,10 @@ +import datetime from unittest import TestCase +from piccolo.apps.user.tables import BaseUser +from piccolo.testing.test_case import AsyncTableTest + from piccolo_api.mfa.authenticator.tables import AuthenticatorSeed -from piccolo.ta class TestGenerateSecret(TestCase): @@ -17,3 +20,21 @@ def test_generate_secret(self): self.assertNotEqual(secret_1, secret_2) self.assertEqual(len(secret_1), 32) + +class TestCreateNew(AsyncTableTest): + + tables = [AuthenticatorSeed, BaseUser] + + async def test_create_new(self): + user = await BaseUser.create_user( + username="test", password="test123456" + ) + + seed = await AuthenticatorSeed.create_new(user_id=user.id) + + self.assertEqual(seed.id, user.id) + self.assertIsNotNone(seed.secret) + self.assertIsInstance(seed.created_at, datetime.datetime) + self.assertIsNone(seed.last_used_at) + self.assertIsNone(seed.revoked_at) + self.assertIsNone(seed.last_used_code) From 9bcaf1ffde51c931a7818ecaa6c62a983f6156c9 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 8 Aug 2024 18:25:07 +0100 Subject: [PATCH 013/102] add qrcode logic from @sinisaos PR --- piccolo_api/mfa/authenticator/tables.py | 5 +++++ piccolo_api/mfa/authenticator/utils.py | 14 ++++++++++++++ requirements/extras/authenticator.txt | 1 + 3 files changed, 20 insertions(+) create mode 100644 piccolo_api/mfa/authenticator/utils.py diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index 48c891d3..a0544504 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -78,3 +78,8 @@ async def authenticate(cls, user_id: int, code: str) -> bool: return True return False + + def get_authentication_setup_uri(self, email: str) -> str: + return pyotp.totp.TOTP(self.secret).provisioning_uri( + name=email, issuer_name="Piccolo-Admin-2FA" + ) diff --git a/piccolo_api/mfa/authenticator/utils.py b/piccolo_api/mfa/authenticator/utils.py new file mode 100644 index 00000000..fac71e26 --- /dev/null +++ b/piccolo_api/mfa/authenticator/utils.py @@ -0,0 +1,14 @@ +from base64 import b64encode +from io import BytesIO + +import qrcode + + +def get_b64encoded_qr_image(data): + qr = qrcode.QRCode(version=1, box_size=4, border=5) + qr.add_data(data) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white") + buffered = BytesIO() + img.save(buffered) + return b64encode(buffered.getvalue()).decode("utf-8") diff --git a/requirements/extras/authenticator.txt b/requirements/extras/authenticator.txt index 862384fc..55bd1192 100644 --- a/requirements/extras/authenticator.txt +++ b/requirements/extras/authenticator.txt @@ -1 +1,2 @@ pyotp==2.9.0 +qrcode==7.4.2 From f67d5436412faab6cc679f05e5654ea5fd3886be Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 8 Aug 2024 18:58:15 +0100 Subject: [PATCH 014/102] flesh out auth methods --- piccolo_api/mfa/authenticator/provider.py | 24 +++++++++++++++++++++-- piccolo_api/mfa/authenticator/tables.py | 4 ++-- piccolo_api/mfa/provider.py | 12 ++++++++++-- piccolo_api/session_auth/endpoints.py | 2 ++ tests/mfa/authenticator/test_tables.py | 10 +++++----- 5 files changed, 41 insertions(+), 11 deletions(-) diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index 35a58b0f..a2a4dd01 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -1,5 +1,25 @@ +import typing as t + +from piccolo.apps.user.tables import BaseUser + from piccolo_api.mfa.provider import MFAProvider +from .tables import AuthenticatorSecret + + +class AuthenticatorProvider(MFAProvider): + + def __init__( + self, seed_table: t.Type[AuthenticatorSecret] = AuthenticatorSecret + ): + """ + :param seed_table: + By default, just use the out of the box ``AuthenticatorSecret`` + table - you can specify a subclass instead if you want to override + certain functionality. + + """ + self.seed_table = seed_table -class AuthenticatorMFAProvider(MFAProvider): - pass + async def authenticate(self, user: BaseUser, code: str) -> bool: + return await self.seed_table.authenticate(user_id=user.id, code=code) diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index a0544504..79539fa4 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -23,7 +23,7 @@ def get_pyotp() -> pyotp: return pyotp -class AuthenticatorSeed(Table): +class AuthenticatorSecret(Table): id: Serial user_id = Integer(null=False) secret = Text(secret=True) @@ -44,7 +44,7 @@ def generate_secret(cls) -> str: return pyotp.random_base32() @classmethod - async def create_new(cls, user_id: int) -> AuthenticatorSeed: + async def create_new(cls, user_id: int) -> AuthenticatorSecret: instance = cls( {cls.user_id: user_id, cls.secret: cls.generate_secret()} ) diff --git a/piccolo_api/mfa/provider.py b/piccolo_api/mfa/provider.py index c8d769e5..8a586542 100644 --- a/piccolo_api/mfa/provider.py +++ b/piccolo_api/mfa/provider.py @@ -1,2 +1,10 @@ -class MFAProvider: - pass +from abc import ABCMeta, abstractmethod + +from piccolo.apps.user.tables import BaseUser + + +class MFAProvider(metaclass=ABCMeta): + + @abstractmethod + async def authenticate(self, user: BaseUser) -> bool: + pass diff --git a/piccolo_api/session_auth/endpoints.py b/piccolo_api/session_auth/endpoints.py index c06f597a..70a1e883 100644 --- a/piccolo_api/session_auth/endpoints.py +++ b/piccolo_api/session_auth/endpoints.py @@ -230,6 +230,8 @@ async def post(self, request: Request) -> Response: password = body.get("password", None) return_html = body.get("format") == "html" + if self._mfa_providers + if (not username) or (not password): error_message = "Missing username or password" if return_html: diff --git a/tests/mfa/authenticator/test_tables.py b/tests/mfa/authenticator/test_tables.py index f1d29f27..663ec525 100644 --- a/tests/mfa/authenticator/test_tables.py +++ b/tests/mfa/authenticator/test_tables.py @@ -4,7 +4,7 @@ from piccolo.apps.user.tables import BaseUser from piccolo.testing.test_case import AsyncTableTest -from piccolo_api.mfa.authenticator.tables import AuthenticatorSeed +from piccolo_api.mfa.authenticator.tables import AuthenticatorSecret class TestGenerateSecret(TestCase): @@ -13,8 +13,8 @@ def test_generate_secret(self): """ Make sure secrets are generated correctly. """ - secret_1 = AuthenticatorSeed.generate_secret() - secret_2 = AuthenticatorSeed.generate_secret() + secret_1 = AuthenticatorSecret.generate_secret() + secret_2 = AuthenticatorSecret.generate_secret() self.assertIsInstance(secret_1, str) self.assertNotEqual(secret_1, secret_2) @@ -23,14 +23,14 @@ def test_generate_secret(self): class TestCreateNew(AsyncTableTest): - tables = [AuthenticatorSeed, BaseUser] + tables = [AuthenticatorSecret, BaseUser] async def test_create_new(self): user = await BaseUser.create_user( username="test", password="test123456" ) - seed = await AuthenticatorSeed.create_new(user_id=user.id) + seed = await AuthenticatorSecret.create_new(user_id=user.id) self.assertEqual(seed.id, user.id) self.assertIsNotNone(seed.secret) From f4c8bf0a5825905c751648921b60186cece98c19 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 8 Aug 2024 19:59:35 +0100 Subject: [PATCH 015/102] lazy load `qrcode` --- piccolo_api/mfa/authenticator/utils.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/piccolo_api/mfa/authenticator/utils.py b/piccolo_api/mfa/authenticator/utils.py index fac71e26..44de8694 100644 --- a/piccolo_api/mfa/authenticator/utils.py +++ b/piccolo_api/mfa/authenticator/utils.py @@ -1,10 +1,29 @@ +from __future__ import annotations + +import typing as t from base64 import b64encode from io import BytesIO -import qrcode +if t.TYPE_CHECKING: + import qrcode + + +def get_qrcode() -> qrcode: + try: + import qrcode + except ImportError as e: + print( + "Install pip install piccolo_api[authenticator] to use this " + "feature." + ) + raise e + + return qrcode def get_b64encoded_qr_image(data): + qrcode = get_qrcode() + qr = qrcode.QRCode(version=1, box_size=4, border=5) qr.add_data(data) qr.make(fit=True) From 71f9aee959ded2d781b4c6fc60fac0401faab997 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 8 Aug 2024 20:17:48 +0100 Subject: [PATCH 016/102] fleshing out logic some more in session login endpoint --- piccolo_api/mfa/authenticator/provider.py | 11 +++- piccolo_api/mfa/authenticator/tables.py | 4 ++ piccolo_api/mfa/email/provider.py | 2 +- piccolo_api/mfa/provider.py | 21 +++++++- piccolo_api/session_auth/endpoints.py | 61 +++++++++++++++++------ 5 files changed, 81 insertions(+), 18 deletions(-) diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index a2a4dd01..116f19f3 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -21,5 +21,14 @@ def __init__( """ self.seed_table = seed_table - async def authenticate(self, user: BaseUser, code: str) -> bool: + async def authenticate_user(self, user: BaseUser, code: str) -> bool: return await self.seed_table.authenticate(user_id=user.id, code=code) + + async def is_user_enrolled(self, user: BaseUser) -> bool: + return await self.seed_table.is_user_enrolled(user_id=user.id) + + async def send_code(self, user: BaseUser): + """ + Deliberately sent blank - the user already has the code on their phone. + """ + pass diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index 79539fa4..6a4c2957 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -79,6 +79,10 @@ async def authenticate(cls, user_id: int, code: str) -> bool: return False + @classmethod + async def is_user_enrolled(cls, user_id: int) -> bool: + return await cls.exists().where(cls.user_id == user_id) + def get_authentication_setup_uri(self, email: str) -> str: return pyotp.totp.TOTP(self.secret).provisioning_uri( name=email, issuer_name="Piccolo-Admin-2FA" diff --git a/piccolo_api/mfa/email/provider.py b/piccolo_api/mfa/email/provider.py index 853127f7..cadd2ff8 100644 --- a/piccolo_api/mfa/email/provider.py +++ b/piccolo_api/mfa/email/provider.py @@ -10,5 +10,5 @@ class EmailProvider(MFAProvider): async def register(self, user: BaseUser): return await EmailCode.create_new(email=user.email) - async def authenticate(self, user: BaseUser, code: str) -> bool: + async def authenticate_user(self, user: BaseUser, code: str) -> bool: return await EmailCode.authenticate(email=user.email, code=code) diff --git a/piccolo_api/mfa/provider.py b/piccolo_api/mfa/provider.py index 8a586542..792a42be 100644 --- a/piccolo_api/mfa/provider.py +++ b/piccolo_api/mfa/provider.py @@ -6,5 +6,24 @@ class MFAProvider(metaclass=ABCMeta): @abstractmethod - async def authenticate(self, user: BaseUser) -> bool: + async def authenticate_user(self, user: BaseUser, code: str) -> bool: + """ + Should return ``True`` if the code is correct for the user. + """ + pass + + @abstractmethod + async def is_user_enrolled(self, user: BaseUser) -> bool: + """ + Should return ``True`` if the user is enrolled in this MFA, and hence + should submit a code. + """ + pass + + @abstractmethod + async def send_code(self, user: BaseUser): + """ + If the provider needs to send a code (e.g. if using email or SMS), then + implement it here. For app based TOTP codes, this can be a NO-OP. + """ pass diff --git a/piccolo_api/session_auth/endpoints.py b/piccolo_api/session_auth/endpoints.py index 70a1e883..40e2a12b 100644 --- a/piccolo_api/session_auth/endpoints.py +++ b/piccolo_api/session_auth/endpoints.py @@ -226,11 +226,10 @@ async def post(self, request: Request) -> Response: except JSONDecodeError: body = await request.form() - username = body.get("username", None) - password = body.get("password", None) + username = body.get("username") + password = body.get("password") return_html = body.get("format") == "html" - - if self._mfa_providers + mfa_code = body.get("mfa_code") if (not username) or (not password): error_message = "Missing username or password" @@ -274,17 +273,49 @@ async def post(self, request: Request) -> Response: if user_id: # Apply MFA - if self._mfa_providers: - for mfa_provider in self._mfa_providers: - if isinstance(mfa_provider, EmailProvider): - email = ( - await self._auth_table.select( - self._auth_table.email - ) - .where(self._auth_table.id == user_id) - .first() - )["email"] - await EmailProvider.authenticate(email=email) + if mfa_providers := self._mfa_providers: + user = ( + await self._auth_table.objects() + .where(self._auth_table.id == user_id) + .first() + ) + + assert user is not None + + for mfa_provider in mfa_providers: + if mfa_provider.is_user_enrolled(user=user): + if mfa_code is None: + # Send the code (only used with things like codes + # over email or SMS). + await mfa_provider.send_code() + + if return_html: + return self._render_template( + request, + template_context={ + "error": "Please enter a MFA code." + }, + ) + else: + raise HTTPException( + status_code=401, detail="MFA code required" + ) + else: + if not await mfa_provider.authenticate_user( + user=user, code=mfa_code + ): + if return_html: + return self._render_template( + request, + template_context={ + "error": "MFA failed." + }, + ) + else: + raise HTTPException( + status_code=401, + detail="MFA failed", + ) # Run login_success hooks if self._hooks and self._hooks.login_success: From 33dfbf9d92ab5794f16ab85a2cebbfba88ad31d9 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 8 Aug 2024 20:18:52 +0100 Subject: [PATCH 017/102] change imports --- piccolo_api/mfa/authenticator/provider.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index 116f19f3..ac4531bc 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -2,10 +2,9 @@ from piccolo.apps.user.tables import BaseUser +from piccolo_api.mfa.authenticator.tables import AuthenticatorSecret from piccolo_api.mfa.provider import MFAProvider -from .tables import AuthenticatorSecret - class AuthenticatorProvider(MFAProvider): From 0f4f1fcffe5240f3ac6e50140d00f9041aaf05fa Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 8 Aug 2024 20:22:20 +0100 Subject: [PATCH 018/102] make error messages consistent --- piccolo_api/session_auth/endpoints.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/piccolo_api/session_auth/endpoints.py b/piccolo_api/session_auth/endpoints.py index 40e2a12b..a2db6ec2 100644 --- a/piccolo_api/session_auth/endpoints.py +++ b/piccolo_api/session_auth/endpoints.py @@ -19,7 +19,6 @@ ) from starlette.status import HTTP_303_SEE_OTHER -from piccolo_api.mfa.email.provider import EmailProvider from piccolo_api.mfa.provider import MFAProvider from piccolo_api.session_auth.tables import SessionsBase from piccolo_api.shared.auth.hooks import LoginHooks @@ -283,17 +282,17 @@ async def post(self, request: Request) -> Response: assert user is not None for mfa_provider in mfa_providers: - if mfa_provider.is_user_enrolled(user=user): + if await mfa_provider.is_user_enrolled(user=user): if mfa_code is None: - # Send the code (only used with things like codes - # over email or SMS). + # Send the code (only used with things like email + # and SMS MFA). await mfa_provider.send_code() if return_html: return self._render_template( request, template_context={ - "error": "Please enter a MFA code." + "error": "MFA code required" }, ) else: @@ -308,7 +307,7 @@ async def post(self, request: Request) -> Response: return self._render_template( request, template_context={ - "error": "MFA failed." + "error": "MFA failed" }, ) else: From c3f4ca36acb9ae7457495a1bc495c225c602b68e Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 8 Aug 2024 21:23:42 +0100 Subject: [PATCH 019/102] add TODO --- piccolo_api/session_auth/endpoints.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/piccolo_api/session_auth/endpoints.py b/piccolo_api/session_auth/endpoints.py index a2db6ec2..dcc5865a 100644 --- a/piccolo_api/session_auth/endpoints.py +++ b/piccolo_api/session_auth/endpoints.py @@ -288,6 +288,12 @@ async def post(self, request: Request) -> Response: # and SMS MFA). await mfa_provider.send_code() + # TODO - have a param to request a code be sent? + # It's OK for now, but we might not want to send + # a code if another was recently sent. + # That could always be in the logic of `send_code` + # though. + if return_html: return self._render_template( request, From 785de9328f0fa4e2e58b2d4c64580fb5e09de35e Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 8 Aug 2024 21:48:49 +0100 Subject: [PATCH 020/102] adding example app for testing --- example_projects/mfa_demo/README.md | 28 ++++ example_projects/mfa_demo/app.py | 71 +++++++++ example_projects/mfa_demo/main.py | 12 ++ example_projects/mfa_demo/piccolo_conf.py | 26 +++ example_projects/mfa_demo/requirements.txt | 5 + piccolo_api/mfa/authenticator/piccolo_app.py | 8 +- ...uthenticator_2024_08_08t21_41_46_837552.py | 149 ++++++++++++++++++ piccolo_api/mfa/email/piccolo_app.py | 8 +- 8 files changed, 299 insertions(+), 8 deletions(-) create mode 100644 example_projects/mfa_demo/README.md create mode 100644 example_projects/mfa_demo/app.py create mode 100644 example_projects/mfa_demo/main.py create mode 100644 example_projects/mfa_demo/piccolo_conf.py create mode 100644 example_projects/mfa_demo/requirements.txt create mode 100644 piccolo_api/mfa/authenticator/piccolo_migrations/mfa_authenticator_2024_08_08t21_41_46_837552.py diff --git a/example_projects/mfa_demo/README.md b/example_projects/mfa_demo/README.md new file mode 100644 index 00000000..c3e82382 --- /dev/null +++ b/example_projects/mfa_demo/README.md @@ -0,0 +1,28 @@ +# Change password demo + +This project demos how to use the `change_password` endpoint. + +## Setup + +### Install requirements + +```bash +pip install -r requirements.txt +``` + +### Create database + +Make sure a Postgres database exists, called 'piccolo_api_change_password'. See +`piccolo_conf.py` for the full details. + +### Run migrations + +``` +piccolo migrations forwards all +``` + +## Run the app + +```bash +python main.py +``` diff --git a/example_projects/mfa_demo/app.py b/example_projects/mfa_demo/app.py new file mode 100644 index 00000000..9e63d40f --- /dev/null +++ b/example_projects/mfa_demo/app.py @@ -0,0 +1,71 @@ +from starlette.applications import Starlette +from starlette.endpoints import HTTPEndpoint +from starlette.middleware import Middleware +from starlette.middleware.authentication import AuthenticationMiddleware +from starlette.requests import Request +from starlette.responses import HTMLResponse, RedirectResponse +from starlette.routing import Mount, Route + +from piccolo_api.csrf.middleware import CSRFMiddleware +from piccolo_api.register.endpoints import register +from piccolo_api.session_auth.endpoints import session_login, session_logout +from piccolo_api.session_auth.middleware import SessionsAuthBackend + + +class HomeEndpoint(HTTPEndpoint): + async def get(self, request): + return HTMLResponse( + content=( + "" + "

MFA Demo

" + '

First register

' # noqa: E501 + '

Then login

' # noqa: E501 + '

Then try the private page

' # noqa: E501 + '

And logout

' # noqa: E501 + ) + ) + + +class PrivateEndpoint(HTTPEndpoint): + async def get(self, request): + return HTMLResponse( + content=( + "" + "

Private page

" + ) + ) + + +def on_auth_error(request: Request, exc: Exception): + return RedirectResponse("/login/") + + +private_app = Starlette( + routes=[ + Route("/", PrivateEndpoint), + Route("/logout/", session_logout()), + ], + middleware=[ + Middleware( + AuthenticationMiddleware, + on_error=on_auth_error, + backend=SessionsAuthBackend(admin_only=False), + ), + ], +) + + +app = Starlette( + routes=[ + Route("/", HomeEndpoint), + Route("/login/", session_login()), + Route( + "/register/", + register(redirect_to="/login/", user_defaults={"active": True}), + ), + Mount("/private/", private_app), + ], + middleware=[ + Middleware(CSRFMiddleware, allow_form_param=True), + ], +) diff --git a/example_projects/mfa_demo/main.py b/example_projects/mfa_demo/main.py new file mode 100644 index 00000000..17b75904 --- /dev/null +++ b/example_projects/mfa_demo/main.py @@ -0,0 +1,12 @@ +import os +import sys + +# Modify the path, so piccolo_api is available +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) + + +if __name__ == "__main__": + + import uvicorn + + uvicorn.run("app:app", reload=True) diff --git a/example_projects/mfa_demo/piccolo_conf.py b/example_projects/mfa_demo/piccolo_conf.py new file mode 100644 index 00000000..8825471d --- /dev/null +++ b/example_projects/mfa_demo/piccolo_conf.py @@ -0,0 +1,26 @@ +import os +import sys + +from piccolo.conf.apps import AppRegistry +from piccolo.engine.postgres import PostgresEngine + +# Modify the path, so piccolo_api is available +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) + +DB = PostgresEngine( + config={ + "database": "piccolo_api_mfa", + "user": "postgres", + "password": "", + "host": "localhost", + "port": 5432, + } +) + +APP_REGISTRY = AppRegistry( + apps=[ + "piccolo.apps.user.piccolo_app", + "piccolo_api.session_auth.piccolo_app", + "piccolo_api.mfa.authenticator.piccolo_app", + ] +) diff --git a/example_projects/mfa_demo/requirements.txt b/example_projects/mfa_demo/requirements.txt new file mode 100644 index 00000000..1ca5e39e --- /dev/null +++ b/example_projects/mfa_demo/requirements.txt @@ -0,0 +1,5 @@ +starlette +uvicorn[all] +piccolo[postgres] +httpx +python-multipart diff --git a/piccolo_api/mfa/authenticator/piccolo_app.py b/piccolo_api/mfa/authenticator/piccolo_app.py index 285e0993..2b57d90a 100644 --- a/piccolo_api/mfa/authenticator/piccolo_app.py +++ b/piccolo_api/mfa/authenticator/piccolo_app.py @@ -5,7 +5,9 @@ import os -from piccolo.conf.apps import AppConfig, table_finder +from piccolo.conf.apps import AppConfig + +from .tables import AuthenticatorSecret CURRENT_DIRECTORY = os.path.dirname(os.path.abspath(__file__)) @@ -15,9 +17,7 @@ migrations_folder_path=os.path.join( CURRENT_DIRECTORY, "piccolo_migrations" ), - table_classes=table_finder( - modules=["authenticator.tables"], exclude_imported=True - ), + table_classes=[AuthenticatorSecret], migration_dependencies=[], commands=[], ) diff --git a/piccolo_api/mfa/authenticator/piccolo_migrations/mfa_authenticator_2024_08_08t21_41_46_837552.py b/piccolo_api/mfa/authenticator/piccolo_migrations/mfa_authenticator_2024_08_08t21_41_46_837552.py new file mode 100644 index 00000000..26271e1a --- /dev/null +++ b/piccolo_api/mfa/authenticator/piccolo_migrations/mfa_authenticator_2024_08_08t21_41_46_837552.py @@ -0,0 +1,149 @@ +from piccolo.apps.migrations.auto.migration_manager import MigrationManager +from piccolo.columns.column_types import Integer, Text, Timestamptz +from piccolo.columns.defaults.timestamptz import TimestamptzNow +from piccolo.columns.indexes import IndexMethod + +ID = "2024-08-08T21:41:46:837552" +VERSION = "1.16.0" +DESCRIPTION = "Add AuthenticatorSecret table" + + +async def forwards(): + manager = MigrationManager( + migration_id=ID, app_name="mfa_authenticator", description=DESCRIPTION + ) + + manager.add_table( + class_name="AuthenticatorSecret", + tablename="authenticator_secret", + schema=None, + columns=None, + ) + + manager.add_column( + table_class_name="AuthenticatorSecret", + tablename="authenticator_secret", + column_name="user_id", + db_column_name="user_id", + column_class_name="Integer", + column_class=Integer, + params={ + "default": 0, + "null": False, + "primary_key": False, + "unique": False, + "index": False, + "index_method": IndexMethod.btree, + "choices": None, + "db_column_name": None, + "secret": False, + }, + schema=None, + ) + + manager.add_column( + table_class_name="AuthenticatorSecret", + tablename="authenticator_secret", + column_name="secret", + db_column_name="secret", + column_class_name="Text", + column_class=Text, + params={ + "default": "", + "null": False, + "primary_key": False, + "unique": False, + "index": False, + "index_method": IndexMethod.btree, + "choices": None, + "db_column_name": None, + "secret": True, + }, + schema=None, + ) + + manager.add_column( + table_class_name="AuthenticatorSecret", + tablename="authenticator_secret", + column_name="created_at", + db_column_name="created_at", + column_class_name="Timestamptz", + column_class=Timestamptz, + params={ + "default": TimestamptzNow(), + "null": False, + "primary_key": False, + "unique": False, + "index": False, + "index_method": IndexMethod.btree, + "choices": None, + "db_column_name": None, + "secret": False, + }, + schema=None, + ) + + manager.add_column( + table_class_name="AuthenticatorSecret", + tablename="authenticator_secret", + column_name="revoked_at", + db_column_name="revoked_at", + column_class_name="Timestamptz", + column_class=Timestamptz, + params={ + "default": None, + "null": True, + "primary_key": False, + "unique": False, + "index": False, + "index_method": IndexMethod.btree, + "choices": None, + "db_column_name": None, + "secret": False, + }, + schema=None, + ) + + manager.add_column( + table_class_name="AuthenticatorSecret", + tablename="authenticator_secret", + column_name="last_used_at", + db_column_name="last_used_at", + column_class_name="Timestamptz", + column_class=Timestamptz, + params={ + "default": None, + "null": True, + "primary_key": False, + "unique": False, + "index": False, + "index_method": IndexMethod.btree, + "choices": None, + "db_column_name": None, + "secret": False, + }, + schema=None, + ) + + manager.add_column( + table_class_name="AuthenticatorSecret", + tablename="authenticator_secret", + column_name="last_used_code", + db_column_name="last_used_code", + column_class_name="Text", + column_class=Text, + params={ + "default": None, + "null": True, + "primary_key": False, + "unique": False, + "index": False, + "index_method": IndexMethod.btree, + "choices": None, + "db_column_name": None, + "secret": False, + }, + schema=None, + ) + + return manager diff --git a/piccolo_api/mfa/email/piccolo_app.py b/piccolo_api/mfa/email/piccolo_app.py index bdd42d5e..d24b863c 100644 --- a/piccolo_api/mfa/email/piccolo_app.py +++ b/piccolo_api/mfa/email/piccolo_app.py @@ -5,7 +5,9 @@ import os -from piccolo.conf.apps import AppConfig, table_finder +from piccolo.conf.apps import AppConfig + +from .tables import EmailCode CURRENT_DIRECTORY = os.path.dirname(os.path.abspath(__file__)) @@ -15,9 +17,7 @@ migrations_folder_path=os.path.join( CURRENT_DIRECTORY, "piccolo_migrations" ), - table_classes=table_finder( - modules=["email.tables"], exclude_imported=True - ), + table_classes=[EmailCode], migration_dependencies=[], commands=[], ) From 38bd3bfc4321562d401786c63f379f3c86295808 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 8 Aug 2024 21:50:42 +0100 Subject: [PATCH 021/102] add `AuthenticatorProvider` to example app --- example_projects/mfa_demo/app.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/example_projects/mfa_demo/app.py b/example_projects/mfa_demo/app.py index 9e63d40f..00e78a85 100644 --- a/example_projects/mfa_demo/app.py +++ b/example_projects/mfa_demo/app.py @@ -7,6 +7,7 @@ from starlette.routing import Mount, Route from piccolo_api.csrf.middleware import CSRFMiddleware +from piccolo_api.mfa.authenticator.provider import AuthenticatorProvider from piccolo_api.register.endpoints import register from piccolo_api.session_auth.endpoints import session_login, session_logout from piccolo_api.session_auth.middleware import SessionsAuthBackend @@ -58,7 +59,9 @@ def on_auth_error(request: Request, exc: Exception): app = Starlette( routes=[ Route("/", HomeEndpoint), - Route("/login/", session_login()), + Route( + "/login/", session_login(mfa_providers=[AuthenticatorProvider()]) + ), Route( "/register/", register(redirect_to="/login/", user_defaults={"active": True}), From 7424132e65ff3bd0352cafcc9fa10b577c9f7ae5 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 8 Aug 2024 22:00:41 +0100 Subject: [PATCH 022/102] added `get_registration_html` to provider --- piccolo_api/mfa/authenticator/provider.py | 11 ++++++++++- piccolo_api/mfa/provider.py | 8 ++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index ac4531bc..04a9e619 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -28,6 +28,15 @@ async def is_user_enrolled(self, user: BaseUser) -> bool: async def send_code(self, user: BaseUser): """ - Deliberately sent blank - the user already has the code on their phone. + Deliberately blank - the user already has the code on their phone. """ pass + + async def get_registration_html(self, user: BaseUser) -> str: + """ + When a user wants to register for MFA, this HTML is shown containing + instructions. + """ + return """ +

Use an authenticator app like Google Authenticator to scan this QR code:

+ """ # noqa: E501 diff --git a/piccolo_api/mfa/provider.py b/piccolo_api/mfa/provider.py index 792a42be..053658ef 100644 --- a/piccolo_api/mfa/provider.py +++ b/piccolo_api/mfa/provider.py @@ -27,3 +27,11 @@ async def send_code(self, user: BaseUser): implement it here. For app based TOTP codes, this can be a NO-OP. """ pass + + @abstractmethod + async def get_registration_html(self, user: BaseUser) -> str: + """ + When a user wants to register for MFA, this HTML is shown containing + instructions. + """ + pass From a2d7719a1dd5c206fb1b07974e326543be4b8e51 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 9 Aug 2024 10:39:06 +0100 Subject: [PATCH 023/102] rename `seed_table` to `secret_table` --- piccolo_api/mfa/authenticator/provider.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index 04a9e619..bbd790cb 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -9,7 +9,7 @@ class AuthenticatorProvider(MFAProvider): def __init__( - self, seed_table: t.Type[AuthenticatorSecret] = AuthenticatorSecret + self, secret_table: t.Type[AuthenticatorSecret] = AuthenticatorSecret ): """ :param seed_table: @@ -18,13 +18,13 @@ def __init__( certain functionality. """ - self.seed_table = seed_table + self.secret_table = secret_table async def authenticate_user(self, user: BaseUser, code: str) -> bool: - return await self.seed_table.authenticate(user_id=user.id, code=code) + return await self.secret_table.authenticate(user_id=user.id, code=code) async def is_user_enrolled(self, user: BaseUser) -> bool: - return await self.seed_table.is_user_enrolled(user_id=user.id) + return await self.secret_table.is_user_enrolled(user_id=user.id) async def send_code(self, user: BaseUser): """ From 1084b7f222448ca55d86224737c8c7d438d32f5a Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 9 Aug 2024 10:40:29 +0100 Subject: [PATCH 024/102] change params in `send_code` for authenticator --- piccolo_api/mfa/authenticator/provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index bbd790cb..c3462f76 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -26,7 +26,7 @@ async def authenticate_user(self, user: BaseUser, code: str) -> bool: async def is_user_enrolled(self, user: BaseUser) -> bool: return await self.secret_table.is_user_enrolled(user_id=user.id) - async def send_code(self, user: BaseUser): + async def send_code(self, *args, **kwargs): """ Deliberately blank - the user already has the code on their phone. """ From d59540aa4f1128876f4519ad486cbb92813c4287 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 9 Aug 2024 10:41:40 +0100 Subject: [PATCH 025/102] Create README.md --- piccolo_api/mfa/README.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 piccolo_api/mfa/README.md diff --git a/piccolo_api/mfa/README.md b/piccolo_api/mfa/README.md new file mode 100644 index 00000000..5f35600e --- /dev/null +++ b/piccolo_api/mfa/README.md @@ -0,0 +1,4 @@ +# MFA + +Multi Factor Authentication - using an authenticator app on a mobile device, or +via email. From 15b9c8c35a90ed0331b69ec7d8e17ee371220adc Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 9 Aug 2024 10:44:59 +0100 Subject: [PATCH 026/102] add `issuer_name` to `AuthenticatorProvider` --- piccolo_api/mfa/authenticator/provider.py | 7 ++++++- piccolo_api/mfa/authenticator/tables.py | 6 ++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index c3462f76..6d717128 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -9,16 +9,21 @@ class AuthenticatorProvider(MFAProvider): def __init__( - self, secret_table: t.Type[AuthenticatorSecret] = AuthenticatorSecret + self, + secret_table: t.Type[AuthenticatorSecret] = AuthenticatorSecret, + issuer_name: str = "Piccolo-MFA", ): """ :param seed_table: By default, just use the out of the box ``AuthenticatorSecret`` table - you can specify a subclass instead if you want to override certain functionality. + :param issuer_name: + This is how it will identified in the user's authenticator app. """ self.secret_table = secret_table + self.issuer_name = issuer_name async def authenticate_user(self, user: BaseUser, code: str) -> bool: return await self.secret_table.authenticate(user_id=user.id, code=code) diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index 6a4c2957..e46b22d9 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -83,7 +83,9 @@ async def authenticate(cls, user_id: int, code: str) -> bool: async def is_user_enrolled(cls, user_id: int) -> bool: return await cls.exists().where(cls.user_id == user_id) - def get_authentication_setup_uri(self, email: str) -> str: + def get_authentication_setup_uri( + self, email: str, issuer_name: str = "Piccolo-MFA" + ) -> str: return pyotp.totp.TOTP(self.secret).provisioning_uri( - name=email, issuer_name="Piccolo-Admin-2FA" + name=email, issuer_name=issuer_name ) From 62e1bbf96809d474782458ef62d2215d0dddfd29 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 9 Aug 2024 10:46:19 +0100 Subject: [PATCH 027/102] fix typo in docstring --- piccolo_api/mfa/authenticator/provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index 6d717128..47ffd1c4 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -19,7 +19,7 @@ def __init__( table - you can specify a subclass instead if you want to override certain functionality. :param issuer_name: - This is how it will identified in the user's authenticator app. + This is how it will be identified in the user's authenticator app. """ self.secret_table = secret_table From 77b67a1c2ef651ba341f6a39b4be672a3cfaf537 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 9 Aug 2024 11:05:01 +0100 Subject: [PATCH 028/102] add `get_registration_json` --- piccolo_api/mfa/authenticator/provider.py | 27 +++++++++++++++++++++++ piccolo_api/mfa/endpoints.py | 7 ++++++ 2 files changed, 34 insertions(+) create mode 100644 piccolo_api/mfa/endpoints.py diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index 47ffd1c4..522476a4 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -3,6 +3,7 @@ from piccolo.apps.user.tables import BaseUser from piccolo_api.mfa.authenticator.tables import AuthenticatorSecret +from piccolo_api.mfa.authenticator.utils import get_b64encoded_qr_image from piccolo_api.mfa.provider import MFAProvider @@ -37,11 +38,37 @@ async def send_code(self, *args, **kwargs): """ pass + ########################################################################### + # Registration + + async def _generate_qrcode_image(self, user: BaseUser): + secret = await self.secret_table.create_new(user_id=user.id) + + uri = secret.get_authentication_setup_uri( + email=user.email, issuer_name=self.issuer_name + ) + + return get_b64encoded_qr_image(data=uri) + async def get_registration_html(self, user: BaseUser) -> str: """ When a user wants to register for MFA, this HTML is shown containing instructions. """ + qrcode_image = await self._generate_qrcode_image(user=user) # noqa + + # TODO - embed qrcode image in HTML if possible + return """

Use an authenticator app like Google Authenticator to scan this QR code:

""" # noqa: E501 + + async def get_registration_json(self, user: BaseUser) -> dict: + """ + When a user wants to register for MFA, the client can request a JSON + response, rather than HTML, if they want to render the UI themselves. + """ + + qrcode_image = await self._generate_qrcode_image(user=user) + + return {qrcode_image: qrcode_image} diff --git a/piccolo_api/mfa/endpoints.py b/piccolo_api/mfa/endpoints.py new file mode 100644 index 00000000..a77855b0 --- /dev/null +++ b/piccolo_api/mfa/endpoints.py @@ -0,0 +1,7 @@ +from abc import ABCMeta + +from starlette.endpoints import HTTPEndpoint + + +class MFARegisterEndpoint(HTTPEndpoint, metaclass=ABCMeta): + pass From 9162b9aafbcf1de2623182eec76c1a19f044812f Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 9 Aug 2024 11:06:08 +0100 Subject: [PATCH 029/102] add `get_registration_json` to base class --- piccolo_api/mfa/provider.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/piccolo_api/mfa/provider.py b/piccolo_api/mfa/provider.py index 053658ef..ea34c8bd 100644 --- a/piccolo_api/mfa/provider.py +++ b/piccolo_api/mfa/provider.py @@ -28,6 +28,9 @@ async def send_code(self, user: BaseUser): """ pass + ########################################################################### + # Registration + @abstractmethod async def get_registration_html(self, user: BaseUser) -> str: """ @@ -35,3 +38,11 @@ async def get_registration_html(self, user: BaseUser) -> str: instructions. """ pass + + @abstractmethod + async def get_registration_json(self, user: BaseUser) -> dict: + """ + When a user wants to register for MFA, the client can request a JSON + response, rather than HTML, if they want to render the UI themselves. + """ + pass From e45be092ebccc9bb285e8684b28ae3d51b9e30c8 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 9 Aug 2024 11:08:09 +0100 Subject: [PATCH 030/102] try embedding QR code image in HTML response --- piccolo_api/mfa/authenticator/provider.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index 522476a4..8073d7c8 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -57,10 +57,9 @@ async def get_registration_html(self, user: BaseUser) -> str: """ qrcode_image = await self._generate_qrcode_image(user=user) # noqa - # TODO - embed qrcode image in HTML if possible - - return """ + return f"""

Use an authenticator app like Google Authenticator to scan this QR code:

+ """ # noqa: E501 async def get_registration_json(self, user: BaseUser) -> dict: From c1b1c059ecd50e9bb3282f15d81b02a042e7bf78 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 9 Aug 2024 11:21:52 +0100 Subject: [PATCH 031/102] flesh out `MFARegisterEndpoint` endpoint --- piccolo_api/mfa/endpoints.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/piccolo_api/mfa/endpoints.py b/piccolo_api/mfa/endpoints.py index a77855b0..02bdb7d4 100644 --- a/piccolo_api/mfa/endpoints.py +++ b/piccolo_api/mfa/endpoints.py @@ -1,7 +1,34 @@ -from abc import ABCMeta +from abc import ABCMeta, abstractmethod from starlette.endpoints import HTTPEndpoint +from starlette.requests import Request +from starlette.responses import HTMLResponse, JSONResponse + +from piccolo_api.mfa.provider import MFAProvider class MFARegisterEndpoint(HTTPEndpoint, metaclass=ABCMeta): - pass + + @property + @abstractmethod + def _provider(self) -> MFAProvider: + raise NotImplementedError + + async def get(self, request: Request): + if request.query_params.get("format") == "json": + return HTMLResponse(content=self._provider.get_registration_html()) + else: + return JSONResponse(content=self._provider.get_registration_json()) + + async def post(self, request: Request): + # TODO - we might need the user to confirm once they're setup. + # We could embed the ID of the row in the HTML response (in a form). + pass + + +def mfa_register_endpoint(provider: MFAProvider) -> HTTPEndpoint: + + class _MFARegisterEndpoint(MFARegisterEndpoint): + _provider = provider + + return _MFARegisterEndpoint From cdc5d478991b76cfb5f8c866758d279b14ca3d21 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 9 Aug 2024 11:24:00 +0100 Subject: [PATCH 032/102] add todo about primary key --- piccolo_api/mfa/authenticator/tables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index e46b22d9..6c451cdf 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -24,7 +24,7 @@ def get_pyotp() -> pyotp: class AuthenticatorSecret(Table): - id: Serial + id: Serial # TODO - we might change this to a UUID primary key user_id = Integer(null=False) secret = Text(secret=True) created_at = Timestamptz() From d7bd02e7ee49def885e99778afccccf57c5210f0 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 9 Aug 2024 11:46:18 +0100 Subject: [PATCH 033/102] fix bugs in register endpoint --- example_projects/mfa_demo/app.py | 7 +++++++ piccolo_api/mfa/authenticator/provider.py | 2 +- piccolo_api/mfa/authenticator/tables.py | 2 ++ piccolo_api/mfa/endpoints.py | 12 ++++++++++-- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/example_projects/mfa_demo/app.py b/example_projects/mfa_demo/app.py index 00e78a85..a6ff7933 100644 --- a/example_projects/mfa_demo/app.py +++ b/example_projects/mfa_demo/app.py @@ -8,6 +8,7 @@ from piccolo_api.csrf.middleware import CSRFMiddleware from piccolo_api.mfa.authenticator.provider import AuthenticatorProvider +from piccolo_api.mfa.endpoints import mfa_register_endpoint from piccolo_api.register.endpoints import register from piccolo_api.session_auth.endpoints import session_login, session_logout from piccolo_api.session_auth.middleware import SessionsAuthBackend @@ -21,6 +22,7 @@ async def get(self, request): "

MFA Demo

" '

First register

' # noqa: E501 '

Then login

' # noqa: E501 + '

Then sign up for MFA

' # noqa: E501 '

Then try the private page

' # noqa: E501 '

And logout

' # noqa: E501 ) @@ -45,6 +47,10 @@ def on_auth_error(request: Request, exc: Exception): routes=[ Route("/", PrivateEndpoint), Route("/logout/", session_logout()), + Route( + "/mfa-register/", + mfa_register_endpoint(provider=AuthenticatorProvider()), + ), ], middleware=[ Middleware( @@ -53,6 +59,7 @@ def on_auth_error(request: Request, exc: Exception): backend=SessionsAuthBackend(admin_only=False), ), ], + debug=True, ) diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index 8073d7c8..957fad23 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -70,4 +70,4 @@ async def get_registration_json(self, user: BaseUser) -> dict: qrcode_image = await self._generate_qrcode_image(user=user) - return {qrcode_image: qrcode_image} + return {"qrcode_image": qrcode_image} diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index 6c451cdf..be8e06e5 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -86,6 +86,8 @@ async def is_user_enrolled(cls, user_id: int) -> bool: def get_authentication_setup_uri( self, email: str, issuer_name: str = "Piccolo-MFA" ) -> str: + pyotp = get_pyotp() + return pyotp.totp.TOTP(self.secret).provisioning_uri( name=email, issuer_name=issuer_name ) diff --git a/piccolo_api/mfa/endpoints.py b/piccolo_api/mfa/endpoints.py index 02bdb7d4..439ce3ba 100644 --- a/piccolo_api/mfa/endpoints.py +++ b/piccolo_api/mfa/endpoints.py @@ -15,10 +15,18 @@ def _provider(self) -> MFAProvider: raise NotImplementedError async def get(self, request: Request): + piccolo_user = request.user.user + if request.query_params.get("format") == "json": - return HTMLResponse(content=self._provider.get_registration_html()) + content = await self._provider.get_registration_html( + user=piccolo_user + ) + return HTMLResponse(content=content) else: - return JSONResponse(content=self._provider.get_registration_json()) + content = await self._provider.get_registration_json( + user=piccolo_user + ) + return JSONResponse(content=content) async def post(self, request: Request): # TODO - we might need the user to confirm once they're setup. From cabf6cd57d2bf88fe527ac81f73e93ffda54946e Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 9 Aug 2024 11:47:42 +0100 Subject: [PATCH 034/102] fix error - was returning html instead of json --- piccolo_api/mfa/endpoints.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/piccolo_api/mfa/endpoints.py b/piccolo_api/mfa/endpoints.py index 439ce3ba..dd186c3d 100644 --- a/piccolo_api/mfa/endpoints.py +++ b/piccolo_api/mfa/endpoints.py @@ -18,15 +18,15 @@ async def get(self, request: Request): piccolo_user = request.user.user if request.query_params.get("format") == "json": - content = await self._provider.get_registration_html( + content = await self._provider.get_registration_json( user=piccolo_user ) - return HTMLResponse(content=content) + return JSONResponse(content=content) else: - content = await self._provider.get_registration_json( + content = await self._provider.get_registration_html( user=piccolo_user ) - return JSONResponse(content=content) + return HTMLResponse(content=content) async def post(self, request: Request): # TODO - we might need the user to confirm once they're setup. From ec69778199125f3b2513eab4faf8e2ecd39315bc Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 9 Aug 2024 12:00:22 +0100 Subject: [PATCH 035/102] show MFA code input on login page --- example_projects/mfa_demo/app.py | 2 +- piccolo_api/mfa/authenticator/tables.py | 2 +- piccolo_api/session_auth/endpoints.py | 6 ++++-- piccolo_api/templates/session_login.html | 5 +++++ 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/example_projects/mfa_demo/app.py b/example_projects/mfa_demo/app.py index a6ff7933..bc4a47e5 100644 --- a/example_projects/mfa_demo/app.py +++ b/example_projects/mfa_demo/app.py @@ -46,7 +46,7 @@ def on_auth_error(request: Request, exc: Exception): private_app = Starlette( routes=[ Route("/", PrivateEndpoint), - Route("/logout/", session_logout()), + Route("/logout/", session_logout(redirect_to="/")), Route( "/mfa-register/", mfa_register_endpoint(provider=AuthenticatorProvider()), diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index be8e06e5..07cd4dff 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -53,7 +53,7 @@ async def create_new(cls, user_id: int) -> AuthenticatorSecret: @classmethod async def authenticate(cls, user_id: int, code: str) -> bool: - seeds = cls.objects().where( + seeds = await cls.objects().where( cls.user_id == user_id, cls.revoked_at.is_null(), ) diff --git a/piccolo_api/session_auth/endpoints.py b/piccolo_api/session_auth/endpoints.py index dcc5865a..9a5ac6fe 100644 --- a/piccolo_api/session_auth/endpoints.py +++ b/piccolo_api/session_auth/endpoints.py @@ -298,7 +298,8 @@ async def post(self, request: Request) -> Response: return self._render_template( request, template_context={ - "error": "MFA code required" + "error": "MFA code required", + "show_mfa_input": True, }, ) else: @@ -313,7 +314,8 @@ async def post(self, request: Request) -> Response: return self._render_template( request, template_context={ - "error": "MFA failed" + "error": "MFA failed", + "show_mfa_input": True, }, ) else: diff --git a/piccolo_api/templates/session_login.html b/piccolo_api/templates/session_login.html index 524d247d..3b532f6a 100644 --- a/piccolo_api/templates/session_login.html +++ b/piccolo_api/templates/session_login.html @@ -15,6 +15,11 @@

Login

+ {% if show_mfa_input %} + + + {% endif %} + {% if csrftoken and csrf_cookie_name %} {% endif %} From b4dd8164d36b986486e7100beace3f4a57e4d425 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 9 Aug 2024 12:18:44 +0100 Subject: [PATCH 036/102] Update README.md --- example_projects/mfa_demo/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/example_projects/mfa_demo/README.md b/example_projects/mfa_demo/README.md index c3e82382..571779a8 100644 --- a/example_projects/mfa_demo/README.md +++ b/example_projects/mfa_demo/README.md @@ -1,6 +1,6 @@ -# Change password demo +# MFA demo -This project demos how to use the `change_password` endpoint. +This project demos how to use the MFA with the `session_login` endpoint. ## Setup @@ -12,7 +12,7 @@ pip install -r requirements.txt ### Create database -Make sure a Postgres database exists, called 'piccolo_api_change_password'. See +Make sure a Postgres database exists, called 'piccolo_api_mfa'. See `piccolo_conf.py` for the full details. ### Run migrations From 923e3ef2cc0dcbe31f2b23a66867f30a00960559 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 9 Aug 2024 12:47:04 +0100 Subject: [PATCH 037/102] Update tests.yaml --- .github/workflows/tests.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 46f9d911..6ae7c3a9 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -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 From 4df3da956c89733dea74ae10f2e15ecbf6d9dfee Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 9 Aug 2024 12:47:18 +0100 Subject: [PATCH 038/102] fix some linter errors --- piccolo_api/mfa/email/tables.py | 2 +- piccolo_api/mfa/endpoints.py | 11 ++++++----- piccolo_api/session_auth/endpoints.py | 2 +- pyproject.toml | 3 ++- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/piccolo_api/mfa/email/tables.py b/piccolo_api/mfa/email/tables.py index cee6d511..f3d45f26 100644 --- a/piccolo_api/mfa/email/tables.py +++ b/piccolo_api/mfa/email/tables.py @@ -25,7 +25,7 @@ async def create_new(cls, email: str) -> EmailCode: async def authenticate(cls, email: str, code: str) -> bool: now = datetime.datetime.now(tz=datetime.timezone.utc) - return cls.exists().where( + return await cls.exists().where( cls.email == email, cls.code == code, cls.used_at.is_null(), diff --git a/piccolo_api/mfa/endpoints.py b/piccolo_api/mfa/endpoints.py index dd186c3d..ab3f969a 100644 --- a/piccolo_api/mfa/endpoints.py +++ b/piccolo_api/mfa/endpoints.py @@ -1,3 +1,4 @@ +import typing as t from abc import ABCMeta, abstractmethod from starlette.endpoints import HTTPEndpoint @@ -18,15 +19,15 @@ async def get(self, request: Request): piccolo_user = request.user.user if request.query_params.get("format") == "json": - content = await self._provider.get_registration_json( + json_content = await self._provider.get_registration_json( user=piccolo_user ) - return JSONResponse(content=content) + return JSONResponse(content=json_content) else: - content = await self._provider.get_registration_html( + html_content = await self._provider.get_registration_html( user=piccolo_user ) - return HTMLResponse(content=content) + return HTMLResponse(content=html_content) async def post(self, request: Request): # TODO - we might need the user to confirm once they're setup. @@ -34,7 +35,7 @@ async def post(self, request: Request): pass -def mfa_register_endpoint(provider: MFAProvider) -> HTTPEndpoint: +def mfa_register_endpoint(provider: MFAProvider) -> t.Type[HTTPEndpoint]: class _MFARegisterEndpoint(MFARegisterEndpoint): _provider = provider diff --git a/piccolo_api/session_auth/endpoints.py b/piccolo_api/session_auth/endpoints.py index 9a5ac6fe..2fb58db8 100644 --- a/piccolo_api/session_auth/endpoints.py +++ b/piccolo_api/session_auth/endpoints.py @@ -286,7 +286,7 @@ async def post(self, request: Request) -> Response: if mfa_code is None: # Send the code (only used with things like email # and SMS MFA). - await mfa_provider.send_code() + await mfa_provider.send_code(user=user) # TODO - have a param to request a code be sent? # It's OK for now, but we might not want to send diff --git a/pyproject.toml b/pyproject.toml index d4812e3b..4b33f556 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,8 @@ module = [ "moto", "botocore", "botocore.config", - "httpx" + "httpx", + "qrcode" ] ignore_missing_imports = true From 6a5e948fba6194b0dd3527bd4993bf33f0f17367 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 9 Aug 2024 21:16:27 +0100 Subject: [PATCH 039/102] add `device_name` column to `AuthenticatorSecret` --- ...uthenticator_2024_08_08t21_41_46_837552.py | 24 ++++++++++++++++++- piccolo_api/mfa/authenticator/tables.py | 10 +++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/piccolo_api/mfa/authenticator/piccolo_migrations/mfa_authenticator_2024_08_08t21_41_46_837552.py b/piccolo_api/mfa/authenticator/piccolo_migrations/mfa_authenticator_2024_08_08t21_41_46_837552.py index 26271e1a..683969be 100644 --- a/piccolo_api/mfa/authenticator/piccolo_migrations/mfa_authenticator_2024_08_08t21_41_46_837552.py +++ b/piccolo_api/mfa/authenticator/piccolo_migrations/mfa_authenticator_2024_08_08t21_41_46_837552.py @@ -1,5 +1,5 @@ from piccolo.apps.migrations.auto.migration_manager import MigrationManager -from piccolo.columns.column_types import Integer, Text, Timestamptz +from piccolo.columns.column_types import Integer, Text, Timestamptz, Varchar from piccolo.columns.defaults.timestamptz import TimestamptzNow from piccolo.columns.indexes import IndexMethod @@ -41,6 +41,28 @@ async def forwards(): schema=None, ) + manager.add_column( + table_class_name="AuthenticatorSecret", + tablename="authenticator_secret", + column_name="device_name", + db_column_name="device_name", + column_class_name="Varchar", + column_class=Varchar, + params={ + "length": 255, + "default": None, + "null": True, + "primary_key": False, + "unique": False, + "index": False, + "index_method": IndexMethod.btree, + "choices": None, + "db_column_name": None, + "secret": False, + }, + schema=None, + ) + manager.add_column( table_class_name="AuthenticatorSecret", tablename="authenticator_secret", diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index 07cd4dff..87cef373 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -3,7 +3,7 @@ import datetime import typing as t -from piccolo.columns import Integer, Serial, Text, Timestamptz +from piccolo.columns import Integer, Serial, Text, Timestamptz, Varchar from piccolo.table import Table if t.TYPE_CHECKING: @@ -26,6 +26,14 @@ def get_pyotp() -> pyotp: class AuthenticatorSecret(Table): id: Serial # TODO - we might change this to a UUID primary key user_id = Integer(null=False) + device_name = Varchar( + null=True, + default=None, + help_text=( + "The user can specify this to make the device memorable, " + "if we want to allow them to delete secrets." + ), + ) secret = Text(secret=True) created_at = Timestamptz() revoked_at = Timestamptz(null=True, default=None) From e33a0e4c66f4da59b6ac1058cb54bba69002f2a7 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 9 Aug 2024 21:21:47 +0100 Subject: [PATCH 040/102] make sure each provider has a custom token name --- piccolo_api/mfa/authenticator/provider.py | 2 ++ piccolo_api/mfa/provider.py | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index 957fad23..97b3f1b7 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -23,6 +23,8 @@ def __init__( This is how it will be identified in the user's authenticator app. """ + super().__init__(token_name="authenticator_token") + self.secret_table = secret_table self.issuer_name = issuer_name diff --git a/piccolo_api/mfa/provider.py b/piccolo_api/mfa/provider.py index ea34c8bd..8606ca39 100644 --- a/piccolo_api/mfa/provider.py +++ b/piccolo_api/mfa/provider.py @@ -5,6 +5,16 @@ class MFAProvider(metaclass=ABCMeta): + def __init__(self, token_name: str = "mfa_code"): + """ + :param token_name: + Each provider should specify a unique ``token_name``, so + when a token is passed to the login endpoint, we know which + ``MFAProvider`` it belongs to. + + """ + self.token_name = token_name + @abstractmethod async def authenticate_user(self, user: BaseUser, code: str) -> bool: """ From 3345e344d87b225b5323ff5a7f56b1d7e83e1624 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 9 Aug 2024 21:29:51 +0100 Subject: [PATCH 041/102] make each MFA Provider have a unique token name This means we can potentially put several MFA providers on the login page --- piccolo_api/session_auth/endpoints.py | 9 ++++++++- piccolo_api/templates/session_login.html | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/piccolo_api/session_auth/endpoints.py b/piccolo_api/session_auth/endpoints.py index 2fb58db8..c429d351 100644 --- a/piccolo_api/session_auth/endpoints.py +++ b/piccolo_api/session_auth/endpoints.py @@ -228,7 +228,6 @@ async def post(self, request: Request) -> Response: username = body.get("username") password = body.get("password") return_html = body.get("format") == "html" - mfa_code = body.get("mfa_code") if (not username) or (not password): error_message = "Missing username or password" @@ -283,6 +282,8 @@ async def post(self, request: Request) -> Response: for mfa_provider in mfa_providers: if await mfa_provider.is_user_enrolled(user=user): + mfa_code = body.get(mfa_provider.token_name) + if mfa_code is None: # Send the code (only used with things like email # and SMS MFA). @@ -300,6 +301,9 @@ async def post(self, request: Request) -> Response: template_context={ "error": "MFA code required", "show_mfa_input": True, + "mfa_token_name": ( + mfa_provider.token_name + ), }, ) else: @@ -316,6 +320,9 @@ async def post(self, request: Request) -> Response: template_context={ "error": "MFA failed", "show_mfa_input": True, + "mfa_token_name": ( + mfa_provider.token_name + ), }, ) else: diff --git a/piccolo_api/templates/session_login.html b/piccolo_api/templates/session_login.html index 3b532f6a..b7ffe96b 100644 --- a/piccolo_api/templates/session_login.html +++ b/piccolo_api/templates/session_login.html @@ -17,7 +17,7 @@

Login

{% if show_mfa_input %} - + {% endif %} {% if csrftoken and csrf_cookie_name %} From 67dfb6e89db913a32ff182ff2e2fe627d91d3bda Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 9 Aug 2024 21:44:04 +0100 Subject: [PATCH 042/102] If the user reused a code make sure auth fails (could be a replay attack) --- piccolo_api/mfa/authenticator/tables.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index 87cef373..cf94c0df 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -1,6 +1,7 @@ from __future__ import annotations import datetime +import logging import typing as t from piccolo.columns import Integer, Serial, Text, Timestamptz, Varchar @@ -10,6 +11,9 @@ import pyotp +logger = logging.getLogger(__name__) + + def get_pyotp() -> pyotp: try: import pyotp @@ -74,6 +78,12 @@ async def authenticate(cls, user_id: int, code: str) -> bool: # We check all seeds - a user is allowed multiple seeds (i.e. if they # have multiple devices). for seed in seeds: + if seed.last_used_code == code: + logger.warning( + f"User {user_id} reused a token - potential replay attack." + ) + return False + totp = pyotp.TOTP(seed.secret) if totp.verify(code): From ccb36483a62e91f4593db02c02e4860a1a17b907 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 9 Aug 2024 21:59:55 +0100 Subject: [PATCH 043/102] add auth test for replay attacks --- piccolo_api/mfa/authenticator/tables.py | 18 +++++++------- tests/mfa/authenticator/test_tables.py | 31 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index cf94c0df..27eac831 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -65,33 +65,35 @@ async def create_new(cls, user_id: int) -> AuthenticatorSecret: @classmethod async def authenticate(cls, user_id: int, code: str) -> bool: - seeds = await cls.objects().where( + secrets = await cls.objects().where( cls.user_id == user_id, cls.revoked_at.is_null(), ) - if not seeds: + if not secrets: return False pyotp = get_pyotp() # We check all seeds - a user is allowed multiple seeds (i.e. if they # have multiple devices). - for seed in seeds: - if seed.last_used_code == code: + for secret in secrets: + if secret.last_used_code == code: logger.warning( f"User {user_id} reused a token - potential replay attack." ) return False - totp = pyotp.TOTP(seed.secret) + totp = pyotp.TOTP(secret.secret) if totp.verify(code): - seed.last_used_at = datetime.datetime.now( + secret.last_used_at = datetime.datetime.now( tz=datetime.timezone.utc ) - seed.last_used_code = code - await seed.save(columns=[cls.last_used_at, cls.last_used_code]) + secret.last_used_code = code + await secret.save( + columns=[cls.last_used_at, cls.last_used_code] + ) return True diff --git a/tests/mfa/authenticator/test_tables.py b/tests/mfa/authenticator/test_tables.py index 663ec525..0605f600 100644 --- a/tests/mfa/authenticator/test_tables.py +++ b/tests/mfa/authenticator/test_tables.py @@ -1,5 +1,6 @@ import datetime from unittest import TestCase +from unittest.mock import MagicMock, patch from piccolo.apps.user.tables import BaseUser from piccolo.testing.test_case import AsyncTableTest @@ -21,6 +22,36 @@ def test_generate_secret(self): self.assertEqual(len(secret_1), 32) +class TestAuthenticate(AsyncTableTest): + + tables = [AuthenticatorSecret, BaseUser] + + @patch("piccolo_api.mfa.authenticator.tables.logger") + async def test_replay_attack(self, logger: MagicMock): + """ + If a token which was just used successfully is reused, it should be + rejected, because it might be a replay attack. + """ + user = await BaseUser.create_user( + username="test", password="test123456" + ) + + code = "123456" + + seed = await AuthenticatorSecret.create_new(user_id=user.id) + seed.last_used_code = code + await seed.save() + + auth_response = await AuthenticatorSecret.authenticate( + user_id=user.id, code=code + ) + assert auth_response is False + + logger.warning.assert_called_with( + "User 1 reused a token - potential replay attack." + ) + + class TestCreateNew(AsyncTableTest): tables = [AuthenticatorSecret, BaseUser] From e3141187c5cd42e84e7a8ec230a02ed987fede95 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Tue, 13 Aug 2024 05:49:38 +0100 Subject: [PATCH 044/102] add `generate_recovery_code` --- piccolo_api/mfa/recovery_codes.py | 52 +++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 piccolo_api/mfa/recovery_codes.py diff --git a/piccolo_api/mfa/recovery_codes.py b/piccolo_api/mfa/recovery_codes.py new file mode 100644 index 00000000..2d456b57 --- /dev/null +++ b/piccolo_api/mfa/recovery_codes.py @@ -0,0 +1,52 @@ +import math +import secrets +import string +import typing as t + +DEFAULT_CHARACTERS = string.ascii_lowercase + string.digits + + +def _get_random_string(length: int, characters: t.Sequence[str]) -> str: + """ + :param length: + How long to make the string. + :param characters: + Which characters to randomly pick from. + + """ + return "".join(secrets.choice(characters) for _ in range(length)) + + +def generate_recovery_code( + length: int = 12, + characters: t.Sequence[str] = DEFAULT_CHARACTERS, + separator: str = "-", +): + """ + :param length: + How long the recovery code should be, excluding the separator. Must + be at least 10 (it's unusual for a recovery code to be shorter than + this). + :param characters: + Which characters to randomly pick from. Recovery codes tend to be + case insensitive, and just use a-z and 0-9 (presumably to make them + less error prone for users). + :param separator: + The recovery code will have this character in the middle, making it + easier for users to read (e.g. ``abc123-xyz789``). Specify an empty + string if you want to disable this behaviour. + + """ + if length < 10: + raise ValueError("The length must be at least 10.") + + random_string = _get_random_string(length=length, characters=characters) + + if separator: + split_at = math.ceil(length / 2) + + return separator.join( + [random_string[:split_at], random_string[split_at:]] + ) + + return random_string From 24be31ad416388cdf53d7c1c9cc2f95e3fc4af00 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Tue, 13 Aug 2024 09:54:21 +0100 Subject: [PATCH 045/102] store recovery codes, and return recovery codes in endpoints (taken from @sinisaos example) --- ...uthenticator_2024_08_08t21_41_46_837552.py | 40 +++++++++++++++++- piccolo_api/mfa/authenticator/provider.py | 29 +++++++++---- piccolo_api/mfa/authenticator/tables.py | 41 +++++++++++++++++-- tests/mfa/authenticator/test_tables.py | 20 ++++----- 4 files changed, 108 insertions(+), 22 deletions(-) diff --git a/piccolo_api/mfa/authenticator/piccolo_migrations/mfa_authenticator_2024_08_08t21_41_46_837552.py b/piccolo_api/mfa/authenticator/piccolo_migrations/mfa_authenticator_2024_08_08t21_41_46_837552.py index 683969be..132eda53 100644 --- a/piccolo_api/mfa/authenticator/piccolo_migrations/mfa_authenticator_2024_08_08t21_41_46_837552.py +++ b/piccolo_api/mfa/authenticator/piccolo_migrations/mfa_authenticator_2024_08_08t21_41_46_837552.py @@ -1,5 +1,11 @@ from piccolo.apps.migrations.auto.migration_manager import MigrationManager -from piccolo.columns.column_types import Integer, Text, Timestamptz, Varchar +from piccolo.columns.column_types import ( + Array, + Integer, + Text, + Timestamptz, + Varchar, +) from piccolo.columns.defaults.timestamptz import TimestamptzNow from piccolo.columns.indexes import IndexMethod @@ -84,6 +90,38 @@ async def forwards(): schema=None, ) + manager.add_column( + table_class_name="AuthenticatorSecret", + tablename="authenticator_secret", + column_name="recovery_codes", + db_column_name="recovery_codes", + column_class_name="Array", + column_class=Array, + params={ + "base_column": Text( + default="", + null=False, + primary_key=False, + unique=False, + index=False, + index_method=IndexMethod.btree, + choices=None, + db_column_name=None, + secret=False, + ), + "default": list, + "null": False, + "primary_key": False, + "unique": False, + "index": False, + "index_method": IndexMethod.btree, + "choices": None, + "db_column_name": None, + "secret": False, + }, + schema=None, + ) + manager.add_column( table_class_name="AuthenticatorSecret", tablename="authenticator_secret", diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index 97b3f1b7..bf881b4e 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -43,11 +43,11 @@ async def send_code(self, *args, **kwargs): ########################################################################### # Registration - async def _generate_qrcode_image(self, user: BaseUser): - secret = await self.secret_table.create_new(user_id=user.id) - + async def _generate_qrcode_image( + self, secret: AuthenticatorSecret, email: str + ): uri = secret.get_authentication_setup_uri( - email=user.email, issuer_name=self.issuer_name + email=email, issuer_name=self.issuer_name ) return get_b64encoded_qr_image(data=uri) @@ -57,11 +57,21 @@ async def get_registration_html(self, user: BaseUser) -> str: When a user wants to register for MFA, this HTML is shown containing instructions. """ - qrcode_image = await self._generate_qrcode_image(user=user) # noqa + secret, recovery_codes = await self.secret_table.create_new( + user_id=user.id + ) + + qrcode_image = await self._generate_qrcode_image( + secret=secret, email=user.email + ) + + recovery_codes_str = "\n".join(recovery_codes) return f"""

Use an authenticator app like Google Authenticator to scan this QR code:

+

Copy these recovery codes and keep them safe:

+ """ # noqa: E501 async def get_registration_json(self, user: BaseUser) -> dict: @@ -69,7 +79,12 @@ async def get_registration_json(self, user: BaseUser) -> dict: When a user wants to register for MFA, the client can request a JSON response, rather than HTML, if they want to render the UI themselves. """ + secret, recovery_codes = await self.secret_table.create_new( + user_id=user.id + ) - qrcode_image = await self._generate_qrcode_image(user=user) + qrcode_image = await self._generate_qrcode_image( + secret=secret, email=user.email + ) - return {"qrcode_image": qrcode_image} + return {"qrcode_image": qrcode_image, "recovery_codes": recovery_codes} diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index 27eac831..74ff40fb 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -4,9 +4,12 @@ import logging import typing as t -from piccolo.columns import Integer, Serial, Text, Timestamptz, Varchar +from piccolo.apps.user.tables import BaseUser +from piccolo.columns import Array, Integer, Serial, Text, Timestamptz, Varchar from piccolo.table import Table +from piccolo_api.mfa.recovery_codes import generate_recovery_code + if t.TYPE_CHECKING: import pyotp @@ -39,6 +42,10 @@ class AuthenticatorSecret(Table): ), ) secret = Text(secret=True) + recovery_codes = Array( + Text(), + help_text="Used to gain temporary access, if they lose their phone.", + ) created_at = Timestamptz() revoked_at = Timestamptz(null=True, default=None) last_used_at = Timestamptz(null=True, default=None) @@ -56,12 +63,38 @@ def generate_secret(cls) -> str: return pyotp.random_base32() @classmethod - async def create_new(cls, user_id: int) -> AuthenticatorSecret: + async def create_new( + cls, user_id: int, recovery_code_count: int = 8 + ) -> t.Tuple[AuthenticatorSecret, t.List[str]]: + """ + Returns the new ``AuthenticatorSecret`` and the unhashed recovery + codes. This is the only time the unhashed recovery codes will be + accessible. + """ + recovery_codes = [ + generate_recovery_code() for _ in range(recovery_code_count) + ] + + # Use the hashing logic from BaseUser. + # We want to use the same salt for all of the user's recovery codes, + # otherwise logging in using a recovery code will take a long time. + salt = BaseUser.get_salt() + + hashed_recovery_codes = [ + BaseUser.hash_password(password=recovery_code, salt=salt) + for recovery_code in recovery_codes + ] + instance = cls( - {cls.user_id: user_id, cls.secret: cls.generate_secret()} + { + cls.user_id: user_id, + cls.secret: cls.generate_secret(), + cls.recovery_codes: hashed_recovery_codes, + } ) await instance.save() - return instance + + return (instance, recovery_codes) @classmethod async def authenticate(cls, user_id: int, code: str) -> bool: diff --git a/tests/mfa/authenticator/test_tables.py b/tests/mfa/authenticator/test_tables.py index 0605f600..00964d59 100644 --- a/tests/mfa/authenticator/test_tables.py +++ b/tests/mfa/authenticator/test_tables.py @@ -38,9 +38,9 @@ async def test_replay_attack(self, logger: MagicMock): code = "123456" - seed = await AuthenticatorSecret.create_new(user_id=user.id) - seed.last_used_code = code - await seed.save() + secret, _ = await AuthenticatorSecret.create_new(user_id=user.id) + secret.last_used_code = code + await secret.save() auth_response = await AuthenticatorSecret.authenticate( user_id=user.id, code=code @@ -61,11 +61,11 @@ async def test_create_new(self): username="test", password="test123456" ) - seed = await AuthenticatorSecret.create_new(user_id=user.id) + secret, _ = await AuthenticatorSecret.create_new(user_id=user.id) - self.assertEqual(seed.id, user.id) - self.assertIsNotNone(seed.secret) - self.assertIsInstance(seed.created_at, datetime.datetime) - self.assertIsNone(seed.last_used_at) - self.assertIsNone(seed.revoked_at) - self.assertIsNone(seed.last_used_code) + self.assertEqual(secret.id, user.id) + self.assertIsNotNone(secret.secret) + self.assertIsInstance(secret.created_at, datetime.datetime) + self.assertIsNone(secret.last_used_at) + self.assertIsNone(secret.revoked_at) + self.assertIsNone(secret.last_used_code) From 63e70fdae72ad1bc20d86497f06ae4a1ce331865 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Tue, 13 Aug 2024 11:08:04 +0100 Subject: [PATCH 046/102] make sure recovery codes can be used to login --- ...uthenticator_2024_08_08t21_41_46_837552.py | 32 +++++++ piccolo_api/mfa/authenticator/provider.py | 3 + piccolo_api/mfa/authenticator/tables.py | 92 ++++++++++++++----- piccolo_api/mfa/provider.py | 3 + piccolo_api/templates/session_login.html | 2 +- 5 files changed, 106 insertions(+), 26 deletions(-) diff --git a/piccolo_api/mfa/authenticator/piccolo_migrations/mfa_authenticator_2024_08_08t21_41_46_837552.py b/piccolo_api/mfa/authenticator/piccolo_migrations/mfa_authenticator_2024_08_08t21_41_46_837552.py index 132eda53..7d9153b9 100644 --- a/piccolo_api/mfa/authenticator/piccolo_migrations/mfa_authenticator_2024_08_08t21_41_46_837552.py +++ b/piccolo_api/mfa/authenticator/piccolo_migrations/mfa_authenticator_2024_08_08t21_41_46_837552.py @@ -117,6 +117,38 @@ async def forwards(): "index_method": IndexMethod.btree, "choices": None, "db_column_name": None, + "secret": True, + }, + schema=None, + ) + + manager.add_column( + table_class_name="AuthenticatorSecret", + tablename="authenticator_secret", + column_name="recovery_codes_used_at", + db_column_name="recovery_codes_used_at", + column_class_name="Array", + column_class=Array, + params={ + "base_column": Timestamptz( + default=TimestamptzNow(), + null=False, + primary_key=False, + unique=False, + index=False, + index_method=IndexMethod.btree, + choices=None, + db_column_name=None, + secret=False, + ), + "default": list, + "null": False, + "primary_key": False, + "unique": False, + "index": False, + "index_method": IndexMethod.btree, + "choices": None, + "db_column_name": None, "secret": False, }, schema=None, diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index bf881b4e..3d2bab87 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -29,6 +29,9 @@ def __init__( self.issuer_name = issuer_name async def authenticate_user(self, user: BaseUser, code: str) -> bool: + """ + The code could be a TOTP code, or a recovery code. + """ return await self.secret_table.authenticate(user_id=user.id, code=code) async def is_user_enrolled(self, user: BaseUser) -> bool: diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index 74ff40fb..13863119 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -45,6 +45,11 @@ class AuthenticatorSecret(Table): recovery_codes = Array( Text(), help_text="Used to gain temporary access, if they lose their phone.", + secret=True, + ) + recovery_codes_used_at = Array( + Timestamptz(), + help_text="Whenever a recovery code is used, store a timestamp here.", ) created_at = Timestamptz() revoked_at = Timestamptz(null=True, default=None) @@ -98,37 +103,74 @@ async def create_new( @classmethod async def authenticate(cls, user_id: int, code: str) -> bool: - secrets = await cls.objects().where( - cls.user_id == user_id, - cls.revoked_at.is_null(), + secret = ( + await cls.objects() + .where( + cls.user_id == user_id, + cls.revoked_at.is_null(), + ) + .order_by(cls.created_at, ascending=False) + .first() ) - if not secrets: + if secret is None: return False pyotp = get_pyotp() - # We check all seeds - a user is allowed multiple seeds (i.e. if they - # have multiple devices). - for secret in secrets: - if secret.last_used_code == code: - logger.warning( - f"User {user_id} reused a token - potential replay attack." - ) - return False - - totp = pyotp.TOTP(secret.secret) - - if totp.verify(code): - secret.last_used_at = datetime.datetime.now( - tz=datetime.timezone.utc - ) - secret.last_used_code = code - await secret.save( - columns=[cls.last_used_at, cls.last_used_code] - ) - - return True + if secret.last_used_code == code: + logger.warning( + f"User {user_id} reused a token - potential replay attack." + ) + return False + + totp = pyotp.TOTP(secret.secret) + + if totp.verify(code): + secret.last_used_at = datetime.datetime.now( + tz=datetime.timezone.utc + ) + secret.last_used_code = code + await secret.save(columns=[cls.last_used_at, cls.last_used_code]) + + return True + + ####################################################################### + # Check recovery code + + # Do a sanity check that it's roughly long enough. + if len(code) > 10 and (recovery_codes := secret.recovery_codes): + first_recovery_code = recovery_codes[0] + + # Get the algorithm, salt etc - they should be the same for each + # of the user's recovery codes, to save overhead. + _, iterations_, salt, _ = BaseUser.split_stored_password( + password=first_recovery_code + ) + + hashed_code = BaseUser.hash_password( + password=code, + salt=salt, + iterations=int(iterations_), + ) + + for recovery_code in recovery_codes: + if recovery_code == hashed_code: + # Remove the recovery code, and record when it was used. + secret.recovery_codes = [ + i for i in recovery_codes if i != recovery_code + ] + secret.recovery_codes_used_at.append( + datetime.datetime.now(tz=datetime.timezone.utc) + ) + await secret.save( + columns=[ + cls.recovery_codes, + cls.recovery_codes_used_at, + ] + ) + + return True return False diff --git a/piccolo_api/mfa/provider.py b/piccolo_api/mfa/provider.py index 8606ca39..26e54ab2 100644 --- a/piccolo_api/mfa/provider.py +++ b/piccolo_api/mfa/provider.py @@ -19,6 +19,9 @@ def __init__(self, token_name: str = "mfa_code"): async def authenticate_user(self, user: BaseUser, code: str) -> bool: """ Should return ``True`` if the code is correct for the user. + + The code could be a TOTP code, or a recovery code. + """ pass diff --git a/piccolo_api/templates/session_login.html b/piccolo_api/templates/session_login.html index b7ffe96b..9d166b78 100644 --- a/piccolo_api/templates/session_login.html +++ b/piccolo_api/templates/session_login.html @@ -16,7 +16,7 @@

Login

{% if show_mfa_input %} - + {% endif %} From a53f51a6183d91a712b1a7d4e452eac67b24e72e Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Tue, 13 Aug 2024 11:10:00 +0100 Subject: [PATCH 047/102] ignore mypy warnings for now --- piccolo_api/mfa/authenticator/tables.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index 13863119..9889c8d4 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -17,7 +17,7 @@ logger = logging.getLogger(__name__) -def get_pyotp() -> pyotp: +def get_pyotp() -> pyotp: # type: ignore try: import pyotp except ImportError as e: @@ -65,7 +65,7 @@ class AuthenticatorSecret(Table): @classmethod def generate_secret(cls) -> str: pyotp = get_pyotp() - return pyotp.random_base32() + return pyotp.random_base32() # type: ignore @classmethod async def create_new( @@ -124,7 +124,7 @@ async def authenticate(cls, user_id: int, code: str) -> bool: ) return False - totp = pyotp.TOTP(secret.secret) + totp = pyotp.TOTP(secret.secret) # type: ignore if totp.verify(code): secret.last_used_at = datetime.datetime.now( @@ -183,6 +183,6 @@ def get_authentication_setup_uri( ) -> str: pyotp = get_pyotp() - return pyotp.totp.TOTP(self.secret).provisioning_uri( + return pyotp.totp.TOTP(self.secret).provisioning_uri( # type: ignore name=email, issuer_name=issuer_name ) From 2d714e1614aa28b092e6895177da2383554cd192 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Tue, 13 Aug 2024 11:16:58 +0100 Subject: [PATCH 048/102] install pyotp in CI --- .github/workflows/tests.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 6ae7c3a9..20a81365 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -61,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: @@ -88,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 From 95e67547b25c2ed39b669e627f522e0c19584539 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Tue, 13 Aug 2024 12:15:41 +0100 Subject: [PATCH 049/102] endpoint test WIP --- tests/mfa/test_mfa_endpoints.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tests/mfa/test_mfa_endpoints.py diff --git a/tests/mfa/test_mfa_endpoints.py b/tests/mfa/test_mfa_endpoints.py new file mode 100644 index 00000000..3a23256c --- /dev/null +++ b/tests/mfa/test_mfa_endpoints.py @@ -0,0 +1,30 @@ +from piccolo.testing.test_case import AsyncTableTest +from starlette.applications import Starlette +from starlette.routing import Route +from starlette.testclient import TestClient + +from piccolo_api.mfa.authenticator.provider import AuthenticatorProvider +from piccolo_api.mfa.authenticator.tables import AuthenticatorSecret +from piccolo_api.mfa.endpoints import mfa_register_endpoint + + +class TestMFARegisterEndpoint(AsyncTableTest): + + tables = [AuthenticatorSecret] + + async def test_register(self): + # Rather than setting all of this up ... what if I use the example app? + app = Starlette( + routes=[ + Route( + path="/register/", + endpoint=mfa_register_endpoint( + provider=AuthenticatorProvider() + ), + ) + ] + ) + + client = TestClient(app=app) + + client.get("/register/") From 64ef81b5fd588cab1a4890ba83287c72482a3632 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Tue, 13 Aug 2024 14:11:30 +0100 Subject: [PATCH 050/102] update `TestMFARegisterEndpoint` --- example_projects/__init__.py | 0 tests/mfa/test_mfa_endpoints.py | 50 +++++++++++++++++++++------------ 2 files changed, 32 insertions(+), 18 deletions(-) create mode 100644 example_projects/__init__.py diff --git a/example_projects/__init__.py b/example_projects/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/mfa/test_mfa_endpoints.py b/tests/mfa/test_mfa_endpoints.py index 3a23256c..e74bccfd 100644 --- a/tests/mfa/test_mfa_endpoints.py +++ b/tests/mfa/test_mfa_endpoints.py @@ -1,30 +1,44 @@ +from piccolo.apps.user.tables import BaseUser from piccolo.testing.test_case import AsyncTableTest -from starlette.applications import Starlette -from starlette.routing import Route from starlette.testclient import TestClient -from piccolo_api.mfa.authenticator.provider import AuthenticatorProvider +from example_projects.mfa_demo.app import app from piccolo_api.mfa.authenticator.tables import AuthenticatorSecret -from piccolo_api.mfa.endpoints import mfa_register_endpoint +from piccolo_api.session_auth.tables import SessionsBase class TestMFARegisterEndpoint(AsyncTableTest): - tables = [AuthenticatorSecret] - - async def test_register(self): - # Rather than setting all of this up ... what if I use the example app? - app = Starlette( - routes=[ - Route( - path="/register/", - endpoint=mfa_register_endpoint( - provider=AuthenticatorProvider() - ), - ) - ] + tables = [AuthenticatorSecret, BaseUser, SessionsBase] + username = "alice" + password = "test123" + + async def asyncSetUp(self) -> None: + await super().asyncSetUp() + + self.user = await BaseUser.create_user( + username=self.username, password=self.password, active=True ) + async def test_register_json(self): client = TestClient(app=app) - client.get("/register/") + # Get a CSRF cookie + response = client.get("/login/") + csrf_token = response.cookies["csrftoken"] + self.assertEqual(response.status_code, 200) + + # Login + response = client.post( + "/login/", + json={"username": self.username, "password": self.password}, + headers={"X-CSRFToken": csrf_token}, + ) + self.assertEqual(response.status_code, 200) + self.assertIn("id", client.cookies) + + # TODO - rather than a GET param, can we pass in a content header? + response = client.get("/private/mfa-register/?format=json") + data = response.json() + self.assertIn("qrcode_image", data) + self.assertIn("recovery_codes", data) From c3a302942534715cd23e87467e8cc5e4709ce6dc Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Tue, 13 Aug 2024 14:15:06 +0100 Subject: [PATCH 051/102] remove todo --- tests/mfa/test_mfa_endpoints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/mfa/test_mfa_endpoints.py b/tests/mfa/test_mfa_endpoints.py index e74bccfd..fbf0f397 100644 --- a/tests/mfa/test_mfa_endpoints.py +++ b/tests/mfa/test_mfa_endpoints.py @@ -37,7 +37,7 @@ async def test_register_json(self): self.assertEqual(response.status_code, 200) self.assertIn("id", client.cookies) - # TODO - rather than a GET param, can we pass in a content header? + # Register for MFA response = client.get("/private/mfa-register/?format=json") data = response.json() self.assertIn("qrcode_image", data) From 2efd8c9b091b17df118f17d13e69c904c75f3fbb Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Wed, 14 Aug 2024 11:24:23 +0100 Subject: [PATCH 052/102] encrypt secret in db --- example_projects/mfa_demo/app.py | 17 ++++- piccolo_api/mfa/authenticator/provider.py | 26 +++++-- piccolo_api/mfa/authenticator/tables.py | 84 +++++++++++++++++++++-- requirements/extras/authenticator.txt | 1 + tests/mfa/authenticator/test_tables.py | 15 +++- 5 files changed, 128 insertions(+), 15 deletions(-) diff --git a/example_projects/mfa_demo/app.py b/example_projects/mfa_demo/app.py index bc4a47e5..17db03be 100644 --- a/example_projects/mfa_demo/app.py +++ b/example_projects/mfa_demo/app.py @@ -13,6 +13,8 @@ from piccolo_api.session_auth.endpoints import session_login, session_logout from piccolo_api.session_auth.middleware import SessionsAuthBackend +EXAMPLE_DB_ENCRYPTION_KEY = "wqsOqyTTEsrWppZeIMS8a3l90yPUtrqT48z7FS6_U8g=" + class HomeEndpoint(HTTPEndpoint): async def get(self, request): @@ -49,7 +51,11 @@ def on_auth_error(request: Request, exc: Exception): Route("/logout/", session_logout(redirect_to="/")), Route( "/mfa-register/", - mfa_register_endpoint(provider=AuthenticatorProvider()), + mfa_register_endpoint( + provider=AuthenticatorProvider( + db_encryption_key=EXAMPLE_DB_ENCRYPTION_KEY + ) + ), ), ], middleware=[ @@ -67,7 +73,14 @@ def on_auth_error(request: Request, exc: Exception): routes=[ Route("/", HomeEndpoint), Route( - "/login/", session_login(mfa_providers=[AuthenticatorProvider()]) + "/login/", + session_login( + mfa_providers=[ + AuthenticatorProvider( + db_encryption_key=EXAMPLE_DB_ENCRYPTION_KEY + ) + ] + ), ), Route( "/register/", diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index 3d2bab87..1da6198a 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -11,10 +11,17 @@ class AuthenticatorProvider(MFAProvider): def __init__( self, + db_encryption_key: str, + recovery_code_count: int = 8, secret_table: t.Type[AuthenticatorSecret] = AuthenticatorSecret, issuer_name: str = "Piccolo-MFA", ): """ + :param db_encryption_key: + The shared secrets are encrypted in the database - pass in a random + string which is used for encrypting them. + :param recovery_code_count: + How many recovery codes should be generated. :param seed_table: By default, just use the out of the box ``AuthenticatorSecret`` table - you can specify a subclass instead if you want to override @@ -25,6 +32,8 @@ def __init__( """ super().__init__(token_name="authenticator_token") + self.db_encryption_key = db_encryption_key + self.recovery_code_count = recovery_code_count self.secret_table = secret_table self.issuer_name = issuer_name @@ -32,7 +41,11 @@ async def authenticate_user(self, user: BaseUser, code: str) -> bool: """ The code could be a TOTP code, or a recovery code. """ - return await self.secret_table.authenticate(user_id=user.id, code=code) + return await self.secret_table.authenticate( + user_id=user.id, + code=code, + db_encryption_key=self.db_encryption_key, + ) async def is_user_enrolled(self, user: BaseUser) -> bool: return await self.secret_table.is_user_enrolled(user_id=user.id) @@ -50,7 +63,9 @@ async def _generate_qrcode_image( self, secret: AuthenticatorSecret, email: str ): uri = secret.get_authentication_setup_uri( - email=email, issuer_name=self.issuer_name + email=email, + db_encryption_key=self.db_encryption_key, + issuer_name=self.issuer_name, ) return get_b64encoded_qr_image(data=uri) @@ -61,7 +76,9 @@ async def get_registration_html(self, user: BaseUser) -> str: instructions. """ secret, recovery_codes = await self.secret_table.create_new( - user_id=user.id + user_id=user.id, + db_encryption_key=self.db_encryption_key, + recovery_code_count=self.recovery_code_count, ) qrcode_image = await self._generate_qrcode_image( @@ -83,7 +100,8 @@ async def get_registration_json(self, user: BaseUser) -> dict: response, rather than HTML, if they want to render the UI themselves. """ secret, recovery_codes = await self.secret_table.create_new( - user_id=user.id + user_id=user.id, + db_encryption_key=self.db_encryption_key, ) qrcode_image = await self._generate_qrcode_image( diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index 9889c8d4..1626e8db 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -4,6 +4,7 @@ import logging import typing as t +import cryptography.fernet from piccolo.apps.user.tables import BaseUser from piccolo.columns import Array, Integer, Serial, Text, Timestamptz, Varchar from piccolo.table import Table @@ -11,6 +12,7 @@ from piccolo_api.mfa.recovery_codes import generate_recovery_code if t.TYPE_CHECKING: + import cryptography import pyotp @@ -30,6 +32,33 @@ def get_pyotp() -> pyotp: # type: ignore return pyotp +def get_cryptography() -> cryptography: # type: ignore + try: + import cryptography + except ImportError as e: + print( + "Install pip install piccolo_api[authenticator] to use this " + "feature." + ) + raise e + + return cryptography + + +def _encrypt(value: str, db_encryption_key: str) -> str: + cryptography = get_cryptography() + fernet = cryptography.fernet.Fernet(db_encryption_key) + encrypted_value = fernet.encrypt(value.encode("utf8")) + return encrypted_value.decode("utf8") + + +def _decrypt(encrypted_value: str, db_encryption_key: str) -> str: + cryptography = get_cryptography() + fernet = cryptography.fernet.Fernet(db_encryption_key) + value = fernet.decrypt(encrypted_value.encode("utf8")) + return value.decode("utf8") + + class AuthenticatorSecret(Table): id: Serial # TODO - we might change this to a UUID primary key user_id = Integer(null=False) @@ -69,17 +98,36 @@ def generate_secret(cls) -> str: @classmethod async def create_new( - cls, user_id: int, recovery_code_count: int = 8 + cls, + user_id: int, + db_encryption_key: str, + recovery_code_count: int = 8, ) -> t.Tuple[AuthenticatorSecret, t.List[str]]: """ Returns the new ``AuthenticatorSecret`` and the unhashed recovery codes. This is the only time the unhashed recovery codes will be accessible. + + :param user_id: + The user to create the secret for. + :param db_encryption_key: + The secret is encrypted in the database - ``db_encryption_key`` + can be any reasonably random string. It provides a little extra + protection vs storing the secret in plain text. + :param recovery_code_count: + How many recovery codes to generate for the user - this allows + them to still gain access if their phone is lost. + """ + # Generate recovery codes + recovery_codes = [ generate_recovery_code() for _ in range(recovery_code_count) ] + ####################################################################### + # Hash the recovery codes + # Use the hashing logic from BaseUser. # We want to use the same salt for all of the user's recovery codes, # otherwise logging in using a recovery code will take a long time. @@ -90,10 +138,22 @@ async def create_new( for recovery_code in recovery_codes ] + ####################################################################### + # Generate a shared secret + + secret = cls.generate_secret() + + # We'll encrypt the secret for storing in the database. + encrypted_secret = _encrypt( + value=secret, db_encryption_key=db_encryption_key + ) + + ####################################################################### + instance = cls( { cls.user_id: user_id, - cls.secret: cls.generate_secret(), + cls.secret: encrypted_secret, cls.recovery_codes: hashed_recovery_codes, } ) @@ -102,7 +162,9 @@ async def create_new( return (instance, recovery_codes) @classmethod - async def authenticate(cls, user_id: int, code: str) -> bool: + async def authenticate( + cls, user_id: int, code: str, db_encryption_key: str + ) -> bool: secret = ( await cls.objects() .where( @@ -124,7 +186,10 @@ async def authenticate(cls, user_id: int, code: str) -> bool: ) return False - totp = pyotp.TOTP(secret.secret) # type: ignore + shared_secret = _decrypt( + encrypted_value=secret.secret, db_encryption_key=db_encryption_key + ) + totp = pyotp.TOTP(shared_secret) # type: ignore if totp.verify(code): secret.last_used_at = datetime.datetime.now( @@ -179,10 +244,17 @@ async def is_user_enrolled(cls, user_id: int) -> bool: return await cls.exists().where(cls.user_id == user_id) def get_authentication_setup_uri( - self, email: str, issuer_name: str = "Piccolo-MFA" + self, + email: str, + db_encryption_key: str, + issuer_name: str = "Piccolo-MFA", ) -> str: pyotp = get_pyotp() - return pyotp.totp.TOTP(self.secret).provisioning_uri( # type: ignore + shared_secret = _decrypt( + encrypted_value=self.secret, db_encryption_key=db_encryption_key + ) + + return pyotp.totp.TOTP(shared_secret).provisioning_uri( # type: ignore name=email, issuer_name=issuer_name ) diff --git a/requirements/extras/authenticator.txt b/requirements/extras/authenticator.txt index 55bd1192..5c4c2489 100644 --- a/requirements/extras/authenticator.txt +++ b/requirements/extras/authenticator.txt @@ -1,2 +1,3 @@ pyotp==2.9.0 qrcode==7.4.2 +cryptography==43.0.0 diff --git a/tests/mfa/authenticator/test_tables.py b/tests/mfa/authenticator/test_tables.py index 00964d59..db988037 100644 --- a/tests/mfa/authenticator/test_tables.py +++ b/tests/mfa/authenticator/test_tables.py @@ -5,6 +5,7 @@ from piccolo.apps.user.tables import BaseUser from piccolo.testing.test_case import AsyncTableTest +from example_projects.mfa_demo.app import EXAMPLE_DB_ENCRYPTION_KEY from piccolo_api.mfa.authenticator.tables import AuthenticatorSecret @@ -38,12 +39,17 @@ async def test_replay_attack(self, logger: MagicMock): code = "123456" - secret, _ = await AuthenticatorSecret.create_new(user_id=user.id) + secret, _ = await AuthenticatorSecret.create_new( + user_id=user.id, + db_encryption_key=EXAMPLE_DB_ENCRYPTION_KEY, + ) secret.last_used_code = code await secret.save() auth_response = await AuthenticatorSecret.authenticate( - user_id=user.id, code=code + user_id=user.id, + code=code, + db_encryption_key=EXAMPLE_DB_ENCRYPTION_KEY, ) assert auth_response is False @@ -61,7 +67,10 @@ async def test_create_new(self): username="test", password="test123456" ) - secret, _ = await AuthenticatorSecret.create_new(user_id=user.id) + secret, _ = await AuthenticatorSecret.create_new( + user_id=user.id, + db_encryption_key=EXAMPLE_DB_ENCRYPTION_KEY, + ) self.assertEqual(secret.id, user.id) self.assertIsNotNone(secret.secret) From 1e6dc9b10667ed1930b503570ae12340306384c7 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Wed, 14 Aug 2024 11:47:18 +0100 Subject: [PATCH 053/102] use a proper template for MFA sign up --- piccolo_api/mfa/authenticator/provider.py | 32 +++++++++++++++---- piccolo_api/mfa/authenticator/tables.py | 4 +-- piccolo_api/templates/base.html | 5 +++ .../templates/mfa_authenticator_setup.html | 13 ++++++++ 4 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 piccolo_api/templates/mfa_authenticator_setup.html diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index 1da6198a..bb6ed1a5 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -1,10 +1,19 @@ +import os import typing as t +from jinja2 import Environment, FileSystemLoader from piccolo.apps.user.tables import BaseUser from piccolo_api.mfa.authenticator.tables import AuthenticatorSecret from piccolo_api.mfa.authenticator.utils import get_b64encoded_qr_image from piccolo_api.mfa.provider import MFAProvider +from piccolo_api.shared.auth.styles import Styles + +MFA_SETUP_TEMPLATE_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + "templates", + "mfa_authenticator_setup.html", +) class AuthenticatorProvider(MFAProvider): @@ -15,6 +24,8 @@ def __init__( recovery_code_count: int = 8, secret_table: t.Type[AuthenticatorSecret] = AuthenticatorSecret, issuer_name: str = "Piccolo-MFA", + register_template_path: t.Optional[str] = None, + styles: t.Optional[Styles] = None, ): """ :param db_encryption_key: @@ -36,6 +47,10 @@ def __init__( self.recovery_code_count = recovery_code_count self.secret_table = secret_table self.issuer_name = issuer_name + self.register_template_path = ( + register_template_path or MFA_SETUP_TEMPLATE_PATH + ) + self.styles = styles or Styles() async def authenticate_user(self, user: BaseUser, code: str) -> bool: """ @@ -87,12 +102,17 @@ async def get_registration_html(self, user: BaseUser) -> str: recovery_codes_str = "\n".join(recovery_codes) - return f""" -

Use an authenticator app like Google Authenticator to scan this QR code:

- -

Copy these recovery codes and keep them safe:

- - """ # noqa: E501 + directory, filename = os.path.split(self.register_template_path) + environment = Environment( + loader=FileSystemLoader(directory), autoescape=True + ) + register_template = environment.get_template(filename) + + return register_template.render( + qrcode_image=qrcode_image, + recovery_codes_str=recovery_codes_str, + styles=self.styles, + ) async def get_registration_json(self, user: BaseUser) -> dict: """ diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index 1626e8db..7aa21002 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -47,14 +47,14 @@ def get_cryptography() -> cryptography: # type: ignore def _encrypt(value: str, db_encryption_key: str) -> str: cryptography = get_cryptography() - fernet = cryptography.fernet.Fernet(db_encryption_key) + fernet = cryptography.fernet.Fernet(db_encryption_key) # type: ignore encrypted_value = fernet.encrypt(value.encode("utf8")) return encrypted_value.decode("utf8") def _decrypt(encrypted_value: str, db_encryption_key: str) -> str: cryptography = get_cryptography() - fernet = cryptography.fernet.Fernet(db_encryption_key) + fernet = cryptography.fernet.Fernet(db_encryption_key) # type: ignore value = fernet.decrypt(encrypted_value.encode("utf8")) return value.decode("utf8") diff --git a/piccolo_api/templates/base.html b/piccolo_api/templates/base.html index b9c34209..20090093 100644 --- a/piccolo_api/templates/base.html +++ b/piccolo_api/templates/base.html @@ -85,6 +85,11 @@ margin: 0.5rem 0 0.8rem; } + textarea { + width: 100%; + max-width: 100%; + } + button { background-color: var(--button_color); border: none; diff --git a/piccolo_api/templates/mfa_authenticator_setup.html b/piccolo_api/templates/mfa_authenticator_setup.html new file mode 100644 index 00000000..a3620b51 --- /dev/null +++ b/piccolo_api/templates/mfa_authenticator_setup.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %}MFA Authenticator Setup{% endblock %} + +{% block content %} +

MFA Authenticator Setup

+ +

Use an authenticator app like Google Authenticator to scan this QR code:

+ + +

Copy these recovery codes and keep them safe:

+ +{% endblock %} From 47744ddee9fe1fb43ac45ec91e0cd427f6fef4b6 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Wed, 14 Aug 2024 12:17:17 +0100 Subject: [PATCH 054/102] add links for where to download the authenticator app, and add JS for copying to clipboard --- piccolo_api/mfa/authenticator/provider.py | 24 +++++++++---------- piccolo_api/shared/auth/styles.py | 1 + piccolo_api/templates/base.html | 10 ++++++++ .../templates/mfa_authenticator_setup.html | 18 ++++++++++---- 4 files changed, 36 insertions(+), 17 deletions(-) diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index bb6ed1a5..e002c9c4 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -47,10 +47,17 @@ def __init__( self.recovery_code_count = recovery_code_count self.secret_table = secret_table self.issuer_name = issuer_name - self.register_template_path = ( + self.styles = styles or Styles() + + # Load the Jinja Template + register_template_path = ( register_template_path or MFA_SETUP_TEMPLATE_PATH ) - self.styles = styles or Styles() + directory, filename = os.path.split(register_template_path) + environment = Environment( + loader=FileSystemLoader(directory), autoescape=True + ) + self.register_template = environment.get_template(filename) async def authenticate_user(self, user: BaseUser, code: str) -> bool: """ @@ -100,17 +107,10 @@ async def get_registration_html(self, user: BaseUser) -> str: secret=secret, email=user.email ) - recovery_codes_str = "\n".join(recovery_codes) - - directory, filename = os.path.split(self.register_template_path) - environment = Environment( - loader=FileSystemLoader(directory), autoescape=True - ) - register_template = environment.get_template(filename) - - return register_template.render( + return self.register_template.render( qrcode_image=qrcode_image, - recovery_codes_str=recovery_codes_str, + recovery_codes=recovery_codes, + recovery_codes_str="\n".join(recovery_codes), styles=self.styles, ) diff --git a/piccolo_api/shared/auth/styles.py b/piccolo_api/shared/auth/styles.py index 6a6b5be5..b5d082b1 100644 --- a/piccolo_api/shared/auth/styles.py +++ b/piccolo_api/shared/auth/styles.py @@ -19,4 +19,5 @@ class Styles: error_text_color: str = "red" button_color: str = "#419EF8" button_text_color: str = "white" + link_color: str = "#419EF8" border_color: str = "rgba(0, 0, 0, 0.2)" diff --git a/piccolo_api/templates/base.html b/piccolo_api/templates/base.html index 20090093..755c796f 100644 --- a/piccolo_api/templates/base.html +++ b/piccolo_api/templates/base.html @@ -18,6 +18,7 @@ --error_text_color: {{ styles.error_text_color }}; --button_color: {{ styles.button_color }}; --button_text_color: {{ styles.button_text_color }}; + --link_color: {{ styles.link_color }}; --border_color: {{ styles.border_color }}; } @@ -51,6 +52,11 @@ font-size: 1.8rem; } + a { + color: var(--link_color); + text-decoration: none; + } + p.error { font-size: 0.9rem; color: var(--error_text_color); @@ -106,6 +112,10 @@ div.captcha { margin-bottom: 0.5rem; } + + div.qr_code { + text-align: center; + } diff --git a/piccolo_api/templates/mfa_authenticator_setup.html b/piccolo_api/templates/mfa_authenticator_setup.html index a3620b51..714859f7 100644 --- a/piccolo_api/templates/mfa_authenticator_setup.html +++ b/piccolo_api/templates/mfa_authenticator_setup.html @@ -3,11 +3,19 @@ {% block title %}MFA Authenticator Setup{% endblock %} {% block content %} -

MFA Authenticator Setup

+

Authenticator Setup

-

Use an authenticator app like Google Authenticator to scan this QR code:

- +

Use an authenticator app like Google Authenticator, available on iOS and Android, to scan this QR code:

-

Copy these recovery codes and keep them safe:

- +
+ +
+ +

Copy these recovery codes and keep them safe:

+ +
    + {% for recovery_code in recovery_codes %} +
  • {{ recovery_code }}
  • + {% endfor %} +
{% endblock %} From ff806e9ce35e1ddd7c9132e793d528f5b9aa1385 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Wed, 14 Aug 2024 13:08:10 +0100 Subject: [PATCH 055/102] also test HTML register endpoint --- tests/mfa/test_mfa_endpoints.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/mfa/test_mfa_endpoints.py b/tests/mfa/test_mfa_endpoints.py index fbf0f397..5ad0f0e8 100644 --- a/tests/mfa/test_mfa_endpoints.py +++ b/tests/mfa/test_mfa_endpoints.py @@ -20,7 +20,7 @@ async def asyncSetUp(self) -> None: username=self.username, password=self.password, active=True ) - async def test_register_json(self): + async def test_register(self): client = TestClient(app=app) # Get a CSRF cookie @@ -37,8 +37,17 @@ async def test_register_json(self): self.assertEqual(response.status_code, 200) self.assertIn("id", client.cookies) - # Register for MFA + # Register for MFA - JSON response = client.get("/private/mfa-register/?format=json") + self.assertEqual(response.status_code, 200) + data = response.json() self.assertIn("qrcode_image", data) self.assertIn("recovery_codes", data) + + # Register for MFA - HTML + response = client.get("/private/mfa-register/") + + self.assertEqual(response.status_code, 200) + html = response.content + self.assertIn(b"Authenticator Setup", html) From 605d09b74d27d8263981e96146648f32fbdbb22e Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 15 Aug 2024 12:01:27 +0100 Subject: [PATCH 056/102] add playwright tests --- .gitignore | 3 + e2e/__init__.py | 0 e2e/conftest.py | 62 ++++++++++++++++++ e2e/pages.py | 57 +++++++++++++++++ e2e/test_mfa.py | 19 ++++++ example_projects/mfa_demo/app.py | 24 +++---- example_projects/mfa_demo/main.py | 16 +++++ example_projects/mfa_demo/templates/home.html | 24 +++++++ piccolo_api/mfa/authenticator/provider.py | 11 ++++ piccolo_api/mfa/authenticator/tables.py | 12 +++- piccolo_api/mfa/endpoints.py | 63 ++++++++++++++----- piccolo_api/mfa/provider.py | 7 +++ .../templates/mfa_authenticator_setup.html | 33 +++++++--- requirements/e2e-requirements.txt | 3 + scripts/run-e2e-test.sh | 6 ++ scripts/test-postgres.sh | 2 +- scripts/test-sqlite.sh | 2 +- tests/mfa/test_mfa_endpoints.py | 13 +++- 18 files changed, 316 insertions(+), 41 deletions(-) create mode 100644 e2e/__init__.py create mode 100644 e2e/conftest.py create mode 100644 e2e/pages.py create mode 100644 e2e/test_mfa.py create mode 100644 example_projects/mfa_demo/templates/home.html create mode 100644 requirements/e2e-requirements.txt create mode 100755 scripts/run-e2e-test.sh diff --git a/.gitignore b/.gitignore index 21e7046b..1e200d95 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ docs/source/_build/ example_projects/token_auth/ .env/ .venv/ + +# Playwright +videos/ diff --git a/e2e/__init__.py b/e2e/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/e2e/conftest.py b/e2e/conftest.py new file mode 100644 index 00000000..84def3cb --- /dev/null +++ b/e2e/conftest.py @@ -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() diff --git a/e2e/pages.py b/e2e/pages.py new file mode 100644 index 00000000..cf248ff4 --- /dev/null +++ b/e2e/pages.py @@ -0,0 +1,57 @@ +""" +By using pages we can make out test more scalable. + +https://playwright.dev/docs/pom +""" + +from playwright.sync_api import Page + + +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): + self.username_input.fill("piccolo") + self.password_input.fill("piccolo123") + 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): + self.username_input.fill("piccolo") + self.email_input.fill("test@piccolo-orm.com") + self.password_input.fill("piccolo123") + self.confirm_password_input.fill("piccolo123") + self.button.click() + + +class MFARegisterPage: + url = "http://localhost:8000/private/mfa-register/" + + def __init__(self, page: Page): + self.page = page + + def reset(self): + self.page.goto(self.url) diff --git a/e2e/test_mfa.py b/e2e/test_mfa.py new file mode 100644 index 00000000..2bcd6e02 --- /dev/null +++ b/e2e/test_mfa.py @@ -0,0 +1,19 @@ +from playwright.async_api import Page + +from .pages import LoginPage, MFARegisterPage, RegisterPage + + +def test_login(page: Page, mfa_app): + """ + Make sure we can register, 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_register_page = MFARegisterPage(page=page) + mfa_register_page.reset() diff --git a/example_projects/mfa_demo/app.py b/example_projects/mfa_demo/app.py index 17db03be..e7094576 100644 --- a/example_projects/mfa_demo/app.py +++ b/example_projects/mfa_demo/app.py @@ -1,3 +1,6 @@ +import os + +from jinja2 import Environment, FileSystemLoader from starlette.applications import Starlette from starlette.endpoints import HTTPEndpoint from starlette.middleware import Middleware @@ -16,19 +19,18 @@ EXAMPLE_DB_ENCRYPTION_KEY = "wqsOqyTTEsrWppZeIMS8a3l90yPUtrqT48z7FS6_U8g=" +environment = Environment( + loader=FileSystemLoader( + os.path.join(os.path.dirname(__file__), "templates"), + ) +) + + class HomeEndpoint(HTTPEndpoint): async def get(self, request): - return HTMLResponse( - content=( - "" - "

MFA Demo

" - '

First register

' # noqa: E501 - '

Then login

' # noqa: E501 - '

Then sign up for MFA

' # noqa: E501 - '

Then try the private page

' # noqa: E501 - '

And logout

' # noqa: E501 - ) - ) + home_template = environment.get_template("home.html") + + return HTMLResponse(content=home_template.render()) class PrivateEndpoint(HTTPEndpoint): diff --git a/example_projects/mfa_demo/main.py b/example_projects/mfa_demo/main.py index 17b75904..f28f4e1a 100644 --- a/example_projects/mfa_demo/main.py +++ b/example_projects/mfa_demo/main.py @@ -5,8 +5,24 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) +def reset_db(): + print("Resetting DB ...") + + from piccolo.apps.user.tables import BaseUser + + from piccolo_api.mfa.authenticator.tables import AuthenticatorSecret + from piccolo_api.session_auth.tables import SessionsBase + + BaseUser.delete(force=True).run_sync() + AuthenticatorSecret.delete(force=True).run_sync() + SessionsBase.delete(force=True).run_sync() + + if __name__ == "__main__": + if "--reset-db" in sys.argv: + reset_db() + import uvicorn uvicorn.run("app:app", reload=True) diff --git a/example_projects/mfa_demo/templates/home.html b/example_projects/mfa_demo/templates/home.html new file mode 100644 index 00000000..4cd614be --- /dev/null +++ b/example_projects/mfa_demo/templates/home.html @@ -0,0 +1,24 @@ + + + + + + Home + + + + + +

MFA Demo

+

First register

+

Then login

+

Then sign up for MFA

+

Then try the private page

+

And logout

+ + + diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index e002c9c4..2d5938b4 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -97,6 +97,13 @@ async def get_registration_html(self, user: BaseUser) -> str: When a user wants to register for MFA, this HTML is shown containing instructions. """ + # If the user is already enrolled, don't create a new secret. + if await self.secret_table.is_user_enrolled(user_id=user.id): + return self.register_template.render( + already_enrolled=True, + styles=self.styles, + ) + secret, recovery_codes = await self.secret_table.create_new( user_id=user.id, db_encryption_key=self.db_encryption_key, @@ -129,3 +136,7 @@ async def get_registration_json(self, user: BaseUser) -> dict: ) return {"qrcode_image": qrcode_image, "recovery_codes": recovery_codes} + + async def delete_registration(self, user: BaseUser) -> str: + await self.secret_table.revoke_all(user_id=user.id) + return "

Successfully deleted

" diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index 7aa21002..f9aae877 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -161,6 +161,14 @@ async def create_new( return (instance, recovery_codes) + @classmethod + async def revoke_all(cls, user_id: int): + now = datetime.datetime.now(tz=datetime.timezone.utc) + await cls.update({cls.revoked_at: now}).where( + cls.user_id == user_id, + cls.revoked_at.is_null(), + ) + @classmethod async def authenticate( cls, user_id: int, code: str, db_encryption_key: str @@ -241,7 +249,9 @@ async def authenticate( @classmethod async def is_user_enrolled(cls, user_id: int) -> bool: - return await cls.exists().where(cls.user_id == user_id) + return await cls.exists().where( + cls.user_id == user_id, cls.revoked_at.is_null() + ) def get_authentication_setup_uri( self, diff --git a/piccolo_api/mfa/endpoints.py b/piccolo_api/mfa/endpoints.py index ab3f969a..af15db3d 100644 --- a/piccolo_api/mfa/endpoints.py +++ b/piccolo_api/mfa/endpoints.py @@ -1,6 +1,8 @@ import typing as t from abc import ABCMeta, abstractmethod +from json import JSONDecodeError +from piccolo.apps.user.tables import BaseUser from starlette.endpoints import HTTPEndpoint from starlette.requests import Request from starlette.responses import HTMLResponse, JSONResponse @@ -16,23 +18,54 @@ def _provider(self) -> MFAProvider: raise NotImplementedError async def get(self, request: Request): - piccolo_user = request.user.user - - if request.query_params.get("format") == "json": - json_content = await self._provider.get_registration_json( - user=piccolo_user - ) - return JSONResponse(content=json_content) - else: - html_content = await self._provider.get_registration_html( - user=piccolo_user - ) - return HTMLResponse(content=html_content) + return HTMLResponse( + content=f""" +
+ + + +
+ """ # noqa: E501 + ) async def post(self, request: Request): - # TODO - we might need the user to confirm once they're setup. - # We could embed the ID of the row in the HTML response (in a form). - pass + piccolo_user: BaseUser = request.user.user + + # Some middleware (for example CSRF) has already awaited the request + # body, and adds it to the request. + body: t.Any = request.scope.get("form") + + if not body: + try: + body = await request.json() + except JSONDecodeError: + body = await request.form() + + if action := body.get("action"): + if action == "register": + if body.get("format") == "json": + json_content = await self._provider.get_registration_json( + user=piccolo_user + ) + return JSONResponse(content=json_content) + else: + html_content = await self._provider.get_registration_html( + user=piccolo_user + ) + return HTMLResponse(content=html_content) + elif action == "revoke": + if password := body.get("password"): + if await piccolo_user.__class__.login( + username=piccolo_user.username, password=password + ): + html_content = ( + await self._provider.delete_registration( + user=piccolo_user + ) + ) + return HTMLResponse(content=html_content) + + return HTMLResponse(content="

Error

") def mfa_register_endpoint(provider: MFAProvider) -> t.Type[HTTPEndpoint]: diff --git a/piccolo_api/mfa/provider.py b/piccolo_api/mfa/provider.py index 26e54ab2..bddebee1 100644 --- a/piccolo_api/mfa/provider.py +++ b/piccolo_api/mfa/provider.py @@ -59,3 +59,10 @@ async def get_registration_json(self, user: BaseUser) -> dict: response, rather than HTML, if they want to render the UI themselves. """ pass + + @abstractmethod + async def delete_registration(self, user: BaseUser) -> str: + """ + Used to remove the MFA. + """ + pass diff --git a/piccolo_api/templates/mfa_authenticator_setup.html b/piccolo_api/templates/mfa_authenticator_setup.html index 714859f7..18cabab0 100644 --- a/piccolo_api/templates/mfa_authenticator_setup.html +++ b/piccolo_api/templates/mfa_authenticator_setup.html @@ -5,17 +5,30 @@ {% block content %}

Authenticator Setup

-

Use an authenticator app like Google Authenticator, available on iOS and Android, to scan this QR code:

+ {% if already_enrolled %} +

You are already enrolled.

-
- -
+
+ + -

Copy these recovery codes and keep them safe:

+ + + +
+ {% else %} +

Use an authenticator app like Google Authenticator, available on iOS and Android, to scan this QR code:

-
    - {% for recovery_code in recovery_codes %} -
  • {{ recovery_code }}
  • - {% endfor %} -
+
+ +
+ +

Copy these recovery codes and keep them safe:

+ +
    + {% for recovery_code in recovery_codes %} +
  • {{ recovery_code }}
  • + {% endfor %} +
+ {% endif %} {% endblock %} diff --git a/requirements/e2e-requirements.txt b/requirements/e2e-requirements.txt new file mode 100644 index 00000000..ed853472 --- /dev/null +++ b/requirements/e2e-requirements.txt @@ -0,0 +1,3 @@ +pytest==8.0.1 +playwright==1.41.2 +pytest-playwright==0.4.4 diff --git a/scripts/run-e2e-test.sh b/scripts/run-e2e-test.sh new file mode 100755 index 00000000..fcaa7341 --- /dev/null +++ b/scripts/run-e2e-test.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Run end-to-end tests + +extraArgs=$@ + +pytest --ignore=tests -s $extraArgs diff --git a/scripts/test-postgres.sh b/scripts/test-postgres.sh index 656be4dd..2cbada1a 100755 --- a/scripts/test-postgres.sh +++ b/scripts/test-postgres.sh @@ -7,4 +7,4 @@ export PYTHONPATH="$PWD:$PYTHONPATH" export PICCOLO_CONF="tests.postgres_conf" -python -m pytest --cov=piccolo_api --cov-report xml --cov-report html --cov-fail-under 85 -s $@ +python -m pytest --ignore=e2e --cov=piccolo_api --cov-report xml --cov-report html --cov-fail-under 85 -s $@ diff --git a/scripts/test-sqlite.sh b/scripts/test-sqlite.sh index 2388d3da..a1bba931 100755 --- a/scripts/test-sqlite.sh +++ b/scripts/test-sqlite.sh @@ -7,4 +7,4 @@ export PYTHONPATH="$PWD:$PYTHONPATH" export PICCOLO_CONF="tests.sqlite_conf" -python -m pytest --cov=piccolo_api --cov-report xml --cov-report html --cov-fail-under 85 -s $@ +python -m pytest --ignore=e2e --cov=piccolo_api --cov-report xml --cov-report html --cov-fail-under 85 -s $@ diff --git a/tests/mfa/test_mfa_endpoints.py b/tests/mfa/test_mfa_endpoints.py index 5ad0f0e8..fbe87db7 100644 --- a/tests/mfa/test_mfa_endpoints.py +++ b/tests/mfa/test_mfa_endpoints.py @@ -38,7 +38,11 @@ async def test_register(self): self.assertIn("id", client.cookies) # Register for MFA - JSON - response = client.get("/private/mfa-register/?format=json") + response = client.post( + "/private/mfa-register/", + json={"action": "register", "format": "json"}, + headers={"X-CSRFToken": csrf_token}, + ) self.assertEqual(response.status_code, 200) data = response.json() @@ -46,8 +50,13 @@ async def test_register(self): self.assertIn("recovery_codes", data) # Register for MFA - HTML - response = client.get("/private/mfa-register/") + response = client.post( + "/private/mfa-register/", + data={"action": "register"}, + headers={"X-CSRFToken": csrf_token}, + ) + # TODO - change this, as we can't register twice. self.assertEqual(response.status_code, 200) html = response.content self.assertIn(b"Authenticator Setup", html) From 1f05a1f05ad3777be374435a57e52d8cca6425ed Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 15 Aug 2024 13:27:12 +0100 Subject: [PATCH 057/102] require a password to enable MFA --- e2e/pages.py | 23 ++++++--- e2e/test_mfa.py | 12 ++++- example_projects/mfa_demo/app.py | 3 +- piccolo_api/mfa/endpoints.py | 67 +++++++++++++++++++++---- piccolo_api/templates/mfa_register.html | 22 ++++++++ 5 files changed, 108 insertions(+), 19 deletions(-) create mode 100644 piccolo_api/templates/mfa_register.html diff --git a/e2e/pages.py b/e2e/pages.py index cf248ff4..a731eaf8 100644 --- a/e2e/pages.py +++ b/e2e/pages.py @@ -6,6 +6,9 @@ from playwright.sync_api import Page +USERNAME = "piccolo" +PASSWORD = "piccolo123" + class LoginPage: url = "http://localhost:8000/login/" @@ -19,9 +22,9 @@ def __init__(self, page: Page): def reset(self): self.page.goto(self.url) - def login(self): - self.username_input.fill("piccolo") - self.password_input.fill("piccolo123") + def login(self, username: str = USERNAME, password: str = PASSWORD): + self.username_input.fill(username) + self.password_input.fill(password) self.button.click() @@ -39,11 +42,11 @@ def __init__(self, page: Page): def reset(self): self.page.goto(self.url) - def login(self): - self.username_input.fill("piccolo") + def login(self, username: str = USERNAME, password: str = PASSWORD): + self.username_input.fill(username) self.email_input.fill("test@piccolo-orm.com") - self.password_input.fill("piccolo123") - self.confirm_password_input.fill("piccolo123") + self.password_input.fill(password) + self.confirm_password_input.fill(password) self.button.click() @@ -52,6 +55,12 @@ class MFARegisterPage: 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() diff --git a/e2e/test_mfa.py b/e2e/test_mfa.py index 2bcd6e02..b87ba725 100644 --- a/e2e/test_mfa.py +++ b/e2e/test_mfa.py @@ -3,9 +3,9 @@ from .pages import LoginPage, MFARegisterPage, RegisterPage -def test_login(page: Page, mfa_app): +def test_mfa_signup(page: Page, mfa_app): """ - Make sure we can register, sign up for MFA. + Make sure we create an account nad sign up for MFA. """ register_page = RegisterPage(page=page) register_page.reset() @@ -17,3 +17,11 @@ def test_login(page: Page, mfa_app): mfa_register_page = MFARegisterPage(page=page) mfa_register_page.reset() + + # Test an incorrect password + # TODO - assert response code is correct + mfa_register_page.register(password="fake_password_123") + + # Test the correct password + mfa_register_page.register() + breakpoint() diff --git a/example_projects/mfa_demo/app.py b/example_projects/mfa_demo/app.py index e7094576..43aca2b2 100644 --- a/example_projects/mfa_demo/app.py +++ b/example_projects/mfa_demo/app.py @@ -22,7 +22,8 @@ environment = Environment( loader=FileSystemLoader( os.path.join(os.path.dirname(__file__), "templates"), - ) + ), + autoescape=True, ) diff --git a/piccolo_api/mfa/endpoints.py b/piccolo_api/mfa/endpoints.py index af15db3d..cc9ff06a 100644 --- a/piccolo_api/mfa/endpoints.py +++ b/piccolo_api/mfa/endpoints.py @@ -1,13 +1,26 @@ +import os import typing as t from abc import ABCMeta, abstractmethod from json import JSONDecodeError +from jinja2 import Environment, FileSystemLoader from piccolo.apps.user.tables import BaseUser from starlette.endpoints import HTTPEndpoint from starlette.requests import Request from starlette.responses import HTMLResponse, JSONResponse from piccolo_api.mfa.provider import MFAProvider +from piccolo_api.shared.auth.styles import Styles + +TEMPLATE_PATH = os.path.join( + os.path.dirname(os.path.dirname(__file__)), + "templates", +) + + +environment = Environment( + loader=FileSystemLoader(TEMPLATE_PATH), autoescape=True +) class MFARegisterEndpoint(HTTPEndpoint, metaclass=ABCMeta): @@ -17,17 +30,36 @@ class MFARegisterEndpoint(HTTPEndpoint, metaclass=ABCMeta): def _provider(self) -> MFAProvider: raise NotImplementedError - async def get(self, request: Request): + @property + @abstractmethod + def _auth_table(self) -> t.Type[BaseUser]: + raise NotImplementedError + + @property + @abstractmethod + def _styles(self) -> Styles: + raise NotImplementedError + + def _render_register_template( + self, + request: Request, + extra_context: t.Optional[t.Dict] = None, + status_code: int = 200, + ): + template = environment.get_template("mfa_register.html") + return HTMLResponse( - content=f""" -
- - - -
- """ # noqa: E501 + status_code=status_code, + content=template.render( + styles=self._styles, + csrftoken=request.scope.get("csrftoken"), + **(extra_context or {}), + ), ) + async def get(self, request: Request): + return self._render_register_template(request=request) + async def post(self, request: Request): piccolo_user: BaseUser = request.user.user @@ -43,6 +75,17 @@ async def post(self, request: Request): if action := body.get("action"): if action == "register": + password = body.get("password") + + if not password or not await self._auth_table.login( + username=piccolo_user.username, password=password + ): + return self._render_register_template( + request=request, + status_code=403, + extra_context={"error": "Incorrect password"}, + ) + if body.get("format") == "json": json_content = await self._provider.get_registration_json( user=piccolo_user @@ -68,9 +111,15 @@ async def post(self, request: Request): return HTMLResponse(content="

Error

") -def mfa_register_endpoint(provider: MFAProvider) -> t.Type[HTTPEndpoint]: +def mfa_register_endpoint( + provider: MFAProvider, + auth_table: t.Type[BaseUser] = BaseUser, + styles: t.Optional[Styles] = None, +) -> t.Type[HTTPEndpoint]: class _MFARegisterEndpoint(MFARegisterEndpoint): + _auth_table = auth_table _provider = provider + _styles = styles or Styles() return _MFARegisterEndpoint diff --git a/piccolo_api/templates/mfa_register.html b/piccolo_api/templates/mfa_register.html new file mode 100644 index 00000000..15a424a3 --- /dev/null +++ b/piccolo_api/templates/mfa_register.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block title %}MFA Register{% endblock %} + +{% block content %} +

MFA Register

+ + {% if error %} +

{{ error }}

+ {% endif %} + +
+ + + +

Please enter your password to enable MFA:

+ + + + +
+{% endblock %} From f525ef3403c162590fcc1caa63f53b8335e4165a Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 15 Aug 2024 13:44:00 +0100 Subject: [PATCH 058/102] fix test --- e2e/test_mfa.py | 2 +- tests/mfa/test_mfa_endpoints.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/e2e/test_mfa.py b/e2e/test_mfa.py index b87ba725..9abcbf68 100644 --- a/e2e/test_mfa.py +++ b/e2e/test_mfa.py @@ -5,7 +5,7 @@ def test_mfa_signup(page: Page, mfa_app): """ - Make sure we create an account nad sign up for MFA. + Make sure we create an account and sign up for MFA. """ register_page = RegisterPage(page=page) register_page.reset() diff --git a/tests/mfa/test_mfa_endpoints.py b/tests/mfa/test_mfa_endpoints.py index fbe87db7..cc011086 100644 --- a/tests/mfa/test_mfa_endpoints.py +++ b/tests/mfa/test_mfa_endpoints.py @@ -40,7 +40,11 @@ async def test_register(self): # Register for MFA - JSON response = client.post( "/private/mfa-register/", - json={"action": "register", "format": "json"}, + json={ + "action": "register", + "format": "json", + "password": self.password, + }, headers={"X-CSRFToken": csrf_token}, ) self.assertEqual(response.status_code, 200) @@ -52,7 +56,7 @@ async def test_register(self): # Register for MFA - HTML response = client.post( "/private/mfa-register/", - data={"action": "register"}, + data={"action": "register", "password": self.password}, headers={"X-CSRFToken": csrf_token}, ) From 033c98606db6eb68b516c0313bbc969625c9d3ca Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 15 Aug 2024 22:00:41 +0100 Subject: [PATCH 059/102] create separate template for cancelling MFA --- e2e/test_mfa.py | 4 ++- piccolo_api/mfa/authenticator/provider.py | 7 ---- piccolo_api/mfa/endpoints.py | 20 +++++++++++ .../templates/mfa_authenticator_setup.html | 33 ++++++------------- piccolo_api/templates/mfa_cancel.html | 18 ++++++++++ tests/mfa/test_mfa_endpoints.py | 6 ++-- 6 files changed, 54 insertions(+), 34 deletions(-) create mode 100644 piccolo_api/templates/mfa_cancel.html diff --git a/e2e/test_mfa.py b/e2e/test_mfa.py index 9abcbf68..9c62bf8c 100644 --- a/e2e/test_mfa.py +++ b/e2e/test_mfa.py @@ -23,5 +23,7 @@ def test_mfa_signup(page: Page, mfa_app): mfa_register_page.register(password="fake_password_123") # Test the correct password + # TODO - make sure it navigated to the right page mfa_register_page.register() - breakpoint() + + mfa_register_page.reset() diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index 2d5938b4..a7862674 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -97,13 +97,6 @@ async def get_registration_html(self, user: BaseUser) -> str: When a user wants to register for MFA, this HTML is shown containing instructions. """ - # If the user is already enrolled, don't create a new secret. - if await self.secret_table.is_user_enrolled(user_id=user.id): - return self.register_template.render( - already_enrolled=True, - styles=self.styles, - ) - secret, recovery_codes = await self.secret_table.create_new( user_id=user.id, db_encryption_key=self.db_encryption_key, diff --git a/piccolo_api/mfa/endpoints.py b/piccolo_api/mfa/endpoints.py index cc9ff06a..07fa8aec 100644 --- a/piccolo_api/mfa/endpoints.py +++ b/piccolo_api/mfa/endpoints.py @@ -75,6 +75,23 @@ async def post(self, request: Request): if action := body.get("action"): if action == "register": + + ############################################################### + # If the user is already enrolled, don't proceed. + if await self._provider.is_user_enrolled(user=piccolo_user): + template = environment.get_template("mfa_cancel.html") + + return HTMLResponse( + status_code=400, + content=template.render( + styles=self._styles, + csrftoken=request.scope.get("csrftoken"), + ), + ) + + ############################################################### + # Make sure the password is correct. + password = body.get("password") if not password or not await self._auth_table.login( @@ -86,6 +103,9 @@ async def post(self, request: Request): extra_context={"error": "Incorrect password"}, ) + ############################################################### + # Return the content + if body.get("format") == "json": json_content = await self._provider.get_registration_json( user=piccolo_user diff --git a/piccolo_api/templates/mfa_authenticator_setup.html b/piccolo_api/templates/mfa_authenticator_setup.html index 18cabab0..714859f7 100644 --- a/piccolo_api/templates/mfa_authenticator_setup.html +++ b/piccolo_api/templates/mfa_authenticator_setup.html @@ -5,30 +5,17 @@ {% block content %}

Authenticator Setup

- {% if already_enrolled %} -

You are already enrolled.

+

Use an authenticator app like Google Authenticator, available on iOS and Android, to scan this QR code:

-
- - +
+ +
- - - -
- {% else %} -

Use an authenticator app like Google Authenticator, available on iOS and Android, to scan this QR code:

+

Copy these recovery codes and keep them safe:

-
- -
- -

Copy these recovery codes and keep them safe:

- -
    - {% for recovery_code in recovery_codes %} -
  • {{ recovery_code }}
  • - {% endfor %} -
- {% endif %} +
    + {% for recovery_code in recovery_codes %} +
  • {{ recovery_code }}
  • + {% endfor %} +
{% endblock %} diff --git a/piccolo_api/templates/mfa_cancel.html b/piccolo_api/templates/mfa_cancel.html new file mode 100644 index 00000000..e6ce15b7 --- /dev/null +++ b/piccolo_api/templates/mfa_cancel.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} + +{% block title %}MFA Authenticator Cancel{% endblock %} + +{% block content %} +

MFA Register

+ +

You are already enrolled.

+ +
+ + + + + + +
+{% endblock %} diff --git a/tests/mfa/test_mfa_endpoints.py b/tests/mfa/test_mfa_endpoints.py index cc011086..902b9566 100644 --- a/tests/mfa/test_mfa_endpoints.py +++ b/tests/mfa/test_mfa_endpoints.py @@ -61,6 +61,6 @@ async def test_register(self): ) # TODO - change this, as we can't register twice. - self.assertEqual(response.status_code, 200) - html = response.content - self.assertIn(b"Authenticator Setup", html) + # self.assertEqual(response.status_code, 200) + # html = response.content + # self.assertIn(b"Authenticator Setup", html) From 1fd4de92c6e7a0bd6ddb4be86935309de7a10d19 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 16 Aug 2024 12:27:48 +0100 Subject: [PATCH 060/102] initial docs --- docs/source/index.rst | 1 + docs/source/mfa/endpoints.rst | 18 ++++++++++++++++++ docs/source/mfa/example.rst | 13 +++++++++++++ docs/source/mfa/index.rst | 13 +++++++++++++ docs/source/mfa/introduction.rst | 13 +++++++++++++ docs/source/mfa/providers.rst | 23 +++++++++++++++++++++++ docs/source/mfa/tables.rst | 4 ++++ piccolo_api/mfa/authenticator/provider.py | 3 +++ piccolo_api/mfa/provider.py | 4 ++++ 9 files changed, 92 insertions(+) create mode 100644 docs/source/mfa/endpoints.rst create mode 100644 docs/source/mfa/example.rst create mode 100644 docs/source/mfa/index.rst create mode 100644 docs/source/mfa/introduction.rst create mode 100644 docs/source/mfa/providers.rst create mode 100644 docs/source/mfa/tables.rst diff --git a/docs/source/index.rst b/docs/source/index.rst index bd4538f1..7793517d 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -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 diff --git a/docs/source/mfa/endpoints.rst b/docs/source/mfa/endpoints.rst new file mode 100644 index 00000000..1e7a5f1e --- /dev/null +++ b/docs/source/mfa/endpoints.rst @@ -0,0 +1,18 @@ +Endpoints +========= + +You must mount these ASGI endpoints in your app. + +.. currentmodule:: piccolo_api.mfa.endpoints + +``mfa_register_endpoint`` +------------------------- + +.. autofunction:: mfa_register_endpoint + +``session_login`` +----------------- + +Make sure you pass the ``mfa_providers`` argument to +:func:`session_login `, +so it knows to look for an MFA token. diff --git a/docs/source/mfa/example.rst b/docs/source/mfa/example.rst new file mode 100644 index 00000000..ae528e2e --- /dev/null +++ b/docs/source/mfa/example.rst @@ -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 diff --git a/docs/source/mfa/index.rst b/docs/source/mfa/index.rst new file mode 100644 index 00000000..8ae65be7 --- /dev/null +++ b/docs/source/mfa/index.rst @@ -0,0 +1,13 @@ +.. _MFA: + +Multi-Factor Authentication +=========================== + +.. toctree:: + :maxdepth: 1 + + ./introduction + ./endpoints + ./providers + ./tables + ./example diff --git a/docs/source/mfa/introduction.rst b/docs/source/mfa/introduction.rst new file mode 100644 index 00000000..ce09290a --- /dev/null +++ b/docs/source/mfa/introduction.rst @@ -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. diff --git a/docs/source/mfa/providers.rst b/docs/source/mfa/providers.rst new file mode 100644 index 00000000..0c3e4791 --- /dev/null +++ b/docs/source/mfa/providers.rst @@ -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 diff --git a/docs/source/mfa/tables.rst b/docs/source/mfa/tables.rst new file mode 100644 index 00000000..6ed42ea0 --- /dev/null +++ b/docs/source/mfa/tables.rst @@ -0,0 +1,4 @@ +Tables +====== + +... diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index a7862674..d3670599 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -28,6 +28,9 @@ def __init__( styles: t.Optional[Styles] = None, ): """ + Allows authentication using an authenticator app on the user's phone, + like Google Authenticator. + :param db_encryption_key: The shared secrets are encrypted in the database - pass in a random string which is used for encrypting them. diff --git a/piccolo_api/mfa/provider.py b/piccolo_api/mfa/provider.py index bddebee1..55d7e3f1 100644 --- a/piccolo_api/mfa/provider.py +++ b/piccolo_api/mfa/provider.py @@ -7,6 +7,10 @@ class MFAProvider(metaclass=ABCMeta): def __init__(self, token_name: str = "mfa_code"): """ + This is the base class which all providers must inherit from. Use it + to build your own custom providers. Don't use it directly, it does + nothing. + :param token_name: Each provider should specify a unique ``token_name``, so when a token is passed to the login endpoint, we know which From 78fd06f4fb57742b2ab120c813fba64ae5a5e5a3 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 16 Aug 2024 12:34:30 +0100 Subject: [PATCH 061/102] improve docs for AuthenticatorProvider params --- piccolo_api/mfa/authenticator/provider.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index d3670599..945e0574 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -36,12 +36,18 @@ def __init__( string which is used for encrypting them. :param recovery_code_count: How many recovery codes should be generated. - :param seed_table: - By default, just use the out of the box ``AuthenticatorSecret`` - table - you can specify a subclass instead if you want to override - certain functionality. + :param secret_table: + This is the table used to store secrets. You shouldn't have to + override this, unless you subclassed the default + ``AuthenticatorSecret`` table for some reason. :param issuer_name: This is how it will be identified in the user's authenticator app. + :param register_template_path: + You can override the HTML template if you want. Try using the + ``styles`` param instead though if possible if you just want basic + visual changes. + :param styles: + Modify the appearance of the HTML template using CSS. """ super().__init__(token_name="authenticator_token") From d79087bd2f46ef454e3ffa3d10873d003d2d4bb7 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 16 Aug 2024 13:47:57 +0100 Subject: [PATCH 062/102] add docs for tables --- docs/source/mfa/tables.rst | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/source/mfa/tables.rst b/docs/source/mfa/tables.rst index 6ed42ea0..c497dfb5 100644 --- a/docs/source/mfa/tables.rst +++ b/docs/source/mfa/tables.rst @@ -1,4 +1,35 @@ Tables ====== -... +``AuthenticatorSecret`` +----------------------- + +This is required by :class:`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() From 2902e455e52127408544fb03517134dec2c19040 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 16 Aug 2024 13:49:55 +0100 Subject: [PATCH 063/102] rename `mfa_register_endpoint` to `mfa_setup` So the name is more consistent with other endpoints in `piccolo_api` --- docs/source/mfa/endpoints.rst | 4 ++-- example_projects/mfa_demo/app.py | 4 ++-- piccolo_api/mfa/endpoints.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/source/mfa/endpoints.rst b/docs/source/mfa/endpoints.rst index 1e7a5f1e..e44dbe83 100644 --- a/docs/source/mfa/endpoints.rst +++ b/docs/source/mfa/endpoints.rst @@ -5,10 +5,10 @@ You must mount these ASGI endpoints in your app. .. currentmodule:: piccolo_api.mfa.endpoints -``mfa_register_endpoint`` +``mfa_setup`` ------------------------- -.. autofunction:: mfa_register_endpoint +.. autofunction:: mfa_setup ``session_login`` ----------------- diff --git a/example_projects/mfa_demo/app.py b/example_projects/mfa_demo/app.py index 43aca2b2..b158c161 100644 --- a/example_projects/mfa_demo/app.py +++ b/example_projects/mfa_demo/app.py @@ -11,7 +11,7 @@ from piccolo_api.csrf.middleware import CSRFMiddleware from piccolo_api.mfa.authenticator.provider import AuthenticatorProvider -from piccolo_api.mfa.endpoints import mfa_register_endpoint +from piccolo_api.mfa.endpoints import mfa_setup from piccolo_api.register.endpoints import register from piccolo_api.session_auth.endpoints import session_login, session_logout from piccolo_api.session_auth.middleware import SessionsAuthBackend @@ -54,7 +54,7 @@ def on_auth_error(request: Request, exc: Exception): Route("/logout/", session_logout(redirect_to="/")), Route( "/mfa-register/", - mfa_register_endpoint( + mfa_setup( provider=AuthenticatorProvider( db_encryption_key=EXAMPLE_DB_ENCRYPTION_KEY ) diff --git a/piccolo_api/mfa/endpoints.py b/piccolo_api/mfa/endpoints.py index 07fa8aec..2d8cf713 100644 --- a/piccolo_api/mfa/endpoints.py +++ b/piccolo_api/mfa/endpoints.py @@ -131,7 +131,7 @@ async def post(self, request: Request): return HTMLResponse(content="

Error

") -def mfa_register_endpoint( +def mfa_setup( provider: MFAProvider, auth_table: t.Type[BaseUser] = BaseUser, styles: t.Optional[Styles] = None, From 28a3ddb3ae0dc565f8e8ba4a6aa4f7356249fd97 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 16 Aug 2024 17:02:20 +0100 Subject: [PATCH 064/102] improve docs, and rename from register to setup --- docs/source/mfa/endpoints.rst | 3 +++ .../source/mfa/images/mfa_register_endpoint.jpg | Bin 0 -> 63743 bytes e2e/pages.py | 4 ++-- e2e/test_mfa.py | 13 +++++++------ example_projects/mfa_demo/app.py | 2 +- example_projects/mfa_demo/templates/home.html | 2 +- piccolo_api/mfa/endpoints.py | 9 ++++++++- piccolo_api/mfa/provider.py | 7 ++++--- piccolo_api/templates/mfa_cancel.html | 4 ++-- .../{mfa_register.html => mfa_setup.html} | 6 +++--- tests/mfa/test_mfa_endpoints.py | 4 ++-- 11 files changed, 33 insertions(+), 21 deletions(-) create mode 100644 docs/source/mfa/images/mfa_register_endpoint.jpg rename piccolo_api/templates/{mfa_register.html => mfa_setup.html} (82%) diff --git a/docs/source/mfa/endpoints.rst b/docs/source/mfa/endpoints.rst index e44dbe83..c8ba4cf7 100644 --- a/docs/source/mfa/endpoints.rst +++ b/docs/source/mfa/endpoints.rst @@ -10,6 +10,9 @@ You must mount these ASGI endpoints in your app. .. autofunction:: mfa_setup +.. image:: images/mfa_register_endpoint.jpg + + ``session_login`` ----------------- diff --git a/docs/source/mfa/images/mfa_register_endpoint.jpg b/docs/source/mfa/images/mfa_register_endpoint.jpg new file mode 100644 index 0000000000000000000000000000000000000000..894082b674f91b95f82d89a16114bf52e53ff470 GIT binary patch literal 63743 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 literal 0 HcmV?d00001 diff --git a/e2e/pages.py b/e2e/pages.py index a731eaf8..236a466c 100644 --- a/e2e/pages.py +++ b/e2e/pages.py @@ -50,8 +50,8 @@ def login(self, username: str = USERNAME, password: str = PASSWORD): self.button.click() -class MFARegisterPage: - url = "http://localhost:8000/private/mfa-register/" +class MFASetupPage: + url = "http://localhost:8000/private/mfa-setup/" def __init__(self, page: Page): self.page = page diff --git a/e2e/test_mfa.py b/e2e/test_mfa.py index 9c62bf8c..a58e5d6b 100644 --- a/e2e/test_mfa.py +++ b/e2e/test_mfa.py @@ -1,6 +1,6 @@ from playwright.async_api import Page -from .pages import LoginPage, MFARegisterPage, RegisterPage +from .pages import LoginPage, MFASetupPage, RegisterPage def test_mfa_signup(page: Page, mfa_app): @@ -15,15 +15,16 @@ def test_mfa_signup(page: Page, mfa_app): login_page.reset() login_page.login() - mfa_register_page = MFARegisterPage(page=page) - mfa_register_page.reset() + mfa_setup_page = MFASetupPage(page=page) + mfa_setup_page.reset() + breakpoint() # Test an incorrect password # TODO - assert response code is correct - mfa_register_page.register(password="fake_password_123") + mfa_setup_page.register(password="fake_password_123") # Test the correct password # TODO - make sure it navigated to the right page - mfa_register_page.register() + mfa_setup_page.register() - mfa_register_page.reset() + mfa_setup_page.reset() diff --git a/example_projects/mfa_demo/app.py b/example_projects/mfa_demo/app.py index b158c161..a6a471d7 100644 --- a/example_projects/mfa_demo/app.py +++ b/example_projects/mfa_demo/app.py @@ -53,7 +53,7 @@ def on_auth_error(request: Request, exc: Exception): Route("/", PrivateEndpoint), Route("/logout/", session_logout(redirect_to="/")), Route( - "/mfa-register/", + "/mfa-setup/", mfa_setup( provider=AuthenticatorProvider( db_encryption_key=EXAMPLE_DB_ENCRYPTION_KEY diff --git a/example_projects/mfa_demo/templates/home.html b/example_projects/mfa_demo/templates/home.html index 4cd614be..25d5c5ed 100644 --- a/example_projects/mfa_demo/templates/home.html +++ b/example_projects/mfa_demo/templates/home.html @@ -16,7 +16,7 @@

MFA Demo

First register

Then login

-

Then sign up for MFA

+

Then sign up for MFA

Then try the private page

And logout

diff --git a/piccolo_api/mfa/endpoints.py b/piccolo_api/mfa/endpoints.py index 2d8cf713..5b695214 100644 --- a/piccolo_api/mfa/endpoints.py +++ b/piccolo_api/mfa/endpoints.py @@ -46,7 +46,7 @@ def _render_register_template( extra_context: t.Optional[t.Dict] = None, status_code: int = 200, ): - template = environment.get_template("mfa_register.html") + template = environment.get_template("mfa_setup.html") return HTMLResponse( status_code=status_code, @@ -136,6 +136,13 @@ def mfa_setup( auth_table: t.Type[BaseUser] = BaseUser, styles: t.Optional[Styles] = None, ) -> t.Type[HTTPEndpoint]: + """ + This endpoint needs to be protected ``SessionAuthMiddleware``, ensuring + that only logged in users can access it. + + Users can setup and manage their MFA setup using this endpoint. + + """ class _MFARegisterEndpoint(MFARegisterEndpoint): _auth_table = auth_table diff --git a/piccolo_api/mfa/provider.py b/piccolo_api/mfa/provider.py index 55d7e3f1..a917011b 100644 --- a/piccolo_api/mfa/provider.py +++ b/piccolo_api/mfa/provider.py @@ -8,15 +8,16 @@ class MFAProvider(metaclass=ABCMeta): def __init__(self, token_name: str = "mfa_code"): """ This is the base class which all providers must inherit from. Use it - to build your own custom providers. Don't use it directly, it does - nothing. + to build your own custom providers. If you use it directly, it won't + do anything. See :class:`AuthenticatorProvider ` + for a concrete implementation. :param token_name: Each provider should specify a unique ``token_name``, so when a token is passed to the login endpoint, we know which ``MFAProvider`` it belongs to. - """ + """ # noqa: E501 self.token_name = token_name @abstractmethod diff --git a/piccolo_api/templates/mfa_cancel.html b/piccolo_api/templates/mfa_cancel.html index e6ce15b7..41abc648 100644 --- a/piccolo_api/templates/mfa_cancel.html +++ b/piccolo_api/templates/mfa_cancel.html @@ -3,7 +3,7 @@ {% block title %}MFA Authenticator Cancel{% endblock %} {% block content %} -

MFA Register

+

MFA Setup

You are already enrolled.

@@ -13,6 +13,6 @@

MFA Register

- + {% endblock %} diff --git a/piccolo_api/templates/mfa_register.html b/piccolo_api/templates/mfa_setup.html similarity index 82% rename from piccolo_api/templates/mfa_register.html rename to piccolo_api/templates/mfa_setup.html index 15a424a3..436ba40b 100644 --- a/piccolo_api/templates/mfa_register.html +++ b/piccolo_api/templates/mfa_setup.html @@ -1,9 +1,9 @@ {% extends "base.html" %} -{% block title %}MFA Register{% endblock %} +{% block title %}MFA Setup{% endblock %} {% block content %} -

MFA Register

+

MFA Setup

{% if error %}

{{ error }}

@@ -17,6 +17,6 @@

MFA Register

- + {% endblock %} diff --git a/tests/mfa/test_mfa_endpoints.py b/tests/mfa/test_mfa_endpoints.py index 902b9566..a39f83d3 100644 --- a/tests/mfa/test_mfa_endpoints.py +++ b/tests/mfa/test_mfa_endpoints.py @@ -39,7 +39,7 @@ async def test_register(self): # Register for MFA - JSON response = client.post( - "/private/mfa-register/", + "/private/mfa-setup/", json={ "action": "register", "format": "json", @@ -55,7 +55,7 @@ async def test_register(self): # Register for MFA - HTML response = client.post( - "/private/mfa-register/", + "/private/mfa-setup/", data={"action": "register", "password": self.password}, headers={"X-CSRFToken": csrf_token}, ) From b20662acc6a51acbc7aa7b5703bd0a7920f68fa7 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 16 Aug 2024 17:02:49 +0100 Subject: [PATCH 065/102] remove debugging --- e2e/test_mfa.py | 1 - 1 file changed, 1 deletion(-) diff --git a/e2e/test_mfa.py b/e2e/test_mfa.py index a58e5d6b..ba8c2c6b 100644 --- a/e2e/test_mfa.py +++ b/e2e/test_mfa.py @@ -17,7 +17,6 @@ def test_mfa_signup(page: Page, mfa_app): mfa_setup_page = MFASetupPage(page=page) mfa_setup_page.reset() - breakpoint() # Test an incorrect password # TODO - assert response code is correct From 3833250452a7c4d02a1ebdc894684c42864f23ad Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 16 Aug 2024 22:06:42 +0100 Subject: [PATCH 066/102] improve template when MFA is disabled --- piccolo_api/mfa/authenticator/provider.py | 1 - piccolo_api/mfa/endpoints.py | 17 ++++++++++------- piccolo_api/templates/mfa_disabled.html | 9 +++++++++ 3 files changed, 19 insertions(+), 8 deletions(-) create mode 100644 piccolo_api/templates/mfa_disabled.html diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index 945e0574..65f531a5 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -141,4 +141,3 @@ async def get_registration_json(self, user: BaseUser) -> dict: async def delete_registration(self, user: BaseUser) -> str: await self.secret_table.revoke_all(user_id=user.id) - return "

Successfully deleted

" diff --git a/piccolo_api/mfa/endpoints.py b/piccolo_api/mfa/endpoints.py index 5b695214..ea6a050b 100644 --- a/piccolo_api/mfa/endpoints.py +++ b/piccolo_api/mfa/endpoints.py @@ -23,7 +23,7 @@ ) -class MFARegisterEndpoint(HTTPEndpoint, metaclass=ABCMeta): +class MFASetupEndpoint(HTTPEndpoint, metaclass=ABCMeta): @property @abstractmethod @@ -121,12 +121,15 @@ async def post(self, request: Request): if await piccolo_user.__class__.login( username=piccolo_user.username, password=password ): - html_content = ( - await self._provider.delete_registration( - user=piccolo_user - ) + await self._provider.delete_registration( + user=piccolo_user ) - return HTMLResponse(content=html_content) + + template = environment.get_template( + "mfa_disabled.html" + ) + + return HTMLResponse(content=template.render()) return HTMLResponse(content="

Error

") @@ -144,7 +147,7 @@ def mfa_setup( """ - class _MFARegisterEndpoint(MFARegisterEndpoint): + class _MFARegisterEndpoint(MFASetupEndpoint): _auth_table = auth_table _provider = provider _styles = styles or Styles() diff --git a/piccolo_api/templates/mfa_disabled.html b/piccolo_api/templates/mfa_disabled.html new file mode 100644 index 00000000..329626a8 --- /dev/null +++ b/piccolo_api/templates/mfa_disabled.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} + +{% block title %}MFA Disabled{% endblock %} + +{% block content %} +

MFA Disabled

+ +

You no longer require MFA to login - consider re-enabling it when you can.

+{% endblock %} From 01e785a232e95bd9de27c6287bbbbf69b099e063 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 16 Aug 2024 22:10:21 +0100 Subject: [PATCH 067/102] add re-enabled link on disabled template --- piccolo_api/mfa/endpoints.py | 8 ++++++-- piccolo_api/templates/mfa_disabled.html | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/piccolo_api/mfa/endpoints.py b/piccolo_api/mfa/endpoints.py index ea6a050b..8075c233 100644 --- a/piccolo_api/mfa/endpoints.py +++ b/piccolo_api/mfa/endpoints.py @@ -126,10 +126,14 @@ async def post(self, request: Request): ) template = environment.get_template( - "mfa_disabled.html" + "mfa_disabled.html", ) - return HTMLResponse(content=template.render()) + return HTMLResponse( + content=template.render( + styles=self._styles, + ) + ) return HTMLResponse(content="

Error

") diff --git a/piccolo_api/templates/mfa_disabled.html b/piccolo_api/templates/mfa_disabled.html index 329626a8..954c5f68 100644 --- a/piccolo_api/templates/mfa_disabled.html +++ b/piccolo_api/templates/mfa_disabled.html @@ -6,4 +6,6 @@

MFA Disabled

You no longer require MFA to login - consider re-enabling it when you can.

+ +

Re-enable

{% endblock %} From 8e94d28951774ff0d2d5413d1fd337de44f513c4 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 16 Aug 2024 22:19:57 +0100 Subject: [PATCH 068/102] fix linter errors --- piccolo_api/mfa/authenticator/provider.py | 2 +- piccolo_api/mfa/provider.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index 65f531a5..9b88129f 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -139,5 +139,5 @@ async def get_registration_json(self, user: BaseUser) -> dict: return {"qrcode_image": qrcode_image, "recovery_codes": recovery_codes} - async def delete_registration(self, user: BaseUser) -> str: + async def delete_registration(self, user: BaseUser): await self.secret_table.revoke_all(user_id=user.id) diff --git a/piccolo_api/mfa/provider.py b/piccolo_api/mfa/provider.py index a917011b..4251e0fe 100644 --- a/piccolo_api/mfa/provider.py +++ b/piccolo_api/mfa/provider.py @@ -66,7 +66,7 @@ async def get_registration_json(self, user: BaseUser) -> dict: pass @abstractmethod - async def delete_registration(self, user: BaseUser) -> str: + async def delete_registration(self, user: BaseUser): """ Used to remove the MFA. """ From f997bbdb8bd33531ebdad73137ccc7113133b057 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Sun, 18 Aug 2024 21:35:09 +0100 Subject: [PATCH 069/102] use `self._auth_table` --- piccolo_api/mfa/endpoints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piccolo_api/mfa/endpoints.py b/piccolo_api/mfa/endpoints.py index 8075c233..9ffe9429 100644 --- a/piccolo_api/mfa/endpoints.py +++ b/piccolo_api/mfa/endpoints.py @@ -118,7 +118,7 @@ async def post(self, request: Request): return HTMLResponse(content=html_content) elif action == "revoke": if password := body.get("password"): - if await piccolo_user.__class__.login( + if await self._auth_table.login( username=piccolo_user.username, password=password ): await self._provider.delete_registration( From 47e6508d7317dfd17829dcf2584263abc6ac3abd Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Sun, 18 Aug 2024 21:59:13 +0100 Subject: [PATCH 070/102] render cancel template in GET endpoint if user is already enrolled --- piccolo_api/mfa/endpoints.py | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/piccolo_api/mfa/endpoints.py b/piccolo_api/mfa/endpoints.py index 9ffe9429..278be726 100644 --- a/piccolo_api/mfa/endpoints.py +++ b/piccolo_api/mfa/endpoints.py @@ -57,8 +57,27 @@ def _render_register_template( ), ) + def _render_cancel_template( + self, + request: Request, + ): + template = environment.get_template("mfa_cancel.html") + + return HTMLResponse( + status_code=400, + content=template.render( + styles=self._styles, + csrftoken=request.scope.get("csrftoken"), + ), + ) + async def get(self, request: Request): - return self._render_register_template(request=request) + piccolo_user: BaseUser = request.user.user + + if await self._provider.is_user_enrolled(user=piccolo_user): + return self._render_cancel_template(request=request) + else: + return self._render_register_template(request=request) async def post(self, request: Request): piccolo_user: BaseUser = request.user.user @@ -79,15 +98,7 @@ async def post(self, request: Request): ############################################################### # If the user is already enrolled, don't proceed. if await self._provider.is_user_enrolled(user=piccolo_user): - template = environment.get_template("mfa_cancel.html") - - return HTMLResponse( - status_code=400, - content=template.render( - styles=self._styles, - csrftoken=request.scope.get("csrftoken"), - ), - ) + return self._render_cancel_template(request=request) ############################################################### # Make sure the password is correct. From d288cd4fc1c78433fa01b5fb22e7fe0482722cdb Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 30 Aug 2024 14:39:57 +0100 Subject: [PATCH 071/102] remove email for now --- piccolo_api/mfa/email/__init__.py | 0 piccolo_api/mfa/email/piccolo_app.py | 23 ------------- .../mfa/email/piccolo_migrations/__init__.py | 0 piccolo_api/mfa/email/provider.py | 14 -------- piccolo_api/mfa/email/tables.py | 33 ------------------- 5 files changed, 70 deletions(-) delete mode 100644 piccolo_api/mfa/email/__init__.py delete mode 100644 piccolo_api/mfa/email/piccolo_app.py delete mode 100644 piccolo_api/mfa/email/piccolo_migrations/__init__.py delete mode 100644 piccolo_api/mfa/email/provider.py delete mode 100644 piccolo_api/mfa/email/tables.py diff --git a/piccolo_api/mfa/email/__init__.py b/piccolo_api/mfa/email/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/piccolo_api/mfa/email/piccolo_app.py b/piccolo_api/mfa/email/piccolo_app.py deleted file mode 100644 index d24b863c..00000000 --- a/piccolo_api/mfa/email/piccolo_app.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -Import all of the Tables subclasses in your app here, and register them with -the APP_CONFIG. -""" - -import os - -from piccolo.conf.apps import AppConfig - -from .tables import EmailCode - -CURRENT_DIRECTORY = os.path.dirname(os.path.abspath(__file__)) - - -APP_CONFIG = AppConfig( - app_name="mfa_email", - migrations_folder_path=os.path.join( - CURRENT_DIRECTORY, "piccolo_migrations" - ), - table_classes=[EmailCode], - migration_dependencies=[], - commands=[], -) diff --git a/piccolo_api/mfa/email/piccolo_migrations/__init__.py b/piccolo_api/mfa/email/piccolo_migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/piccolo_api/mfa/email/provider.py b/piccolo_api/mfa/email/provider.py deleted file mode 100644 index cadd2ff8..00000000 --- a/piccolo_api/mfa/email/provider.py +++ /dev/null @@ -1,14 +0,0 @@ -from piccolo.apps.user.tables import BaseUser - -from piccolo_api.mfa.provider import MFAProvider - -from .tables import EmailCode - - -class EmailProvider(MFAProvider): - - async def register(self, user: BaseUser): - return await EmailCode.create_new(email=user.email) - - async def authenticate_user(self, user: BaseUser, code: str) -> bool: - return await EmailCode.authenticate(email=user.email, code=code) diff --git a/piccolo_api/mfa/email/tables.py b/piccolo_api/mfa/email/tables.py deleted file mode 100644 index f3d45f26..00000000 --- a/piccolo_api/mfa/email/tables.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -import datetime - -from piccolo.columns import Email, Timestamptz, Varchar -from piccolo.table import Table - - -class EmailCode(Table): - email = Email() - code = Varchar() # TODO - look how best to generate the codes - created_at = Timestamptz() - used_at = Timestamptz(null=True, default=None) - - _expiry_time = datetime.timedelta(minutes=5) - - @classmethod - async def create_new(cls, email: str) -> EmailCode: - # TODO - generate proper code - instance = cls({cls.email: email, cls.code: "ABC123"}) - await instance.save() - return instance - - @classmethod - async def authenticate(cls, email: str, code: str) -> bool: - now = datetime.datetime.now(tz=datetime.timezone.utc) - - return await cls.exists().where( - cls.email == email, - cls.code == code, - cls.used_at.is_null(), - cls.created_at >= now - cls._expiry_time, - ) From 8c6e95d92fda806cd1495fc87a908f0d9a4e2064 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 30 Aug 2024 14:40:06 +0100 Subject: [PATCH 072/102] remove email from README --- piccolo_api/mfa/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/piccolo_api/mfa/README.md b/piccolo_api/mfa/README.md index 5f35600e..78b017f0 100644 --- a/piccolo_api/mfa/README.md +++ b/piccolo_api/mfa/README.md @@ -1,4 +1,4 @@ # MFA -Multi Factor Authentication - using an authenticator app on a mobile device, or -via email. +Multi Factor Authentication - currently using an authenticator app on a mobile +device. From d4007a13a4d0cbfaf3c398b5359831658fcfba05 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 30 Aug 2024 16:43:53 +0100 Subject: [PATCH 073/102] start moving encryption into its own file --- piccolo_api/encryption/__init__.py | 0 piccolo_api/encryption/providers.py | 123 ++++++++++++++++++++++ piccolo_api/mfa/authenticator/provider.py | 15 +-- piccolo_api/mfa/authenticator/tables.py | 56 +++------- requirements/extras/authenticator.txt | 1 - requirements/extras/cryptography.txt | 1 + 6 files changed, 145 insertions(+), 51 deletions(-) create mode 100644 piccolo_api/encryption/__init__.py create mode 100644 piccolo_api/encryption/providers.py create mode 100644 requirements/extras/cryptography.txt diff --git a/piccolo_api/encryption/__init__.py b/piccolo_api/encryption/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/piccolo_api/encryption/providers.py b/piccolo_api/encryption/providers.py new file mode 100644 index 00000000..5e361bd9 --- /dev/null +++ b/piccolo_api/encryption/providers.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import logging +import typing as t +from abc import ABCMeta, abstractmethod + +import cryptography.fernet + +if t.TYPE_CHECKING: + import cryptography + + +logger = logging.getLogger(__name__) + + +def get_cryptography() -> cryptography: # type: ignore + try: + import cryptography + except ImportError as e: + print( + "Install pip install piccolo_api[cryptography] to use this " + "feature." + ) + raise e + + return cryptography + + +class EncryptionProvider(meta=ABCMeta): + """ + Base class for encryption providers. Don't use it directly, it must be + subclassed. + """ + + def __init__(self, prefix: str): + self.prefix = prefix + + @abstractmethod + def encrypt(self, value: str, *args, **kwargs) -> str: + raise NotImplementedError() + + @abstractmethod + def decrypt(self, value: str, *args, **kwargs) -> str: + raise NotImplementedError() + + +class PlainTextProvider(EncryptionProvider): + """ + Store the + """ + + def __init__(self): + super.__init__(prefix="plain") + + def encrypt(self, value: str, *args, **kwargs) -> str: + return value + + def decrypt(self, value: str, *args, **kwargs) -> str: + return value + + +class FernetProvider(EncryptionProvider): + + def __init__(self, encryption_key: str): + """ + :param db_encryption_key: + This can be generated using ``FernetEncryption.get_new_key()``. + + """ + self.encryption_key = encryption_key + super.__init__(prefix="fernet") + + @staticmethod + def get_new_key() -> str: + cryptography = get_cryptography() + return cryptography.fernet.Fernet.generate_key() # type: ignore + + def encrypt( + self, value: str, use_prefix: bool = True, *args, **kwargs + ) -> str: + cryptography = get_cryptography() + fernet = cryptography.fernet.Fernet( # type: ignore + self.encryption_key + ) + encrypted_value = fernet.encrypt(value.encode("utf8")).decode("utf8") + return ( + f"{self.prefix}-{encrypted_value}" + if use_prefix + else encrypted_value + ) + + def decrypt( + self, encrypted_value: str, use_prefix: bool = True, *args, **kwargs + ) -> str: + cryptography = get_cryptography() + + if use_prefix: + if encrypted_value.startswith(self.prefix): + encrypted_value = encrypted_value.lstrip(f"{self.prefix}-") + else: + raise ValueError( + "Unable to identify which encryption was used - if moving " + "to a new encryption provider, use " + "`migrate_encrypted_value`." + ) + + fernet = cryptography.fernet.Fernet( # type: ignore + self.encryption_key + ) + value = fernet.decrypt(encrypted_value.encode("utf8")) + return value.decode("utf8") + + +async def migrate_encrypted_value( + old_provider: EncryptionProvider, + new_provider: EncryptionProvider, + encrypted_value: str, +): + """ + If you're migrating from one form of encryption to another, you can use + this utility. + """ + return new_provider.encrypt(old_provider.decrypt(encrypted_value)) diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index 9b88129f..44d017f6 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -4,6 +4,7 @@ from jinja2 import Environment, FileSystemLoader from piccolo.apps.user.tables import BaseUser +from piccolo_api.encryption.providers import EncryptionProvider from piccolo_api.mfa.authenticator.tables import AuthenticatorSecret from piccolo_api.mfa.authenticator.utils import get_b64encoded_qr_image from piccolo_api.mfa.provider import MFAProvider @@ -20,7 +21,7 @@ class AuthenticatorProvider(MFAProvider): def __init__( self, - db_encryption_key: str, + encryption_provider: EncryptionProvider, recovery_code_count: int = 8, secret_table: t.Type[AuthenticatorSecret] = AuthenticatorSecret, issuer_name: str = "Piccolo-MFA", @@ -31,9 +32,11 @@ def __init__( Allows authentication using an authenticator app on the user's phone, like Google Authenticator. - :param db_encryption_key: - The shared secrets are encrypted in the database - pass in a random - string which is used for encrypting them. + :param encryption_provider: + The shared secrets can be encrypted in the database. We recommend + using :class:`piccolo_api.encryption.provider.FernetProvider`. + Use :class:`piccolo_api.encryption.provider.PlainTextProvider` to + store the secrets as plain text. :param recovery_code_count: How many recovery codes should be generated. :param secret_table: @@ -52,7 +55,7 @@ def __init__( """ super().__init__(token_name="authenticator_token") - self.db_encryption_key = db_encryption_key + self.encryption_provider = encryption_provider self.recovery_code_count = recovery_code_count self.secret_table = secret_table self.issuer_name = issuer_name @@ -75,7 +78,7 @@ async def authenticate_user(self, user: BaseUser, code: str) -> bool: return await self.secret_table.authenticate( user_id=user.id, code=code, - db_encryption_key=self.db_encryption_key, + encryption_provider=self.encryption_provider, ) async def is_user_enrolled(self, user: BaseUser) -> bool: diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index f9aae877..1915d59b 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -4,15 +4,14 @@ import logging import typing as t -import cryptography.fernet from piccolo.apps.user.tables import BaseUser from piccolo.columns import Array, Integer, Serial, Text, Timestamptz, Varchar from piccolo.table import Table +from piccolo_api.encryption.providers import EncryptionProvider from piccolo_api.mfa.recovery_codes import generate_recovery_code if t.TYPE_CHECKING: - import cryptography import pyotp @@ -32,35 +31,8 @@ def get_pyotp() -> pyotp: # type: ignore return pyotp -def get_cryptography() -> cryptography: # type: ignore - try: - import cryptography - except ImportError as e: - print( - "Install pip install piccolo_api[authenticator] to use this " - "feature." - ) - raise e - - return cryptography - - -def _encrypt(value: str, db_encryption_key: str) -> str: - cryptography = get_cryptography() - fernet = cryptography.fernet.Fernet(db_encryption_key) # type: ignore - encrypted_value = fernet.encrypt(value.encode("utf8")) - return encrypted_value.decode("utf8") - - -def _decrypt(encrypted_value: str, db_encryption_key: str) -> str: - cryptography = get_cryptography() - fernet = cryptography.fernet.Fernet(db_encryption_key) # type: ignore - value = fernet.decrypt(encrypted_value.encode("utf8")) - return value.decode("utf8") - - class AuthenticatorSecret(Table): - id: Serial # TODO - we might change this to a UUID primary key + id: Serial user_id = Integer(null=False) device_name = Varchar( null=True, @@ -100,7 +72,7 @@ def generate_secret(cls) -> str: async def create_new( cls, user_id: int, - db_encryption_key: str, + encryption_provider: EncryptionProvider, recovery_code_count: int = 8, ) -> t.Tuple[AuthenticatorSecret, t.List[str]]: """ @@ -110,10 +82,8 @@ async def create_new( :param user_id: The user to create the secret for. - :param db_encryption_key: - The secret is encrypted in the database - ``db_encryption_key`` - can be any reasonably random string. It provides a little extra - protection vs storing the secret in plain text. + :param encryption_provider: + Determines how the secret is stored in the database. :param recovery_code_count: How many recovery codes to generate for the user - this allows them to still gain access if their phone is lost. @@ -144,9 +114,7 @@ async def create_new( secret = cls.generate_secret() # We'll encrypt the secret for storing in the database. - encrypted_secret = _encrypt( - value=secret, db_encryption_key=db_encryption_key - ) + encrypted_secret = encryption_provider.encrypt(value=secret) ####################################################################### @@ -171,7 +139,7 @@ async def revoke_all(cls, user_id: int): @classmethod async def authenticate( - cls, user_id: int, code: str, db_encryption_key: str + cls, user_id: int, code: str, encryption_provider: EncryptionProvider ) -> bool: secret = ( await cls.objects() @@ -194,8 +162,8 @@ async def authenticate( ) return False - shared_secret = _decrypt( - encrypted_value=secret.secret, db_encryption_key=db_encryption_key + shared_secret = encryption_provider.decrypt( + encrypted_value=secret.secret ) totp = pyotp.TOTP(shared_secret) # type: ignore @@ -256,13 +224,13 @@ async def is_user_enrolled(cls, user_id: int) -> bool: def get_authentication_setup_uri( self, email: str, - db_encryption_key: str, + encryption_provider: EncryptionProvider, issuer_name: str = "Piccolo-MFA", ) -> str: pyotp = get_pyotp() - shared_secret = _decrypt( - encrypted_value=self.secret, db_encryption_key=db_encryption_key + shared_secret = encryption_provider.decrypt( + encrypted_value=self.secret ) return pyotp.totp.TOTP(shared_secret).provisioning_uri( # type: ignore diff --git a/requirements/extras/authenticator.txt b/requirements/extras/authenticator.txt index 5c4c2489..55bd1192 100644 --- a/requirements/extras/authenticator.txt +++ b/requirements/extras/authenticator.txt @@ -1,3 +1,2 @@ pyotp==2.9.0 qrcode==7.4.2 -cryptography==43.0.0 diff --git a/requirements/extras/cryptography.txt b/requirements/extras/cryptography.txt new file mode 100644 index 00000000..ce12e287 --- /dev/null +++ b/requirements/extras/cryptography.txt @@ -0,0 +1 @@ +cryptography==43.0.0 From b7bbf869ec8d66ec22e4311101274d77b0425b60 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 30 Aug 2024 17:32:08 +0100 Subject: [PATCH 074/102] update code to use encryption provider --- example_projects/mfa_demo/app.py | 9 ++- piccolo_api/encryption/providers.py | 67 ++++++++++++----------- piccolo_api/mfa/authenticator/provider.py | 7 +-- tests/mfa/authenticator/test_tables.py | 13 ++++- 4 files changed, 56 insertions(+), 40 deletions(-) diff --git a/example_projects/mfa_demo/app.py b/example_projects/mfa_demo/app.py index a6a471d7..f040c8c1 100644 --- a/example_projects/mfa_demo/app.py +++ b/example_projects/mfa_demo/app.py @@ -10,6 +10,7 @@ from starlette.routing import Mount, Route from piccolo_api.csrf.middleware import CSRFMiddleware +from piccolo_api.encryption.providers import FernetProvider from piccolo_api.mfa.authenticator.provider import AuthenticatorProvider from piccolo_api.mfa.endpoints import mfa_setup from piccolo_api.register.endpoints import register @@ -56,7 +57,9 @@ def on_auth_error(request: Request, exc: Exception): "/mfa-setup/", mfa_setup( provider=AuthenticatorProvider( - db_encryption_key=EXAMPLE_DB_ENCRYPTION_KEY + encryption_provider=FernetProvider( + encryption_key=EXAMPLE_DB_ENCRYPTION_KEY + ) ) ), ), @@ -80,7 +83,9 @@ def on_auth_error(request: Request, exc: Exception): session_login( mfa_providers=[ AuthenticatorProvider( - db_encryption_key=EXAMPLE_DB_ENCRYPTION_KEY + encryption_provider=FernetProvider( + encryption_key=EXAMPLE_DB_ENCRYPTION_KEY + ) ) ] ), diff --git a/piccolo_api/encryption/providers.py b/piccolo_api/encryption/providers.py index 5e361bd9..cc1ead52 100644 --- a/piccolo_api/encryption/providers.py +++ b/piccolo_api/encryption/providers.py @@ -26,7 +26,7 @@ def get_cryptography() -> cryptography: # type: ignore return cryptography -class EncryptionProvider(meta=ABCMeta): +class EncryptionProvider(metaclass=ABCMeta): """ Base class for encryption providers. Don't use it directly, it must be subclassed. @@ -36,79 +36,84 @@ def __init__(self, prefix: str): self.prefix = prefix @abstractmethod - def encrypt(self, value: str, *args, **kwargs) -> str: + def encrypt(self, value: str, add_prefix: bool = True) -> str: raise NotImplementedError() @abstractmethod - def decrypt(self, value: str, *args, **kwargs) -> str: + def decrypt(self, encrypted_value: str, has_prefix: bool = True) -> str: raise NotImplementedError() + def remove_prefix(self, encrypted_value: str) -> str: + if encrypted_value.startswith(self.prefix): + return encrypted_value.lstrip(f"{self.prefix}-") + else: + raise ValueError( + "Unable to identify which encryption was used - if moving " + "to a new encryption provider, use " + "`migrate_encrypted_value`." + ) + + def add_prefix(self, encrypted_value: str) -> str: + return f"{self.prefix}-{encrypted_value}" + class PlainTextProvider(EncryptionProvider): """ - Store the + Store the """ def __init__(self): - super.__init__(prefix="plain") + super().__init__(prefix="plain") - def encrypt(self, value: str, *args, **kwargs) -> str: - return value + def encrypt(self, value: str, add_prefix: bool = True) -> str: + return self.add_prefix(value) if add_prefix else value - def decrypt(self, value: str, *args, **kwargs) -> str: - return value + def decrypt(self, encrypted_value: str, has_prefix: bool = True) -> str: + return ( + self.remove_prefix(encrypted_value) + if has_prefix + else encrypted_value + ) class FernetProvider(EncryptionProvider): def __init__(self, encryption_key: str): """ - :param db_encryption_key: + :param encryption_key: This can be generated using ``FernetEncryption.get_new_key()``. """ self.encryption_key = encryption_key - super.__init__(prefix="fernet") + super().__init__(prefix="fernet") @staticmethod def get_new_key() -> str: cryptography = get_cryptography() return cryptography.fernet.Fernet.generate_key() # type: ignore - def encrypt( - self, value: str, use_prefix: bool = True, *args, **kwargs - ) -> str: + def encrypt(self, value: str, add_prefix: bool = True) -> str: cryptography = get_cryptography() fernet = cryptography.fernet.Fernet( # type: ignore self.encryption_key ) encrypted_value = fernet.encrypt(value.encode("utf8")).decode("utf8") return ( - f"{self.prefix}-{encrypted_value}" - if use_prefix + self.add_prefix(encrypted_value=encrypted_value) + if add_prefix else encrypted_value ) - def decrypt( - self, encrypted_value: str, use_prefix: bool = True, *args, **kwargs - ) -> str: - cryptography = get_cryptography() + def decrypt(self, encrypted_value: str, has_prefix: bool = True) -> str: + if has_prefix: + encrypted_value = self.remove_prefix(encrypted_value) - if use_prefix: - if encrypted_value.startswith(self.prefix): - encrypted_value = encrypted_value.lstrip(f"{self.prefix}-") - else: - raise ValueError( - "Unable to identify which encryption was used - if moving " - "to a new encryption provider, use " - "`migrate_encrypted_value`." - ) + cryptography = get_cryptography() fernet = cryptography.fernet.Fernet( # type: ignore self.encryption_key ) - value = fernet.decrypt(encrypted_value.encode("utf8")) - return value.decode("utf8") + return fernet.decrypt(encrypted_value.encode("utf8")).decode("utf8") async def migrate_encrypted_value( diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index 44d017f6..94c82b57 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -98,7 +98,7 @@ async def _generate_qrcode_image( ): uri = secret.get_authentication_setup_uri( email=email, - db_encryption_key=self.db_encryption_key, + encryption_provider=self.encryption_provider, issuer_name=self.issuer_name, ) @@ -111,7 +111,7 @@ async def get_registration_html(self, user: BaseUser) -> str: """ secret, recovery_codes = await self.secret_table.create_new( user_id=user.id, - db_encryption_key=self.db_encryption_key, + encryption_provider=self.encryption_provider, recovery_code_count=self.recovery_code_count, ) @@ -132,8 +132,7 @@ async def get_registration_json(self, user: BaseUser) -> dict: response, rather than HTML, if they want to render the UI themselves. """ secret, recovery_codes = await self.secret_table.create_new( - user_id=user.id, - db_encryption_key=self.db_encryption_key, + user_id=user.id, encryption_provider=self.encryption_provider ) qrcode_image = await self._generate_qrcode_image( diff --git a/tests/mfa/authenticator/test_tables.py b/tests/mfa/authenticator/test_tables.py index db988037..3f56e2e8 100644 --- a/tests/mfa/authenticator/test_tables.py +++ b/tests/mfa/authenticator/test_tables.py @@ -6,6 +6,7 @@ from piccolo.testing.test_case import AsyncTableTest from example_projects.mfa_demo.app import EXAMPLE_DB_ENCRYPTION_KEY +from piccolo_api.encryption.providers import FernetProvider from piccolo_api.mfa.authenticator.tables import AuthenticatorSecret @@ -41,7 +42,9 @@ async def test_replay_attack(self, logger: MagicMock): secret, _ = await AuthenticatorSecret.create_new( user_id=user.id, - db_encryption_key=EXAMPLE_DB_ENCRYPTION_KEY, + encryption_provider=FernetProvider( + encryption_key=EXAMPLE_DB_ENCRYPTION_KEY + ), ) secret.last_used_code = code await secret.save() @@ -49,7 +52,9 @@ async def test_replay_attack(self, logger: MagicMock): auth_response = await AuthenticatorSecret.authenticate( user_id=user.id, code=code, - db_encryption_key=EXAMPLE_DB_ENCRYPTION_KEY, + encryption_provider=FernetProvider( + encryption_key=EXAMPLE_DB_ENCRYPTION_KEY + ), ) assert auth_response is False @@ -69,7 +74,9 @@ async def test_create_new(self): secret, _ = await AuthenticatorSecret.create_new( user_id=user.id, - db_encryption_key=EXAMPLE_DB_ENCRYPTION_KEY, + encryption_provider=FernetProvider( + encryption_key=EXAMPLE_DB_ENCRYPTION_KEY + ), ) self.assertEqual(secret.id, user.id) From ea1dd57e40a8d367547519c835ea8c96c55d3ff8 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 30 Aug 2024 18:00:56 +0100 Subject: [PATCH 075/102] improve the docstring for `mfa_setup` - mention rate limiting --- piccolo_api/mfa/endpoints.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/piccolo_api/mfa/endpoints.py b/piccolo_api/mfa/endpoints.py index 278be726..b4f7187b 100644 --- a/piccolo_api/mfa/endpoints.py +++ b/piccolo_api/mfa/endpoints.py @@ -158,6 +158,13 @@ def mfa_setup( This endpoint needs to be protected ``SessionAuthMiddleware``, ensuring that only logged in users can access it. + We also recommend protecting it with ``RateLimitingMiddleware``, because: + + * Some of the forms accept a password, and we want to protect against brute + forcing. + * Generating secrets and refresh tokens is somewhat expensive, so we want + to protect against abuse. + Users can setup and manage their MFA setup using this endpoint. """ From a910d17f9f28cd9c60ad2b234d3a7d0bd2244763 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 30 Aug 2024 18:54:18 +0100 Subject: [PATCH 076/102] improve docstrings --- piccolo_api/encryption/providers.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/piccolo_api/encryption/providers.py b/piccolo_api/encryption/providers.py index cc1ead52..14541b39 100644 --- a/piccolo_api/encryption/providers.py +++ b/piccolo_api/encryption/providers.py @@ -59,7 +59,7 @@ def add_prefix(self, encrypted_value: str) -> str: class PlainTextProvider(EncryptionProvider): """ - Store the + The values aren't encrypted - can be useful for testing. """ def __init__(self): @@ -80,6 +80,8 @@ class FernetProvider(EncryptionProvider): def __init__(self, encryption_key: str): """ + Uses the Fernet algorithm for encryption. + :param encryption_key: This can be generated using ``FernetEncryption.get_new_key()``. @@ -116,7 +118,7 @@ def decrypt(self, encrypted_value: str, has_prefix: bool = True) -> str: return fernet.decrypt(encrypted_value.encode("utf8")).decode("utf8") -async def migrate_encrypted_value( +def migrate_encrypted_value( old_provider: EncryptionProvider, new_provider: EncryptionProvider, encrypted_value: str, From 3ff17781660af3511cd7002696e053035dde947a Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 30 Aug 2024 19:02:12 +0100 Subject: [PATCH 077/102] add params to docstrings --- piccolo_api/encryption/providers.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/piccolo_api/encryption/providers.py b/piccolo_api/encryption/providers.py index 14541b39..3b38ad76 100644 --- a/piccolo_api/encryption/providers.py +++ b/piccolo_api/encryption/providers.py @@ -37,10 +37,28 @@ def __init__(self, prefix: str): @abstractmethod def encrypt(self, value: str, add_prefix: bool = True) -> str: + """ + :param value: + The value to encrypt. + :param add_prefix: + For example, with ``FernetProvider``, it will return a value like: + ``'fernet-abc123'`` if ``add_prefix=True``. It can be useful to + have some idea of how the value was encrypted if stored in a + database. + + """ raise NotImplementedError() @abstractmethod def decrypt(self, encrypted_value: str, has_prefix: bool = True) -> str: + """ + :param encrypted_value: + The value to decrypt. + :param has_prefix: + If the value has a prefix or not, indicating the algorithm used, + i.e. ``'fernet-abc123'`` or just ``'abc123'``. + + """ raise NotImplementedError() def remove_prefix(self, encrypted_value: str) -> str: From 96ca0aed093c6c931fc9a2e80d3bbfc6536dbd07 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 5 Sep 2024 11:36:54 +0100 Subject: [PATCH 078/102] remove `device_name` - not currently used --- ...uthenticator_2024_08_08t21_41_46_837552.py | 30 +------------------ piccolo_api/mfa/authenticator/tables.py | 10 +------ 2 files changed, 2 insertions(+), 38 deletions(-) diff --git a/piccolo_api/mfa/authenticator/piccolo_migrations/mfa_authenticator_2024_08_08t21_41_46_837552.py b/piccolo_api/mfa/authenticator/piccolo_migrations/mfa_authenticator_2024_08_08t21_41_46_837552.py index 7d9153b9..e51ea4c0 100644 --- a/piccolo_api/mfa/authenticator/piccolo_migrations/mfa_authenticator_2024_08_08t21_41_46_837552.py +++ b/piccolo_api/mfa/authenticator/piccolo_migrations/mfa_authenticator_2024_08_08t21_41_46_837552.py @@ -1,11 +1,5 @@ from piccolo.apps.migrations.auto.migration_manager import MigrationManager -from piccolo.columns.column_types import ( - Array, - Integer, - Text, - Timestamptz, - Varchar, -) +from piccolo.columns.column_types import Array, Integer, Text, Timestamptz from piccolo.columns.defaults.timestamptz import TimestamptzNow from piccolo.columns.indexes import IndexMethod @@ -47,28 +41,6 @@ async def forwards(): schema=None, ) - manager.add_column( - table_class_name="AuthenticatorSecret", - tablename="authenticator_secret", - column_name="device_name", - db_column_name="device_name", - column_class_name="Varchar", - column_class=Varchar, - params={ - "length": 255, - "default": None, - "null": True, - "primary_key": False, - "unique": False, - "index": False, - "index_method": IndexMethod.btree, - "choices": None, - "db_column_name": None, - "secret": False, - }, - schema=None, - ) - manager.add_column( table_class_name="AuthenticatorSecret", tablename="authenticator_secret", diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index 1915d59b..2207d833 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -5,7 +5,7 @@ import typing as t from piccolo.apps.user.tables import BaseUser -from piccolo.columns import Array, Integer, Serial, Text, Timestamptz, Varchar +from piccolo.columns import Array, Integer, Serial, Text, Timestamptz from piccolo.table import Table from piccolo_api.encryption.providers import EncryptionProvider @@ -34,14 +34,6 @@ def get_pyotp() -> pyotp: # type: ignore class AuthenticatorSecret(Table): id: Serial user_id = Integer(null=False) - device_name = Varchar( - null=True, - default=None, - help_text=( - "The user can specify this to make the device memorable, " - "if we want to allow them to delete secrets." - ), - ) secret = Text(secret=True) recovery_codes = Array( Text(), From 210bfa3f3c7d49c1b6e0ec5a99e4f169c3c61b41 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 5 Sep 2024 11:37:31 +0100 Subject: [PATCH 079/102] change `revoke_all` to `revoke` The current design assumes a single device per user --- piccolo_api/mfa/authenticator/provider.py | 2 +- piccolo_api/mfa/authenticator/tables.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index 94c82b57..e9543976 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -142,4 +142,4 @@ async def get_registration_json(self, user: BaseUser) -> dict: return {"qrcode_image": qrcode_image, "recovery_codes": recovery_codes} async def delete_registration(self, user: BaseUser): - await self.secret_table.revoke_all(user_id=user.id) + await self.secret_table.revoke(user_id=user.id) diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index 2207d833..0f3b3107 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -122,7 +122,7 @@ async def create_new( return (instance, recovery_codes) @classmethod - async def revoke_all(cls, user_id: int): + async def revoke(cls, user_id: int): now = datetime.datetime.now(tz=datetime.timezone.utc) await cls.update({cls.revoked_at: now}).where( cls.user_id == user_id, From 1a21746edcc0418166c491459ad496dfe4a48642 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 5 Sep 2024 12:05:24 +0100 Subject: [PATCH 080/102] add `XChaCha20Provider` --- example_projects/mfa_demo/app.py | 8 +- piccolo_api/encryption/providers.py | 94 ++++++++++++++++++++++- piccolo_api/mfa/authenticator/provider.py | 2 +- requirements/extras/pynacl.txt | 1 + tests/mfa/authenticator/test_tables.py | 8 +- 5 files changed, 100 insertions(+), 13 deletions(-) create mode 100644 requirements/extras/pynacl.txt diff --git a/example_projects/mfa_demo/app.py b/example_projects/mfa_demo/app.py index f040c8c1..257f861e 100644 --- a/example_projects/mfa_demo/app.py +++ b/example_projects/mfa_demo/app.py @@ -10,14 +10,14 @@ from starlette.routing import Mount, Route from piccolo_api.csrf.middleware import CSRFMiddleware -from piccolo_api.encryption.providers import FernetProvider +from piccolo_api.encryption.providers import XChaCha20Provider from piccolo_api.mfa.authenticator.provider import AuthenticatorProvider from piccolo_api.mfa.endpoints import mfa_setup from piccolo_api.register.endpoints import register from piccolo_api.session_auth.endpoints import session_login, session_logout from piccolo_api.session_auth.middleware import SessionsAuthBackend -EXAMPLE_DB_ENCRYPTION_KEY = "wqsOqyTTEsrWppZeIMS8a3l90yPUtrqT48z7FS6_U8g=" +EXAMPLE_DB_ENCRYPTION_KEY = b"W\x8b&E[\x8elr\xba\xb7\x19g\n\xd5`g\xea!Q#\x97\xcf\xed\xdd+\xc7\x0e\xf7P\x82\xdf\x86" # noqa: E501 environment = Environment( @@ -57,7 +57,7 @@ def on_auth_error(request: Request, exc: Exception): "/mfa-setup/", mfa_setup( provider=AuthenticatorProvider( - encryption_provider=FernetProvider( + encryption_provider=XChaCha20Provider( encryption_key=EXAMPLE_DB_ENCRYPTION_KEY ) ) @@ -83,7 +83,7 @@ def on_auth_error(request: Request, exc: Exception): session_login( mfa_providers=[ AuthenticatorProvider( - encryption_provider=FernetProvider( + encryption_provider=XChaCha20Provider( encryption_key=EXAMPLE_DB_ENCRYPTION_KEY ) ) diff --git a/piccolo_api/encryption/providers.py b/piccolo_api/encryption/providers.py index 3b38ad76..0f861985 100644 --- a/piccolo_api/encryption/providers.py +++ b/piccolo_api/encryption/providers.py @@ -5,9 +5,12 @@ from abc import ABCMeta, abstractmethod import cryptography.fernet +import nacl.encoding +import nacl.secret if t.TYPE_CHECKING: import cryptography + import nacl logger = logging.getLogger(__name__) @@ -96,7 +99,7 @@ def decrypt(self, encrypted_value: str, has_prefix: bool = True) -> str: class FernetProvider(EncryptionProvider): - def __init__(self, encryption_key: str): + def __init__(self, encryption_key: bytes): """ Uses the Fernet algorithm for encryption. @@ -108,7 +111,7 @@ def __init__(self, encryption_key: str): super().__init__(prefix="fernet") @staticmethod - def get_new_key() -> str: + def get_new_key() -> bytes: cryptography = get_cryptography() return cryptography.fernet.Fernet.generate_key() # type: ignore @@ -117,7 +120,7 @@ def encrypt(self, value: str, add_prefix: bool = True) -> str: fernet = cryptography.fernet.Fernet( # type: ignore self.encryption_key ) - encrypted_value = fernet.encrypt(value.encode("utf8")).decode("utf8") + encrypted_value = fernet.encrypt(value.encode("utf-8")).decode("utf-8") return ( self.add_prefix(encrypted_value=encrypted_value) if add_prefix @@ -133,7 +136,90 @@ def decrypt(self, encrypted_value: str, has_prefix: bool = True) -> str: fernet = cryptography.fernet.Fernet( # type: ignore self.encryption_key ) - return fernet.decrypt(encrypted_value.encode("utf8")).decode("utf8") + return fernet.decrypt(encrypted_value.encode("utf-8")).decode("utf-8") + + +def get_nacl_encoding() -> nacl.encoding: # type: ignore + try: + import nacl.encoding + except ImportError as e: + print("Install pip install piccolo_api[pynacl] to use this feature.") + raise e + + return nacl.encoding + + +def get_nacl_utils() -> nacl.utils: # type: ignore + try: + import nacl.utils + except ImportError as e: + print("Install pip install piccolo_api[pynacl] to use this feature.") + raise e + + return nacl.utils + + +def get_nacl_secret() -> nacl.secret: # type: ignore + try: + import nacl.secret + except ImportError as e: + print("Install pip install piccolo_api[pynacl] to use this feature.") + raise e + + return nacl.secret + + +class XChaCha20Provider(EncryptionProvider): + + def __init__(self, encryption_key: bytes): + """ + Uses the XChaCha20-Poly1305 algorithm for encryption. + + This is more secure than ``FernetProvider``. + + :param encryption_key: + This can be generated using ``XChaCha20Provider.get_new_key()``. + + """ + self.encryption_key = encryption_key + super().__init__(prefix="xchacha20") + + @staticmethod + def get_new_key() -> bytes: + nacl_utils = get_nacl_utils() + return nacl_utils.random(nacl.secret.Aead.KEY_SIZE) # type: ignore + + def _get_nacl_box(self) -> nacl.secret.Aead: + nacl_secret = get_nacl_secret() + return nacl_secret.Aead(self.encryption_key) # type: ignore + + def _get_encoder(self) -> t.Type[nacl.encoding.URLSafeBase64Encoder]: + nacl_encoding = get_nacl_encoding() + return nacl_encoding.URLSafeBase64Encoder # type: ignore + + def encrypt(self, value: str, add_prefix: bool = True) -> str: + box = self._get_nacl_box() + + encrypted_value = box.encrypt( + value.encode("utf-8"), + encoder=self._get_encoder(), + ).decode("utf-8") + + return ( + self.add_prefix(encrypted_value=encrypted_value) + if add_prefix + else encrypted_value + ) + + def decrypt(self, encrypted_value: str, has_prefix: bool = True) -> str: + if has_prefix: + encrypted_value = self.remove_prefix(encrypted_value) + + box = self._get_nacl_box() + return box.decrypt( + encrypted_value.encode("utf-8"), + encoder=self._get_encoder(), + ).decode("utf-8") def migrate_encrypted_value( diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index e9543976..52fc704d 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -34,7 +34,7 @@ def __init__( :param encryption_provider: The shared secrets can be encrypted in the database. We recommend - using :class:`piccolo_api.encryption.provider.FernetProvider`. + using :class:`piccolo_api.encryption.provider.XChaCha20Provider`. Use :class:`piccolo_api.encryption.provider.PlainTextProvider` to store the secrets as plain text. :param recovery_code_count: diff --git a/requirements/extras/pynacl.txt b/requirements/extras/pynacl.txt new file mode 100644 index 00000000..ae848ece --- /dev/null +++ b/requirements/extras/pynacl.txt @@ -0,0 +1 @@ +PyNaCl==1.5.0 diff --git a/tests/mfa/authenticator/test_tables.py b/tests/mfa/authenticator/test_tables.py index 3f56e2e8..2894f2e1 100644 --- a/tests/mfa/authenticator/test_tables.py +++ b/tests/mfa/authenticator/test_tables.py @@ -6,7 +6,7 @@ from piccolo.testing.test_case import AsyncTableTest from example_projects.mfa_demo.app import EXAMPLE_DB_ENCRYPTION_KEY -from piccolo_api.encryption.providers import FernetProvider +from piccolo_api.encryption.providers import XChaCha20Provider from piccolo_api.mfa.authenticator.tables import AuthenticatorSecret @@ -42,7 +42,7 @@ async def test_replay_attack(self, logger: MagicMock): secret, _ = await AuthenticatorSecret.create_new( user_id=user.id, - encryption_provider=FernetProvider( + encryption_provider=XChaCha20Provider( encryption_key=EXAMPLE_DB_ENCRYPTION_KEY ), ) @@ -52,7 +52,7 @@ async def test_replay_attack(self, logger: MagicMock): auth_response = await AuthenticatorSecret.authenticate( user_id=user.id, code=code, - encryption_provider=FernetProvider( + encryption_provider=XChaCha20Provider( encryption_key=EXAMPLE_DB_ENCRYPTION_KEY ), ) @@ -74,7 +74,7 @@ async def test_create_new(self): secret, _ = await AuthenticatorSecret.create_new( user_id=user.id, - encryption_provider=FernetProvider( + encryption_provider=XChaCha20Provider( encryption_key=EXAMPLE_DB_ENCRYPTION_KEY ), ) From 8226e6a75682e92be54a2454dc987c23b80c3f18 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 5 Sep 2024 12:13:31 +0100 Subject: [PATCH 081/102] make sure pynacl is installed in tests --- .github/workflows/tests.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 20a81365..a984b371 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -22,6 +22,7 @@ jobs: 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 From eb5bfa5e13f27bf17682b8d84d0773949b24f6da Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 5 Sep 2024 12:32:12 +0100 Subject: [PATCH 082/102] make sure pynacl is installed in tests (continued) --- .github/workflows/tests.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index a984b371..fae4a611 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -63,6 +63,7 @@ jobs: 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: @@ -91,6 +92,7 @@ jobs: 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 From 5379bb040342b5c3de10651cab75d62c71fee3e8 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 5 Sep 2024 12:36:38 +0100 Subject: [PATCH 083/102] improve coverage --- piccolo_api/mfa/authenticator/tables.py | 4 ++-- piccolo_api/mfa/authenticator/utils.py | 4 ++-- piccolo_api/mfa/provider.py | 6 ------ 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index 0f3b3107..31393463 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -11,14 +11,14 @@ from piccolo_api.encryption.providers import EncryptionProvider from piccolo_api.mfa.recovery_codes import generate_recovery_code -if t.TYPE_CHECKING: +if t.TYPE_CHECKING: # pragma: no cover import pyotp logger = logging.getLogger(__name__) -def get_pyotp() -> pyotp: # type: ignore +def get_pyotp() -> pyotp: # type: ignore # pragma: no cover try: import pyotp except ImportError as e: diff --git a/piccolo_api/mfa/authenticator/utils.py b/piccolo_api/mfa/authenticator/utils.py index 44de8694..abd49941 100644 --- a/piccolo_api/mfa/authenticator/utils.py +++ b/piccolo_api/mfa/authenticator/utils.py @@ -4,11 +4,11 @@ from base64 import b64encode from io import BytesIO -if t.TYPE_CHECKING: +if t.TYPE_CHECKING: # pragma: no cover import qrcode -def get_qrcode() -> qrcode: +def get_qrcode() -> qrcode: # pragma: no cover try: import qrcode except ImportError as e: diff --git a/piccolo_api/mfa/provider.py b/piccolo_api/mfa/provider.py index 4251e0fe..2b94ec6b 100644 --- a/piccolo_api/mfa/provider.py +++ b/piccolo_api/mfa/provider.py @@ -28,7 +28,6 @@ async def authenticate_user(self, user: BaseUser, code: str) -> bool: The code could be a TOTP code, or a recovery code. """ - pass @abstractmethod async def is_user_enrolled(self, user: BaseUser) -> bool: @@ -36,7 +35,6 @@ async def is_user_enrolled(self, user: BaseUser) -> bool: Should return ``True`` if the user is enrolled in this MFA, and hence should submit a code. """ - pass @abstractmethod async def send_code(self, user: BaseUser): @@ -44,7 +42,6 @@ async def send_code(self, user: BaseUser): If the provider needs to send a code (e.g. if using email or SMS), then implement it here. For app based TOTP codes, this can be a NO-OP. """ - pass ########################################################################### # Registration @@ -55,7 +52,6 @@ async def get_registration_html(self, user: BaseUser) -> str: When a user wants to register for MFA, this HTML is shown containing instructions. """ - pass @abstractmethod async def get_registration_json(self, user: BaseUser) -> dict: @@ -63,11 +59,9 @@ async def get_registration_json(self, user: BaseUser) -> dict: When a user wants to register for MFA, the client can request a JSON response, rather than HTML, if they want to render the UI themselves. """ - pass @abstractmethod async def delete_registration(self, user: BaseUser): """ Used to remove the MFA. """ - pass From 3e673dddab3922a6d3dc80c68a4f776dfd124395 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 5 Sep 2024 12:36:45 +0100 Subject: [PATCH 084/102] add `TestRevoke` --- tests/mfa/authenticator/test_tables.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/mfa/authenticator/test_tables.py b/tests/mfa/authenticator/test_tables.py index 2894f2e1..5d8e1129 100644 --- a/tests/mfa/authenticator/test_tables.py +++ b/tests/mfa/authenticator/test_tables.py @@ -85,3 +85,29 @@ async def test_create_new(self): self.assertIsNone(secret.last_used_at) self.assertIsNone(secret.revoked_at) self.assertIsNone(secret.last_used_code) + + +class TestRevoke(AsyncTableTest): + """ + Make sure we can revoke a user's MFA code. + """ + + tables = [AuthenticatorSecret, BaseUser] + + async def test_revoke(self): + user = await BaseUser.create_user( + username="test", password="test123456" + ) + + secret, _ = await AuthenticatorSecret.create_new( + user_id=user.id, + encryption_provider=XChaCha20Provider( + encryption_key=EXAMPLE_DB_ENCRYPTION_KEY + ), + ) + + await AuthenticatorSecret.revoke(user_id=user.id) + + await secret.refresh() + + assert secret.revoked_at is not None From 780d7ad1069bfd05d1590aece85f3b11098254e1 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 5 Sep 2024 13:02:14 +0100 Subject: [PATCH 085/102] add a test to make sure auth works --- tests/mfa/authenticator/test_tables.py | 32 ++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/mfa/authenticator/test_tables.py b/tests/mfa/authenticator/test_tables.py index 5d8e1129..fd3fc52a 100644 --- a/tests/mfa/authenticator/test_tables.py +++ b/tests/mfa/authenticator/test_tables.py @@ -1,7 +1,9 @@ import datetime +import time from unittest import TestCase from unittest.mock import MagicMock, patch +import pyotp from piccolo.apps.user.tables import BaseUser from piccolo.testing.test_case import AsyncTableTest @@ -62,6 +64,36 @@ async def test_replay_attack(self, logger: MagicMock): "User 1 reused a token - potential replay attack." ) + async def test_success(self): + """ + Need to + """ + user = await BaseUser.create_user( + username="test", password="test123456" + ) + + encryption_provider = XChaCha20Provider( + encryption_key=EXAMPLE_DB_ENCRYPTION_KEY + ) + + authenticator_secret, _ = await AuthenticatorSecret.create_new( + user_id=user.id, + encryption_provider=encryption_provider, + ) + + secret = encryption_provider.decrypt(authenticator_secret.secret) + + # Generate a valid code + code = pyotp.TOTP(s=secret).now() + + auth_response = await AuthenticatorSecret.authenticate( + user_id=user.id, + code=code, + encryption_provider=encryption_provider, + ) + + assert auth_response is True + class TestCreateNew(AsyncTableTest): From 27f5658916c64424d978adecaf1aeb59cf3935ff Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 5 Sep 2024 13:45:48 +0100 Subject: [PATCH 086/102] remove unused import --- tests/mfa/authenticator/test_tables.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/mfa/authenticator/test_tables.py b/tests/mfa/authenticator/test_tables.py index fd3fc52a..42f3c29b 100644 --- a/tests/mfa/authenticator/test_tables.py +++ b/tests/mfa/authenticator/test_tables.py @@ -1,5 +1,4 @@ import datetime -import time from unittest import TestCase from unittest.mock import MagicMock, patch @@ -94,6 +93,9 @@ async def test_success(self): assert auth_response is True + async def test_recovery_code(self): + pass + class TestCreateNew(AsyncTableTest): From bd5829e27d2e49dc9d0047f93829ee911ed4268a Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Thu, 5 Sep 2024 14:18:32 +0100 Subject: [PATCH 087/102] add tests for recovery codes --- tests/mfa/authenticator/test_tables.py | 59 ++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/tests/mfa/authenticator/test_tables.py b/tests/mfa/authenticator/test_tables.py index 42f3c29b..fbc3d9be 100644 --- a/tests/mfa/authenticator/test_tables.py +++ b/tests/mfa/authenticator/test_tables.py @@ -63,9 +63,9 @@ async def test_replay_attack(self, logger: MagicMock): "User 1 reused a token - potential replay attack." ) - async def test_success(self): + async def test_code(self): """ - Need to + Make sure a valid code can be used to authenticate. """ user = await BaseUser.create_user( username="test", password="test123456" @@ -80,21 +80,64 @@ async def test_success(self): encryption_provider=encryption_provider, ) - secret = encryption_provider.decrypt(authenticator_secret.secret) + print("secret = ", authenticator_secret.secret) + try: + secret = encryption_provider.decrypt(authenticator_secret.secret) + except Exception as e: + foo = e + breakpoint() - # Generate a valid code - code = pyotp.TOTP(s=secret).now() + # Make sure a valid code works + auth_response = await AuthenticatorSecret.authenticate( + user_id=user.id, + code=pyotp.TOTP(s=secret).now(), + encryption_provider=encryption_provider, + ) + assert auth_response is True + # Make sure an invalid code fails auth_response = await AuthenticatorSecret.authenticate( user_id=user.id, - code=code, + code="ABC123", encryption_provider=encryption_provider, ) + assert auth_response is False + + async def test_recovery_code(self): + """ + Make sure a valid recovery code can be used to authenticate. + """ + user = await BaseUser.create_user( + username="test", password="test123456" + ) + + encryption_provider = XChaCha20Provider( + encryption_key=EXAMPLE_DB_ENCRYPTION_KEY + ) + + authenticator_secret, recovery_codes = ( + await AuthenticatorSecret.create_new( + user_id=user.id, + encryption_provider=encryption_provider, + ) + ) + # Make sure a valid recovery code works + auth_response = await AuthenticatorSecret.authenticate( + user_id=user.id, + code=recovery_codes[0], + encryption_provider=encryption_provider, + ) assert auth_response is True - async def test_recovery_code(self): - pass + # Make sure an invalid recovery code fails + fake_code = "".join("a" for _ in range(len(recovery_codes[0]))) + auth_response = await AuthenticatorSecret.authenticate( + user_id=user.id, + code=fake_code, + encryption_provider=encryption_provider, + ) + assert auth_response is False class TestCreateNew(AsyncTableTest): From 81b09e4f2a3aa4481aba358fc30be2557ee53d70 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 6 Sep 2024 17:59:14 +0100 Subject: [PATCH 088/102] remove breakpoint --- tests/mfa/authenticator/test_tables.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/mfa/authenticator/test_tables.py b/tests/mfa/authenticator/test_tables.py index fbc3d9be..d4493f85 100644 --- a/tests/mfa/authenticator/test_tables.py +++ b/tests/mfa/authenticator/test_tables.py @@ -80,12 +80,7 @@ async def test_code(self): encryption_provider=encryption_provider, ) - print("secret = ", authenticator_secret.secret) - try: - secret = encryption_provider.decrypt(authenticator_secret.secret) - except Exception as e: - foo = e - breakpoint() + secret = encryption_provider.decrypt(authenticator_secret.secret) # Make sure a valid code works auth_response = await AuthenticatorSecret.authenticate( From c8d679cd33d36bf3463c119fc91c9a598c49b403 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Fri, 6 Sep 2024 19:06:49 +0100 Subject: [PATCH 089/102] fix bug with prefix --- piccolo_api/encryption/providers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piccolo_api/encryption/providers.py b/piccolo_api/encryption/providers.py index 0f861985..b8deb32b 100644 --- a/piccolo_api/encryption/providers.py +++ b/piccolo_api/encryption/providers.py @@ -66,7 +66,7 @@ def decrypt(self, encrypted_value: str, has_prefix: bool = True) -> str: def remove_prefix(self, encrypted_value: str) -> str: if encrypted_value.startswith(self.prefix): - return encrypted_value.lstrip(f"{self.prefix}-") + return encrypted_value.replace(f"{self.prefix}-", "", 1) else: raise ValueError( "Unable to identify which encryption was used - if moving " From 9bc3939eae4be00e340da90b7e2ac1d100e09852 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Sat, 7 Sep 2024 20:14:20 +0100 Subject: [PATCH 090/102] simplify encoding --- piccolo_api/encryption/providers.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/piccolo_api/encryption/providers.py b/piccolo_api/encryption/providers.py index b8deb32b..b7555893 100644 --- a/piccolo_api/encryption/providers.py +++ b/piccolo_api/encryption/providers.py @@ -193,17 +193,10 @@ def _get_nacl_box(self) -> nacl.secret.Aead: nacl_secret = get_nacl_secret() return nacl_secret.Aead(self.encryption_key) # type: ignore - def _get_encoder(self) -> t.Type[nacl.encoding.URLSafeBase64Encoder]: - nacl_encoding = get_nacl_encoding() - return nacl_encoding.URLSafeBase64Encoder # type: ignore - def encrypt(self, value: str, add_prefix: bool = True) -> str: box = self._get_nacl_box() - encrypted_value = box.encrypt( - value.encode("utf-8"), - encoder=self._get_encoder(), - ).decode("utf-8") + encrypted_value = box.encrypt(value.encode()).hex() return ( self.add_prefix(encrypted_value=encrypted_value) @@ -216,10 +209,8 @@ def decrypt(self, encrypted_value: str, has_prefix: bool = True) -> str: encrypted_value = self.remove_prefix(encrypted_value) box = self._get_nacl_box() - return box.decrypt( - encrypted_value.encode("utf-8"), - encoder=self._get_encoder(), - ).decode("utf-8") + + return box.decrypt(bytes.fromhex(encrypted_value)).decode("utf-8") def migrate_encrypted_value( From 5bcb5cdf76e344694f972b9302c6c77a93327a07 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Sun, 8 Sep 2024 07:19:24 +0100 Subject: [PATCH 091/102] changed login logic for multiple MFA providers --- piccolo_api/mfa/authenticator/provider.py | 4 +- piccolo_api/mfa/provider.py | 4 +- piccolo_api/session_auth/endpoints.py | 116 ++++++++++++++-------- piccolo_api/templates/base.html | 7 +- piccolo_api/templates/session_login.html | 15 ++- 5 files changed, 94 insertions(+), 52 deletions(-) diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index 52fc704d..f8fca9a3 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -53,7 +53,9 @@ def __init__( Modify the appearance of the HTML template using CSS. """ - super().__init__(token_name="authenticator_token") + super().__init__( + name="Authenticator App", + ) self.encryption_provider = encryption_provider self.recovery_code_count = recovery_code_count diff --git a/piccolo_api/mfa/provider.py b/piccolo_api/mfa/provider.py index 2b94ec6b..2c1e8c68 100644 --- a/piccolo_api/mfa/provider.py +++ b/piccolo_api/mfa/provider.py @@ -5,7 +5,7 @@ class MFAProvider(metaclass=ABCMeta): - def __init__(self, token_name: str = "mfa_code"): + def __init__(self, name: str = "MFA Code"): """ This is the base class which all providers must inherit from. Use it to build your own custom providers. If you use it directly, it won't @@ -18,7 +18,7 @@ def __init__(self, token_name: str = "mfa_code"): ``MFAProvider`` it belongs to. """ # noqa: E501 - self.token_name = token_name + self.name = name @abstractmethod async def authenticate_user(self, user: BaseUser, code: str) -> bool: diff --git a/piccolo_api/session_auth/endpoints.py b/piccolo_api/session_auth/endpoints.py index c429d351..2c20b9db 100644 --- a/piccolo_api/session_auth/endpoints.py +++ b/piccolo_api/session_auth/endpoints.py @@ -280,56 +280,84 @@ async def post(self, request: Request) -> Response: assert user is not None - for mfa_provider in mfa_providers: - if await mfa_provider.is_user_enrolled(user=user): - mfa_code = body.get(mfa_provider.token_name) - - if mfa_code is None: + if enrolled_mfa_providers := [ + mfa_provider + for mfa_provider in mfa_providers + if await mfa_provider.is_user_enrolled(user=user) + ]: + mfa_code = body.get("mfa_code") + + if mfa_code is None: + for mfa_provider in enrolled_mfa_providers: # Send the code (only used with things like email # and SMS MFA). await mfa_provider.send_code(user=user) - # TODO - have a param to request a code be sent? - # It's OK for now, but we might not want to send - # a code if another was recently sent. - # That could always be in the logic of `send_code` - # though. - - if return_html: - return self._render_template( - request, - template_context={ - "error": "MFA code required", - "show_mfa_input": True, - "mfa_token_name": ( - mfa_provider.token_name - ), + if return_html: + return self._render_template( + request, + template_context={ + "error": "MFA code required", + "show_mfa_input": True, + "mfa_provider_names": [ + mfa_provider.name + for mfa_provider in enrolled_mfa_providers # noqa: E501 + ], + }, + ) + else: + raise HTTPException( + status_code=401, detail="MFA code required" + ) + + mfa_provider_name = body.get("mfa_provider_name") + + if mfa_provider_name is None: + raise HTTPException( + status_code=401, + detail="MFA provider must be specified", + ) + + filtered_mfa_providers = [ + i + for i in enrolled_mfa_providers + if i.name == mfa_provider_name + ] + + if len(filtered_mfa_providers) == 0: + raise HTTPException( + status_code=401, + detail="MFA provider not recognised.", + ) + + if len(filtered_mfa_providers) > 1: + raise HTTPException( + status_code=401, + detail="Multiple matching MFA providers found.", + ) + + active_mfa_provider = filtered_mfa_providers[0] + + if not await active_mfa_provider.authenticate_user( + user=user, code=mfa_code + ): + if return_html: + return self._render_template( + request, + template_context={ + "error": "MFA failed", + "show_mfa_input": True, + "mfa_provider_names": { + mfa_provider.name + for mfa_provider in enrolled_mfa_providers # noqa: E501 }, - ) - else: - raise HTTPException( - status_code=401, detail="MFA code required" - ) + }, + ) else: - if not await mfa_provider.authenticate_user( - user=user, code=mfa_code - ): - if return_html: - return self._render_template( - request, - template_context={ - "error": "MFA failed", - "show_mfa_input": True, - "mfa_token_name": ( - mfa_provider.token_name - ), - }, - ) - else: - raise HTTPException( - status_code=401, - detail="MFA failed", - ) + raise HTTPException( + status_code=401, + detail="MFA failed", + ) # Run login_success hooks if self._hooks and self._hooks.login_success: diff --git a/piccolo_api/templates/base.html b/piccolo_api/templates/base.html index 755c796f..3a164926 100644 --- a/piccolo_api/templates/base.html +++ b/piccolo_api/templates/base.html @@ -71,12 +71,13 @@ label { font-size: 0.85rem; + margin: 0.5rem 0; } input, - label { + label, + select { display: block; - margin: 0.5rem 0; width: 100%; } @@ -85,7 +86,7 @@ border-radius: 0.2rem; } - input { + input, select { border: 1px solid var(--border_color); padding: 0.5rem; margin: 0.5rem 0 0.8rem; diff --git a/piccolo_api/templates/session_login.html b/piccolo_api/templates/session_login.html index 9d166b78..1ecfa001 100644 --- a/piccolo_api/templates/session_login.html +++ b/piccolo_api/templates/session_login.html @@ -16,8 +16,19 @@

Login

{% if show_mfa_input %} - - + + + {% if mfa_provider_names|length > 1 %} + + {% else %} + + {% endif %} + + {% endif %} {% if csrftoken and csrf_cookie_name %} From 78de9a9340e7bf95e25131ffe9b59299876aca3e Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Sun, 8 Sep 2024 07:26:48 +0100 Subject: [PATCH 092/102] make `mfa_provider_name` param optional if there's only a single MFA provider --- piccolo_api/session_auth/endpoints.py | 60 +++++++++++++----------- piccolo_api/templates/session_login.html | 2 - 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/piccolo_api/session_auth/endpoints.py b/piccolo_api/session_auth/endpoints.py index 2c20b9db..bb2e3bcd 100644 --- a/piccolo_api/session_auth/endpoints.py +++ b/piccolo_api/session_auth/endpoints.py @@ -310,33 +310,39 @@ async def post(self, request: Request) -> Response: status_code=401, detail="MFA code required" ) - mfa_provider_name = body.get("mfa_provider_name") - - if mfa_provider_name is None: - raise HTTPException( - status_code=401, - detail="MFA provider must be specified", - ) - - filtered_mfa_providers = [ - i - for i in enrolled_mfa_providers - if i.name == mfa_provider_name - ] - - if len(filtered_mfa_providers) == 0: - raise HTTPException( - status_code=401, - detail="MFA provider not recognised.", - ) - - if len(filtered_mfa_providers) > 1: - raise HTTPException( - status_code=401, - detail="Multiple matching MFA providers found.", - ) - - active_mfa_provider = filtered_mfa_providers[0] + # Work out which MFA provider to use: + if len(enrolled_mfa_providers) == 1: + active_mfa_provider = enrolled_mfa_providers[0] + else: + mfa_provider_name = body.get("mfa_provider_name") + + if mfa_provider_name is None: + raise HTTPException( + status_code=401, + detail="MFA provider must be specified", + ) + + filtered_mfa_providers = [ + i + for i in enrolled_mfa_providers + if i.name == mfa_provider_name + ] + + if len(filtered_mfa_providers) == 0: + raise HTTPException( + status_code=401, + detail="MFA provider not recognised.", + ) + + if len(filtered_mfa_providers) > 1: + raise HTTPException( + status_code=401, + detail=( + "Multiple matching MFA providers found." + ), + ) + + active_mfa_provider = filtered_mfa_providers[0] if not await active_mfa_provider.authenticate_user( user=user, code=mfa_code diff --git a/piccolo_api/templates/session_login.html b/piccolo_api/templates/session_login.html index 1ecfa001..f808479e 100644 --- a/piccolo_api/templates/session_login.html +++ b/piccolo_api/templates/session_login.html @@ -24,8 +24,6 @@

Login

{% endfor %} - {% else %} - {% endif %} From 4e4a90fa6ed3eedea159dc20c88692903a2d9d3d Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Sun, 8 Sep 2024 13:31:30 +0100 Subject: [PATCH 093/102] add `help_text` to `revoked_at` --- piccolo_api/mfa/authenticator/tables.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index 31393463..ebfe93a3 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -45,7 +45,14 @@ class AuthenticatorSecret(Table): help_text="Whenever a recovery code is used, store a timestamp here.", ) created_at = Timestamptz() - revoked_at = Timestamptz(null=True, default=None) + revoked_at = Timestamptz( + null=True, + default=None, + help_text=( + "If set, this instance should be considered unusable for " + "authentication purposes." + ), + ) last_used_at = Timestamptz(null=True, default=None) last_used_code = Text( null=True, From 01b7b3684860f45e7d17665a75033c2ec548540c Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Sun, 8 Sep 2024 13:44:50 +0100 Subject: [PATCH 094/102] add `valid_window` argument to `AuthenticatorProvider` --- piccolo_api/mfa/authenticator/provider.py | 7 +++++++ piccolo_api/mfa/authenticator/tables.py | 14 ++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index f8fca9a3..dadcfa8a 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -27,6 +27,7 @@ def __init__( issuer_name: str = "Piccolo-MFA", register_template_path: t.Optional[str] = None, styles: t.Optional[Styles] = None, + valid_window: int = 0, ): """ Allows authentication using an authenticator app on the user's phone, @@ -51,6 +52,10 @@ def __init__( visual changes. :param styles: Modify the appearance of the HTML template using CSS. + :param valid_window: + Extends the validity to this many counter ticks before and after + the current one. Increasing it is more convenient for users, but + is less secure. """ super().__init__( @@ -62,6 +67,7 @@ def __init__( self.secret_table = secret_table self.issuer_name = issuer_name self.styles = styles or Styles() + self.valid_window = valid_window # Load the Jinja Template register_template_path = ( @@ -81,6 +87,7 @@ async def authenticate_user(self, user: BaseUser, code: str) -> bool: user_id=user.id, code=code, encryption_provider=self.encryption_provider, + valid_window=self.valid_window, ) async def is_user_enrolled(self, user: BaseUser) -> bool: diff --git a/piccolo_api/mfa/authenticator/tables.py b/piccolo_api/mfa/authenticator/tables.py index ebfe93a3..466ac2a6 100644 --- a/piccolo_api/mfa/authenticator/tables.py +++ b/piccolo_api/mfa/authenticator/tables.py @@ -138,8 +138,18 @@ async def revoke(cls, user_id: int): @classmethod async def authenticate( - cls, user_id: int, code: str, encryption_provider: EncryptionProvider + cls, + user_id: int, + code: str, + encryption_provider: EncryptionProvider, + valid_window: int = 0, ) -> bool: + """ + :param valid_window: + Extends the validity to this many counter ticks before and after + the current one. + + """ secret = ( await cls.objects() .where( @@ -166,7 +176,7 @@ async def authenticate( ) totp = pyotp.TOTP(shared_secret) # type: ignore - if totp.verify(code): + if totp.verify(code, valid_window=valid_window): secret.last_used_at = datetime.datetime.now( tz=datetime.timezone.utc ) From fa727aa44f14aab84c5fbeb5c3d1fedffce410dd Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Sun, 8 Sep 2024 14:25:52 +0100 Subject: [PATCH 095/102] tell the user whether we sent them a code --- piccolo_api/mfa/authenticator/provider.py | 4 ++-- piccolo_api/mfa/provider.py | 8 ++++++-- piccolo_api/session_auth/endpoints.py | 13 ++++++++++--- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index dadcfa8a..c2b266a8 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -93,11 +93,11 @@ async def authenticate_user(self, user: BaseUser, code: str) -> bool: async def is_user_enrolled(self, user: BaseUser) -> bool: return await self.secret_table.is_user_enrolled(user_id=user.id) - async def send_code(self, *args, **kwargs): + async def send_code(self, *args, **kwargs) -> bool: """ Deliberately blank - the user already has the code on their phone. """ - pass + return False ########################################################################### # Registration diff --git a/piccolo_api/mfa/provider.py b/piccolo_api/mfa/provider.py index 2c1e8c68..45f05e1a 100644 --- a/piccolo_api/mfa/provider.py +++ b/piccolo_api/mfa/provider.py @@ -37,10 +37,14 @@ async def is_user_enrolled(self, user: BaseUser) -> bool: """ @abstractmethod - async def send_code(self, user: BaseUser): + async def send_code(self, user: BaseUser) -> bool: """ If the provider needs to send a code (e.g. if using email or SMS), then - implement it here. For app based TOTP codes, this can be a NO-OP. + implement it here. + + Return ``True`` if a code was sent, and ``False`` if not (e.g. an app + based TOTP codes). + """ ########################################################################### diff --git a/piccolo_api/session_auth/endpoints.py b/piccolo_api/session_auth/endpoints.py index bb2e3bcd..a1580231 100644 --- a/piccolo_api/session_auth/endpoints.py +++ b/piccolo_api/session_auth/endpoints.py @@ -288,16 +288,23 @@ async def post(self, request: Request) -> Response: mfa_code = body.get("mfa_code") if mfa_code is None: + has_sent_code: t.List[bool] = [] for mfa_provider in enrolled_mfa_providers: # Send the code (only used with things like email # and SMS MFA). - await mfa_provider.send_code(user=user) + has_sent_code.append( + await mfa_provider.send_code(user=user) + ) + + message = "MFA code required" + if any(has_sent_code): + message += " (we sent you a code)" if return_html: return self._render_template( request, template_context={ - "error": "MFA code required", + "error": message, "show_mfa_input": True, "mfa_provider_names": [ mfa_provider.name @@ -307,7 +314,7 @@ async def post(self, request: Request) -> Response: ) else: raise HTTPException( - status_code=401, detail="MFA code required" + status_code=401, detail=message ) # Work out which MFA provider to use: From 2fa99620ae4309bd305423e8faf5b1ac86033f82 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Sun, 8 Sep 2024 22:09:59 +0100 Subject: [PATCH 096/102] increase coverage for `AuthenticatorSecret` --- tests/mfa/authenticator/test_tables.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/tests/mfa/authenticator/test_tables.py b/tests/mfa/authenticator/test_tables.py index d4493f85..657d451a 100644 --- a/tests/mfa/authenticator/test_tables.py +++ b/tests/mfa/authenticator/test_tables.py @@ -110,11 +110,9 @@ async def test_recovery_code(self): encryption_key=EXAMPLE_DB_ENCRYPTION_KEY ) - authenticator_secret, recovery_codes = ( - await AuthenticatorSecret.create_new( - user_id=user.id, - encryption_provider=encryption_provider, - ) + _, recovery_codes = await AuthenticatorSecret.create_new( + user_id=user.id, + encryption_provider=encryption_provider, ) # Make sure a valid recovery code works @@ -134,6 +132,23 @@ async def test_recovery_code(self): ) assert auth_response is False + async def test_unenrolled_user(self): + """ + Make sure a user who isn't enrolled fails authentication. + """ + user = await BaseUser.create_user( + username="test", password="test123456" + ) + + auth_response = await AuthenticatorSecret.authenticate( + user_id=user.id, + code="abc123", + encryption_provider=XChaCha20Provider( + encryption_key=EXAMPLE_DB_ENCRYPTION_KEY + ), + ) + assert auth_response is False + class TestCreateNew(AsyncTableTest): From fad9ef262ad88ca0f42d4599a31960bee32b541c Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Sun, 8 Sep 2024 22:10:05 +0100 Subject: [PATCH 097/102] remove TODO in endpoint test --- tests/mfa/test_mfa_endpoints.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/mfa/test_mfa_endpoints.py b/tests/mfa/test_mfa_endpoints.py index a39f83d3..7db234ad 100644 --- a/tests/mfa/test_mfa_endpoints.py +++ b/tests/mfa/test_mfa_endpoints.py @@ -54,13 +54,14 @@ async def test_register(self): self.assertIn("recovery_codes", data) # Register for MFA - HTML + await AuthenticatorSecret.delete().where( + AuthenticatorSecret.user_id == self.user.id + ) response = client.post( "/private/mfa-setup/", data={"action": "register", "password": self.password}, headers={"X-CSRFToken": csrf_token}, ) - - # TODO - change this, as we can't register twice. - # self.assertEqual(response.status_code, 200) - # html = response.content - # self.assertIn(b"Authenticator Setup", html) + self.assertEqual(response.status_code, 200) + html = response.content + self.assertIn(b"Authenticator Setup", html) From 97c8572224dd448f35dbf3926077fff72dfd810c Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Sun, 8 Sep 2024 22:23:19 +0100 Subject: [PATCH 098/102] add tests for generating recovery codes --- tests/mfa/test_recovery_codes.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 tests/mfa/test_recovery_codes.py diff --git a/tests/mfa/test_recovery_codes.py b/tests/mfa/test_recovery_codes.py new file mode 100644 index 00000000..e560955c --- /dev/null +++ b/tests/mfa/test_recovery_codes.py @@ -0,0 +1,25 @@ +from unittest import TestCase + +from piccolo_api.mfa.recovery_codes import generate_recovery_code + + +class TestGenerateRecoveryCode(TestCase): + + def test_randomness(self): + self.assertNotEqual(generate_recovery_code(), generate_recovery_code()) + + def test_response_format(self): + self.assertEqual( + generate_recovery_code(length=10, characters=["a"]), + "aaaaa-aaaaa", + ) + + def test_no_separator(self): + self.assertEqual( + generate_recovery_code(length=10, characters=["a"], separator=""), + "aaaaaaaaaa", + ) + + def test_length(self): + with self.assertRaises(ValueError): + generate_recovery_code(length=6), From 4f38884842ad3e9b7fffd5e23e1baf4402944735 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Sun, 8 Sep 2024 22:39:07 +0100 Subject: [PATCH 099/102] fix path to `AuthenticatorProvider` in docstring --- piccolo_api/mfa/provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piccolo_api/mfa/provider.py b/piccolo_api/mfa/provider.py index 45f05e1a..8551789e 100644 --- a/piccolo_api/mfa/provider.py +++ b/piccolo_api/mfa/provider.py @@ -9,7 +9,7 @@ def __init__(self, name: str = "MFA Code"): """ This is the base class which all providers must inherit from. Use it to build your own custom providers. If you use it directly, it won't - do anything. See :class:`AuthenticatorProvider ` + do anything. See :class:`AuthenticatorProvider ` for a concrete implementation. :param token_name: From 98953beb7a123edca1ad687841dcec2e3de9ce49 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Sun, 8 Sep 2024 23:10:55 +0100 Subject: [PATCH 100/102] add docs for encryption --- docs/source/encryption/index.rst | 8 +++ docs/source/encryption/introduction.rst | 6 ++ docs/source/encryption/providers.rst | 69 +++++++++++++++++++++++ docs/source/index.rst | 1 + piccolo_api/mfa/authenticator/provider.py | 8 +-- 5 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 docs/source/encryption/index.rst create mode 100644 docs/source/encryption/introduction.rst create mode 100644 docs/source/encryption/providers.rst diff --git a/docs/source/encryption/index.rst b/docs/source/encryption/index.rst new file mode 100644 index 00000000..6d60ed27 --- /dev/null +++ b/docs/source/encryption/index.rst @@ -0,0 +1,8 @@ +Encryption +========== + +.. toctree:: + :maxdepth: 1 + + ./introduction + ./providers diff --git a/docs/source/encryption/introduction.rst b/docs/source/encryption/introduction.rst new file mode 100644 index 00000000..4f4a5a06 --- /dev/null +++ b/docs/source/encryption/introduction.rst @@ -0,0 +1,6 @@ +Introduction +============ + +Piccolo API provides some wrappers around popular encryption libraries. + +These are current used by :ref:`Multifactor Authentication `. diff --git a/docs/source/encryption/providers.rst b/docs/source/encryption/providers.rst new file mode 100644 index 00000000..3ddfbd9c --- /dev/null +++ b/docs/source/encryption/providers.rst @@ -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. diff --git a/docs/source/index.rst b/docs/source/index.rst index 7793517d..a054638e 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -26,6 +26,7 @@ ASGI app, covering authentication, security, and more. ./csp/index ./csrf/index + ./encryption/index ./rate_limiting/index .. toctree:: diff --git a/piccolo_api/mfa/authenticator/provider.py b/piccolo_api/mfa/authenticator/provider.py index c2b266a8..f87e5677 100644 --- a/piccolo_api/mfa/authenticator/provider.py +++ b/piccolo_api/mfa/authenticator/provider.py @@ -35,9 +35,9 @@ def __init__( :param encryption_provider: The shared secrets can be encrypted in the database. We recommend - using :class:`piccolo_api.encryption.provider.XChaCha20Provider`. - Use :class:`piccolo_api.encryption.provider.PlainTextProvider` to - store the secrets as plain text. + using :class:`XChaCha20Provider `. + Use :class:`PlainTextProvider ` + to store the secrets as plain text. :param recovery_code_count: How many recovery codes should be generated. :param secret_table: @@ -57,7 +57,7 @@ def __init__( the current one. Increasing it is more convenient for users, but is less secure. - """ + """ # noqa: E501 super().__init__( name="Authenticator App", ) From d4f4b5bfb80332e2c3e197cc1ee985834965e9c4 Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Mon, 9 Sep 2024 07:47:42 +0100 Subject: [PATCH 101/102] remove imports --- piccolo_api/encryption/providers.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/piccolo_api/encryption/providers.py b/piccolo_api/encryption/providers.py index b7555893..fe1eaf58 100644 --- a/piccolo_api/encryption/providers.py +++ b/piccolo_api/encryption/providers.py @@ -4,10 +4,6 @@ import typing as t from abc import ABCMeta, abstractmethod -import cryptography.fernet -import nacl.encoding -import nacl.secret - if t.TYPE_CHECKING: import cryptography import nacl From 5974031e0ee99d0da7b1ac5ead353e9aa1c0c5fe Mon Sep 17 00:00:00 2001 From: Daniel Townsend Date: Mon, 9 Sep 2024 07:58:40 +0100 Subject: [PATCH 102/102] add docstring and type annotations to `get_b64encoded_qr_image` --- piccolo_api/mfa/authenticator/utils.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/piccolo_api/mfa/authenticator/utils.py b/piccolo_api/mfa/authenticator/utils.py index abd49941..b6c67521 100644 --- a/piccolo_api/mfa/authenticator/utils.py +++ b/piccolo_api/mfa/authenticator/utils.py @@ -21,7 +21,16 @@ def get_qrcode() -> qrcode: # pragma: no cover return qrcode -def get_b64encoded_qr_image(data): +def get_b64encoded_qr_image(data: str) -> str: + """ + Creates a QR code from ``data``, and returns a base64 PNG image, which can + be used in a HTML document as follows: + + .. code-block:: html + + + + """ qrcode = get_qrcode() qr = qrcode.QRCode(version=1, box_size=4, border=5)