Skip to content

Commit

Permalink
feat(auth): add option to enforce second factor authentication
Browse files Browse the repository at this point in the history
- projects can define this and it will block users from editing
- teams can define this and roles from the teams will not be applied
  unless user is 2fa verified

Fixes #11341
  • Loading branch information
nijel committed Aug 8, 2024
1 parent 69d534f commit 3218862
Show file tree
Hide file tree
Showing 20 changed files with 227 additions and 37 deletions.
4 changes: 4 additions & 0 deletions docs/admin/auth.rst
Original file line number Diff line number Diff line change
Expand Up @@ -772,3 +772,7 @@ Recovery codes

Each user can configure this in :ref:`profile-account` and second factor will
be required to sign in addition to the existing authentication method.

This can be enforced for users at project (see :ref:`project-enforced_2fa`) or team level.

The permissions of a team with enforced two factor authentication won't be applied to users not having it configured.
10 changes: 10 additions & 0 deletions docs/admin/projects.rst
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,16 @@ Configure per project access control, see :ref:`acl` for more details.

The default value can be changed by :setting:`DEFAULT_ACCESS_CONTROL`.

.. _project-enforced_2fa:

Enforced two factor authentication
++++++++++++++++++++++++++++++++++

.. versionadded:: 5.7

Enforce :ref:`2fa` for all contributors. Users without it will not be allowed
to perform any operations within the project.

.. _project-translation_review:

Enable reviews
Expand Down
1 change: 1 addition & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Not yet released.
**New features**

* :ref:`2fa` is now supported using Passkeys, WebAuthn, authentication apps (TOTP), and recovery codes.
* :ref:`2fa` can be enforced at team or project level.
* :ref:`adding-new-strings` can now create plural strings in the user interface.
* :ref:`labels` now include description to explain them.
* New :ref:`subscriptions` for completed translation and component.
Expand Down
33 changes: 33 additions & 0 deletions weblate/accounts/tests/test_twofactor.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,36 @@ def test_login_totp(self):
expected_url, {"otp_token": totp_response}, follow=True
)
self.assertEqual(response.context["user"], self.user)

def test_team_enforced_2fa(self):
# Turn on enforcement on all user teams
self.user.groups.update(enforced_2fa=True)
url = self.project.get_absolute_url()

# Access without second factor
response = self.client.get(url)
# Not found because user doesn't have access to the project
self.assertEqual(response.status_code, 404)

# Configure second factor
self.test_login_totp()
response = self.client.get(url)
self.assertEqual(response.status_code, 200)

def test_project_enforced_2fa(self):
# Turn on enforcement on project and make user an admin
self.project.add_user(self.user, "Administration")
self.project.enforced_2fa = True
self.project.save()

url = reverse("git_status", kwargs={"path": self.project.get_url_path()})

# Access without second factor
response = self.client.get(url)
# Permission denied because user still has access to the project
self.assertEqual(response.status_code, 403)

# Configure second factor
self.test_login_totp()
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
2 changes: 1 addition & 1 deletion weblate/accounts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ def user_profile(request: AuthenticatedHttpRequest):
"userform": forms[6],
"notification_forms": forms[7:],
"all_forms": forms,
"user_groups": user.groups.prefetch_related(
"user_groups": user.cached_groups.prefetch_related(
"roles", "projects", "languages", "components"
),
"profile": profile,
Expand Down
2 changes: 2 additions & 0 deletions weblate/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ class Meta:
"projects",
"componentlists",
"components",
"enforced_2fa",
)
extra_kwargs = {"url": {"view_name": "api:group-detail", "lookup_field": "id"}}

Expand Down Expand Up @@ -420,6 +421,7 @@ class Meta:
"instructions",
"enable_hooks",
"language_aliases",
"enforced_2fa",
)
extra_kwargs = {
"url": {"view_name": "api:project-detail", "lookup_field": "slug"}
Expand Down
10 changes: 9 additions & 1 deletion weblate/auth/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,14 @@ class Meta:
class BaseTeamForm(forms.ModelForm):
class Meta:
model = Group
fields = ["name", "roles", "language_selection", "languages", "components"]
fields = [
"name",
"roles",
"language_selection",
"languages",
"components",
"enforced_2fa",
]

internal_fields = [
"name",
Expand Down Expand Up @@ -177,4 +184,5 @@ class Meta:
"componentlists",
"language_selection",
"languages",
"enforced_2fa",
]
25 changes: 25 additions & 0 deletions weblate/auth/migrations/0006_group_enforced_2fa.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Copyright © Michal Čihař <michal@weblate.org>
#
# SPDX-License-Identifier: GPL-3.0-or-later

# Generated by Django 5.0.7 on 2024-08-05 14:06

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("weblate_auth", "0005_invitation_full_name_invitation_username"),
]

operations = [
migrations.AddField(
model_name="group",
name="enforced_2fa",
field=models.BooleanField(
default=False,
help_text="Requires users to have configured two factor authentication.",
verbose_name="Enforced two factor authentication",
),
),
]
52 changes: 37 additions & 15 deletions weblate/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@
from weblate.utils.validators import CRUD_RE, validate_fullname, validate_username

if TYPE_CHECKING:
from collections.abc import Iterable

from social_core.backends.base import BaseAuth
from social_django.models import DjangoStorage
from social_django.strategy import DjangoStrategy
Expand Down Expand Up @@ -188,6 +190,13 @@ class Group(models.Model):
),
related_name="administered_group_set",
)
enforced_2fa = models.BooleanField(
verbose_name=gettext_lazy("Enforced two factor authentication"),
default=False,
help_text=gettext_lazy(
"Requires users to have configured two factor authentication."
),
)

objects = GroupQuerySet.as_manager()

Expand Down Expand Up @@ -491,6 +500,7 @@ def clear_cache(self) -> None:
"watched_projects",
"owned_projects",
"managed_projects",
"cached_groups",
)
for name in perm_caches:
if name in self.__dict__:
Expand All @@ -508,6 +518,10 @@ def is_anonymous(self):
def is_authenticated(self) -> bool: # type: ignore[override]
return not self.is_anonymous

# django_otp integration, this is overridden in OTPMiddleware
def is_verified(self) -> bool:
return False

def get_full_name(self):
return self.full_name

Expand Down Expand Up @@ -668,26 +682,34 @@ def managed_projects(self):
def administered_group_ids(self):
return set(self.administered_group_set.values_list("id", flat=True))

@cached_property
def cached_groups(self) -> Iterable[Group]:
return self.groups.prefetch_related(
"roles__permissions",
Prefetch(
"componentlists__components",
queryset=Component.objects.only("id", "project_id"),
),
Prefetch(
"components",
queryset=Component.objects.all().only("id", "project_id"),
),
Prefetch("projects", queryset=Project.objects.only("id", "access_control")),
Prefetch("languages", queryset=Language.objects.only("id")),
)

def group_enforces_2fa(self) -> bool:
return any(group.enforced_2fa for group in self.cached_groups)

def _fetch_permissions(self) -> None:
"""Fetch all user permissions into a dictionary."""
projects: PermissionCacheType = defaultdict(list)
components: SimplePermissionCacheType = defaultdict(list)
with sentry_sdk.start_span(op="permissions", description=self.username):
for group in self.groups.prefetch_related(
"roles__permissions",
Prefetch(
"componentlists__components",
queryset=Component.objects.only("id", "project_id"),
),
Prefetch(
"components",
queryset=Component.objects.all().only("id", "project_id"),
),
Prefetch(
"projects", queryset=Project.objects.only("id", "access_control")
),
Prefetch("languages", queryset=Language.objects.only("id")),
):
for group in self.cached_groups:
# Skip permissions for not verified users
if group.enforced_2fa and not self.is_verified():
continue
if group.language_selection == SELECTION_ALL:
languages = None
else:
Expand Down
55 changes: 38 additions & 17 deletions weblate/auth/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ def check_global_permission(user: User, permission: str) -> bool:
return permission in user.global_permissions


def check_enforced_2fa(user: User, project: Project) -> bool:
"""Check whether user has 2fa in case it is enforced by the project."""
return not project.enforced_2fa or user.is_verified()


def check_permission(user: User, permission: str, obj: Model):
"""Check whether user has a object-specific permission."""
if user.is_superuser:
Expand All @@ -78,39 +83,46 @@ def check_permission(user: User, permission: str, obj: Model):
return any(
permission in permissions
for permissions, _langs in user.get_project_permissions(obj)
)
) and check_enforced_2fa(user, obj)
if isinstance(obj, ComponentList):
return all(
check_permission(user, permission, component)
and check_enforced_2fa(user, component.project)
for component in obj.components.iterator()
)
if isinstance(obj, Component):
return (
not obj.restricted
and any(
(
not obj.restricted
and any(
permission in permissions
for permissions, _langs in user.get_project_permissions(obj.project)
)
)
or any(
permission in permissions
for permissions, _langs in user.get_project_permissions(obj.project)
for permissions, _langs in user.component_permissions[obj.pk]
)
) or any(
permission in permissions
for permissions, _langs in user.component_permissions[obj.pk]
)
) and check_enforced_2fa(user, obj.project)
if isinstance(obj, Unit):
obj = obj.translation
if isinstance(obj, Translation):
lang = obj.language_id
return (
not obj.component.restricted
and any(
permission in permissions and (langs is None or lang in langs)
for permissions, langs in user.get_project_permissions(
obj.component.project
(
not obj.component.restricted
and any(
permission in permissions and (langs is None or lang in langs)
for permissions, langs in user.get_project_permissions(
obj.component.project
)
)
)
) or any(
permission in permissions and (langs is None or lang in langs)
for permissions, langs in user.component_permissions[obj.component_id]
)
or any(
permission in permissions and (langs is None or lang in langs)
for permissions, langs in user.component_permissions[obj.component_id]
)
) and check_enforced_2fa(user, obj.component.project)
raise TypeError(
f"Permission {permission} does not support: {obj.__class__}: {obj!r}"
)
Expand Down Expand Up @@ -157,6 +169,15 @@ def check_can_edit(user: User, permission: str, obj: Model, is_vote=False): # n
gettext("Can not perform this operation without an e-mail address.")
)

if project and not check_enforced_2fa(user, project):
# This would later fail in check_permission, but we can give a nicer error
# message here when checking this specifically.
return Denied(

Check warning on line 175 in weblate/auth/permissions.py

View check run for this annotation

Codecov / codecov/patch

weblate/auth/permissions.py#L175

Added line #L175 was not covered by tests
gettext(
"This project requires two factor authentication, please configure it."
)
)

if component:
# Check component lock
if component.locked:
Expand Down
6 changes: 5 additions & 1 deletion weblate/templates/accounts/profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,11 @@ <h5>{% trans "Recovery codes" %}</h5>
<tbody>
{% for group in user_groups %}
<tr>
<td><a href="{{ group.get_absolute_url }}">{{ group }}</a></td>
<td>
<a href="{{ group.get_absolute_url }}">
{% include "auth/teams-name.html" %}
</a>
</td>
<td>
{% include "auth/teams-roles.html" %}
</td>
Expand Down
5 changes: 5 additions & 0 deletions weblate/templates/auth/teams-name.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{% load i18n %}
{{ group.name }}
{% if group.enforced_2fa %}
<span class="badge">{% trans "Requires two factor authentication" %}</span>
{% endif %}
4 changes: 4 additions & 0 deletions weblate/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,10 @@ <h1>
{% show_message 'warning' msg %}
{% endif %}

{% if user.is_authenticated and not user.is_verified and user.group_enforces_2fa %}
{% include "snippets/enforced_2fa.html" %}
{% endif %}

{% if messages %}
{% for message in messages %}
{% show_message message.tags message %}
Expand Down
2 changes: 1 addition & 1 deletion weblate/templates/manage/teams.html
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ <h4 class="panel-title">
{% for group in object_list %}
<tr>
<td>
{{ group.name }}
{% include "auth/teams-name.html" %}
</td>
<td>
{% include "auth/teams-roles.html" %}
Expand Down
13 changes: 13 additions & 0 deletions weblate/templates/snippets/enforced_2fa.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{% extends "message.html" %}
{% load i18n %}

{% block tags %}warning{% endblock %}

{% block message %}
<a href="{% url 'profile' %}#account" class="btn btn-primary pull-right flip">{% trans "Configure two factor authentication" %}</a>
{% if project %}
{% trans "This project is requiring two factor authentication from all contributors. You will not be able to contribute until your configure it." %}
{% else %}
{% trans "You are member of a team that requires two factor authentication. You will not be able to use any of its privileges until you configure it." %}
{% endif %}
{% endblock %}
4 changes: 4 additions & 0 deletions weblate/templates/snippets/project/state.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@
{% elif object.is_trial and user_can_view_billing %}
{% include "snippets/project/billing-trial.html" %}
{% endif %}

{% if object.enforced_2fa and not user.is_verified %}
{% include "snippets/enforced_2fa.html" with project=1 %}
{% endif %}
2 changes: 1 addition & 1 deletion weblate/templates/trans/project-access.html
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ <h4 class="panel-title">
{% for group in groups %}
<tr>
<td>
{{ group.name }}
{% include "auth/teams-name.html" %}
</td>
<td>
{% include "auth/teams-roles.html" %}
Expand Down
Loading

0 comments on commit 3218862

Please sign in to comment.