diff --git a/admin_ui/src/components/DropDownMenu.vue b/admin_ui/src/components/DropDownMenu.vue
index 7089a1de..07b2b0b9 100644
--- a/admin_ui/src/components/DropDownMenu.vue
+++ b/admin_ui/src/components/DropDownMenu.vue
@@ -4,11 +4,7 @@
-
+
diff --git a/admin_ui/src/components/NavBar.vue b/admin_ui/src/components/NavBar.vue
index 4a68b04d..0c9558bc 100644
--- a/admin_ui/src/components/NavBar.vue
+++ b/admin_ui/src/components/NavBar.vue
@@ -44,7 +44,8 @@
{{ truncatedUsername }}
+ Hint: Use your authenticator app to generate the MFA + code - if you've lost your phone, you can use a recovery + code instead. +
+ + @@ -26,7 +37,9 @@ export default defineComponent({ data() { return { username: "", - password: "" + password: "", + mfaCode: "", + mfaCodeRequired: false } }, components: { @@ -43,16 +56,31 @@ export default defineComponent({ try { await axios.post(`./public/login/`, { username: this.username, - password: this.password + password: this.password, + ...(this.mfaCodeRequired ? { mfa_code: this.mfaCode } : {}) }) } catch (error) { console.log("Request failed") + if (axios.isAxiosError(error)) { console.log(error.response) - this.$store.commit("updateApiResponseMessage", { - contents: "Problem logging in", - type: "error" - }) + + if ( + error.response?.status == 401 && + error.response?.data?.detail == "MFA code required" + ) { + this.$store.commit("updateApiResponseMessage", { + contents: "MFA code required", + type: "neutral" + }) + + this.mfaCodeRequired = true + } else { + this.$store.commit("updateApiResponseMessage", { + contents: "Problem logging in", + type: "error" + }) + } } return diff --git a/docs/source/index.rst b/docs/source/index.rst index 182a57f8..8e6589da 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -49,6 +49,7 @@ Table of Contents ../sidebar_links/index ../actions/index ../media_storage/index + ../mfa/index ../internationalization/index ../rest_api_documentation/index ../debugging/index diff --git a/docs/source/mfa/index.rst b/docs/source/mfa/index.rst new file mode 100644 index 00000000..e073ff71 --- /dev/null +++ b/docs/source/mfa/index.rst @@ -0,0 +1,8 @@ +Multi-factor Authentication +=========================== + +Piccolo Admin supports Multi-factor Authentication (MFA). See the +``mfa_providers`` argument in ``create_admin``. + +We currently recommend using the ``AuthenticatorProvider`` with +``XChaCha20Provider`` for encryption. diff --git a/piccolo_admin/endpoints.py b/piccolo_admin/endpoints.py index 897a1164..ce96dd0c 100644 --- a/piccolo_admin/endpoints.py +++ b/piccolo_admin/endpoints.py @@ -35,6 +35,8 @@ from piccolo_api.fastapi.endpoints import FastAPIKwargs, FastAPIWrapper from piccolo_api.media.base import MediaStorage from piccolo_api.media.local import LocalMediaStorage +from piccolo_api.mfa.endpoints import mfa_setup +from piccolo_api.mfa.provider import MFAProvider from piccolo_api.openapi.endpoints import swagger_ui from piccolo_api.rate_limiting.middleware import ( InMemoryLimitProvider, @@ -431,12 +433,17 @@ def __init__( allowed_hosts: t.Sequence[str] = [], debug: bool = False, sidebar_links: t.Dict[str, str] = {}, + mfa_providers: t.Optional[t.Sequence[MFAProvider]] = None, ) -> None: super().__init__( title=site_name, description=f"{site_name} documentation", middleware=[ - Middleware(CSRFMiddleware, allowed_hosts=allowed_hosts) + Middleware( + CSRFMiddleware, + allowed_hosts=allowed_hosts, + allow_form_param=True, + ) ], debug=debug, exception_handlers={500: log_error}, @@ -680,6 +687,30 @@ def __init__( ), ) + ####################################################################### + # MFA + + if mfa_providers: + if len(mfa_providers) > 1: + raise ValueError( + "Only a single mfa_provider is currently supported." + ) + + for mfa_provider in mfa_providers: + private_app.mount( + path="/mfa-setup/", + # This rate limiting is because some of the forms accept + # a password, and generating recovery codes is somewhat + # expensive, so we want to prevent abuse. + app=RateLimitingMiddleware( + app=mfa_setup( + provider=mfa_provider, + auth_table=self.auth_table, + ), + provider=InMemoryLimitProvider(limit=20, timespan=300), + ), + ) + ####################################################################### public_app = FastAPI( @@ -692,11 +723,14 @@ def __init__( if not rate_limit_provider: rate_limit_provider = InMemoryLimitProvider( - limit=100, timespan=300 + limit=20, + timespan=300, ) public_app.mount( path="/login/", + # This rate limiting is to prevent brute forcing password login, + # and MFA codes. app=RateLimitingMiddleware( app=session_login( auth_table=self.auth_table, @@ -705,6 +739,7 @@ def __init__( max_session_expiry=max_session_expiry, redirect_to=None, production=production, + mfa_providers=mfa_providers, ), provider=rate_limit_provider, ), @@ -1083,6 +1118,7 @@ def create_admin( allowed_hosts: t.Sequence[str] = [], debug: bool = False, sidebar_links: t.Dict[str, str] = {}, + mfa_providers: t.Optional[t.Sequence[MFAProvider]] = None, ): """ :param tables: @@ -1203,6 +1239,8 @@ def create_admin( "Google": "https://google.com" }, ) + param mfa_providers: + Enables Multi-factor Authentication in the login process. """ # noqa: E501 auth_table = auth_table or BaseUser @@ -1249,4 +1287,5 @@ def create_admin( allowed_hosts=allowed_hosts, debug=debug, sidebar_links=sidebar_links, + mfa_providers=mfa_providers, ) diff --git a/piccolo_admin/example.py b/piccolo_admin/example.py index e51d67c8..a8d32447 100644 --- a/piccolo_admin/example.py +++ b/piccolo_admin/example.py @@ -45,8 +45,13 @@ from piccolo.engine.postgres import PostgresEngine from piccolo.engine.sqlite import SQLiteEngine from piccolo.table import Table, create_db_tables_sync, drop_db_tables_sync +from piccolo_api.encryption.providers import XChaCha20Provider from piccolo_api.media.local import LocalMediaStorage from piccolo_api.media.s3 import S3MediaStorage +from piccolo_api.mfa.authenticator.provider import AuthenticatorProvider +from piccolo_api.mfa.authenticator.tables import ( + AuthenticatorSecret as AuthenticatorSecret_, +) from piccolo_api.session_auth.tables import SessionsBase from pydantic import BaseModel, field_validator from starlette.requests import Request @@ -139,6 +144,10 @@ class User(BaseUser, tablename="piccolo_user"): pass +class AuthenticatorSecret(AuthenticatorSecret_): + pass + + class Director(Table, help_text="The main director for a movie."): class Gender(str, enum.Enum): male = "m" @@ -439,6 +448,7 @@ def booking_endpoint(request: Request, data: BookingModel) -> str: Studio, User, Sessions, + AuthenticatorSecret, Ticket, ArrayColumns, NullableColumns, @@ -607,6 +617,16 @@ def booking_endpoint(request: Request, data: BookingModel) -> str: "Top Movies": "/#/movie?__order=-box_office", "Google": "https://google.com", }, + mfa_providers=[ + AuthenticatorProvider( + encryption_provider=XChaCha20Provider( + encryption_key=( + b"\x01\xfdN\xe4E?\xaa\xf8