mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-07-28 18:24:38 -05:00
Feature: two-factor authentication (#8012)
This commit is contained in:
@@ -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:
|
||||
|
35
src/documents/templates/mfa/authenticate.html
Normal file
35
src/documents/templates/mfa/authenticate.html
Normal 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 %}
|
@@ -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/"
|
||||
|
@@ -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)
|
||||
|
@@ -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 ""
|
||||
|
||||
|
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
@@ -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")
|
||||
|
@@ -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",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@@ -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
|
||||
|
Reference in New Issue
Block a user