From ca49e1623d5a558973da0bf5b132dada425034de Mon Sep 17 00:00:00 2001 From: Mohamed El-Kalioby Date: Thu, 24 Jun 2021 18:42:33 +0300 Subject: [PATCH] Controlling TOTP Authetication --- CHANGELOG.md | 4 +++ README.md | 6 +++- example/example/settings.py | 28 +++++++-------- .../0012_rename_user_keys_userkey.py | 6 ++-- mfa/migrations/0013_auto_20210624_1505.py | 35 +++++++++++++++++++ mfa/models.py | 10 +++--- mfa/templates/TOTP/recheck.html | 2 +- mfa/totp.py | 29 +++++++++++---- 8 files changed, 91 insertions(+), 29 deletions(-) create mode 100644 mfa/migrations/0013_auto_20210624_1505.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8219064..e8c61f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Change Log +## 2.3.0 + * Some code cleanup thanks to @xi + * Added: MFA_TOTP_FAILURE_WINDOW & MFA_TOTP_FAILURE_LIMIT + ## 2.2.0 * Added: MFA_REDIRECT_AFTER_REGISTRATION settings parameter * Fixed: Deprecation error for NullBooleanField diff --git a/README.md b/README.md index 482b1ab..be85c60 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,9 @@ Depends on MFA_OWNED_BY_ENTERPRISE = FALSE # Who owns security keys TOKEN_ISSUER_NAME="PROJECT_NAME" #TOTP Issuer name - + MFA_TOTP_FAILURE_LIMIT = 3 # Allowed TOTP Failures / user + MFA_TOTP_FAILURE_WINDOW = 5 # The number of minutes to check failed logins against. + U2F_APPID="https://localhost" #URL For U2F FIDO_SERVER_ID=u"localehost" # Server rp id for FIDO2, it the full domain of your project FIDO_SERVER_NAME=u"PROJECT_NAME" @@ -96,6 +98,8 @@ Depends on * Starting version 1.7.0, Key owners can be specified. * Starting version 2.2.0 * Added: `MFA_SUCCESS_REGISTRATION_MSG` & `MFA_REDIRECT_AFTER_REGISTRATION` + * Starting version 2.3.0 + * Added: `MFA_TOTP_FAILURE_LIMIT` & `MFA_TOTP_FAILURE_WINDOW` 1. Break your login function Usually your login function will check for username and password, log the user in if the username and password are correct and create the user session, to support mfa, this has to change diff --git a/example/example/settings.py b/example/example/settings.py index a3fe9f2..920964a 100644 --- a/example/example/settings.py +++ b/example/example/settings.py @@ -86,20 +86,20 @@ DATABASES = { # Password validation # https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators -AUTH_PASSWORD_VALIDATORS = [ - { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", - }, -] +# AUTH_PASSWORD_VALIDATORS = [ +# { +# "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", +# }, +# { +# "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", +# }, +# { +# "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", +# }, +# { +# "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", +# }, +# ] # Internationalization diff --git a/mfa/migrations/0012_rename_user_keys_userkey.py b/mfa/migrations/0012_rename_user_keys_userkey.py index ddf4889..560f22d 100644 --- a/mfa/migrations/0012_rename_user_keys_userkey.py +++ b/mfa/migrations/0012_rename_user_keys_userkey.py @@ -6,12 +6,12 @@ from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ('mfa', '0011_auto_20210530_0622'), + ("mfa", "0011_auto_20210530_0622"), ] operations = [ migrations.RenameModel( - old_name='User_Keys', - new_name='UserKey', + old_name="User_Keys", + new_name="UserKey", ), ] diff --git a/mfa/migrations/0013_auto_20210624_1505.py b/mfa/migrations/0013_auto_20210624_1505.py new file mode 100644 index 0000000..383a13e --- /dev/null +++ b/mfa/migrations/0013_auto_20210624_1505.py @@ -0,0 +1,35 @@ +# Generated by Django 2.2 on 2021-06-24 15:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("mfa", "0012_rename_user_keys_userkey"), + ] + + operations = [ + migrations.CreateModel( + name="OTPTracker", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("actor", models.CharField(max_length=50)), + ("value", models.CharField(max_length=6)), + ("success", models.BooleanField(blank=True)), + ("done_on", models.DateTimeField(auto_now=True)), + ], + ), + migrations.AddIndex( + model_name="otptracker", + index=models.Index(fields=["actor"], name="mfa_otptrac_usernam_1f423f_idx"), + ), + ] diff --git a/mfa/models.py b/mfa/models.py index ff60416..ac94ac8 100644 --- a/mfa/models.py +++ b/mfa/models.py @@ -33,11 +33,13 @@ class UserKey(models.Model): class OTPTracker(models.Model): - username = models.CharField(max_length=50) - value = models.CharField(max_length = 6) + actor = models.CharField( + max_length=50, help_text="Username" + ) # named this way for indexing purpose. + value = models.CharField(max_length=6) success = models.BooleanField(blank=True) done_on = models.DateTimeField(auto_now=True) class Meta: - app_label = 'mfa' - indexes = [models.Index(fields=['username'])] \ No newline at end of file + app_label = "mfa" + indexes = [models.Index(fields=["actor"])] diff --git a/mfa/templates/TOTP/recheck.html b/mfa/templates/TOTP/recheck.html index 87c5a7c..1a990bf 100644 --- a/mfa/templates/TOTP/recheck.html +++ b/mfa/templates/TOTP/recheck.html @@ -28,7 +28,7 @@ {% csrf_token %} {% if invalid %}
- Sorry, The provided token is not valid. + {{ invalid_msg }}
{% endif %} {% if quota %} diff --git a/mfa/totp.py b/mfa/totp.py index 3d2b6db..0d0d46b 100644 --- a/mfa/totp.py +++ b/mfa/totp.py @@ -16,17 +16,34 @@ from .views import login def verify_login(request, username, token): + FAILURE_LIMIT = getattr("settings", "MFA_TOTP_FAILURE_LIMIT", 3) + start_time = timezone.now() + datetime.timedelta( + minutes=-1 * getattr(settings, "MFA_TOTP_FAILURE_WINDOW", 5) + ) + if ( + OTPTracker.objects.filter( + done_on__gt=start_time, actor=username, success=0 + ).count() + >= FAILURE_LIMIT + ): + return [ + False, + "Using this method is temporarily suspended on your account, use another method, or later again later ", + ] for key in UserKey.objects.filter(username=username, key_type="TOTP"): totp = pyotp.TOTP(key.properties["secret_key"]) if totp.verify(token, valid_window=30): - if OTPTracker.objects.filter(username=username, value=token).exists(): - return [False, "Used Before, please generate another token"] - TOTP_Tracker.objects.create(username=username,value=token, success=True) + if OTPTracker.objects.filter(actor=username, value=token).exists(): + return [ + False, + "This code is used before, please generate another token", + ] + OTPTracker.objects.create(actor=username, value=token, success=True) key.last_used = timezone.now() key.save() return [True, key.id] - TOTP_Tracker.objects.create(username = username, value = token, success = False) - return [False,"Invalid Token"] + OTPTracker.objects.create(actor=username, value=token, success=False) + return [False, "Invalid Token"] def recheck(request): @@ -68,7 +85,7 @@ def auth(request): return render(request, "TOTP/Auth.html", context) -def getToken(request): +def get_token(request): secret_key = pyotp.random_base32() totp = pyotp.TOTP(secret_key) request.session["new_mfa_answer"] = totp.now()