mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-18 05:51:26 +02:00
Added Fido API
This commit is contained in:
@@ -34,3 +34,5 @@ test_project/modoboa.db
|
||||
.ash_history
|
||||
modoboa.kdev4
|
||||
.kateproject
|
||||
test_project/cert.crt
|
||||
test_project/cert.key
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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 += [
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user