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)
|
||||
|
Reference in New Issue
Block a user