1
0
mirror of https://github.com/dkmstr/openuds.git synced 2025-01-12 09:17:56 +03:00

advancing on MFA implementation

This commit is contained in:
Adolfo Gómez García 2022-06-23 12:16:08 +02:00
parent 0de655d14f
commit ec02f63cac
7 changed files with 92 additions and 41 deletions

View File

@ -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:
"""

View File

@ -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
"""

View File

@ -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)

View File

@ -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

View File

@ -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': ''}),

View File

@ -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'))