diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev
index ce827da36..d0187cf93 100644
--- a/docker/Dockerfile.dev
+++ b/docker/Dockerfile.dev
@@ -7,7 +7,7 @@ ENV PATH="$VIRTUAL_ENV/bin:$PATH"
FROM base as build
RUN apk add --update openssl librrd python3-dev libffi-dev gcc g++ musl-dev libxml2-dev libxslt-dev \
- libressl-dev jpeg-dev rrdtool-dev file make gettext freetype-dev cairo-dev \
+ libressl-dev jpeg-dev rrdtool-dev file make gettext freetype-dev cairo-dev cargo \
&& rm -rf /var/cache/apk/*
RUN python3 -m venv $VIRTUAL_ENV
WORKDIR /tmp
diff --git a/frontend/package.json b/frontend/package.json
index dec25024e..5aacece55 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -23,7 +23,8 @@
"vue-gettext": "^2.1.12",
"vue-router": "^3.2.0",
"vuetify": "^2.6.10",
- "vuex": "^3.4.0"
+ "vuex": "^3.4.0",
+ "qrcode.vue": "~1.7.0"
},
"devDependencies": {
"@babel/core": "^7.12.16",
diff --git a/frontend/src/api/account.js b/frontend/src/api/account.js
index 07e631b57..b0c54554e 100644
--- a/frontend/src/api/account.js
+++ b/frontend/src/api/account.js
@@ -16,18 +16,18 @@ export default {
startTFASetup () {
return repository.post(`${resource}/tfa/setup/`)
},
- getQRCodeForTFASetup () {
- return repository.get(`${resource}/tfa/setup/qr_code/`)
+ getKeyForTFASetup () {
+ return repository.get(`${resource}/tfa/setup/key/`)
},
finalizeTFASetup (pinCode) {
const payload = { pin_code: pinCode }
return repository.post(`${resource}/tfa/setup/check/`, payload)
},
- disableTFA () {
- return repository.post(`${resource}/tfa/disable/`)
+ disableTFA (data) {
+ return repository.post(`${resource}/tfa/disable/`, data)
},
- resetRecoveryCodes () {
- return repository.post(`${resource}/tfa/reset_codes/`)
+ resetRecoveryCodes (data) {
+ return repository.post(`${resource}/tfa/reset_codes/`, data)
},
getForward () {
return repository.get(`${resource}/forward/`)
diff --git a/frontend/src/components/user/TwoFactorAuthForm.vue b/frontend/src/components/user/TwoFactorAuthForm.vue
index 7bf933c34..b5419eebc 100644
--- a/frontend/src/components/user/TwoFactorAuthForm.vue
+++ b/frontend/src/components/user/TwoFactorAuthForm.vue
@@ -5,10 +5,31 @@
Two factor authentication
-
+
-
+
+
+
+
+
+
+ Click here to copy the key
+
+
+ mdi-check-all
+
+
+
@@ -22,7 +43,8 @@
- Two-Factor Authentication is enabled for your account.
+ Two-Factor Authentication is enabled for your account.
-
+
+
+
+
+
+
+
Disable 2FA
-
+
Reset recovery codes
@@ -95,10 +134,13 @@
import account from '@/api/account'
import Cookies from 'js-cookie'
import RecoveryCodesResetDialog from './RecoveryCodesResetDialog'
+import QrcodeVue from 'qrcode.vue'
+import { bus } from '@/main'
export default {
components: {
- RecoveryCodesResetDialog
+ RecoveryCodesResetDialog,
+ QrcodeVue
},
props: {
account: Object
@@ -107,20 +149,31 @@ export default {
return {
newTokens: [],
pinCode: null,
- qrCode: null,
+ key: null,
+ qrURL: null,
+ clicked: false,
showCodesResetDialog: false,
- tokens: []
+ loadingDisable: false,
+ loadingReset: false,
+ password: '',
+ tokens: [],
+ errors: {}
}
},
methods: {
- getQRCode () {
- account.getQRCodeForTFASetup().then(resp => {
- this.qrCode = resp.data
+ getKey () {
+ account.getKeyForTFASetup().then(resp => {
+ this.key = resp.data.key
+ this.qrURL = resp.data.url
})
},
+ copyKey () {
+ navigator.clipboard.writeText(this.key)
+ this.clicked = true
+ },
startTFASetup () {
account.startTFASetup().then(resp => {
- this.getQRCode()
+ this.getKey()
})
},
async finalizeTFASetup () {
@@ -128,22 +181,66 @@ export default {
if (!valid) {
return
}
- const resp = await account.finalizeTFASetup(this.pinCode)
- this.qrCode = null
- this.tokens = resp.data.tokens
- Cookies.set('token', resp.data.access, { sameSite: 'strict' })
- Cookies.set('refreshToken', resp.data.refresh, { sameSite: 'strict' })
- this.$store.dispatch('auth/initialize')
+ try {
+ const resp = await account.finalizeTFASetup(this.pinCode)
+ this.key = null
+ this.qrURL = null
+ this.tokens = resp.data.tokens
+ Cookies.set('token', resp.data.access, { sameSite: 'strict' })
+ Cookies.set('refreshToken', resp.data.refresh, { sameSite: 'strict' })
+ this.$store.dispatch('auth/initialize')
+ } catch (err) {
+ if (err.response.status === 400) {
+ this.$refs.observer.setErrors(err.response.data)
+ }
+ }
},
- disableTFA () {
- account.disableTFA().then(resp => {
+ async disableTFA () {
+ const isValid = await this.$refs.observer.validate()
+ if (!isValid) {
+ return
+ }
+ const payload = {
+ password: this.password
+ }
+ this.loadingDisable = true
+ account.disableTFA(payload).then(resp => {
+ this.loadingDisable = false
this.$store.dispatch('auth/fetchUser')
+ this.$refs.password.reset()
+ this.$refs.observer.reset()
+ }).catch(err => {
+ this.loadingDisable = false
+ if (err.response.status === 400) {
+ this.$refs.observer.setErrors(err.response.data)
+ } else {
+ bus.$emit('notification', { msg: this.$gettext(err.response.data), type: 'error' })
+ }
})
},
- resetRecoveryCodes () {
- account.resetRecoveryCodes().then(resp => {
+ async resetRecoveryCodes () {
+ const isValid = await this.$refs.observer.validate()
+ if (!isValid) {
+ return
+ }
+ const payload = {
+ password: this.password
+ }
+ this.loadingReset = true
+
+ account.resetRecoveryCodes(payload).then(resp => {
+ this.loadingReset = false
this.newTokens = resp.data.tokens
this.showCodesResetDialog = true
+ this.$refs.password.reset()
+ this.$refs.observer.reset()
+ }).catch(err => {
+ this.loadingReset = false
+ if (err.response.status === 400) {
+ this.$refs.observer.setErrors(err.response.data)
+ } else {
+ bus.$emit('notification', { msg: this.$gettext(err.response.data), type: 'error' })
+ }
})
},
closeRecoveryCodesResetDialog () {
@@ -153,8 +250,19 @@ export default {
},
created () {
if (!this.account.tfa_enabled) {
- this.getQRCode()
+ this.getKey()
}
}
}
+
+
diff --git a/frontend/src/plugins/vee-validate.js b/frontend/src/plugins/vee-validate.js
index 3d753f006..bf78b912b 100644
--- a/frontend/src/plugins/vee-validate.js
+++ b/frontend/src/plugins/vee-validate.js
@@ -15,6 +15,13 @@ extend('numeric', {
...numeric,
message: () => $gettext('This field must be a valid number')
})
+extend('minmax', {
+ validate: async (value, args) => {
+ return value.length >= args.length && value.length <= args.length
+ },
+ params: ['length'],
+ message: $gettext('This field must have a length of {length} characters')
+})
extend('required', {
...required,
message: () => $gettext('This field is required')
diff --git a/frontend/src/views/TwoFA.vue b/frontend/src/views/TwoFA.vue
index 692ed6f5d..7aeb861bd 100644
--- a/frontend/src/views/TwoFA.vue
+++ b/frontend/src/views/TwoFA.vue
@@ -20,6 +20,7 @@
self.test_me_password(password_ok="MyHardenedPass1!")
-
class AuthenticationTestCase(ModoAPITestCase):
@mock.patch("django_otp.match_token")
diff --git a/modoboa/core/api/v2/viewsets.py b/modoboa/core/api/v2/viewsets.py
index 24ba9adee..7cba4fdf6 100644
--- a/modoboa/core/api/v2/viewsets.py
+++ b/modoboa/core/api/v2/viewsets.py
@@ -1,10 +1,5 @@
"""Core API v2 viewsets."""
-import io
-
-import qrcode
-import qrcode.image.svg
-
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
from drf_spectacular.utils import extend_schema
from rest_framework import filters, permissions, response, viewsets
@@ -102,19 +97,16 @@ class AccountViewSet(core_v1_viewsets.AccountViewSet):
"access": str(refresh.access_token)
})
- @action(methods=["get"], detail=False, url_path="tfa/setup/qr_code")
- def tfa_setup_get_qr_code(self, request):
- """Get a QR code to finalize the setup process."""
+ @action(methods=["get"], detail=False, url_path="tfa/setup/key")
+ def tfa_setup_get_key(self, request):
+ """Get a key and url to finalize the setup process."""
if request.user.tfa_enabled:
return response.Response(status=404)
device = request.user.totpdevice_set.first()
if not device:
return response.Response(status=404)
- factory = qrcode.image.svg.SvgPathImage
- img = qrcode.make(device.config_url, image_factory=factory)
- buf = io.BytesIO()
- img.save(buf)
- return response.Response(buf.getvalue(), content_type="application/xml")
+ return response.Response({"key": device.key, "url":device.config_url},
+ content_type="application/json")
@extend_schema(
request=core_v1_serializers.CheckTFASetupSerializer
diff --git a/modoboa/core/templates/core/user_security.html b/modoboa/core/templates/core/user_security.html
index 5a31cc418..3541799f7 100644
--- a/modoboa/core/templates/core/user_security.html
+++ b/modoboa/core/templates/core/user_security.html
@@ -54,10 +54,10 @@
Two-Factor Authentication is enabled for your account.
{% endblocktrans %}
-
+
{% trans "Disable 2FA" %}
-
+
{% trans "Reset recovery codes" %}
{% else %}
@@ -101,3 +101,82 @@
+
+
+
diff --git a/modoboa/core/tests/test_viewsets.py b/modoboa/core/tests/test_viewsets.py
index 1f04ef026..c2c6553e2 100644
--- a/modoboa/core/tests/test_viewsets.py
+++ b/modoboa/core/tests/test_viewsets.py
@@ -19,7 +19,7 @@ class AccountViewSetTestCase(ModoAPITestCase):
admin_factories.populate_database()
@mock.patch("django_otp.plugins.otp_totp.models.TOTPDevice.verify_token")
- def test_tfa_setup_process(self, verify_mock):
+ def test_tfa_setup_process(self, verify_mock, password_ok="password"):
admin = models.User.objects.get(username="admin")
self.client.force_login(admin)
@@ -58,18 +58,20 @@ class AccountViewSetTestCase(ModoAPITestCase):
# Try to disable TFA auth
url = reverse("api:account-tfa-disable")
- response = self.client.post(url)
+ data = {"password": password_ok}
+ response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 200)
self.assertFalse(admin.totpdevice_set.exists())
- def test_tfa_reset_codes(self):
+ def test_tfa_reset_codes(self, password_ok="password"):
admin = models.User.objects.get(username="admin")
self.client.force_login(admin)
url = reverse("api:account-tfa-reset-codes")
- response = self.client.post(url)
+ data = {"password": password_ok}
+ response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 403)
device = admin.staticdevice_set.create(name="Device")
- response = self.client.post(url)
+ response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 200)
self.assertEqual(device.token_set.count(), 10)
diff --git a/modoboa/static/js/twocols_nav.js b/modoboa/static/js/twocols_nav.js
index d04b3e3e8..e3574a13e 100644
--- a/modoboa/static/js/twocols_nav.js
+++ b/modoboa/static/js/twocols_nav.js
@@ -166,12 +166,6 @@ TwocolsNav.prototype = {
this.update_content(data);
$('#start-tfa-setup').click(this.startTFASetup);
$('#finalize-tfa-setup').click(this.finalizeTFASetup);
- $('#deactivate-tfa').confirm({
- question: gettext('Are you sure?'),
- warning: gettext('This will invalidate your registered application.'),
- method: 'POST'
- });
- $('#tfa-refresh-codes').click(this.resetTFARecoveryCodes);
},
startTFASetup: function (evt) {
@@ -202,24 +196,6 @@ TwocolsNav.prototype = {
var errors = JSON.parse(jqxhr.responseText);
display_field_error($('#pin-code'), errors.pin_code.join(' '));
});
- },
-
- resetTFARecoveryCodes: function (evt) {
- evt.preventDefault();
- $.ajax({
- url: $(this).attr('href'),
- type: 'post',
- contentType: 'application/json'
- }).done(function (resp) {
- var $container = $('#tfa-codes-area');
- var $ul = $('');
- $.each(resp.tokens, function (index, value) {
- var $li = $('', { html: value });
- $ul.append($li);
- });
- $container.append($ul);
- $('#codes-modal').modal();
- });
}
};