Skip to content
Merged

MFA #405

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions admin_ui/src/components/DropDownMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,7 @@
</ul>
</template>

<script lang="ts">
import { defineComponent } from "vue"

export default defineComponent({})
</script>
<script setup lang="ts"></script>

<style lang="less">
@import "../vars.less";
Expand Down
16 changes: 12 additions & 4 deletions admin_ui/src/components/MessagePopup.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<template>
<div
:class="{ error: apiResponseMessage.type == 'error' }"
:class="{
error: apiResponseMessage.type == 'error',
neutral: apiResponseMessage.type == 'neutral'
}"
id="message_popup"
v-if="visible"
>
Expand Down Expand Up @@ -85,8 +88,13 @@ div#message_popup {
p.close {
flex-grow: 0;
}
}
div#message_popup.error {
background-color: @red;

&.error {
background-color: @red;
}

&.neutral {
background-color: @dark_blue;
}
}
</style>
3 changes: 2 additions & 1 deletion admin_ui/src/components/NavBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@
{{ truncatedUsername }}
<font-awesome-icon icon="angle-up" v-if="showDropdown" />
<font-awesome-icon icon="angle-down" v-if="!showDropdown" />
<NavDropDownMenu v-if="showDropdown" />

<NavDropDownMenu v-show="showDropdown" />
</a>
</li>
</ul>
Expand Down
11 changes: 8 additions & 3 deletions admin_ui/src/components/NavDropDownMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,18 @@
><font-awesome-icon icon="key" />{{ $t("Change Password") }}
</router-link>
</li>
<li>
<a href="/api/mfa-setup/" @click="$event.stopPropagation()">
<font-awesome-icon icon="mobile-alt" />{{ $t("MFA Setup") }}
</a>
</li>
<li v-if="darkMode">
<a href="#" v-on:click.prevent="updateDarkMode(false)">
<a href="#" @click.prevent="updateDarkMode(false)">
<font-awesome-icon icon="sun" />{{ $t("Light Mode") }}
</a>
</li>
<li v-else>
<a href="#" v-on:click.prevent="updateDarkMode(true)">
<a href="#" @click.prevent="updateDarkMode(true)">
<font-awesome-icon icon="moon" />{{ $t("Dark Mode") }}
</a>
</li>
Expand All @@ -29,7 +34,7 @@
>
</li>
<li>
<a href="#" v-on:click.prevent="showAboutModal">
<a href="#" @click.prevent="showAboutModal">
<font-awesome-icon icon="info-circle" />{{ $t("About") }}
Piccolo
</a>
Expand Down
2 changes: 2 additions & 0 deletions admin_ui/src/fontawesome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
faLayerGroup,
faLevelUpAlt,
faLink,
faMobileAlt,
faMoon,
faPlus,
faQuestionCircle,
Expand Down Expand Up @@ -74,6 +75,7 @@ library.add(
faLayerGroup,
faLevelUpAlt,
faLink,
faMobileAlt,
faMoon,
faPlus,
faQuestionCircle,
Expand Down
2 changes: 1 addition & 1 deletion admin_ui/src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export interface FetchSingleRowConfig {

export interface APIResponseMessage {
contents: string
type: string
type: "success" | "error" | "neutral"
}

export interface OrderByConfig {
Expand Down
40 changes: 34 additions & 6 deletions admin_ui/src/views/Login.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@

<label>{{ $t("Password") }}</label>
<PasswordInput @input="password = $event" :value="password" />

<template v-if="mfaCodeRequired">
<label>{{ $t("MFA Code") }}</label>
<input placeholder="123456" type="text" v-model="mfaCode" />
<p>
Hint: Use your authenticator app to generate the MFA
code - if you've lost your phone, you can use a recovery
code instead.
</p>
</template>

<button data-uitest="login_button">{{ $t("Login") }}</button>
</form>
</div>
Expand All @@ -26,7 +37,9 @@ export default defineComponent({
data() {
return {
username: "",
password: ""
password: "",
mfaCode: "",
mfaCodeRequired: false
}
},
components: {
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/source/mfa/index.rst
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 41 additions & 2 deletions piccolo_admin/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -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,
),
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1249,4 +1287,5 @@ def create_admin(
allowed_hosts=allowed_hosts,
debug=debug,
sidebar_links=sidebar_links,
mfa_providers=mfa_providers,
)
20 changes: 20 additions & 0 deletions piccolo_admin/example.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -439,6 +448,7 @@ def booking_endpoint(request: Request, data: BookingModel) -> str:
Studio,
User,
Sessions,
AuthenticatorSecret,
Ticket,
ArrayColumns,
NullableColumns,
Expand Down Expand Up @@ -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<e\xfc\x9f\x0b9\x8b\x00H%~\xe1/\xd7\xdcz\xff\xd88\xdajd\xae\x06" # noqa: E501
)
),
secret_table=AuthenticatorSecret,
),
],
)


Expand Down
Loading