1
0
mirror of https://github.com/dkmstr/openuds.git synced 2024-12-22 13:34:04 +03:00

Added flow to MFA description, to easy development of it

Also, added IP for "remembe device" information, so only when device stays on same ip, will not be asked for a new MFA
This commit is contained in:
Adolfo Gómez García 2024-06-18 02:41:58 +02:00
parent 2d2c20909d
commit 7cb494c4d5
No known key found for this signature in database
GPG Key ID: DD1ABF20724CDA23
4 changed files with 42 additions and 6 deletions

View File

@ -304,10 +304,12 @@ class Authenticator(Module):
If this method is provided by an authenticator, the user will be allowed to enter a MFA code
You must return the value used by a MFA provider to identify the user (i.e. email, phone number, etc)
If not provided, or the return value is '', the user will be allowed to access UDS without MFA
only if the mfa itself allows empty mfaIdentifier. (look at mfa base, allow_login_without_identifier)
Note: Field capture will be responsible of provider. Put it on MFA tab of user form.
Take into consideration that mfaIdentifier will never be invoked if the user has not been
previously authenticated. (that is, authenticate method has already been called)
So, you can store the mfaIdentifier at authenticate method, and return it here for example.
"""
return ''

View File

@ -54,6 +54,34 @@ if typing.TYPE_CHECKING:
logger = logging.getLogger(__name__)
# MFA flow:
# 1.- User logs in
# 2.- If user has no MFA, login is allowed
# 3.- If remember_device (stored on DB) is active, and the remember_device cookie is valid, login is allowed
# 4.- If user has MFA, and remember_device is not active, or the cookie is not valid, MFA is requested
# 5.- The MFA identifier is requested to the authenticator (phone, email, etc).
# - If the identifier is empty, "allow_login_without_identifier" method form MFA is called and processed acordly
# - If the method returns True, login is allowed
# - If the method returns False, login is denied
# - If the method returns None, the MFA is processed with an empty identifier
# 6.- The process method is called, and the MFA code is sent to the user (or whatever the MFA method does)
# - If returns MFA.RESULT.OK, the MFA code was sent, the MFA form is shown to the user with:
# - The label of the field to enter the MFA code (label method)
# - The HTML to be shown below the MFA code form (html method)
# - If returns MFA.RESULT.ALLOWED, the MFA code was not sent, the user does not need to enter the MFA code, login is done
# - If raises an error, the MFA code was not sent, and the user is shown an error
# 7.- The user enters the MFA code, and POSTs the form
# 8.- The validate method is called with the MFA code and rest of parameters
# - If the code is valid, the user is allowed to login (returns)
# - If the code is not valid, an exception of type 'exceptions.auth.MFAError' is raised
# - The exception message will be shown to the user
# - The MFA code will be retried config.GlobalConfig.MAX_LOGIN_TRIES times at most
# 9.- If the user is allowed to login, the remember_device cookie is set if the user has checked the remember_device checkbox
# (this part is in fact done by the login view, but it's part of the MFA flow, just to remember it here)
# Note: if the MFA process takes too long, the user will be redirected to the login page, and the process will start again
class LoginAllowed(enum.StrEnum):
"""
@ -363,7 +391,7 @@ class MFA(Module):
"""
@staticmethod
def get_user_id(user: 'User') -> str:
def get_user_unique_id(user: 'User') -> str:
"""
Composes an unique, mfa dependant, id for the user (at this time, it's sha3_256 of user + mfa)
"""

View File

@ -188,7 +188,7 @@ class User(UUIDModel, properties.PropertiesMixin):
"""
# If has mfa, remove related data
if self.manager.mfa:
self.manager.mfa.get_instance().reset_data(mfas.MFA.get_user_id(self))
self.manager.mfa.get_instance().reset_data(mfas.MFA.get_user_unique_id(self))
@staticmethod
def pre_delete(sender: typing.Any, **kwargs: typing.Any) -> None: # pylint: disable=unused-argument

View File

@ -173,23 +173,26 @@ def mfa(
store: 'storage.Storage' = storage.Storage('mfs')
mfa_provider = request.user.manager.mfa # typing.cast('None|models.MFA',
mfa_provider = request.user.manager.mfa # Get MFA provider for user
if not mfa_provider:
logger.warning('MFA: No MFA provider for user')
return HttpResponseRedirect(reverse('page.index'))
mfa_user_id = mfas.MFA.get_user_id(request.user)
mfa_user_id = mfas.MFA.get_user_unique_id(request.user)
# Try to get cookie anc check it
mfa_cookie = request.COOKIES.get(consts.auth.MFA_COOKIE_NAME, None)
if mfa_cookie and mfa_provider.remember_device > 0:
stored_user_id: typing.Optional[str]
created: typing.Optional[datetime.datetime]
stored_user_id, created = store.read_pickled(mfa_cookie) or (None, None)
stored_data = store.read_pickled(mfa_cookie) or (None, None, None)
stored_user_id, created, ip = (stored_data + (None,))[:3]
if (
stored_user_id
and created
and created + datetime.timedelta(hours=mfa_provider.remember_device) > datetime.datetime.now()
# Old stored values do not have ip, so we need to check it
and (not ip or ip == request.ip)
):
# Cookie is valid, skip MFA setting authorization
logger.debug('MFA: Cookie is valid, skipping MFA')
@ -261,7 +264,7 @@ def mfa(
mfa_cookie = CryptoManager().random_string(96)
store.save_pickled(
mfa_cookie,
(mfa_user_id, now),
(mfa_user_id, now, request.ip), # MFA will only be valid for this user and this ip
)
response.set_cookie(
consts.auth.MFA_COOKIE_NAME,
@ -280,6 +283,9 @@ def mfa(
# Too many tries, redirect to login error page
return errors.error_view(request, types.errors.Error.ACCESS_DENIED)
return errors.error_view(request, types.errors.Error.INVALID_MFA_CODE)
except Exception as e:
logger.error('Error processing MFA: %s', e)
return errors.error_view(request, types.errors.Error.UNKNOWN_ERROR)
else:
pass # Will render again the page
else: