Feature: two-factor authentication (#8012)

This commit is contained in:
shamoon
2024-11-18 10:34:46 -08:00
committed by GitHub
parent 6c3d6d562d
commit e94a92ed59
29 changed files with 1128 additions and 175 deletions

View File

@@ -8,6 +8,7 @@ from pathlib import Path
from typing import TYPE_CHECKING
import tqdm
from allauth.mfa.models import Authenticator
from allauth.socialaccount.models import SocialAccount
from allauth.socialaccount.models import SocialApp
from allauth.socialaccount.models import SocialToken
@@ -270,6 +271,7 @@ class Command(CryptMixin, BaseCommand):
"social_accounts": SocialAccount.objects.all(),
"social_apps": SocialApp.objects.all(),
"social_tokens": SocialToken.objects.all(),
"authenticators": Authenticator.objects.all(),
}
if settings.AUDIT_LOG_ENABLED:

View File

@@ -0,0 +1,35 @@
{% extends "paperless-ngx/base.html" %}
{% load i18n %}
{% load allauth %}
{% load allauth static %}
{% block head_title %}
{% trans "Paperless-ngx Two-Factor Authentication" %}
{% endblock head_title %}
{% block form_top_content %}
<p>
{% blocktranslate %}Your account is protected by two-factor authentication. Please enter an authenticator code:{% endblocktranslate %}
</p>
{% endblock form_top_content %}
{% block form_content %}
{% translate "Code" as i18n_code %}
<div class="form-floating">
<input type="code" name="code" id="inputCode" autocomplete="one-time-code" placeholder="{{ i18n_code }}" class="form-control" required>
<label for="inputCode">{{ i18n_code }}</label>
</div>
<div class="d-grid mt-3">
<button class="btn btn-lg btn-primary" type="submit">{% translate "Sign in" %}</button>
<button class="btn btn-lg btn-secondary mt-2" type="submit" form="logout-from-stage">{% translate "Cancel" %}</button>
</div>
{% endblock form_content %}
{% block after_form_content %}
<form id="logout-from-stage"
method="post"
action="{% url 'account_logout' %}">
<input type="hidden" name="next" value="{% url 'account_login' %}">
{% csrf_token %}
</form>
{% endblock after_form_content %}

View File

@@ -1,5 +1,6 @@
import json
from allauth.mfa.models import Authenticator
from django.contrib.auth.models import Group
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
@@ -601,6 +602,59 @@ class TestApiUser(DirectoriesMixin, APITestCase):
self.assertEqual(returned_user2.first_name, "Updated Name 2")
self.assertNotEqual(returned_user2.password, initial_password)
def test_deactivate_totp(self):
"""
GIVEN:
- Existing user account with TOTP enabled
WHEN:
- API request by a superuser is made to deactivate TOTP
- API request by a regular user is made to deactivate TOTP
THEN:
- TOTP is deactivated, if exists
- Regular user is forbidden from deactivating TOTP
"""
user1 = User.objects.create(
username="testuser",
password="test",
first_name="Test",
last_name="User",
)
Authenticator.objects.create(
user=user1,
type=Authenticator.Type.TOTP,
data={},
)
response = self.client.post(
f"{self.ENDPOINT}{user1.pk}/deactivate_totp/",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(Authenticator.objects.filter(user=user1).count(), 0)
# fail if already deactivated
response = self.client.post(
f"{self.ENDPOINT}{user1.pk}/deactivate_totp/",
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
regular_user = User.objects.create_user(username="regular_user")
regular_user.user_permissions.add(
*Permission.objects.all(),
)
self.client.force_authenticate(regular_user)
Authenticator.objects.create(
user=user1,
type=Authenticator.Type.TOTP,
data={},
)
response = self.client.post(
f"{self.ENDPOINT}{user1.pk}/deactivate_totp/",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
class TestApiGroup(DirectoriesMixin, APITestCase):
ENDPOINT = "/api/groups/"

View File

@@ -1,5 +1,6 @@
from unittest import mock
from allauth.mfa.models import Authenticator
from allauth.socialaccount.models import SocialAccount
from allauth.socialaccount.models import SocialApp
from django.contrib.auth.models import User
@@ -299,3 +300,82 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
len(self.user.socialaccount_set.filter(pk=social_account_id)),
0,
)
class TestApiTOTPViews(APITestCase):
ENDPOINT = "/api/profile/totp/"
def setUp(self):
super().setUp()
self.user = User.objects.create_superuser(username="temp_admin")
self.client.force_authenticate(user=self.user)
def test_get_totp(self):
"""
GIVEN:
- Existing user account
WHEN:
- API request is made to TOTP endpoint
THEN:
- TOTP is generated
"""
response = self.client.get(
self.ENDPOINT,
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn("qr_svg", response.data)
self.assertIn("secret", response.data)
@mock.patch("allauth.mfa.totp.internal.auth.validate_totp_code")
def test_activate_totp(self, mock_validate_totp_code):
"""
GIVEN:
- Existing user account
WHEN:
- API request is made to activate TOTP
THEN:
- TOTP is activated, recovery codes are returned
"""
mock_validate_totp_code.return_value = True
response = self.client.post(
self.ENDPOINT,
data={
"secret": "123",
"code": "456",
},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(Authenticator.objects.filter(user=self.user).exists())
self.assertIn("recovery_codes", response.data)
def test_deactivate_totp(self):
"""
GIVEN:
- Existing user account with TOTP enabled
WHEN:
- API request is made to deactivate TOTP
THEN:
- TOTP is deactivated
"""
Authenticator.objects.create(
user=self.user,
type=Authenticator.Type.TOTP,
data={},
)
response = self.client.delete(
self.ENDPOINT,
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(Authenticator.objects.filter(user=self.user).count(), 0)
# test fails
response = self.client.delete(
self.ENDPOINT,
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

View File

@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-10-19 22:56-0700\n"
"POT-Creation-Date: 2024-10-19 23:22-0700\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -1039,6 +1039,7 @@ msgid "Password"
msgstr ""
#: documents/templates/account/login.html:30
#: documents/templates/mfa/authenticate.html:23
msgid "Sign in"
msgstr ""
@@ -1161,6 +1162,24 @@ msgstr ""
msgid "Here's a link to the docs."
msgstr ""
#: documents/templates/mfa/authenticate.html:7
msgid "Paperless-ngx Two-Factor Authentication"
msgstr ""
#: documents/templates/mfa/authenticate.html:12
msgid ""
"Your account is protected by two-factor authentication. Please enter an "
"authenticator code:"
msgstr ""
#: documents/templates/mfa/authenticate.html:17
msgid "Code"
msgstr ""
#: documents/templates/mfa/authenticate.html:24
msgid "Cancel"
msgstr ""
#: documents/templates/paperless-ngx/base.html:58
msgid "Share link was not found."
msgstr ""
@@ -1366,139 +1385,139 @@ msgstr ""
msgid "paperless application settings"
msgstr ""
#: paperless/settings.py:684
#: paperless/settings.py:687
msgid "English (US)"
msgstr ""
#: paperless/settings.py:685
#: paperless/settings.py:688
msgid "Arabic"
msgstr ""
#: paperless/settings.py:686
#: paperless/settings.py:689
msgid "Afrikaans"
msgstr ""
#: paperless/settings.py:687
#: paperless/settings.py:690
msgid "Belarusian"
msgstr ""
#: paperless/settings.py:688
#: paperless/settings.py:691
msgid "Bulgarian"
msgstr ""
#: paperless/settings.py:689
#: paperless/settings.py:692
msgid "Catalan"
msgstr ""
#: paperless/settings.py:690
#: paperless/settings.py:693
msgid "Czech"
msgstr ""
#: paperless/settings.py:691
#: paperless/settings.py:694
msgid "Danish"
msgstr ""
#: paperless/settings.py:692
#: paperless/settings.py:695
msgid "German"
msgstr ""
#: paperless/settings.py:693
#: paperless/settings.py:696
msgid "Greek"
msgstr ""
#: paperless/settings.py:694
#: paperless/settings.py:697
msgid "English (GB)"
msgstr ""
#: paperless/settings.py:695
#: paperless/settings.py:698
msgid "Spanish"
msgstr ""
#: paperless/settings.py:696
#: paperless/settings.py:699
msgid "Finnish"
msgstr ""
#: paperless/settings.py:697
#: paperless/settings.py:700
msgid "French"
msgstr ""
#: paperless/settings.py:698
#: paperless/settings.py:701
msgid "Hungarian"
msgstr ""
#: paperless/settings.py:699
#: paperless/settings.py:702
msgid "Italian"
msgstr ""
#: paperless/settings.py:700
#: paperless/settings.py:703
msgid "Japanese"
msgstr ""
#: paperless/settings.py:701
#: paperless/settings.py:704
msgid "Korean"
msgstr ""
#: paperless/settings.py:702
#: paperless/settings.py:705
msgid "Luxembourgish"
msgstr ""
#: paperless/settings.py:703
#: paperless/settings.py:706
msgid "Norwegian"
msgstr ""
#: paperless/settings.py:704
#: paperless/settings.py:707
msgid "Dutch"
msgstr ""
#: paperless/settings.py:705
#: paperless/settings.py:708
msgid "Polish"
msgstr ""
#: paperless/settings.py:706
#: paperless/settings.py:709
msgid "Portuguese (Brazil)"
msgstr ""
#: paperless/settings.py:707
#: paperless/settings.py:710
msgid "Portuguese"
msgstr ""
#: paperless/settings.py:708
#: paperless/settings.py:711
msgid "Romanian"
msgstr ""
#: paperless/settings.py:709
#: paperless/settings.py:712
msgid "Russian"
msgstr ""
#: paperless/settings.py:710
#: paperless/settings.py:713
msgid "Slovak"
msgstr ""
#: paperless/settings.py:711
#: paperless/settings.py:714
msgid "Slovenian"
msgstr ""
#: paperless/settings.py:712
#: paperless/settings.py:715
msgid "Serbian"
msgstr ""
#: paperless/settings.py:713
#: paperless/settings.py:716
msgid "Swedish"
msgstr ""
#: paperless/settings.py:714
#: paperless/settings.py:717
msgid "Turkish"
msgstr ""
#: paperless/settings.py:715
#: paperless/settings.py:718
msgid "Ukrainian"
msgstr ""
#: paperless/settings.py:716
#: paperless/settings.py:719
msgid "Chinese Simplified"
msgstr ""
#: paperless/urls.py:254
#: paperless/urls.py:268
msgid "Paperless-ngx administration"
msgstr ""

View File

@@ -1,5 +1,6 @@
import logging
from allauth.mfa.adapter import get_adapter as get_mfa_adapter
from allauth.socialaccount.models import SocialAccount
from django.contrib.auth.models import Group
from django.contrib.auth.models import Permission
@@ -32,6 +33,11 @@ class UserSerializer(serializers.ModelSerializer):
required=False,
)
inherited_permissions = serializers.SerializerMethodField()
is_mfa_enabled = serializers.SerializerMethodField()
def get_is_mfa_enabled(self, user: User):
mfa_adapter = get_mfa_adapter()
return mfa_adapter.is_mfa_enabled(user)
class Meta:
model = User
@@ -49,6 +55,7 @@ class UserSerializer(serializers.ModelSerializer):
"groups",
"user_permissions",
"inherited_permissions",
"is_mfa_enabled",
)
def get_inherited_permissions(self, obj):
@@ -130,6 +137,11 @@ class ProfileSerializer(serializers.ModelSerializer):
read_only=True,
source="socialaccount_set",
)
is_mfa_enabled = serializers.SerializerMethodField()
def get_is_mfa_enabled(self, user: User):
mfa_adapter = get_mfa_adapter()
return mfa_adapter.is_mfa_enabled(user)
class Meta:
model = User
@@ -141,6 +153,7 @@ class ProfileSerializer(serializers.ModelSerializer):
"auth_token",
"social_accounts",
"has_usable_password",
"is_mfa_enabled",
)

View File

@@ -316,6 +316,7 @@ INSTALLED_APPS = [
"allauth",
"allauth.account",
"allauth.socialaccount",
"allauth.mfa",
*env_apps,
]
@@ -458,6 +459,8 @@ SOCIALACCOUNT_PROVIDERS = json.loads(
os.getenv("PAPERLESS_SOCIALACCOUNT_PROVIDERS", "{}"),
)
MFA_TOTP_ISSUER = "Paperless-ngx"
ACCOUNT_EMAIL_SUBJECT_PREFIX = "[Paperless-ngx] "
DISABLE_REGULAR_LOGIN = __get_boolean("PAPERLESS_DISABLE_REGULAR_LOGIN")

View File

@@ -1,6 +1,7 @@
import os
from allauth.account import views as allauth_account_views
from allauth.mfa.base import views as allauth_mfa_views
from allauth.socialaccount import views as allauth_social_account_views
from allauth.urls import build_provider_urlpatterns
from django.conf import settings
@@ -54,6 +55,7 @@ from paperless.views import GenerateAuthTokenView
from paperless.views import GroupViewSet
from paperless.views import ProfileView
from paperless.views import SocialAccountProvidersView
from paperless.views import TOTPView
from paperless.views import UserViewSet
from paperless_mail.views import MailAccountTestView
from paperless_mail.views import MailAccountViewSet
@@ -146,19 +148,34 @@ urlpatterns = [
BulkEditObjectsView.as_view(),
name="bulk_edit_objects",
),
path("profile/generate_auth_token/", GenerateAuthTokenView.as_view()),
path(
"profile/disconnect_social_account/",
DisconnectSocialAccountView.as_view(),
),
path(
"profile/social_account_providers/",
SocialAccountProvidersView.as_view(),
),
re_path(
"^profile/",
ProfileView.as_view(),
name="profile_view",
include(
[
path(
"generate_auth_token/",
GenerateAuthTokenView.as_view(),
),
path(
"disconnect_social_account/",
DisconnectSocialAccountView.as_view(),
),
path(
"social_account_providers/",
SocialAccountProvidersView.as_view(),
),
re_path(
"^$",
ProfileView.as_view(),
name="profile_view",
),
path(
"totp/",
TOTPView.as_view(),
name="totp_view",
),
],
),
),
re_path(
"^status/",
@@ -296,6 +313,12 @@ urlpatterns = [
),
),
*build_provider_urlpatterns(),
# mfa, see allauth/mfa/base/urls.py
path(
"2fa/authenticate/",
allauth_mfa_views.authenticate,
name="mfa_authenticate",
),
],
),
),

View File

@@ -1,6 +1,12 @@
import os
from collections import OrderedDict
from allauth.mfa import signals
from allauth.mfa.adapter import get_adapter as get_mfa_adapter
from allauth.mfa.base.internal.flows import delete_and_cleanup
from allauth.mfa.models import Authenticator
from allauth.mfa.recovery_codes.internal.flows import auto_generate_recovery_codes
from allauth.mfa.totp.internal import auth as totp_auth
from allauth.socialaccount.adapter import get_adapter
from allauth.socialaccount.models import SocialAccount
from django.contrib.auth.models import Group
@@ -8,9 +14,12 @@ from django.contrib.auth.models import User
from django.db.models.functions import Lower
from django.http import HttpResponse
from django.http import HttpResponseBadRequest
from django.http import HttpResponseForbidden
from django.http import HttpResponseNotFound
from django.views.generic import View
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.authtoken.models import Token
from rest_framework.decorators import action
from rest_framework.filters import OrderingFilter
from rest_framework.generics import GenericAPIView
from rest_framework.pagination import PageNumberPagination
@@ -100,6 +109,24 @@ class UserViewSet(ModelViewSet):
filterset_class = UserFilterSet
ordering_fields = ("username",)
@action(detail=True, methods=["post"])
def deactivate_totp(self, request, pk=None):
request_user = request.user
user = User.objects.get(pk=pk)
if not request_user.is_superuser and request_user != user:
return HttpResponseForbidden(
"You do not have permission to deactivate TOTP for this user",
)
authenticator = Authenticator.objects.filter(
user=user,
type=Authenticator.Type.TOTP,
).first()
if authenticator is not None:
delete_and_cleanup(request, authenticator)
return Response(True)
else:
return HttpResponseNotFound("TOTP not found")
class GroupViewSet(ModelViewSet):
model = Group
@@ -145,6 +172,76 @@ class ProfileView(GenericAPIView):
return Response(serializer.to_representation(user))
class TOTPView(GenericAPIView):
"""
TOTP views
"""
permission_classes = [IsAuthenticated]
def get(self, request, *args, **kwargs):
"""
Generates a new TOTP secret and returns the URL and SVG
"""
user = self.request.user
mfa_adapter = get_mfa_adapter()
secret = totp_auth.get_totp_secret(regenerate=True)
url = mfa_adapter.build_totp_url(user, secret)
svg = mfa_adapter.build_totp_svg(url)
return Response(
{
"url": url,
"qr_svg": svg,
"secret": secret,
},
)
def post(self, request, *args, **kwargs):
"""
Validates a TOTP code and activates the TOTP authenticator
"""
valid = totp_auth.validate_totp_code(
request.data["secret"],
request.data["code"],
)
recovery_codes = None
if valid:
auth = totp_auth.TOTP.activate(
request.user,
request.data["secret"],
).instance
signals.authenticator_added.send(
sender=Authenticator,
request=request,
user=request.user,
authenticator=auth,
)
rc_auth: Authenticator = auto_generate_recovery_codes(request)
if rc_auth:
recovery_codes = rc_auth.wrap().get_unused_codes()
return Response(
{
"success": valid,
"recovery_codes": recovery_codes,
},
)
def delete(self, request, *args, **kwargs):
"""
Deactivates the TOTP authenticator
"""
user = self.request.user
authenticator = Authenticator.objects.filter(
user=user,
type=Authenticator.Type.TOTP,
).first()
if authenticator is not None:
delete_and_cleanup(request, authenticator)
return Response(True)
else:
return HttpResponseNotFound("TOTP not found")
class GenerateAuthTokenView(GenericAPIView):
"""
Generates (or re-generates) an auth token, requires a logged in user