Skip to content

Commit

Permalink
feat(auth): add two-factor authentication
Browse files Browse the repository at this point in the history
- Implemented using django-otp and django-otp-webauthn
- Support for TOTP, WebAuthn and recovery codes

Fixes WeblateOrg#1681
  • Loading branch information
nijel committed Aug 2, 2024
1 parent c1e1fea commit c5c5e16
Show file tree
Hide file tree
Showing 22 changed files with 953 additions and 7 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
3 changes: 3 additions & 0 deletions ci/pip-install
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ python -m pip install $(sed -n 's/.*"\(uv==\([^"]*\)\)".*/\1/p' pyproject.toml)
# SystemError: ffi_prep_closure(): bad user_data (it seems that the version of the libffi library seen at runtime is different from the 'ffi.h' file seen at compile-time)
uv pip install --system --no-binary :all: cffi

# TODO: Testing https://github.com/Stormbase/django-otp-webauthn/pull/18
uv pip install --system https://github.com/Stormbase/django-otp-webauthn/archive/refs/heads/fix/credential-id-hash-as-hex-mysql-compat.zip

if [ "${1:-latest}" = migrations ] ; then
uv pip install --system -e ".[all,mysql,ci]"
else
Expand Down
33 changes: 33 additions & 0 deletions docs/admin/auth.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ dependencies = [
"django-crispy-forms>=2.1,<2.4",
"django-filter>=23.4,<24.4",
"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",
Expand All @@ -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",
Expand Down
76 changes: 76 additions & 0 deletions weblate/accounts/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"],
)
58 changes: 58 additions & 0 deletions weblate/accounts/migrations/0012_alter_auditlog_activity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Copyright © Michal Čihař <michal@weblate.org>
#
# 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,
),
),
]
53 changes: 53 additions & 0 deletions weblate/accounts/models.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# Copyright © Michal Čihař <michal@weblate.org>
#
# 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
Expand All @@ -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

Expand All @@ -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."""
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -218,6 +237,10 @@ def __str__(self) -> str:
"username",
"full_name",
"blocked",
"recovery-generate",
"recovery-show",
"twofactor-add",
"twofactor-remove",
}


Expand Down Expand Up @@ -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.user)

@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."""
Expand Down
19 changes: 18 additions & 1 deletion weblate/accounts/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions weblate/accounts/templatetags/authnames.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Loading

0 comments on commit c5c5e16

Please sign in to comment.