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

Merge remote-tracking branch 'origin/v3.6'

This commit is contained in:
Adolfo Gómez García 2023-02-23 03:17:48 +01:00
commit 4e00e66611
No known key found for this signature in database
GPG Key ID: DD1ABF20724CDA23
4 changed files with 144 additions and 31 deletions

View File

@ -33,6 +33,7 @@
import datetime
import random
import enum
import hashlib
import logging
import typing
@ -47,6 +48,7 @@ if typing.TYPE_CHECKING:
logger = logging.getLogger(__name__)
class MFA(Module):
"""
this class provides an abstraction of a Multi Factor Authentication
@ -78,7 +80,7 @@ class MFA(Module):
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
# : 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 minutes
# : Note: This value is used by default "process" methos, but you can
@ -89,6 +91,7 @@ class MFA(Module):
"""
This enum is used to know if the MFA code was sent or not.
"""
OK = 1
ALLOWED = 2
@ -140,17 +143,54 @@ class MFA(Module):
"""
return True
def sendCode(self, request: 'ExtendedHttpRequest', userId: str, username: str, identifier: str, code: str) -> 'MFA.RESULT':
def sendCode(
self,
request: 'ExtendedHttpRequest',
userId: str,
username: str,
identifier: str,
code: str,
) -> 'MFA.RESULT':
"""
This method will be invoked from "process" method, to send the MFA code to the user.
If returns MFA.RESULT.OK, the MFA code was sent.
If returns MFA.RESULT.ALLOW, the MFA code was not sent, the user does not need to enter the MFA code.
If raises an error, the MFA code was not sent, and the user needs to enter the MFA code.
"""
raise NotImplementedError('sendCode method not implemented')
def process(self, request: 'ExtendedHttpRequest', userId: str, username: str, identifier: str, validity: typing.Optional[int] = None) -> 'MFA.RESULT':
def _getData(
self, request: 'ExtendedHttpRequest', userId: str
) -> typing.Optional[typing.Tuple[datetime.datetime, str]]:
"""
Internal method to get the data from storage
"""
storageKey = request.ip + userId
return self.storage.getPickle(storageKey)
def _removeData(self, request: 'ExtendedHttpRequest', userId: str) -> None:
"""
Internal method to remove the data from storage
"""
storageKey = request.ip + userId
self.storage.remove(storageKey)
def _putData(self, request: 'ExtendedHttpRequest', userId: str, code: str) -> None:
"""
Internal method to put the data into storage
"""
storageKey = request.ip + userId
self.storage.putPickle(storageKey, (models.getSqlDatetime(), code))
def process(
self,
request: 'ExtendedHttpRequest',
userId: str,
username: str,
identifier: str,
validity: typing.Optional[int] = None,
) -> 'MFA.RESULT':
"""
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.
@ -159,57 +199,111 @@ class MFA(Module):
If returns MFA.RESULT.OK, the MFA code was sent.
If returns MFA.RESULT.ALLOW, the MFA code was not sent, the user does not need to enter the MFA code.
If raises an error, the MFA code was not sent, and the user needs to enter the MFA code.
Args:
request: The request object
userId: An unique, non authenticator dependant, id for the user (at this time, it's sha3_256 of user + authenticator)
username: The user name, the one used to login
identifier: The identifier where to send the code (phone, email, etc)
validity: The validity of the code in seconds. If None, the default value will be used.
Returns:
MFA.RESULT.OK if the code was already sent
MFA.RESULT.ALLOW if the user does not need to enter the MFA code (i.e. fail to send the code)
Raises an error if the code was not sent and was required to be sent
"""
# try to get the stored code
storageKey = request.ip + userId
data: typing.Any = self.storage.getPickle(storageKey)
data = self._getData(request, userId)
validity = validity if validity is not None else self.validity() * 60
try:
if data and validity:
# if we have a stored code, check if it's still valid
if data[0] + datetime.timedelta(seconds=validity) > getSqlDatetime():
if data[0] + datetime.timedelta(seconds=validity) > models.getSqlDatetime():
# if it's still valid, just return without sending a new one
return MFA.RESULT.OK
except Exception:
# if we have a problem, just remove the stored code
self.storage.remove(storageKey)
self._removeData(request, userId)
# Generate a 6 digit code (0-9)
code = ''.join(random.SystemRandom().choices('0123456789', k=6))
logger.debug('Generated OTP is %s', code)
# Store the code in the database, own storage space
self.storage.putPickle(storageKey, (getSqlDatetime(), code))
self._putData(request, userId, code)
# Send the code to the user
return self.sendCode(request, userId, username, identifier, code)
def validate(self, request: 'ExtendedHttpRequest', userId: str, username: str, identifier: str, code: str, validity: typing.Optional[int] = None) -> None:
def validate(
self,
request: 'ExtendedHttpRequest',
userId: str,
username: str,
identifier: str,
code: str,
validity: typing.Optional[int] = None,
) -> 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.
Args:
request: The request object
userId: An unique, non authenticator dependant, id for the user (at this time, it's sha3_256 of user + authenticator)
username: The user name, the one used to login
identifier: The identifier where to send the code (phone, email, etc)
code: The code entered by the user
validity: The validity of the code in seconds. If None, the default value will be used.
Returns:
None if the code is valid
Raises an error if the code is not valid ("exceptions.MFAError")
"""
# Validate the code
try:
err = _('Invalid MFA code')
storageKey = request.ip + userId
data = self.storage.getPickle(storageKey)
data = self._getData(request, userId)
if data and len(data) == 2:
validity = validity if validity is not None else self.validity() * 60
if validity > 0 and data[0] + datetime.timedelta(seconds=validity) < getSqlDatetime():
if (
validity > 0
and data[0] + datetime.timedelta(seconds=validity)
< models.getSqlDatetime()
):
# if it is no more valid, raise an error
# Remove stored code and raise error
self.storage.remove(storageKey)
self._removeData(request, userId)
raise exceptions.MFAError('MFA Code expired')
# Check if the code is valid
if data[1] == code:
# Code is valid, remove it from storage
self.storage.remove(storageKey)
self._removeData(request, userId)
return
except Exception as e:
# Any error means invalid code
err = str(e)
raise exceptions.MFAError(err)
def reset_data(
self,
request: 'ExtendedHttpRequest',
userId: str,
) -> None:
"""
This method allows to reset the MFA state of an user.
Normally, this will do nothing, but for persistent MFA data (as Google Authenticator), this will remove the data.
"""
pass
@staticmethod
def getUserId(user: models.User) -> str:
mfa = user.manager.mfa
if not mfa:
raise exceptions.MFAError('MFA is not enabled')
return hashlib.sha3_256(
(user.name + (user.uuid or '') + mfa.uuid).encode()
).hexdigest()

View File

@ -132,7 +132,7 @@ class EmailMFA(mfas.MFA):
label=_('Policy for users without MFA support'),
order=31,
defaultValue='0',
tooltip=_('Action for SMS response error'),
tooltip=_('Action for MFA response error'),
required=True,
values={
'0': _('Allow user login'),
@ -144,12 +144,12 @@ class EmailMFA(mfas.MFA):
)
networks = gui.MultiChoiceField(
label=_('SMS networks'),
label=_('Mail OTP Networks'),
rdonly=False,
rows=5,
order=32,
tooltip=_('Networks for SMS authentication'),
required=True,
tooltip=_('Networks for Email OTP authentication'),
required=False,
tab=_('Config'),
)

View File

@ -149,7 +149,7 @@ class RadiusOTP(mfas.MFA):
# Populate the networks list
self.networks.setValues(
[
gui.choiceItem(v.uuid, v.name)
gui.choiceItem(v.uuid, v.name) # type: ignore
for v in models.Network.objects.all().order_by('name')
]
)

View File

@ -30,7 +30,6 @@ Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import time
import logging
import hashlib
import typing
import random
import json
@ -47,6 +46,7 @@ from django.views.decorators.cache import never_cache
from uds.core.util.request import ExtendedHttpRequest, ExtendedHttpRequestWithUser
from uds.core.auths import auth, exceptions
from uds.core.util.config import GlobalConfig
from uds.core.managers import cryptoManager
from uds.web.util import errors
from uds.web.forms.LoginForm import LoginForm
@ -67,6 +67,7 @@ MFA_COOKIE_NAME = 'mfa_status'
if typing.TYPE_CHECKING:
pass
@never_cache
def index(request: HttpRequest) -> HttpResponse:
# Gets csrf token
@ -220,6 +221,7 @@ def mfa(request: ExtendedHttpRequest) -> HttpResponse:
)
return errors.errorView(request, errors.ACCESS_DENIED)
tries = request.session.get('mfa_tries', 0)
if request.method == 'POST': # User has provided MFA code
form = MFAForm(request.POST)
if form.is_valid():
@ -252,6 +254,13 @@ def mfa(request: ExtendedHttpRequest) -> HttpResponse:
return response
except exceptions.MFAError as e:
tries += 1
request.session['mfa_tries'] = tries
if tries >= GlobalConfig.MAX_LOGIN_TRIES.getInt():
# Clean session
request.session.flush()
# Too many tries, redirect to login error page
return errors.errorView(request, errors.ACCESS_DENIED)
logger.error('MFA error: %s', e)
return errors.errorView(request, errors.INVALID_MFA_CODE)
else:
@ -300,17 +309,22 @@ def mfa(request: ExtendedHttpRequest) -> HttpResponse:
}
return index(request) # Render index with MFA data
@csrf_exempt
@auth.denyNonAuthenticated
def update_transport_ticket(request: ExtendedHttpRequestWithUser, idTicket: str, scrambler: str) -> HttpResponse:
def update_transport_ticket(
request: ExtendedHttpRequestWithUser, idTicket: str, scrambler: str
) -> HttpResponse:
try:
if request.method == 'POST':
# Get request body as json
data = json.loads(request.body)
# Update username andd password in ticket
username = data.get('username', None) or None # None if not present
password = data.get('password', None) or None # If password is empty, set it to None
username = data.get('username', None) or None # None if not present
password = (
data.get('password', None) or None
) # If password is empty, set it to None
domain = data.get('domain', None) or None # If empty string, set to None
if password:
@ -320,13 +334,14 @@ def update_transport_ticket(request: ExtendedHttpRequestWithUser, idTicket: str,
if 'ticket-info' not in data:
return True
try:
user = models.User.objects.get(uuid=data['ticket-info'].get('user', None))
user = models.User.objects.get(
uuid=data['ticket-info'].get('user', None)
)
if request.user == user:
return True
except models.User.DoesNotExist:
pass
return False
models.TicketStore.update(
uuid=idTicket,
@ -335,10 +350,14 @@ def update_transport_ticket(request: ExtendedHttpRequestWithUser, idTicket: str,
password=password,
domain=domain,
)
return HttpResponse('{"status": "OK"}', status=200, content_type='application/json')
return HttpResponse(
'{"status": "OK"}', status=200, content_type='application/json'
)
except Exception as e:
# fallback to error
logger.warning('Error updating ticket: %s', e)
# Invalid request
return HttpResponse('{"status": "Invalid Request"}', status=400, content_type='application/json')
return HttpResponse(
'{"status": "Invalid Request"}', status=400, content_type='application/json'
)