Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(auth): add option to enforce second factor authentication #12200

Merged
merged 12 commits into from
Aug 9, 2024
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 the 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 who do not have 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
nijel marked this conversation as resolved.
Show resolved Hide resolved
++++++++++++++++++++++++++++++++++

.. versionadded:: 5.7

Enforce :ref:`2fa` for all contributors. Users won’t be allowed
to perform any operations within the project without having it configured.

.. _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 the 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 two-factor authentication configured.",
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 two-factor authentication configured."
),
)

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 the user has 2FA configured, 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(
gettext(
"This project requires two-factor authentication; configure it in your profile."
)
)

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 requires two-factor authentication from all contributors. You won’t be able to contribute until you configure it." %}
{% else %}
{% trans "You are a member of the team that requires two-factor authentication. You won’t be able to use any of its privileges until you configure 2FA." %}
{% 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
Loading