From 171a6e0a56fcaeff252412d2ab03922505015983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Wed, 31 Jul 2024 09:34:03 +0200 Subject: [PATCH] feat(auth): add two-factor authentication - Implemented using django-otp and django-otp-webauthn - Support for TOTP, WebAuthn and recovery codes Fixes #1681 --- .github/workflows/test.yml | 2 + ci/pip-install | 3 + docs/admin/auth.rst | 33 +++ docs/changes.rst | 1 + pyproject.toml | 3 + weblate/accounts/forms.py | 76 ++++++ .../0012_alter_auditlog_activity.py | 58 +++++ weblate/accounts/models.py | 53 ++++ weblate/accounts/tasks.py | 19 +- weblate/accounts/templatetags/authnames.py | 8 + weblate/accounts/tests/test_twofactor.py | 129 ++++++++++ weblate/accounts/urls.py | 24 ++ weblate/accounts/utils.py | 53 ++++ weblate/accounts/views.py | 239 +++++++++++++++++- weblate/settings_docker.py | 13 + weblate/settings_example.py | 15 ++ weblate/settings_test.py | 3 + weblate/static/loader-bootstrap.js | 22 ++ weblate/static/style-bootstrap.css | 7 + weblate/templates/accounts/profile.html | 85 +++++++ .../templates/accounts/recovery-codes.html | 61 +++++ weblate/templates/accounts/totp.html | 53 ++++ 22 files changed, 953 insertions(+), 7 deletions(-) create mode 100644 weblate/accounts/migrations/0012_alter_auditlog_activity.py create mode 100644 weblate/accounts/tests/test_twofactor.py create mode 100644 weblate/templates/accounts/recovery-codes.html create mode 100644 weblate/templates/accounts/totp.html diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0f85f2bba5f9..38a18078d9bf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,6 +23,8 @@ jobs: runs-on: ubuntu-22.04 continue-on-error: ${{ matrix.experimental }} strategy: + # TODO: temporary workaround until MySQL compatibility is achieved + fail-fast: false matrix: python-version: - '3.10' diff --git a/ci/pip-install b/ci/pip-install index cc6dafa4a605..b2683457cdab 100755 --- a/ci/pip-install +++ b/ci/pip-install @@ -37,6 +37,9 @@ else fi fi +# TODO: Testing https://github.com/Stormbase/django-otp-webauthn/pull/18 +uv pip install --system --upgrade --force-reinstall https://github.com/Stormbase/django-otp-webauthn/archive/refs/heads/fix/credential-id-hash-as-hex-mysql-compat.zip + # Verify that deps are consistent if [ "${1:-latest}" != edge ] ; then uv pip check diff --git a/docs/admin/auth.rst b/docs/admin/auth.rst index b0134810bab7..66579929e98b 100644 --- a/docs/admin/auth.rst +++ b/docs/admin/auth.rst @@ -737,3 +737,36 @@ there is any) into :setting:`django:INSTALLED_APPS`: INSTALLED_APPS += ( # Install authentication app here ) + + +.. _2fa: + +Two-factor authentication +========================= + +.. hint:: + + Two-factor authentication adds an additional layer of security to your account by requiring more than just a password to sign in. + +Weblate supports the following second factors: + +Security keys (WebAuthn) + Both Passkeys and security keys are supported. + + Passkeys validate your identity using touch, facial recognition, a device password, or a PIN as they include user verification. + + Security keys are webauthn credentials that can only be used as a second factor of authentication and these only validate user presence. + +Authenticator app (TOTP) + Authenticator apps and browser extensions like Aegis, Bitwarden, Google Authenticator, + 1Password, Authy, Microsoft Authenticator, etc. generate one-time passwords + that are used as a second factor to verify your identity when prompted + during sign-in. + +Recovery codes + Recovery codes can be used to access your account in the event you lose access to your device and cannot receive two-factor authentication codes. + + Keep your recovery codes as safe as your password. We recommend saving them with a password manager such as Bitwarden, 1Password, Authy, or Keeper. + +Each user can configure this in :ref:`profile-account` and second factor will +be required to sign in addition to the existing authentication method. diff --git a/docs/changes.rst b/docs/changes.rst index 802862f3c6be..18cd8571c4d6 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -9,6 +9,7 @@ Not yet released. * :ref:`labels` now include description to explain them. * New :ref:`subscriptions` for completed translation and component. * :ref:`mt-openai` now supports custom models and URLs and offers rephrasing of existing strings. +* :ref:`2fa` is now supported using Passkeys, WebAuthn, authentication apps (TOTP) and recovery codes. **Improvements** diff --git a/pyproject.toml b/pyproject.toml index ce6b6c658d68..661f700a26a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,8 @@ dependencies = [ "django-crispy-forms>=2.1,<2.4", "django-filter>=23.4,<24.3", "django-redis>=5.4.0,<6.0", + "django-otp>=1.5.1,<2.0", + "django-otp-webauthn>=0.2.0,<0.3", "Django[argon2]>=5.0,<5.1", "djangorestframework>=3.15.0,<3.16", "filelock<4,>=3.12.2", @@ -67,6 +69,7 @@ dependencies = [ "pyparsing>=3.1.1,<3.2", "python-dateutil>=2.8.2", "python-redis-lock[django]>=4,<4.1", + "qrcode>=7.0,<8.0", "rapidfuzz>=2.6.0,<3.10", "redis>=5.0.2,<5.1.0", "requests>=2.32.2,<2.33", diff --git a/weblate/accounts/forms.py b/weblate/accounts/forms.py index 4ccc15e4c183..7bec4711c100 100644 --- a/weblate/accounts/forms.py +++ b/weblate/accounts/forms.py @@ -4,6 +4,8 @@ from __future__ import annotations +from binascii import unhexlify +from time import time from typing import cast from crispy_forms.helper import FormHelper @@ -17,6 +19,8 @@ from django.utils.functional import cached_property from django.utils.html import escape from django.utils.translation import activate, gettext, gettext_lazy, ngettext, pgettext +from django_otp.oath import totp +from django_otp.plugins.otp_totp.models import TOTPDevice from weblate.accounts.auth import try_get_user from weblate.accounts.captcha import MathCaptcha @@ -939,3 +943,75 @@ def __init__(self, *args, **kwargs) -> None: class GroupRemoveForm(forms.Form): remove_group = forms.ModelChoiceField(queryset=Group.objects.all(), required=True) + + +class TOTPDeviceForm(forms.Form): + """Based on two_factor.forms.TOTPDeviceForm.""" + + name = forms.CharField( + # Must match django_otp.models.Device.name + max_length=64, + label=gettext_lazy("Name your authentication app"), + ) + token = forms.IntegerField( + label=gettext_lazy("Verify the code from the app"), + min_value=0, + max_value=999999, + ) + + token.widget.attrs.update( + { + "autofocus": "autofocus", + "inputmode": "numeric", + "autocomplete": "one-time-code", + } + ) + + error_messages = { + "invalid_token": gettext_lazy("Entered token is not valid."), + } + + def __init__(self, key, user, metadata=None, **kwargs): + super().__init__(**kwargs) + self.key = key + self.tolerance = 1 + self.t0 = 0 + self.step = 30 + self.drift = 0 + self.digits = 6 + self.user = user + self.metadata = metadata or {} + + @property + def bin_key(self): + """The secret key as a binary string.""" + return unhexlify(self.key.encode()) + + def clean_token(self): + token = self.cleaned_data.get("token") + validated = False + t0s = [self.t0] + key = self.bin_key + if "valid_t0" in self.metadata: + t0s.append(int(time()) - self.metadata["valid_t0"]) + for t0 in t0s: + for offset in range(-self.tolerance, self.tolerance + 1): + if totp(key, self.step, t0, self.digits, self.drift + offset) == token: + self.drift = offset + self.metadata["valid_t0"] = int(time()) - t0 + validated = True + if not validated: + raise forms.ValidationError(self.error_messages["invalid_token"]) + return token + + def save(self): + return TOTPDevice.objects.create( + user=self.user, + key=self.key, + tolerance=self.tolerance, + t0=self.t0, + step=self.step, + drift=self.drift, + digits=self.digits, + name=self.cleaned_data["name"], + ) diff --git a/weblate/accounts/migrations/0012_alter_auditlog_activity.py b/weblate/accounts/migrations/0012_alter_auditlog_activity.py new file mode 100644 index 000000000000..a4b993b052ec --- /dev/null +++ b/weblate/accounts/migrations/0012_alter_auditlog_activity.py @@ -0,0 +1,58 @@ +# Copyright © Michal Čihař +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# Generated by Django 5.0.7 on 2024-08-02 11:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("accounts", "0011_alter_subscription_notification"), + ] + + operations = [ + migrations.AlterField( + model_name="auditlog", + name="activity", + field=models.CharField( + choices=[ + ("accepted", "accepted"), + ("auth-connect", "auth-connect"), + ("auth-disconnect", "auth-disconnect"), + ("autocreated", "autocreated"), + ("blocked", "blocked"), + ("connect", "connect"), + ("disabled", "disabled"), + ("donate", "donate"), + ("email", "email"), + ("enabled", "enabled"), + ("failed-auth", "failed-auth"), + ("full_name", "full_name"), + ("invited", "invited"), + ("locked", "locked"), + ("login", "login"), + ("login-new", "login-new"), + ("password", "password"), + ("recovery-generate", "recovery-generate"), + ("recovery-show", "recovery-show"), + ("register", "register"), + ("removal-request", "removal-request"), + ("removed", "removed"), + ("reset", "reset"), + ("reset-request", "reset-request"), + ("sent-email", "sent-email"), + ("team-add", "team-add"), + ("team-remove", "team-remove"), + ("tos", "tos"), + ("trial", "trial"), + ("twofactor-add", "twofactor-add"), + ("twofactor-remove", "twofactor-remove"), + ("username", "username"), + ], + db_index=True, + max_length=20, + ), + ), + ] diff --git a/weblate/accounts/models.py b/weblate/accounts/models.py index ecb7442e7a22..ac41bc43468c 100644 --- a/weblate/accounts/models.py +++ b/weblate/accounts/models.py @@ -1,10 +1,12 @@ # Copyright © Michal Čihař # # SPDX-License-Identifier: GPL-3.0-or-later + from __future__ import annotations import datetime from datetime import timedelta +from typing import TYPE_CHECKING, Literal from urllib.parse import urlparse from appconf import AppConf @@ -22,6 +24,9 @@ from django.utils.functional import cached_property from django.utils.timezone import now from django.utils.translation import get_language, gettext, gettext_lazy, pgettext_lazy +from django_otp.plugins.otp_static.models import StaticDevice +from django_otp.plugins.otp_totp.models import TOTPDevice +from django_otp_webauthn.models import WebAuthnCredential from rest_framework.authtoken.models import Token from social_django.models import UserSocialAuth @@ -42,6 +47,11 @@ from weblate.utils.validators import WeblateURLValidator from weblate.wladmin.models import get_support_status +if TYPE_CHECKING: + from collections.abc import Iterable + + from django_otp.models import Device + class WeblateAccountsConf(AppConf): """Accounts settings.""" @@ -172,6 +182,15 @@ def __str__(self) -> str: "donate": gettext_lazy("Semiannual support status review was displayed."), "team-add": gettext_lazy("User was added to the {team} team by {username}."), "team-remove": gettext_lazy("User was removed from the {team} team by {username}."), + "recovery-generate": gettext_lazy( + "Two-factor authentication recovery codes were generated" + ), + "recovery-show": gettext_lazy( + "Two-factor authentication recovery codes were viewed" + ), + "twofactor-add": gettext_lazy("Two-factor authentication added: {device}"), + "twofactor-remove": gettext_lazy("Two-factor authentication removed: {device}"), + "twofactor-login": gettext_lazy("Two-factor authentication sign in using {device}"), } # Override activity messages based on method ACCOUNT_ACTIVITY_METHOD = { @@ -218,6 +237,10 @@ def __str__(self) -> str: "username", "full_name", "blocked", + "recovery-generate", + "recovery-show", + "twofactor-add", + "twofactor-remove", } @@ -850,6 +873,36 @@ def get_site_commit_email(self) -> str: site_domain=settings.SITE_DOMAIN.rsplit(":", 1)[0], ) + def _get_second_factors(self) -> Iterable[Device]: + backend: type[Device] + for backend in (StaticDevice, TOTPDevice, WebAuthnCredential): + yield from backend.objects.filter(user=self) + + @cached_property + def second_factors(self) -> list[Device]: + return list(self._get_second_factors()) + + @property + def has_2fa(self) -> bool: + return any( + isinstance(device, TOTPDevice | WebAuthnCredential) + for device in self.second_factors + ) + + def log_2fa(self, request: AuthenticatedHttpRequest, device: Device): + from weblate.accounts.utils import get_key_name + + # Audit log entry + AuditLog.objects.create( + self.user, request, "twofactor-login", device=get_key_name(device) + ) + # Store preferred method + + def get_second_factor_type( + self, second_factors: list[Device] + ) -> Literal["totp", "webauthn"]: + raise NotImplementedError + def set_lang_cookie(response, profile) -> None: """Set session language based on user preferences.""" diff --git a/weblate/accounts/tasks.py b/weblate/accounts/tasks.py index 5197a937512d..3bc3dbe169e0 100644 --- a/weblate/accounts/tasks.py +++ b/weblate/accounts/tasks.py @@ -40,10 +40,27 @@ def cleanup_auditlog() -> None: """Cleanup old auditlog entries.""" from weblate.accounts.models import AuditLog + timestamp = now() + + # Cleanup old entries AuditLog.objects.filter( - timestamp__lt=now() - timedelta(days=settings.AUDITLOG_EXPIRY) + timestamp__lt=timestamp - timedelta(days=settings.AUDITLOG_EXPIRY) ).delete() + # Finalize pending two factor entries, these happen due to + # WebAuthn keys being added in two stages. Mature entries older than 5 minutes + # but look only two hours into past for performance reasons + for audit in AuditLog.objects.filter( + timestamp__range=( + timestamp - timedelta(hours=2), + timestamp - timedelta(minutes=5), + ), + activity="twofactor-add", + ): + if "skip_notify" in audit.params: + del audit.params["skip_notify"] + audit.save(update_fields=["params"]) + @app.task(trail=False) def notify_change(change_id) -> None: diff --git a/weblate/accounts/templatetags/authnames.py b/weblate/accounts/templatetags/authnames.py index c98e1a7428bb..dd917ab07301 100644 --- a/weblate/accounts/templatetags/authnames.py +++ b/weblate/accounts/templatetags/authnames.py @@ -15,7 +15,10 @@ from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy +from weblate.accounts.utils import get_key_name + if TYPE_CHECKING: + from django_otp.models import Device from django_stubs_ext import StrOrPromise register = template.Library() @@ -100,3 +103,8 @@ def auth_name(auth: str, separator: str = auth_name_default_separator, only: str def get_auth_name(auth: str): """Get nice name for authentication backend.""" return get_auth_params(auth)["name"] + + +@register.simple_tag +def key_name(device: Device) -> str: + return get_key_name(device) diff --git a/weblate/accounts/tests/test_twofactor.py b/weblate/accounts/tests/test_twofactor.py new file mode 100644 index 000000000000..73ba9e592f83 --- /dev/null +++ b/weblate/accounts/tests/test_twofactor.py @@ -0,0 +1,129 @@ +# Copyright © Michal Čihař +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Test for user handling.""" + +from __future__ import annotations + +from datetime import timedelta + +from django.core import mail +from django.urls import reverse +from django.utils.timezone import now +from django_otp.oath import totp +from django_otp.plugins.otp_static.models import StaticDevice, StaticToken +from django_otp.plugins.otp_totp.models import TOTPDevice +from django_otp_webauthn.models import WebAuthnCredential + +from weblate.accounts.models import AuditLog +from weblate.accounts.tasks import cleanup_auditlog +from weblate.accounts.utils import SESION_WEBAUTHN_AUDIT +from weblate.trans.tests.test_views import FixtureTestCase + + +class TwoFactorTestCase(FixtureTestCase): + def test_recovery_codes(self): + user = self.user + response = self.client.get(reverse("recovery-codes")) + self.assertContains(response, "Recovery codes") + self.assertFalse(StaticDevice.objects.filter(user=user).exists()) + + response = self.client.post(reverse("recovery-codes"), follow=True) + self.assertContains(response, "Recovery codes") + self.assertTrue(StaticDevice.objects.filter(user=user).exists()) + self.assertTrue(StaticToken.objects.filter(device__user=user).exists()) + + code = StaticToken.objects.filter(device__user=user).first().token + + self.assertContains(response, code) + + def create_webauthn_audit(self): + return AuditLog.objects.create( + self.user, None, "twofactor-add", device="", skip_notify=True + ) + + def assert_audit_mail(self): + self.assertEqual(len(mail.outbox), 1) + self.assertEqual( + mail.outbox[0].subject, "[Weblate] Activity on your account at Weblate" + ) + mail.outbox.clear() + + def test_audit_maturing(self): + audit = self.create_webauthn_audit() + audit.timestamp = now() - timedelta(minutes=10) + audit.save() + self.assertEqual(len(mail.outbox), 0) + cleanup_auditlog() + self.assert_audit_mail() + + def test_webauthn(self): + user = self.user + test_name = "test webauthn name" + credential = WebAuthnCredential.objects.create(user=user) + + url = reverse("webauthn-detail", kwargs={"pk": credential.pk}) + + # Mock what weblate.accounts.utils.WeblateWebAuthnHelper does + audit = self.create_webauthn_audit() + session = self.client.session + session.update({SESION_WEBAUTHN_AUDIT: audit.pk}) + session.save() + self.assertEqual(len(mail.outbox), 0) + + # Test initial naming + response = self.client.post(url, {"name": test_name}, follow=True) + # The device should be listed + self.assertContains(response, test_name) + # Audit log mail should be triggered + self.assert_audit_mail() + # The audit log should be updated + audit.refresh_from_db() + self.assertEqual(audit.params, {"device": test_name}) + + # Test naming + response = self.client.post(url, {"name": test_name}, follow=True) + # The device should be listed + self.assertContains(response, test_name) + + # The name should be updated + credential.refresh_from_db() + self.assertEqual(credential.name, test_name) + + # Test removal + response = self.client.post(url, {"delete": ""}, follow=True) + self.assertEqual(WebAuthnCredential.objects.all().count(), 0) + # The audit log for removal should be present + self.assertContains(response, test_name) + self.assert_audit_mail() + + def test_totp(self): + test_name = "test totp name" + + # Display form to get TOTP params + response = self.client.get(reverse("totp")) + + # Generate TOTP response + totp_key = response.context["form"].bin_key + totp_response = totp(totp_key, 30, 0, 6, 0) + + # Register TOTP device + response = self.client.post( + reverse("totp"), {"name": test_name, "token": totp_response}, follow=True + ) + self.assertContains(response, test_name) + devices = TOTPDevice.objects.all() + self.assertEqual(len(devices), 1) + device = devices[0] + self.assert_audit_mail() + + # Remove it + response = self.client.post( + reverse("totp-detail", kwargs={"pk": device.pk}), + {"delete": "1"}, + follow=True, + ) + self.assertContains(response, test_name) + self.assertFalse(TOTPDevice.objects.all().exists()) + self.assert_audit_mail() diff --git a/weblate/accounts/urls.py b/weblate/accounts/urls.py index e6e4ab133ee5..fe6cc01798b6 100644 --- a/weblate/accounts/urls.py +++ b/weblate/accounts/urls.py @@ -64,7 +64,31 @@ weblate.auth.views.InvitationView.as_view(), name="invitation", ), + path( + "auth/tokens/webauthn//", + weblate.accounts.views.WebAuthnCredentialView.as_view(), + name="webauthn-detail", + ), + path( + "auth/tokens/totp/", + weblate.accounts.views.TOTPView.as_view(), + name="totp", + ), + path( + "auth/tokens/totp//", + weblate.accounts.views.TOTPDetailView.as_view(), + name="totp-detail", + ), + path( + "auth/tokens/recovery-codes/", + weblate.accounts.views.RecoveryCodesView.as_view(), + name="recovery-codes", + ), path("", include((social_urls, "social_auth"), namespace="social")), + path( + "auth/webauthn/", + include("django_otp_webauthn.urls", namespace="otp_webauthn"), + ), ] if "simple_sso.sso_server" in settings.INSTALLED_APPS: diff --git a/weblate/accounts/utils.py b/weblate/accounts/utils.py index 19f6684f886b..dca06c37fa50 100644 --- a/weblate/accounts/utils.py +++ b/weblate/accounts/utils.py @@ -4,10 +4,15 @@ from __future__ import annotations import os +from typing import TYPE_CHECKING from django.conf import settings from django.contrib.auth import update_session_auth_hash from django.core.exceptions import ObjectDoesNotExist +from django.utils.translation import gettext +from django_otp.plugins.otp_totp.models import TOTPDevice +from django_otp_webauthn.helpers import WebAuthnHelper +from django_otp_webauthn.models import WebAuthnCredential from rest_framework.authtoken.models import Token from social_django.models import Code @@ -15,6 +20,14 @@ from weblate.auth.models import AuthenticatedHttpRequest, User from weblate.trans.signals import user_pre_delete +if TYPE_CHECKING: + from django_otp.models import Device + +SESSION_WEBAUTHN_AUDIT = "weblate:second_factor:webauthn_audit_log" +SESSION_SECOND_FACTOR_USER = "weblate:second_factor:user" +SESSION_SECOND_FACTOR_SOCIAL = "weblate:second_factor:social" +SESSION_SECOND_FACTOR_TOTP = "weblate:second_factor:totp_key" + def remove_user(user: User, request: AuthenticatedHttpRequest, **params) -> None: """Remove user account.""" @@ -139,3 +152,43 @@ def adjust_session_expiry(request: AuthenticatedHttpRequest) -> None: request.session.set_expiry(60) else: request.session.set_expiry(settings.SESSION_COOKIE_AGE_AUTHENTICATED) + + +def get_key_name(device: Device) -> str: + # Prefer user provided name + if device.name: + return device.name + + device_id: str | int = device.id + device_label = f"{device.__class__.__name__} (%s)" + + if isinstance(device, WebAuthnCredential): + device_label = gettext("Security key (%s)") + # GUID is often masked by browser and zeroed one is useless + if device.aaguid != "00000000-0000-0000-0000-000000000000": + device_id = device.aaguid + + elif isinstance(device, TOTPDevice): + device_label = gettext("Authentication app (%s)") + + return device_label % device_id + + +class WeblateWebAuthnHelper(WebAuthnHelper): + def register_complete(self, user: User, state: dict, data: dict): + device = super().register_complete(user, state, data) + + # Create audit log, but skip notification for now as + # the device name should be updated in the next request + audit = AuditLog.objects.create( + user, + self.request, + "twofactor-add", + skip_notify=True, + device=get_key_name(device), + ) + + # Store in session to possibly update after rename + self.request.session[SESSION_WEBAUTHN_AUDIT] = audit.pk + + return device diff --git a/weblate/accounts/views.py b/weblate/accounts/views.py index ab9360fafd39..2486f244d5fe 100644 --- a/weblate/accounts/views.py +++ b/weblate/accounts/views.py @@ -1,20 +1,28 @@ # Copyright © Michal Čihař # # SPDX-License-Identifier: GPL-3.0-or-later + from __future__ import annotations import os import re +from base64 import b32encode +from binascii import unhexlify from collections import defaultdict from datetime import timedelta from importlib import import_module from typing import TYPE_CHECKING, Any +from urllib.parse import quote +import qrcode +import qrcode.image.svg import social_django.utils from django.conf import settings -from django.contrib.auth import REDIRECT_FIELD_NAME, logout +from django.contrib.auth import REDIRECT_FIELD_NAME +from django.contrib.auth import login as auth_login +from django.contrib.auth import logout as auth_logout from django.contrib.auth.decorators import login_required -from django.contrib.auth.views import LoginView, LogoutView +from django.contrib.auth.views import LoginView, LogoutView, RedirectURLMixin from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError from django.core.mail.message import EmailMultiAlternatives from django.core.signing import ( @@ -35,12 +43,24 @@ from django.utils import timezone from django.utils.cache import patch_response_headers from django.utils.decorators import method_decorator +from django.utils.functional import cached_property from django.utils.http import urlencode -from django.utils.translation import gettext +from django.utils.safestring import mark_safe +from django.utils.translation import gettext, gettext_lazy from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST -from django.views.generic import ListView, TemplateView, UpdateView +from django.views.generic import ( + DetailView, + FormView, + ListView, + TemplateView, + UpdateView, +) +from django_otp.plugins.otp_static.models import StaticDevice, StaticToken +from django_otp.plugins.otp_totp.models import TOTPDevice +from django_otp.util import random_hex +from django_otp_webauthn.models import WebAuthnCredential from rest_framework.authtoken.models import Token from social_core.actions import do_auth from social_core.backends.open_id import OpenIdAuth @@ -78,6 +98,7 @@ ResetForm, SetPasswordForm, SubscriptionForm, + TOTPDeviceForm, UserForm, UserSearchForm, UserSettingsForm, @@ -95,7 +116,14 @@ send_notification_email, ) from weblate.accounts.pipeline import EmailAlreadyAssociated, UsernameAlreadyAssociated -from weblate.accounts.utils import remove_user +from weblate.accounts.utils import ( + SESSION_SECOND_FACTOR_SOCIAL, + SESSION_SECOND_FACTOR_TOTP, + SESSION_SECOND_FACTOR_USER, + SESSION_WEBAUTHN_AUDIT, + get_key_name, + remove_user, +) from weblate.auth.forms import UserEditForm from weblate.auth.models import ( AuthenticatedHttpRequest, @@ -400,6 +428,11 @@ def user_profile(request: AuthenticatedHttpRequest): "new_backends": new_backends, "has_email_auth": "email" in all_backends, "auditlog": user.auditlog_set.order()[:20], + "totp_keys": user.totpdevice_set.all(), + "webauthn_keys": user.webauthncredential_set.all(), + "recovery_keys_count": StaticToken.objects.filter( + device__user=user + ).count(), }, ) @@ -413,7 +446,7 @@ def user_remove(request: AuthenticatedHttpRequest): if request.method == "POST": remove_user(request.user, request) rotate_token(request) - logout(request) + auth_logout(request) messages.success(request, gettext("Your account has been removed.")) return redirect("home") confirm_form = EmptyConfirmForm(request) @@ -765,6 +798,21 @@ def form_invalid(self, form): rotate_token(self.request) return super().form_invalid(form) + def form_valid(self, form): + """Security check complete. Log the user in.""" + user = form.get_user() + if user.profile.has_2fa: + # Store session indication for second factor + self.request.session[SESSION_SECOND_FACTOR_USER] = user.id + # Redirect to second factor login + redirect_to = self.request.POST.get( + self.redirect_field_name, self.request.GET.get(self.redirect_field_name) + ) + user.profile.get_second_factor_type() + return redirect({"next": redirect_to}) + auth_login(self.request, user) + return HttpResponseRedirect(self.get_success_url()) + class WeblateLogoutView(LogoutView): """Logout handler, just a wrapper around standard Django logout.""" @@ -1492,3 +1540,182 @@ def get_context_data(self, **kwargs): ) context["query_string"] = urlencode(context["search_items"]) return context + + +@method_decorator(login_required, name="dispatch") +class RecoveryCodesView(TemplateView): + template_name = "accounts/recovery-codes.html" + + def post(self, request, *args, **kwargs): + user = request.user + # Delete all existing tokens + StaticToken.objects.filter(device__user=user).delete() + + # Get device + device = StaticDevice.objects.filter(user=user).first() + if device is None: + device = StaticDevice.objects.create(user=user, name="Backup Code") + + # Generate tokens + for _i in range(16): + token = StaticToken.random_token() + device.token_set.create(token=token) + + AuditLog.objects.create(user, request, "recovery-generate") + + return redirect("recovery-codes") + + def get(self, request, *args, **kwargs): + user = request.user + AuditLog.objects.create(user, request, "recovery-show") + return super().get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + result = super().get_context_data(**kwargs) + user = self.request.user + recovery_codes = StaticToken.objects.filter(device__user=user).values_list( + "token", flat=True + ) + result["recovery_codes"] = recovery_codes + result["recovery_codes_str"] = "\n".join(recovery_codes) + return result + + +@method_decorator(login_required, name="dispatch") +class WebAuthnCredentialView(DetailView): + model = WebAuthnCredential + + message_remove = gettext_lazy("The security key was removed.") + message_add = gettext_lazy("The security key %s was registered.") + + def get_queryset(self): + return super().get_queryset().filter(user=self.request.user) + + def get(self, request, *args, **kwargs): + return redirect_profile("#account") + + def post(self, request, *args, **kwargs): + obj = self.get_object() + if "delete" in request.POST: + key_name = get_key_name(obj) + obj.delete() + AuditLog.objects.create( + request.user, request, "twofactor-remove", device=key_name + ) + messages.success(request, self.message_remove) + elif "name" in request.POST: + obj.name = request.POST["name"] + obj.save(update_fields=["name"]) + key_name = get_key_name(obj) + audit_pk = request.session.pop(SESSION_WEBAUTHN_AUDIT, None) + if isinstance(obj, WebAuthnCredential) and audit_pk is not None: + audit = AuditLog.objects.get(pk=audit_pk) + audit.params = {"device": key_name} + audit.save(update_fields=["params"]) + messages.success( + request, + self.message_add % key_name, + ) + return redirect_profile("#account") + + +@method_decorator(login_required, name="dispatch") +class TOTPDetailView(WebAuthnCredentialView): + model = TOTPDevice + + message_remove = gettext_lazy("The authentication app was removed.") + message_add = gettext_lazy("The authentication app %s was registered.") + + +@method_decorator(login_required, name="dispatch") +class TOTPView(FormView): + template_name = "accounts/totp.html" + form_class = TOTPDeviceForm + session_key = SESSION_SECOND_FACTOR_TOTP + + @cached_property + def totp_key(self) -> str: + key = self.request.session.get(self.session_key, None) + if key is None: + key = random_hex(20) + self.request.session[self.session_key] = key + return key + + @cached_property + def totp_key_b32(self) -> str: + key = self.totp_key + rawkey = unhexlify(key.encode("ascii")) + return b32encode(rawkey).decode("utf-8") + + @cached_property + def totp_url(self) -> str: + # For a complete run-through of all the parameters, have a look at the + # specs at: + # https://github.com/google/google-authenticator/wiki/Key-Uri-Format + + accountname = self.request.user.username + issuer = settings.SITE_TITLE + label = f"{issuer}: {accountname}" + + # Ensure that the secret parameter is the FIRST parameter of the URI, this + # allows Microsoft Authenticator to work. + query = ( + ("secret", self.totp_key_b32), + ("digits", "6"), + ("issuer", issuer), + ) + + return f"otpauth://totp/{quote(label)}?{urlencode(query)}" + + @property + def totp_svg(self): + image = qrcode.make(self.totp_url, image_factory=qrcode.image.svg.SvgPathImage) + return mark_safe(image.to_string(encoding="unicode")) # noqa: S308 + + def get_context_data(self, **kwargs): + """Create context for rendering page.""" + context = super().get_context_data(**kwargs) + context["totp_key_b32"] = self.totp_key_b32 + context["totp_url"] = self.totp_url + context["totp_svg"] = self.totp_svg + return context + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["key"] = self.totp_key + kwargs["user"] = self.request.user + return kwargs + + def form_valid(self, form: TOTPDeviceForm): + device = form.save() + AuditLog.objects.create( + self.request.user, + self.request, + "twofactor-add", + device=get_key_name(device), + ) + return redirect_profile("#account") + + +class SecondFactorLogin(RedirectURLMixin, FormView): + # TODO: form = + + def dispatch(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: ignore[override] + try: + user_id = self.request.session[SESSION_SECOND_FACTOR_USER] + except KeyError as error: + raise Http404 from error + try: + self.user = User.objects.get(pk=user_id) + except User.DoesNotExist as error: + raise Http404 from error + return super().dispatch(request, *args, **kwargs) + + def form_valid(self, form): + # Store audit log entry aboute used device and update last used device type + self.user.profile.log_2fa(form.device) + + if not self.request.session.get(SESSION_SECOND_FACTOR_SOCIAL): + # Keep login on social pipeline if we got here from it + auth_login(self.request, self.user) + return HttpResponseRedirect(self.get_success_url()) diff --git a/weblate/settings_docker.py b/weblate/settings_docker.py index 7327df7de755..1364659b5e0a 100644 --- a/weblate/settings_docker.py +++ b/weblate/settings_docker.py @@ -29,6 +29,7 @@ # Whether site uses https ENABLE_HTTPS = get_env_bool("WEBLATE_ENABLE_HTTPS") +# Site URL SITE_URL = "{}://{}".format("https" if ENABLE_HTTPS else "http", SITE_DOMAIN) # @@ -250,6 +251,13 @@ # Custom user model AUTH_USER_MODEL = "weblate_auth.User" +# WebAuthn +OTP_WEBAUTHN_RP_NAME = SITE_TITLE +OTP_WEBAUTHN_RP_ID = SITE_DOMAIN.split(":")[0] +OTP_WEBAUTHN_ALLOWED_ORIGINS = [SITE_URL] +OTP_WEBAUTHN_ALLOW_PASSWORDLESS_LOGIN = False +OTP_WEBAUTHN_HELPER_CLASS = "weblate.accounts.utils.WeblateWebAuthnHelper" + if "WEBLATE_NO_EMAIL_AUTH" not in os.environ: AUTHENTICATION_BACKENDS += ("social_core.backends.email.EmailAuth",) @@ -688,6 +696,7 @@ "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "weblate.accounts.middleware.AuthenticationMiddleware", + "django_otp.middleware.OTPMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "social_django.middleware.SocialAuthExceptionMiddleware", @@ -760,6 +769,10 @@ "django_filters", "django_celery_beat", "corsheaders", + "django_otp", + "django_otp.plugins.otp_static", + "django_otp.plugins.otp_totp", + "django_otp_webauthn", ] modify_env_list(INSTALLED_APPS, "APPS") diff --git a/weblate/settings_example.py b/weblate/settings_example.py index 8b0a7ee67792..b88d4372149a 100644 --- a/weblate/settings_example.py +++ b/weblate/settings_example.py @@ -18,6 +18,9 @@ # Whether site uses https ENABLE_HTTPS = False +# Site URL +SITE_URL = "{}://{}".format("https" if ENABLE_HTTPS else "http", SITE_DOMAIN) + # # Django settings for Weblate project. # @@ -233,6 +236,13 @@ # Custom user model AUTH_USER_MODEL = "weblate_auth.User" +# WebAuthn +OTP_WEBAUTHN_RP_NAME = SITE_TITLE +OTP_WEBAUTHN_RP_ID = SITE_DOMAIN.split(":")[0] +OTP_WEBAUTHN_ALLOWED_ORIGINS = [SITE_URL] +OTP_WEBAUTHN_ALLOW_PASSWORDLESS_LOGIN = False +OTP_WEBAUTHN_HELPER_CLASS = "weblate.accounts.utils.WeblateWebAuthnHelper" + # Social auth backends setup SOCIAL_AUTH_GITHUB_KEY = "" SOCIAL_AUTH_GITHUB_SECRET = "" @@ -363,6 +373,7 @@ "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "weblate.accounts.middleware.AuthenticationMiddleware", + "django_otp.middleware.OTPMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "social_django.middleware.SocialAuthExceptionMiddleware", @@ -417,6 +428,10 @@ "django_filters", "django_celery_beat", "corsheaders", + "django_otp", + "django_otp.plugins.otp_static", + "django_otp.plugins.otp_totp", + "django_otp_webauthn", ] # Custom exception reporter to include some details diff --git a/weblate/settings_test.py b/weblate/settings_test.py index ba5fd82227da..b4575bed4631 100644 --- a/weblate/settings_test.py +++ b/weblate/settings_test.py @@ -49,6 +49,9 @@ SECRET_KEY = "secret key used for tests only" # noqa: S105 SITE_DOMAIN = "example.com" +OTP_WEBAUTHN_RP_NAME = SITE_DOMAIN +OTP_WEBAUTHN_RP_ID = SITE_DOMAIN +OTP_WEBAUTHN_ALLOWED_ORIGINS = [f"https://{SITE_DOMAIN}"] # Different root for test repos if "CI_BASE_DIR" in os.environ: diff --git a/weblate/static/loader-bootstrap.js b/weblate/static/loader-bootstrap.js index 803f9fd3e3b8..173f715441ba 100644 --- a/weblate/static/loader-bootstrap.js +++ b/weblate/static/loader-bootstrap.js @@ -1491,6 +1491,28 @@ $(function () { } }); + /* WebAuthn registration completion in profile */ + document.addEventListener("otp_webauthn.register_complete", (event) => { + console.log(event); + const id = event.detail.id; + const deviceInput = document.querySelector( + "input[name=passkey-device-name]", + ); + const csrfToken = document.querySelector("input[name=csrfmiddlewaretoken]"); + + const action = deviceInput.getAttribute("data-href").replace("000000", id); + + const $form = $("#link-post"); + + $form.attr("action", action); + const elm = $("") + .attr("type", "hidden") + .attr("name", "name") + .attr("value", deviceInput.value); + $form.append(elm); + $form.submit(); + }); + /* Warn users that they do not want to use developer console in most cases */ console.log( "%c%s", diff --git a/weblate/static/style-bootstrap.css b/weblate/static/style-bootstrap.css index 37082cae0bc0..6276895b3f5b 100644 --- a/weblate/static/style-bootstrap.css +++ b/weblate/static/style-bootstrap.css @@ -1928,6 +1928,7 @@ a.language:hover { animation: flags-updated ease-out 3000ms; } +.second-factor h5, h5.list-group-item-heading { font-weight: bolder; } @@ -2023,3 +2024,9 @@ tbody.warning { vertical-align: top; padding: 0 5px; } +.recovery-codes { + columns: 2; +} +.recovery-codes li { + font-family: monospace; +} diff --git a/weblate/templates/accounts/profile.html b/weblate/templates/accounts/profile.html index 2c8fa39bb15d..19c03ba180bd 100644 --- a/weblate/templates/accounts/profile.html +++ b/weblate/templates/accounts/profile.html @@ -4,6 +4,7 @@ {% load authnames %} {% load crispy_forms_tags %} {% load icons %} +{% load otp_webauthn %} {% block breadcrumbs %}
  • {% trans "Your profile" %}
  • @@ -115,6 +116,9 @@

    {% documentation_icon 'user/profile' right=True %}{% trans "Account" %}

    + + + {% crispy userform %} {% crispy commitform %}

    @@ -177,6 +181,87 @@ {% endif %}

    +
    +
    +

    + {% documentation_icon 'admin/auth' '2fa' right=True %} + {% trans "Two-factor authentication" %} +

    +
    +
    +

    {% trans "Two-factor authentication adds an additional layer of security to your account by requiring more than just a password to sign in." %}

    + +
    +
    +
    {% trans "Security keys (WebAuthn)" %}
    + {% if not webauthn_keys %} +

    {% trans "There are currently no WebAuthn keys registered." %}

    + {% else %} +
      + {% for key in webauthn_keys %} +
    • + {% trans "Remove" %} + {% key_name key %} +
      +
    • + {% endfor %} +
    + {% endif %} + + + + + + + + {% render_otp_webauthn_register_scripts %} +
    +
    +
    {% trans "Authentication apps (TOTP)" %}
    + {% if not totp_keys %} +

    {% trans "There are currently no authentication apps registered." %}

    + {% else %} + + {% endif %} + {% trans "Register new authentication app" %} +
    +
    +
    {% trans "Recovery codes" %}
    + {% if recovery_keys_count == 0 %} +

    {% trans "There are currently no recovery codes generated." %}

    + {% trans "Generate new recovery codes" %} + {% else %} +

    + {% blocktranslate count count=recovery_keys_count trimmed %} + {{ count }} recovery code is available. + {% plural %} + {{ count }} recovery codes are available. + {% endblocktranslate %} +

    + {% trans "View recovery codes" %} + {% endif %} +
    +
    +
    +
    + +
    diff --git a/weblate/templates/accounts/recovery-codes.html b/weblate/templates/accounts/recovery-codes.html new file mode 100644 index 000000000000..11c2e4e1ed57 --- /dev/null +++ b/weblate/templates/accounts/recovery-codes.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} +{% load i18n %} +{% load translations %} +{% load authnames %} +{% load crispy_forms_tags %} +{% load icons %} +{% load otp_webauthn %} + +{% block breadcrumbs %} +
  • {% trans "Your profile" %}
  • +
  • {% trans "Recovery codes" %}
  • +{% endblock %} + +{% block content %} + +
    +
    +

    + {% documentation_icon 'admin/auth' '2fa' right=True %} + {% trans "Two-factor recovery codes" %} +

    +
    +
    +

    + {% trans "Recovery codes can be used to access your account in the event you lose access to your device and cannot receive two-factor authentication codes." %} +

    + +
    + {% trans "Keep your recovery codes in a safe spot. These codes are the last resort for accessing your account in case you lose your password and second factors. If you cannot find these codes, you will lose access to your account." %} +
    + + {% if recovery_codes %} +
    {% trans "Recovery codes" %}
    +
      + {% for code in recovery_codes %} +
    • {{ code }}
    • + {% endfor %} +
    + + {% else %} +

    {% trans "There are currently no recovery codes generated." %}

    + {% endif %} + +
    {% trans "Generate new recovery codes" %}
    + +

    + {% trans "When you generate new recovery codes, you must download or print the new codes." %} + {% trans "Your old codes won't work anymore." %} +

    +

    + {% trans "Generate new recovery codes" %} +

    + +

    + {% trans "Back to account settings" %} +

    + +
    +
    + +{% endblock %} diff --git a/weblate/templates/accounts/totp.html b/weblate/templates/accounts/totp.html new file mode 100644 index 000000000000..aa982d65241c --- /dev/null +++ b/weblate/templates/accounts/totp.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% load i18n %} +{% load translations %} +{% load authnames %} +{% load crispy_forms_tags %} +{% load icons %} +{% load otp_webauthn %} + +{% block breadcrumbs %} +
  • {% trans "Your profile" %}
  • +
  • {% trans "Register new authentication app" %}
  • +{% endblock %} + +{% block content %} + +
    + {% csrf_token %} +
    +
    +

    + {% documentation_icon 'admin/auth' '2fa' right=True %} + {% trans "Register new authentication app" %} +

    +
    +
    +

    + {% trans "Authenticator apps generate one-time passwords that are used as a second factor to verify your identity when prompted during sign-in. " %} +

    + +
    {% trans "Scan the QR code" %}
    + + {{ totp_svg }} + +

    + {% trans "You can also enter the secret key manually:" %} +

    + +
    {{ totp_key_b32 }}
    + + {{ form|crispy }} + +

    + +

    + +

    + {% trans "Back to account settings" %} +

    + +
    +
    +
    +{% endblock %}