Added webauthn_enabled

This commit is contained in:
Spitfireap
2024-06-22 19:45:57 +02:00
parent c3124f02b3
commit 883dad9b15
14 changed files with 126 additions and 110 deletions
@@ -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>
+2
View File
@@ -253,6 +253,8 @@ class AccountSerializer(serializers.ModelSerializer):
"secondary_email",
"domains",
"tfa_enabled",
"totp_enabled",
"webauthn_enabled",
"date_joined",
"last_login",
)
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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()
+2 -6
View File
@@ -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)
+28 -5
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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)
+1
View File
@@ -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)
+2 -6
View File
@@ -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")
)
+2 -2
View File
@@ -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()