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" %}
{% 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 %}
<div class="container">
<div class="row">
@ -13,8 +24,14 @@
<br>
<form method="POST">
{% csrf_token %}
{% crispy form %}
<button type="submit" class="btn btn-primary btn-block" value="Disable 2FA">
<div class="input-group gap-4">
<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>
</div>
</div>

View File

@ -1,16 +1,40 @@
{% 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 %}
<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="card">
<div class="card-header bg-primary text-white">Enter OTP</div>
<div class="card-body">
<form method="post">
<form method="POST" class="form">
{% csrf_token %}
{{ form|crispy }}
<button type="submit" class="btn btn-primary mt-2">Submit OTP</button>
<div class="input-group gap-4">
<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>
</div>
</div>

View File

@ -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)

View File

@ -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(),

View File

@ -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)

View File

@ -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

View File

@ -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})