Skip to content

Commit dd10b39

Browse files
authored
MFA (#405)
* add basic view for MFA setup * add link in dropdown * integrate MFA from `piccolo_api` * fix issue with `npm run build` * fix rate limit for MFA setup endpoint * fix variable name * fix `MFA Setup` link in nav bar dropdown * show MFA Code input * show banner telling user that MFA code is required * use the correct param name for the MFA code * update for latest piccolo api changes * change param to `mfa_code` * bump `piccolo_admin` version * use `XChaCha20Provider` in example, and bump `piccolo_api` version * add placeholder translations for now * fix tests * add very basic docs
1 parent 8918d5a commit dd10b39

File tree

16 files changed

+173
-41
lines changed

16 files changed

+173
-41
lines changed

admin_ui/src/components/DropDownMenu.vue

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,7 @@
44
</ul>
55
</template>
66

7-
<script lang="ts">
8-
import { defineComponent } from "vue"
9-
10-
export default defineComponent({})
11-
</script>
7+
<script setup lang="ts"></script>
128

139
<style lang="less">
1410
@import "../vars.less";

admin_ui/src/components/MessagePopup.vue

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
<template>
22
<div
3-
:class="{ error: apiResponseMessage.type == 'error' }"
3+
:class="{
4+
error: apiResponseMessage.type == 'error',
5+
neutral: apiResponseMessage.type == 'neutral'
6+
}"
47
id="message_popup"
58
v-if="visible"
69
>
@@ -85,8 +88,13 @@ div#message_popup {
8588
p.close {
8689
flex-grow: 0;
8790
}
88-
}
89-
div#message_popup.error {
90-
background-color: @red;
91+
92+
&.error {
93+
background-color: @red;
94+
}
95+
96+
&.neutral {
97+
background-color: @dark_blue;
98+
}
9199
}
92100
</style>

admin_ui/src/components/NavBar.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@
4444
{{ truncatedUsername }}
4545
<font-awesome-icon icon="angle-up" v-if="showDropdown" />
4646
<font-awesome-icon icon="angle-down" v-if="!showDropdown" />
47-
<NavDropDownMenu v-if="showDropdown" />
47+
48+
<NavDropDownMenu v-show="showDropdown" />
4849
</a>
4950
</li>
5051
</ul>

admin_ui/src/components/NavDropDownMenu.vue

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,18 @@
1313
><font-awesome-icon icon="key" />{{ $t("Change Password") }}
1414
</router-link>
1515
</li>
16+
<li>
17+
<a href="/api/mfa-setup/" @click="$event.stopPropagation()">
18+
<font-awesome-icon icon="mobile-alt" />{{ $t("MFA Setup") }}
19+
</a>
20+
</li>
1621
<li v-if="darkMode">
17-
<a href="#" v-on:click.prevent="updateDarkMode(false)">
22+
<a href="#" @click.prevent="updateDarkMode(false)">
1823
<font-awesome-icon icon="sun" />{{ $t("Light Mode") }}
1924
</a>
2025
</li>
2126
<li v-else>
22-
<a href="#" v-on:click.prevent="updateDarkMode(true)">
27+
<a href="#" @click.prevent="updateDarkMode(true)">
2328
<font-awesome-icon icon="moon" />{{ $t("Dark Mode") }}
2429
</a>
2530
</li>
@@ -29,7 +34,7 @@
2934
>
3035
</li>
3136
<li>
32-
<a href="#" v-on:click.prevent="showAboutModal">
37+
<a href="#" @click.prevent="showAboutModal">
3338
<font-awesome-icon icon="info-circle" />{{ $t("About") }}
3439
Piccolo
3540
</a>

admin_ui/src/fontawesome.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
faLayerGroup,
3131
faLevelUpAlt,
3232
faLink,
33+
faMobileAlt,
3334
faMoon,
3435
faPlus,
3536
faQuestionCircle,
@@ -74,6 +75,7 @@ library.add(
7475
faLayerGroup,
7576
faLevelUpAlt,
7677
faLink,
78+
faMobileAlt,
7779
faMoon,
7880
faPlus,
7981
faQuestionCircle,

admin_ui/src/interfaces.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export interface FetchSingleRowConfig {
3535

3636
export interface APIResponseMessage {
3737
contents: string
38-
type: string
38+
type: "success" | "error" | "neutral"
3939
}
4040

4141
export interface OrderByConfig {

admin_ui/src/views/Login.vue

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@
1010

1111
<label>{{ $t("Password") }}</label>
1212
<PasswordInput @input="password = $event" :value="password" />
13+
14+
<template v-if="mfaCodeRequired">
15+
<label>{{ $t("MFA Code") }}</label>
16+
<input placeholder="123456" type="text" v-model="mfaCode" />
17+
<p>
18+
Hint: Use your authenticator app to generate the MFA
19+
code - if you've lost your phone, you can use a recovery
20+
code instead.
21+
</p>
22+
</template>
23+
1324
<button data-uitest="login_button">{{ $t("Login") }}</button>
1425
</form>
1526
</div>
@@ -26,7 +37,9 @@ export default defineComponent({
2637
data() {
2738
return {
2839
username: "",
29-
password: ""
40+
password: "",
41+
mfaCode: "",
42+
mfaCodeRequired: false
3043
}
3144
},
3245
components: {
@@ -43,16 +56,31 @@ export default defineComponent({
4356
try {
4457
await axios.post(`./public/login/`, {
4558
username: this.username,
46-
password: this.password
59+
password: this.password,
60+
...(this.mfaCodeRequired ? { mfa_code: this.mfaCode } : {})
4761
})
4862
} catch (error) {
4963
console.log("Request failed")
64+
5065
if (axios.isAxiosError(error)) {
5166
console.log(error.response)
52-
this.$store.commit("updateApiResponseMessage", {
53-
contents: "Problem logging in",
54-
type: "error"
55-
})
67+
68+
if (
69+
error.response?.status == 401 &&
70+
error.response?.data?.detail == "MFA code required"
71+
) {
72+
this.$store.commit("updateApiResponseMessage", {
73+
contents: "MFA code required",
74+
type: "neutral"
75+
})
76+
77+
this.mfaCodeRequired = true
78+
} else {
79+
this.$store.commit("updateApiResponseMessage", {
80+
contents: "Problem logging in",
81+
type: "error"
82+
})
83+
}
5684
}
5785
5886
return

docs/source/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ Table of Contents
4949
../sidebar_links/index
5050
../actions/index
5151
../media_storage/index
52+
../mfa/index
5253
../internationalization/index
5354
../rest_api_documentation/index
5455
../debugging/index

docs/source/mfa/index.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Multi-factor Authentication
2+
===========================
3+
4+
Piccolo Admin supports Multi-factor Authentication (MFA). See the
5+
``mfa_providers`` argument in ``create_admin``.
6+
7+
We currently recommend using the ``AuthenticatorProvider`` with
8+
``XChaCha20Provider`` for encryption.

piccolo_admin/endpoints.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
from piccolo_api.fastapi.endpoints import FastAPIKwargs, FastAPIWrapper
3636
from piccolo_api.media.base import MediaStorage
3737
from piccolo_api.media.local import LocalMediaStorage
38+
from piccolo_api.mfa.endpoints import mfa_setup
39+
from piccolo_api.mfa.provider import MFAProvider
3840
from piccolo_api.openapi.endpoints import swagger_ui
3941
from piccolo_api.rate_limiting.middleware import (
4042
InMemoryLimitProvider,
@@ -431,12 +433,17 @@ def __init__(
431433
allowed_hosts: t.Sequence[str] = [],
432434
debug: bool = False,
433435
sidebar_links: t.Dict[str, str] = {},
436+
mfa_providers: t.Optional[t.Sequence[MFAProvider]] = None,
434437
) -> None:
435438
super().__init__(
436439
title=site_name,
437440
description=f"{site_name} documentation",
438441
middleware=[
439-
Middleware(CSRFMiddleware, allowed_hosts=allowed_hosts)
442+
Middleware(
443+
CSRFMiddleware,
444+
allowed_hosts=allowed_hosts,
445+
allow_form_param=True,
446+
)
440447
],
441448
debug=debug,
442449
exception_handlers={500: log_error},
@@ -680,6 +687,30 @@ def __init__(
680687
),
681688
)
682689

690+
#######################################################################
691+
# MFA
692+
693+
if mfa_providers:
694+
if len(mfa_providers) > 1:
695+
raise ValueError(
696+
"Only a single mfa_provider is currently supported."
697+
)
698+
699+
for mfa_provider in mfa_providers:
700+
private_app.mount(
701+
path="/mfa-setup/",
702+
# This rate limiting is because some of the forms accept
703+
# a password, and generating recovery codes is somewhat
704+
# expensive, so we want to prevent abuse.
705+
app=RateLimitingMiddleware(
706+
app=mfa_setup(
707+
provider=mfa_provider,
708+
auth_table=self.auth_table,
709+
),
710+
provider=InMemoryLimitProvider(limit=20, timespan=300),
711+
),
712+
)
713+
683714
#######################################################################
684715

685716
public_app = FastAPI(
@@ -692,11 +723,14 @@ def __init__(
692723

693724
if not rate_limit_provider:
694725
rate_limit_provider = InMemoryLimitProvider(
695-
limit=100, timespan=300
726+
limit=20,
727+
timespan=300,
696728
)
697729

698730
public_app.mount(
699731
path="/login/",
732+
# This rate limiting is to prevent brute forcing password login,
733+
# and MFA codes.
700734
app=RateLimitingMiddleware(
701735
app=session_login(
702736
auth_table=self.auth_table,
@@ -705,6 +739,7 @@ def __init__(
705739
max_session_expiry=max_session_expiry,
706740
redirect_to=None,
707741
production=production,
742+
mfa_providers=mfa_providers,
708743
),
709744
provider=rate_limit_provider,
710745
),
@@ -1083,6 +1118,7 @@ def create_admin(
10831118
allowed_hosts: t.Sequence[str] = [],
10841119
debug: bool = False,
10851120
sidebar_links: t.Dict[str, str] = {},
1121+
mfa_providers: t.Optional[t.Sequence[MFAProvider]] = None,
10861122
):
10871123
"""
10881124
:param tables:
@@ -1203,6 +1239,8 @@ def create_admin(
12031239
"Google": "https://google.com"
12041240
},
12051241
)
1242+
param mfa_providers:
1243+
Enables Multi-factor Authentication in the login process.
12061244
12071245
""" # noqa: E501
12081246
auth_table = auth_table or BaseUser
@@ -1249,4 +1287,5 @@ def create_admin(
12491287
allowed_hosts=allowed_hosts,
12501288
debug=debug,
12511289
sidebar_links=sidebar_links,
1290+
mfa_providers=mfa_providers,
12521291
)

0 commit comments

Comments
 (0)