From 98ca5e972d923cb0148d9211d39d611ee6452e65 Mon Sep 17 00:00:00 2001 From: Spitap Date: Thu, 25 Aug 2022 19:19:30 +0200 Subject: [PATCH] recovery code hashing --- README.md | 6 ++++- example/example/settings.py | 5 +++- mfa/Email.py | 2 -- mfa/FIDO2.py | 2 -- mfa/TrustedDevice.py | 2 -- mfa/U2F.py | 2 -- mfa/recovery.py | 44 +++++++++++++++++------------- mfa/templates/RECOVERY/Add.html | 47 +++++++++++++++------------------ mfa/templates/TOTP/recheck.html | 9 ++++--- mfa/totp.py | 2 -- mfa/urls.py | 2 +- 11 files changed, 63 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 967ceae..b2ac4c3 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,8 @@ Depends on `python manage.py collectstatic` 3. Add the following settings to your file - ```python + ```python + from django.conf.global_settings import PASSWORD_HASHERS as DEFAULT_PASSWORD_HASHERS #Preferably at the same place where you import your other modules MFA_UNALLOWED_METHODS=() # Methods that shouldn't be allowed for the user MFA_LOGIN_CALLBACK="" # A function that should be called by username to login the user in session MFA_RECHECK=True # Allow random rechecking of the user @@ -77,6 +78,9 @@ Depends on MFA_QUICKLOGIN=True # Allow quick login for returning users by provide only their 2FA MFA_HIDE_DISABLE=('FIDO2',) # Can the user disable his key (Added in 1.2.0). MFA_OWNED_BY_ENTERPRISE = FALSE # Who owns security keys + PASSWORD_HASHERS = DEFAULT_PASSWORD_HASHERS #Comment if PASSWORD_HASHER already set + PASSWORD_HASHERS += ['mfa.recovery.Hash'] + RECOVERY_ITERATION = 350000 #Number of iteration for recovery code, higher is more secure, but uses more resources for generation and check... TOKEN_ISSUER_NAME="PROJECT_NAME" #TOTP Issuer name diff --git a/example/example/settings.py b/example/example/settings.py index 6c1b772..b55196d 100644 --- a/example/example/settings.py +++ b/example/example/settings.py @@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/2.0/ref/settings/ """ import os +from django.conf.global_settings import PASSWORD_HASHERS as DEFAULT_PASSWORD_HASHERS # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -142,7 +143,9 @@ MFA_QUICKLOGIN=True # Allow quick login for returning users by provide on MFA_HIDE_DISABLE=('',) # Can the user disable his key (Added in 1.2.0). MFA_REDIRECT_AFTER_REGISTRATION="registered" MFA_SUCCESS_REGISTRATION_MSG="Go to Home" - +PASSWORD_HASHERS = DEFAULT_PASSWORD_HASHERS #Comment if PASSWORD_HASHER already set +PASSWORD_HASHERS += ['mfa.recovery.Hash'] +RECOVERY_ITERATION = 1 #Number of iteration for recovery code, higher is more secure, but uses more resources for generation and check... TOKEN_ISSUER_NAME="PROJECT_NAME" #TOTP Issuer name U2F_APPID="https://localhost" #URL For U2F diff --git a/mfa/Email.py b/mfa/Email.py index 7bbb26d..010d6d5 100644 --- a/mfa/Email.py +++ b/mfa/Email.py @@ -7,7 +7,6 @@ from .models import * #from django.template.context import RequestContext from .views import login from .Common import send -from . import recovery def sendEmail(request,username,secret): """Send Email to the user after rendering `mfa_email_token_template`""" @@ -35,7 +34,6 @@ def start(request): from django.core.urlresolvers import reverse except: from django.urls import reverse - recovery.genTokens(request, True) #recovery tokens return HttpResponseRedirect(reverse(getattr(settings,'MFA_REDIRECT_AFTER_REGISTRATION','mfa_home'))) context["invalid"] = True else: diff --git a/mfa/FIDO2.py b/mfa/FIDO2.py index e41c27c..dcdf9f2 100644 --- a/mfa/FIDO2.py +++ b/mfa/FIDO2.py @@ -16,7 +16,6 @@ from .views import login, reset_cookie import datetime from .Common import get_redirect_url from django.utils import timezone -from . import recovery def recheck(request): @@ -67,7 +66,6 @@ def complete_reg(request): uk.owned_by_enterprise = getattr(settings, "MFA_OWNED_BY_ENTERPRISE", False) uk.key_type = "FIDO2" uk.save() - recovery.genTokens(request, True) #recovery tokens return HttpResponse(simplejson.dumps({'status': 'OK'})) except Exception as exp: import traceback diff --git a/mfa/TrustedDevice.py b/mfa/TrustedDevice.py index aac4f6c..94b2136 100644 --- a/mfa/TrustedDevice.py +++ b/mfa/TrustedDevice.py @@ -7,7 +7,6 @@ from django.template.context_processors import csrf from .models import * import user_agents from django.utils import timezone -from . import recovery def id_generator(size=6, chars=string.ascii_uppercase + string.digits): x=''.join(random.choice(chars) for _ in range(size)) @@ -76,7 +75,6 @@ def add(request): tk.properties["user_agent"]=ua tk.save() context["success"]=True - recovery.genTokens(request, True) #recovery tokens # tk.properties["user_agent"]=ua # tk.save() # context["success"]=True diff --git a/mfa/U2F.py b/mfa/U2F.py index 18387d2..0eb04f0 100644 --- a/mfa/U2F.py +++ b/mfa/U2F.py @@ -15,7 +15,6 @@ from .views import login from .Common import get_redirect_url import datetime from django.utils import timezone -from . import recovery def recheck(request): context = csrf(request) @@ -99,7 +98,6 @@ def bind(request): uk.properties = {"device":simplejson.loads(device.json),"cert":cert_hash} uk.key_type = "U2F" uk.save() - recovery.genTokens(request, True) #recovery tokens return HttpResponse("OK") def sign(username): diff --git a/mfa/recovery.py b/mfa/recovery.py index 46d60a3..07a0b66 100644 --- a/mfa/recovery.py +++ b/mfa/recovery.py @@ -1,6 +1,7 @@ from django.shortcuts import render from django.views.decorators.cache import never_cache from django.template.context_processors import csrf +from django.contrib.auth.hashers import make_password, PBKDF2PasswordHasher from django.http import HttpResponse from .Common import get_redirect_url from .models import * @@ -9,6 +10,9 @@ import random import string import datetime +class Hash(PBKDF2PasswordHasher): + algorithm = 'pbkdf2_sha256_custom' + iterations = settings.RECOVERY_ITERATION def token_left(request, username=None): if not username and request: @@ -32,46 +36,50 @@ def delTokens(request): def randomGen(n): return ''.join(random.choice(string.ascii_uppercase + string.ascii_lowercase + string.digits) for _ in range(n)) + +@never_cache def genTokens(request, softGen=False): if not softGen or (softGen and token_left(request) == 0): #Delete old ones delTokens(request) number = 5 #Then generate new one - newKeys = [] + salt = randomGen(15) + hashedKeys = [] + clearKeys = [] for i in range(5): token = randomGen(5) + "-" + randomGen(5) - newKeys.append(token) + hashedToken = make_password(token, salt, 'pbkdf2_sha256_custom') + hashedKeys.append(hashedToken) + clearKeys.append(token) uk=User_Keys() uk.username = request.user.username - uk.properties={"secret_keys":newKeys, "enabled":[True for j in range(5)]} + uk.properties={"secret_keys":hashedKeys, "enabled":[True for j in range(5)], "salt":salt} uk.key_type="RECOVERY" + uk.enabled = False uk.save() - return HttpResponse("Success") + return HttpResponse(simplejson.dumps({"keys":clearKeys})) def verify_login(request, username,token): for key in User_Keys.objects.filter(username=username, key_type = "RECOVERY"): secret_keys = key.properties["secret_keys"] + salt = key.properties["salt"] + hashedToken = make_password(token, salt, "pbkdf2_sha256_custom") for i in range(len(secret_keys)): - if token == secret_keys[i] and key.properties["enabled"][i]: + if hashedToken == secret_keys[i] and key.properties["enabled"][i]: key.properties["enabled"][i] = False key.save() - lastToken = False - if token_left(None, username) == 0: - lastToken = True - return [True, key.id, lastToken] + return [True, key.id, token_left(None, username) == 0] return [False] -def getTokens(request): - tokens = [] - enable = [] +def getTokenLeft(request): + enable = 0 for key in User_Keys.objects.filter(username=request.user.username, key_type = "RECOVERY"): - secret_keys = key.properties["secret_keys"] - for i in range(len(secret_keys)): - tokens.append(secret_keys[i]) - enable.append(key.properties["enabled"][i]) - return HttpResponse(simplejson.dumps({"keys":tokens, "enable":enable})) + tokenStatus = key.properties["enabled"] + for i in range(len(tokenStatus)): + enable += 1 + return HttpResponse(simplejson.dumps({"left":enable})) @never_cache def auth(request): @@ -79,7 +87,7 @@ def auth(request): context=csrf(request) if request.method=="POST": tokenLength = len(request.POST["otp"]) - if tokenLength == 10 and "RECOVERY" not in settings.MFA_UNALLOWED_METHODS: + if tokenLength == 11 and "RECOVERY" not in settings.MFA_UNALLOWED_METHODS: #Backup code check resBackup=verify_login(request, request.session["base_username"], token=request.POST["otp"]) if resBackup[0]: diff --git a/mfa/templates/RECOVERY/Add.html b/mfa/templates/RECOVERY/Add.html index 4f419ad..1b871e5 100644 --- a/mfa/templates/RECOVERY/Add.html +++ b/mfa/templates/RECOVERY/Add.html @@ -25,39 +25,36 @@