Compare commits

...

4 Commits

Author SHA1 Message Date
dependabot[bot]
041301bd7f
Merge 93e6169b37 into 87fc3a906f 2024-02-02 23:50:55 +00:00
87fc3a906f fixing git 2024-02-03 02:50:46 +03:00
3e26cb24a6 Major 2fa updates 2024-02-03 02:41:50 +03:00
dependabot[bot]
93e6169b37
Bump drf-spectacular from 0.26.5 to 0.27.1
Bumps [drf-spectacular](https://github.com/tfranzel/drf-spectacular) from 0.26.5 to 0.27.1.
- [Release notes](https://github.com/tfranzel/drf-spectacular/releases)
- [Changelog](https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/tfranzel/drf-spectacular/compare/0.26.5...0.27.1)

---
updated-dependencies:
- dependency-name: drf-spectacular
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-19 14:25:04 +00:00
10 changed files with 174 additions and 362 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,17 +1,41 @@
{% 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"
</form> 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>
</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})

383
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -32,7 +32,7 @@ django-colorfield = "^0.11.0"
djangorestframework = "^3.14.0" djangorestframework = "^3.14.0"
django-rest-auth = "^0.9.5" django-rest-auth = "^0.9.5"
django-cors-headers = "^4.0.0" django-cors-headers = "^4.0.0"
drf-spectacular = "^0.26.2" drf-spectacular = "^0.27.1"
werkzeug = {extras = ["watchdog"], version = "^2.3.4"} werkzeug = {extras = ["watchdog"], version = "^2.3.4"}
ipdb = "^0.13.13" ipdb = "^0.13.13"
watchfiles = "^0.18.1" watchfiles = "^0.18.1"
@ -119,7 +119,7 @@ spotdl = "^4.2.4"
fuzzywuzzy = "^0.18.0" fuzzywuzzy = "^0.18.0"
python-levenshtein = "^0.23.0" python-levenshtein = "^0.23.0"
pylast = "^5.2.0" pylast = "^5.2.0"
textract = {git = "https://github.com/Alexander-D-Karpov/textract", branch = "master"} textract = {git = "https://github.com/Alexander-D-Karpov/textract.git", branch = "master"}
librosa = "^0.10.1" librosa = "^0.10.1"