mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-17 05:41:25 +02:00
Added webauthn_enabled
This commit is contained in:
@@ -8,25 +8,14 @@
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col v-if="!authStore.authUser.tfa_enabled" cols="4">
|
||||
<v-col v-if="fidoCreds.length === 0" cols="4">
|
||||
<div tag="p" class="my-4">
|
||||
{{
|
||||
$gettext(
|
||||
'You must enable two-factor authentication using one-time passwords first.'
|
||||
"You don't have any WebAuthN registered as a second authentication method."
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col v-else cols="4">
|
||||
<template v-if="fidoCreds.length === 0">
|
||||
<div tag="p" class="my-4">
|
||||
{{
|
||||
$gettext(
|
||||
"You don't have any WebAuthN registered as a second authentication method."
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="browserCapable">
|
||||
<v-form ref="fidoForm" @submit.prevent="startFidoRegistration">
|
||||
<v-text-field
|
||||
@@ -104,6 +93,12 @@
|
||||
/>
|
||||
</v-form>
|
||||
</ConfirmDialog>
|
||||
<v-dialog v-model="showBackupTokenDialog" max-width="800px" persistent>
|
||||
<RecoveryCodesResetDialog
|
||||
:tokens="newTokens"
|
||||
@close="closeRecoveryCodesResetDialog"
|
||||
/>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
@@ -113,6 +108,7 @@ import { useGettext } from 'vue3-gettext'
|
||||
import rules from '@/plugins/rules.js'
|
||||
import { useAuthStore } from '@/stores'
|
||||
import ConfirmDialog from '@/components/tools/ConfirmDialog.vue'
|
||||
import RecoveryCodesResetDialog from './RecoveryCodesResetDialog.vue'
|
||||
import {
|
||||
create,
|
||||
parseCreationOptionsFromJSON,
|
||||
@@ -145,27 +141,37 @@ const confirmEdition = ref()
|
||||
const editForm = ref()
|
||||
const editName = ref('')
|
||||
const editEnabled = ref(false)
|
||||
const tokens = ref()
|
||||
const showBackupTokenDialog = ref(false)
|
||||
|
||||
async function startFidoRegistration() {
|
||||
registrationLoading.value = true
|
||||
const { valid } = await fidoForm.value.validate()
|
||||
if (!valid) {
|
||||
registrationLoading.value = false
|
||||
return
|
||||
}
|
||||
registrationLoading.value = true
|
||||
const creationOption = await authApi.beginFidoRegistration()
|
||||
if (creationOption) {
|
||||
const options = parseCreationOptionsFromJSON(creationOption.data)
|
||||
const result = await create(options)
|
||||
const response = result.toJSON()
|
||||
console.log(response)
|
||||
response.name = name.value
|
||||
await authStore.addFidoCred(response)
|
||||
const device = await create(options)
|
||||
const result = device.toJSON()
|
||||
result.name = name.value
|
||||
authStore.addFidoCred(result).then((res) => {
|
||||
if (res.status === 200) {
|
||||
tokens.value = res.data.tokens
|
||||
showBackupTokenDialog.value = true
|
||||
}
|
||||
})
|
||||
fidoForm.value.reset()
|
||||
}
|
||||
registrationLoading.value = false
|
||||
}
|
||||
|
||||
function closeRecoveryCodesResetDialog() {
|
||||
tokens.value = []
|
||||
showBackupTokenDialog.value = false
|
||||
}
|
||||
|
||||
async function deleteCred(cred) {
|
||||
const result = await confirmDeletion.value.open(
|
||||
$gettext('Warning'),
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<span class="headline"> {{ $gettext('WebAuthN registration') }} </span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
|
||||
<v-btn color="success" :loading="registrationLoading" @click="startFidoRegistration">{{ $gettext('Register device') }}</v-btn>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="grey darken-1" text @click="close">
|
||||
{{ $gettext('Close') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const name = ref()
|
||||
const registrationLoading = ref(false)
|
||||
const publicKey = ref()
|
||||
|
||||
async function startFidoRegistration() {
|
||||
if (publicKey.value) {
|
||||
navigator.credentials.create(publicKey.value)
|
||||
.then(function (attestation) {
|
||||
// Send new credential info to server for verification and registration.
|
||||
console.log(attestation)
|
||||
})
|
||||
.catch(function (err) {
|
||||
// No acceptable authenticator or user refused consent. Handle appropriately.
|
||||
console.log(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
registrationLoading.value = true
|
||||
authApi.beginFidoRegistration().then((resp) => {
|
||||
publicKey.value = resp.data.publicKey
|
||||
}).finally(() => registrationLoading.value = false)
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
@@ -253,6 +253,8 @@ class AccountSerializer(serializers.ModelSerializer):
|
||||
"secondary_email",
|
||||
"domains",
|
||||
"tfa_enabled",
|
||||
"totp_enabled",
|
||||
"webauthn_enabled",
|
||||
"date_joined",
|
||||
"last_login",
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@ class CheckTFASetupSerializer(serializers.Serializer):
|
||||
|
||||
def validate(self, data):
|
||||
user = self.context["user"]
|
||||
if user.tfa_enabled or user.staticdevice_set.exists():
|
||||
if user.totp_enabled or user.staticdevice_set.exists():
|
||||
raise serializers.ValidationError(_("2FA is already enabled"))
|
||||
return data
|
||||
|
||||
|
||||
@@ -59,12 +59,12 @@ class AccountViewSet(GetThrottleViewsetMixin, viewsets.ViewSet):
|
||||
context={"user": request.user, "remote_addr": request.META["REMOTE_ADDR"]},
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
if not request.user.tfa_enabled:
|
||||
if not request.user.totp_enabled:
|
||||
# We include it as "password" to display the error
|
||||
return response.Response({"error": _("2FA is not enabled")}, status=403)
|
||||
request.user.totpdevice_set.all().delete()
|
||||
request.user.staticdevice_set.all().delete()
|
||||
request.user.tfa_enabled = False
|
||||
request.user.totp_enabled = False
|
||||
request.user.save()
|
||||
return response.Response()
|
||||
|
||||
|
||||
@@ -80,7 +80,6 @@ CORE_SETTINGS = {
|
||||
|
||||
|
||||
class ParametersAPITestCase(ModoAPITestCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
cache.delete("password_scheme_choice")
|
||||
@@ -184,7 +183,6 @@ class ParametersAPITestCase(ModoAPITestCase):
|
||||
|
||||
|
||||
class AccountViewSetTestCase(ModoAPITestCase):
|
||||
|
||||
def test_me(self):
|
||||
url = reverse("v2:account-me")
|
||||
resp = self.client.get(url)
|
||||
@@ -226,7 +224,7 @@ class AccountViewSetTestCase(ModoAPITestCase):
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.content_type, "application/json")
|
||||
|
||||
user.tfa_enabled = True
|
||||
user.totp_enabled = True
|
||||
user.save()
|
||||
resp = self.client.get(url)
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
@@ -275,7 +273,7 @@ class AccountViewSetTestCase(ModoAPITestCase):
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
user.refresh_from_db()
|
||||
self.assertEqual(user.tfa_enabled, False)
|
||||
self.assertEqual(user.totp_enabled, False)
|
||||
|
||||
# def test_api_token(self):
|
||||
# # 1. Obtain a JWT token so we can safely play with basic token
|
||||
@@ -574,7 +572,6 @@ class PasswordResetTestCase(AccountViewSetTestCase):
|
||||
|
||||
|
||||
class LanguageViewSetTestCase(ModoAPITestCase):
|
||||
|
||||
def test_list(self):
|
||||
url = reverse("v2:language-list")
|
||||
resp = self.client.get(url)
|
||||
@@ -582,7 +579,6 @@ class LanguageViewSetTestCase(ModoAPITestCase):
|
||||
|
||||
|
||||
class ComponentAPITestCase(ModoAPITestCase):
|
||||
|
||||
def test_information(self):
|
||||
url = reverse("v2:components_information")
|
||||
resp = self.client.get(url)
|
||||
|
||||
@@ -82,7 +82,7 @@ class AccountViewSet(core_v1_viewsets.AccountViewSet):
|
||||
@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:
|
||||
if request.user.totp_enabled:
|
||||
return response.Response(status=404)
|
||||
device = request.user.totpdevice_set.first()
|
||||
if not device:
|
||||
@@ -110,7 +110,7 @@ class AccountViewSet(core_v1_viewsets.AccountViewSet):
|
||||
device.token_set.create(token=token)
|
||||
tokens.append(token)
|
||||
# Set enable flag to True so we can't go here anymore
|
||||
request.user.tfa_enabled = True
|
||||
request.user.totp_enabled = True
|
||||
request.user.save()
|
||||
# Generate new tokens
|
||||
device = request.user.totpdevice_set.first()
|
||||
@@ -166,7 +166,17 @@ class FIDOViewSet(
|
||||
serializer_class = serializers.FIDOSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return models.UserFidoKeys.objects.filter(user=self.request.user)
|
||||
if self.request.user.webautn_enabled:
|
||||
return models.UserFidoKeys.objects.filter(user=self.request.user)
|
||||
return None
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
super().destroy(request, *args, **kwargs)
|
||||
device = request.user.totpdevice_set.first()
|
||||
if device is None:
|
||||
request.user.webautn_enabled = False
|
||||
request.user.staticdevice_set.all().delete()
|
||||
request.user.save()
|
||||
|
||||
@action(methods=["post"], detail=False, url_path="registration/begin")
|
||||
def registration_begin(self, request):
|
||||
@@ -187,7 +197,20 @@ class FIDOViewSet(
|
||||
credential_data=credential_data,
|
||||
user=request.user,
|
||||
)
|
||||
if not request.user.tfa_enabled:
|
||||
request.user.tfa_enabled = True
|
||||
if not request.user.webautn_enabled:
|
||||
request.user.webautn_enabled = True
|
||||
request.user.save()
|
||||
|
||||
# create static device for recovery purposes if needed
|
||||
device = request.user.staticdevice_set.first()
|
||||
if device is None:
|
||||
device = StaticDevice.objects.create(
|
||||
user=request.user, name="{} static device".format(request.user)
|
||||
)
|
||||
for cpt in range(10):
|
||||
token = StaticToken.random_token()
|
||||
device.token_set.create(token=token)
|
||||
return response.Response(
|
||||
{"tokens": device.token_set.all().values_list("token", flat=True)}
|
||||
)
|
||||
return response.Response(status=204)
|
||||
|
||||
@@ -9,8 +9,11 @@ A management command to load Modoboa initial data.
|
||||
from functools import reduce
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from oauth2_provider.models import get_application_model
|
||||
|
||||
from modoboa.lib.permissions import add_permissions_to_group
|
||||
from ... import constants, extensions, models, signals
|
||||
|
||||
@@ -27,11 +30,19 @@ class Command(BaseCommand):
|
||||
default="admin",
|
||||
help="Username of the initial super administrator.",
|
||||
)
|
||||
(
|
||||
parser.add_argument(
|
||||
"--extra-fixtures",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Also load some fixtures from the admin application.",
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--extra-fixtures",
|
||||
"--dev",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Also load some fixtures from the admin application.",
|
||||
help="Setup dev environment. DO NOT USE IN PRODUCTION",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
@@ -76,3 +87,16 @@ class Command(BaseCommand):
|
||||
from modoboa.admin import factories
|
||||
|
||||
factories.populate_database()
|
||||
|
||||
if options["dev"]:
|
||||
app_model = get_application_model()
|
||||
if not app_model.objects.filter(name="frontend").exists():
|
||||
call_command(
|
||||
"createapplication",
|
||||
"--algorithm=RS256",
|
||||
"--redirect-uris=https://localhost:3000/login/logged",
|
||||
"--name=frontend",
|
||||
"--client-id=LVQbfIIX3khWR3nDvix1u9yEGHZUxcx53bhJ7FlD",
|
||||
"public",
|
||||
"authorization-code",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 4.2.13 on 2024-06-21 15:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0028_userfidokeys"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="user",
|
||||
old_name="tfa_enabled",
|
||||
new_name="totp_enabled",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="webauthn_enabled",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
+11
-11
@@ -58,8 +58,7 @@ class User(AbstractUser):
|
||||
master_user = models.BooleanField(
|
||||
gettext_lazy("Allow mailboxes access"),
|
||||
default=False,
|
||||
help_text=gettext_lazy(
|
||||
"Allow this administrator to access user mailboxes"),
|
||||
help_text=gettext_lazy("Allow this administrator to access user mailboxes"),
|
||||
)
|
||||
password = models.CharField(gettext_lazy("password"), max_length=256)
|
||||
|
||||
@@ -70,8 +69,7 @@ class User(AbstractUser):
|
||||
choices=constants.LANGUAGES,
|
||||
help_text=gettext_lazy("Prefered language to display pages."),
|
||||
)
|
||||
phone_number = PhoneNumberField(gettext_lazy(
|
||||
"Phone number"), blank=True, null=True)
|
||||
phone_number = PhoneNumberField(gettext_lazy("Phone number"), blank=True, null=True)
|
||||
secondary_email = models.EmailField(
|
||||
gettext_lazy("Secondary email"),
|
||||
max_length=254,
|
||||
@@ -82,7 +80,8 @@ class User(AbstractUser):
|
||||
),
|
||||
)
|
||||
|
||||
tfa_enabled = models.BooleanField(default=False)
|
||||
totp_enabled = models.BooleanField(default=False)
|
||||
webauthn_enabled = models.BooleanField(default=False)
|
||||
|
||||
_parameters = models.JSONField(default=dict, encoder=DjangoJSONEncoder)
|
||||
|
||||
@@ -206,6 +205,10 @@ class User(AbstractUser):
|
||||
)
|
||||
return self.email
|
||||
|
||||
@property
|
||||
def tfa_enabled(self):
|
||||
return self.totp_enabled or self.webauthn_enabled
|
||||
|
||||
def is_owner(self, obj):
|
||||
"""Tell is the user is the unique owner of this object.
|
||||
|
||||
@@ -214,8 +217,7 @@ class User(AbstractUser):
|
||||
"""
|
||||
ct = ContentType.objects.get_for_model(obj)
|
||||
try:
|
||||
ooentry = self.objectaccess_set.get(
|
||||
content_type=ct, object_id=obj.id)
|
||||
ooentry = self.objectaccess_set.get(content_type=ct, object_id=obj.id)
|
||||
except ObjectAccess.DoesNotExist:
|
||||
return False
|
||||
return ooentry.is_owner
|
||||
@@ -236,8 +238,7 @@ class User(AbstractUser):
|
||||
|
||||
ct = ContentType.objects.get_for_model(obj)
|
||||
try:
|
||||
ooentry = self.objectaccess_set.get(
|
||||
content_type=ct, object_id=obj.id)
|
||||
ooentry = self.objectaccess_set.get(content_type=ct, object_id=obj.id)
|
||||
except ObjectAccess.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
@@ -398,8 +399,7 @@ class User(AbstractUser):
|
||||
smart_str(self.role),
|
||||
smart_str(self.email),
|
||||
]
|
||||
results = signals.account_exported.send(
|
||||
sender=self.__class__, user=self)
|
||||
results = signals.account_exported.send(sender=self.__class__, user=self)
|
||||
for result in results:
|
||||
row += result[1]
|
||||
return row
|
||||
|
||||
@@ -53,7 +53,7 @@ class AuthenticationTestCase(ModoTestCase):
|
||||
def test_authentication_with_2fa(self, login_mock, match_mock):
|
||||
user = models.User.objects.get(username="user@test.com")
|
||||
user.totpdevice_set.create(name="Device")
|
||||
user.tfa_enabled = True
|
||||
user.totp_enabled = True
|
||||
user.save()
|
||||
data = {"username": "user@test.com", "password": "toto"}
|
||||
response = self.client.post(reverse("core:login"), data)
|
||||
|
||||
@@ -51,6 +51,7 @@ class AccountViewSetTestCase(ModoAPITestCase):
|
||||
|
||||
self.assertIn("recovery codes", response.json()["content"])
|
||||
admin.refresh_from_db()
|
||||
self.assertTrue(admin.totp_enabled)
|
||||
self.assertTrue(admin.tfa_enabled)
|
||||
device = admin.staticdevice_set.first()
|
||||
self.assertIsNot(device, None)
|
||||
|
||||
@@ -39,7 +39,6 @@ logger = logging.getLogger("modoboa.auth")
|
||||
|
||||
|
||||
class LoginViewMixin:
|
||||
|
||||
@cached_property
|
||||
def logger(self):
|
||||
return logging.getLogger("modoboa.auth")
|
||||
@@ -122,7 +121,7 @@ class LoginView(LoginViewMixin, auth_views.LoginView):
|
||||
username=user.username,
|
||||
password=form.cleaned_data["password"],
|
||||
)
|
||||
if user.tfa_enabled:
|
||||
if user.totp_enabled:
|
||||
self.request.session[constants.TFA_PRE_VERIFY_USER_PK] = user.pk
|
||||
self.request.session[constants.TFA_PRE_VERIFY_USER_BACKEND] = user.backend
|
||||
self.request.session["rememberme"] = form.cleaned_data["rememberme"]
|
||||
@@ -276,10 +275,7 @@ class TwoFactorCodeVerifyView(LoginViewMixin, generic.FormView):
|
||||
context = super().get_context_data(**kwargs)
|
||||
has_totp_device = django_otp.user_has_device(self.user)
|
||||
context["totp_device"] = has_totp_device
|
||||
has_webauthn_device = models.UserFidoKeys.objects.filter(
|
||||
user=self.user
|
||||
).exists()
|
||||
context["webauthn_device"] = has_webauthn_device
|
||||
context["webauthn_device"] = self.user.webauthn_enabled
|
||||
context["nextlocation"] = self.request.POST.get(
|
||||
"next", self.request.GET.get("next")
|
||||
)
|
||||
|
||||
@@ -127,13 +127,13 @@ def api_access(request):
|
||||
def security(request):
|
||||
"""View to manage security settings."""
|
||||
context = {"user_has_device": django_otp.user_has_device(request.user)}
|
||||
if not request.user.tfa_enabled:
|
||||
if not request.user.totp_enabled:
|
||||
device = request.user.staticdevice_set.first()
|
||||
if device:
|
||||
tokens = device.token_set.all().values_list("token", flat=True)
|
||||
context.update({"tokens": tokens})
|
||||
# Set enable flag to True so we can't go here anymore
|
||||
request.user.tfa_enabled = True
|
||||
request.user.totp_enabled = True
|
||||
request.user.save()
|
||||
else:
|
||||
device = request.user.totpdevice_set.first()
|
||||
|
||||
Reference in New Issue
Block a user