├── kagi
├── __init__.py
├── tests
│ ├── __init__.py
│ ├── test_util.py
│ ├── test_admin.py
│ ├── test_two_factor_settings.py
│ ├── test_backups_code.py
│ ├── test_webauthn.py
│ ├── test_totp.py
│ └── test_webauthn_keys.py
├── management
│ ├── __init__.py
│ └── commands
│ │ ├── __init__.py
│ │ └── addbackupcode.py
├── migrations
│ ├── __init__.py
│ ├── 0002_remove_webauthnkey_ukey.py
│ └── 0001_initial.py
├── constants.py
├── views
│ ├── mixin.py
│ ├── backup_codes.py
│ ├── webauthn_keys.py
│ ├── __init__.py
│ ├── totp_devices.py
│ ├── api.py
│ └── login.py
├── templates
│ └── kagi
│ │ ├── login.html
│ │ ├── add_key.html
│ │ ├── backup_codes.html
│ │ ├── totp_device.html
│ │ ├── base.html
│ │ ├── two_factor_settings.html
│ │ ├── totpdevice_list.html
│ │ ├── key_list.html
│ │ └── verify_second_factor.html
├── apps.py
├── utils
│ ├── __init__.py
│ └── webauthn.py
├── settings.py
├── urls.py
├── oath.py
├── forms.py
├── static
│ └── kagi
│ │ ├── base64js.min.js
│ │ └── webauthn.js
├── admin.py
└── models.py
├── docs
├── _static
│ └── .gitkeep
├── requirements.txt
├── index.rst
├── customization.rst
├── troubleshooting.rst
├── conf.py
├── community.rst
└── _templates
│ └── page.html
├── testproj
├── testproj
│ ├── __init__.py
│ ├── wsgi.py
│ ├── urls.py
│ └── settings.py
├── mkcert.sh
├── manage.py
└── templates
│ └── base.html
├── .github
├── FUNDING.yml
└── workflows
│ └── main.yml
├── .editorconfig
├── .readthedocs.yml
├── CODE_OF_CONDUCT.rst
├── trusted_attestation_roots
├── HyperFIDO_CA_Cert_V1.pem
├── HyperFIDO_CA_Cert_V2.pem
├── solokeys_u2f_device_attestation_ca.pem
└── yubico_u2f_device_attestation_ca.pem
├── CHANGELOG.md
├── LICENSE
├── .gitignore
├── .pre-commit-config.yaml
├── pyproject.toml
├── CONTRIBUTING.rst
├── tasks.py
└── README.rst
/kagi/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/_static/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/kagi/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/kagi/management/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/kagi/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/testproj/testproj/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/kagi/management/commands/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | ---
2 |
3 | github: justinmayer
4 |
--------------------------------------------------------------------------------
/docs/requirements.txt:
--------------------------------------------------------------------------------
1 | furo==2024.04.27
2 | sphinx==6.2.1
3 |
--------------------------------------------------------------------------------
/kagi/constants.py:
--------------------------------------------------------------------------------
1 | SESSION_TOTP_SECRET_KEY = "kagi_totp_secret"
2 |
--------------------------------------------------------------------------------
/kagi/views/mixin.py:
--------------------------------------------------------------------------------
1 | class OriginMixin:
2 | def get_origin(self):
3 | return "{scheme}://{host}".format(
4 | scheme=self.request.scheme, host=self.request.get_host()
5 | )
6 |
--------------------------------------------------------------------------------
/testproj/mkcert.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # Create a self signed certificate and matching private key for localhost
4 | openssl req -x509 -nodes -sha256 -days 365 -newkey rsa:2048 -keyout localhost.key -out localhost.crt -subj /CN=localhost
5 |
--------------------------------------------------------------------------------
/kagi/tests/test_util.py:
--------------------------------------------------------------------------------
1 | from ..utils import get_origin
2 |
3 |
4 | def test_get_origin(rf):
5 | request = rf.get("/")
6 | origin = get_origin(request)
7 | assert origin == "http://testserver", "Origin should be 'testserver' over HTTP"
8 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | end_of_line = lf
6 | indent_size = 4
7 | indent_style = space
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
11 | [*.py]
12 | max_line_length = 88
13 |
14 | [*.yml]
15 | indent_size = 2
16 |
--------------------------------------------------------------------------------
/kagi/templates/kagi/login.html:
--------------------------------------------------------------------------------
1 | {% extends "kagi/base.html" %}
2 | {% load i18n %}
3 |
4 | {% block content %}
5 |
6 |
11 | community
12 | customization
13 | troubleshooting
14 |
15 | .. Links
16 |
17 | .. _Django: https://www.djangoproject.com/
18 | .. _WebAuthn: https://en.wikipedia.org/wiki/WebAuthn
19 |
--------------------------------------------------------------------------------
/kagi/migrations/0002_remove_webauthnkey_ukey.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.1.1 on 2022-09-23 10:10
2 |
3 | from django.db import migrations
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("kagi", "0001_initial"),
9 | ]
10 |
11 | operations = [
12 | migrations.RemoveField(
13 | model_name="webauthnkey",
14 | name="ukey",
15 | ),
16 | ]
17 |
--------------------------------------------------------------------------------
/testproj/testproj/wsgi.py:
--------------------------------------------------------------------------------
1 | """
2 | WSGI config for testproj project.
3 |
4 | It exposes the WSGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.wsgi import get_wsgi_application
13 |
14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproj.settings")
15 |
16 | application = get_wsgi_application()
17 |
--------------------------------------------------------------------------------
/.readthedocs.yml:
--------------------------------------------------------------------------------
1 | ---
2 | # Read the Docs configuration file
3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
4 |
5 | # Required
6 | version: 2
7 | build:
8 | os: "ubuntu-22.04"
9 | tools:
10 | python: "3.11"
11 |
12 | # Build documentation in the docs/ directory with Sphinx
13 | sphinx:
14 | configuration: docs/conf.py
15 |
16 | # Version of Python and requirements required to build the docs
17 | python:
18 | install:
19 | - requirements: docs/requirements.txt
20 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.rst:
--------------------------------------------------------------------------------
1 | Code of conduct
2 | ===============
3 |
4 | This repository is governed by Mozilla's code of conduct and etiquette
5 | guidelines.
6 |
7 | For more details please see the `Mozilla Community Participation Guidelines`_
8 | and `Developer Etiquette Guidelines`_.
9 |
10 |
11 | .. _`Mozilla Community Participation Guidelines`: https://www.mozilla.org/about/governance/policies/participation/
12 | .. _`Developer Etiquette Guidelines`: https://bugzilla.mozilla.org/page.cgi?id=etiquette.html
13 |
--------------------------------------------------------------------------------
/kagi/views/backup_codes.py:
--------------------------------------------------------------------------------
1 | from django.http import HttpResponseRedirect
2 | from django.views.generic import ListView
3 |
4 |
5 | class BackupCodesView(ListView):
6 | template_name = "kagi/backup_codes.html"
7 |
8 | def get_queryset(self):
9 | return self.request.user.backup_codes.all()
10 |
11 | def post(self, request):
12 | for i in range(10):
13 | self.request.user.backup_codes.create_backup_code()
14 | return HttpResponseRedirect(self.request.build_absolute_uri())
15 |
--------------------------------------------------------------------------------
/kagi/tests/test_admin.py:
--------------------------------------------------------------------------------
1 | from django.urls import reverse
2 |
3 | import kagi.views
4 |
5 |
6 | def test_get_admin_login_loads_the_form(client):
7 | response = client.get(reverse("admin:login"))
8 | assert isinstance(response.context_data["view"], kagi.views.KagiLoginView)
9 |
10 |
11 | def test_get_admin_login_redirects_to_admin_index_if_already_logged_in(admin_client):
12 | response = admin_client.get(reverse("admin:login"))
13 | assert response.status_code == 302
14 | assert response.url == reverse("admin:index")
15 |
--------------------------------------------------------------------------------
/kagi/templates/kagi/add_key.html:
--------------------------------------------------------------------------------
1 | {% extends "kagi/base.html" %}
2 | {% load i18n %}
3 | {% load static %}
4 |
5 | {% block content %}
6 | {{ block.super }}
7 | {% trans 'To add a security key to your account, insert it, tap the button below, and accept the browser prompt to add the key.' %}
8 |
9 |
14 |
15 |
16 | {% endblock %}
17 |
--------------------------------------------------------------------------------
/kagi/templates/kagi/backup_codes.html:
--------------------------------------------------------------------------------
1 | {% extends "kagi/base.html" %}
2 | {% load i18n %}
3 |
4 | {% block content %}
5 | {{ block.super }}
6 |
7 | {% trans '← Back to settings' %}
8 |
9 |
10 | {% for code in object_list %}
11 | - {{ code.code }}
12 | {% empty %}
13 | - {% trans 'You do not have any backup codes! Please create some!' %}
14 | {% endfor %}
15 |
16 |
17 |
21 |
22 | {% endblock %}
23 |
--------------------------------------------------------------------------------
/trusted_attestation_roots/HyperFIDO_CA_Cert_V1.pem:
--------------------------------------------------------------------------------
1 | -----BEGIN CERTIFICATE-----
2 | MIIBjTCCATOgAwIBAgIBATAKBggqhkjOPQQDAjAXMRUwEwYDVQQDEwxGVCBGSURP
3 | IDAxMDAwHhcNMTQwNzAxMTUzNjI2WhcNNDQwNzAzMTUzNjI2WjAXMRUwEwYDVQQD
4 | EwxGVCBGSURPIDAxMDAwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASxdLxJx8ol
5 | S3DS5cIHzunPF0gg69d+o8ZVCMJtpRtlfBzGuVL4YhaXk2SC2gptPTgmpZCV2vbN
6 | fAPi5gOF0vbZo3AwbjAdBgNVHQ4EFgQUXt4jWlYDgwhaPU+EqLmeM9LoPRMwPwYD
7 | VR0jBDgwNoAUXt4jWlYDgwhaPU+EqLmeM9LoPROhG6QZMBcxFTATBgNVBAMTDEZU
8 | IEZJRE8gMDEwMIIBATAMBgNVHRMEBTADAQH/MAoGCCqGSM49BAMCA0gAMEUCIQC2
9 | D9o9cconKTo8+4GZPyZBJ3amc8F0/kzyidX9dhrAIAIgM9ocs5BW/JfmshVP9Mb+
10 | Joa/kgX4dWbZxrk0ioTfJZg=
11 | -----END CERTIFICATE-----
12 |
--------------------------------------------------------------------------------
/kagi/templates/kagi/totp_device.html:
--------------------------------------------------------------------------------
1 | {% extends "kagi/base.html" %}
2 | {% load i18n %}
3 |
4 | {% block content %}
5 | {{ block.super }}
6 |
7 | {% trans 'Scan this in your authenticator app:' %}
8 |
9 |
10 | {{ qr_svg|safe }}
11 |
12 |
13 |
14 | {% trans "Or, if you can't scan a QR Code, enter this key as a time-based account:" %} {{ base32_key }}
15 |
16 |
17 |
18 | {% trans 'Then, enter the token it gives you.' %}
19 |
20 |
21 |
26 |
27 | {% endblock %}
28 |
--------------------------------------------------------------------------------
/testproj/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """Django's command-line utility for administrative tasks."""
3 | import os
4 | import sys
5 |
6 |
7 | def main():
8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproj.settings")
9 | try:
10 | from django.core.management import execute_from_command_line
11 | except ImportError as exc:
12 | raise ImportError(
13 | "Couldn't import Django. Are you sure it's installed and "
14 | "available on your PYTHONPATH environment variable? Did you "
15 | "forget to activate a virtual environment?"
16 | ) from exc
17 | execute_from_command_line(sys.argv)
18 |
19 |
20 | if __name__ == "__main__":
21 | main()
22 |
--------------------------------------------------------------------------------
/kagi/utils/__init__.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.contrib.auth import load_backend
3 |
4 |
5 | def get_origin(request):
6 | return f"{request.scheme}://{request.get_host()}"
7 |
8 |
9 | def get_user(request):
10 | try:
11 | user_id = request.session["kagi_pre_verify_user_pk"]
12 | backend_path = request.session["kagi_pre_verify_user_backend"]
13 | assert backend_path in settings.AUTHENTICATION_BACKENDS
14 | backend = load_backend(backend_path)
15 | user = backend.get_user(user_id)
16 | if user is not None:
17 | user.backend = backend_path
18 | return user
19 | except (KeyError, AssertionError): # pragma: no cover
20 | return None
21 |
--------------------------------------------------------------------------------
/testproj/templates/base.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {% if messages %}
6 |
7 | {% for message in messages %}
8 | -
9 | {% if message.level == DEFAULT_MESSAGE_LEVELS.ERROR %}Important: {% endif %}
10 | {{ message }}
11 |
12 | {% endfor %}
13 |
14 | {% endif %}
15 | {% block content %}
16 | {% endblock %}
17 |
18 |
19 |
--------------------------------------------------------------------------------
/trusted_attestation_roots/HyperFIDO_CA_Cert_V2.pem:
--------------------------------------------------------------------------------
1 | -----BEGIN CERTIFICATE-----
2 | MIIBxzCCAWygAwIBAgICEAswCgYIKoZIzj0EAwIwOjELMAkGA1UEBhMCQ0ExEjAQ
3 | BgNVBAoMCUhZUEVSU0VDVTEXMBUGA1UEAwwOSFlQRVJGSURPIDAyMDAwIBcNMTgw
4 | MTAxMDAwMDAwWhgPMjA0NzEyMzEyMzU5NTlaMDoxCzAJBgNVBAYTAkNBMRIwEAYD
5 | VQQKDAlIWVBFUlNFQ1UxFzAVBgNVBAMMDkhZUEVSRklETyAwMjAwMFkwEwYHKoZI
6 | zj0CAQYIKoZIzj0DAQcDQgAErKUI1G0S7a6IOLlmHipLlBuxTYjsEESQvzQh3dB7
7 | dvxxWWm7kWL91rq6S7ayZG0gZPR+zYqdFzwAYDcG4+aX66NgMF4wHQYDVR0OBBYE
8 | FLZYcfMMwkQAGbt3ryzZFPFypmsIMB8GA1UdIwQYMBaAFLZYcfMMwkQAGbt3ryzZ
9 | FPFypmsIMAwGA1UdEwQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMC
10 | A0kAMEYCIQCG2/ppMGt7pkcRie5YIohS3uDPIrmiRcTjqDclKVWg0gIhANcPNDZH
11 | E2/zZ+uB5ThG9OZus+xSb4knkrbAyXKX2zm/
12 | -----END CERTIFICATE-----
13 |
--------------------------------------------------------------------------------
/trusted_attestation_roots/solokeys_u2f_device_attestation_ca.pem:
--------------------------------------------------------------------------------
1 | -----BEGIN CERTIFICATE-----
2 | MIIB9DCCAZoCCQDER2OSj/S+jDAKBggqhkjOPQQDAjCBgDELMAkGA1UEBhMCVVMx
3 | ETAPBgNVBAgMCE1hcnlsYW5kMRIwEAYDVQQKDAlTb2xvIEtleXMxEDAOBgNVBAsM
4 | B1Jvb3QgQ0ExFTATBgNVBAMMDHNvbG9rZXlzLmNvbTEhMB8GCSqGSIb3DQEJARYS
5 | aGVsbG9Ac29sb2tleXMuY29tMCAXDTE4MTExMTEyNTE0MloYDzIwNjgxMDI5MTI1
6 | MTQyWjCBgDELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1hcnlsYW5kMRIwEAYDVQQK
7 | DAlTb2xvIEtleXMxEDAOBgNVBAsMB1Jvb3QgQ0ExFTATBgNVBAMMDHNvbG9rZXlz
8 | LmNvbTEhMB8GCSqGSIb3DQEJARYSaGVsbG9Ac29sb2tleXMuY29tMFkwEwYHKoZI
9 | zj0CAQYIKoZIzj0DAQcDQgAEWHAN0CCJVZdMs0oktZ5m93uxmB1iyq8ELRLtqVFL
10 | SOiHQEab56qRTB/QzrpGAY++Y2mw+vRuQMNhBiU0KzwjBjAKBggqhkjOPQQDAgNI
11 | ADBFAiEAz9SlrAXIlEu87vra54rICPs+4b0qhp3PdzcTg7rvnP0CIGjxzlteQQx+
12 | jQGd7rwSZuE5RWUPVygYhUstQO9zNUOs
13 | -----END CERTIFICATE-----
14 |
--------------------------------------------------------------------------------
/kagi/settings.py:
--------------------------------------------------------------------------------
1 | import os.path
2 |
3 | from django.conf import settings
4 |
5 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
6 |
7 |
8 | RELYING_PARTY_ID = getattr(settings, "RELYING_PARTY_ID", "localhost")
9 | RELYING_PARTY_NAME = getattr(settings, "RELYING_PARTY_NAME", "Kagi Test Project")
10 | WEBAUTHN_TRUSTED_CERTIFICATES = getattr(
11 | settings,
12 | "WEBAUTHN_TRUSTED_CERTIFICATES",
13 | os.path.join(BASE_DIR, "..", "trusted_attestation_roots"),
14 | )
15 | WEBAUTHN_TRUSTED_ATTESTATION_CERT_REQUIRED = getattr(
16 | settings, "WEBAUTHN_TRUSTED_ATTESTATION_CERT_REQUIRED", False
17 | )
18 | WEBAUTHN_SELF_ATTESTATION_PERMITTED = getattr(
19 | settings, "WEBAUTHN_SELF_ATTESTATION_PERMITTED", False
20 | )
21 | WEBAUTHN_NONE_ATTESTATION_PERMITTED = getattr(
22 | settings, "WEBAUTHN_NONE_ATTESTATION_PERMITTED", False
23 | )
24 |
--------------------------------------------------------------------------------
/docs/customization.rst:
--------------------------------------------------------------------------------
1 | Customization
2 | #############
3 |
4 | Handling Unsupported Browsers
5 | =============================
6 |
7 | The provided ``webauthn.js`` file detects whether browsers support WebAuthn, and
8 | if not, displays or hides selected element IDs as appropriate. There are matching
9 | element IDs in the bundled templates, which can be useful as a guide for how to
10 | handle this in customized templates.
11 |
12 | Elements with ID ``webauthn-feature`` will be set to ``style="display: none"``.
13 | This is for hiding functional elements that require WebAuthn support, since using
14 | those elements in an unsupported browser would only result in errors.
15 |
16 | Elements with ID ``webauthn-undefined-error`` will be set to ``style="display: block"``.
17 | This is useful for displaying a warning in unsupported browsers, along with a link
18 | to a list of compatible browsers.
19 |
--------------------------------------------------------------------------------
/kagi/templates/kagi/base.html:
--------------------------------------------------------------------------------
1 | {% extends base_template|default:"base.html" %}
2 | {% load static %}
3 | {% load i18n %}
4 |
5 | {% block content %}
6 |
7 |
15 | {{ block.super }}
16 |
17 |
23 | {% endblock %}
24 |
--------------------------------------------------------------------------------
/kagi/templates/kagi/two_factor_settings.html:
--------------------------------------------------------------------------------
1 | {% extends "kagi/base.html" %}
2 | {% load i18n %}
3 |
4 | {% block content %}
5 | {{ block.super }}
6 | {% trans "Two Factor Settings" %}
7 |
12 |
13 | {% trans "Status" %}
14 |
15 | - {% trans "WebAuthn" %}: {% if webauthn_enabled %}{% trans "On" %}{% else %}{% trans "Off" %}{% endif %}
16 | - {% trans "TOTP" %}: {% if totp_enabled %}{% trans "On" %}{% else %}{% trans "Off" %}{% endif %}
17 | - {% trans "Backup codes" %}: {% if backup_codes_count %}{{ backup_codes_count }} {% trans "remaining" %}{% else %}{% trans "None generated" %}{% endif %}
18 |
19 |
20 | {% endblock %}
21 |
--------------------------------------------------------------------------------
/kagi/templates/kagi/totpdevice_list.html:
--------------------------------------------------------------------------------
1 | {% extends "kagi/base.html" %}
2 | {% load i18n %}
3 |
4 | {% block content %}
5 | {{ block.super }}
6 | {% trans "TOTP (Authenticator) Devices" %}
7 | {% trans '← Back to settings' %}
8 |
9 |
10 |
11 | | {% trans 'Added on' %} |
12 | {% trans 'Last used on' %} |
13 |
14 |
15 |
16 | {% for device in object_list %}
17 |
18 | | {{ device.created_at }} |
19 | {% trans 'Never' as never %}
20 | {{ device.last_used_at|default:never}} |
21 |
22 |
26 | |
27 |
28 | {% endfor %}
29 |
30 |
31 | {% trans 'Add another TOTP (Authenticator) Device' %}
32 | {% endblock %}
33 |
--------------------------------------------------------------------------------
/testproj/testproj/urls.py:
--------------------------------------------------------------------------------
1 | """testproj URL Configuration
2 |
3 | The `urlpatterns` list routes URLs to views. For more information please see:
4 | https://docs.djangoproject.com/en/2.2/topics/http/urls/
5 | Examples:
6 | Function views
7 | 1. Add an import: from my_app import views
8 | 2. Add a URL to urlpatterns: path('', views.home, name='home')
9 | Class-based views
10 | 1. Add an import: from other_app.views import Home
11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
12 | Including another URLconf
13 | 1. Import the include() function: from django.urls import include, path
14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
15 | """
16 |
17 | from django.contrib import admin
18 | from django.urls import include, path, reverse_lazy
19 | from django.views.generic import RedirectView
20 |
21 | urlpatterns = [
22 | path("", RedirectView.as_view(url=reverse_lazy("kagi:login")), name="index"),
23 | path("kagi/", include("kagi.urls", namespace="kagi")),
24 | path("admin/", admin.site.urls),
25 | ]
26 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | CHANGELOG
2 | =========
3 |
4 | 0.4.0 - 2023-06-08
5 | ------------------
6 |
7 | * Add support for Python 3.11 and Django 4.2, by @MarkusH (#67).
8 | * "Pin" primary keys to `AutoField` so no new migrations are generated for now (#55).
9 | * Properly update `last_used_at` for FIDO tokens, by @MarkusH (#66).
10 | * Improve secret submission security when adding TOTP devices, by @MarkusH (#72).
11 | * Improve QR code display in Django Admin in dark mode, by @evanottinger (#75).
12 | * Publish Kagi via PyPI trusted publisher system, by @apollo13 (#74).
13 |
14 | Contributed by [Florian Apolloner](https://github.com/apollo13) via [PR #76](https://github.com/justinmayer/kagi/pull/76/)
15 |
16 |
17 | 0.3.0 - 2022-09-18
18 | ------------------
19 |
20 | * Update project for Django 4.1 compatibility
21 | * Upgrade code for Python 3.7+ conventions
22 |
23 | 0.2.0 - 2021-11-05
24 | ------------------
25 |
26 | - Add support for multiple WebAuthn keys (#4)
27 | - Remove `django-extensions` and the need for HTTPS on localhost (#29)
28 | - Many minor enhancements
29 |
30 | 0.1.0 - 2019-08-20
31 | ------------------
32 |
33 | - Initial release
34 |
--------------------------------------------------------------------------------
/kagi/views/webauthn_keys.py:
--------------------------------------------------------------------------------
1 | from django.contrib import messages
2 | from django.http import HttpResponseRedirect
3 | from django.shortcuts import get_object_or_404
4 | from django.urls import reverse
5 | from django.utils.translation import gettext as _
6 | from django.views.generic import ListView, TemplateView
7 |
8 | from ..forms import KeyRegistrationForm
9 | from .mixin import OriginMixin
10 |
11 |
12 | class AddWebAuthnKeyView(OriginMixin, TemplateView):
13 | template_name = "kagi/add_key.html"
14 |
15 | def get_context_data(self, **kwargs):
16 | kwargs = super().get_context_data(**kwargs)
17 | kwargs["form"] = KeyRegistrationForm()
18 | return kwargs
19 |
20 |
21 | class KeyManagementView(ListView):
22 | template_name = "kagi/key_list.html"
23 |
24 | def get_queryset(self):
25 | return self.request.user.webauthn_keys.all()
26 |
27 | def post(self, request):
28 | assert "delete" in self.request.POST
29 | key = get_object_or_404(self.get_queryset(), pk=self.request.POST["key_id"])
30 | key.delete()
31 | messages.success(request, _("Key removed."))
32 | return HttpResponseRedirect(reverse("kagi:webauthn-keys"))
33 |
--------------------------------------------------------------------------------
/kagi/templates/kagi/key_list.html:
--------------------------------------------------------------------------------
1 | {% extends "kagi/base.html" %}
2 | {% load i18n %}
3 | {% load static %}
4 |
5 | {% block content %}
6 | {{ block.super }}
7 | WebAuthn Keys
8 | {% trans '← Back to settings' %}
9 |
10 |
11 |
12 | | {% trans 'Key name' %} |
13 | {% trans 'Added on' %} |
14 | {% trans 'Last used on' %} |
15 |
16 |
17 |
18 | {% for key in object_list %}
19 |
20 | | {{ key.key_name }} |
21 | {{ key.created_at }} |
22 | {% trans 'Never' as never %}
23 | {{ key.last_used_at|default:never }} |
24 |
25 |
29 | |
30 |
31 | {% endfor %}
32 |
33 |
34 |
37 |
38 |
39 | {% endblock %}
40 |
--------------------------------------------------------------------------------
/trusted_attestation_roots/yubico_u2f_device_attestation_ca.pem:
--------------------------------------------------------------------------------
1 | -----BEGIN CERTIFICATE-----
2 | MIIDHjCCAgagAwIBAgIEG0BT9zANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZ
3 | dWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAw
4 | MDBaGA8yMDUwMDkwNDAwMDAwMFowLjEsMCoGA1UEAxMjWXViaWNvIFUyRiBSb290
5 | IENBIFNlcmlhbCA0NTcyMDA2MzEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
6 | AoIBAQC/jwYuhBVlqaiYWEMsrWFisgJ+PtM91eSrpI4TK7U53mwCIawSDHy8vUmk
7 | 5N2KAj9abvT9NP5SMS1hQi3usxoYGonXQgfO6ZXyUA9a+KAkqdFnBnlyugSeCOep
8 | 8EdZFfsaRFtMjkwz5Gcz2Py4vIYvCdMHPtwaz0bVuzneueIEz6TnQjE63Rdt2zbw
9 | nebwTG5ZybeWSwbzy+BJ34ZHcUhPAY89yJQXuE0IzMZFcEBbPNRbWECRKgjq//qT
10 | 9nmDOFVlSRCt2wiqPSzluwn+v+suQEBsUjTGMEd25tKXXTkNW21wIWbxeSyUoTXw
11 | LvGS6xlwQSgNpk2qXYwf8iXg7VWZAgMBAAGjQjBAMB0GA1UdDgQWBBQgIvz0bNGJ
12 | hjgpToksyKpP9xv9oDAPBgNVHRMECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBBjAN
13 | BgkqhkiG9w0BAQsFAAOCAQEAjvjuOMDSa+JXFCLyBKsycXtBVZsJ4Ue3LbaEsPY4
14 | MYN/hIQ5ZM5p7EjfcnMG4CtYkNsfNHc0AhBLdq45rnT87q/6O3vUEtNMafbhU6kt
15 | hX7Y+9XFN9NpmYxr+ekVY5xOxi8h9JDIgoMP4VB1uS0aunL1IGqrNooL9mmFnL2k
16 | LVVee6/VR6C5+KSTCMCWppMuJIZII2v9o4dkoZ8Y7QRjQlLfYzd3qGtKbw7xaF1U
17 | sG/5xUb/Btwb2X2g4InpiB/yt/3CpQXpiWX/K4mBvUKiGn05ZsqeY1gx4g0xLBqc
18 | U9psmyPzK+Vsgw2jeRQ5JlKDyqE0hebfC1tvFu0CCrJFcw==
19 | -----END CERTIFICATE-----
20 |
--------------------------------------------------------------------------------
/kagi/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import path
2 |
3 | from . import views
4 | from .views import api
5 |
6 | app_name = "kagi"
7 |
8 | urlpatterns = [
9 | path(
10 | "verify-second-factor/",
11 | views.verify_second_factor,
12 | name="verify-second-factor",
13 | ),
14 | path("login/", views.login, name="login"),
15 | path("keys/", views.keys, name="webauthn-keys"),
16 | path("add-webauthn-key/", views.add_webauthn_key, name="add-webauthn-key"),
17 | path("two-factor-settings/", views.two_factor_settings, name="two-factor-settings"),
18 | path("backup-codes/", views.backup_codes, name="backup-codes"),
19 | path("add-totp-device/", views.add_totp, name="add-totp"),
20 | path("totp-devices/", views.totp_devices, name="totp-devices"),
21 | path("api/begin-activate/", api.webauthn_begin_activate, name="begin-activate"),
22 | path(
23 | "api/verify-credential-info/",
24 | api.webauthn_verify_credential_info,
25 | name="verify-credential-info",
26 | ),
27 | path("api/begin-assertion/", api.webauthn_begin_assertion, name="begin-assertion"),
28 | path(
29 | "api/verify-assertion/",
30 | api.webauthn_verify_assertion,
31 | name="verify-assertion",
32 | ),
33 | ]
34 |
--------------------------------------------------------------------------------
/docs/troubleshooting.rst:
--------------------------------------------------------------------------------
1 | Troubleshooting
2 | ###############
3 |
4 | .. _troubleshooting:
5 |
6 | We are doing the best we can so you do not have to read this section.
7 |
8 | That said, we have included solutions (or at least explanations) for
9 | some common problems below.
10 |
11 | If you do not find a solution to your problem here, please
12 | :ref:`ask for help `!
13 |
14 |
15 | socket.error: [Errno 48] Address already in use
16 | ===============================================
17 |
18 | Another process has occupied Django's default port 8000.
19 |
20 | To fix this, see which service is running on port 8000::
21 |
22 | $ sudo lsof -i :8000
23 |
24 | and kill the process using PID from output::
25 |
26 | $ kill -kill [PID]
27 |
28 |
29 | DOMException / SecurityError: "The operation is insecure."
30 | ==========================================================
31 |
32 | This means that the `navigator.credentials` Javascript API refused to
33 | start because you are not in a secure context.
34 |
35 | This means that either:
36 |
37 | - You are not connected on your website through HTTPS
38 | - The certificate doesn't match the HOST.
39 | - In development, maybe you are trying
40 | http://127.0.0.1:8000/kagi/login/ rather than
41 | http://localhost:8000/kagi/login/
42 |
--------------------------------------------------------------------------------
/kagi/views/__init__.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.decorators import login_required
2 | from django.views.generic import TemplateView
3 |
4 | from .backup_codes import BackupCodesView
5 | from .login import KagiLoginView, VerifySecondFactorView
6 | from .totp_devices import AddTOTPDeviceView, TOTPDeviceManagementView
7 | from .webauthn_keys import AddWebAuthnKeyView, KeyManagementView
8 |
9 |
10 | class TwoFactorSettingsView(TemplateView):
11 | template_name = "kagi/two_factor_settings.html"
12 |
13 | def get_context_data(self, **kwargs):
14 | context = super().get_context_data(**kwargs)
15 | context["webauthn_enabled"] = self.request.user.webauthn_keys.exists()
16 | context["backup_codes_count"] = self.request.user.backup_codes.count()
17 | context["totp_enabled"] = self.request.user.totp_devices.exists()
18 | return context
19 |
20 |
21 | add_webauthn_key = AddWebAuthnKeyView.as_view()
22 | verify_second_factor = VerifySecondFactorView.as_view()
23 | login = KagiLoginView.as_view()
24 | keys = login_required(KeyManagementView.as_view())
25 | two_factor_settings = login_required(TwoFactorSettingsView.as_view())
26 | backup_codes = login_required(BackupCodesView.as_view())
27 | add_totp = login_required(AddTOTPDeviceView.as_view())
28 | totp_devices = login_required(TOTPDeviceManagementView.as_view())
29 |
--------------------------------------------------------------------------------
/kagi/templates/kagi/verify_second_factor.html:
--------------------------------------------------------------------------------
1 | {% extends "kagi/base.html" %}
2 | {% load i18n %}
3 | {% load static %}
4 |
5 | {% block content %}
6 | {{ block.super }}
7 |
8 |
9 | {% trans 'Please verify one of the authentication methods below.' %}
10 |
11 |
12 |
13 | {% if forms.webauthn %}
14 |
23 | {% endif %}
24 |
25 | {% if forms.totp %}
26 |
27 |
{% trans 'Enter an Authenticator (TOTP) Token:' %}
28 |
29 |
34 |
35 | {% endif %}
36 |
37 | {% if forms.backup %}
38 |
39 |
{% trans 'Use a backup code:' %}
40 |
41 |
46 |
47 | {% endif %}
48 |
49 |
50 | {% endblock %}
51 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2019 – present, Justin Mayer
2 | Copyright (c) 2019 – present, Rémy Hubscher
3 | Copyright (c) 2014 – 2018, Gavin Wahl
4 |
5 | All rights reserved.
6 |
7 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
8 |
9 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
10 |
11 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
12 |
13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
14 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import os
3 |
4 | on_rtd = os.environ.get("READTHEDOCS", None) == "True"
5 |
6 |
7 | # -- Project information -----------------------------------------------------
8 |
9 | project = "Kagi"
10 | year = datetime.datetime.now().date().year
11 | copyright = f"2019–{year} Justin Mayer & Rémy Hubscher"
12 | author = "Justin Mayer & Rémy Hubscher"
13 |
14 |
15 | # -- General configuration ---------------------------------------------------
16 |
17 | extensions = ["sphinx.ext.autosectionlabel", "sphinx.ext.extlinks"]
18 | templates_path = ["_templates"]
19 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
20 | master_doc = "index"
21 |
22 | # -- Options for HTML output -------------------------------------------------
23 |
24 | html_title = "Kagi Docs"
25 |
26 | html_theme = "default"
27 | try:
28 | import furo # NOQA
29 |
30 | html_theme = "furo"
31 | except ImportError:
32 | pass
33 |
34 | html_static_path = ["_static"]
35 |
36 | # If false, no module index is generated.
37 | html_use_modindex = False
38 |
39 | # If false, no index is generated.
40 | html_use_index = False
41 |
42 | # If true, links to the reST sources are added to the pages.
43 | html_show_sourcelink = False
44 |
45 |
46 | # -- Extension Configuration -------------------------------------------------
47 |
48 | extlinks = {
49 | "issue": ("https://github.com/justinmayer/kagi/issues/%s", "issue "),
50 | "github": ("https://github.com/%s/", ""),
51 | "rtd": ("https://%s.readthedocs.io", ""),
52 | }
53 |
--------------------------------------------------------------------------------
/kagi/oath.py:
--------------------------------------------------------------------------------
1 | import hashlib
2 | import hmac
3 | import struct
4 |
5 |
6 | def hotp(key, counter, digits=6):
7 | """
8 | These test vectors come from RFC-4226
9 | (https://tools.ietf.org/html/rfc4226#page-32).
10 |
11 | >>> key = b'12345678901234567890'
12 | >>> for c in range(10):
13 | ... hotp(key, c)
14 | '755224'
15 | '287082'
16 | '359152'
17 | '969429'
18 | '338314'
19 | '254676'
20 | '287922'
21 | '162583'
22 | '399871'
23 | '520489'
24 | """
25 |
26 | msg = struct.pack(">Q", counter)
27 | hs = hmac.new(key, msg, hashlib.sha1).digest()
28 | offset = hs[19] & 0x0F
29 | val = struct.unpack(">L", hs[offset : offset + 4])[0] & 0x7FFFFFFF
30 | return "{val:0{digits}d}".format(val=val % 10**digits, digits=digits)
31 |
32 |
33 | def T(t, step=30):
34 | """
35 | The TOTP T value (number of time steps since the epoch)
36 | """
37 | return int(t.timestamp()) // step
38 |
39 |
40 | def totp(key, t, digits=6, step=30):
41 | """
42 | These test vectors come from RFC-6238
43 | (https://tools.ietf.org/html/rfc6238#appendix-B).
44 | >>> import datetime
45 | >>> key = b'12345678901234567890'
46 | >>> totp(key, datetime.datetime.fromtimestamp(59), digits=8)
47 | '94287082'
48 | >>> totp(key, datetime.datetime.fromtimestamp(1111111109), digits=8)
49 | '07081804'
50 | >>> totp(key, datetime.datetime.fromtimestamp(20000000000), digits=8)
51 | '65353130'
52 | """
53 | return hotp(key, T(t, step), digits)
54 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 | poetry.lock
6 |
7 | # C extensions
8 | *.so
9 |
10 | # Distribution / packaging
11 | .Python
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .coverage
43 | .coverage.*
44 | .cache
45 | nosetests.xml
46 | coverage.xml
47 | *.cover
48 | .hypothesis/
49 | .pytest_cache/
50 |
51 | # Translations
52 | *.mo
53 | *.pot
54 |
55 | # Django stuff:
56 | *.log
57 | local_settings.py
58 | db.sqlite3
59 |
60 | # Flask stuff:
61 | instance/
62 | .webassets-cache
63 |
64 | # Scrapy stuff:
65 | .scrapy
66 |
67 | # Sphinx documentation
68 | docs/_build/
69 |
70 | # PyBuilder
71 | target/
72 |
73 | # Jupyter Notebook
74 | .ipynb_checkpoints
75 |
76 | # pyenv
77 | .python-version
78 |
79 | # celery beat schedule file
80 | celerybeat-schedule
81 |
82 | # SageMath parsed files
83 | *.sage.py
84 |
85 | # Environments
86 | .env
87 | .venv
88 | env/
89 | venv/
90 | ENV/
91 | env.bak/
92 | venv.bak/
93 |
94 | # Spyder project settings
95 | .spyderproject
96 | .spyproject
97 |
98 | # Rope project settings
99 | .ropeproject
100 |
101 | # mkdocs documentation
102 | /site
103 |
104 | # mypy
105 | .mypy_cache/
106 |
--------------------------------------------------------------------------------
/kagi/tests/test_two_factor_settings.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.models import User
2 | from django.urls import reverse
3 |
4 |
5 | def test_mfa_settings_page_displays_mfa_statuses(admin_client):
6 | response = admin_client.get(reverse("kagi:two-factor-settings"))
7 |
8 | assert response.context_data["webauthn_enabled"] is False
9 | assert response.context_data["totp_enabled"] is False
10 | assert response.context_data["backup_codes_count"] == 0
11 |
12 |
13 | def test_mfa_settings_page_knows_when_webauthn_is_enabled(admin_client):
14 | user = User.objects.get(pk=1)
15 |
16 | user.webauthn_keys.create(key_name="SoloKey", sign_count=0)
17 | response = admin_client.get(reverse("kagi:two-factor-settings"))
18 |
19 | assert response.context_data["webauthn_enabled"] is True
20 | assert response.context_data["totp_enabled"] is False
21 | assert response.context_data["backup_codes_count"] == 0
22 |
23 |
24 | def test_mfa_settings_page_knows_when_totp_is_enabled(admin_client):
25 | user = User.objects.get(pk=1)
26 |
27 | user.totp_devices.create()
28 | response = admin_client.get(reverse("kagi:two-factor-settings"))
29 |
30 | assert response.context_data["webauthn_enabled"] is False
31 | assert response.context_data["totp_enabled"] is True
32 | assert response.context_data["backup_codes_count"] == 0
33 |
34 |
35 | def test_mfa_settings_page_knows_how_to_count_backup_codes(admin_client):
36 | user = User.objects.get(pk=1)
37 |
38 | user.backup_codes.create_backup_code()
39 | response = admin_client.get(reverse("kagi:two-factor-settings"))
40 |
41 | assert response.context_data["webauthn_enabled"] is False
42 | assert response.context_data["totp_enabled"] is False
43 | assert response.context_data["backup_codes_count"] == 1
44 |
--------------------------------------------------------------------------------
/kagi/forms.py:
--------------------------------------------------------------------------------
1 | from django import forms
2 | from django.utils import timezone
3 | from django.utils.translation import gettext_lazy as _
4 |
5 |
6 | class SecondFactorForm(forms.Form):
7 | def __init__(self, *args, **kwargs):
8 | self.user = kwargs.pop("user")
9 | self.request = kwargs.pop("request")
10 | self.appId = kwargs.pop("appId")
11 | return super().__init__(*args, **kwargs)
12 |
13 |
14 | class BackupCodeForm(SecondFactorForm):
15 | INVALID_ERROR_MESSAGE = _("That is not a valid backup code.")
16 |
17 | code = forms.CharField(
18 | label=_("Code"), widget=forms.TextInput(attrs={"autocomplete": "off"})
19 | )
20 |
21 | def validate_second_factor(self):
22 | count, _ = self.user.backup_codes.filter(
23 | code=self.cleaned_data["code"]
24 | ).delete()
25 | if count == 0:
26 | self.add_error("code", self.INVALID_ERROR_MESSAGE)
27 | return False
28 | elif count == 1:
29 | return True
30 |
31 |
32 | class TOTPForm(SecondFactorForm):
33 | INVALID_ERROR_MESSAGE = _("That token is invalid.")
34 |
35 | token = forms.CharField(
36 | min_length=6,
37 | max_length=6,
38 | label=_("Token"),
39 | widget=forms.TextInput(attrs={"autocomplete": "off"}),
40 | )
41 |
42 | def validate_second_factor(self):
43 | for device in self.user.totp_devices.all():
44 | if device.validate_token(self.cleaned_data["token"]):
45 | device.last_used_at = timezone.now()
46 | device.save()
47 | return True
48 | self.add_error("token", self.INVALID_ERROR_MESSAGE)
49 | return False
50 |
51 |
52 | class KeyRegistrationForm(forms.Form):
53 | key_name = forms.CharField(label=_("Key name"))
54 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | # See https://pre-commit.com/hooks.html for info on hooks
3 | repos:
4 | - repo: https://github.com/pre-commit/pre-commit-hooks
5 | rev: v4.6.0
6 | hooks:
7 | - id: check-added-large-files
8 | - id: check-ast
9 | - id: check-case-conflict
10 | - id: check-docstring-first
11 | - id: check-json
12 | - id: check-merge-conflict
13 | - id: check-toml
14 | - id: check-yaml
15 | - id: debug-statements
16 | - id: detect-private-key
17 | - id: end-of-file-fixer
18 | - id: forbid-new-submodules
19 | - id: trailing-whitespace
20 |
21 | - repo: https://github.com/psf/black
22 | rev: 24.4.2
23 | hooks:
24 | - id: black
25 |
26 | - repo: https://github.com/PyCQA/flake8
27 | rev: 7.0.0
28 | hooks:
29 | - id: flake8
30 | additional_dependencies: [Flake8-pyproject]
31 |
32 | - repo: https://github.com/PyCQA/isort
33 | rev: 5.13.2
34 | hooks:
35 | - id: isort
36 |
37 | - repo: https://github.com/PyCQA/bandit
38 | rev: 1.7.8
39 | hooks:
40 | - id: bandit
41 | args: ["-lll", "-vr", "kagi", "testproj"]
42 |
43 | - repo: https://github.com/Lucas-C/pre-commit-hooks-safety
44 | rev: v1.3.3
45 | hooks:
46 | - id: python-safety-dependencies-check
47 | # 65213: https://data.safetycli.com/vulnerabilities/CVE-2023-6129/65213/
48 | args: ["--disable-optional-telemetry", "--ignore=65213"]
49 | files: pyproject.toml
50 |
51 | - repo: https://github.com/asottile/pyupgrade
52 | rev: v3.15.2
53 | hooks:
54 | - id: pyupgrade
55 | args: [--py37-plus]
56 |
57 | - repo: https://github.com/hakancelikdev/unimport
58 | rev: 1.2.1
59 | hooks:
60 | - id: unimport
61 | args: [--remove, --include-star-import]
62 |
--------------------------------------------------------------------------------
/kagi/static/kagi/base64js.min.js:
--------------------------------------------------------------------------------
1 | (function(r){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=r()}else if(typeof define==="function"&&define.amd){define([],r)}else{var e;if(typeof window!=="undefined"){e=window}else if(typeof global!=="undefined"){e=global}else if(typeof self!=="undefined"){e=self}else{e=this}e.base64js=r()}})(function(){var r,e,n;return function(){function d(a,f,i){function u(n,r){if(!f[n]){if(!a[n]){var e="function"==typeof require&&require;if(!r&&e)return e(n,!0);if(v)return v(n,!0);var t=new Error("Cannot find module '"+n+"'");throw t.code="MODULE_NOT_FOUND",t}var o=f[n]={exports:{}};a[n][0].call(o.exports,function(r){var e=a[n][1][r];return u(e||r)},o,o.exports,d,a,f,i)}return f[n].exports}for(var v="function"==typeof require&&require,r=0;r0){throw new Error("Invalid string. Length must be a multiple of 4")}var n=r.indexOf("=");if(n===-1)n=e;var t=n===e?0:4-n%4;return[n,t]}function f(r){var e=c(r);var n=e[0];var t=e[1];return(n+t)*3/4-t}function h(r,e,n){return(e+n)*3/4-n}function i(r){var e;var n=c(r);var t=n[0];var o=n[1];var a=new d(h(r,t,o));var f=0;var i=o>0?t-4:t;var u;for(u=0;u>16&255;a[f++]=e>>8&255;a[f++]=e&255}if(o===2){e=v[r.charCodeAt(u)]<<2|v[r.charCodeAt(u+1)]>>4;a[f++]=e&255}if(o===1){e=v[r.charCodeAt(u)]<<10|v[r.charCodeAt(u+1)]<<4|v[r.charCodeAt(u+2)]>>2;a[f++]=e>>8&255;a[f++]=e&255}return a}function s(r){return u[r>>18&63]+u[r>>12&63]+u[r>>6&63]+u[r&63]}function l(r,e,n){var t;var o=[];for(var a=e;ai?i:f+a))}if(t===1){e=r[n-1];o.push(u[e>>2]+u[e<<4&63]+"==")}else if(t===2){e=(r[n-2]<<8)+r[n-1];o.push(u[e>>10]+u[e>>4&63]+u[e<<2&63]+"=")}return o.join("")}},{}]},{},[])("/")});
2 |
--------------------------------------------------------------------------------
/kagi/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.contrib.auth import REDIRECT_FIELD_NAME
3 | from django.http import HttpResponseRedirect
4 | from django.urls import reverse
5 | from django.utils.translation import gettext as _
6 |
7 | from .models import WebAuthnKey
8 |
9 |
10 | def make_login_view(view_class):
11 | def login(self, request, extra_context=None):
12 | """
13 | Displays the login form for the given HttpRequest.
14 | """
15 | if request.method == "GET" and self.has_permission(request):
16 | # Already logged-in, redirect to admin index
17 | index_path = reverse("admin:index", current_app=self.name)
18 | return HttpResponseRedirect(index_path)
19 |
20 | # Since this module gets imported in the application's root package,
21 | # it cannot import models from other applications at the module level,
22 | # and django.contrib.admin.forms eventually imports User.
23 | from django.contrib.admin.forms import AdminAuthenticationForm
24 |
25 | context = dict(
26 | self.each_context(request),
27 | title=_("Log in"),
28 | app_path=request.get_full_path(),
29 | username=request.user.get_username(),
30 | )
31 | if (
32 | REDIRECT_FIELD_NAME not in request.GET
33 | and REDIRECT_FIELD_NAME not in request.POST
34 | ):
35 | context[REDIRECT_FIELD_NAME] = reverse("admin:index", current_app=self.name)
36 | context.update(extra_context or {})
37 |
38 | defaults = {
39 | "extra_context": context,
40 | "authentication_form": self.login_form or AdminAuthenticationForm,
41 | "template_name": self.login_template or "admin/login.html",
42 | }
43 | request.current_app = self.name
44 | return view_class.as_view(**defaults)(request)
45 |
46 | return login
47 |
48 |
49 | def monkeypatch_admin(view_class=None):
50 | if view_class is None:
51 | from kagi.views.login import KagiLoginView
52 |
53 | view_class = KagiLoginView
54 |
55 | from django.contrib.admin.sites import AdminSite
56 |
57 | AdminSite.login = make_login_view(view_class)
58 |
59 |
60 | @admin.register(WebAuthnKey)
61 | class WebAuthnKeyAdmin(admin.ModelAdmin):
62 | pass
63 |
--------------------------------------------------------------------------------
/kagi/management/commands/addbackupcode.py:
--------------------------------------------------------------------------------
1 | """
2 | This is inspired by addstatictoken in django-otp, which is licensed as follows:
3 |
4 | Copyright (c) 2012, Peter Sagerson
5 | All rights reserved.
6 |
7 | Redistribution and use in source and binary forms, with or without
8 | modification, are permitted provided that the following conditions are met:
9 |
10 | - Redistributions of source code must retain the above copyright notice, this
11 | list of conditions and the following disclaimer.
12 |
13 | - Redistributions in binary form must reproduce the above copyright notice,
14 | this list of conditions and the following disclaimer in the documentation
15 | and/or other materials provided with the distribution.
16 |
17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
21 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
22 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
23 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
24 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
25 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27 | """
28 |
29 | from django.contrib.auth import get_user_model
30 | from django.core.management.base import BaseCommand, CommandError
31 |
32 |
33 | class Command(BaseCommand):
34 | help = "Adds a single backup code to the given user."
35 |
36 | def add_arguments(self, parser):
37 | parser.add_argument("username")
38 | parser.add_argument(
39 | "--code",
40 | default=None,
41 | help="The code to add. If omitted, one will be randomly generated.",
42 | )
43 |
44 | def handle(self, *args, **options):
45 | user = get_user_model().objects.get_by_natural_key(options["username"])
46 | try:
47 | backupcode_obj = user.backup_codes.create_backup_code(options["code"])
48 | except Exception:
49 | raise CommandError("This code already exists.")
50 | print(backupcode_obj.code, file=self.stdout)
51 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "kagi"
3 | version = "0.4.0"
4 | description = "Django app for WebAuthn and TOTP-based multi-factor authentication"
5 | authors = [
6 | "Justin Mayer ",
7 | "Rémy Hubscher ",
8 | ]
9 | license = "BSD-2-Clause"
10 | readme = "README.rst"
11 | keywords = ["Django", "WebAuthn", "authentication", "MFA", "2FA"]
12 |
13 | repository = "https://github.com/justinmayer/kagi"
14 | documentation = "https://kagi.readthedocs.io"
15 |
16 | classifiers = [
17 | "Development Status :: 4 - Beta",
18 | "Environment :: Web Environment",
19 | "Framework :: Django",
20 | "Framework :: Django :: 4.2",
21 | "Framework :: Django :: 5.0",
22 | "Intended Audience :: Developers",
23 | "License :: OSI Approved :: BSD License",
24 | "Operating System :: OS Independent",
25 | # "Programming Language :: Python :: ..." is auto-generated by poetry!
26 | "Topic :: Security",
27 | "Topic :: Security :: Cryptography",
28 | "Topic :: Software Development :: Libraries :: Python Modules",
29 | ]
30 |
31 | [tool.poetry.urls]
32 | "Issue Tracker" = "https://github.com/justinmayer/kagi/issues"
33 |
34 | [tool.poetry.dependencies]
35 | Django = ">= 2.2"
36 | python = ">= 3.8.1, < 4.0"
37 | qrcode = ">= 6.1, < 8.0"
38 | webauthn = "^1.6.0"
39 |
40 | [tool.poetry.group.dev.dependencies]
41 | black = "^24.4"
42 | flake8 = "^7.0"
43 | Flake8-pyproject = "^1.2.3"
44 | furo = "2024.04.27"
45 | invoke = "^2.0"
46 | isort = "^5.13"
47 | pretend = "^1.0.9"
48 | psutil = { version = "^5.7", optional = true }
49 | pyOpenSSL = "^24.1"
50 | pytest = "^8.2"
51 | pytest-cov = "^5.0"
52 | pytest-django = "^4.0"
53 | pytest-sugar = "^1.0"
54 | pytest-xdist = "^3.6"
55 | sphinx = "^6.0"
56 |
57 | [tool.autopub]
58 | project-name = "Kagi"
59 | git-username = "botpub"
60 | git-email = "52496925+botpub@users.noreply.github.com"
61 | append-github-contributor = true
62 |
63 | [tool.isort]
64 | profile = "black"
65 | combine_as_imports = true
66 |
67 | # Sort imports within their section independent of the import type
68 | force_sort_within_sections = true
69 |
70 | # Designate `django` and `kagi` as separate sections
71 | known_django = "django"
72 | known_kagi = "kagi"
73 |
74 | sections = "FUTURE,STDLIB,DJANGO,THIRDPARTY,KAGI,FIRSTPARTY,LOCALFOLDER"
75 |
76 | # Skip isort checks on these directories and files
77 | skip_glob = "**/migrations/**,**/node_modules/**,kagi/static/**,static,*.json,*.json"
78 |
79 | [build-system]
80 | requires = ["poetry-core>=1.0.0"]
81 | build-backend = "poetry.core.masonry.api"
82 |
83 | [tool.pytest.ini_options]
84 | filterwarnings = [
85 | 'ignore::DeprecationWarning:invoke.loader',
86 | 'ignore::DeprecationWarning:invoke.tasks',
87 | ]
88 | pythonpath = 'testproj'
89 | DJANGO_SETTINGS_MODULE = 'testproj.settings'
90 |
91 | [tool.flake8]
92 | ignore = ['E203', 'E501', 'W503']
93 | max-line-length = 88
94 |
--------------------------------------------------------------------------------
/kagi/models.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import hmac
3 | import string
4 |
5 | from django.conf import settings
6 | from django.db import IntegrityError, models, transaction
7 | from django.utils import timezone
8 | from django.utils.crypto import get_random_string
9 |
10 | from .oath import T, totp
11 |
12 |
13 | class WebAuthnKey(models.Model):
14 | user = models.ForeignKey(
15 | settings.AUTH_USER_MODEL, related_name="webauthn_keys", on_delete=models.CASCADE
16 | )
17 | created_at = models.DateTimeField(auto_now_add=True)
18 | last_used_at = models.DateTimeField(blank=True, null=True)
19 |
20 | key_name = models.CharField(max_length=64)
21 | public_key = models.TextField(unique=True)
22 | credential_id = models.TextField(unique=True)
23 | sign_count = models.IntegerField()
24 |
25 | def __str__(self):
26 | return f"{self.user} - {self.key_name}"
27 |
28 |
29 | class BackupCodeManager(models.Manager):
30 | def create_backup_code(self, code=None):
31 | if code is not None:
32 | return self.create(code=code)
33 |
34 | while True:
35 | try:
36 | with transaction.atomic():
37 | code = get_random_string(length=6, allowed_chars=string.digits)
38 | return self.create(code=code)
39 | except IntegrityError:
40 | pass
41 |
42 |
43 | class BackupCode(models.Model):
44 | user = models.ForeignKey(
45 | settings.AUTH_USER_MODEL, related_name="backup_codes", on_delete=models.CASCADE
46 | )
47 | code = models.CharField(max_length=8)
48 |
49 | class Meta:
50 | unique_together = [("user", "code")]
51 |
52 | objects = BackupCodeManager()
53 |
54 |
55 | class TOTPDevice(models.Model):
56 | user = models.ForeignKey(
57 | settings.AUTH_USER_MODEL, related_name="totp_devices", on_delete=models.CASCADE
58 | )
59 | created_at = models.DateTimeField(auto_now_add=True)
60 | last_used_at = models.DateTimeField(null=True)
61 |
62 | key = models.BinaryField()
63 | # the T value of the most recently-used token. This prevents using the same
64 | # token twice.
65 | last_t = models.PositiveIntegerField(null=True)
66 |
67 | def validate_token(self, token):
68 | step = datetime.timedelta(seconds=30)
69 | now = timezone.now()
70 | # the number of time intervals on either side to check
71 | slop = 1
72 |
73 | times_to_check = [now + i * step for i in range(-slop, slop + 1)]
74 | # prevent using the same token twice
75 | if self.last_t is not None:
76 | times_to_check = [t for t in times_to_check if T(t) > self.last_t]
77 |
78 | token = str(token)
79 | for t in times_to_check:
80 | # BinaryField can be a MemoryView, so make sure to send bytes to hmac.
81 | if hmac.compare_digest(totp(bytes(self.key), t), token):
82 | self.last_t = T(t)
83 | return True
84 | return False
85 |
--------------------------------------------------------------------------------
/CONTRIBUTING.rst:
--------------------------------------------------------------------------------
1 | Contributing
2 | ============
3 |
4 | We welcome your contributions to Kagi and strive to make it as easy as possible
5 | to participate.
6 |
7 | Quick Set-up
8 | ------------
9 |
10 | First, install Poetry_::
11 |
12 | curl -sSL https://install.python-poetry.org/ | python -
13 |
14 | Go to the `Kagi repository`_ on GitHub and tap the **Fork** button at top-right.
15 | Then clone the source for your fork and add the upstream project as a Git remote::
16 |
17 | git clone https://github.com/YOUR_USERNAME/kagi.git
18 | cd kagi
19 | git remote add upstream https://github.com/justinmayer/kagi.git
20 |
21 | Install dependencies and set up the project::
22 |
23 | poetry install
24 | poetry shell
25 | invoke setup
26 |
27 | Your local environment should now be ready to go!
28 |
29 | Detailed Set-up
30 | ---------------
31 |
32 | .. highlight:: none
33 |
34 | The first step is to install Poetry_::
35 |
36 | curl -sSL https://install.python-poetry.org/ | python -
37 |
38 | Next, install Pre-commit_. Here we will install Pipx_ and use it to install Pre-commit_::
39 |
40 | python3 -m pip install --user pipx
41 | python3 -m pipx ensurepath
42 | pipx install pre-commit
43 |
44 | Tell Pre-commit_ where to store its Git hooks, such as ``~/.local/share/git/templates``.
45 | This only needs to be done once per workstation, so if you have already run these
46 | commands for another project, you can skip this step::
47 |
48 | mkdir -p ~/.local/share/git/templates
49 | git config --global init.templateDir ~/.local/share/git/templates
50 | pre-commit init-templatedir ~/.local/share/git/templates
51 |
52 | Go to the `Kagi repository`_ on GitHub and tap the **Fork** button at top-right.
53 | Then clone the source for your fork and add the upstream project as a Git remote::
54 |
55 | git clone https://github.com/YOUR_USERNAME/kagi.git
56 | cd kagi
57 | git remote add upstream https://github.com/justinmayer/kagi.git
58 |
59 | Install the Pre-commit_ hooks::
60 |
61 | pre-commit install
62 |
63 | **(optional)** Poetry will automatically create a virtual environment for you but
64 | will alternatively use an already-activated environment if you prefer to create
65 | and activate your virtual environments manually::
66 |
67 | python3 -m venv ~/virtualenvs/kagi
68 | source ~/virtualenvs/kagi/bin/activate
69 |
70 | Install Kagi and its dependencies via Poetry_::
71 |
72 | poetry install
73 |
74 | Your local environment should now be ready to go. Use the following command to
75 | run the test suite (you can omit ``poetry shell`` if you manually created and
76 | activated a virtual environment via the optional step above)::
77 |
78 | poetry shell
79 | invoke tests
80 |
81 | You can speed up test runs via the following command, replacing ``4`` with your
82 | workstation’s CPU core count::
83 |
84 | PYTEST_ADDOPTS="-n 4" invoke tests
85 |
86 | .. Links
87 |
88 | .. _`Kagi repository`: https://github.com/justinmayer/kagi
89 | .. _Pipx: https://pipxproject.github.io/pipx/installation/
90 | .. _Poetry: https://poetry.eustace.io/docs/#installation
91 | .. _Pre-commit: https://pre-commit.com/
92 |
--------------------------------------------------------------------------------
/docs/community.rst:
--------------------------------------------------------------------------------
1 | Community
2 | ---------
3 |
4 | You can check out Kagi on GitHub at: https://github.com/justinmayer/kagi
5 |
6 | .. _communication_channels:
7 |
8 | Communication channels
9 | ----------------------
10 |
11 | * Don't hesitate to `create issues `_
12 | on the GitHub repository.
13 |
14 | .. _how-to-contribute:
15 |
16 | How to contribute
17 | -----------------
18 |
19 | Thanks for your interest in contributing to *Kagi*!
20 |
21 | .. note::
22 |
23 | We love community feedback and are glad to review contributions of any
24 | size — from typos in the documentation to critical bug fixes — so don't be
25 | shy!
26 |
27 | Report bugs
28 | :::::::::::
29 |
30 | Report bugs at https://github.com/justinmayer/kagi/issues/new
31 |
32 | If you are reporting a bug, please include:
33 |
34 | * Any details about your local setup that might be helpful in troubleshooting.
35 | * Detailed steps to reproduce the bug.
36 |
37 | Fix bugs
38 | ::::::::
39 |
40 | Check out the `open issues `_ - anything
41 | tagged with the |good-first-issue label|_ could be a good choice for newcomers.
42 |
43 | .. |good-first-issue label| replace:: **good-first-issue** label
44 | .. _`good-first-issue label`: https://github.com/justinmayer/kagi/labels/good%20first%20issue
45 |
46 |
47 | Implement features
48 | ::::::::::::::::::
49 |
50 | Look through the GitHub issues for features. Anything tagged with |enhancement|_
51 | is open to whoever wants to implement it.
52 |
53 | .. |enhancement| replace:: **enhancement**
54 | .. _enhancement: https://github.com/justinmayer/kagi/labels/enhancement
55 |
56 | Write documentation
57 | :::::::::::::::::::
58 |
59 | *Kagi* could always use more documentation, whether as part of the
60 | official docs, in docstrings, or even on the Web in blog posts,
61 | articles, and such.
62 |
63 | This official documentation is maintained in `GitHub `_.
64 | The ``docs`` folder contains the documentation sources in `reStructuredText `_ format. And you can generate the docs locally with::
65 |
66 | invoke docs
67 |
68 | Output is written at ``docs/_build/html/index.html``.
69 |
70 | We obviously accept pull requests for this documentation, just as we accept them
71 | for bug fixes and features! See :GitHub:`open issues `.
72 |
73 |
74 | Submit feedback
75 | :::::::::::::::
76 |
77 | Any issue with the |question label|_ is open for feedback, so feel free to
78 | share your thoughts with us!
79 |
80 | .. |question label| replace:: **question** label
81 | .. _`question label`:
82 |
83 | The best way to send feedback is to
84 | `file a new issue `_ on GitHub.
85 |
86 | If you are proposing a feature:
87 |
88 | * Explain how you envision it working. Try to be as detailed as you can.
89 | * Try to keep the scope as narrow as possible. This will help make it easier
90 | to implement.
91 | * Feel free to include any code you might already have, even if it's just a
92 | rough idea. This is a volunteer-driven project, and contributions
93 | are welcome. :)
94 |
95 | .. include:: ../CONTRIBUTING.rst
96 |
--------------------------------------------------------------------------------
/kagi/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 2.2.4 on 2019-08-08 12:43
2 |
3 | from django.conf import settings
4 | from django.db import migrations, models
5 | import django.db.models.deletion
6 |
7 |
8 | class Migration(migrations.Migration):
9 | initial = True
10 |
11 | dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
12 |
13 | operations = [
14 | migrations.CreateModel(
15 | name="TOTPDevice",
16 | fields=[
17 | (
18 | "id",
19 | models.AutoField(
20 | auto_created=True,
21 | primary_key=True,
22 | serialize=False,
23 | verbose_name="ID",
24 | ),
25 | ),
26 | ("created_at", models.DateTimeField(auto_now_add=True)),
27 | ("last_used_at", models.DateTimeField(null=True)),
28 | ("key", models.BinaryField()),
29 | ("last_t", models.PositiveIntegerField(null=True)),
30 | (
31 | "user",
32 | models.ForeignKey(
33 | on_delete=django.db.models.deletion.CASCADE,
34 | related_name="totp_devices",
35 | to=settings.AUTH_USER_MODEL,
36 | ),
37 | ),
38 | ],
39 | ),
40 | migrations.CreateModel(
41 | name="BackupCode",
42 | fields=[
43 | (
44 | "id",
45 | models.AutoField(
46 | auto_created=True,
47 | primary_key=True,
48 | serialize=False,
49 | verbose_name="ID",
50 | ),
51 | ),
52 | ("code", models.CharField(max_length=8)),
53 | (
54 | "user",
55 | models.ForeignKey(
56 | on_delete=django.db.models.deletion.CASCADE,
57 | related_name="backup_codes",
58 | to=settings.AUTH_USER_MODEL,
59 | ),
60 | ),
61 | ],
62 | options={"unique_together": {("user", "code")}},
63 | ),
64 | migrations.CreateModel(
65 | name="WebAuthnKey",
66 | fields=[
67 | (
68 | "id",
69 | models.AutoField(
70 | auto_created=True,
71 | primary_key=True,
72 | serialize=False,
73 | verbose_name="ID",
74 | ),
75 | ),
76 | ("created_at", models.DateTimeField(auto_now_add=True)),
77 | ("last_used_at", models.DateTimeField(blank=True, null=True)),
78 | ("key_name", models.CharField(max_length=64)),
79 | ("public_key", models.TextField(unique=True)),
80 | ("ukey", models.TextField(unique=True)),
81 | ("credential_id", models.TextField(unique=True)),
82 | ("sign_count", models.IntegerField()),
83 | (
84 | "user",
85 | models.ForeignKey(
86 | on_delete=django.db.models.deletion.CASCADE,
87 | related_name="webauthn_keys",
88 | to=settings.AUTH_USER_MODEL,
89 | ),
90 | ),
91 | ],
92 | ),
93 | ]
94 |
--------------------------------------------------------------------------------
/tasks.py:
--------------------------------------------------------------------------------
1 | import os
2 | from pathlib import Path
3 | from shutil import which
4 |
5 | from invoke import task
6 | from invoke.util import cd
7 |
8 | DEMO_PORT = os.environ.get("DEMO_PORT", 8000)
9 | DOCS_PORT = os.environ.get("DOCS_PORT", 8000)
10 | BIN_DIR = "bin" if os.name != "nt" else "Scripts"
11 | PTY = True if os.name != "nt" else False
12 |
13 | ACTIVE_VENV = os.environ.get("VIRTUAL_ENV", None)
14 | VENV_HOME = Path(os.environ.get("WORKON_HOME", "~/.local/share/virtualenvs"))
15 | VENV_PATH = Path(ACTIVE_VENV) if ACTIVE_VENV else (VENV_HOME / "kagi")
16 | VENV = str(VENV_PATH.expanduser())
17 | VENV_BIN = Path(VENV) / Path(BIN_DIR)
18 |
19 | TOOLS = ["poetry", "pre-commit"]
20 | POETRY = which("poetry") if which("poetry") else (VENV / Path("bin") / "poetry")
21 | PRECOMMIT = (
22 | which("pre-commit") if which("pre-commit") else (VENV / Path("bin") / "pre-commit")
23 | )
24 |
25 |
26 | @task
27 | def docs(c):
28 | """Build documentation"""
29 | c.run(f"{VENV_BIN}/sphinx-build docs docs/_build", pty=PTY)
30 |
31 |
32 | @task(docs)
33 | def viewdocs(c):
34 | """Serve docs at http://localhost:$DOCS_PORT/ (default port is 8000)"""
35 | from livereload import Server
36 |
37 | server = Server()
38 | server.watch("docs/conf.py", lambda: docs(c))
39 | server.watch("CONTRIBUTING.rst", lambda: docs(c))
40 | server.watch("docs/*.rst", lambda: docs(c))
41 | server.serve(port=DOCS_PORT, root="docs/_build")
42 |
43 |
44 | @task
45 | def serve(c):
46 | """Serve demo site at https://localhost:$DEMO_PORT/ (default port is 8000)"""
47 | with cd("testproj"):
48 | c.run(f"{VENV_BIN}/python manage.py runserver {DEMO_PORT} ", pty=PTY)
49 |
50 |
51 | @task
52 | def tests(c):
53 | """Run the test suite"""
54 | c.run(
55 | f"{VENV_BIN}/pytest -s --doctest-modules --cov-report term-missing "
56 | "--cov-fail-under 100 --cov kagi",
57 | pty=PTY,
58 | )
59 |
60 |
61 | @task
62 | def makemigrations(c):
63 | """Create database migrations if needed"""
64 | with cd("testproj"):
65 | c.run(f"{VENV_BIN}/python manage.py makemigrations", pty=PTY)
66 |
67 |
68 | @task
69 | def migrate(c):
70 | """Migrate database to current schema"""
71 | with cd("testproj"):
72 | c.run(f"{VENV_BIN}/python manage.py migrate", pty=PTY)
73 |
74 |
75 | @task
76 | def black(c, check=False, diff=False):
77 | """Run Black auto-formatter, optionally with --check or --diff"""
78 | diff_flag, check_flag = "", ""
79 | if check:
80 | check_flag = "--check"
81 | if diff:
82 | diff_flag = "--diff"
83 | c.run(f"{VENV_BIN}/black {check_flag} {diff_flag} kagi testproj tasks.py", pty=PTY)
84 |
85 |
86 | @task
87 | def isort(c, check=False):
88 | check_flag = ""
89 | if check:
90 | check_flag = "-c"
91 | c.run(f"{VENV_BIN}/isort {check_flag} .", pty=PTY)
92 |
93 |
94 | @task
95 | def flake8(c):
96 | c.run(f"{VENV_BIN}/flake8 kagi testproj tasks.py", pty=PTY)
97 |
98 |
99 | @task
100 | def lint(c):
101 | isort(c, check=True)
102 | black(c, check=True)
103 | flake8(c)
104 |
105 |
106 | @task
107 | def tools(c):
108 | """Install tools in the virtual environment if not already on PATH"""
109 | for tool in TOOLS:
110 | if not which(tool):
111 | c.run(f"{VENV_BIN}/python -m pip install {tool}", pty=PTY)
112 |
113 |
114 | @task
115 | def precommit(c):
116 | """Install pre-commit hooks to .git/hooks/pre-commit"""
117 | c.run(f"{PRECOMMIT} install", pty=PTY)
118 |
119 |
120 | @task
121 | def setup(c):
122 | c.run(f"{VENV_BIN}/python -m pip install -U pip", pty=PTY)
123 | tools(c)
124 | c.run(f"{POETRY} install", pty=PTY)
125 | precommit(c)
126 |
--------------------------------------------------------------------------------
/testproj/testproj/settings.py:
--------------------------------------------------------------------------------
1 | """
2 | Django settings for testproj project.
3 |
4 | Generated by 'django-admin startproject' using Django 2.2.4.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/2.2/topics/settings/
8 |
9 | For the full list of settings and their values, see
10 | https://docs.djangoproject.com/en/2.2/ref/settings/
11 | """
12 |
13 | import os
14 |
15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
17 |
18 |
19 | # Quick-start development settings - unsuitable for production
20 | # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
21 |
22 | # SECURITY WARNING: keep the secret key used in production secret!
23 | SECRET_KEY = "#)yzhyb+q0jde(m$!fs8@hd4+$g9u#+pki93motwqd57ts1&as"
24 |
25 | # SECURITY WARNING: don't run with debug turned on in production!
26 | DEBUG = True
27 |
28 | ALLOWED_HOSTS = []
29 |
30 |
31 | # Application definition
32 |
33 | INSTALLED_APPS = [
34 | "django.contrib.admin",
35 | "django.contrib.auth",
36 | "django.contrib.contenttypes",
37 | "django.contrib.sessions",
38 | "django.contrib.messages",
39 | "django.contrib.staticfiles",
40 | "kagi",
41 | ]
42 |
43 | MIDDLEWARE = [
44 | "django.middleware.security.SecurityMiddleware",
45 | "django.contrib.sessions.middleware.SessionMiddleware",
46 | "django.middleware.common.CommonMiddleware",
47 | "django.middleware.csrf.CsrfViewMiddleware",
48 | "django.contrib.auth.middleware.AuthenticationMiddleware",
49 | "django.contrib.messages.middleware.MessageMiddleware",
50 | "django.middleware.clickjacking.XFrameOptionsMiddleware",
51 | ]
52 |
53 | ROOT_URLCONF = "testproj.urls"
54 |
55 | TEMPLATES = [
56 | {
57 | "BACKEND": "django.template.backends.django.DjangoTemplates",
58 | "DIRS": [os.path.join(BASE_DIR, "templates")],
59 | "APP_DIRS": True,
60 | "OPTIONS": {
61 | "context_processors": [
62 | "django.template.context_processors.debug",
63 | "django.template.context_processors.request",
64 | "django.contrib.auth.context_processors.auth",
65 | "django.contrib.messages.context_processors.messages",
66 | ]
67 | },
68 | }
69 | ]
70 |
71 | WSGI_APPLICATION = "testproj.wsgi.application"
72 |
73 |
74 | # Database
75 | # https://docs.djangoproject.com/en/2.2/ref/settings/#databases
76 |
77 | DATABASES = {
78 | "default": {
79 | "ENGINE": "django.db.backends.sqlite3",
80 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"),
81 | }
82 | }
83 |
84 |
85 | # Password validation
86 | # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
87 |
88 | AUTH_PASSWORD_VALIDATORS = [
89 | {
90 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
91 | },
92 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
93 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
94 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
95 | ]
96 |
97 |
98 | # Internationalization
99 | # https://docs.djangoproject.com/en/2.2/topics/i18n/
100 |
101 | LANGUAGE_CODE = "en-us"
102 |
103 | TIME_ZONE = "UTC"
104 |
105 | USE_I18N = True
106 |
107 | USE_L10N = True
108 |
109 | USE_TZ = True
110 |
111 |
112 | # Static files (CSS, JavaScript, Images)
113 | # https://docs.djangoproject.com/en/2.2/howto/static-files/
114 |
115 | STATIC_URL = "/static/"
116 |
117 |
118 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
119 |
120 | LOGIN_REDIRECT_URL = "kagi:two-factor-settings"
121 | LOGIN_URL = "kagi:login"
122 |
123 | INTERNAL_IPS = ["127.0.0.1"]
124 |
125 | RELYING_PARTY_ID = "localhost"
126 | RELYING_PARTY_NAME = "Kagi Test Project"
127 |
128 | # WEBAUTHN_TRUSTED_CERTIFICATES = os.path.join(
129 | # BASE_DIR, "..", "trusted_attestation_roots"
130 | # )
131 | # WEBAUTHN_TRUSTED_ATTESTATION_CERT_REQUIRED = False
132 | # WEBAUTHN_SELF_ATTESTATION_PERMITTED = False
133 | # WEBAUTHN_NONE_ATTESTATION_PERMITTED = False
134 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: build
2 |
3 | on: [push, pull_request]
4 |
5 | env:
6 | PYTEST_ADDOPTS: "--color=yes"
7 |
8 | jobs:
9 | test:
10 | name: Test - Python ${{ matrix.python-version }} - Django ${{ matrix.django-version }}
11 | runs-on: ubuntu-latest
12 |
13 | strategy:
14 | fail-fast: false
15 | matrix:
16 | python-version:
17 | - "3.9"
18 | - "3.10"
19 | - "3.11"
20 | django-version:
21 | - "4.2"
22 | - "5.0"
23 | exclude:
24 | # Django 5.0 is compatible with Python >= 3.10
25 | - python-version: "3.9"
26 | django-version: "5.0"
27 |
28 | steps:
29 | - uses: actions/checkout@v4
30 | - name: Set up Python ${{ matrix.python-version }}
31 | uses: actions/setup-python@v5
32 | with:
33 | python-version: ${{ matrix.python-version }}
34 | - name: Set up Pip cache
35 | uses: actions/cache@v4
36 | id: pip-cache
37 | with:
38 | path: ~/.cache/pip
39 | key: pip-${{ hashFiles('**/pyproject.toml') }}
40 | - name: Upgrade Pip
41 | run: python -m pip install --upgrade pip
42 | - name: Install Poetry
43 | run: python -m pip install poetry
44 | - name: Set up Poetry cache
45 | uses: actions/cache@v4
46 | id: poetry-cache
47 | with:
48 | path: ~/.cache/pypoetry/virtualenvs
49 | key: poetry-${{ hashFiles('**/poetry.lock') }}
50 | - name: Install Django
51 | run: |
52 | poetry run pip install --upgrade pip
53 | poetry run pip install "Django~=${{ matrix.django-version }}.0"
54 | - name: Python and Django versions
55 | run: |
56 | poetry run echo "Python ${{ matrix.python-version }} -> Django ${{ matrix.django-version }}"
57 | poetry run python --version
58 | poetry run echo "Django `django-admin --version`"
59 | - name: Install dependencies
60 | run: |
61 | poetry install
62 | - name: Run tests
63 | run: poetry run invoke tests
64 |
65 |
66 | lint:
67 | name: Lint
68 | runs-on: ubuntu-latest
69 |
70 | steps:
71 | - uses: actions/checkout@v4
72 | - name: Set up Python
73 | uses: actions/setup-python@v5
74 | with:
75 | python-version: "3.10"
76 | - name: Set Poetry cache
77 | uses: actions/cache@v4
78 | id: poetry-cache
79 | with:
80 | path: ~/.cache/pypoetry/virtualenvs
81 | key: poetry-${{ hashFiles('**/poetry.lock') }}
82 | - name: Upgrade Pip
83 | run: python -m pip install --upgrade pip
84 | - name: Install Poetry
85 | run: python -m pip install poetry
86 | - name: Install dependencies
87 | run: |
88 | poetry run pip install --upgrade pip
89 | poetry install
90 | - name: Run linters
91 | run: poetry run invoke lint
92 |
93 |
94 | deploy:
95 | name: Deploy
96 | environment: Deployment
97 | needs: [test, lint]
98 | runs-on: ubuntu-latest
99 | if: ${{ github.ref=='refs/heads/main' && github.event_name!='pull_request' }}
100 |
101 | permissions:
102 | contents: write
103 | id-token: write
104 |
105 | steps:
106 | - uses: actions/checkout@v4
107 | with:
108 | token: ${{ secrets.GH_TOKEN }}
109 | - name: Set up Python
110 | uses: actions/setup-python@v5
111 | with:
112 | python-version: "3.10"
113 | - name: Check release
114 | id: check_release
115 | run: |
116 | python -m pip install --upgrade pip
117 | python -m pip install autopub[github]
118 | autopub check
119 | - name: Publish
120 | if: ${{ steps.check_release.outputs.autopub_release=='true' }}
121 | env:
122 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
123 | run: |
124 | autopub prepare
125 | autopub commit
126 | autopub build
127 | autopub githubrelease
128 | - name: Upload package to PyPI
129 | if: ${{ steps.check_release.outputs.autopub_release=='true' }}
130 | uses: pypa/gh-action-pypi-publish@release/v1
131 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | Kagi
2 | ====
3 |
4 | |coc| |build-status| |coverage| |readthedocs| |pypi|
5 |
6 |
7 | .. |coc| image:: https://img.shields.io/badge/%E2%9D%A4-code%20of%20conduct-blue.svg
8 | :target: https://github.com/justinmayer/kagi/blob/master/CODE_OF_CONDUCT.rst
9 | :alt: Code of Conduct
10 |
11 | .. |build-status| image:: https://img.shields.io/github/actions/workflow/status/justinmayer/kagi/main.yml?branch=main
12 | :target: https://github.com/justinmayer/kagi/actions
13 | :alt: Build Status
14 |
15 | .. |coverage| image:: https://img.shields.io/badge/coverage-100%25-brightgreen
16 | :target: https://github.com/justinmayer/kagi
17 | :alt: Code Coverage
18 |
19 | .. |readthedocs| image:: https://readthedocs.org/projects/kagi/badge/?version=latest
20 | :target: https://kagi.readthedocs.io/en/latest/
21 | :alt: Documentation Status
22 |
23 | .. |pypi| image:: https://img.shields.io/pypi/v/kagi.svg
24 | :target: https://pypi.org/project/kagi/
25 | :alt: PyPI Version
26 |
27 |
28 | Kagi provides support for FIDO WebAuthn security keys and TOTP tokens in Django.
29 |
30 | Kagi is a relatively young project and has not yet been fully battle-tested.
31 | Its use in a high-impact environment should be accompanied by a thorough
32 | understanding of how it works before relying on it.
33 |
34 | `Full documentation is hosted on Read the Docs`_.
35 |
36 | Installation
37 | ------------
38 |
39 | ::
40 |
41 | python -m pip install kagi
42 |
43 | Add ``kagi`` to ``INSTALLED_APPS`` and include ``kagi.urls`` somewhere in your
44 | URL patterns. Set: ``LOGIN_URL = "kagi:login"``
45 |
46 | Make sure that Django’s built-in login view does not have a
47 | ``urlpattern``, because it will authenticate users without their second
48 | factor. Kagi provides its own login view to handle that.
49 |
50 | Demo
51 | ----
52 |
53 | To see a demo, use the test project included in this repository and perform the
54 | following steps (creating and activating a virtual environment first is optional).
55 |
56 | First, install Poetry_::
57 |
58 | curl -sSL https://install.python-poetry.org/ | python -
59 |
60 | Clone the Kagi source code and switch to its directory::
61 |
62 | git clone https://github.com/justinmayer/kagi.git && cd kagi
63 |
64 | Install dependencies, run database migrations, create a user, and serve the demo::
65 |
66 | poetry install
67 | poetry shell
68 | invoke migrate
69 | python testproj/manage.py createsuperuser
70 | invoke serve
71 |
72 | You should now be able to see the demo project login page in your browser at:
73 | http://localhost:8000/kagi/login
74 |
75 | Supported browsers and versions can be found here: https://caniuse.com/webauthn
76 | For domains other than ``localhost``, WebAuthn requires that the site is served
77 | over a secure (HTTPS) connection.
78 |
79 | Since you haven’t added any security keys yet, you will be logged in with just a
80 | username and password. Once logged in and on the multi-factor settings page,
81 | choose “Manage WebAuthn keys” and then “Add another key” and follow the provided
82 | instructions. Once WebAuthn and/or TOTP has been successfully configured, your
83 | account will be protected by multi-factor authentication, and when you log in
84 | the next time, your WebAuthn key or TOTP token will be required.
85 |
86 | You can manage the keys attached to your account on the key management page at:
87 | http://localhost:8000/kagi/keys
88 |
89 |
90 | Using WebAuthn Keys on Linux
91 | ============================
92 |
93 | Some distros don’t come with udev rules to make USB HID /dev/
94 | nodes accessible to normal users. If your key doesn’t light up
95 | and start flashing when you expect it to, this might be what is
96 | happening. See https://github.com/Yubico/libu2f-host/issues/2 and
97 | https://github.com/Yubico/libu2f-host/blob/master/70-u2f.rules for some
98 | discussion of the rule to make it accessible. If you just want a quick
99 | temporary fix, you can run ``sudo chmod 666 /dev/hidraw*`` every time
100 | after you plug in your key (the files disappear after unplugging).
101 |
102 |
103 | Gratitude
104 | =========
105 |
106 | This project would not exist without the significant contributions made by
107 | `Rémy HUBSCHER `_.
108 |
109 | Thanks to Gavin Wahl for `django-u2f `_,
110 | which served as useful initial scaffolding for this project.
111 |
112 |
113 | .. _Poetry: https://python-poetry.org/docs/#installation
114 | .. _Full documentation is hosted on Read the Docs: https://kagi.readthedocs.io
115 |
--------------------------------------------------------------------------------
/kagi/tests/test_backups_code.py:
--------------------------------------------------------------------------------
1 | from io import StringIO
2 | from unittest import mock
3 |
4 | from django.contrib.auth.models import User
5 | from django.core.management import CommandError, call_command
6 | from django.urls import reverse
7 |
8 | import pytest
9 |
10 | from ..models import BackupCode
11 |
12 |
13 | def test_list_backup_codes(admin_client):
14 | response = admin_client.get(reverse("kagi:backup-codes"))
15 | assert list(response.context_data["backupcode_list"]) == []
16 | assert response.status_code == 200
17 |
18 |
19 | def test_add_new_backup_codes(admin_client):
20 | response = admin_client.post(reverse("kagi:backup-codes"))
21 | assert response.status_code == 302
22 | response = admin_client.get(reverse("kagi:backup-codes"))
23 | assert len(response.context_data["backupcode_list"]) == 10
24 |
25 |
26 | @pytest.mark.django_db
27 | def test_addbackupcode_command():
28 | User.objects.create(username="admin", password="admin", email="john.doe@kagi.com")
29 | assert BackupCode.objects.count() == 0
30 | stdout = StringIO()
31 | call_command("addbackupcode", "admin", stdout=stdout)
32 | assert len(stdout.getvalue()) == 8
33 | assert BackupCode.objects.count() == 1
34 |
35 |
36 | @pytest.mark.django_db
37 | def test_addbackupcode_command_can_use_a_specific_code():
38 | User.objects.create(username="admin", password="admin", email="john.doe@kagi.com")
39 | assert BackupCode.objects.count() == 0
40 | call_command("addbackupcode", "admin", "--code", "123456", stdout=StringIO())
41 | assert BackupCode.objects.count() == 1
42 |
43 |
44 | @pytest.mark.django_db
45 | def test_addbackupcode_command_refuse_to_create_twice_the_same_code():
46 | User.objects.create(username="admin", password="admin", email="john.doe@kagi.com")
47 | assert BackupCode.objects.count() == 0
48 | call_command("addbackupcode", "admin", stdout=StringIO())
49 | code = BackupCode.objects.get().code
50 | with pytest.raises(CommandError):
51 | call_command("addbackupcode", "admin", "--code", code)
52 |
53 |
54 | @pytest.mark.django_db
55 | def test_backup_code_manager_handles_code_duplication():
56 | user = User.objects.create(
57 | username="admin", password="admin", email="john.doe@kagi.com"
58 | )
59 | assert BackupCode.objects.count() == 0
60 | with mock.patch(
61 | "kagi.models.get_random_string", side_effect=["123456", "123456", "45678"]
62 | ) as mocked:
63 | user.backup_codes.create_backup_code()
64 | user.backup_codes.create_backup_code()
65 |
66 | assert mocked.call_count == 3
67 | assert BackupCode.objects.count() == 2
68 |
69 |
70 | @pytest.mark.django_db
71 | def test_a_user_with_backup_codes_and_no_mfa_is_not_asked_for_its_code(client):
72 | # We need to create a couple of WebAuthnKey for our user.
73 | user = User.objects.create_user("admin", "john.doe@kagi.com", "admin")
74 | user.backup_codes.create_backup_code()
75 | response = client.post(
76 | reverse("kagi:login"), {"username": "admin", "password": "admin"}
77 | )
78 | assert response.status_code == 302
79 | assert response.url == reverse("kagi:two-factor-settings")
80 |
81 | # Are we truly logged in?
82 | response = client.get(reverse("kagi:two-factor-settings"))
83 | assert response.status_code == 200
84 |
85 |
86 | @pytest.mark.django_db
87 | def test_a_user_with_mfa_can_use_a_backup_code(client):
88 | # We need to create a couple of WebAuthnKey for our user.
89 | user = User.objects.create_user("admin", "john.doe@kagi.com", "admin")
90 | user.totp_devices.create()
91 | user.backup_codes.create_backup_code(code="123456")
92 | response = client.post(
93 | reverse("kagi:login"), {"username": "admin", "password": "admin"}
94 | )
95 | assert response.status_code == 302
96 | assert response.url == reverse("kagi:verify-second-factor")
97 |
98 | response = client.post(
99 | reverse("kagi:verify-second-factor"), {"type": "backup", "code": "123456"}
100 | )
101 | assert response.status_code == 302
102 | assert response.url == reverse("kagi:two-factor-settings")
103 |
104 | # Check that the used code has been removed.
105 | assert BackupCode.objects.count() == 0
106 |
107 | # Are we truly logged in?
108 | response = client.get(reverse("kagi:two-factor-settings"))
109 | assert response.status_code == 200
110 |
111 |
112 | @pytest.mark.django_db
113 | def test_a_user_cannot_login_with_a_wrong_backup_code(client):
114 | # We need to create a couple of WebAuthnKey for our user.
115 | user = User.objects.create_user("admin", "john.doe@kagi.com", "admin")
116 | user.totp_devices.create()
117 | user.backup_codes.create_backup_code(code="123456")
118 | response = client.post(
119 | reverse("kagi:login"), {"username": "admin", "password": "admin"}
120 | )
121 | assert response.status_code == 302
122 | assert response.url == reverse("kagi:verify-second-factor")
123 |
124 | response = client.post(
125 | reverse("kagi:verify-second-factor"), {"type": "backup", "code": "213456"}
126 | )
127 | assert response.status_code == 200
128 | assert response.context_data["forms"]["backup"].errors == {
129 | "code": ["That is not a valid backup code."]
130 | }
131 |
--------------------------------------------------------------------------------
/kagi/views/totp_devices.py:
--------------------------------------------------------------------------------
1 | from base64 import b32decode, b32encode
2 | from collections import OrderedDict
3 | from io import BytesIO
4 | import os
5 | from urllib.parse import quote
6 |
7 | from django.contrib import messages
8 | from django.contrib.sites.shortcuts import get_current_site
9 | from django.http import HttpResponseRedirect
10 | from django.shortcuts import get_object_or_404, redirect
11 | from django.urls import reverse, reverse_lazy
12 | from django.utils.http import url_has_allowed_host_and_scheme, urlencode
13 | from django.utils.translation import gettext as _
14 | from django.views.generic import FormView, ListView
15 |
16 | import qrcode
17 | from qrcode.image.svg import SvgPathFillImage
18 |
19 | from ..constants import SESSION_TOTP_SECRET_KEY
20 | from ..forms import TOTPForm
21 | from ..models import TOTPDevice
22 | from .mixin import OriginMixin
23 |
24 |
25 | class AddTOTPDeviceView(OriginMixin, FormView):
26 | form_class = TOTPForm
27 | template_name = "kagi/totp_device.html"
28 | success_url = reverse_lazy("kagi:totp-devices")
29 |
30 | def get(self, request, *args: str, **kwargs):
31 | # When opening the view with a GET request, we treat it as a "add new
32 | # device" request. There, we create a new TOTP secret and put it into
33 | # the current user's session. Upon POST, the secret is read from the
34 | # session again.
35 | # Once a new TOTP device was successfully added, we'll drop the secret
36 | # from the session.
37 | # This approach allows to re-enter the token if mistyped, while keeping
38 | # the same TOTP device setup on the TOTP generator.
39 | self.secret = self.gen_secret()
40 | request.session[SESSION_TOTP_SECRET_KEY] = self.secret
41 | return super().get(request, *args, **kwargs)
42 |
43 | def post(self, request, *args: str, **kwargs):
44 | # Try to get the TOTP secret from the session. If the secret doesn't
45 | # exist, redirect to the view again, to configure a new TOTP secret.
46 | self.secret = request.session.get(SESSION_TOTP_SECRET_KEY, None)
47 | if not self.secret:
48 | messages.error(request, _("Missing TOTP secret. Please try again."))
49 | return redirect(request.path)
50 |
51 | return super().post(request, *args, **kwargs)
52 |
53 | def gen_secret(self):
54 | return b32encode(os.urandom(20)).decode()
55 |
56 | def get_otpauth_url(self, secret):
57 | issuer = get_current_site(self.request).name
58 |
59 | params = OrderedDict([("secret", secret), ("digits", 6), ("issuer", issuer)])
60 |
61 | return "otpauth://totp/{issuer}:{username}?{params}".format(
62 | issuer=quote(issuer),
63 | username=quote(self.request.user.get_username()),
64 | params=urlencode(params),
65 | )
66 |
67 | def get_qrcode(self, data):
68 | img = qrcode.make(data, image_factory=SvgPathFillImage)
69 | buf = BytesIO()
70 | img.save(buf)
71 | return buf.getvalue().decode("utf-8")
72 |
73 | def get_context_data(self, **kwargs):
74 | kwargs = super().get_context_data(**kwargs)
75 | kwargs["base32_key"] = self.secret
76 | kwargs["otpauth"] = self.get_otpauth_url(self.secret)
77 | kwargs["qr_svg"] = self.get_qrcode(kwargs["otpauth"])
78 | return kwargs
79 |
80 | def get_form_kwargs(self):
81 | kwargs = super().get_form_kwargs()
82 | kwargs.update(
83 | user=self.request.user, request=self.request, appId=self.get_origin()
84 | )
85 | return kwargs
86 |
87 | def form_valid(self, form):
88 | device = TOTPDevice(user=self.request.user, key=b32decode(self.secret))
89 | if device.validate_token(form.cleaned_data["token"]):
90 | del self.request.session[SESSION_TOTP_SECRET_KEY]
91 | device.save()
92 | messages.success(self.request, _("Device added."))
93 | return super().form_valid(form)
94 | else:
95 | assert not device.pk
96 | form.add_error("token", TOTPForm.INVALID_ERROR_MESSAGE)
97 | return self.form_invalid(form)
98 |
99 | def form_invalid(self, form):
100 | # Should this go in Django's FormView?!
101 | #
102 | return self.render_to_response(self.get_context_data(form=form))
103 |
104 | def get_success_url(self):
105 | if "next" in self.request.GET and url_has_allowed_host_and_scheme(
106 | self.request.GET["next"], allowed_hosts=[self.request.get_host()]
107 | ):
108 | return self.request.GET["next"]
109 | else:
110 | return super().get_success_url()
111 |
112 |
113 | class TOTPDeviceManagementView(ListView):
114 | template_name = "kagi/totpdevice_list.html"
115 |
116 | def get_queryset(self):
117 | return self.request.user.totp_devices.all()
118 |
119 | def post(self, request):
120 | assert "delete" in self.request.POST
121 | device = get_object_or_404(
122 | self.get_queryset(), pk=self.request.POST["device_id"]
123 | )
124 | device.delete()
125 | messages.success(request, _("Device removed."))
126 | return HttpResponseRedirect(reverse("kagi:totp-devices"))
127 |
--------------------------------------------------------------------------------
/kagi/views/api.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings as django_settings
2 | from django.contrib import auth
3 | from django.contrib.auth.decorators import login_required
4 | from django.http import JsonResponse
5 | from django.shortcuts import resolve_url
6 | from django.utils.http import url_has_allowed_host_and_scheme
7 | from django.utils.timezone import now
8 | from django.views.decorators.csrf import csrf_exempt
9 | from django.views.decorators.http import require_http_methods
10 |
11 | from webauthn.helpers import base64url_to_bytes, bytes_to_base64url
12 |
13 | from .. import settings, utils
14 | from ..forms import KeyRegistrationForm
15 | from ..models import WebAuthnKey
16 | from ..utils import webauthn
17 |
18 | # Registration
19 |
20 |
21 | @login_required
22 | @require_http_methods(["GET"])
23 | def webauthn_begin_activate(request):
24 | challenge = webauthn.generate_webauthn_challenge()
25 |
26 | request.session["challenge"] = bytes_to_base64url(challenge)
27 |
28 | credential_options = webauthn.get_credential_options(
29 | request.user,
30 | challenge=challenge,
31 | rp_name=settings.RELYING_PARTY_NAME,
32 | rp_id=settings.RELYING_PARTY_ID,
33 | )
34 |
35 | return JsonResponse(credential_options)
36 |
37 |
38 | @login_required
39 | @csrf_exempt
40 | @require_http_methods(["POST"])
41 | def webauthn_verify_credential_info(request):
42 | challenge = base64url_to_bytes(request.session["challenge"])
43 | credentials = request.POST["credentials"]
44 |
45 | form = KeyRegistrationForm(request.POST)
46 |
47 | if not form.is_valid():
48 | return JsonResponse({"errors": form.errors}, status=400)
49 |
50 | try:
51 | webauthn_registration_response = webauthn.verify_registration_response(
52 | credentials,
53 | rp_id=settings.RELYING_PARTY_ID,
54 | origin=utils.get_origin(request),
55 | challenge=challenge,
56 | )
57 | except webauthn.RegistrationRejectedError as e:
58 | return JsonResponse({"fail": f"Registration failed. Error: {e}"}, status=400)
59 |
60 | # W3C spec. Step 17.
61 | #
62 | # Check that the credentialId is not yet registered to any other user.
63 | # If registration is requested for a credential that is already registered
64 | # to a different user, the Relying Party SHOULD fail this registration
65 | # ceremony, or it MAY decide to accept the registration, e.g. while deleting
66 | # the older registration.
67 | credential_id_exists = WebAuthnKey.objects.filter(
68 | credential_id=bytes_to_base64url(webauthn_registration_response.credential_id)
69 | ).first()
70 | if credential_id_exists:
71 | return JsonResponse({"fail": "Credential ID already exists."}, status=400)
72 |
73 | WebAuthnKey.objects.create(
74 | user=request.user,
75 | key_name=form.cleaned_data["key_name"],
76 | public_key=bytes_to_base64url(
77 | webauthn_registration_response.credential_public_key
78 | ),
79 | credential_id=bytes_to_base64url(webauthn_registration_response.credential_id),
80 | sign_count=webauthn_registration_response.sign_count,
81 | )
82 |
83 | try:
84 | del request.session["challenge"]
85 | del request.session["key_name"]
86 | except KeyError: # pragma: no cover
87 | pass
88 |
89 | return JsonResponse({"success": "User successfully registered."})
90 |
91 |
92 | # Login
93 | @require_http_methods(["GET"])
94 | def webauthn_begin_assertion(request):
95 | challenge = webauthn.generate_webauthn_challenge()
96 | request.session["challenge"] = bytes_to_base64url(challenge)
97 |
98 | user = utils.get_user(request)
99 |
100 | webauthn_assertion_options = webauthn.get_assertion_options(
101 | user, challenge=challenge, rp_id=settings.RELYING_PARTY_ID
102 | )
103 |
104 | return JsonResponse(webauthn_assertion_options)
105 |
106 |
107 | @csrf_exempt
108 | @require_http_methods(["POST"])
109 | def webauthn_verify_assertion(request):
110 | challenge = base64url_to_bytes(request.session.get("challenge"))
111 |
112 | user = utils.get_user(request)
113 |
114 | try:
115 | webauthn_assertion_response = webauthn.verify_assertion_response(
116 | request.POST["credentials"],
117 | challenge=challenge,
118 | user=user,
119 | origin=utils.get_origin(request),
120 | rp_id=settings.RELYING_PARTY_ID,
121 | )
122 | except webauthn.AuthenticationRejectedError as e:
123 | return JsonResponse({"fail": f"Assertion failed. Error: {e}"}, status=400)
124 |
125 | # Update counter.
126 | key = user.webauthn_keys.get(
127 | credential_id=bytes_to_base64url(webauthn_assertion_response.credential_id)
128 | )
129 | key.sign_count = webauthn_assertion_response.new_sign_count
130 | key.last_used_at = now()
131 | key.save()
132 |
133 | try:
134 | del request.session["kagi_pre_verify_user_pk"]
135 | del request.session["kagi_pre_verify_user_backend"]
136 | del request.session["challenge"]
137 | except KeyError: # pragma: no cover
138 | pass
139 |
140 | auth.login(request, user)
141 |
142 | redirect_to = request.POST.get(
143 | auth.REDIRECT_FIELD_NAME, request.GET.get(auth.REDIRECT_FIELD_NAME, "")
144 | )
145 | if not url_has_allowed_host_and_scheme(
146 | url=redirect_to, allowed_hosts=[request.get_host()]
147 | ):
148 | redirect_to = resolve_url(django_settings.LOGIN_REDIRECT_URL)
149 |
150 | return JsonResponse(
151 | {
152 | "success": f"Successfully authenticated as {user.get_username()}",
153 | "redirect_to": redirect_to,
154 | }
155 | )
156 |
--------------------------------------------------------------------------------
/kagi/views/login.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.contrib import auth
3 | from django.contrib.auth import load_backend
4 | from django.contrib.auth.forms import AuthenticationForm
5 | from django.contrib.auth.views import LoginView
6 | from django.http import HttpResponseRedirect
7 | from django.shortcuts import resolve_url
8 | from django.urls import reverse
9 | from django.utils.http import url_has_allowed_host_and_scheme, urlencode
10 | from django.views.generic import TemplateView
11 |
12 | from ..forms import BackupCodeForm, SecondFactorForm, TOTPForm
13 | from .mixin import OriginMixin
14 |
15 |
16 | class KagiLoginView(LoginView):
17 | form_class = AuthenticationForm
18 | template_name = "kagi/login.html"
19 |
20 | @property
21 | def is_admin(self):
22 | return self.template_name == "admin/login.html"
23 |
24 | def requires_two_factor(self, user):
25 | return user.webauthn_keys.exists() or user.totp_devices.exists()
26 |
27 | def form_valid(self, form):
28 | user = form.get_user()
29 | if not self.requires_two_factor(user):
30 | # no keys registered, use single-factor auth
31 | return super().form_valid(form)
32 | else:
33 | self.request.session["kagi_pre_verify_user_pk"] = user.pk
34 | self.request.session["kagi_pre_verify_user_backend"] = user.backend
35 |
36 | verify_url = reverse("kagi:verify-second-factor")
37 | redirect_to = self.request.POST.get(
38 | auth.REDIRECT_FIELD_NAME,
39 | self.request.GET.get(auth.REDIRECT_FIELD_NAME, ""),
40 | )
41 | params = {}
42 | if url_has_allowed_host_and_scheme(
43 | url=redirect_to,
44 | allowed_hosts=[self.request.get_host()],
45 | require_https=True,
46 | ):
47 | params[auth.REDIRECT_FIELD_NAME] = redirect_to
48 | if self.is_admin:
49 | params["admin"] = 1
50 | if params:
51 | verify_url += "?" + urlencode(params)
52 |
53 | return HttpResponseRedirect(verify_url)
54 |
55 | def get_context_data(self, **kwargs):
56 | kwargs = super().get_context_data(**kwargs)
57 | kwargs[auth.REDIRECT_FIELD_NAME] = self.request.GET.get(
58 | auth.REDIRECT_FIELD_NAME, ""
59 | )
60 | kwargs.update(self.kwargs.get("extra_context", {}))
61 | return kwargs
62 |
63 |
64 | class VerifySecondFactorView(OriginMixin, TemplateView):
65 | template_name = "kagi/verify_second_factor.html"
66 |
67 | @property
68 | def form_classes(self):
69 | ret = {}
70 | if self.user.webauthn_keys.exists():
71 | ret["webauthn"] = SecondFactorForm
72 | if self.user.backup_codes.exists():
73 | ret["backup"] = BackupCodeForm
74 | if self.user.totp_devices.exists():
75 | ret["totp"] = TOTPForm
76 |
77 | return ret
78 |
79 | def get_user(self):
80 | try:
81 | user_id = self.request.session["kagi_pre_verify_user_pk"]
82 | backend_path = self.request.session["kagi_pre_verify_user_backend"]
83 | assert backend_path in settings.AUTHENTICATION_BACKENDS
84 | backend = load_backend(backend_path)
85 | user = backend.get_user(user_id)
86 | if user is not None:
87 | user.backend = backend_path
88 | return user
89 | except (KeyError, AssertionError):
90 | return None
91 |
92 | def dispatch(self, request, *args, **kwargs):
93 | self.user = self.get_user()
94 | if self.user is None:
95 | return HttpResponseRedirect(reverse("kagi:login"))
96 | return super().dispatch(request, *args, **kwargs)
97 |
98 | def post(self, request, *args, **kwargs):
99 | forms = self.get_forms()
100 | form = forms[request.POST["type"]]
101 | if form.is_valid() and form.validate_second_factor():
102 | return self.form_valid(form, forms)
103 | else:
104 | return self.form_invalid(forms)
105 |
106 | def form_invalid(self, forms):
107 | return self.render_to_response(self.get_context_data(forms=forms))
108 |
109 | def get_form_kwargs(self):
110 | return {"user": self.user, "request": self.request, "appId": self.get_origin()}
111 |
112 | def get_forms(self):
113 | kwargs = self.get_form_kwargs()
114 | if self.request.method == "GET":
115 | forms = {key: form(**kwargs) for key, form in self.form_classes.items()}
116 | else:
117 | method = self.request.POST["type"]
118 | forms = {
119 | key: form(**kwargs)
120 | for key, form in self.form_classes.items()
121 | if key != method
122 | }
123 | forms[method] = self.form_classes[method](self.request.POST, **kwargs)
124 | return forms
125 |
126 | def get_context_data(self, **kwargs):
127 | if "forms" not in kwargs:
128 | kwargs["forms"] = self.get_forms()
129 | kwargs = super().get_context_data(**kwargs)
130 | if self.request.GET.get("admin"):
131 | kwargs["base_template"] = "admin/base_site.html"
132 | else:
133 | kwargs["base_template"] = "base.html"
134 | kwargs["user"] = self.user
135 | return kwargs
136 |
137 | def form_valid(self, form, forms):
138 | del self.request.session["kagi_pre_verify_user_pk"]
139 | del self.request.session["kagi_pre_verify_user_backend"]
140 |
141 | auth.login(self.request, self.user)
142 |
143 | redirect_to = self.request.POST.get(
144 | auth.REDIRECT_FIELD_NAME, self.request.GET.get(auth.REDIRECT_FIELD_NAME, "")
145 | )
146 | if not url_has_allowed_host_and_scheme(
147 | url=redirect_to, allowed_hosts=[self.request.get_host()]
148 | ):
149 | redirect_to = resolve_url(settings.LOGIN_REDIRECT_URL)
150 | return HttpResponseRedirect(redirect_to)
151 |
--------------------------------------------------------------------------------
/kagi/utils/webauthn.py:
--------------------------------------------------------------------------------
1 | # Licensed under the Apache License, Version 2.0 (the "License");
2 | # you may not use this file except in compliance with the License.
3 | # You may obtain a copy of the License at
4 | #
5 | # http://www.apache.org/licenses/LICENSE-2.0
6 | #
7 | # Unless required by applicable law or agreed to in writing, software
8 | # distributed under the License is distributed on an "AS IS" BASIS,
9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10 | # See the License for the specific language governing permissions and
11 | # limitations under the License.
12 | #
13 | # Origin: https://github.com/pypi/warehouse
14 |
15 | import base64
16 | import json
17 |
18 | import webauthn as pywebauthn
19 | from webauthn.helpers import base64url_to_bytes, generate_challenge
20 | from webauthn.helpers.exceptions import (
21 | InvalidAuthenticationResponse,
22 | InvalidRegistrationResponse,
23 | )
24 | from webauthn.helpers.options_to_json import options_to_json
25 | from webauthn.helpers.structs import (
26 | AttestationConveyancePreference,
27 | AuthenticationCredential,
28 | AuthenticatorSelectionCriteria,
29 | AuthenticatorTransport,
30 | PublicKeyCredentialDescriptor,
31 | RegistrationCredential,
32 | UserVerificationRequirement,
33 | )
34 |
35 |
36 | class AuthenticationRejectedError(Exception):
37 | pass
38 |
39 |
40 | class RegistrationRejectedError(Exception):
41 | pass
42 |
43 |
44 | def _get_webauthn_user_public_key_credential_descriptors(user, *, rp_id):
45 | """
46 | Returns a webauthn.WebAuthnUser instance corresponding
47 | to the given user model, with properties suitable for
48 | usage within the webauthn API.
49 | """
50 | return [
51 | PublicKeyCredentialDescriptor(
52 | id=base64url_to_bytes(credential.credential_id),
53 | transports=[
54 | AuthenticatorTransport.USB,
55 | AuthenticatorTransport.NFC,
56 | AuthenticatorTransport.BLE,
57 | AuthenticatorTransport.INTERNAL,
58 | ],
59 | )
60 | for credential in user.webauthn_keys.all()
61 | ]
62 |
63 |
64 | def _get_webauthn_user_public_keys(user, *, rp_id):
65 | return [
66 | (
67 | base64url_to_bytes(credential.public_key),
68 | credential.sign_count,
69 | )
70 | for credential in user.webauthn_keys.all()
71 | ]
72 |
73 |
74 | def _webauthn_b64encode(source):
75 | return base64.urlsafe_b64encode(source).rstrip(b"=")
76 |
77 |
78 | def generate_webauthn_challenge():
79 | """
80 | Returns a random challenge suitable for use within
81 | Webauthn's credential and configuration option objects.
82 |
83 | See: https://w3c.github.io/webauthn/#cryptographic-challenges
84 | """
85 | return generate_challenge()
86 |
87 |
88 | def get_credential_options(user, *, challenge, rp_name, rp_id):
89 | """
90 | Returns a dictionary of options for credential creation
91 | on the client side.
92 | """
93 | _authenticator_selection = AuthenticatorSelectionCriteria()
94 | _authenticator_selection.user_verification = UserVerificationRequirement.DISCOURAGED
95 | options = pywebauthn.generate_registration_options(
96 | rp_id=rp_id,
97 | rp_name=rp_name,
98 | user_id=str(user.id),
99 | user_name=user.get_username(),
100 | user_display_name=user.get_full_name(),
101 | challenge=challenge,
102 | attestation=AttestationConveyancePreference.NONE,
103 | authenticator_selection=_authenticator_selection,
104 | )
105 | return json.loads(options_to_json(options))
106 |
107 |
108 | def get_assertion_options(user, *, challenge, rp_id):
109 | """
110 | Returns a dictionary of options for assertion retrieval
111 | on the client side.
112 | """
113 | options = pywebauthn.generate_authentication_options(
114 | rp_id=rp_id,
115 | challenge=challenge,
116 | allow_credentials=_get_webauthn_user_public_key_credential_descriptors(
117 | user, rp_id=rp_id
118 | ),
119 | user_verification=UserVerificationRequirement.DISCOURAGED,
120 | )
121 | return json.loads(options_to_json(options))
122 |
123 |
124 | def verify_registration_response(response, challenge, *, rp_id, origin):
125 | """
126 | Validates the challenge and attestation information
127 | sent from the client during device registration.
128 |
129 | Returns a WebAuthnCredential on success.
130 | Raises RegistrationRejectedError on failire.
131 | """
132 | # NOTE: We re-encode the challenge below, because our
133 | # response's clientData.challenge is encoded twice:
134 | # first for the entire clientData payload, and then again
135 | # for the individual challenge.
136 | encoded_challenge = _webauthn_b64encode(challenge)
137 | try:
138 | _credential = RegistrationCredential.parse_raw(response)
139 | return pywebauthn.verify_registration_response(
140 | credential=_credential,
141 | expected_challenge=encoded_challenge,
142 | expected_rp_id=rp_id,
143 | expected_origin=origin,
144 | require_user_verification=False,
145 | )
146 | except InvalidRegistrationResponse as e:
147 | raise RegistrationRejectedError(str(e))
148 |
149 |
150 | def verify_assertion_response(assertion, *, challenge, user, origin, rp_id):
151 | """
152 | Validates the challenge and assertion information
153 | sent from the client during authentication.
154 |
155 | Returns an updated signage count on success.
156 | Raises AuthenticationRejectedError on failure.
157 | """
158 | # NOTE: We re-encode the challenge below, because our
159 | # response's clientData.challenge is encoded twice:
160 | # first for the entire clientData payload, and then again
161 | # for the individual challenge.
162 | encoded_challenge = _webauthn_b64encode(challenge)
163 | webauthn_user_public_keys = _get_webauthn_user_public_keys(user, rp_id=rp_id)
164 |
165 | for public_key, current_sign_count in webauthn_user_public_keys:
166 | try:
167 | _credential = AuthenticationCredential.parse_raw(assertion)
168 | return pywebauthn.verify_authentication_response(
169 | credential=_credential,
170 | expected_challenge=encoded_challenge,
171 | expected_rp_id=rp_id,
172 | expected_origin=origin,
173 | credential_public_key=public_key,
174 | credential_current_sign_count=current_sign_count,
175 | require_user_verification=False,
176 | )
177 | except InvalidAuthenticationResponse:
178 | pass
179 |
180 | # If we exit the loop, then we've failed to verify the assertion against
181 | # any of the user's WebAuthn credentials. Fail.
182 | raise AuthenticationRejectedError("Invalid WebAuthn credential")
183 |
--------------------------------------------------------------------------------
/kagi/tests/test_webauthn.py:
--------------------------------------------------------------------------------
1 | # Licensed under the Apache License, Version 2.0 (the "License");
2 | # you may not use this file except in compliance with the License.
3 | # You may obtain a copy of the License at
4 | #
5 | # http://www.apache.org/licenses/LICENSE-2.0
6 | #
7 | # Unless required by applicable law or agreed to in writing, software
8 | # distributed under the License is distributed on an "AS IS" BASIS,
9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10 | # See the License for the specific language governing permissions and
11 | # limitations under the License.
12 | #
13 | # Origin: https://github.com/pypi/warehouse
14 |
15 | import pretend
16 | import pytest
17 | import webauthn as pywebauthn
18 | from webauthn.authentication.verify_authentication_response import (
19 | VerifiedAuthentication,
20 | )
21 | from webauthn.helpers import base64url_to_bytes, bytes_to_base64url
22 | from webauthn.helpers.structs import (
23 | AttestationFormat,
24 | AuthenticationCredential,
25 | AuthenticatorAssertionResponse,
26 | AuthenticatorAttestationResponse,
27 | PublicKeyCredentialType,
28 | RegistrationCredential,
29 | )
30 | from webauthn.registration.verify_registration_response import VerifiedRegistration
31 |
32 | import kagi.utils.webauthn as webauthn
33 |
34 |
35 | def test_generate_webauthn_challenge():
36 | challenge = webauthn.generate_webauthn_challenge()
37 |
38 | assert isinstance(challenge, bytes)
39 | assert challenge == base64url_to_bytes(bytes_to_base64url(challenge))
40 |
41 |
42 | def test_verify_registration_response(monkeypatch):
43 | fake_verified_registration = VerifiedRegistration(
44 | credential_id=b"foo",
45 | credential_public_key=b"bar",
46 | sign_count=0,
47 | aaguid="wutang",
48 | fmt=AttestationFormat.NONE,
49 | credential_type=PublicKeyCredentialType.PUBLIC_KEY,
50 | user_verified=False,
51 | attestation_object=b"foobar",
52 | credential_device_type="single_device",
53 | credential_backed_up=False,
54 | )
55 | mock_verify_registration_response = pretend.call_recorder(
56 | lambda *a, **kw: fake_verified_registration
57 | )
58 | monkeypatch.setattr(
59 | pywebauthn, "verify_registration_response", mock_verify_registration_response
60 | )
61 |
62 | resp = webauthn.verify_registration_response(
63 | (
64 | '{"id": "foo", "rawId": "foo", "response": '
65 | '{"attestationObject": "foo", "clientDataJSON": "bar"}}'
66 | ),
67 | b"not_a_real_challenge",
68 | rp_id="fake_rp_id",
69 | origin="fake_origin",
70 | )
71 |
72 | assert mock_verify_registration_response.calls == [
73 | pretend.call(
74 | credential=RegistrationCredential(
75 | id="foo",
76 | raw_id=b"~\x8a",
77 | response=AuthenticatorAttestationResponse(
78 | client_data_json=b"m\xaa", attestation_object=b"~\x8a"
79 | ),
80 | transports=None,
81 | type=PublicKeyCredentialType.PUBLIC_KEY,
82 | ),
83 | expected_challenge=bytes_to_base64url(b"not_a_real_challenge").encode(),
84 | expected_rp_id="fake_rp_id",
85 | expected_origin="fake_origin",
86 | require_user_verification=False,
87 | )
88 | ]
89 | assert resp == fake_verified_registration
90 |
91 |
92 | def test_verify_registration_response_failure(monkeypatch):
93 | monkeypatch.setattr(
94 | pywebauthn,
95 | "verify_registration_response",
96 | pretend.raiser(pywebauthn.helpers.exceptions.InvalidRegistrationResponse),
97 | )
98 |
99 | with pytest.raises(webauthn.RegistrationRejectedError):
100 | webauthn.verify_registration_response(
101 | (
102 | '{"id": "foo", "rawId": "foo", "response": '
103 | '{"attestationObject": "foo", "clientDataJSON": "bar"}}'
104 | ),
105 | b"not_a_real_challenge",
106 | rp_id="fake_rp_id",
107 | origin="fake_origin",
108 | )
109 |
110 |
111 | def test_verify_assertion_response(monkeypatch):
112 | fake_verified_authentication = VerifiedAuthentication(
113 | credential_id=b"a credential id",
114 | new_sign_count=69,
115 | credential_device_type="single_device",
116 | credential_backed_up=False,
117 | )
118 | mock_verify_authentication_response = pretend.call_recorder(
119 | lambda *a, **kw: fake_verified_authentication
120 | )
121 | monkeypatch.setattr(
122 | pywebauthn,
123 | "verify_authentication_response",
124 | mock_verify_authentication_response,
125 | )
126 |
127 | not_a_real_user = pretend.stub(
128 | webauthn_keys=pretend.stub(
129 | all=lambda: [
130 | pretend.stub(
131 | public_key=bytes_to_base64url(b"fake public key"), sign_count=68
132 | )
133 | ]
134 | )
135 | )
136 | resp = webauthn.verify_assertion_response(
137 | (
138 | '{"id": "foo", "rawId": "foo", "response": '
139 | '{"authenticatorData": "foo", "clientDataJSON": "bar", '
140 | '"signature": "wutang"}}'
141 | ),
142 | challenge=b"not_a_real_challenge",
143 | user=not_a_real_user,
144 | origin="fake_origin",
145 | rp_id="fake_rp_id",
146 | )
147 |
148 | assert mock_verify_authentication_response.calls == [
149 | pretend.call(
150 | credential=AuthenticationCredential(
151 | id="foo",
152 | raw_id=b"~\x8a",
153 | response=AuthenticatorAssertionResponse(
154 | client_data_json=b"m\xaa",
155 | authenticator_data=b"~\x8a",
156 | signature=b"\xc2\xebZ\x9e",
157 | user_handle=None,
158 | ),
159 | type=PublicKeyCredentialType.PUBLIC_KEY,
160 | ),
161 | expected_challenge=b"bm90X2FfcmVhbF9jaGFsbGVuZ2U",
162 | expected_rp_id="fake_rp_id",
163 | expected_origin="fake_origin",
164 | credential_public_key=b"fake public key",
165 | credential_current_sign_count=68,
166 | require_user_verification=False,
167 | )
168 | ]
169 | assert resp == fake_verified_authentication
170 |
171 |
172 | def test_verify_assertion_response_failure(monkeypatch):
173 | monkeypatch.setattr(
174 | pywebauthn,
175 | "verify_authentication_response",
176 | pretend.raiser(pywebauthn.helpers.exceptions.InvalidAuthenticationResponse),
177 | )
178 |
179 | get_webauthn_users = pretend.call_recorder(
180 | lambda *a, **kw: [(b"not a public key", 0)]
181 | )
182 | monkeypatch.setattr(webauthn, "_get_webauthn_user_public_keys", get_webauthn_users)
183 |
184 | with pytest.raises(webauthn.AuthenticationRejectedError):
185 | webauthn.verify_assertion_response(
186 | (
187 | '{"id": "foo", "rawId": "foo", "response": '
188 | '{"authenticatorData": "foo", "clientDataJSON": "bar", '
189 | '"signature": "wutang"}}'
190 | ),
191 | challenge=b"not_a_real_challenge",
192 | user=pretend.stub(),
193 | origin="fake_origin",
194 | rp_id="fake_rp_id",
195 | )
196 |
--------------------------------------------------------------------------------
/kagi/static/kagi/webauthn.js:
--------------------------------------------------------------------------------
1 | /* Licensed under the Apache License, Version 2.0 (the "License");
2 | * you may not use this file except in compliance with the License.
3 | * You may obtain a copy of the License at
4 | *
5 | * http://www.apache.org/licenses/LICENSE-2.0
6 | *
7 | * Unless required by applicable law or agreed to in writing, software
8 | * distributed under the License is distributed on an "AS IS" BASIS,
9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10 | * See the License for the specific language governing permissions and
11 | * limitations under the License.
12 | *
13 | * Origin: https://github.com/pypi/warehouse
14 | */
15 |
16 | const populateWebAuthnErrorList = (errors) => {
17 | const errorList = document.getElementById("webauthn-errors");
18 | if (errorList === null) {
19 | return;
20 | }
21 |
22 | /* NOTE: We only set the alert role once we actually have errors to present,
23 | * to avoid hijacking screenreaders unnecessarily.
24 | */
25 | errorList.setAttribute("role", "alert");
26 |
27 | errors.forEach((error) => {
28 | const errorItem = document.createElement("li");
29 | errorItem.appendChild(document.createTextNode(error));
30 | errorList.appendChild(errorItem);
31 | });
32 | };
33 |
34 | const doWebAuthn = (formId, func) => {
35 | if (!window.PublicKeyCredential) {
36 | return;
37 | }
38 |
39 | const webAuthnForm = document.getElementById(formId);
40 | if (webAuthnForm === null) {
41 | return null;
42 | }
43 |
44 | const webAuthnButton = webAuthnForm.querySelector("button[type=submit]");
45 | webAuthnButton.disabled = false;
46 |
47 | webAuthnForm.addEventListener("submit", async () => {
48 | func(webAuthnButton.value);
49 | event.preventDefault();
50 | });
51 | };
52 |
53 | const webAuthnBtoA = (encoded) => {
54 | return btoa(encoded)
55 | .replace(/\+/g, "-")
56 | .replace(/\//g, "_")
57 | .replace(/=/g, "");
58 | };
59 |
60 | const webAuthnBase64Normalize = (encoded) => {
61 | return encoded.replace(/_/g, "/").replace(/-/g, "+");
62 | };
63 |
64 | const transformAssertionOptions = (assertionOptions) => {
65 | let { challenge, allowCredentials } = assertionOptions;
66 |
67 | challenge = Uint8Array.from(challenge, (c) => c.charCodeAt(0));
68 | allowCredentials = allowCredentials.map((credentialDescriptor) => {
69 | let { id } = credentialDescriptor;
70 | id = webAuthnBase64Normalize(id);
71 | id = Uint8Array.from(atob(id), (c) => c.charCodeAt(0));
72 | return Object.assign({}, credentialDescriptor, { id });
73 | });
74 |
75 | const transformedOptions = Object.assign({}, assertionOptions, {
76 | challenge,
77 | allowCredentials,
78 | });
79 |
80 | return transformedOptions;
81 | };
82 |
83 | const transformAssertion = (assertion) => {
84 | const authData = new Uint8Array(assertion.response.authenticatorData);
85 | const clientDataJSON = new Uint8Array(assertion.response.clientDataJSON);
86 | const rawId = new Uint8Array(assertion.rawId);
87 | const sig = new Uint8Array(assertion.response.signature);
88 | const assertionClientExtensions = assertion.getClientExtensionResults();
89 |
90 | return {
91 | id: assertion.id,
92 | rawId: webAuthnBtoA(String.fromCharCode(...rawId)),
93 | response: {
94 | authenticatorData: webAuthnBtoA(String.fromCharCode(...authData)),
95 | clientDataJSON: webAuthnBtoA(String.fromCharCode(...clientDataJSON)),
96 | signature: webAuthnBtoA(String.fromCharCode(...sig)),
97 | },
98 | type: assertion.type,
99 | assertionClientExtensions: JSON.stringify(assertionClientExtensions),
100 | };
101 | };
102 |
103 | const transformCredentialOptions = (credentialOptions) => {
104 | let { challenge, user } = credentialOptions;
105 | user.id = Uint8Array.from(credentialOptions.user.id, (c) => c.charCodeAt(0));
106 | challenge = Uint8Array.from(credentialOptions.challenge, (c) =>
107 | c.charCodeAt(0)
108 | );
109 |
110 | const transformedOptions = Object.assign({}, credentialOptions, {
111 | challenge,
112 | user,
113 | });
114 |
115 | return transformedOptions;
116 | };
117 |
118 | const transformCredential = (credential) => {
119 | const attObj = new Uint8Array(credential.response.attestationObject);
120 | const clientDataJSON = new Uint8Array(credential.response.clientDataJSON);
121 | const rawId = new Uint8Array(credential.rawId);
122 | const registrationClientExtensions = credential.getClientExtensionResults();
123 |
124 | return {
125 | id: credential.id,
126 | rawId: webAuthnBtoA(String.fromCharCode(...rawId)),
127 | type: credential.type,
128 | response: {
129 | attestationObject: webAuthnBtoA(String.fromCharCode(...attObj)),
130 | clientDataJSON: webAuthnBtoA(String.fromCharCode(...clientDataJSON)),
131 | },
132 | registrationClientExtensions: JSON.stringify(registrationClientExtensions),
133 | };
134 | };
135 |
136 | const postCredential = async (keyName, credential, token) => {
137 | const formData = new FormData();
138 | formData.set("key_name", keyName);
139 | formData.set("credentials", JSON.stringify(credential));
140 | formData.set("csrf_token", token);
141 |
142 | const resp = await fetch(Kagi.verify_credential_info, {
143 | method: "POST",
144 | cache: "no-cache",
145 | body: formData,
146 | credentials: "same-origin",
147 | });
148 |
149 | return await resp.json();
150 | };
151 |
152 | const postAssertion = async (assertion, token) => {
153 | const formData = new FormData();
154 | formData.set("credentials", JSON.stringify(assertion));
155 | formData.set("csrf_token", token);
156 |
157 | const resp = await fetch(Kagi.verify_assertion + window.location.search, {
158 | method: "POST",
159 | cache: "no-cache",
160 | body: formData,
161 | credentials: "same-origin",
162 | });
163 |
164 | return await resp.json();
165 | };
166 |
167 | const GuardWebAuthn = () => {
168 | if (!window.PublicKeyCredential) {
169 | let webauthn_button = document.getElementById("webauthn-button");
170 | if (webauthn_button) {
171 | webauthn_button.className += " button--disabled";
172 | }
173 |
174 | let webauthn_error = document.getElementById("webauthn-browser-support");
175 | if (webauthn_error) {
176 | webauthn_error.style.display = "block";
177 | }
178 |
179 | let webauthn_label = document.getElementById("webauthn-provision-label");
180 | if (webauthn_label) {
181 | webauthn_label.disabled = true;
182 | }
183 | }
184 | };
185 |
186 | const ProvisionWebAuthn = () => {
187 | doWebAuthn("webauthn-provision-form", async (csrfToken) => {
188 | const label = document.getElementById("id_key_name").value;
189 |
190 | const resp = await fetch(Kagi.begin_activate, {
191 | cache: "no-cache",
192 | credentials: "same-origin",
193 | });
194 |
195 | const credentialOptions = await resp.json();
196 | const transformedOptions = transformCredentialOptions(credentialOptions);
197 | await navigator.credentials
198 | .create({
199 | publicKey: transformedOptions,
200 | })
201 | .then(async (credential) => {
202 | const transformedCredential = transformCredential(credential);
203 |
204 | const status = await postCredential(
205 | label,
206 | transformedCredential,
207 | csrfToken
208 | );
209 | if (status.fail) {
210 | populateWebAuthnErrorList(status.fail.errors);
211 | return;
212 | }
213 |
214 | window.location.replace(Kagi.keys_list);
215 | })
216 | .catch((error) => {
217 | console.log(error);
218 | populateWebAuthnErrorList([error.message]);
219 | return;
220 | });
221 | });
222 | };
223 |
224 | const AuthenticateWebAuthn = () => {
225 | doWebAuthn("webauthn-auth-form", async (csrfToken) => {
226 | const resp = await fetch(Kagi.begin_assertion + window.location.search, {
227 | cache: "no-cache",
228 | credentials: "same-origin",
229 | });
230 |
231 | const assertionOptions = await resp.json();
232 | if (assertionOptions.fail) {
233 | window.location.replace("/account/");
234 | return;
235 | }
236 |
237 | const transformedOptions = transformAssertionOptions(assertionOptions);
238 | await navigator.credentials
239 | .get({
240 | publicKey: transformedOptions,
241 | })
242 | .then(async (assertion) => {
243 | const transformedAssertion = transformAssertion(assertion);
244 |
245 | const status = await postAssertion(transformedAssertion, csrfToken);
246 | if (status.fail) {
247 | populateWebAuthnErrorList(status.fail.errors);
248 | return;
249 | }
250 |
251 | window.location.replace(status.redirect_to);
252 | })
253 | .catch((error) => {
254 | populateWebAuthnErrorList([error.message]);
255 | return;
256 | });
257 | });
258 | };
259 |
260 | document.addEventListener("DOMContentLoaded", (e) => {
261 | const registerElement = document.querySelector("#webauthn-provision-form");
262 | if (registerElement) {
263 | ProvisionWebAuthn();
264 | }
265 |
266 | const loginElement = document.querySelector("#webauthn-auth-form");
267 | if (loginElement) {
268 | AuthenticateWebAuthn();
269 | }
270 | // If browser doesn't support WebAuthn, hide related elements and show warning
271 | if (typeof PublicKeyCredential == "undefined") {
272 | var webAuthnFeature = document.getElementById("webauthn-feature");
273 | if (webAuthnFeature) {
274 | webAuthnFeature.style.display = "none";
275 | }
276 | var webAuthnUndefinedError = document.getElementById(
277 | "webauthn-undefined-error"
278 | );
279 | if (webAuthnUndefinedError) {
280 | webAuthnUndefinedError.style.display = "block";
281 | }
282 | }
283 | });
284 |
--------------------------------------------------------------------------------
/kagi/tests/test_totp.py:
--------------------------------------------------------------------------------
1 | import base64
2 | from datetime import datetime
3 | import re
4 |
5 | from django.contrib.auth.models import User
6 | from django.urls import reverse
7 | from django.utils import timezone
8 |
9 | import pytest
10 |
11 | from ..models import TOTPDevice
12 | from ..oath import totp
13 |
14 | base32_regexp = re.compile(
15 | r"^(?:[A-Z2-7]{8})*(?:[A-Z2-7]{2}={6}|[A-Z2-7]{4}={4}|[A-Z2-7]{5}={3}|[A-Z2-7]{7}=)?$"
16 | )
17 |
18 |
19 | def add_new_totp_device(client, *, url=None, now=None):
20 | if url is None:
21 | url = reverse("kagi:add-totp")
22 |
23 | if now is None:
24 | now = timezone.now()
25 |
26 | response = client.get(reverse("kagi:add-totp"))
27 | assert response.status_code == 200
28 |
29 | base32_key = response.context_data["base32_key"]
30 | key = base64.b32decode(base32_key.encode("utf-8"))
31 | token = totp(key, now)
32 | response = client.post(url, {"token": token})
33 | response.token = token
34 | return response
35 |
36 |
37 | def test_list_totp_devices(admin_client):
38 | response = admin_client.get(reverse("kagi:totp-devices"))
39 | assert list(response.context_data["totpdevice_list"]) == []
40 | assert response.status_code == 200
41 |
42 |
43 | def test_add_a_new_totp_device_shows_a_qrcode(admin_client):
44 | response = admin_client.get(reverse("kagi:add-totp"))
45 | assert response.status_code == 200
46 | assert re.match(
47 | r"<\?xml version='1\.0' encoding='UTF-8'\?>\n