diff --git a/CHANGELOG.md b/CHANGELOG.md index 3028b2e..a758b04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## 2.6.0 (dev) * Adding Backup Recovery Codes (Recovery) as a method. Thanks to @Spitfireap for work, and @peterthomassen for guidance. + * Added: `RECOVERY_ITERATION` to set the number of iteration when hashing recovery token + * Added: `MFA_ENFORCE_RECOVERY_METHOD` to enforce the user to enroll in the recovery code method once, they add any other method, + * Added: `MFA_ALWAYS_GO_TO_LAST_METHOD` to the settings which redirects the user automatically to the last used method when logging in + * Added: `MFA_RENAME_METHODS` to be able to rename the methods for the user. + * Fix: Alot of CSS fixes for the example application ## 2.5.0 diff --git a/README.md b/README.md index b2ac4c3..e113f1e 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ Depends on MFA_RECHECK_MIN=10 # Minimum interval in seconds MFA_RECHECK_MAX=30 # Maximum in seconds MFA_QUICKLOGIN=True # Allow quick login for returning users by provide only their 2FA + MFA_ALWAYS_GO_TO_LAST_METHOD = False # Always redirect the user to the last method used to save a click (Added in 2.6.0). + MFA_RENAME_METHODS={} #Rename the methods in a more user-friendly way e.g {"RECOVERY":"Backup Codes"} (Added in 2.6.0) 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 @@ -102,6 +104,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` + Start version 2.6.0 + * Added: `MFA_ALWAYS_GO_TO_LAST_METHOD`, & `MFA_RENAME_METHODS` 4. 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 b55196d..a341a6c 100644 --- a/example/example/settings.py +++ b/example/example/settings.py @@ -143,11 +143,14 @@ 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" +MFA_ALWAYS_GO_TO_LAST_METHOD = True +MFA_ENFORCE_RECOVERY_METHOD = True +MFA_RENAME_METHODS = {"RECOVERY":"Backup Codes","FIDO2":"Biometric Authentication"} 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 +U2F_APPID="https://localhost:9000" #URL For U2F FIDO_SERVER_ID="localhost" # Server rp id for FIDO2, it the full domain of your project FIDO_SERVER_NAME="TestApp" diff --git a/example/example/templates/base.html b/example/example/templates/base.html index e5b1a6e..be5fdb6 100644 --- a/example/example/templates/base.html +++ b/example/example/templates/base.html @@ -10,7 +10,7 @@ - SB Admin - Blank Page + Django-mfa2 Example diff --git a/mfa/Email.py b/mfa/Email.py index 010d6d5..047ec7a 100644 --- a/mfa/Email.py +++ b/mfa/Email.py @@ -34,10 +34,16 @@ def start(request): from django.core.urlresolvers import reverse except: from django.urls import reverse - return HttpResponseRedirect(reverse(getattr(settings,'MFA_REDIRECT_AFTER_REGISTRATION','mfa_home'))) + if getattr(settings, 'MFA_ENFORCE_RECOVERY_METHOD', False) and not User_Keys.objects.filter( + key_type="RECOVERY", username=request.user.username).exists(): + request.session["mfa_reg"] = {"method": "Email", + "name": getattr(settings, "MFA_RENAME_METHODS", {}).get("Email", "Email")} + else: + return HttpResponseRedirect(reverse(getattr(settings,'MFA_REDIRECT_AFTER_REGISTRATION','mfa_home'))) context["invalid"] = True else: request.session["email_secret"] = str(randint(0,100000)) #generate a random integer + if sendEmail(request, request.user.username, request.session["email_secret"]): context["sent"] = True return render(request,"Email/Add.html", context) diff --git a/mfa/FIDO2.py b/mfa/FIDO2.py index 752a5a2..22eae99 100644 --- a/mfa/FIDO2.py +++ b/mfa/FIDO2.py @@ -66,7 +66,11 @@ def complete_reg(request): uk.owned_by_enterprise = getattr(settings, "MFA_OWNED_BY_ENTERPRISE", False) uk.key_type = "FIDO2" uk.save() - return HttpResponse(simplejson.dumps({'status': 'OK'})) + if getattr(settings, 'MFA_ENFORCE_RECOVERY_METHOD', False) and not User_Keys.objects.filter(key_type = "RECOVERY", username=request.user.username).exists(): + request.session["mfa_reg"] = {"method":"FIDO2","name": getattr(settings, "MFA_RENAME_METHODS", {}).get("FIDO2", "FIDO2")} + return HttpResponse(simplejson.dumps({'status': 'RECOVERY'})) + else: + return HttpResponse(simplejson.dumps({'status': 'OK'})) except Exception as exp: import traceback print(traceback.format_exc()) @@ -79,9 +83,11 @@ def complete_reg(request): def start(request): - """Start Registeration a new FIDO Token""" + """Start Registration a new FIDO Token""" context = csrf(request) context.update(get_redirect_url()) + context["method"] = {"name":getattr(settings,"MFA_RENAME_METHODS",{}).get("FIDO2","FIDO2 Security Key")} + context["RECOVERY_METHOD"]=getattr(settings,"MFA_RENAME_METHODS",{}).get("RECOVERY","Recovery codes") return render(request, "FIDO2/Add.html", context) diff --git a/mfa/U2F.py b/mfa/U2F.py index 0eb04f0..bf247cc 100644 --- a/mfa/U2F.py +++ b/mfa/U2F.py @@ -52,25 +52,29 @@ def validate(request,username): challenge = request.session.pop('_u2f_challenge_') device, c, t = complete_authentication(challenge, data, [settings.U2F_APPID]) + try: + key=User_Keys.objects.get(username=username,properties__icontains='"publicKey": "%s"'%device["publicKey"]) + key.last_used=timezone.now() + key.save() + mfa = {"verified": True, "method": "U2F","id":key.id} + if getattr(settings, "MFA_RECHECK", False): + mfa["next_check"] = datetime.datetime.timestamp((datetime.datetime.now() + + datetime.timedelta( + seconds=random.randint(settings.MFA_RECHECK_MIN, settings.MFA_RECHECK_MAX)))) + request.session["mfa"] = mfa + return True + except: + return False + - key=User_Keys.objects.get(username=username,properties__shas="$.device.publicKey=%s"%device["publicKey"]) - key.last_used=timezone.now() - key.save() - mfa = {"verified": True, "method": "U2F","id":key.id} - if getattr(settings, "MFA_RECHECK", False): - mfa["next_check"] = datetime.datetime.timestamp((datetime.datetime.now() - + datetime.timedelta( - seconds=random.randint(settings.MFA_RECHECK_MIN, settings.MFA_RECHECK_MAX)))) - request.session["mfa"] = mfa - return True def auth(request): context=csrf(request) s=sign(request.session["base_username"]) request.session["_u2f_challenge_"]=s[0] context["token"]=s[1] - - return render(request,"U2F/Auth.html") + context["method"] = {"name": getattr(settings, "MFA_RENAME_METHODS", {}).get("U2F", "Classical Security Key")} + return render(request,"U2F/Auth.html",context) def start(request): enroll = begin_registration(settings.U2F_APPID, []) @@ -78,6 +82,8 @@ def start(request): context=csrf(request) context["token"]=simplejson.dumps(enroll.data_for_client) context.update(get_redirect_url()) + context["method"] = {"name": getattr(settings, "MFA_RENAME_METHODS", {}).get("U2F", "Classical Security Key")} + context["RECOVERY_METHOD"] = getattr(settings, "MFA_RENAME_METHODS", {}).get("RECOVERY", "Recovery codes") return render(request,"U2F/Add.html",context) @@ -98,6 +104,11 @@ def bind(request): uk.properties = {"device":simplejson.loads(device.json),"cert":cert_hash} uk.key_type = "U2F" uk.save() + if getattr(settings, 'MFA_ENFORCE_RECOVERY_METHOD', False) and not User_Keys.objects.filter(key_type="RECOVERY", + username=request.user.username).exists(): + request.session["mfa_reg"] = {"method": "U2F", + "name": getattr(settings, "MFA_RENAME_METHODS", {}).get("U2F", "Classical Security Key")} + return HttpResponse('RECOVERY') return HttpResponse("OK") def sign(username): diff --git a/mfa/recovery.py b/mfa/recovery.py index 38f4b9a..f2c1547 100644 --- a/mfa/recovery.py +++ b/mfa/recovery.py @@ -15,7 +15,7 @@ USER_FRIENDLY_NAME = "Recovery Codes" class Hash(PBKDF2PasswordHasher): algorithm = 'pbkdf2_sha256_custom' - iterations = settings.RECOVERY_ITERATION + iterations = getattr(settings,"RECOVERY_ITERATION",1) def delTokens(request): #Only when all MFA have been deactivated, or to generate new ! @@ -116,4 +116,7 @@ def auth(request): @never_cache def start(request): """Start Managing recovery tokens""" - return render(request,"RECOVERY/Add.html",get_redirect_url()) \ No newline at end of file + context = get_redirect_url() + if "mfa_reg" in request.session: + context["mfa_redirect"] = request.session["mfa_reg"]["name"] + return render(request,"RECOVERY/Add.html",context) \ No newline at end of file diff --git a/mfa/templates/FIDO2/Add.html b/mfa/templates/FIDO2/Add.html index fc11d37..2b8071d 100644 --- a/mfa/templates/FIDO2/Add.html +++ b/mfa/templates/FIDO2/Add.html @@ -32,9 +32,14 @@ }).then(function (res) { if (res["status"] =='OK') - $("#res").html("
Registered Successfully, {{reg_success_msg}}
") - else - $("#res").html("
Registration Failed as " + res["message"] + ", try again or Go to Security Home
") + $("#res").html("
Registered Successfully, {{reg_success_msg}}
") + else if (res['status'] = "RECOVERY") + { + setTimeout(function (){location.href="{% url 'manage_recovery_codes' %}"},2500) + $("#res").html("
Registered Successfully, but redirecting to {{ RECOVERY_METHOD }} method
") + } + else + $("#res").html("
Registration Failed as " + res["message"] + ", try again or Go to Security Home
") }, function(reason) { @@ -61,7 +66,7 @@
- FIDO2 Security Key + Adding a New {{ method.name }}
diff --git a/mfa/templates/RECOVERY/Add.html b/mfa/templates/RECOVERY/Add.html index 6b5470a..00a09aa 100644 --- a/mfa/templates/RECOVERY/Add.html +++ b/mfa/templates/RECOVERY/Add.html @@ -35,13 +35,16 @@ $.ajax({"url":"{% url 'get_recovery_token_left' %}", dataType:"JSON", success:function (data) { tokenLeft = data.left - let html + html = "" + {% if mfa_redirect %} + html += "
You have enrolled successfully in {{ mfa_redirect }} method, please generate recovery codes so that you can use in case you lost access to all your verification methods.
" + {% endif %} if (tokenLeft == 0) { - html = "
You don't have any backup code left, please generate new ones !
" + html += "
You don't have any backup code linked to your account, please generate new ones !
" } else { - html = "

You still have "+tokenLeft+" backup code left." + html += "

You still have "+tokenLeft+" backup code left." } document.getElementById('tokens').innerHTML = html }}) diff --git a/mfa/templates/TOTP/Add.html b/mfa/templates/TOTP/Add.html index 7bd2ce6..a7aebaa 100644 --- a/mfa/templates/TOTP/Add.html +++ b/mfa/templates/TOTP/Add.html @@ -38,13 +38,16 @@ $.ajax({ "url":"{% url 'verify_otop' %}?key="+key+ "&answer="+answer, success:function (data) { - if (data == "Error") - alert("You entered wrong numbers, please try again") - else - { - alert("Your authenticator is added successfully.") - window.location.href="{{ redirect_html }}" - } + if (data =='Success') + $("#res").html("

Your authenticator is registered successfully, {{reg_success_msg}}
") + else if (data == "RECOVERY") + { + setTimeout(function (){location.href="{% url 'manage_recovery_codes' %}"},2500) + $("#res").html("
Your authenticator is registered successfully, but redirecting to {{ RECOVERY_METHOD }} method
") + } + else + $("#res").html("
The code provided doesn't match the key, please try again or Go to Security Home
") + } }) } @@ -66,21 +69,24 @@
-

Adding Authenticator

+

Adding a new {{ method.name }}

Scan the image below with the two-factor authentication app on your phone/PC. If you can’t use a barcode, enter this text instead.

+
-
+
+
-

Enter the six-digit code from the application

@@ -88,16 +94,13 @@
- - +
- +
-
+
-
-
diff --git a/mfa/templates/U2F/Add.html b/mfa/templates/U2F/Add.html index 0eefcd4..ae81563 100644 --- a/mfa/templates/U2F/Add.html +++ b/mfa/templates/U2F/Add.html @@ -13,7 +13,7 @@ {% endblock %} @@ -37,9 +46,11 @@

+
+
-

Adding Security Key

+

Adding {{ method.name}}

Your secure Key should be flashing now, please press on button.

diff --git a/mfa/templates/U2F/recheck.html b/mfa/templates/U2F/recheck.html index 5f47b44..a908ae8 100644 --- a/mfa/templates/U2F/recheck.html +++ b/mfa/templates/U2F/recheck.html @@ -4,7 +4,7 @@
- Security Key + Verify your identity using {{ method.name }}
diff --git a/mfa/totp.py b/mfa/totp.py index 3d42f23..4cf099c 100644 --- a/mfa/totp.py +++ b/mfa/totp.py @@ -72,10 +72,19 @@ def verify(request): #uk.name="Authenticatior #%s"%User_Keys.objects.filter(username=user.username,type="TOTP") uk.key_type="TOTP" uk.save() - return HttpResponse("Success") + if getattr(settings, 'MFA_ENFORCE_RECOVERY_METHOD', False) and not User_Keys.objects.filter(key_type="RECOVERY", + username=request.user.username).exists(): + request.session["mfa_reg"] = {"method": "TOTP", + "name": getattr(settings, "MFA_RENAME_METHODS", {}).get("TOTP", "TOTP")} + return HttpResponse("RECOVERY") + else: + return HttpResponse("Success") else: return HttpResponse("Error") @never_cache def start(request): """Start Adding Time One Time Password (TOTP)""" - return render(request,"TOTP/Add.html",get_redirect_url()) + context = get_redirect_url() + context["RECOVERY_METHOD"] = getattr(settings, "MFA_RENAME_METHODS", {}).get("RECOVERY", "Recovery codes") + context["method"] = {"name":getattr(settings,"MFA_RENAME_METHODS",{}).get("TOTP","Authenticator")} + return render(request,"TOTP/Add.html",context) diff --git a/mfa/views.py b/mfa/views.py index b8fe3aa..819dae5 100644 --- a/mfa/views.py +++ b/mfa/views.py @@ -48,7 +48,8 @@ def verify(request,username): return HttpResponseRedirect(reverse(methods[0].lower()+"_auth")) if getattr(settings,"MFA_ALWAYS_GO_TO_LAST_METHOD",False): keys = keys.exclude(last_used__isnull=True).order_by("last_used") - return HttpResponseRedirect(reverse(keys[0].key_type.lower() + "_auth")) + if keys.count()>0: + return HttpResponseRedirect(reverse(keys[0].key_type.lower() + "_auth")) return show_methods(request) def show_methods(request):