Mehrstufige Authentifizierung (#115)

Fixes #106.

Co-authored-by: Tobias Bölz <tobias@boelz.eu>
Reviewed-on: foerderverein/antragsverwaltung#115
pull/116/head
Tobias Bölz 11 months ago
parent 5935e8b832
commit cd7c2c0208

4
.gitignore vendored

@ -115,8 +115,8 @@ dmypy.json
.pyre/
# Static files and upload directories
static/
uploads/
/static/
/uploads/
# ---> macOS
# General

3
.gitmodules vendored

@ -1,3 +0,0 @@
[submodule "bootstrap"]
path = bootstrap
url = https://github.com/twbs/bootstrap.git

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-05-26 18:58+0200\n"
"POT-Creation-Date: 2022-07-15 05:57+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -18,83 +18,261 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: forms.py:13
#: .\forms.py:13
msgid "Email"
msgstr "E-Mail"
#: .\middleware.py:16
msgid ""
"Multi-factor authentication is required for your account. Please add at "
"lease one key."
msgstr ""
"Für deinen Account ist eine mehrstufige Authentifizierung vorgeschrieben. "
"Bitte füge mindestens einen Schlüssel hinzu."
#: templates/accounts/lockout.html:5 templates/accounts/lockout.html:8
#: .\templates\accounts\lockout.html:5 .\templates\accounts\lockout.html:8
msgid "Account Locked"
msgstr "Account gesperrt"
#: templates/accounts/lockout.html:11
#: .\templates\accounts\lockout.html:11
msgid "Too many failed login attempts. Please try again in a few hours."
msgstr ""
"Zu viele fehlgeschlagene Anmeldeversuche. Bitte versuches es in ein paar "
"Zu viele fehlgeschlagene Anmeldeversuche. Bitte versuche es in ein paar "
"Stunden erneut."
#: templates/accounts/lockout.html:13
#: .\templates\accounts\lockout.html:13
msgid "Too many failed login attempts. Contact us to unlock your account."
msgstr ""
"Zu viele fehlgeschlagene Anmeldeversuche. Setzte dich mit uns in Verbindung, "
"um deinen Account entsperren zu lassen."
#: templates/accounts/profile.html:5 templates/accounts/profile.html:8
msgid "User profile"
#: .\templates\accounts\profile.html:5 .\templates\accounts\profile.html:8
msgid "User Profile"
msgstr "Benutzerprofil"
#: templates/accounts/profile.html:10
#: .\templates\accounts\profile.html:10
msgid "Username"
msgstr "Benutzername"
#: templates/accounts/profile.html:12
#: .\templates\accounts\profile.html:12
msgid "Email address"
msgstr "E-Mail-Adresse"
#: templates/accounts/profile.html:14
#: .\templates\accounts\profile.html:14
msgid "Full name"
msgstr "Name"
#: templates/accounts/profile.html:16
#: .\templates\accounts\profile.html:16
msgid "Membership number"
msgstr "Mitglied-Nr."
#: templates/accounts/profile.html:18
#: .\templates\accounts\profile.html:18
msgid "Superuser status"
msgstr "Administrator-Status"
#: templates/accounts/profile.html:18 templates/accounts/profile.html:20
#: .\templates\accounts\profile.html:18 .\templates\accounts\profile.html:20
msgid "Yes"
msgstr "Ja"
#: templates/accounts/profile.html:20
#: .\templates\accounts\profile.html:20
msgid "Staff status"
msgstr "Mitarbeiter-Status"
#: templates/accounts/profile.html:24
#: .\templates\accounts\profile.html:24
msgid "Change Password"
msgstr "Passwort ändern"
#: templates/accounts/profile.html:27
msgid "Manage App Specific Password"
#: .\templates\accounts\profile.html:26
msgid "Manage Multi-Factor Authentication"
msgstr "Mehrstufige Authentifizierung verwalten"
#: .\templates\accounts\profile.html:29
msgid "Manage App Specific Passwords"
msgstr "Anwendungsspezifische Passwörter verwalten"
#: templates/registration/activation_complete.html:5
#: templates/registration/activation_complete.html:8
#: .\templates\mfa\auth_FIDO2.html:7 .\templates\mfa\auth_FIDO2.html:11
msgid "Authenticate With FIDO2"
msgstr "Mit FIDO2 authentifizieren"
#: .\templates\mfa\auth_FIDO2.html:13
msgid ""
"When you click Verify, youll be prompted to authenticate or activate the "
"device."
msgstr ""
"Wenn du auf „Bestätigen“ klickst, wirst du dazu aufgefordert, dich zu "
"authentifizieren bzw. dein Gerät zu aktivieren."
#: .\templates\mfa\auth_FIDO2.html:17 .\templates\mfa\create_FIDO2.html:17
msgid "This feature requires JavaScript."
msgstr "Diese Funktion benötigt JavaScript."
#: .\templates\mfa\auth_FIDO2.html:22 .\templates\mfa\create_FIDO2.html:22
msgid "This feature is not supported by your browser."
msgstr "Diese Funktion wird von deinem Browser nicht unterstützt."
#: .\templates\mfa\auth_FIDO2.html:28 .\templates\mfa\auth_TOTP.html:17
#: .\templates\mfa\auth_recovery.html:19
msgid "Verify"
msgstr "Bestätigen"
#: .\templates\mfa\auth_FIDO2.html:37 .\templates\mfa\auth_recovery.html:25
msgid "Use TOTP instead"
msgstr "Stattdessen TOTP verwenden"
#: .\templates\mfa\auth_FIDO2.html:38 .\templates\mfa\auth_TOTP.html:23
msgid "Use recovery code instead"
msgstr "Stattdessen Wiederherstellungsschlüssel verwenden"
#: .\templates\mfa\auth_TOTP.html:6 .\templates\mfa\auth_TOTP.html:13
msgid "Authenticate With TOTP"
msgstr "Mit TOTP authentifizieren"
#: .\templates\mfa\auth_TOTP.html:22 .\templates\mfa\auth_recovery.html:24
msgid "Use FIDO2 instead"
msgstr "Stattdessen FIDO2 verwenden"
#: .\templates\mfa\auth_recovery.html:6 .\templates\mfa\auth_recovery.html:10
msgid "Authenticate With Recovery Code"
msgstr "Mit Wiederherstellungs­schlüssel authentifizieren"
#: .\templates\mfa\auth_recovery.html:13
#: .\templates\mfa\create_recovery.html:14 .\templates\mfa\mfakey_list.html:12
msgid "Warning!"
msgstr "Achtung!"
#: .\templates\mfa\auth_recovery.html:14
msgid ""
"The recovery code will be removed after it has been used. Make sure to "
"create a new one!"
msgstr ""
"Der Wiederherstellungsschlüssel wird entfernt, nachdem er verwendet wurde. "
"Erstelle unbedingt einen neuen!"
#: .\templates\mfa\create_FIDO2.html:7 .\templates\mfa\create_FIDO2.html:11
#: .\templates\mfa\mfakey_list.html:51
msgid "Add FIDO2 Key"
msgstr "FIDO2-Schlüssel hinzufügen"
#: .\templates\mfa\create_FIDO2.html:13
msgid ""
"When you click Add, youll be prompted to authenticate or activate the "
"device."
msgstr ""
"Wenn du auf „Hinzufügen“ klickst, wirst du dazu aufgefordert, dich zu "
"authentifizieren bzw. dein Gerät zu aktivieren."
#: .\templates\mfa\create_FIDO2.html:29 .\templates\mfa\create_TOTP.html:21
#: .\templates\mfa\create_recovery.html:35
msgid "Add"
msgstr "Hinzufügen"
#: .\templates\mfa\create_FIDO2.html:31 .\templates\mfa\create_TOTP.html:23
#: .\templates\mfa\create_recovery.html:37
#: .\templates\mfa\mfakey_confirm_delete.html:17
msgid "Cancel"
msgstr "Abbrechen"
#: .\templates\mfa\create_TOTP.html:7 .\templates\mfa\create_TOTP.html:11
#: .\templates\mfa\mfakey_list.html:48
msgid "Add TOTP Key"
msgstr "TOTP-Schlüssel hinzufügen"
#: .\templates\mfa\create_TOTP.html:13
msgid "Scan the code with your TOTP app and enter a valid code to add the key."
msgstr ""
"Scanne den Code mit deiner TOTP-App und gib einen gültigen "
"Authentifizierungscode ein, um den Schlüssel hinzuzufügen."
#: .\templates\mfa\create_recovery.html:6
#: .\templates\mfa\create_recovery.html:10 .\templates\mfa\mfakey_list.html:54
msgid "Add Recovery Code"
msgstr "Wiederherstellungs­schlüssel hinzufügen"
#: .\templates\mfa\create_recovery.html:12
msgid ""
"A recovery code can be used when you lose access to your other two-factor "
"authentication options. Each recovery code can only be used once."
msgstr ""
"Ein Wiederherstellungsschlüssel kann verwendet werden, falls du denn Zugriff "
"auf die anderen Möglichkeiten zur mehrstufigen Authentifizierung verlierst. "
"Jeder Wiederherstellungsschlüssel kann nur einmal verwendet werden."
#: .\templates\mfa\create_recovery.html:15
msgid ""
"Make sure to store the code in a safe place. If you lose your login keys and "
"dont have the recovery codes you will lose access to your account."
msgstr ""
"Achte darauf, den Wiederherstellungsschlüssel an einem sicheren Ort "
"aufzubewahren. Wenn du deinen Anmeldeschlüssel verlierst und keinen "
"Wiederherstellungsschlüssel hast, kannst du nicht mehr auf deinen Account "
"zugreifen."
#: .\templates\mfa\create_recovery.html:32
msgid "I have safely stored the code"
msgstr "Ich habe den Wiederherstellungsschlüssel sicher verwahrt"
#: .\templates\mfa\mfakey_confirm_delete.html:6
#: .\templates\mfa\mfakey_confirm_delete.html:10
msgid "Delete Multi-Factor Key"
msgstr "Schlüssel löschen"
#: .\templates\mfa\mfakey_confirm_delete.html:14
#, python-format
msgid "Are you sure you want to delete the multi-factor key “%(object_name)s”?"
msgstr ""
"Bist du sicher, dass du den Schlüssel „%(object_name)s“ löschen willst?"
#: .\templates\mfa\mfakey_confirm_delete.html:16
#: .\templates\mfa\mfakey_list.html:32
msgid "Delete"
msgstr "Löschen"
#: .\templates\mfa\mfakey_list.html:5 .\templates\mfa\mfakey_list.html:8
msgid "Multi-Factor Authentication"
msgstr "Mehrstufige Authentifizierung"
#: .\templates\mfa\mfakey_list.html:13
msgid ""
"You have added only a single login key. If you lose access to that key you "
"wont be able log into your account again. Make sure to create a recovery "
"code!"
msgstr ""
"Du hast nur einen einzigen Anmeldeschlüssel hinzugefügt. Wenn du den Zugriff "
"auf diesen Schlüssel verlierst, kannst du dich nicht mehr anmelden. Erstelle "
"unbedingt einen Wiederherstellungsschlüssel!"
#: .\templates\mfa\mfakey_list.html:20
msgctxt "TOTP key"
msgid "Name"
msgstr "Bezeichnung"
#: .\templates\mfa\mfakey_list.html:21
msgid "Type"
msgstr "Art"
#: .\templates\mfa\mfakey_list.html:29
msgid "Recovery Key"
msgstr "Wiederherstellungsschlüssel"
#: .\templates\mfa\mfakey_list.html:39
msgid "No Login Keys"
msgstr "Keine Schlüssel"
#: .\templates\registration\activation_complete.html:5
#: .\templates\registration\activation_complete.html:8
msgid "Account Activated"
msgstr "Account aktiviert"
#: templates/registration/activation_complete.html:10
#: .\templates\registration\activation_complete.html:10
msgid "Your account is now activated."
msgstr "Dein Account ist nun aktiviert."
#: templates/registration/activation_complete.html:12
#: templates/registration/login.html:6 templates/registration/login.html:9
#: templates/registration/login.html:14
#: templates/registration/password_reset_complete.html:12
#: .\templates\registration\activation_complete.html:12
#: .\templates\registration\password_reset_complete.html:12
msgid "Log in"
msgstr "Anmelden"
#: templates/registration/activation_email.txt:1
#: .\templates\registration\activation_email.txt:1
#, python-format
msgid ""
"You (or someone pretending to be you) have asked to register an account at "
@ -105,7 +283,7 @@ msgstr ""
"%(site_name)s registrieren. Falls du es nicht warst, ignoriere diese E-Mail "
"und deine Adresse wird aus unserem System gelöscht."
#: templates/registration/activation_email.txt:3
#: .\templates\registration\activation_email.txt:3
#, python-format
msgid ""
"To activate this account, please click the following link within the next "
@ -114,97 +292,102 @@ msgstr ""
"Um diesen Account zu aktivieren, klicke bitte auf den folgenden Link "
"innerhalb der nächsten %(expiration_days)s Tage:"
#: templates/registration/activation_email_subject.txt:1
#: .\templates\registration\activation_email_subject.txt:1
#, python-format
msgid "Account activation on %(site_name)s"
msgstr "Account auf %(site_name)s aktivieren"
#: templates/registration/login.html:20
#: .\templates\registration\login.html:6 .\templates\registration\login.html:9
#: .\templates\registration\login.html:14
msgid "Log In"
msgstr "Anmelden"
#: .\templates\registration\login.html:20
msgid "Forgot your password?"
msgstr ""
#: templates/registration/login.html:20
#: .\templates\registration\login.html:20
msgid "Reset it"
msgstr ""
#: templates/registration/login.html:21
#: .\templates\registration\login.html:21
msgid "No account?"
msgstr "Kein Account?"
#: templates/registration/login.html:21
#: templates/registration/registration_form.html:19
#: .\templates\registration\login.html:21
#: .\templates\registration\registration_form.html:19
msgid "Register"
msgstr "Registrieren"
#: templates/registration/password_change_done.html:9
#: .\templates\registration\password_change_done.html:9
msgid "Your password was changed."
msgstr ""
#: templates/registration/password_change_form.html:11
#: .\templates\registration\password_change_form.html:11
msgid ""
"Please enter your old password, for securitys sake, and then enter your new "
"password twice so we can verify you typed it in correctly."
msgstr ""
#: templates/registration/password_change_form.html:16
#: templates/registration/password_reset_confirm.html:18
#: .\templates\registration\password_change_form.html:16
#: .\templates\registration\password_reset_confirm.html:18
msgid "Change my password"
msgstr ""
#: templates/registration/password_reset_complete.html:10
#: .\templates\registration\password_reset_complete.html:10
msgid "Your password has been set. You may go ahead and log in now."
msgstr ""
#: templates/registration/password_reset_confirm.html:13
#: .\templates\registration\password_reset_confirm.html:13
msgid ""
"Please enter your new password twice so we can verify you typed it in "
"correctly."
msgstr ""
#: templates/registration/password_reset_confirm.html:23
#: .\templates\registration\password_reset_confirm.html:23
msgid ""
"The password reset link was invalid, possibly because it has already been "
"used. Please request a new password reset."
msgstr ""
#: templates/registration/password_reset_done.html:11
#: .\templates\registration\password_reset_done.html:11
msgid ""
"Weve emailed you instructions for setting your password, if an account "
"exists with the email you entered. You should receive them shortly."
msgstr ""
#: templates/registration/password_reset_done.html:13
#: .\templates\registration\password_reset_done.html:13
msgid ""
"If you dont receive an email, please make sure youve entered the address "
"you registered with, and check your spam folder."
msgstr ""
#: templates/registration/password_reset_form.html:11
#: .\templates\registration\password_reset_form.html:11
msgid ""
"Forgotten your password? Enter your email address below, and well email "
"instructions for setting a new one."
msgstr ""
#: templates/registration/password_reset_form.html:16
#: .\templates\registration\password_reset_form.html:16
msgid "Reset my password"
msgstr ""
#: templates/registration/registration_complete.html:5
#: templates/registration/registration_complete.html:8
#: .\templates\registration\registration_complete.html:5
#: .\templates\registration\registration_complete.html:8
msgid "Activation email sent"
msgstr "Aktivierungs-E-Mail gesendet"
#: templates/registration/registration_complete.html:10
#: .\templates\registration\registration_complete.html:10
msgid "Please check your email to complete the registration process."
msgstr ""
"Bitte überprüfe deine E-Mails um den Registrierungs-Prozess abzuschließen."
#: templates/registration/registration_form.html:6
#: templates/registration/registration_form.html:9
#: .\templates\registration\registration_form.html:6
#: .\templates\registration\registration_form.html:9
msgid "Register for an account"
msgstr "Für einen Account registrieren"
#: templates/registration/registration_form.html:12
#: .\templates\registration\registration_form.html:12
msgid ""
"Registration for an account is possible only for members of the Förderverein "
"der Studierendenschaft des KIT."
@ -212,7 +395,7 @@ msgstr ""
"Ausschließlich Mitglieder des Fördervereins der Studierendenschaft des KIT "
"können sich für einen Account registrieren."
#: templates/registration/registration_form.html:13
#: .\templates\registration\registration_form.html:13
msgid ""
"Please make sure that you provide the email address that you gave to the "
"board of the association. Another email address cannot be used for "
@ -222,16 +405,16 @@ msgstr ""
"Mail-Adresse eingibst. Eine andere E-Mail-Adresse kann nicht für die "
"Registrierung verwendet werden."
#: templates/registration/resend_activation_form.html:6
#: templates/registration/resend_activation_form.html:9
#: .\templates\registration\resend_activation_form.html:6
#: .\templates\registration\resend_activation_form.html:9
msgid "Resend Activation Email"
msgstr "Aktivierungs-E-Mail erneut senden"
#: templates/registration/resend_activation_form.html:14
#: .\templates\registration\resend_activation_form.html:14
msgid "Submit"
msgstr "Absenden"
#: validators.py:23
#: .\validators.py:23
msgid "Registration is not allowed with the entered email address."
msgstr ""
"Eine Registrierung ist mit der eingegebenen E-Mail-Adresse nicht erlaubt."

@ -0,0 +1,21 @@
from django.contrib import messages
from django.utils.translation import gettext_lazy as _
from mfa.middleware import MFAEnforceMiddleware
class EnforceMFAForSuperusersMiddleware(MFAEnforceMiddleware):
"""Middleware that enforces multi-factor authentication for superusers."""
def process_view(self, request, view_func, view_args, view_kwargs):
response = super() \
.process_view(request, view_func, view_args, view_kwargs)
if response and request.user.is_superuser:
messages.error(
request,
_('Multi-factor authentication is required for your account.'
' Please add at lease one key.')
)
return response
else:
return None

@ -0,0 +1,5 @@
if ('credentials' in navigator) {
document.querySelector('#submit-button').disabled = false;
} else {
document.querySelector('#unsupported-alert').classList.remove('d-none');
}

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 Patrick Gansterer <paroga@paroga.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

@ -0,0 +1,53 @@
cbor-js
=======
The Concise Binary Object Representation (CBOR) data format ([RFC 7049](http://tools.ietf.org/html/rfc7049)) implemented in pure JavaScript.
[![Build Status](https://api.travis-ci.org/paroga/cbor-js.png)](https://travis-ci.org/paroga/cbor-js)
[![Coverage Status](https://coveralls.io/repos/paroga/cbor-js/badge.png?branch=master)](https://coveralls.io/r/paroga/cbor-js?branch=master)
[![Dependency status](https://david-dm.org/paroga/cbor-js/status.png)](https://david-dm.org/paroga/cbor-js#info=dependencies&view=table)
[![Dev Dependency Status](https://david-dm.org/paroga/cbor-js/dev-status.png)](https://david-dm.org/paroga/cbor-js#info=devDependencies&view=table)
[![Selenium Test Status](https://saucelabs.com/buildstatus/paroga-cbor-js)](https://saucelabs.com/u/paroga-cbor-js)
[![Selenium Test Status](https://saucelabs.com/browser-matrix/paroga-cbor-js.svg)](https://saucelabs.com/u/paroga-cbor-js)
API
---
The `CBOR`-object provides the following two functions:
CBOR.**decode**(*data*)
> Take the ArrayBuffer object *data* and return it decoded as a JavaScript object.
CBOR.**encode**(*data*)
> Take the JavaScript object *data* and return it encoded as a ArrayBuffer object.
Usage
-----
Include `cbor.js` in your or HTML page:
```html
<script src="path/to/cbor.js" type="text/javascript"></script>
```
Then you can use it via the `CBOR`-object in your code:
```javascript
var initial = { Hello: "World" };
var encoded = CBOR.encode(initial);
var decoded = CBOR.decode(encoded);
```
After running this example `initial` and `decoded` represent the same value.
### Combination with WebSocket
The API was designed to play well with the `WebSocket` object in the browser:
```javascript
var websocket = new WebSocket(url);
websocket.binaryType = "arraybuffer";
...
websocket.onmessage = function(event) {
var message = CBOR.decode(event.data);
};
...
websocket.send(CBOR.encode(message));
```

@ -0,0 +1,404 @@
/*
* The MIT License (MIT)
*
* Copyright (c) 2014 Patrick Gansterer <paroga@paroga.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
(function(global, undefined) { "use strict";
var POW_2_24 = Math.pow(2, -24),
POW_2_32 = Math.pow(2, 32),
POW_2_53 = Math.pow(2, 53);
function encode(value) {
var data = new ArrayBuffer(256);
var dataView = new DataView(data);
var lastLength;
var offset = 0;
function ensureSpace(length) {
var newByteLength = data.byteLength;
var requiredLength = offset + length;
while (newByteLength < requiredLength)
newByteLength *= 2;
if (newByteLength !== data.byteLength) {
var oldDataView = dataView;
data = new ArrayBuffer(newByteLength);
dataView = new DataView(data);
var uint32count = (offset + 3) >> 2;
for (var i = 0; i < uint32count; ++i)
dataView.setUint32(i * 4, oldDataView.getUint32(i * 4));
}
lastLength = length;
return dataView;
}
function write() {
offset += lastLength;
}
function writeFloat64(value) {
write(ensureSpace(8).setFloat64(offset, value));
}
function writeUint8(value) {
write(ensureSpace(1).setUint8(offset, value));
}
function writeUint8Array(value) {
var dataView = ensureSpace(value.length);
for (var i = 0; i < value.length; ++i)
dataView.setUint8(offset + i, value[i]);
write();
}
function writeUint16(value) {
write(ensureSpace(2).setUint16(offset, value));
}
function writeUint32(value) {
write(ensureSpace(4).setUint32(offset, value));
}
function writeUint64(value) {
var low = value % POW_2_32;
var high = (value - low) / POW_2_32;
var dataView = ensureSpace(8);
dataView.setUint32(offset, high);
dataView.setUint32(offset + 4, low);
write();
}
function writeTypeAndLength(type, length) {
if (length < 24) {
writeUint8(type << 5 | length);
} else if (length < 0x100) {
writeUint8(type << 5 | 24);
writeUint8(length);
} else if (length < 0x10000) {
writeUint8(type << 5 | 25);
writeUint16(length);
} else if (length < 0x100000000) {
writeUint8(type << 5 | 26);
writeUint32(length);
} else {
writeUint8(type << 5 | 27);
writeUint64(length);
}
}
function encodeItem(value) {
var i;
if (value === false)
return writeUint8(0xf4);
if (value === true)
return writeUint8(0xf5);
if (value === null)
return writeUint8(0xf6);
if (value === undefined)
return writeUint8(0xf7);
switch (typeof value) {
case "number":
if (Math.floor(value) === value) {
if (0 <= value && value <= POW_2_53)
return writeTypeAndLength(0, value);
if (-POW_2_53 <= value && value < 0)
return writeTypeAndLength(1, -(value + 1));
}
writeUint8(0xfb);
return writeFloat64(value);
case "string":
var utf8data = [];
for (i = 0; i < value.length; ++i) {
var charCode = value.charCodeAt(i);
if (charCode < 0x80) {
utf8data.push(charCode);
} else if (charCode < 0x800) {
utf8data.push(0xc0 | charCode >> 6);
utf8data.push(0x80 | charCode & 0x3f);
} else if (charCode < 0xd800) {
utf8data.push(0xe0 | charCode >> 12);
utf8data.push(0x80 | (charCode >> 6) & 0x3f);
utf8data.push(0x80 | charCode & 0x3f);
} else {
charCode = (charCode & 0x3ff) << 10;
charCode |= value.charCodeAt(++i) & 0x3ff;
charCode += 0x10000;
utf8data.push(0xf0 | charCode >> 18);
utf8data.push(0x80 | (charCode >> 12) & 0x3f);
utf8data.push(0x80 | (charCode >> 6) & 0x3f);
utf8data.push(0x80 | charCode & 0x3f);
}
}
writeTypeAndLength(3, utf8data.length);
return writeUint8Array(utf8data);
default:
var length;
if (Array.isArray(value)) {
length = value.length;
writeTypeAndLength(4, length);
for (i = 0; i < length; ++i)
encodeItem(value[i]);
} else if (value instanceof Uint8Array) {
writeTypeAndLength(2, value.length);
writeUint8Array(value);
} else {
var keys = Object.keys(value);
length = keys.length;
writeTypeAndLength(5, length);
for (i = 0; i < length; ++i) {
var key = keys[i];
encodeItem(key);
encodeItem(value[key]);
}
}
}
}
encodeItem(value);
if ("slice" in data)
return data.slice(0, offset);
var ret = new ArrayBuffer(offset);
var retView = new DataView(ret);
for (var i = 0; i < offset; ++i)
retView.setUint8(i, dataView.getUint8(i));
return ret;
}
function decode(data, tagger, simpleValue) {
var dataView = new DataView(data);
var offset = 0;
if (typeof tagger !== "function")
tagger = function(value) { return value; };
if (typeof simpleValue !== "function")
simpleValue = function() { return undefined; };
function read(value, length) {
offset += length;
return value;
}
function readArrayBuffer(length) {
return read(new Uint8Array(data, offset, length), length);
}
function readFloat16() {
var tempArrayBuffer = new ArrayBuffer(4);
var tempDataView = new DataView(tempArrayBuffer);
var value = readUint16();
var sign = value & 0x8000;
var exponent = value & 0x7c00;
var fraction = value & 0x03ff;
if (exponent === 0x7c00)
exponent = 0xff << 10;
else if (exponent !== 0)
exponent += (127 - 15) << 10;
else if (fraction !== 0)
return fraction * POW_2_24;
tempDataView.setUint32(0, sign << 16 | exponent << 13 | fraction << 13);
return tempDataView.getFloat32(0);
}
function readFloat32() {
return read(dataView.getFloat32(offset), 4);
}
function readFloat64() {
return read(dataView.getFloat64(offset), 8);
}
function readUint8() {
return read(dataView.getUint8(offset), 1);
}
function readUint16() {
return read(dataView.getUint16(offset), 2);
}
function readUint32() {
return read(dataView.getUint32(offset), 4);
}
function readUint64() {
return readUint32() * POW_2_32 + readUint32();
}
function readBreak() {
if (dataView.getUint8(offset) !== 0xff)
return false;
offset += 1;
return true;
}
function readLength(additionalInformation) {
if (additionalInformation < 24)
return additionalInformation;
if (additionalInformation === 24)
return readUint8();
if (additionalInformation === 25)
return readUint16();
if (additionalInformation === 26)
return readUint32();
if (additionalInformation === 27)
return readUint64();
if (additionalInformation === 31)
return -1;
throw "Invalid length encoding";
}
function readIndefiniteStringLength(majorType) {
var initialByte = readUint8();
if (initialByte === 0xff)
return -1;
var length = readLength(initialByte & 0x1f);
if (length < 0 || (initialByte >> 5) !== majorType)
throw "Invalid indefinite length element";
return length;
}
function appendUtf16data(utf16data, length) {
for (var i = 0; i < length; ++i) {
var value = readUint8();
if (value & 0x80) {
if (value < 0xe0) {
value = (value & 0x1f) << 6
| (readUint8() & 0x3f);
length -= 1;
} else if (value < 0xf0) {
value = (value & 0x0f) << 12
| (readUint8() & 0x3f) << 6
| (readUint8() & 0x3f);
length -= 2;
} else {
value = (value & 0x0f) << 18
| (readUint8() & 0x3f) << 12
| (readUint8() & 0x3f) << 6
| (readUint8() & 0x3f);
length -= 3;
}
}
if (value < 0x10000) {
utf16data.push(value);
} else {
value -= 0x10000;
utf16data.push(0xd800 | (value >> 10));
utf16data.push(0xdc00 | (value & 0x3ff));
}
}
}
function decodeItem() {
var initialByte = readUint8();
var majorType = initialByte >> 5;
var additionalInformation = initialByte & 0x1f;
var i;
var length;
if (majorType === 7) {
switch (additionalInformation) {
case 25:
return readFloat16();
case 26:
return readFloat32();
case 27:
return readFloat64();
}
}
length = readLength(additionalInformation);
if (length < 0 && (majorType < 2 || 6 < majorType))
throw "Invalid length";
switch (majorType) {
case 0:
return length;
case 1:
return -1 - length;
case 2:
if (length < 0) {
var elements = [];
var fullArrayLength = 0;
while ((length = readIndefiniteStringLength(majorType)) >= 0) {
fullArrayLength += length;
elements.push(readArrayBuffer(length));
}
var fullArray = new Uint8Array(fullArrayLength);
var fullArrayOffset = 0;
for (i = 0; i < elements.length; ++i) {
fullArray.set(elements[i], fullArrayOffset);
fullArrayOffset += elements[i].length;
}
return fullArray;
}
return readArrayBuffer(length);
case 3:
var utf16data = [];
if (length < 0) {
while ((length = readIndefiniteStringLength(majorType)) >= 0)
appendUtf16data(utf16data, length);
} else
appendUtf16data(utf16data, length);
return String.fromCharCode.apply(null, utf16data);
case 4:
var retArray;
if (length < 0) {
retArray = [];
while (!readBreak())
retArray.push(decodeItem());
} else {
retArray = new Array(length);
for (i = 0; i < length; ++i)
retArray[i] = decodeItem();
}
return retArray;
case 5:
var retObject = {};
for (i = 0; i < length || length < 0 && !readBreak(); ++i) {
var key = decodeItem();
retObject[key] = decodeItem();
}
return retObject;
case 6:
return tagger(decodeItem(), length);
case 7:
switch (length) {
case 20:
return false;
case 21:
return true;
case 22:
return null;
case 23:
return undefined;
default:
return simpleValue(length);
}
}
}
var ret = decodeItem();
if (offset !== data.byteLength)
throw "Remaining bytes";
return ret;
}
var obj = { encode: encode, decode: decode };
if (typeof define === "function" && define.amd)
define("cbor/cbor", obj);
else if (!global.CBOR)
global.CBOR = obj;
})(this);

@ -0,0 +1,2 @@
// cbor v0.1.0 - Patrick Gansterer <paroga@paroga.com> (http://paroga.com/) - Licensed under MIT
!function(a,b){"use strict";function c(a){function c(a){for(var b=p.byteLength,c=r+a;c>b;)b*=2;if(b!==p.byteLength){var d=q;p=new ArrayBuffer(b),q=new DataView(p);for(var e=r+3>>2,f=0;e>f;++f)q.setUint32(4*f,d.getUint32(4*f))}return o=a,q}function d(){r+=o}function e(a){d(c(8).setFloat64(r,a))}function h(a){d(c(1).setUint8(r,a))}function i(a){for(var b=c(a.length),e=0;e<a.length;++e)b.setUint8(r+e,a[e]);d()}function j(a){d(c(2).setUint16(r,a))}function k(a){d(c(4).setUint32(r,a))}function l(a){var b=a%f,e=(a-b)/f,g=c(8);g.setUint32(r,e),g.setUint32(r+4,b),d()}function m(a,b){24>b?h(a<<5|b):256>b?(h(a<<5|24),h(b)):65536>b?(h(a<<5|25),j(b)):4294967296>b?(h(a<<5|26),k(b)):(h(a<<5|27),l(b))}function n(a){var c;if(a===!1)return h(244);if(a===!0)return h(245);if(null===a)return h(246);if(a===b)return h(247);switch(typeof a){case"number":if(Math.floor(a)===a){if(a>=0&&g>=a)return m(0,a);if(a>=-g&&0>a)return m(1,-(a+1))}return h(251),e(a);case"string":var d=[];for(c=0;c<a.length;++c){var f=a.charCodeAt(c);128>f?d.push(f):2048>f?(d.push(192|f>>6),d.push(128|63&f)):55296>f?(d.push(224|f>>12),d.push(128|f>>6&63),d.push(128|63&f)):(f=(1023&f)<<10,f|=1023&a.charCodeAt(++c),f+=65536,d.push(240|f>>18),d.push(128|f>>12&63),d.push(128|f>>6&63),d.push(128|63&f))}return m(3,d.length),i(d);default:var j;if(Array.isArray(a))for(j=a.length,m(4,j),c=0;j>c;++c)n(a[c]);else if(a instanceof Uint8Array)m(2,a.length),i(a);else{var k=Object.keys(a);for(j=k.length,m(5,j),c=0;j>c;++c){var l=k[c];n(l),n(a[l])}}}}var o,p=new ArrayBuffer(256),q=new DataView(p),r=0;if(n(a),"slice"in p)return p.slice(0,r);for(var s=new ArrayBuffer(r),t=new DataView(s),u=0;r>u;++u)t.setUint8(u,q.getUint8(u));return s}function d(a,c,d){function g(a,b){return v+=b,a}function h(b){return g(new Uint8Array(a,v,b),b)}function i(){var a=new ArrayBuffer(4),b=new DataView(a),c=m(),d=32768&c,f=31744&c,g=1023&c;if(31744===f)f=261120;else if(0!==f)f+=114688;else if(0!==g)return g*e;return b.setUint32(0,d<<16|f<<13|g<<13),b.getFloat32(0)}function j(){return g(u.getFloat32(v),4)}function k(){return g(u.getFloat64(v),8)}function l(){return g(u.getUint8(v),1)}function m(){return g(u.getUint16(v),2)}function n(){return g(u.getUint32(v),4)}function o(){return n()*f+n()}function p(){return 255!==u.getUint8(v)?!1:(v+=1,!0)}function q(a){if(24>a)return a;if(24===a)return l();if(25===a)return m();if(26===a)return n();if(27===a)return o();if(31===a)return-1;throw"Invalid length encoding"}function r(a){var b=l();if(255===b)return-1;var c=q(31&b);if(0>c||b>>5!==a)throw"Invalid indefinite length element";return c}function s(a,b){for(var c=0;b>c;++c){var d=l();128&d&&(224>d?(d=(31&d)<<6|63&l(),b-=1):240>d?(d=(15&d)<<12|(63&l())<<6|63&l(),b-=2):(d=(15&d)<<18|(63&l())<<12|(63&l())<<6|63&l(),b-=3)),65536>d?a.push(d):(d-=65536,a.push(55296|d>>10),a.push(56320|1023&d))}}function t(){var a,e,f=l(),g=f>>5,m=31&f;if(7===g)switch(m){case 25:return i();case 26:return j();case 27:return k()}if(e=q(m),0>e&&(2>g||g>6))throw"Invalid length";switch(g){case 0:return e;case 1:return-1-e;case 2:if(0>e){for(var n=[],o=0;(e=r(g))>=0;)o+=e,n.push(h(e));var u=new Uint8Array(o),v=0;for(a=0;a<n.length;++a)u.set(n[a],v),v+=n[a].length;return u}return h(e);case 3:var w=[];if(0>e)for(;(e=r(g))>=0;)s(w,e);else s(w,e);return String.fromCharCode.apply(null,w);case 4:var x;if(0>e)for(x=[];!p();)x.push(t());else for(x=new Array(e),a=0;e>a;++a)x[a]=t();return x;case 5:var y={};for(a=0;e>a||0>e&&!p();++a){var z=t();y[z]=t()}return y;case 6:return c(t(),e);case 7:switch(e){case 20:return!1;case 21:return!0;case 22:return null;case 23:return b;default:return d(e)}}}var u=new DataView(a),v=0;"function"!=typeof c&&(c=function(a){return a}),"function"!=typeof d&&(d=function(){return b});var w=t();if(v!==a.byteLength)throw"Remaining bytes";return w}var e=Math.pow(2,-24),f=Math.pow(2,32),g=Math.pow(2,53),h={encode:c,decode:d};"function"==typeof define&&define.amd?define("cbor/cbor",h):a.CBOR||(a.CBOR=h)}(this);

@ -2,14 +2,14 @@
{% load i18n %}
{% block title %}{% translate 'User profile' %}{% endblock %}
{% block title %}{% translate 'User Profile' %}{% endblock %}
{% block content %}
<h1>{% translate 'User profile' %}</h1>
<h1>{% translate 'User Profile' %}</h1>
<p><strong>{% translate 'Username' %}:</strong> {{ user.username }}</p>
<p><strong>{% translate 'Email address' %}:</strong> {{ user.email }}</p>
<p><strong>{% translate 'Email address' %}:</strong> {% if user.email %}{{ user.email }}{% else %}{% endif %}</p>
<p><strong>{% translate 'Full name' %}:</strong> {% if user.member %}{{ user.member.name}}{% elif user.get_full_name %}{{ user.get_full_name }}{% else %}{% endif %}</p>
@ -21,9 +21,11 @@
<hr>
<a class="btn btn-primary" href="{% url 'password_change' %}" role="button">{% translate 'Change Password' %}</a>
<a href="{% url 'password_change' %}" class="btn btn-primary mb-1" role="button">{% translate 'Change Password' %}</a>
<a href="{% url 'mfa:list' %}" class="btn btn-primary mb-1" role="button">{% translate 'Manage Multi-Factor Authentication' %}</a>
{% if perms.appspecificpasswords.view_appspecificpassword %}
<a href="{% url 'appspecificpasswords:list' %}" class="btn btn-primary">{% translate 'Manage App Specific Password' %}</a>
<a href="{% url 'appspecificpasswords:list' %}" class="btn btn-primary mb-1" role="button">{% translate 'Manage App Specific Passwords' %}</a>
{% endif %}
{% endblock %}

@ -0,0 +1,41 @@
{% extends 'base.html' %}
{% load i18n %}
{% load static %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Authenticate With FIDO2' %}{% endblock %}
{% block content %}
<div class="content">
<h1>{% translate 'Authenticate With FIDO2' %}</h1>
<p>{% translate 'When you click Verify, youll be prompted to authenticate or activate the device.' %}</p>
<noscript>
<div class="alert alert-danger" role="alert">
{% translate 'This feature requires JavaScript.' %}
</div>
</noscript>
<div id="unsupported-alert" class="d-none alert alert-danger" role="alert">
{% translate 'This feature is not supported by your browser.' %}
</div>
<form data-fido2-auth="{{ mfa_data }}" method="POST">{% csrf_token %}
{{ form|as_crispy_errors }}
{{ form.code.as_hidden }}
<input id="submit-button" type="submit" value="{% translate 'Verify' %}" class="btn btn-primary" disabled>
</form>
<script src="{% static 'cbor-js/cbor.min.js' %}"></script>
<script src="{% static 'mfa/fido2.js' %}"></script>
<script src="{% static 'accounts/fido2-forms.js' %}"></script>
<hr>
<p><a href="{% url 'mfa:auth' 'TOTP' %}">{% translate 'Use TOTP instead' %}</a></p>
<p><a href="{% url 'mfa:auth' 'recovery' %}">{% translate 'Use recovery code instead' %}</a></p>
</div>
{% endblock content %}

@ -0,0 +1,26 @@
{% extends 'base.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Authenticate With TOTP' %}{% endblock %}
{% block login_url%}{% url 'login' %}{% endblock login_url %}
{% block content %}
<div class="content">
<h1>{% translate 'Authenticate With TOTP' %}</h1>
<form method="post">{% csrf_token %}
{{ form|crispy }}
<input type="submit" value="{% translate 'Verify' %}" class="btn btn-primary">
</form>
<hr>
<p><a href="{% url 'mfa:auth' 'FIDO2' %}">{% translate 'Use FIDO2 instead' %}</a></p>
<p><a href="{% url 'mfa:auth' 'recovery' %}">{% translate 'Use recovery code instead' %}</a></p>
</div>
{% endblock content %}

@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Authenticate With Recovery Code' %}{% endblock %}
{% block content %}
<div class="content">
<h1>{% translate 'Authenticate With Recovery Code' %}</h1>
<div class="alert alert-warning" role="alert">
<h4>{% translate 'Warning!' %}</h4>
<p class="mb-0">{% translate 'The recovery code will be removed after it has been used. Make sure to create a new one!' %}</p>
</div>
<form method="post">{% csrf_token %}
{{ form|crispy }}
<input type="submit" value="{% translate 'Verify' %}" class="btn btn-primary">
</form>
<hr>
<p><a href="{% url 'mfa:auth' 'FIDO2' %}">{% translate 'Use FIDO2 instead' %}</a></p>
<p><a href="{% url 'mfa:auth' 'TOTP' %}">{% translate 'Use TOTP instead' %}</a></p>
</div>
{% endblock content %}

@ -0,0 +1,40 @@
{% extends 'base.html' %}
{% load i18n %}
{% load static %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Add FIDO2 Key' %}{% endblock %}
{% block content %}
<div class="content">
<h1>{% translate 'Add FIDO2 Key' %}</h1>
<p>{% translate 'When you click Add, youll be prompted to authenticate or activate the device.' %}</p>
<noscript>
<div class="alert alert-danger" role="alert">
{% translate 'This feature requires JavaScript.' %}
</div>
</noscript>
<div id="unsupported-alert" class="d-none alert alert-danger" role="alert">
{% translate 'This feature is not supported by your browser.' %}
</div>
<form data-fido2-create="{{ mfa_data }}" method="POST">{% csrf_token %}
{{ form|as_crispy_errors }}
{{ form.name|as_crispy_field }}
{{ form.code.as_hidden }}
<input id="submit-button" type="submit" value="{% translate 'Add' %}" class="btn btn-primary float-right ml-2" disabled>
<a href="{% url 'mfa:list' %}" class="btn btn-light float-right" role="button">
{% translate 'Cancel' %}
</a>
</form>
<script src="{% static 'cbor-js/cbor.min.js' %}"></script>
<script src="{% static 'mfa/fido2.js' %}"></script>
<script src="{% static 'accounts/fido2-forms.js' %}"></script>
</div>
{% endblock content %}

@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% load i18n %}
{% load mfa %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Add TOTP Key' %}{% endblock %}
{% block content %}
<div class="content">
<h1>{% translate 'Add TOTP Key' %}</h1>
<p>{% translate 'Scan the code with your TOTP app and enter a valid code to add the key.' %}</p>
<p>{{ mfa_data.url|qrcode }}</p>
<p>{{ mfa_data.secret }}</p>
<form method="post">{% csrf_token %}
{{ form|crispy }}
<input type="submit" value="{% translate 'Add' %}" class="btn btn-primary float-right ml-2">
<a href="{% url 'mfa:list' %}" class="btn btn-light float-right" role="button">
{% translate 'Cancel' %}
</a>
</form>
</div>
{% endblock content %}

@ -0,0 +1,42 @@
{% extends 'base.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Add Recovery Code' %}{% endblock %}
{% block content %}
<div class="content">
<h1>{% translate 'Add Recovery Code' %}</h1>
<p>{% translate 'A recovery code can be used when you lose access to your other two-factor authentication options. Each recovery code can only be used once.' %}</p>
<div class="alert alert-warning" role="alert">
<h4>{% translate 'Warning!' %}</h4>
<p class="mb-0">{% translate 'Make sure to store the code in a safe place. If you lose your login keys and dont have the recovery codes you will lose access to your account.' %}</p>
</div>
<form method="post">{% csrf_token %}
{{ form|as_crispy_errors }}
{{ form.name|as_crispy_field }}
<div class="form-group">
<label class="requiredField" for="id_code">
{{ form.code.label }}<span class="asteriskField">*</span>
</label>
<div>
<input id="id_code" class="textinput textInput form-control" type="text" name="code" value="{{ mfa_data.code }}" required readonly>
</div>
</div>
<div class="form-check">
<input id="id_confirm" type="checkbox" class="form-check-input" required>
<label class="form-check-label" for="id_confirm">
{% translate 'I have safely stored the code' %}<span class="asteriskField">*</span>
</label>
</div>
<input type="submit" value="{% translate 'Add' %}" class="btn btn-primary float-right ml-2">
<a href="{% url 'mfa:list' %}" class="btn btn-light float-right" role="button">
{% translate 'Cancel' %}
</a>
</form>
</div>
{% endblock content %}

@ -0,0 +1,21 @@
{% extends 'base.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Delete Multi-Factor Key' %}{% endblock %}
{% block content %}
<div class="content">
<h1>{% translate 'Delete Multi-Factor Key' %}</h1>
<form method="post">{% csrf_token %}
<p>
{% blocktranslate with object_name=object.name %}Are you sure you want to delete the multi-factor key “{{ object_name }}”?{% endblocktranslate %}
</p>
<input type="submit" value="{% translate 'Delete' %}" class="btn btn-danger float-right ml-2">
<a href="{% url 'mfa:list' %}" class="btn btn-light float-right" role="button">{% translate 'Cancel' %}</a>
</form>
</div>
{% endblock content %}

@ -0,0 +1,58 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% translate 'Multi-Factor Authentication' %}{% endblock title %}
{% block content %}
<h1>{% translate 'Multi-Factor Authentication' %}</h1>
{% if object_list|length == 1 and object_list.0.method != 'recovery' %}
<div class="alert alert-warning" role="alert">
<h4>{% translate 'Warning!' %}</h4>
<p class="mb-0">{% translate 'You have added only a single login key. If you lose access to that key you wont be able log into your account again. Make sure to create a recovery code!' %}</p>
</div>
{% endif %}
<table class="table">
<thead>
<tr>
<th scope="col" class="w-50">{% translate 'Name' context 'TOTP key' %}</th>
<th scope="col" class="w-50">{% translate 'Type' %}</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{% for object in object_list %}
<tr>
<td class="align-middle">{{ object.name }}</td>
<td class="align-middle">{% if object.method == 'recovery' %}{% translate 'Recovery Key' %}{% else %}{{ object.method }}{% endif %}</td>
<td>
<span class="float-right">
<a href="{% url 'mfa:delete' object.id %}" class="btn btn-danger" role="button">{% translate 'Delete' %}</a>
</span>
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center">
{% translate 'No Login Keys' %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p>
<a href="{% url 'mfa:create' 'TOTP' %}" class="btn btn-primary mb-1">
{% translate 'Add TOTP Key' %}
</a>
<a href="{% url 'mfa:create' 'FIDO2' %}" class="btn btn-primary mb-1">
{% translate 'Add FIDO2 Key' %}
</a>
<a href="{% url 'mfa:create' 'recovery' %}" class="btn btn-primary mb-1">
{% translate 'Add Recovery Code' %}
</a>
</p>
{% endblock content %}

@ -2,7 +2,7 @@
{% load i18n %}
{% block title %}{{ title }}{% endblock %}
{% block title %}{{ title }}{% endblock title %}
{% block content %}
<h1>{{ title }}</h1>

@ -3,15 +3,15 @@
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Log in' %}{% endblock %}
{% block title %}{% translate 'Log In' %}{% endblock %}
{% block content %}
<h1>{% translate 'Log in' %}</h1>
<h1>{% translate 'Log In' %}</h1>
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
{{ form|crispy }}
<input type="submit" value="{% translate 'Log in' %}" class="btn btn-primary">
<input type="submit" value="{% translate 'Log In' %}" class="btn btn-primary">
<input type="hidden" name="next" value="{{ next }}">
</form>

@ -0,0 +1,31 @@
from django.urls import reverse
from mfa.models import MFAKey
def test_ensure_mfa_superuser(client, admin_user):
"""Redirect superuser without MFA key to MFA list."""
client.force_login(admin_user)
response = client.get(reverse('profile'))
assert response.status_code == 302
assert response.url == '/accounts/mfa/'
def test_ensure_mfa_superuser_has_key(client, admin_user):
"""Dont redirect superuser with MFA key."""
MFAKey.objects.create(
user=admin_user,
method='recovery',
name='foo',
secret='asd'
)
client.force_login(admin_user)
response = client.get(reverse('profile'))
assert response.status_code == 200
def test_ensure_mfa_normal_user(client, django_user_model):
"""Dont redirect non-superuser without keys."""
user = django_user_model.objects.create(username='test')
client.force_login(user)
response = client.get(reverse('profile'))
assert response.status_code == 200

@ -0,0 +1,24 @@
from django.urls import reverse
from pytest_django.asserts import assertTemplateUsed
class TestProfileView:
def test_get_view(self, client):
response = client.get('/accounts/profile/')
assert response.status_code == 302
assert response.url.startswith('/accounts/login')
def test_get_view_by_name(self, client):
response = client.get(reverse('profile'))
assert response.status_code == 302
assert response.url.startswith('/accounts/login')
def test_get_view_logged_in(self, client, django_user_model):
user = django_user_model.objects.create(username='test')
client.force_login(user)
response = client.get(reverse('profile'))
assert response.status_code == 200
assertTemplateUsed(
response,
'accounts/profile.html'
)

@ -1,8 +1,22 @@
from django.contrib.auth.views import LogoutView
from django.urls import include, path
from mfa.decorators import public as mfa_public
from mfa.views import LoginView
from . import views
from .views import ProfileView
urlpatterns = [
# The mfa login view has to be above django.contrib.auth.urls!
path(
'login/',
LoginView.as_view(),
name='login'
),
path(
'logout/',
mfa_public(LogoutView.as_view()),
name='logout'
),