diff --git a/CHANGELOG.md b/CHANGELOG.md index 916aa93..666747f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,22 @@ # Change Log ## 3.0 (Beta) + +This is a major cleanup and CSS adjustments so please test before deployment. + * Updated to fido2==1.1.3 -* Removed: CBOR and exchange is done in JSON now +* Removed: CBOR and exchange is done in JSON now. +* Removed: `simplejson` package from dependencies. +* Email OTP is always 6 numbers. +* Better support for bootstrap 4 and 5. * Added: the following settings * `MFA_FIDO2_RESIDENT_KEY`: Defaults to `Discouraged` which was the old behaviour * `MFA_FIDO2_AUTHENTICATOR_ATTACHMENT`: If you like to have a PLATFORM Authenticator, Defaults to NONE * `MFA_FIDO2_USER_VERIFICATION`: If you need User Verification * `MFA_FIDO2_ATTESTATION_PREFERENCE`: If you like to have an Attention + * `MFA_ENFORCE_EMAIL_TOKEN`: if you want the user to receive OTP by email without enrolling, if this the case, the system admins shall make sure that emails are valid. + * `MFA_SHOW_OTP_IN_EMAIL_SUBJECT`: If you like to show the OTP in the email subject + * `MFA_OTP_EMAIL_SUBJECT`: The subject of the email after the token allows placeholder '%s' for otp ## 2.9.0 * Add: Set black as code formatter diff --git a/README.md b/README.md index d4c8fce..5b5be2e 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ Depends on ```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 e.g ('TOTP','U2F',) 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 @@ -91,7 +92,7 @@ Depends on 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 + MFA_OWNED_BY_ENTERPRISE = False # Who owns security keys PASSWORD_HASHERS = DEFAULT_PASSWORD_HASHERS # Comment if PASSWORD_HASHER already set in your settings.py 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... @@ -101,10 +102,16 @@ Depends on U2F_APPID="https://localhost" #URL For U2F FIDO_SERVER_ID=u"localehost" # Server rp id for FIDO2, it is the full domain of your project FIDO_SERVER_NAME=u"PROJECT_NAME" + + import mfa MFA_FIDO2_RESIDENT_KEY = mfa.ResidentKey.DISCOURAGED # Resident Key allows a special User Handle MFA_FIDO2_AUTHENTICATOR_ATTACHMENT = None # Let the user choose MFA_FIDO2_USER_VERIFICATION = None # Verify User Presence MFA_FIDO2_ATTESTATION_PREFERENCE = mfa.AttestationPreference.NONE + + MFA_ENFORCE_EMAIL_TOKEN = False # If you want the user to receive OTP by email without enrolling, if this the case, the system admins shall make sure that emails are valid. + MFA_SHOW_OTP_IN_EMAIL_SUBJECT = False #If you like to show the OTP in the email subject + MFA_OTP_EMAIL_SUBJECT= "OTP" # The subject of the email after the token ``` **Method Names** * U2F @@ -123,6 +130,7 @@ Depends on * Added: `MFA_ALWAYS_GO_TO_LAST_METHOD`, `MFA_RENAME_METHODS`, `MFA_ENFORCE_RECOVERY_METHOD` & `RECOVERY_ITERATION` * Starting version 3.0 * Added: `MFA_FIDO2_RESIDENT_KEY`, `MFA_FIDO2_AUTHENTICATOR_ATTACHMENT`, `MFA_FIDO2_USER_VERIFICATION`, `MFA_FIDO2_ATTESTATION_PREFERENCE` + * Added: `MFA_ENFORCE_EMAIL_TOKEN`, `MFA_SHOW_OTP_IN_EMAIL_SUBJECT`, `MFA_OTP_EMAIL_SUBJECT` 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/mfa/Common.py b/mfa/Common.py index 2263ba9..6679e97 100644 --- a/mfa/Common.py +++ b/mfa/Common.py @@ -1,4 +1,8 @@ +import datetime +from random import randint + from django.conf import settings +from django.contrib.auth import get_user_model from django.core.mail import EmailMessage try: @@ -24,3 +28,20 @@ def get_redirect_url(): ), "reg_success_msg": getattr(settings, "MFA_SUCCESS_REGISTRATION_MSG"), } + + +def get_username_field(): + User = get_user_model() + USERNAME_FIELD = getattr(User, "USERNAME_FIELD", "username") + return User, USERNAME_FIELD + + +def set_next_recheck(): + if getattr(settings, "MFA_RECHECK", False): + delta = datetime.timedelta( + seconds=randint(settings.MFA_RECHECK_MIN, settings.MFA_RECHECK_MAX) + ) + return { + "next_check": datetime.datetime.timestamp(datetime.datetime.now() + delta) + } + return {} diff --git a/mfa/Email.py b/mfa/Email.py index 0b3fdb2..564518b 100644 --- a/mfa/Email.py +++ b/mfa/Email.py @@ -10,22 +10,26 @@ from .models import User_Keys from .views import login -from .Common import send +from .Common import send, get_username_field, set_next_recheck def sendEmail(request, username, secret): """Send Email to the user after rendering `mfa_email_token_template`""" - - User = get_user_model() - key = getattr(User, "USERNAME_FIELD", "username") - kwargs = {key: username} + User, UsernameField = get_username_field() + kwargs = {UsernameField: username} user = User.objects.get(**kwargs) res = render( request, "mfa_email_token_template.html", {"request": request, "user": user, "otp": secret}, ) - return send([user.email], "OTP", res.content.decode()) + subject = getattr(settings, "MFA_OTP_EMAIL_SUBJECT", "OTP") + if getattr(settings, "MFA_SHOW_OTP_IN_EMAIL_SUBJECT", False): + if "%s" in subject: + subject = subject % secret + else: + subject = secret + " " + subject + return send([user.email], subject, res.content.decode()) @never_cache @@ -35,7 +39,8 @@ def start(request): if request.method == "POST": if request.session["email_secret"] == request.POST["otp"]: # if successful uk = User_Keys() - uk.username = request.user.username + User, USERNAME_FIELD = get_username_field() + uk.username = USERNAME_FIELD uk.key_type = "Email" uk.enabled = 1 uk.save() @@ -64,8 +69,8 @@ def start(request): ) context["invalid"] = True else: - request.session["email_secret"] = str( - randint(0, 100000) + request.session["email_secret"] = str(randint(0, 1000000)).zfill( + 6 ) # generate a random integer if sendEmail(request, request.user.username, request.session["email_secret"]): @@ -78,20 +83,23 @@ def auth(request): """Authenticating the user by email.""" context = csrf(request) if request.method == "POST": + username = request.session["base_username"] + if request.session["email_secret"] == request.POST["otp"].strip(): - uk = User_Keys.objects.get( - username=request.session["base_username"], key_type="Email" - ) + email_keys = User_Keys.objects.filter(username=username, key_type="Email") + if email_keys.exists(): + uk = email_keys.first() + elif getattr(settings, "MFA_ENFORCE_EMAIL_TOKEN", False): + uk = User_Keys() + uk.username = username + uk.key_type = "Email" + uk.enabled = 1 + uk.save() + else: + raise Exception("Email is not a valid method for this user") + mfa = {"verified": True, "method": "Email", "id": uk.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 - ) - ) - ) + mfa.update(set_next_recheck()) request.session["mfa"] = mfa from django.utils import timezone @@ -101,7 +109,7 @@ def auth(request): return login(request) context["invalid"] = True else: - request.session["email_secret"] = str(randint(0, 100000)) + request.session["email_secret"] = str(randint(0, 1000000)).zfill(6) if sendEmail( request, request.session["base_username"], request.session["email_secret"] ): diff --git a/mfa/FIDO2.py b/mfa/FIDO2.py index 300f1a5..6b46991 100644 --- a/mfa/FIDO2.py +++ b/mfa/FIDO2.py @@ -1,11 +1,11 @@ +import json + from fido2.server import Fido2Server, PublicKeyCredentialRpEntity -from fido2.webauthn import AttestationObject, AuthenticatorData, CollectedClientData, RegistrationResponse +from fido2.webauthn import RegistrationResponse from django.template.context_processors import csrf from django.views.decorators.csrf import csrf_exempt from django.shortcuts import render -import simplejson -from fido2 import cbor from django.http import HttpResponse from django.conf import settings from fido2.utils import websafe_decode, websafe_encode @@ -13,16 +13,19 @@ from .views import login, reset_cookie from .models import User_Keys import datetime -from .Common import get_redirect_url +from .Common import get_redirect_url, set_next_recheck from django.utils import timezone import fido2.features from django.http import JsonResponse + def enable_json_mapping(): try: fido2.features.webauthn_json_mapping.enabled = True except: pass + + def recheck(request): """Starts FIDO2 recheck""" context = csrf(request) @@ -34,9 +37,14 @@ def recheck(request): def getServer(): """Get Server Info from settings and returns a Fido2Server""" from mfa import AttestationPreference - rp = PublicKeyCredentialRpEntity(id=settings.FIDO_SERVER_ID, name=settings.FIDO_SERVER_NAME) - attestation= getattr(settings,'MFA_FIDO2_ATTESTATION_PREFERENCE', AttestationPreference.NONE ) - return Fido2Server(rp,attestation=attestation) + + rp = PublicKeyCredentialRpEntity( + id=settings.FIDO_SERVER_ID, name=settings.FIDO_SERVER_NAME + ) + attestation = getattr( + settings, "MFA_FIDO2_ATTESTATION_PREFERENCE", AttestationPreference.NONE + ) + return Fido2Server(rp, attestation=attestation) def begin_registeration(request): @@ -44,33 +52,46 @@ def begin_registeration(request): enable_json_mapping() server = getServer() from mfa import ResidentKey - resident_key = getattr(settings,'MFA_FIDO2_RESIDENT_KEY', ResidentKey.DISCOURAGED) - auth_attachment = getattr(settings,'MFA_FIDO2_AUTHENTICATOR_ATTACHMENT', None) - user_verification = getattr(settings,'MFA_FIDO2_USER_VERIFICATION', None) - registration_data, state = server.register_begin({ - u'id': request.user.username.encode("utf8"), - u'name': request.user.username, - u'displayName': request.user.username, - }, getUserCredentials(request.user.username),user_verification = user_verification, - resident_key_requirement = resident_key, authenticator_attachment = auth_attachment) - request.session['fido2_state'] = state + + resident_key = getattr(settings, "MFA_FIDO2_RESIDENT_KEY", ResidentKey.DISCOURAGED) + auth_attachment = getattr(settings, "MFA_FIDO2_AUTHENTICATOR_ATTACHMENT", None) + user_verification = getattr(settings, "MFA_FIDO2_USER_VERIFICATION", None) + registration_data, state = server.register_begin( + { + "id": request.user.username.encode("utf8"), + "name": request.user.username, + "displayName": request.user.username, + }, + getUserCredentials(request.user.username), + user_verification=user_verification, + resident_key_requirement=resident_key, + authenticator_attachment=auth_attachment, + ) + request.session["fido2_state"] = state return JsonResponse(dict(registration_data)) - #return HttpResponse(cbor.encode(registration_data), content_type = 'application/octet-stream') + # return HttpResponse(cbor.encode(registration_data), content_type = 'application/octet-stream') @csrf_exempt def complete_reg(request): - """Completes the registeration, called by API""" + """Completes the registration, called by API""" try: if not "fido2_state" in request.session: - return JsonResponse({'status': 'ERR', "message": "FIDO Status can't be found, please try again"}) + return JsonResponse( + { + "status": "ERR", + "message": "FIDO Status can't be found, please try again", + } + ) enable_json_mapping() - data = simplejson.loads(request.body) + data = json.loads(request.body) server = getServer() - auth_data = server.register_complete(request.session["fido2_state"], response = data) + auth_data = server.register_complete( + request.session["fido2_state"], response=data + ) registration = RegistrationResponse.from_dict(data) attestation_object = registration.response.attestation_object - #auth_data = attestation_object.auth_data + # auth_data = attestation_object.auth_data att_obj = attestation_object encoded = websafe_encode(auth_data.credential_data) @@ -97,14 +118,16 @@ def complete_reg(request): "FIDO2", "FIDO2" ), } - return HttpResponse(simplejson.dumps({"status": "RECOVERY"})) + return JsonResponse({"status": "RECOVERY"}) else: - return HttpResponse(simplejson.dumps({"status": "OK"})) + return JsonResponse({"status": "OK"}) except Exception as exp: import traceback + print(traceback.format_exc()) return JsonResponse( - {"status": "ERR", "message": "Error on server, please try again later"}, status=500 + {"status": "ERR", "message": "Error on server, please try again later"}, + status=500, ) @@ -124,7 +147,10 @@ def start(request): def getUserCredentials(username): - return [AttestedCredentialData(websafe_decode(uk.properties["device"])) for uk in User_Keys.objects.filter(username = username, key_type = "FIDO2")] + return [ + AttestedCredentialData(websafe_decode(uk.properties["device"])) + for uk in User_Keys.objects.filter(username=username, key_type="FIDO2") + ] def auth(request): @@ -135,16 +161,18 @@ def auth(request): def authenticate_begin(request): enable_json_mapping() server = getServer() - credentials=[] + credentials = [] username = None if "base_username" in request.session: username = request.session["base_username"] if request.user.is_authenticated: username = request.user.username if username: - credentials = getUserCredentials(request.session.get("base_username", request.user.username)) + credentials = getUserCredentials( + request.session.get("base_username", request.user.username) + ) auth_data, state = server.authenticate_begin(credentials) - request.session['fido2_state'] = state + request.session["fido2_state"] = state return JsonResponse(dict(auth_data)) @@ -160,53 +188,61 @@ def authenticate_complete(request): if request.user.is_authenticated: username = request.user.username server = getServer() - data = simplejson.loads(request.body) - userHandle = data.get("response",{}).get('userHandle') - credential_id = data['id'] + data = json.loads(request.body) + userHandle = data.get("response", {}).get("userHandle") + credential_id = data["id"] if userHandle: if User_Keys.objects.filter(username=userHandle).exists(): credentials = getUserCredentials(userHandle) - username=userHandle + username = userHandle else: - keys = User_Keys.objects.filter(user_handle = userHandle) + keys = User_Keys.objects.filter(user_handle=userHandle) if keys.exists(): - credentials = [AttestedCredentialData(websafe_decode(keys[0].properties["device"]))] + credentials = [ + AttestedCredentialData( + websafe_decode(keys[0].properties["device"]) + ) + ] elif credential_id and username is None: - keys = User_Keys.objects.filter(user_handle = credential_id) + keys = User_Keys.objects.filter(user_handle=credential_id) if keys.exists(): - credentials=[AttestedCredentialData(websafe_decode(keys[0].properties["device"]))] + credentials = [ + AttestedCredentialData(websafe_decode(keys[0].properties["device"])) + ] else: credentials = getUserCredentials(username) try: cred = server.authenticate_complete( - request.session.pop('fido2_state'), credentials = credentials, response = data + request.session.pop("fido2_state"), + credentials=credentials, + response=data, ) except ValueError: - return JsonResponse( + return ( + JsonResponse( { "status": "ERR", "message": "Wrong challenge received, make sure that this is your security and try again.", - } - , status=400), + }, + status=400, + ), + ) except Exception as excep: - return JsonResponse({"status": "ERR", "message": str(excep)}, - status=500 - ) + return JsonResponse({"status": "ERR", "message": str(excep)}, status=500) if request.session.get("mfa_recheck", False): - import time - request.session["mfa"]["rechecked_at"] = time.time() - return HttpResponse( - simplejson.dumps({"status": "OK"}), content_type="application/json" - ) + request.session["mfa"].update(set_next_recheck()) + return JsonResponse({"status": "OK"}) + else: - import random if keys is None: - keys = User_Keys.objects.filter(username = username, key_type = "FIDO2", enabled = 1) + keys = User_Keys.objects.filter( + username=username, key_type="FIDO2", enabled=1 + ) for k in keys: if ( AttestedCredentialData( @@ -217,29 +253,24 @@ def authenticate_complete(request): k.last_used = timezone.now() k.save() mfa = {"verified": True, "method": "FIDO2", "id": k.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, - ) - ) - ) - ) + mfa.update(set_next_recheck()) request.session["mfa"] = mfa try: authenticated = request.user.is_authenticated except: authenticated = request.user.is_authenticated() if not authenticated: - res = login(request,k.username) - if not "location" in res: return reset_cookie(request) - return HttpResponse(simplejson.dumps({'status': "OK", "redirect": res["location"]}), - content_type = "application/json") - return JsonResponse({'status': "OK"}) + res = login(request, k.username) + if not "location" in res: + return reset_cookie(request) + return JsonResponse( + {"status": "OK", "redirect": res["location"]} + ) + + return JsonResponse({"status": "OK"}) except Exception as exp: + import traceback + + print(traceback.format_exc()) return JsonResponse({"status": "ERR", "message": str(exp)}, status=500) diff --git a/mfa/U2F.py b/mfa/U2F.py index 2bed454..533cf75 100644 --- a/mfa/U2F.py +++ b/mfa/U2F.py @@ -1,3 +1,5 @@ +import json + from u2flib_server.u2f import ( begin_registration, begin_authentication, @@ -8,10 +10,10 @@ from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.serialization import Encoding from django.shortcuts import render -import simplejson + from django.template.context_processors import csrf -from django.http import HttpResponse +from django.http import HttpResponse, JsonResponse from django.conf import settings from .models import User_Keys from .views import login @@ -31,13 +33,11 @@ def recheck(request): def process_recheck(request): x = validate(request, request.user.username) - if x == True: + if x is True: import time request.session["mfa"]["rechecked_at"] = time.time() - return HttpResponse( - simplejson.dumps({"recheck": True}), content_type="application/json" - ) + return JsonResponse({"recheck": True}) return x @@ -55,7 +55,7 @@ def check_errors(request, data): def validate(request, username): import datetime, random - data = simplejson.loads(request.POST["response"]) + data = json.loads(request.POST["response"]) res = check_errors(request, data) if res != True: @@ -105,7 +105,7 @@ def start(request): enroll = begin_registration(settings.U2F_APPID, []) request.session["_u2f_enroll_"] = enroll.json context = csrf(request) - context["token"] = simplejson.dumps(enroll.data_for_client) + context["token"] = json.dumps(enroll.data_for_client) context.update(get_redirect_url()) context["method"] = { "name": getattr(settings, "MFA_RENAME_METHODS", {}).get( @@ -122,7 +122,7 @@ def bind(request): import hashlib enroll = request.session["_u2f_enroll_"] - data = simplejson.loads(request.POST["response"]) + data = json.loads(request.POST["response"]) device, cert = complete_registration(enroll, data, [settings.U2F_APPID]) cert = x509.load_der_x509_certificate(cert, default_backend()) cert_hash = hashlib.md5(cert.public_bytes(Encoding.PEM)).hexdigest() @@ -135,7 +135,7 @@ def bind(request): uk = User_Keys() uk.username = request.user.username uk.owned_by_enterprise = getattr(settings, "MFA_OWNED_BY_ENTERPRISE", False) - uk.properties = {"device": simplejson.loads(device.json), "cert": cert_hash} + uk.properties = {"device": json.loads(device.json), "cert": cert_hash} uk.key_type = "U2F" uk.save() if ( @@ -160,7 +160,7 @@ def sign(username): for d in User_Keys.objects.filter(username=username, key_type="U2F") ] challenge = begin_authentication(settings.U2F_APPID, u2f_devices) - return [challenge.json, simplejson.dumps(challenge.data_for_client)] + return [challenge.json, json.dumps(challenge.data_for_client)] def verify(request): diff --git a/mfa/helpers.py b/mfa/helpers.py index 39e85b7..96aa60b 100644 --- a/mfa/helpers.py +++ b/mfa/helpers.py @@ -1,12 +1,17 @@ -import simplejson -from django.shortcuts import HttpResponse +from django.http import JsonResponse +from django.conf import settings + from mfa.views import verify from . import TrustedDevice, U2F, FIDO2, totp from .models import User_Keys def has_mfa(request, username): - if User_Keys.objects.filter(username=username, enabled=1).count() > 0: + FORCE_EMAIL = getattr(settings, "MFA_ENFORCE_EMAIL_TOKEN", False) + if ( + User_Keys.objects.filter(username=username, enabled=1).count() > 0 + or FORCE_EMAIL + ): return verify(request, username) return False @@ -21,26 +26,15 @@ def is_mfa(request, ignore_methods=[]): def recheck(request): method = request.session.get("mfa", {}).get("method", None) if not method: - return HttpResponse( - simplejson.dumps({"res": False}), content_type="application/json" - ) + return JsonResponse({"res": False}) if method == "Trusted Device": - return HttpResponse( - simplejson.dumps({"res": TrustedDevice.verify(request)}), - content_type="application/json", - ) + return JsonResponse({"res": TrustedDevice.verify(request)}) + elif method == "U2F": - return HttpResponse( - simplejson.dumps({"html": U2F.recheck(request).content}), - content_type="application/json", - ) + return JsonResponse({"html": U2F.recheck(request).content}) + elif method == "FIDO2": - return HttpResponse( - simplejson.dumps({"html": FIDO2.recheck(request).content}), - content_type="application/json", - ) + return JsonResponse({"html": FIDO2.recheck(request).content}) + elif method == "TOTP": - return HttpResponse( - simplejson.dumps({"html": totp.recheck(request).content}), - content_type="application/json", - ) + return JsonResponse({"html": totp.recheck(request).content}) diff --git a/mfa/recovery.py b/mfa/recovery.py index f7e4870..77ec058 100644 --- a/mfa/recovery.py +++ b/mfa/recovery.py @@ -1,14 +1,14 @@ import time import random import string -import simplejson + from django.shortcuts import render from django.views.decorators.cache import never_cache from django.template.context_processors import csrf from django.utils import timezone from django.contrib.auth.hashers import make_password, PBKDF2PasswordHasher -from django.http import HttpResponse +from django.http import HttpResponse, JsonResponse from django.conf import settings from .Common import get_redirect_url from .models import User_Keys @@ -58,7 +58,7 @@ def genTokens(request): uk.key_type = "RECOVERY" uk.enabled = True uk.save() - return HttpResponse(simplejson.dumps({"keys": clearKeys})) + return JsonResponse({"keys": clearKeys}) def verify_login(request, username, token): @@ -81,7 +81,7 @@ def getTokenLeft(request): keyLeft = 0 for key in uk: keyLeft += len(key.properties["secret_keys"]) - return HttpResponse(simplejson.dumps({"left": keyLeft})) + return JsonResponse({"left": keyLeft}) def recheck(request): @@ -92,13 +92,11 @@ def recheck(request): 0 ]: request.session["mfa"]["rechecked_at"] = time.time() - return HttpResponse( - simplejson.dumps({"recheck": True}), content_type="application/json" - ) + return JsonResponse({"recheck": True}) + else: - return HttpResponse( - simplejson.dumps({"recheck": False}), content_type="application/json" - ) + return JsonResponse({"recheck": False}) + return render(request, "RECOVERY/recheck.html", context) @@ -123,10 +121,6 @@ def auth(request): "id": resBackup[1], "lastBackup": resBackup[2], } - # 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 if resBackup[2]: # If the last bakup code has just been used, we return a response insead of redirecting to login diff --git a/mfa/templates/Email/Auth.html b/mfa/templates/Email/Auth.html index bb58497..305ffea 100644 --- a/mfa/templates/Email/Auth.html +++ b/mfa/templates/Email/Auth.html @@ -1,14 +1,8 @@ {% extends "mfa_auth_base.html" %} {% block head %} - {% endblock %} {% block content %} -
-
+ {% include "Email/recheck.html" with mode='auth' %} {% endblock %} diff --git a/mfa/templates/Email/recheck.html b/mfa/templates/Email/recheck.html index be342f1..dd7da12 100644 --- a/mfa/templates/Email/recheck.html +++ b/mfa/templates/Email/recheck.html @@ -1,29 +1,12 @@ - -
-
-
+
Email One Time Password
-
+ {% csrf_token %} @@ -38,31 +21,42 @@
{% endif %}
+ {% if mode == "auth" %} +
+
+ Welcome back {{ request.session.base_username }}
+ Not me +
+
+
+ {% endif %}
-
+

Enter the code sent to your email.

-
+
- + - + +
- +
- +
@@ -73,6 +67,4 @@
-
-
-
+ diff --git a/mfa/templates/FIDO2/Auth.html b/mfa/templates/FIDO2/Auth.html index 8e7d30c..b097caa 100644 --- a/mfa/templates/FIDO2/Auth.html +++ b/mfa/templates/FIDO2/Auth.html @@ -1,4 +1,4 @@ {% extends "mfa_auth_base.html" %} {% block content %} -{% include 'FIDO2/recheck.html' with mode='auth' %} + {% include 'FIDO2/recheck.html' with mode='auth' %} {% endblock %} \ No newline at end of file diff --git a/mfa/templates/FIDO2/Auth_JS.html b/mfa/templates/FIDO2/Auth_JS.html index ed011d2..9af0eba 100644 --- a/mfa/templates/FIDO2/Auth_JS.html +++ b/mfa/templates/FIDO2/Auth_JS.html @@ -92,8 +92,8 @@ ua=new UAParser().getResult() if (ua.browser.name == "Safari" || ua.browser.name == "Mobile Safari" || ua.os.name == "iOS" || ua.os.name == "iPadOS") $("#res").html("") - {#else#} - {# authen()#} + else + authen() } }); diff --git a/mfa/templates/FIDO2/recheck.html b/mfa/templates/FIDO2/recheck.html index 8d85a7b..ac953e2 100644 --- a/mfa/templates/FIDO2/recheck.html +++ b/mfa/templates/FIDO2/recheck.html @@ -1,15 +1,15 @@ {% load static %}
-
-
+ +
- Security Key + PassKey
-
+
{% if mode == "auth" %} Welcome back {% comment %}{% endcomment %} {{ request.session.base_username }}
Not me @@ -21,9 +21,9 @@
{% if mode == "auth" %} -
+ {% elif mode == "recheck" %} - + {% endif %} {% csrf_token %} diff --git a/mfa/templates/MFA.html b/mfa/templates/MFA.html index afcc59d..47f81c4 100644 --- a/mfa/templates/MFA.html +++ b/mfa/templates/MFA.html @@ -103,7 +103,7 @@ {% endif %} {% endfor %} - {% if "RECOVERY" not in UNALLOWED_AUTHEN_METHODS %} + {% if "RECOVERY" not in UNALLOWED_AUTHEN_METHODS and recovery %} {{ recovery.name }} diff --git a/mfa/templates/TOTP/recheck.html b/mfa/templates/TOTP/recheck.html index 0a6328e..064d7c4 100644 --- a/mfa/templates/TOTP/recheck.html +++ b/mfa/templates/TOTP/recheck.html @@ -12,17 +12,17 @@ }) } -
-
-
-
+ + + +
One Time Password
- + {% csrf_token %} @@ -31,33 +31,41 @@ Sorry, The provided token is not valid.
{% endif %} - {% if quota %} -
- {{ quota }} -
- {% endif %}
+ {% if mode == "auth" %}
-
+
+ Welcome back {{ request.session.base_username }}
+ Not me +
+
+
+ {% endif %} +
+

Enter the 6-digits on your authenticator

-
+
- + - +
-
+ +
@@ -70,8 +78,5 @@
-
-
-
-
+ {% include "modal.html" %} \ No newline at end of file diff --git a/mfa/templates/select_mfa_method.html b/mfa/templates/select_mfa_method.html index aa59acb..b197933 100644 --- a/mfa/templates/select_mfa_method.html +++ b/mfa/templates/select_mfa_method.html @@ -1,13 +1,9 @@ {% extends "mfa_auth_base.html" %} {% block content %} -
-
-
-
-
+
- Select Second Verification Method + Select Second Factor Verification Method
    @@ -25,8 +21,5 @@
-
-
-
{% endblock %} diff --git a/mfa/totp.py b/mfa/totp.py index 0429ced..8b66838 100644 --- a/mfa/totp.py +++ b/mfa/totp.py @@ -1,15 +1,16 @@ import random import datetime -import simplejson +import time + import pyotp from django.shortcuts import render from django.views.decorators.cache import never_cache -from django.http import HttpResponse +from django.http import HttpResponse, JsonResponse from django.template.context_processors import csrf from django.conf import settings from django.utils import timezone from .views import login -from .Common import get_redirect_url +from .Common import get_redirect_url, set_next_recheck from .models import User_Keys @@ -28,14 +29,12 @@ def recheck(request): context["mode"] = "recheck" if request.method == "POST": if verify_login(request, request.user.username, token=request.POST["otp"])[0]: - request.session["mfa"]["rechecked_at"] = time.time() - return HttpResponse( - simplejson.dumps({"recheck": True}), content_type="application/json" - ) + mfa = request.session["mfa"] + mfa["rechecked_at"] = time.time() + mfa.update(set_next_recheck()) + return JsonResponse({"recheck": True}) else: - return HttpResponse( - simplejson.dumps({"recheck": False}), content_type="application/json" - ) + return JsonResponse({"recheck": False}) return render(request, "TOTP/recheck.html", context) @@ -51,17 +50,7 @@ def auth(request): ) if res[0]: mfa = {"verified": True, "method": "TOTP", "id": res[1]} - 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 - ) - ) - ) - ) + mfa.update(set_next_recheck()) request.session["mfa"] = mfa return login(request) context["invalid"] = True @@ -72,15 +61,14 @@ def getToken(request): secret_key = pyotp.random_base32() totp = pyotp.TOTP(secret_key) request.session["new_mfa_answer"] = totp.now() - return HttpResponse( - simplejson.dumps( - { - "qr": pyotp.totp.TOTP(secret_key).provisioning_uri( - str(request.user.username), issuer_name=settings.TOKEN_ISSUER_NAME - ), - "secret_key": secret_key, - } - ) + qr = pyotp.totp.TOTP(secret_key).provisioning_uri( + str(request.user.username), issuer_name=settings.TOKEN_ISSUER_NAME + ) + return JsonResponse( + { + "qr": qr, + "secret_key": secret_key, + } ) diff --git a/mfa/views.py b/mfa/views.py index 3b58dad..502d76d 100644 --- a/mfa/views.py +++ b/mfa/views.py @@ -24,8 +24,9 @@ def index(request): "HIDE_DISABLE": getattr(settings, "MFA_HIDE_DISABLE", []), "RENAME_METHODS": getattr(settings, "MFA_RENAME_METHODS", {}), } + name_map = getattr(settings, "MFA_RENAME_METHODS", {}) for k in context["keys"]: - k.name = getattr(settings, "MFA_RENAME_METHODS", {}).get(k.key_type, k.key_type) + k.name = name_map.get(k.key_type, k.key_type) if k.key_type == "Trusted Device": setattr(k, "device", parse(k.properties.get("user_agent", "-----"))) elif k.key_type == "FIDO2": @@ -33,6 +34,11 @@ def index(request): elif k.key_type == "RECOVERY": context["recovery"] = k continue + elif k.key_type == "Email" and getattr( + settings, "MFA_ENFORCE_EMAIL_TOKEN", False + ): + continue + keys.append(k) context["keys"] = keys return render(request, "MFA.html", context) @@ -51,11 +57,12 @@ def verify(request, username): return login(request) methods.remove("Trusted Device") request.session["mfa_methods"] = methods - + if len(methods) == 0 and getattr(settings, "MFA_ENFORCE_EMAIL_TOKEN", False): + methods = ["email"] if len(methods) == 1: 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") + keys = keys.exclude(last_used__isnull=True).order_by("-last_used") if keys.count() > 0: return HttpResponseRedirect(reverse(keys[0].key_type.lower() + "_auth")) return show_methods(request) @@ -74,12 +81,14 @@ def reset_cookie(request): response.delete_cookie("base_username") return response -def login(request,username = None): + +def login(request, username=None): from django.conf import settings + callable_func = __get_callable_function__(settings.MFA_LOGIN_CALLBACK) if not username: username = request.session["base_username"] - return callable_func(request,username=username) + return callable_func(request, username=username) @login_required diff --git a/setup.py b/setup.py index c38a3ad..44e11ab 100644 --- a/setup.py +++ b/setup.py @@ -3,9 +3,9 @@ from setuptools import find_packages, setup setup( - name='django-mfa2', - version='3.0b1', - description='Allows user to add 2FA to their accounts', + name="django-mfa2", + version="3.0b2", + description="Allows user to add 2FA to their accounts", long_description=open("README.md").read(), long_description_content_type="text/markdown", author="Mohamed El-Kalioby", @@ -15,21 +15,20 @@ license="MIT", packages=find_packages(), install_requires=[ - "django >= 2.0", - "simplejson", - "pyotp", - "python-u2flib-server", - "ua-parser", - "user-agents", - "python-jose", - "fido2 >= 1.1.1,<1.2.0", - ], + "django >= 2.0", + "pyotp", + "python-u2flib-server", + "ua-parser", + "user-agents", + "python-jose", + "fido2 >= 1.1.1,<1.2.0", + ], python_requires=">=3.5", include_package_data=True, zip_safe=False, # because we're including static files classifiers=[ - #"Development Status :: 5 - Production/Stable", - "Development Status :: 4 - Beta", + # "Development Status :: 5 - Production/Stable", + "Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: Django", "Framework :: Django :: 2.0",