Major 2fa updates

This commit is contained in:
Alexander Karpov 2024-02-03 02:41:50 +03:00
parent 6a7e7d5ade
commit 3e26cb24a6
8 changed files with 134 additions and 15 deletions

19
akarpov/static/js/jquery.mask.min.js vendored Normal file
View File

@ -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<p;k++){var b=a[k];if(n.call(f,b,k,a))return{i:k,v:b}}return{i:-1,v:void 0}};$jscomp.ASSUME_ES5=!1;$jscomp.ASSUME_NO_NATIVE_MAP=!1;$jscomp.ASSUME_NO_NATIVE_SET=!1;$jscomp.SIMPLE_FROUND_POLYFILL=!1;
$jscomp.defineProperty=$jscomp.ASSUME_ES5||"function"==typeof Object.defineProperties?Object.defineProperty:function(a,n,f){a!=Array.prototype&&a!=Object.prototype&&(a[n]=f.value)};$jscomp.getGlobal=function(a){return"undefined"!=typeof window&&window===a?a:"undefined"!=typeof global&&null!=global?global:a};$jscomp.global=$jscomp.getGlobal(this);
$jscomp.polyfill=function(a,n,f,p){if(n){f=$jscomp.global;a=a.split(".");for(p=0;p<a.length-1;p++){var k=a[p];k in f||(f[k]={});f=f[k]}a=a[a.length-1];p=f[a];n=n(p);n!=p&&null!=n&&$jscomp.defineProperty(f,a,{configurable:!0,writable:!0,value:n})}};$jscomp.polyfill("Array.prototype.find",function(a){return a?a:function(a,f){return $jscomp.findInternal(this,a,f).v}},"es6","es3");
(function(a,n,f){"function"===typeof define&&define.amd?define(["jquery"],a):"object"===typeof exports&&"undefined"===typeof Meteor?module.exports=a(require("jquery")):a(n||f)})(function(a){var n=function(b,d,e){var c={invalid:[],getCaret:function(){try{var a=0,r=b.get(0),h=document.selection,d=r.selectionStart;if(h&&-1===navigator.appVersion.indexOf("MSIE 10")){var e=h.createRange();e.moveStart("character",-c.val().length);a=e.text.length}else if(d||"0"===d)a=d;return a}catch(C){}},setCaret:function(a){try{if(b.is(":focus")){var c=
b.get(0);if(c.setSelectionRange)c.setSelectionRange(a,a);else{var g=c.createTextRange();g.collapse(!0);g.moveEnd("character",a);g.moveStart("character",a);g.select()}}}catch(B){}},events:function(){b.on("keydown.mask",function(a){b.data("mask-keycode",a.keyCode||a.which);b.data("mask-previus-value",b.val());b.data("mask-previus-caret-pos",c.getCaret());c.maskDigitPosMapOld=c.maskDigitPosMap}).on(a.jMaskGlobals.useInput?"input.mask":"keyup.mask",c.behaviour).on("paste.mask drop.mask",function(){setTimeout(function(){b.keydown().keyup()},
100)}).on("change.mask",function(){b.data("changed",!0)}).on("blur.mask",function(){f===c.val()||b.data("changed")||b.trigger("change");b.data("changed",!1)}).on("blur.mask",function(){f=c.val()}).on("focus.mask",function(b){!0===e.selectOnFocus&&a(b.target).select()}).on("focusout.mask",function(){e.clearIfNotMatch&&!k.test(c.val())&&c.val("")})},getRegexMask:function(){for(var a=[],b,c,e,t,f=0;f<d.length;f++)(b=l.translation[d.charAt(f)])?(c=b.pattern.toString().replace(/.{1}$|^.{1}/g,""),e=b.optional,
(b=b.recursive)?(a.push(d.charAt(f)),t={digit:d.charAt(f),pattern:c}):a.push(e||b?c+"?":c)):a.push(d.charAt(f).replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&"));a=a.join("");t&&(a=a.replace(new RegExp("("+t.digit+"(.*"+t.digit+")?)"),"($1)?").replace(new RegExp(t.digit,"g"),t.pattern));return new RegExp(a)},destroyEvents:function(){b.off("input keydown keyup paste drop blur focusout ".split(" ").join(".mask "))},val:function(a){var c=b.is("input")?"val":"text";if(0<arguments.length){if(b[c]()!==a)b[c](a);
c=b}else c=b[c]();return c},calculateCaretPosition:function(a){var d=c.getMasked(),h=c.getCaret();if(a!==d){var e=b.data("mask-previus-caret-pos")||0;d=d.length;var g=a.length,f=a=0,l=0,k=0,m;for(m=h;m<d&&c.maskDigitPosMap[m];m++)f++;for(m=h-1;0<=m&&c.maskDigitPosMap[m];m--)a++;for(m=h-1;0<=m;m--)c.maskDigitPosMap[m]&&l++;for(m=e-1;0<=m;m--)c.maskDigitPosMapOld[m]&&k++;h>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<n}}else x=k-1,A=function(){return g<k&&n<p};for(var z;A();){var y=d.charAt(g),v=f.charAt(n),q=l.translation[y];if(q)v.match(q.pattern)?(h[r](v),q.recursive&&(-1===u?u=g:g===x&&g!==u&&(g=u-m),x===u&&(g-=m)),g+=m):v===z?(w--,z=void 0):q.optional?(g+=m,n-=m):q.fallback?(h[r](q.fallback),g+=m,n-=m):c.invalid.push({p:n,v:v,e:q.pattern}),n+=m;else{if(!a)h[r](y);v===y?(b.push(n),n+=m):(z=y,b.push(n+w),w++);g+=m}}a=d.charAt(x);k!==p+1||l.translation[a]||h.push(a);h=h.join("");c.mapMaskdigitPositions(h,
b,p);return h},mapMaskdigitPositions:function(a,b,d){a=e.reverse?a.length-d:0;c.maskDigitPosMap={};for(d=0;d<b.length;d++)c.maskDigitPosMap[b[d]+a]=1},callbacks:function(a){var g=c.val(),h=g!==f,k=[g,a,b,e],l=function(a,b,c){"function"===typeof e[a]&&b&&e[a].apply(this,c)};l("onChange",!0===h,k);l("onKeyPress",!0===h,k);l("onComplete",g.length===d.length,k);l("onInvalid",0<c.invalid.length,[g,a,b,c.invalid,e])}};b=a(b);var l=this,f=c.val(),k;d="function"===typeof d?d(c.val(),void 0,b,e):d;l.mask=
d;l.options=e;l.remove=function(){var a=c.getCaret();l.options.placeholder&&b.removeAttr("placeholder");b.data("mask-maxlength")&&b.removeAttr("maxlength");c.destroyEvents();c.val(l.getCleanVal());c.setCaret(a);return b};l.getCleanVal=function(){return c.getMasked(!0)};l.getMaskedVal=function(a){return c.getMasked(!1,a)};l.init=function(g){g=g||!1;e=e||{};l.clearIfNotMatch=a.jMaskGlobals.clearIfNotMatch;l.byPassKeys=a.jMaskGlobals.byPassKeys;l.translation=a.extend({},a.jMaskGlobals.translation,e.translation);
l=a.extend(!0,{},l,e);k=c.getRegexMask();if(g)c.events(),c.val(c.getMasked());else{e.placeholder&&b.attr("placeholder",e.placeholder);b.data("mask")&&b.attr("autocomplete","off");g=0;for(var f=!0;g<d.length;g++){var h=l.translation[d.charAt(g)];if(h&&h.recursive){f=!1;break}}f&&b.attr("maxlength",d.length).data("mask-maxlength",!0);c.destroyEvents();c.events();g=c.getCaret();c.val(c.getMasked());c.setCaret(g)}};l.init(!b.is("input"))};a.maskWatchers={};var f=function(){var b=a(this),d={},e=b.attr("data-mask");
b.attr("data-mask-reverse")&&(d.reverse=!0);b.attr("data-mask-clearifnotmatch")&&(d.clearIfNotMatch=!0);"true"===b.attr("data-mask-selectonfocus")&&(d.selectOnFocus=!0);if(p(b,e,d))return b.data("mask",new n(this,e,d))},p=function(b,d,e){e=e||{};var c=a(b).data("mask"),f=JSON.stringify;b=a(b).val()||a(b).text();try{return"function"===typeof d&&(d=d(b)),"object"!==typeof c||f(c.options)!==f(e)||c.mask!==d}catch(w){}},k=function(a){var b=document.createElement("div");a="on"+a;var e=a in b;e||(b.setAttribute(a,
"return;"),e="function"===typeof b[a]);return e};a.fn.mask=function(b,d){d=d||{};var e=this.selector,c=a.jMaskGlobals,f=c.watchInterval;c=d.watchInputs||c.watchInputs;var k=function(){if(p(this,b,d))return a(this).data("mask",new n(this,b,d))};a(this).each(k);e&&""!==e&&c&&(clearInterval(a.maskWatchers[e]),a.maskWatchers[e]=setInterval(function(){a(document).find(e).each(k)},f));return this};a.fn.masked=function(a){return this.data("mask").getMaskedVal(a)};a.fn.unmask=function(){clearInterval(a.maskWatchers[this.selector]);
delete a.maskWatchers[this.selector];return this.each(function(){var b=a(this).data("mask");b&&b.remove().removeData("mask")})};a.fn.cleanVal=function(){return this.data("mask").getCleanVal()};a.applyDataMask=function(b){b=b||a.jMaskGlobals.maskElements;(b instanceof a?b:a(b)).filter(a.jMaskGlobals.dataMaskAttr).each(f)};k={maskElements:"input,td,span,div",dataMaskAttr:"*[data-mask]",dataMask:!0,watchInterval:300,watchInputs:!0,keyStrokeCompensation:10,useInput:!/Chrome\/[2-4][0-9]|SamsungBrowser/.test(window.navigator.userAgent)&&
k("input"),watchDataMask:!1,byPassKeys:[9,16,17,18,36,37,38,39,40,91],translation:{0:{pattern:/\d/},9:{pattern:/\d/,optional:!0},"#":{pattern:/\d/,recursive:!0},A:{pattern:/[a-zA-Z0-9]/},S:{pattern:/[a-zA-Z]/}}};a.jMaskGlobals=a.jMaskGlobals||{};k=a.jMaskGlobals=a.extend(!0,{},k,a.jMaskGlobals);k.dataMask&&a.applyDataMask();setInterval(function(){a.jMaskGlobals.watchDataMask&&a.applyDataMask()},k.watchInterval)},window.jQuery,window.Zepto);

View File

@ -1,6 +1,17 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% block inline_javascript %}
<script>
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);
}
});
</script>
{% endblock %}
{% block content %} {% block content %}
<div class="container"> <div class="container">
<div class="row"> <div class="row">
@ -13,8 +24,14 @@
<br> <br>
<form method="POST"> <form method="POST">
{% csrf_token %} {% csrf_token %}
{% crispy form %} <div class="input-group gap-4">
<button type="submit" class="btn btn-primary btn-block" value="Disable 2FA"> <input name="{{ form.otp_token.name }}"
id="totp" type="tel" pattern="[0-9]{6}" inputmode="numeric" class="form-control rounded" placeholder="______" aria-label="TOTP" required autofocus>
{% if form.otp_token.errors %}
{% endif %}
<div class="error-message">{{ form.otp_token.errors }}</div>
<button type="submit" class="btn btn-primary btn-block" value="Disable 2FA"> Disable 2FA </button>
</div>
</form> </form>
</div> </div>
</div> </div>

View File

@ -1,16 +1,40 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load crispy_forms_tags %} {% load crispy_forms_tags static %}
{% block javascript %}
<script src="{% static 'js/jquery.min.js' %}"></script>
<script src="{% static 'js/jquery.mask.min.js' %}"></script>
{% endblock %}
{% block inline_javascript %}
<script>
$(document).ready(function(){
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);
}
});
});
</script>
{% endblock %}
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="d-flex justify-content-center align-items-center" style="height: calc(100vh - 60px);">
<div class="col-md-6"> <div class="col-md-6">
<div class="card"> <div class="card">
<div class="card-header bg-primary text-white">Enter OTP</div> <div class="card-header bg-primary text-white">Enter OTP</div>
<div class="card-body"> <div class="card-body">
<form method="post"> <form method="POST" class="form">
{% csrf_token %} {% csrf_token %}
{{ form|crispy }} <div class="input-group gap-4">
<button type="submit" class="btn btn-primary mt-2">Submit OTP</button> <input name="{{ form.otp_token.name }}" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" maxlength="6" size="6" minlength="6"
id="totp" type="tel" pattern="\d{6}" inputmode="numeric" class="form-control rounded" placeholder="______" aria-label="TOTP" required autofocus>
{% if form.otp_token.errors %}
{% endif %}
<div class="error-message">{{ form.otp_token.errors }}</div>
<button type="submit" class="btn btn-primary btn-block" value="Disable 2FA"> Submit </button>
</div>
</form> </form>
</div> </div>
</div> </div>

View File

@ -78,3 +78,7 @@ def update(self, instance, validated_data):
instance.set_password(validated_data["password"]) instance.set_password(validated_data["password"])
instance.save(update_fields=["password"]) instance.save(update_fields=["password"])
return instance return instance
class OTPSerializer(serializers.Serializer):
token = serializers.CharField(required=True)

View File

@ -6,6 +6,7 @@
UserRetrieveAPIViewSet, UserRetrieveAPIViewSet,
UserRetrieveIdAPIAPIView, UserRetrieveIdAPIAPIView,
UserUpdatePasswordAPIView, UserUpdatePasswordAPIView,
VerifyOTPView,
) )
app_name = "users_api" app_name = "users_api"
@ -18,6 +19,11 @@
UserRetireUpdateSelfAPIViewSet.as_view(), UserRetireUpdateSelfAPIViewSet.as_view(),
name="self", name="self",
), ),
path(
"self/otp/verify/",
VerifyOTPView.as_view(),
name="otp_verify",
),
path( path(
"self/password", "self/password",
UserUpdatePasswordAPIView.as_view(), UserUpdatePasswordAPIView.as_view(),

View File

@ -1,11 +1,14 @@
from django_otp.plugins.otp_totp.models import TOTPDevice
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from rest_framework import generics, permissions, status, views from rest_framework import generics, permissions, status, views
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from akarpov.common.api.pagination import SmallResultsSetPagination from akarpov.common.api.pagination import SmallResultsSetPagination
from akarpov.common.jwt import sign_jwt from akarpov.common.jwt import sign_jwt
from akarpov.users.api.serializers import ( from akarpov.users.api.serializers import (
OTPSerializer,
UserEmailVerification, UserEmailVerification,
UserFullPublicInfoSerializer, UserFullPublicInfoSerializer,
UserFullSerializer, UserFullSerializer,
@ -105,3 +108,24 @@ class UserUpdatePasswordAPIView(generics.UpdateAPIView):
def get_object(self): def get_object(self):
return self.request.user 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)

View File

@ -1,6 +1,7 @@
from cacheops import cached_as from cacheops import cached_as
from django.http import JsonResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import resolve from django.urls import resolve, reverse
from django.utils.deprecation import MiddlewareMixin from django.utils.deprecation import MiddlewareMixin
from django_otp.plugins.otp_totp.models import TOTPDevice from django_otp.plugins.otp_totp.models import TOTPDevice
from rest_framework.exceptions import AuthenticationFailed from rest_framework.exceptions import AuthenticationFailed
@ -20,11 +21,19 @@ def __init__(self, get_response):
def __call__(self, request): def __call__(self, request):
response = self.get_response(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 # Check user is authenticated and OTP token input is not completed
is_authenticated = request.user.is_authenticated is_authenticated = request.user.is_authenticated
otp_not_verified = not request.session.get("otp_verified", False) 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 # Caches the checker for has_otp_device
@cached_as( @cached_as(
@ -33,7 +42,6 @@ def __call__(self, request):
def has_otp_device(user): def has_otp_device(user):
return TOTPDevice.objects.devices_for_user(user, confirmed=True).exists() 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 ( if (
is_authenticated is_authenticated
and has_otp_device(request.user) and has_otp_device(request.user)
@ -41,6 +49,17 @@ def has_otp_device(user):
and not on_2fa_page and not on_2fa_page
): ):
request.session["next"] = request.get_full_path() 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 redirect("users:enforce_otp_login")
return response return response

View File

@ -103,6 +103,8 @@ def get_redirect_url(self):
def enable_2fa_view(request): def enable_2fa_view(request):
user = request.user user = request.user
devices = TOTPDevice.objects.filter(user=user, confirmed=True) devices = TOTPDevice.objects.filter(user=user, confirmed=True)
qr_code_svg = None
totp_key = None
if devices.exists(): if devices.exists():
if request.method == "POST": if request.method == "POST":
@ -184,7 +186,11 @@ def form_valid(self, form):
@login_required @login_required
def enforce_otp_login(request): 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": if request.method == "POST":
form = OTPForm(request.POST) form = OTPForm(request.POST)
if form.is_valid(): if form.is_valid():
@ -192,8 +198,8 @@ def enforce_otp_login(request):
device = TOTPDevice.objects.filter(user=request.user).first() device = TOTPDevice.objects.filter(user=request.user).first()
if device.verify_token(otp_token): if device.verify_token(otp_token):
request.session["otp_verified"] = True request.session["otp_verified"] = True
success_url = request.session.pop("next", None) or reverse_lazy("home") request.session.pop("next", None)
return HttpResponseRedirect(success_url) return redirect(next_url)
else: else:
form = OTPForm() form = OTPForm()
return render(request, "users/otp_verify.html", {"form": form}) return render(request, "users/otp_verify.html", {"form": form})