From ec02f63cac33724772042aa822346c7ffe83b6c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20G=C3=B3mez=20Garc=C3=ADa?= Date: Thu, 23 Jun 2022 12:16:08 +0200 Subject: [PATCH] advancing on MFA implementation --- server/src/uds/core/auths/authenticator.py | 21 ------ server/src/uds/core/auths/exceptions.py | 16 +--- server/src/uds/core/mfas/mfa.py | 78 +++++++++++++++++++- server/src/uds/templates/uds/modern/mfa.html | 0 server/src/uds/urls.py | 2 +- server/src/uds/web/util/configjs.py | 2 + server/src/uds/web/views/modern.py | 14 ++-- 7 files changed, 92 insertions(+), 41 deletions(-) create mode 100644 server/src/uds/templates/uds/modern/mfa.html diff --git a/server/src/uds/core/auths/authenticator.py b/server/src/uds/core/auths/authenticator.py index 447fc34d..14f6b4be 100644 --- a/server/src/uds/core/auths/authenticator.py +++ b/server/src/uds/core/auths/authenticator.py @@ -300,27 +300,6 @@ class Authenticator(Module): # pylint: disable=too-many-public-methods """ return '' - def mfaFieldName(self) -> str: - """ - This method will be invoked from the MFA form, to know the human name of the field - that will be used to enter the MFA code. - """ - return 'MFA Code' - - def mfaSendCode(self) -> None: - """ - This method will be invoked from the MFA form, to send the MFA code to the user. - The identifier where to send the code, will be obtained from "mfaIdentifier" method. - """ - raise NotImplementedError() - - def mfaValidate(self, identifier: str, code: str) -> None: - """ - If this method is provided by an authenticator, the user will be allowed to enter a MFA code - You must raise an "exceptions.MFAError" if the code is not valid. - """ - pass - @classmethod def providesMfa(cls) -> bool: """ diff --git a/server/src/uds/core/auths/exceptions.py b/server/src/uds/core/auths/exceptions.py index a6c812f6..506037ad 100644 --- a/server/src/uds/core/auths/exceptions.py +++ b/server/src/uds/core/auths/exceptions.py @@ -32,15 +32,7 @@ """ -class UDSException(Exception): - """ - Base exception for all UDS exceptions - """ - - pass - - -class AuthenticatorException(UDSException): +class AuthenticatorException(Exception): """ Generic authentication exception """ @@ -64,7 +56,7 @@ class InvalidAuthenticatorException(AuthenticatorException): pass -class Redirect(UDSException): +class Redirect(AuthenticatorException): """ This exception indicates that a redirect is required. Used in authUrlCallback to indicate that redirect is needed @@ -73,7 +65,7 @@ class Redirect(UDSException): pass -class Logout(UDSException): +class Logout(AuthenticatorException): """ This exceptions redirects logouts an user and redirects to an url """ @@ -81,7 +73,7 @@ class Logout(UDSException): pass -class MFAError(UDSException): +class MFAError(AuthenticatorException): """ This exceptions indicates than an MFA error has ocurred """ diff --git a/server/src/uds/core/mfas/mfa.py b/server/src/uds/core/mfas/mfa.py index 436ab65c..ce735d3e 100644 --- a/server/src/uds/core/mfas/mfa.py +++ b/server/src/uds/core/mfas/mfa.py @@ -30,11 +30,14 @@ """ @author: Adolfo Gómez, dkmaster at dkmon dot com """ +import datetime +import random import typing from django.utils.translation import ugettext_noop as _ -from uds.core.services import types as serviceTypes +from uds.models import getSqlDatetime from uds.core import Module +from uds.core.auths import exceptions if typing.TYPE_CHECKING: from uds.core.environment import Environment @@ -71,6 +74,14 @@ class MFA(Module): # : your own :py:meth:uds.core.module.BaseModule.icon method. iconFile: typing.ClassVar[str] = 'mfa.png' + # : Cache time for the generated MFA code + # : this means that the code will be valid for this time, and will not + # : be resent to the user until the time expires. + # : This value is in seconds + # : Note: This value is used by default "process" methos, but you can + # : override it in your own implementation. + cacheTime: typing.ClassVar[int] = 300 + def __init__(self, environment: 'Environment', values: Module.ValuesType): super().__init__(environment, values) self.initialize(values) @@ -90,3 +101,68 @@ class MFA(Module): Default implementation does nothing """ + + def label(self) -> str: + """ + This method will be invoked from the MFA form, to know the human name of the field + that will be used to enter the MFA code. + """ + return 'MFA Code' + + def validity(self) -> int: + """ + This method will be invoked from the MFA form, to know the validity in secods + of the MFA code. + If value is 0 or less, means the code is always valid. + """ + return self.cacheTime + + def sendCode(self, code: str) -> None: + """ + This method will be invoked from "process" method, to send the MFA code to the user. + """ + raise NotImplementedError('sendCode method not implemented') + + def process(self, userId: str, identifier: str) -> None: + """ + This method will be invoked from the MFA form, to send the MFA code to the user. + The identifier where to send the code, will be obtained from "mfaIdentifier" method. + Default implementation generates a random code and sends invokes "sendCode" method. + """ + # try to get the stored code + data: typing.Any = self.storage.getPickle(userId) + try: + if data: + # if we have a stored code, check if it's still valid + if data[0] + datetime.timedelta(seconds=self.cacheTime) < getSqlDatetime(): + # if it's still valid, just return without sending a new one + return + except Exception: + # if we have a problem, just remove the stored code + self.storage.remove(userId) + + # Generate a 6 digit code (0-9) + code = ''.join(random.SystemRandom().choices('0123456789', k=6)) + # Store the code in the database, own storage space + self.storage.putPickle(userId, (getSqlDatetime(), code)) + # Send the code to the user + self.sendCode(code) + + def validate(self, userId: str, identifier: str, code: str) -> None: + """ + If this method is provided by an authenticator, the user will be allowed to enter a MFA code + You must raise an "exceptions.MFAError" if the code is not valid. + """ + # Validate the code + try: + data = self.storage.getPickle(userId) + if data and len(data) == 2: + # Check if the code is valid + if data[1] == code: + # Code is valid, remove it from storage + self.storage.remove(userId) + return + except Exception as e: + # Any error means invalid code + raise exceptions.MFAError(e) + diff --git a/server/src/uds/templates/uds/modern/mfa.html b/server/src/uds/templates/uds/modern/mfa.html new file mode 100644 index 00000000..e69de29b diff --git a/server/src/uds/urls.py b/server/src/uds/urls.py index d5da98be..541f4fd0 100644 --- a/server/src/uds/urls.py +++ b/server/src/uds/urls.py @@ -128,7 +128,7 @@ urlpatterns = [ name='page.ticket.launcher', ), # MFA authentication - path('uds/page/mfa/', uds.web.views.modern.mfa, name='page.mfa'), + path(r'uds/page/mfa/', uds.web.views.modern.mfa, name='page.mfa'), # This must be the last, so any patition will be managed by client in fact re_path(r'uds/page/.*', uds.web.views.modern.index, name='page.placeholder'), # Utility diff --git a/server/src/uds/web/util/configjs.py b/server/src/uds/web/util/configjs.py index 447c9739..d2bbdd55 100644 --- a/server/src/uds/web/util/configjs.py +++ b/server/src/uds/web/util/configjs.py @@ -144,6 +144,7 @@ def udsJs(request: 'ExtendedHttpRequest') -> str: 'authenticators': [ getAuthInfo(auth) for auth in authenticators if auth.getType() ], + 'mfa': request.session.get('mfa', None), 'tag': tag, 'os': request.os['OS'].value[0], 'image_size': Image.MAX_IMAGE_SIZE, @@ -164,6 +165,7 @@ def udsJs(request: 'ExtendedHttpRequest') -> str: 'urls': { 'changeLang': reverse('set_language'), 'login': reverse('page.login'), + 'mfa': reverse('page.mfa'), 'logout': reverse('page.logout'), 'user': reverse('page.index'), 'customAuth': reverse('uds.web.views.customAuth', kwargs={'idAuth': ''}), diff --git a/server/src/uds/web/views/modern.py b/server/src/uds/web/views/modern.py index bffbb8e9..e062f77a 100644 --- a/server/src/uds/web/views/modern.py +++ b/server/src/uds/web/views/modern.py @@ -157,15 +157,17 @@ def mfa(request: ExtendedHttpRequest) -> HttpResponse: # Obtain MFA data authInstance = request.user.manager.getInstance() + mfaInstance = mfaProvider.getInstance() + mfaIdentifier = authInstance.mfaIdentifier() - mfaFieldName = authInstance.mfaFieldName() + label = mfaInstance.label() if request.method == 'POST': # User has provided MFA code form = MFAForm(request.POST) if form.is_valid(): code = form.cleaned_data['code'] try: - authInstance.mfaValidate(mfaIdentifier, code) + mfaInstance.validate(str(request.user.pk), mfaIdentifier, code) request.authorized = True return HttpResponseRedirect(reverse('page.index')) except exceptions.MFAError as e: @@ -174,12 +176,12 @@ def mfa(request: ExtendedHttpRequest) -> HttpResponse: else: pass # Will render again the page else: - # First, make MFA send a code - authInstance.mfaSendCode() + # Make MFA send a code + mfaInstance.process(str(request.user.pk), mfaIdentifier) # Redirect to index, but with MFA data request.session['mfa'] = { - 'identifier': mfaIdentifier, - 'fieldName': mfaFieldName, + 'label': label, + 'validity': mfaInstance.validity(), } return HttpResponseRedirect(reverse('page.index')) \ No newline at end of file