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 3, 2024
1 parent f17756a commit b8344fe
Show file tree
Hide file tree
Showing 21 changed files with 1,187 additions and 8 deletions.
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.3.0,<0.4",
"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.4.1,<8.0",
"rapidfuzz>=2.6.0,<3.10",
"redis>=5.0.2,<5.1.0",
"requests>=2.32.2,<2.33",
Expand Down
123 changes: 123 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,9 @@
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.forms import OTPTokenForm as DjangoOTPTokenForm
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 +944,121 @@ 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"],
)


class WebAuthnTokenForm(forms.Form):
def __init__(self, user, request=None, *args, **kwargs):
super().__init__(*args, **kwargs)

self.user = user
self.request = request
# TODO: helper to include custom template


class OTPTokenForm(DjangoOTPTokenForm):
def __init__(self, user, request=None, *args, **kwargs):
super().__init__(user, request, *args, **kwargs)
self.request = request
self.fields["otp_device"].widget = forms.HiddenInput()
self.fields["otp_device"].required = False
self.fields["otp_challenge"].widget = forms.HiddenInput()
self.fields["otp_token"].required = True
self.fields["otp_token"].widget.attrs["autofocus"] = "autofocus"

def _chosen_device(self, user):
return None

@staticmethod
def device_choices(user): # noqa: ARG004
# Not needed as we do not support challenge/response devices
# Also this is incompatible with WebAuthn
return []


class TOTPTokenForm(OTPTokenForm):
otp_token = forms.IntegerField(
label=gettext_lazy("Verify the code from the app"),
min_value=0,
max_value=999999,
)

def __init__(self, user, request=None, *args, **kwargs):
super().__init__(user, request, *args, **kwargs)
self.fields["otp_token"].widget.attrs.update(
{
"inputmode": "numeric",
"autocomplete": "one-time-code",
}
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Copyright © Michal Čihař <michal@weblate.org>
#
# SPDX-License-Identifier: GPL-3.0-or-later

# Generated by Django 5.0.7 on 2024-08-03 06:54

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-login", "twofactor-login"),
("twofactor-remove", "twofactor-remove"),
("username", "username"),
],
db_index=True,
max_length=20,
),
),
migrations.AddField(
model_name="profile",
name="last_2fa",
field=models.CharField(
blank=True,
choices=[("", "None"), ("totp", "TOTP"), ("webauthn", "WebAuthn")],
default="",
max_length=15,
),
),
]
70 changes: 70 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 @@ -624,6 +647,17 @@ class Profile(models.Model):
max_length=EMAIL_LENGTH,
)

last_2fa = models.CharField(
choices=(
("", "None"),
("totp", "TOTP"),
("webauthn", "WebAuthn"),
),
blank=True,
default="",
max_length=15,
)

class Meta:
verbose_name = "User profile"
verbose_name_plural = "User profiles"
Expand Down Expand Up @@ -850,6 +884,42 @@ 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) -> Literal["totp", "webauthn"]:
from weblate.accounts.utils import get_key_type

available_types = {get_key_type(device) for device in self.second_factors}
if self.last_2fa in available_types:
return self.last_2fa
for tested in ("webauthn", "totp"):
if tested in available_types:
return tested
raise ValueError("No second factor available!")


def set_lang_cookie(response, profile) -> None:
"""Set session language based on user preferences."""
Expand Down
Loading

0 comments on commit b8344fe

Please sign in to comment.