Allow to enable only webauthn

This commit is contained in:
Spitfireap
2024-06-22 19:45:58 +02:00
parent 0d8e0690c5
commit b1bf3faf07
12 changed files with 261 additions and 164 deletions
+5 -5
View File
@@ -1,7 +1,7 @@
{
"API_BASE_URL": "/api/v2/",
"API_DOC_URL": "/api/schema-v2/swagger/",
"OAUTH_AUTHORITY_URL": "https://localhost:8000/api/o",
"OAUTH_CLIENT_ID": "LVQbfIIX3khWR3nDvix1u9yEGHZUxcx53bhJ7FlD",
"OAUTH_REDIRECT_URI": "https://localhost:3000/login/logged"
"API_BASE_URL": "/api/v2/",
"API_DOC_URL": "/api/schema-v2/swagger/",
"OAUTH_AUTHORITY_URL": "https://localhost:8000/api/o",
"OAUTH_CLIENT_ID": "LVQbfIIX3khWR3nDvix1u9yEGHZUxcx53bhJ7FlD",
"OAUTH_REDIRECT_URI": "https://localhost:3000/login/logged"
}
+1 -1
View File
@@ -24,5 +24,5 @@ export default {
},
deleteFido(id) {
return repository.delete(`/fido/${id}/`)
}
},
}
@@ -0,0 +1,116 @@
<template>
<div>
<v-card flat>
<v-card-title>
<span class="text-h6">
{{ $gettext('Backup codes') }}
</span>
</v-card-title>
<v-card-text>
<template v-if="tfa_enabled">
<v-alert type="info" class="mb-2">
{{
$gettext(
"You already have generated backup codes for this account. If you don't remember them or have used them all, you generate new ones."
)
}}
</v-alert>
<v-form ref="editTFAForm">
<label class="m-label">{{ $gettext('Password') }} </label>
<v-text-field
v-model="password"
autocomplete="new-password"
type="password"
variant="outlined"
:rules="[rules.required]"
:error-messages="passwordError"
/>
<div class="mt-5">
<v-btn
:loading="loadingReset"
color="success"
@click="resetRecoveryCodes"
>
{{ $gettext('Reset recovery codes') }}
</v-btn>
</div>
</v-form>
</template>
<template v-else>
<div tag="p" class="my-4">
{{
$gettext(
'No two-factor authentificator is yet set for your account.'
)
}}
</div>
</template>
<v-dialog v-model="showCodesResetDialog" max-width="800px" persistent>
<RecoveryCodesResetDialog
:tokens="newTokens"
@close="closeRecoveryCodesResetDialog"
/>
</v-dialog>
</v-card-text>
</v-card>
</div>
</template>
<script setup lang="js">
import accountApi from '@/api/account'
import RecoveryCodesResetDialog from './RecoveryCodesResetDialog'
import { useGettext } from 'vue3-gettext'
import { useBusStore, useAuthStore } from '@/stores'
import rules from '@/plugins/rules'
import { ref, computed } from 'vue'
const { $gettext } = useGettext()
const busStore = useBusStore()
const authStore = useAuthStore()
const tfa_enabled = computed(() => authStore.authUser.tfa_enabled)
const loadingReset = ref(false)
const editTFAForm = ref()
const password = ref('')
const newTokens = ref([])
const passwordError = ref()
const showCodesResetDialog = ref(false)
async function resetRecoveryCodes() {
const isValid = await editTFAForm.value.validate()
if (!isValid) {
return
}
const payload = {
password: password.value,
}
loadingReset.value = true
accountApi
.resetRecoveryCodes(payload)
.then((response) => {
newTokens.value = response.data.tokens
showCodesResetDialog.value = true
})
.catch((error) => {
if (error.response.status === 400) {
passwordError.value = error.response.data.password
} else {
busStore.displayNotification({
msg: $gettext(error.response.data),
type: 'error',
})
}
})
.finally(() => {
loadingReset.value = false
editTFAForm.value.reset()
})
}
function closeRecoveryCodesResetDialog() {
newTokens.value = []
showCodesResetDialog.value = false
}
</script>
@@ -16,33 +16,8 @@
)
}}
</div>
<template v-if="browserCapable">
<v-form ref="fidoForm" @submit.prevent="startFidoRegistration">
<v-text-field
v-model="name"
:label="$gettext('New device name')"
:rules="[rules.required]"
/>
<v-btn
color="success"
type="submit"
:loading="registrationLoading"
>
{{ $gettext('Setup device') }}
</v-btn>
</v-form>
</template>
<template v-else>
<v-alert type="error">
{{
$gettext(
'Your browser does not seem compatible with WebAuthN'
)
}}
</v-alert>
</template>
</v-col>
<v-col v-if="fidoCreds.length" cols="8">
<v-col v-else cols="8">
<h3>{{ $gettext('Registered Webauthn devices') }}</h3>
<v-data-table-virtual
:headers="headers"
@@ -74,6 +49,38 @@
</template>
</v-data-table-virtual>
</v-col>
<v-col cols="4">
<template v-if="browserCapable">
<h3>
{{
$gettext('Enter a name to register a new webauthn device.')
}}
</h3>
<v-form ref="fidoForm" @submit.prevent="startFidoRegistration">
<v-text-field
v-model="name"
:label="$gettext('New device name')"
:rules="[rules.required]"
/>
<v-btn
color="success"
type="submit"
:loading="registrationLoading"
>
{{ $gettext('Setup device') }}
</v-btn>
</v-form>
</template>
<template v-else>
<v-alert type="error">
{{
$gettext(
'Your browser does not seem compatible with WebAuthN'
)
}}
</v-alert>
</template>
</v-col>
</v-row>
</v-card-text>
</v-card>
@@ -95,7 +102,7 @@
</ConfirmDialog>
<v-dialog v-model="showBackupTokenDialog" max-width="800px" persistent>
<RecoveryCodesResetDialog
:tokens="newTokens"
:tokens="tokens"
@close="closeRecoveryCodesResetDialog"
/>
</v-dialog>
@@ -153,16 +160,21 @@ async function startFidoRegistration() {
const creationOption = await authApi.beginFidoRegistration()
if (creationOption) {
const options = parseCreationOptionsFromJSON(creationOption.data)
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()
try {
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()
} catch {
registrationLoading.value = false
return
}
}
registrationLoading.value = false
}
@@ -47,7 +47,7 @@
</v-col>
</v-row>
</template>
<template v-else-if="tokens.length">
<template v-else-if="successSetup">
<v-alert type="success">
{{
$gettext(
@@ -55,22 +55,24 @@
)
}}
</v-alert>
<p class="mt-4">
{{
$gettext(
"The following recovery codes can be used one time each to let you regain access to your account, in case you lose your phone for example. Make sure to save them in a safe place, otherwise you won't be able to access your account anymore."
)
}}
</p>
<v-table density="compact">
<tbody>
<tr v-for="token in tokens" :key="token">
<td>{{ token }}</td>
</tr>
</tbody>
</v-table>
<template v-if="tokens.length">
<p class="mt-4">
{{
$gettext(
"The following recovery codes can be used one time each to let you regain access to your account, in case you lose your phone for example. Make sure to save them in a safe place, otherwise you won't be able to access your account anymore."
)
}}
</p>
<v-table density="compact">
<tbody>
<tr v-for="token in tokens" :key="token">
<td>{{ token }}</td>
</tr>
</tbody>
</v-table>
</template>
</template>
<template v-else-if="tfa_enabled">
<template v-else-if="totp_enabled">
<v-alert type="info" class="mb-2">
{{
$gettext(
@@ -97,9 +99,6 @@
>
{{ $gettext('Disable 2FA') }}
</v-btn>
<v-btn :loading="loadingReset" @click="resetRecoveryCodes">
{{ $gettext('Reset recovery codes') }}
</v-btn>
</div>
</v-form>
</template>
@@ -117,18 +116,11 @@
</template>
</v-card-text>
</v-card>
<v-dialog v-model="showCodesResetDialog" max-width="800px" persistent>
<RecoveryCodesResetDialog
:tokens="newTokens"
@close="closeRecoveryCodesResetDialog"
/>
</v-dialog>
</div>
</template>
<script setup lang="js">
import accountApi from '@/api/account'
import RecoveryCodesResetDialog from './RecoveryCodesResetDialog'
import QrcodeVue from 'qrcode.vue'
import { useBusStore, useAuthStore } from '@/stores'
import { ref, computed } from 'vue'
@@ -140,21 +132,18 @@ const busStore = useBusStore()
const authStore = useAuthStore()
const editTFAForm = ref()
const newTokens = ref([])
const pinCode = ref('')
const pinCodeErrors = ref([])
const key = ref(null)
const qrURL = ref(null)
const clicked = ref(false)
const showCodesResetDialog = ref(false)
const loadingDisable = ref(false)
const loadingReset = ref(false)
const password = ref('')
const passwordError = ref([])
const tokens = ref([])
const successSetup = ref(false)
const tfa_enabled = computed(() => authStore.authUser.tfa_enabled)
const totp_enabled = computed(() => authStore.authUser.totp_enabled)
function getKey() {
accountApi.getKeyForTFASetup().then((resp) => {
@@ -180,7 +169,10 @@ function finalizeTFASetup() {
.then((response) => {
key.value = null
qrURL.value = null
tokens.value = response.data.tokens
if (response.status === 200) {
tokens.value = response.data.tokens
}
successSetup.value = true
})
.catch((error) => {
if (error.response.status === 400) {
@@ -218,46 +210,6 @@ async function disableTFA() {
editTFAForm.value.reset()
})
}
async function resetRecoveryCodes() {
const isValid = await editTFAForm.value.validate()
if (!isValid) {
return
}
const payload = {
password: password.value,
}
loadingReset.value = true
accountApi
.resetRecoveryCodes(payload)
.then((response) => {
newTokens.value = response.data.tokens
showCodesResetDialog.value = true
})
.catch((error) => {
if (error.response.status === 400) {
passwordError.value = error.response.data.password
} else {
busStore.displayNotification({
msg: $gettext(error.response.data),
type: 'error',
})
}
})
.finally(() => {
loadingReset.value = false
editTFAForm.value.reset()
})
}
function closeRecoveryCodesResetDialog() {
newTokens.value = []
showCodesResetDialog.value = false
}
if (!tfa_enabled.value) {
getKey()
}
</script>
<style>
@@ -36,10 +36,10 @@ const { $gettext } = useGettext()
const props = defineProps({
// the callback should return true or false, returning false inhibits the close event
callback_agree : {
callback_agree: {
type: Function,
default: null
}
default: null,
},
})
const dialog = ref(false)
@@ -70,7 +70,7 @@ defineExpose({
})
async function agree() {
if (props.callback_agree !== null && ! (await props.callback_agree())) {
if (props.callback_agree !== null && !(await props.callback_agree())) {
return
}
storedResolve(true)
+12 -2
View File
@@ -46,18 +46,28 @@ export const useAuthStore = defineStore('auth', () => {
async function getFidoCreds() {
return authApi.getAllFidoRegistred().then((resp) => {
fidoCreds.value = resp.data
if (fidoCreds.value.length > 0) {
authUser.value.tfa_enabled = true
authUser.value.webauthn_enabled = true
}
return resp
})
}
async function addFidoCred(result) {
return authApi.endFidoRegistration(result).then(() => {
return authApi.endFidoRegistration(result).then((res) => {
getFidoCreds()
return res
})
}
async function deleteFidoCreds(id) {
return authApi.deleteFido(id).then(() => {
return authApi.deleteFido(id).then((res) => {
fidoCreds.value = fidoCreds.value.filter((cred) => cred.id !== id)
authUser.value.tfa_enabled = res.data.tfa_enabled
if (!res.data.tfa_enabled) {
authUser.value.webauthn_enabled = false
}
})
}
+4 -2
View File
@@ -25,7 +25,8 @@
<ProfileForm />
</v-window-item>
<v-window-item value="security">
<TwoFactorAuthForm />
<TotpAuthForm />
<BackupCodeAuthForm />
<FidoAuthForm />
</v-window-item>
<v-window-item v-if="authStore.userHasMailbox" value="forward">
@@ -47,7 +48,8 @@
<script setup>
import { ref } from 'vue'
import ProfileForm from '@/components/account/ProfileForm.vue'
import TwoFactorAuthForm from '@/components/account/TwoFactorAuthForm.vue'
import TotpAuthForm from '@/components/account/TotpAuthForm.vue'
import BackupCodeAuthForm from '@/components/account/BackupCodeAuthForm.vue'
import ForwardForm from '@/components/account/ForwardForm.vue'
import AutoReplyForm from '@/components/account/AutoReplyForm.vue'
import APISetupForm from '@/components/account/APISetupForm.vue'
+1 -1
View File
@@ -23,7 +23,7 @@ class CheckTFASetupSerializer(serializers.Serializer):
def validate(self, data):
user = self.context["user"]
if user.totp_enabled or user.staticdevice_set.exists():
if user.totp_enabled:
raise serializers.ValidationError(_("2FA is already enabled"))
return data
+2 -1
View File
@@ -63,8 +63,9 @@ class AccountViewSet(GetThrottleViewsetMixin, viewsets.ViewSet):
# 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.totp_enabled = False
if not request.user.tfa_enabled:
request.user.staticdevice_set.all().delete()
request.user.save()
return response.Response()
+40 -38
View File
@@ -13,12 +13,32 @@ from modoboa.core.api.v1 import viewsets as core_v1_viewsets
from modoboa.lib import pagination
from modoboa.lib.throttle import GetThrottleViewsetMixin
import base64
from ... import constants
from ... import fido2_auth as f2_auth
from ... import models
from . import serializers
def create_static_tokens(request):
"""Utility function to create or recreate
static tokens 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)
)
elif device is not None:
return None
tokens = []
for cpt in range(10):
token = StaticToken.random_token()
device.token_set.create(token=token)
tokens.append(token)
return tokens
class AccountViewSet(core_v1_viewsets.AccountViewSet):
"""Account viewset."""
@@ -88,7 +108,7 @@ class AccountViewSet(core_v1_viewsets.AccountViewSet):
if not device:
return response.Response(status=404)
return response.Response(
{"key": device.key, "url": device.config_url},
{"key": base64.b32encode(device.bin_key), "url": device.config_url},
content_type="application/json",
)
@@ -101,28 +121,13 @@ class AccountViewSet(core_v1_viewsets.AccountViewSet):
)
serializer.is_valid(raise_exception=True)
# create static device for recovery purposes
device = StaticDevice.objects.create(
user=request.user, name="{} static device".format(request.user)
)
tokens = []
for cpt in range(10):
token = StaticToken.random_token()
device.token_set.create(token=token)
tokens.append(token)
tokens = create_static_tokens(request)
# Set enable flag to True so we can't go here anymore
request.user.totp_enabled = True
request.user.save()
# Generate new tokens
device = request.user.totpdevice_set.first()
# refresh = RefreshToken.for_user(request.user)
# refresh[constants.TFA_DEVICE_TOKEN_KEY] = device.persistent_id
return response.Response(
{
"tokens": tokens,
# "refresh": str(refresh),
# "access": str(refresh.access_token),
}
)
if tokens is None:
return response.Response(status=204)
return response.Response({"tokens": tokens})
class LogViewSet(GetThrottleViewsetMixin, viewsets.ReadOnlyModelViewSet):
@@ -166,17 +171,23 @@ class FIDOViewSet(
serializer_class = serializers.FIDOSerializer
def get_queryset(self):
if self.request.user.webautn_enabled:
if self.request.user.webauthn_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
queryset = self.get_queryset()
to_save = False
if not queryset.exists():
request.user.webauthn_enabled = False
to_save = True
if not request.user.tfa_enabled:
request.user.staticdevice_set.all().delete()
to_save = True
if to_save:
request.user.save()
return response.Response({"tfa_enabled": request.user.tfa_enabled})
@action(methods=["post"], detail=False, url_path="registration/begin")
def registration_begin(self, request):
@@ -197,20 +208,11 @@ class FIDOViewSet(
credential_data=credential_data,
user=request.user,
)
if not request.user.webautn_enabled:
request.user.webautn_enabled = True
if not request.user.webauthn_enabled:
request.user.webauthn_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)}
)
tokens = create_static_tokens(request)
if tokens is not None:
return response.Response({"tokens": tokens})
return response.Response(status=204)
+3 -1
View File
@@ -121,7 +121,7 @@ class LoginView(LoginViewMixin, auth_views.LoginView):
username=user.username,
password=form.cleaned_data["password"],
)
if user.totp_enabled:
if user.tfa_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"]
@@ -279,6 +279,8 @@ class TwoFactorCodeVerifyView(LoginViewMixin, generic.FormView):
context["nextlocation"] = self.request.POST.get(
"next", self.request.GET.get("next")
)
if context["nextlocation"] is None:
context.pop("nextlocation")
return context
def form_valid(self, form):