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
- TODO: middleware warns user about not meeting this requirement
- TODO: tests
- TODO: documentation

Fixes #11341
  • Loading branch information
nijel committed Aug 5, 2024
1 parent 421e1f2 commit 766501e
Show file tree
Hide file tree
Showing 7 changed files with 118 additions and 18 deletions.
9 changes: 8 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
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",
),
),
]
13 changes: 13 additions & 0 deletions weblate/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,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 @@ -417,6 +424,9 @@ class User(AbstractBaseUser):
# social_auth integration
social_auth: DjangoStorage

# django_otp integration
is_verified: bool

EMAIL_FIELD = "email"
USERNAME_FIELD = "username"
REQUIRED_FIELDS = ["email", "full_name"]
Expand Down Expand Up @@ -688,6 +698,9 @@ def _fetch_permissions(self) -> None:
),
Prefetch("languages", queryset=Language.objects.only("id")),
):
# Skip permissions for not verified users
if group.enforced_2fa and not self.is_verified:
continue

Check warning on line 703 in weblate/auth/models.py

View check run for this annotation

Codecov / codecov/patch

weblate/auth/models.py#L703

Added line #L703 was not covered by tests
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
2 changes: 2 additions & 0 deletions weblate/trans/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -2044,6 +2044,7 @@ class Meta:
"enable_hooks",
"language_aliases",
"access_control",
"enforced_2fa",
"translation_review",
"source_review",
)
Expand Down Expand Up @@ -2141,6 +2142,7 @@ def __init__(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None:
template="%s/layout/radioselect_access.html",
**disabled,
),
"enforced_2fa",
css_id="access",
),
Tab(
Expand Down
25 changes: 25 additions & 0 deletions weblate/trans/migrations/0022_project_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 = [
("trans", "0021_component_processed_revision"),
]

operations = [
migrations.AddField(
model_name="project",
name="enforced_2fa",
field=models.BooleanField(
default=False,
help_text="Requires contributors to have configured two factor authentication before being able to contribute.",
verbose_name="Enforced two factor authentication",
),
),
]
7 changes: 7 additions & 0 deletions weblate/trans/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,13 @@ class Project(models.Model, PathMixin, CacheKeyMixin):
"in the documentation."
),
)
enforced_2fa = models.BooleanField(
verbose_name=gettext_lazy("Enforced two factor authentication"),
default=False,
help_text=gettext_lazy(
"Requires contributors to have configured two factor authentication before being able to contribute."
),
)
# This should match definition in WorkflowSetting
translation_review = models.BooleanField(
verbose_name=gettext_lazy("Enable reviews"),
Expand Down

0 comments on commit 766501e

Please sign in to comment.