diff --git a/akarpov/static/js/jquery.mask.min.js b/akarpov/static/js/jquery.mask.min.js new file mode 100644 index 0000000..1f22376 --- /dev/null +++ b/akarpov/static/js/jquery.mask.min.js @@ -0,0 +1,19 @@ +// jQuery Mask Plugin v1.14.16 +// github.com/igorescobar/jQuery-Mask-Plugin +var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.findInternal=function(a,n,f){a instanceof String&&(a=String(a));for(var p=a.length,k=0;kg?h=10*d:e>=h&&e!==g?c.maskDigitPosMapOld[h]||(e=h,h=h-(k-l)-a,c.maskDigitPosMap[h]&&(h=e)):h>e&&(h=h+(l-k)+f)}return h},behaviour:function(d){d= +d||window.event;c.invalid=[];var e=b.data("mask-keycode");if(-1===a.inArray(e,l.byPassKeys)){e=c.getMasked();var h=c.getCaret(),g=b.data("mask-previus-value")||"";setTimeout(function(){c.setCaret(c.calculateCaretPosition(g))},a.jMaskGlobals.keyStrokeCompensation);c.val(e);c.setCaret(h);return c.callbacks(d)}},getMasked:function(a,b){var h=[],f=void 0===b?c.val():b+"",g=0,k=d.length,n=0,p=f.length,m=1,r="push",u=-1,w=0;b=[];if(e.reverse){r="unshift";m=-1;var x=0;g=k-1;n=p-1;var A=function(){return-1< +g&&-1 +document.getElementById('totp').addEventListener('input', function (e) { + e.target.value = e.target.value.replace(/[^\d]/g, ''); + if (e.target.value.length > 6) { + e.target.value = e.target.value.slice(0, 6); + } +}); + +{% endblock %} + {% block content %}
@@ -13,8 +24,14 @@
{% csrf_token %} - {% crispy form %} - +
diff --git a/akarpov/templates/users/otp_verify.html b/akarpov/templates/users/otp_verify.html index 86e95d1..bc81990 100644 --- a/akarpov/templates/users/otp_verify.html +++ b/akarpov/templates/users/otp_verify.html @@ -1,17 +1,41 @@ {% extends 'base.html' %} -{% load crispy_forms_tags %} +{% load crispy_forms_tags static %} + +{% block javascript %} + + +{% endblock %} + +{% block inline_javascript %} + +{% endblock %} {% block content %} -
+
Enter OTP
-
- {% csrf_token %} - {{ form|crispy }} - -
+
+ {% csrf_token %} +
+ + {% if form.otp_token.errors %} + {% endif %} +
{{ form.otp_token.errors }}
+ +
+
diff --git a/akarpov/users/api/serializers.py b/akarpov/users/api/serializers.py index 704212e..8dc04d0 100644 --- a/akarpov/users/api/serializers.py +++ b/akarpov/users/api/serializers.py @@ -78,3 +78,7 @@ def update(self, instance, validated_data): instance.set_password(validated_data["password"]) instance.save(update_fields=["password"]) return instance + + +class OTPSerializer(serializers.Serializer): + token = serializers.CharField(required=True) diff --git a/akarpov/users/api/urls.py b/akarpov/users/api/urls.py index 68ad295..68cc5ae 100644 --- a/akarpov/users/api/urls.py +++ b/akarpov/users/api/urls.py @@ -6,6 +6,7 @@ UserRetrieveAPIViewSet, UserRetrieveIdAPIAPIView, UserUpdatePasswordAPIView, + VerifyOTPView, ) app_name = "users_api" @@ -18,6 +19,11 @@ UserRetireUpdateSelfAPIViewSet.as_view(), name="self", ), + path( + "self/otp/verify/", + VerifyOTPView.as_view(), + name="otp_verify", + ), path( "self/password", UserUpdatePasswordAPIView.as_view(), diff --git a/akarpov/users/api/views.py b/akarpov/users/api/views.py index de91283..a46e798 100644 --- a/akarpov/users/api/views.py +++ b/akarpov/users/api/views.py @@ -1,11 +1,14 @@ +from django_otp.plugins.otp_totp.models import TOTPDevice from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema from rest_framework import generics, permissions, status, views +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from akarpov.common.api.pagination import SmallResultsSetPagination from akarpov.common.jwt import sign_jwt from akarpov.users.api.serializers import ( + OTPSerializer, UserEmailVerification, UserFullPublicInfoSerializer, UserFullSerializer, @@ -105,3 +108,24 @@ class UserUpdatePasswordAPIView(generics.UpdateAPIView): def get_object(self): return self.request.user + + +class VerifyOTPView(generics.GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = OTPSerializer + + def post(self, request, *args, **kwargs): + serializer = OTPSerializer(data=request.data) + if serializer.is_valid(): + otp_token = serializer.validated_data.get("token") + + device = TOTPDevice.objects.filter(user=request.user).first() + if device.verify_token(otp_token): + return Response({"status": "OTP Token validated successfully"}) + else: + return Response( + {"error": "OTP Token is invalid"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/akarpov/users/middleware.py b/akarpov/users/middleware.py index ae02bdd..a04542a 100644 --- a/akarpov/users/middleware.py +++ b/akarpov/users/middleware.py @@ -1,6 +1,7 @@ from cacheops import cached_as +from django.http import JsonResponse from django.shortcuts import redirect -from django.urls import resolve +from django.urls import resolve, reverse from django.utils.deprecation import MiddlewareMixin from django_otp.plugins.otp_totp.models import TOTPDevice from rest_framework.exceptions import AuthenticationFailed @@ -20,11 +21,19 @@ def __init__(self, get_response): def __call__(self, request): response = self.get_response(request) + if ( + request.path_info.startswith("/api/v1/music/") + or request.path_info == "/api/v1/auth/token/" + ): + return response # Check user is authenticated and OTP token input is not completed is_authenticated = request.user.is_authenticated otp_not_verified = not request.session.get("otp_verified", False) - on_2fa_page = resolve(request.path_info).url_name == "enforce_otp_login" + on_2fa_page = resolve(request.path_info).url_name in [ + "enforce_otp_login", + "otp_verify", + ] # Caches the checker for has_otp_device @cached_as( @@ -33,7 +42,6 @@ def __call__(self, request): def has_otp_device(user): return TOTPDevice.objects.devices_for_user(user, confirmed=True).exists() - # Enforce OTP token input, if user is authenticated, has OTP enabled but has not verified OTP if ( is_authenticated and has_otp_device(request.user) @@ -41,6 +49,17 @@ def has_otp_device(user): and not on_2fa_page ): request.session["next"] = request.get_full_path() + + # If the API is being accessed, return a JsonResponse + if request.path_info.startswith("/api/"): + return JsonResponse( + { + "error": "2FA is required.", + "link": reverse("users:enforce_otp_login"), + }, + status=403, + ) + return redirect("users:enforce_otp_login") return response diff --git a/akarpov/users/views.py b/akarpov/users/views.py index c429754..8bcb916 100644 --- a/akarpov/users/views.py +++ b/akarpov/users/views.py @@ -103,6 +103,8 @@ def get_redirect_url(self): def enable_2fa_view(request): user = request.user devices = TOTPDevice.objects.filter(user=user, confirmed=True) + qr_code_svg = None + totp_key = None if devices.exists(): if request.method == "POST": @@ -184,7 +186,11 @@ def form_valid(self, form): @login_required def enforce_otp_login(request): - # TODO gather next url from loginrequired + next_url = request.GET.get("next") + + if not next_url: + next_url = request.session.get("next", reverse_lazy("home")) + if request.method == "POST": form = OTPForm(request.POST) if form.is_valid(): @@ -192,8 +198,8 @@ def enforce_otp_login(request): device = TOTPDevice.objects.filter(user=request.user).first() if device.verify_token(otp_token): request.session["otp_verified"] = True - success_url = request.session.pop("next", None) or reverse_lazy("home") - return HttpResponseRedirect(success_url) + request.session.pop("next", None) + return redirect(next_url) else: form = OTPForm() return render(request, "users/otp_verify.html", {"form": form})