Skip to content

Commit

Permalink
Merge branch 'main' into chore/upgrade-webauthn-1.6.0
Browse files Browse the repository at this point in the history
  • Loading branch information
justinmayer committed May 14, 2024
2 parents 4101e13 + 7bc2457 commit b22ca49
Show file tree
Hide file tree
Showing 10 changed files with 141 additions and 50 deletions.
52 changes: 34 additions & 18 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,32 @@ jobs:
- "3.8"
- "3.9"
- "3.10"
- "3.11"
django-version:
- "3.2"
- "4.1"
- "4.2"
exclude:
# Python 3.7 is compatible with Django < 4.0
# Django 3.2 is compatible with Python <= 3.10
- python-version: "3.11"
django-version: "3.2"

# Django 4.1 is compatible with Python >= 3.8
- python-version: "3.7"
django-version: "4.1"

# Django 4.2 is compatible with Python >= 3.8
- python-version: "3.7"
django-version: "4.2"

steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Set up Pip cache
uses: actions/cache@v2
uses: actions/cache@v3
id: pip-cache
with:
path: ~/.cache/pip
Expand All @@ -43,7 +53,7 @@ jobs:
- name: Install Poetry
run: python -m pip install poetry
- name: Set up Poetry cache
uses: actions/cache@v2
uses: actions/cache@v3
id: poetry-cache
with:
path: ~/.cache/pypoetry/virtualenvs
Expand All @@ -69,13 +79,13 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v2
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Set Poetry cache
uses: actions/cache@v2
uses: actions/cache@v3
id: poetry-cache
with:
path: ~/.cache/pypoetry/virtualenvs
Expand All @@ -99,27 +109,33 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.ref=='refs/heads/main' && github.event_name!='pull_request' }}

permissions:
contents: write
id-token: write

steps:
- uses: actions/checkout@v2
- name: Setup Python
uses: actions/setup-python@v2
- uses: actions/checkout@v3
with:
token: ${{ secrets.GH_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.8"
- name: Check release
id: check_release
run: |
python -m pip install --upgrade pip
python -m pip install poetry githubrelease httpx==0.16.1 autopub
echo "##[set-output name=release;]$(autopub check)"
python -m pip install autopub[github]
autopub check
- name: Publish
if: ${{ steps.check_release.outputs.release=='' }}
if: ${{ steps.check_release.outputs.autopub_release=='true' }}
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: |
git remote set-url origin https://$GITHUB_TOKEN@github.com/${{ github.repository }}
autopub prepare
poetry build
autopub commit
autopub build
autopub githubrelease
poetry publish -u __token__ -p $PYPI_PASSWORD
- name: Upload package to PyPI
if: ${{ steps.check_release.outputs.autopub_release=='true' }}
uses: pypa/gh-action-pypi-publish@release/v1
14 changes: 13 additions & 1 deletion .readthedocs.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
---
# Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details

# Required
version: 2
build:
os: "ubuntu-22.04"
tools:
python: "3.11"

# Build documentation in the docs/ directory with Sphinx
sphinx:
configuration: docs/conf.py

# Version of Python and requirements required to build the docs
python:
version: 3.8
install:
- requirements: docs/requirements.txt
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
CHANGELOG
=========

0.4.0 - 2023-06-08
------------------

* Add support for Python 3.11 and Django 4.2, by @MarkusH (#67).
* "Pin" primary keys to `AutoField` so no new migrations are generated for now (#55).
* Properly update `last_used_at` for FIDO tokens, by @MarkusH (#66).
* Improve secret submission security when adding TOTP devices, by @MarkusH (#72).
* Improve QR code display in Django Admin in dark mode, by @evanottinger (#75).
* Publish Kagi via PyPI trusted publisher system, by @apollo13 (#74).

Contributed by [Florian Apolloner](https://github.com/apollo13) via [PR #76](https://github.com/justinmayer/kagi/pull/76/)


0.3.0 - 2022-09-18
------------------

Expand Down
3 changes: 3 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ Kagi is a relatively young project and has not yet been fully battle-tested.
Its use in a high-impact environment should be accompanied by a thorough
understanding of how it works before relying on it.

`Full documentation is hosted on Read the Docs`_.

Installation
------------

Expand Down Expand Up @@ -109,3 +111,4 @@ which served as useful initial scaffolding for this project.


.. _Poetry: https://python-poetry.org/docs/#installation
.. _Full documentation is hosted on Read the Docs: https://kagi.readthedocs.io
1 change: 1 addition & 0 deletions kagi/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
SESSION_TOTP_SECRET_KEY = "kagi_totp_secret"
1 change: 0 additions & 1 deletion kagi/templates/kagi/totp_device.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
<form method="POST">
{% csrf_token %}
{{ form.as_p }}
<input type="hidden" name="base32_key" value="{{ base32_key }}">
<button value="backup" name="type">{% trans 'Submit' %}</button>
</form>

Expand Down
38 changes: 33 additions & 5 deletions kagi/tests/test_totp.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def add_new_totp_device(client, *, url=None, now=None):
base32_key = response.context_data["base32_key"]
key = base64.b32decode(base32_key.encode("utf-8"))
token = totp(key, now)
response = client.post(url, {"base32_key": base32_key, "token": token})
response = client.post(url, {"token": token})
response.token = token
return response

Expand Down Expand Up @@ -61,27 +61,55 @@ def test_add_a_new_totp_device_context_data_contains_the_base32_key_and_otpauth_
)


def test_add_a_new_totp_device_validates_the_otpauth_code_and_change_key_in_case_of_mismatch(
def test_add_a_new_totp_device_validates_the_otpauth_code_but_keeps_secret_in_case_of_mismatch(
admin_client,
):
response = admin_client.get(reverse("kagi:add-totp"))
assert response.status_code == 200
base32_key = response.context_data["base32_key"]
response = admin_client.post(
reverse("kagi:add-totp"), {"base32_key": base32_key, "token": "123456"}
)

# Submit the form once, but with an invalid token. The secret should remain
# the same.
response = admin_client.post(reverse("kagi:add-totp"), {"token": "123456"})
assert response.status_code == 200
assert base32_key == response.context_data["base32_key"]
form = response.context_data["form"]
assert form.errors == {"token": ["That token is invalid."]}

# Submit the form a second time with the correct token. This should work.
key = base64.b32decode(base32_key.encode("utf-8"))
token = totp(key, timezone.now())
response = admin_client.post(reverse("kagi:add-totp"), {"token": token})
assert response.status_code == 302
assert response.url == reverse("kagi:totp-devices")
# Ensure the secret is removed from the user session after adding a device.
assert "kagi_totp_secret" not in admin_client.session

response = admin_client.get(reverse("kagi:totp-devices"))
assert len(response.context_data["totpdevice_list"]) == 1


def test_add_a_new_totp_device_fails_when_secret_is_missing_in_session(admin_client):
response = admin_client.get(reverse("kagi:add-totp"))
assert response.status_code == 200

session = admin_client.session
del session["kagi_totp_secret"]
session.save()

response = admin_client.post(reverse("kagi:add-totp"), {"token": "123456"})
assert response.status_code == 302
assert response.url == reverse("kagi:add-totp")


def test_add_a_new_totp_device_validates_the_otpauth_code_and_create_the_device_if_valid(
admin_client,
):
response = add_new_totp_device(admin_client)
assert response.status_code == 302
assert response.url == reverse("kagi:totp-devices")
# Ensure the secret is removed from the user session after adding a device.
assert "kagi_totp_secret" not in admin_client.session

response = admin_client.get(reverse("kagi:totp-devices"))
assert len(response.context_data["totpdevice_list"]) == 1
Expand Down
2 changes: 1 addition & 1 deletion kagi/views/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ def webauthn_verify_assertion(request):
credential_id=bytes_to_base64url(webauthn_assertion_response.credential_id)
)
key.sign_count = webauthn_assertion_response.new_sign_count
key.last_used = now()
key.last_used_at = now()
key.save()

try:
Expand Down
54 changes: 35 additions & 19 deletions kagi/views/totp_devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@
from django.contrib import messages
from django.contrib.sites.shortcuts import get_current_site
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
from django.utils.functional import cached_property
from django.utils.http import url_has_allowed_host_and_scheme, urlencode
from django.utils.translation import gettext as _
from django.views.generic import FormView, ListView

import qrcode
from qrcode.image.svg import SvgPathImage
from qrcode.image.svg import SvgPathFillImage

from ..constants import SESSION_TOTP_SECRET_KEY
from ..forms import TOTPForm
from ..models import TOTPDevice
from .mixin import OriginMixin
Expand All @@ -27,11 +27,33 @@ class AddTOTPDeviceView(OriginMixin, FormView):
template_name = "kagi/totp_device.html"
success_url = reverse_lazy("kagi:totp-devices")

def gen_key(self):
return os.urandom(20)

def get_otpauth_url(self, key):
secret = b32encode(key)
def get(self, request, *args: str, **kwargs):
# When opening the view with a GET request, we treat it as a "add new
# device" request. There, we create a new TOTP secret and put it into
# the current user's session. Upon POST, the secret is read from the
# session again.
# Once a new TOTP device was successfully added, we'll drop the secret
# from the session.
# This approach allows to re-enter the token if mistyped, while keeping
# the same TOTP device setup on the TOTP generator.
self.secret = self.gen_secret()
request.session[SESSION_TOTP_SECRET_KEY] = self.secret
return super().get(request, *args, **kwargs)

def post(self, request, *args: str, **kwargs):
# Try to get the TOTP secret from the session. If the secret doesn't
# exist, redirect to the view again, to configure a new TOTP secret.
self.secret = request.session.get(SESSION_TOTP_SECRET_KEY, None)
if not self.secret:
messages.error(request, _("Missing TOTP secret. Please try again."))
return redirect(request.path)

return super().post(request, *args, **kwargs)

def gen_secret(self):
return b32encode(os.urandom(20)).decode()

def get_otpauth_url(self, secret):
issuer = get_current_site(self.request).name

params = OrderedDict([("secret", secret), ("digits", 6), ("issuer", issuer)])
Expand All @@ -43,22 +65,15 @@ def get_otpauth_url(self, key):
)

def get_qrcode(self, data):
img = qrcode.make(data, image_factory=SvgPathImage)
img = qrcode.make(data, image_factory=SvgPathFillImage)
buf = BytesIO()
img.save(buf)
return buf.getvalue().decode("utf-8")

@cached_property
def key(self):
try:
return b32decode(self.request.POST["base32_key"])
except KeyError:
return self.gen_key()

def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["base32_key"] = b32encode(self.key).decode()
kwargs["otpauth"] = self.get_otpauth_url(self.key)
kwargs["base32_key"] = self.secret
kwargs["otpauth"] = self.get_otpauth_url(self.secret)
kwargs["qr_svg"] = self.get_qrcode(kwargs["otpauth"])
return kwargs

Expand All @@ -70,8 +85,9 @@ def get_form_kwargs(self):
return kwargs

def form_valid(self, form):
device = TOTPDevice(user=self.request.user, key=self.key)
device = TOTPDevice(user=self.request.user, key=b32decode(self.secret))
if device.validate_token(form.cleaned_data["token"]):
del self.request.session[SESSION_TOTP_SECRET_KEY]
device.save()
messages.success(self.request, _("Device added."))
return super().form_valid(form)
Expand Down
13 changes: 8 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "kagi"
version = "0.3.0"
version = "0.4.0"
description = "Django app for WebAuthn and TOTP-based multi-factor authentication"
authors = [
"Justin Mayer <entroP@gmail.com>",
Expand All @@ -17,10 +17,13 @@ classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Web Environment",
"Framework :: Django",
"Framework :: Django :: 2.2",
"Framework :: Django :: 3.2",
"Framework :: Django :: 4.1",
"Framework :: Django :: 4.2",
"Intended Audience :: Developers",
"License :: OSI Approved :: BSD License",
"Operating System :: OS Independent",
# "Programming Language :: Python :: ..." is auto-generated by poetry!
"Topic :: Security",
"Topic :: Security :: Cryptography",
"Topic :: Software Development :: Libraries :: Python Modules",
Expand All @@ -40,12 +43,12 @@ black = "^23.3"
flake8 = "^5.0"
Flake8-pyproject = "^1.2.3"
furo = "2022.04.07"
invoke = "^1.3"
invoke = "^2.0"
isort = "^5.11"
livereload = "^2.6"
pretend = "^1.0.9"
psutil = { version = "^5.7", optional = true }
pyOpenSSL = "^22.0"
pyOpenSSL = "^23.0"
pytest = "^7.1"
pytest-cov = "^3.0"
pytest-django = "^4.0"
Expand All @@ -57,7 +60,7 @@ Werkzeug = "^2.0"
[tool.autopub]
project-name = "Kagi"
git-username = "botpub"
git-email = "botpub@autopub.rocks"
git-email = "52496925+botpub@users.noreply.github.com"
append-github-contributor = true

[tool.isort]
Expand Down

0 comments on commit b22ca49

Please sign in to comment.