Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Webautn support #3238

Open
wants to merge 6 commits into
base: feature/sievefilters
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,5 @@ test_project/modoboa.db
.ash_history
modoboa.kdev4
.kateproject
test_project/cert.crt
test_project/cert.key
3 changes: 3 additions & 0 deletions dev-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ django-debug-toolbar
pre-commit
black
pylint
django-extensions
Werkzeug
pyOpenSSL
2 changes: 1 addition & 1 deletion doc/contributing/getting_started.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Then, just run the following command::

Then if not done already, run this command to create an OIDC application
in order to be able to log in from the frontend::
$ docker exec modoboa-api '/bin/sh -c python3 /code/test_project/manage.py createapplication --name frontend --algorithm RS256 --redirect-uris 'http://localhost:3000/login/logged' public authorization_grant_type authorization-code'
$ docker exec modoboa-api '/bin/sh -c python3 /code/test_project/python manage.py createapplication --name frontend --client-id "LVQbfIIX3khWR3nDvix1u9yEGHZUxcx53bhJ7FlD" --user 1 --algorithm RS256 --redirect-uris 'https://localhost:3000/login/logged' public authorization-code'

It will start the docker environment and make a Modoboa instance
available at ``http://localhost:8000`` and the new admin interface at ``http://localhost:8080``
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file you mention here is listed in gitignore file, so I guess it won't work as expected. We should find a way to add this somewhere in the doc

volumes:
- .:/code

Expand Down
4 changes: 2 additions & 2 deletions docker/Dockerfile.dev
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ RUN python3 -m venv $VIRTUAL_ENV
WORKDIR /tmp
COPY requirements.txt /tmp
COPY test-requirements.txt /tmp
COPY dev-requirements.txt /tmp
RUN python -m pip install -U pip
RUN pip install -r requirements.txt -r test-requirements.txt

RUN pip install -r requirements.txt -r test-requirements.txt -r dev-requirements.txt
FROM base as run
COPY docker/doveadm /usr/local/bin
RUN chmod +x /usr/local/bin/doveadm
Expand Down
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions frontend/public/config.json
Original file line number Diff line number Diff line change
@@ -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"
}
9 changes: 9 additions & 0 deletions frontend/src/api/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,13 @@ export default {
changePassword(data) {
return repository.post('/reset_confirm/', data)
},
beginFidoRegistration() {
return repository.post('/fido/registration/begin/')
},
endFidoRegistration(data) {
return repository.post('/fido/registration/end/', data)
},
getAllFidoRegistred() {
return repository.get('/fido/')
},
}
225 changes: 225 additions & 0 deletions frontend/src/components/account/FidoAuthForm.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
<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-form ref="fidoForm" @submit.prevent="startFidoRegistration">
<v-text-field
v-model="name"
:label="$gettext('Name')"
:rules="[rules.required]"
/>
<v-btn color="success" type="submit" :loading="registrationLoading">
{{ $gettext('Add WebAuthN 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-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'
import rules from '@/plugins/rules.js'

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)
const fidoForm = ref()

async function startFidoRegistration() {
const { valid } = await fidoForm.value.validate()
if (!valid) {
return
}
if (creationOption.value) {
navigator.credentials.create({...creationOption.value})
.then(function (attestation) {
const result = createResponseToJSON(attestation)
result.name = name.value
console.log(result)
authApi.endFidoRegistration(result).then((resp) => {
console.log(resp)
})
})
}
}

onMounted(() => {
registrationLoading.value = true
authApi.getAllFidoRegistred().then((resp) => {
console.log(resp)
})
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>
54 changes: 54 additions & 0 deletions frontend/src/components/account/FidoRegistrationDialog.vue
Original file line number Diff line number Diff line change
@@ -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>