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;k
g?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 %}
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 %}
-
+
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})