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

@@ -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