From 3fc8ced42ceba91adc5f9c132d40c6d530e0208d Mon Sep 17 00:00:00 2001 From: radthenone Date: Thu, 20 Feb 2025 22:40:07 +0100 Subject: [PATCH] #134-add-multi-factor-authentication - add totp generate with secret and QR code link --- backend/pyproject.toml | 2 +- backend/src/apps/auth/serializers.py | 94 ++++++++++++++++++- backend/src/apps/auth/urls.py | 8 +- backend/src/apps/auth/utils.py | 11 +++ backend/src/apps/auth/views.py | 32 +++++++ .../0002_alter_review_time_spent.py | 19 ++++ backend/src/core/settings/development.py | 8 ++ backend/uv.lock | 32 ++++++- 8 files changed, 199 insertions(+), 7 deletions(-) create mode 100644 backend/src/apps/auth/utils.py create mode 100644 backend/src/apps/threads/migrations/0002_alter_review_time_spent.py diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 9da1185..f4f988d 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -8,7 +8,7 @@ dependencies = [ "boto3>=1.35.97", "dj-database-url>=2.3.0", "dj-rest-auth[with-social]>=7.0.1", - "django-allauth>=65.1.0", + "django-allauth[mfa]>=65.1.0", "django-cors-headers>=4.6.0", "django-storages>=1.14.4", "django==4.2", diff --git a/backend/src/apps/auth/serializers.py b/backend/src/apps/auth/serializers.py index 65d8d42..9b6b96a 100644 --- a/backend/src/apps/auth/serializers.py +++ b/backend/src/apps/auth/serializers.py @@ -1,12 +1,15 @@ -import logging - from dj_rest_auth.registration.serializers import ( RegisterSerializer as BaseRegisterSerializer, ) from dj_rest_auth.serializers import LoginSerializer as BaseLoginSerializer from django.conf import settings from django.utils.translation import gettext_lazy as _ -from rest_framework import exceptions, serializers +from rest_framework import exceptions +from rest_framework import serializers +from urllib.parse import quote, urlencode +from allauth.mfa.models import Authenticator +from allauth.mfa.adapter import get_adapter +from apps.auth.utils import generate_otp, convert_to_base64 from apps.users.models import User @@ -70,3 +73,88 @@ def validate(self, attrs): attrs["user"] = user return attrs + + +class MFASetupSerializer(serializers.Serializer): + def create(self, validated_data): + user = self.context['request'].user + adapter = get_adapter() + + existing_totp = Authenticator.objects.filter( + user=user, + type=Authenticator.Type.TOTP + ).first() + + if existing_totp: + raise serializers.ValidationError({ + 'mfa': 'Totp is already exists' + }) + + number = generate_otp() + secret = convert_to_base64(number) + + authenticator = Authenticator.objects.create( + user=user, + type=Authenticator.Type.TOTP, + data={ + 'secret': adapter.encrypt(secret), + 'digits': settings.MFA_TOTP_DIGITS, + 'period': settings.MFA_TOTP_PERIOD, + 'algorithm': 'SHA1', + 'confirmed': False + } + ) + + totp_url = adapter.build_totp_url(user, authenticator.data['secret']) + + return { + 'secret': secret, + 'qr_url': totp_url + } + + def to_representation(self, instance): + return { + 'secret': instance['secret'], + 'qr_url': instance['qr_url'] + } + + +class MFAVerifySerializer(serializers.Serializer): + code = serializers.CharField() + + def validate(self, attrs): + user = self.context['request'].user + try: + authenticator = Authenticator.objects.get( + user=user, + type=Authenticator.Type.TOTP + ) + + adapter = get_adapter() + secret = adapter.decrypt(authenticator.data['secret']) + + if secret != attrs['code']: + raise serializers.ValidationError({ + 'code': 'Wrong code' + }) + + authenticator.data['confirmed'] = True + authenticator.save() + + except Authenticator.DoesNotExist: + raise serializers.ValidationError({ + 'mfa': 'MFA is not enabled' + }) + + return attrs + + +class MFAStatusSerializer(serializers.Serializer): + mfa_enabled = serializers.BooleanField(read_only=True) + type = serializers.CharField(read_only=True) + + def to_representation(self, instance): + return { + 'mfa_enabled': instance['mfa_enabled'], + 'type': instance['type'] + } \ No newline at end of file diff --git a/backend/src/apps/auth/urls.py b/backend/src/apps/auth/urls.py index 7cead62..4e57e1c 100644 --- a/backend/src/apps/auth/urls.py +++ b/backend/src/apps/auth/urls.py @@ -1,5 +1,5 @@ from dj_rest_auth.views import LogoutView -from django.urls import include, path +from django.urls import path from rest_framework_simplejwt.views import TokenRefreshView, TokenVerifyView from apps.auth.views import ( @@ -7,9 +7,15 @@ GitHubConnectView, LoginView, RegisterView, + MFASetupView, + MFAStatusView, + MFAVerifyView, ) urlpatterns = [ + path("mfa/setup/", MFASetupView.as_view(), name="mfa_setup"), + path("mfa/verify/", MFAVerifyView.as_view(), name="mfa_verify"), + path("mfa/status/", MFAStatusView.as_view(), name="mfa_status"), path("register/", RegisterView.as_view(), name="rest_register"), path("login/", LoginView.as_view(), name="rest_login"), path("logout/", LogoutView.as_view(), name="rest_logout"), diff --git a/backend/src/apps/auth/utils.py b/backend/src/apps/auth/utils.py new file mode 100644 index 0000000..8af6649 --- /dev/null +++ b/backend/src/apps/auth/utils.py @@ -0,0 +1,11 @@ +import random +import base64 + + +def generate_otp(): + number = random.randrange(1, 1000000) + return str(number).zfill(6) + +def convert_to_base64(number: str): + bytes_data = number.encode('utf-8') + return base64.b32encode(bytes_data).decode('utf-8').rstrip('=') \ No newline at end of file diff --git a/backend/src/apps/auth/views.py b/backend/src/apps/auth/views.py index 6adff39..44e5f20 100644 --- a/backend/src/apps/auth/views.py +++ b/backend/src/apps/auth/views.py @@ -5,7 +5,12 @@ from dj_rest_auth.registration.views import SocialConnectView from dj_rest_auth.views import LoginView as BaseLoginView from drf_spectacular.utils import OpenApiExample, extend_schema +from rest_framework import generics +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from apps.auth.serializers import MFASetupSerializer, MFAVerifySerializer, MFAStatusSerializer +from allauth.mfa.adapter import get_adapter @extend_schema( tags=["auth"], @@ -71,3 +76,30 @@ class GitHubConnectView(SocialConnectView): ) class FacebookConnectView(SocialConnectView): adapter_class = FacebookOAuth2Adapter + + +class MFASetupView(generics.CreateAPIView): + permission_classes = [IsAuthenticated] + serializer_class = MFASetupSerializer + + +class MFAVerifyView(generics.GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = MFAVerifySerializer + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + return Response({'status': 'verified'}) + + +class MFAStatusView(generics.RetrieveAPIView): + permission_classes = [IsAuthenticated] + serializer_class = MFAStatusSerializer + + def get_object(self): + adapter = get_adapter() + return { + 'mfa_enabled': adapter.is_mfa_enabled(self.request.user), + 'type': 'totp' + } \ No newline at end of file diff --git a/backend/src/apps/threads/migrations/0002_alter_review_time_spent.py b/backend/src/apps/threads/migrations/0002_alter_review_time_spent.py new file mode 100644 index 0000000..7697eb4 --- /dev/null +++ b/backend/src/apps/threads/migrations/0002_alter_review_time_spent.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2 on 2025-02-20 20:38 + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('threads', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='review', + name='time_spent', + field=models.TimeField(blank=True, default=datetime.time(0, 0), help_text='HH:MM:SS', null=True), + ), + ] diff --git a/backend/src/core/settings/development.py b/backend/src/core/settings/development.py index 4900f78..68adc64 100644 --- a/backend/src/core/settings/development.py +++ b/backend/src/core/settings/development.py @@ -15,6 +15,7 @@ "rest_framework_simplejwt.token_blacklist", "allauth", "allauth.account", + "allauth.mfa", "allauth.socialaccount", "allauth.socialaccount.providers.facebook", "allauth.socialaccount.providers.github", @@ -105,6 +106,9 @@ ACCOUNT_USERNAME_REQUIRED = False ACCOUNT_EMAIL_VERIFICATION = "none" ACCOUNT_AUTHENTICATION_METHOD = "email" +MFA_ADAPTER = "allauth.mfa.adapter.DefaultMFAAdapter" +MFA_TOTP_DIGITS = 6 +MFA_TOTP_PERIOD = 30 # REST AUTH REST_AUTH = { @@ -114,6 +118,10 @@ "LOGIN_SERIALIZER": "apps.auth.serializers.LoginSerializer", "JWT_AUTH_COOKIE": "access-token", "JWT_AUTH_REFRESH_COOKIE": "refresh-token", + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.JWTAuthentication", + "rest_framework.authentication.SessionAuthentication", + ], } # JWT diff --git a/backend/uv.lock b/backend/uv.lock index b74163d..6c561d8 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -28,7 +28,7 @@ dependencies = [ { name = "dj-database-url" }, { name = "dj-rest-auth", extra = ["with-social"] }, { name = "django" }, - { name = "django-allauth" }, + { name = "django-allauth", extra = ["mfa"] }, { name = "django-cors-headers" }, { name = "django-storages" }, { name = "djangorestframework" }, @@ -64,7 +64,7 @@ requires-dist = [ { name = "dj-database-url", specifier = ">=2.3.0" }, { name = "dj-rest-auth", extras = ["with-social"], specifier = ">=7.0.1" }, { name = "django", specifier = "==4.2" }, - { name = "django-allauth", specifier = ">=65.1.0" }, + { name = "django-allauth", extras = ["mfa"], specifier = ">=65.1.0" }, { name = "django-cors-headers", specifier = ">=4.6.0" }, { name = "django-storages", specifier = ">=1.14.4" }, { name = "djangorestframework", specifier = ">=3.15.2" }, @@ -412,6 +412,10 @@ dependencies = [ sdist = { url = "https://files.pythonhosted.org/packages/9a/a7/35a6586490b8d7dc47b514b78c0dab37f00625b3305cba89c48aaed71b5e/django_allauth-65.1.0.tar.gz", hash = "sha256:ab2c32c51797bab1e7a334f37afa61ef4a88c6f13dfd7bd898a16e2b7e567ef0", size = 1292228 } [package.optional-dependencies] +mfa = [ + { name = "fido2" }, + { name = "qrcode" }, +] socialaccount = [ { name = "pyjwt", extra = ["crypto"] }, { name = "requests" }, @@ -554,6 +558,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/fe/40452fb1730b10afa34dfe016097b28baa070ad74a1c1a3512ebed438c08/Faker-35.0.0-py3-none-any.whl", hash = "sha256:926d2301787220e0554c2e39afc4dc535ce4b0a8d0a089657137999f66334ef4", size = 1894841 }, ] +[[package]] +name = "fido2" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/cc/4529123364d41f342145f2fd775307eaed817cd22810895dea10e15a4d06/fido2-1.2.0.tar.gz", hash = "sha256:e39f95920122d64283fda5e5581d95a206e704fa42846bfa4662f86aa0d3333b", size = 266369 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/48/e9b99d66f27d3416a619324568739fd6603e093b2f79138d6f47ccf727b6/fido2-1.2.0-py3-none-any.whl", hash = "sha256:f7c8ee62e359aa980a45773f9493965bb29ede1b237a9218169dbfe60c80e130", size = 219418 }, +] + [[package]] name = "idna" version = "3.10" @@ -1161,6 +1177,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, ] +[[package]] +name = "qrcode" +version = "8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/db/6fc9631cac1327f609d2c8ae3680ecd987a2e97472437f2de7ead1235156/qrcode-8.0.tar.gz", hash = "sha256:025ce2b150f7fe4296d116ee9bad455a6643ab4f6e7dce541613a4758cbce347", size = 42743 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/ab/df8d889fd01139db68ae9e5cb5c8f0ea016823559a6ecb427582d52b07dc/qrcode-8.0-py3-none-any.whl", hash = "sha256:9fc05f03305ad27a709eb742cf3097fa19e6f6f93bb9e2f039c0979190f6f1b1", size = 45710 }, +] + [[package]] name = "referencing" version = "0.36.2"