Added Fido API

This commit is contained in:
Spitfireap
2024-06-22 19:45:21 +02:00
parent 37556a03c6
commit 5c5bd47630
24 changed files with 428 additions and 204 deletions
+2
View File
@@ -34,3 +34,5 @@ test_project/modoboa.db
.ash_history
modoboa.kdev4
.kateproject
test_project/cert.crt
test_project/cert.key
+1 -1
View File
@@ -38,7 +38,7 @@ services:
python3 manage.py migrate
python3 manage.py load_initial_data
django-admin compilemessages
python3 manage.py runserver 0:8000"
python3 manage.py runserver_plus 0.0.0.0:8000 --cert-file cert.crt"
volumes:
- .:/code
+1
View File
@@ -15,6 +15,7 @@ COPY requirements.txt /tmp
COPY test-requirements.txt /tmp
RUN python -m pip install -U pip
RUN pip install -r requirements.txt -r test-requirements.txt
RUN pip install django-extensions Werkzeug pyOpenSSL
FROM base as run
COPY docker/doveadm /usr/local/bin
+1
View File
@@ -32,6 +32,7 @@
"vuetify": "^3.5.4"
},
"devDependencies": {
"@vitejs/plugin-basic-ssl": "^1.1.0",
"@vitejs/plugin-vue": "^5.0.4",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.21.1",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"API_BASE_URL": "/api/v2/",
"API_DOC_URL": "/api/schema-v2/swagger/",
"OAUTH_AUTHORITY_URL": "http://localhost:8000/api/o",
"OAUTH_AUTHORITY_URL": "https://localhost:8000/api/o",
"OAUTH_CLIENT_ID": "LVQbfIIX3khWR3nDvix1u9yEGHZUxcx53bhJ7FlD",
"OAUTH_REDIRECT_URI": "http://localhost:3000/login/logged"
"OAUTH_REDIRECT_URI": "https://localhost:3000/login/logged"
}
+3
View File
@@ -13,4 +13,7 @@ export default {
changePassword(data) {
return repository.post('/reset_confirm/', data)
},
beginFidoRegistration() {
return repository.post('/fido/registration/begin/')
},
}
@@ -0,0 +1,206 @@
<template>
<div>
<v-card flat>
<v-card-title>
<span class="text-subtitle-1">
{{ $gettext('WebAuthn') }}
</span>
</v-card-title>
<v-card-text>
<div tag="p" class="my-4">
{{
$gettext(
"You don't have any WebAuthN registered as a second authentication method."
)
}}
</div>
<template v-if="browserCapable">
<v-text-field v-model="name" />
<v-btn color="success" :loading="registrationLoading" @click="startFidoRegistration">
{{ $gettext('Add WebAuthN device') }}
</v-btn>
</template>
<template v-else>
<v-alert type="error">
{{ $gettext('Your browser does not seem compatible with WebAuthN') }}
</v-alert>
</template>
</v-card-text>
</v-card>
</div>
</template>
<script setup lang="js">
import { ref, onMounted } from 'vue'
import authApi from '@/api/auth.js'
import { useGettext } from 'vue3-gettext'
const { $gettext } = useGettext()
const name = ref()
const registrationLoading = ref(false)
const creationOption = ref()
const browserCapable = !!(navigator.credentials && navigator.credentials.create && navigator.credentials.get && window.PublicKeyCredential)
async function startFidoRegistration() {
if (creationOption.value) {
navigator.credentials.create({...creationOption.value})
.then(function (attestation) {
const result = createResponseToJSON(attestation)
console.log(result)
})
}
}
onMounted(() => {
registrationLoading.value = true
authApi.beginFidoRegistration().then((resp) => {
creationOption.value = createRequestFromJSON(resp.data)
}).finally(() => registrationLoading.value = false)
})
// Taken from the example of python-fido2 repo
// src/webauthn-json/base64url.ts
function base64urlToBuffer(baseurl64String) {
const padding = "==".slice(0, (4 - baseurl64String.length % 4) % 4);
const base64String = baseurl64String.replace(/-/g, "+").replace(/_/g, "/") + padding;
const str = atob(base64String);
const buffer = new ArrayBuffer(str.length);
const byteView = new Uint8Array(buffer);
for (let i = 0; i < str.length; i++) {
byteView[i] = str.charCodeAt(i);
}
return buffer;
}
function bufferToBase64url(buffer) {
const byteView = new Uint8Array(buffer);
let str = "";
for (const charCode of byteView) {
str += String.fromCharCode(charCode);
}
const base64String = btoa(str);
const base64urlString = base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
return base64urlString;
}
// src/webauthn-json/convert.ts
const copyValue = "copy";
const convertValue = "convert";
function convert(conversionFn, schema, input) {
if (schema === copyValue) {
return input;
}
if (schema === convertValue) {
return conversionFn(input);
}
if (schema instanceof Array) {
return input.map((v) => convert(conversionFn, schema[0], v));
}
if (schema instanceof Object) {
const output = {};
for (const [key, schemaField] of Object.entries(schema)) {
if (schemaField.derive) {
const v = schemaField.derive(input);
if (v !== void 0) {
input[key] = v;
}
}
if (!(key in input)) {
if (schemaField.required) {
throw new Error(`Missing key: ${key}`);
}
continue;
}
if (input[key] == null) {
output[key] = null;
continue;
}
output[key] = convert(conversionFn, schemaField.schema, input[key]);
}
return output;
}
}
function derived(schema, derive) {
return {
required: true,
schema,
derive
};
}
function required(schema) {
return {
required: true,
schema
};
}
function optional(schema) {
return {
required: false,
schema
};
}
const publicKeyCredentialDescriptorSchema = {
type: required(copyValue),
id: required(convertValue),
transports: optional(copyValue)
}
const simplifiedExtensionsSchema = {
appid: optional(copyValue),
appidExclude: optional(copyValue),
credProps: optional(copyValue)
}
const simplifiedClientExtensionResultsSchema = {
appid: optional(copyValue),
appidExclude: optional(copyValue),
credProps: optional(copyValue)
}
const credentialCreationOptions = {
publicKey: required({
rp: required(copyValue),
user: required({
id: required(convertValue),
name: required(copyValue),
displayName: required(copyValue)
}),
challenge: required(convertValue),
pubKeyCredParams: required(copyValue),
timeout: optional(copyValue),
excludeCredentials: optional([publicKeyCredentialDescriptorSchema]),
authenticatorSelection: optional(copyValue),
attestation: optional(copyValue),
extensions: optional(simplifiedExtensionsSchema)
}),
signal: optional(copyValue)
}
const publicKeyCredentialWithAttestation = {
type: required(copyValue),
id: required(copyValue),
rawId: required(convertValue),
authenticatorAttachment: optional(copyValue),
response: required({
clientDataJSON: required(convertValue),
attestationObject: required(convertValue),
transports: derived(copyValue, (response) => {
var _a;
return ((_a = response.getTransports) == null ? void 0 : _a.call(response)) || [];
})
}),
clientExtensionResults: derived(simplifiedClientExtensionResultsSchema, (pkc) => pkc.getClientExtensionResults())
}
function createRequestFromJSON(requestJSON) {
return convert(base64urlToBuffer, credentialCreationOptions, requestJSON);
}
function createResponseToJSON(credential) {
return convert(bufferToBase64url, publicKeyCredentialWithAttestation, credential);
}
</script>
@@ -0,0 +1,54 @@
<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>
-131
View File
@@ -1,131 +0,0 @@
<template>
<div>
<v-toolbar flat>
<v-toolbar-title>{{ $gettext('API access') }}</v-toolbar-title>
</v-toolbar>
<div class="pa-4">
<v-card v-if="!token" flat>
<v-card-text>
{{ $gettext('Your API token has not been generated yet.') }}
</v-card-text>
<v-card-actions>
<v-btn
color="success"
variant="flat"
:loading="loading"
@click="generateKey"
>
{{ $gettext('Generate') }}
</v-btn>
</v-card-actions>
</v-card>
<v-card v-else flat>
<v-card-text>
<label class="m-label">
{{ $gettext('Your API token is:') }}
</label>
<code class="ml-5 mr-5">
{{ token }}
</code>
<v-btn
icon="mdi-clipboard-plus"
density="compact"
:title="$gettext('Copy token to clipboard')"
variant="text"
@click="copyToClipboard()"
>
</v-btn>
<v-btn
icon="mdi-delete"
density="compact"
color="error"
variant="text"
:title="$gettext('Delete token')"
@click="openDeletionDialog"
>
</v-btn>
</v-card-text>
</v-card>
<v-alert type="info" variant="tonal" border="start" class="mt-4">
{{ $gettext('A documentation of the API is available') }}
<a :href="apiDocUrl" target="_blank">{{ $gettext('here') }}</a
>.
</v-alert>
</div>
<ConfirmDialog ref="confirm" @agree="deleteToken" />
</div>
</template>
<script setup lang="js">
import accountApi from '@/api/account'
import { useBusStore } from '@/stores'
import ConfirmDialog from '@/components/tools/ConfirmDialog.vue'
import { ref, computed, inject, onMounted } from 'vue'
import { useGettext } from 'vue3-gettext'
const { $gettext } = useGettext()
const busStore = useBusStore()
const $config = inject('$config')
const apiDocUrl = computed(() => $config.API_DOC_URL)
const loading = ref(false)
const token = ref(null)
const confirm = ref()
function copyToClipboard() {
navigator.clipboard.writeText(token.value)
busStore.displayNotification({
msg: $gettext('API token copied to your clipboard'),
type: 'success',
})
}
function generateKey() {
loading.value = true
accountApi
.createAPIToken()
.then((resp) => {
token.value = resp.data.token
busStore.displayNotification({
msg: $gettext('API token created'),
type: 'success',
})
})
.finally(() => (loading.value = false))
}
function openDeletionDialog() {
confirm.value.open(
$gettext('Warning'),
$gettext('You are about to delete your API access token'),
{
color: 'error',
cancelLabel: $gettext('Cancel'),
agreeLabel: $gettext('Proceed'),
}
)
}
async function deleteToken() {
await accountApi.deleteAPIToken()
busStore.displayNotification({
msg: $gettext('API token deleted'),
type: 'success',
})
token.value = null
}
onMounted(() => {
accountApi.getAPIToken().then((resp) => {
token.value = resp.data.token
})
})
</script>
<style scoped>
.v-toolbar {
background-color: #f7f8fa !important;
}
</style>
@@ -1,18 +0,0 @@
<template>
<v-toolbar flat>
<v-toolbar-title>{{ $gettext('Forward') }}</v-toolbar-title>
</v-toolbar>
<div class="pa-4">
<ForwardForm />
</div>
</template>
<script setup lang="js">
import ForwardForm from '@/components/account/ForwardForm.vue'
</script>
<style scoped>
.v-toolbar {
background-color: #f7f8fa !important;
}
</style>
@@ -1,23 +0,0 @@
<template>
<div>
<v-toolbar flat>
<v-toolbar-title>{{ $gettext('Profile') }}</v-toolbar-title>
</v-toolbar>
<div class="pa-4">
<ProfileForm />
</div>
</div>
</template>
<script setup lang="js">
import ProfileForm from '@/components/account/ProfileForm.vue'
import { useGettext } from 'vue3-gettext'
const { $gettext } = useGettext()
</script>
<style scoped>
.v-toolbar {
background-color: #f7f8fa !important;
}
</style>
@@ -1,23 +0,0 @@
<template>
<div>
<v-toolbar flat>
<v-toolbar-title>{{ $gettext('Security') }}</v-toolbar-title>
</v-toolbar>
<div class="pa-4">
<TwoFactorAuthForm />
</div>
</div>
</template>
<script setup lang="js">
import TwoFactorAuthForm from '@/components/account/TwoFactorAuthForm.vue'
import { useGettext } from 'vue3-gettext'
const { $gettext } = useGettext()
</script>
<style scoped>
.v-toolbar {
background-color: #f7f8fa !important;
}
</style>
@@ -30,6 +30,7 @@
</v-window-item>
<v-window-item value="security">
<TwoFactorAuthForm />
<FidoAuthForm />
</v-window-item>
<v-window-item value="forward" v-if="authStore.userHasMailbox">
<ForwardForm />
@@ -51,6 +52,7 @@ import TwoFactorAuthForm from '@/components/account/TwoFactorAuthForm.vue'
import ForwardForm from '@/components/account/ForwardForm.vue'
import AutoReplyForm from '@/components/account/AutoReplyForm.vue'
import APISetupForm from '@/components/account/APISetupForm.vue'
import FidoAuthForm from '@/components/account/FidoAuthForm.vue'
import { useAuthStore } from '@/stores'
const authStore = useAuthStore()
+10 -4
View File
@@ -2,6 +2,7 @@
import vue from '@vitejs/plugin-vue'
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
import ViteFonts from 'unplugin-fonts/vite'
import basicSsl from '@vitejs/plugin-basic-ssl'
// Utilities
import { defineConfig } from 'vite'
@@ -10,6 +11,7 @@ import { fileURLToPath, URL } from 'node:url'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
basicSsl(),
vue({
template: { transformAssetUrls },
}),
@@ -42,10 +44,14 @@ export default defineConfig({
server: {
port: 3000,
proxy: {
'/api':
process.env.DOCKER == 'yes'
? 'http://api:8000'
: 'http://127.0.0.1:8000',
'/api': {
target:
process.env.DOCKER == 'yes'
? 'https://api:8000'
: 'http://127.0.0.1:8000',
secure: false,
},
},
https: true,
},
})
+5
View File
@@ -331,6 +331,11 @@
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406"
integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==
"@vitejs/plugin-basic-ssl@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.1.0.tgz#8b840305a6b48e8764803435ec0c716fa27d3802"
integrity sha512-wO4Dk/rm8u7RNhOf95ZzcEmC9rYOncYgvq4z3duaJrCgjN8BxAnDVyndanfcJZ0O6XZzHz6Q0hTimxTg8Y9g/A==
"@vitejs/plugin-vue@^5.0.4":
version "5.0.4"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-5.0.4.tgz#508d6a0f2440f86945835d903fcc0d95d1bb8a37"
+16
View File
@@ -238,6 +238,22 @@ class CoreGlobalParametersSerializer(serializers.Serializer):
return data
class FIDOSerializer(serializers.ModelSerializer):
class Meta:
model = models.UserFidoKeys
fields = ["id", "name", "enabled", "added_on", "last_used"]
class FidoRegistrationSerializer(serializers.Serializer):
"""Serializer used to finish the fido key registration."""
type = serializers.CharField()
id = serializers.CharField()
raw_id = serializers.CharField()
authenticatorAttachment = serializers.CharField()
response = serializers.JSONField()
class LogSerializer(serializers.ModelSerializer):
"""Log serializer."""
+1
View File
@@ -12,6 +12,7 @@ router = routers.SimpleRouter()
router.register(r"account", viewsets.AccountViewSet, basename="account")
router.register(r"languages", viewsets.LanguageViewSet, basename="language")
router.register(r"logs/audit-trail", viewsets.LogViewSet)
router.register(r"fido", viewsets.FIDOViewSet, basename="fido")
urlpatterns = router.urls
urlpatterns += [
+1 -1
View File
@@ -27,7 +27,7 @@ def delete_cache_key(class_target, throttles, request):
"""Attempt to delete cache key from throttling on login/password reset success."""
for throttle in throttles:
if type(throttle) == class_target:
if type(throttle) is class_target:
throttle.reset_cache(request)
return
+32 -1
View File
@@ -2,7 +2,7 @@
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
from rest_framework import filters, permissions, response, viewsets, mixins
from rest_framework.authtoken.models import Token
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
@@ -16,6 +16,7 @@ from modoboa.lib import pagination
from modoboa.lib.throttle import GetThrottleViewsetMixin
from ... import constants
from ... import fido2_auth as f2_auth
from ... import models
from . import serializers
@@ -167,3 +168,33 @@ class LanguageViewSet(GetThrottleViewsetMixin, viewsets.ViewSet):
{"code": lang[0], "label": lang[1]} for lang in constants.LANGUAGES
]
return response.Response(languages)
class FIDOViewSet(GetThrottleViewsetMixin,
mixins.ListModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet):
"""Fido management viewset. """
permission_classes = (permissions.IsAuthenticated,)
serializer_class = serializers.FIDOSerializer
def get_queryset(self):
return models.UserFidoKeys.objects.filter(user=self.request.user)
@action(methods=["post"], detail=False, url_path="registration/begin")
def registration_begin(self, request):
"""An Api View which starts the registration process of a fido key."""
options = f2_auth.begin_registration(request)
return response.Response(dict(options))
@action(methods=["post"], detail=False, url_path="registration/end")
def registration_end(self, request):
"""An Api View to complete the registration process of a fido key."""
serializer = serializers.FidoRegistrationSerializer(
data=request.data)
serializer.is_valid(raise_exception=True)
f2_auth.end_registration(request.data,
request.session.pop("fido2_state"),
request.user.id)
+49
View File
@@ -0,0 +1,49 @@
from .models import UserFidoKeys
from fido2.webauthn import PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity, AttestedCredentialData, UserVerificationRequirement
from fido2.server import Fido2Server
from fido2.utils import websafe_decode
import fido2.features
def set_json_mapping():
try:
fido2.features.webauthn_json_mapping.enabled = True
except ValueError:
pass
def create_fido2_server():
rp = PublicKeyCredentialRpEntity(name="Demo server", id="localhost")
return Fido2Server(rp)
def get_creds_from_user(user_id):
[AttestedCredentialData(websafe_decode(uf.token)) for uf in UserFidoKeys.objects.filter(user=user_id)]
def begin_registration(request):
set_json_mapping()
server = create_fido2_server()
options, state = server.register_begin(
PublicKeyCredentialUserEntity(
id=request.user.pk,
name=request.user.username.encode('utf-8'),
display_name=request.user.username,
),
get_creds_from_user(request.user.pk),
user_verification=UserVerificationRequirement.DISCOURAGED
)
request.session['fido2_state'] = state
return options
def end_registration(data, fido2_state, user_id):
set_json_mapping()
server = create_fido2_server()
server.authenticate_complete(
fido2_state,
get_creds_from_user(user_id),
data["response"],
)
@@ -0,0 +1,29 @@
# Generated by Django 4.2.11 on 2024-04-18 15:18
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('core', '0027_alter_user_language'),
]
operations = [
migrations.CreateModel(
name='UserFidoKeys',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('enabled', models.BooleanField(default=True)),
('platform', models.CharField(default='', max_length=255)),
('added_on', models.DateTimeField(auto_now_add=True)),
('last_used', models.DateTimeField(default=None, null=True)),
('credential_id', models.CharField(max_length=255, unique=True)),
('token', models.CharField(max_length=255)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]
+11
View File
@@ -412,6 +412,17 @@ class User(AbstractUser):
reversion.register(User)
class UserFidoKeys(models.Model):
user = models.ForeignKey(User ,on_delete=models.CASCADE)
name = models.CharField(max_length=255)
enabled = models.BooleanField(default=True)
platform = models.CharField(max_length=255,default='')
added_on = models.DateTimeField(auto_now_add=True)
last_used = models.DateTimeField(null=True,default=None)
credential_id = models.CharField(max_length=255, unique=True)
token = models.CharField(max_length=255, null=False)
def populate_callback(user, group="SimpleUsers"):
"""Populate callback
+1
View File
@@ -22,6 +22,7 @@ bcrypt # Requires libffi-dev and python-dev
asgiref
dnspython==2.6.1
feedparser==6.0.11
fido2==1.1.3
gevent==24.2.1
Pillow # Optional dependency for django-ckeditor
progressbar33==2.4
+1
View File
@@ -76,6 +76,7 @@ INSTALLED_APPS = (
"django_otp.plugins.otp_static",
"django_rename_app",
"django_rq",
"django_extensions", # Just for docker (SSL support)
)
# A dedicated place to register Modoboa applications