mirror of
https://github.com/dkmstr/openuds.git
synced 2025-01-11 05:17:55 +03:00
Merge remote-tracking branch 'origin/v3.6'
This commit is contained in:
commit
4e00e66611
@ -33,6 +33,7 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import random
|
import random
|
||||||
import enum
|
import enum
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
@ -47,6 +48,7 @@ if typing.TYPE_CHECKING:
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class MFA(Module):
|
class MFA(Module):
|
||||||
"""
|
"""
|
||||||
this class provides an abstraction of a Multi Factor Authentication
|
this class provides an abstraction of a Multi Factor Authentication
|
||||||
@ -78,7 +80,7 @@ class MFA(Module):
|
|||||||
iconFile: typing.ClassVar[str] = 'mfa.png'
|
iconFile: typing.ClassVar[str] = 'mfa.png'
|
||||||
|
|
||||||
# : Cache time for the generated MFA code
|
# : 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.
|
# : be resent to the user until the time expires.
|
||||||
# : This value is in minutes
|
# : This value is in minutes
|
||||||
# : Note: This value is used by default "process" methos, but you can
|
# : 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.
|
This enum is used to know if the MFA code was sent or not.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
OK = 1
|
OK = 1
|
||||||
ALLOWED = 2
|
ALLOWED = 2
|
||||||
|
|
||||||
@ -140,17 +143,54 @@ class MFA(Module):
|
|||||||
"""
|
"""
|
||||||
return True
|
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.
|
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.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 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.
|
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')
|
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.
|
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.
|
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.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 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.
|
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
|
# try to get the stored code
|
||||||
storageKey = request.ip + userId
|
data = self._getData(request, userId)
|
||||||
data: typing.Any = self.storage.getPickle(storageKey)
|
|
||||||
validity = validity if validity is not None else self.validity() * 60
|
validity = validity if validity is not None else self.validity() * 60
|
||||||
try:
|
try:
|
||||||
if data and validity:
|
if data and validity:
|
||||||
# if we have a stored code, check if it's still valid
|
# 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
|
# if it's still valid, just return without sending a new one
|
||||||
return MFA.RESULT.OK
|
return MFA.RESULT.OK
|
||||||
except Exception:
|
except Exception:
|
||||||
# if we have a problem, just remove the stored code
|
# 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)
|
# Generate a 6 digit code (0-9)
|
||||||
code = ''.join(random.SystemRandom().choices('0123456789', k=6))
|
code = ''.join(random.SystemRandom().choices('0123456789', k=6))
|
||||||
logger.debug('Generated OTP is %s', code)
|
logger.debug('Generated OTP is %s', code)
|
||||||
# Store the code in the database, own storage space
|
# 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
|
# Send the code to the user
|
||||||
return self.sendCode(request, userId, username, identifier, code)
|
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
|
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.
|
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
|
# Validate the code
|
||||||
try:
|
try:
|
||||||
err = _('Invalid MFA code')
|
err = _('Invalid MFA code')
|
||||||
|
|
||||||
storageKey = request.ip + userId
|
data = self._getData(request, userId)
|
||||||
data = self.storage.getPickle(storageKey)
|
|
||||||
if data and len(data) == 2:
|
if data and len(data) == 2:
|
||||||
validity = validity if validity is not None else self.validity() * 60
|
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
|
# if it is no more valid, raise an error
|
||||||
# Remove stored code and raise error
|
# Remove stored code and raise error
|
||||||
self.storage.remove(storageKey)
|
self._removeData(request, userId)
|
||||||
raise exceptions.MFAError('MFA Code expired')
|
raise exceptions.MFAError('MFA Code expired')
|
||||||
|
|
||||||
# Check if the code is valid
|
# Check if the code is valid
|
||||||
if data[1] == code:
|
if data[1] == code:
|
||||||
# Code is valid, remove it from storage
|
# Code is valid, remove it from storage
|
||||||
self.storage.remove(storageKey)
|
self._removeData(request, userId)
|
||||||
return
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Any error means invalid code
|
# Any error means invalid code
|
||||||
err = str(e)
|
err = str(e)
|
||||||
|
|
||||||
raise exceptions.MFAError(err)
|
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()
|
||||||
|
@ -132,7 +132,7 @@ class EmailMFA(mfas.MFA):
|
|||||||
label=_('Policy for users without MFA support'),
|
label=_('Policy for users without MFA support'),
|
||||||
order=31,
|
order=31,
|
||||||
defaultValue='0',
|
defaultValue='0',
|
||||||
tooltip=_('Action for SMS response error'),
|
tooltip=_('Action for MFA response error'),
|
||||||
required=True,
|
required=True,
|
||||||
values={
|
values={
|
||||||
'0': _('Allow user login'),
|
'0': _('Allow user login'),
|
||||||
@ -144,12 +144,12 @@ class EmailMFA(mfas.MFA):
|
|||||||
)
|
)
|
||||||
|
|
||||||
networks = gui.MultiChoiceField(
|
networks = gui.MultiChoiceField(
|
||||||
label=_('SMS networks'),
|
label=_('Mail OTP Networks'),
|
||||||
rdonly=False,
|
rdonly=False,
|
||||||
rows=5,
|
rows=5,
|
||||||
order=32,
|
order=32,
|
||||||
tooltip=_('Networks for SMS authentication'),
|
tooltip=_('Networks for Email OTP authentication'),
|
||||||
required=True,
|
required=False,
|
||||||
tab=_('Config'),
|
tab=_('Config'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -149,7 +149,7 @@ class RadiusOTP(mfas.MFA):
|
|||||||
# Populate the networks list
|
# Populate the networks list
|
||||||
self.networks.setValues(
|
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')
|
for v in models.Network.objects.all().order_by('name')
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
@ -30,7 +30,6 @@ Author: Adolfo Gómez, dkmaster at dkmon dot com
|
|||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
import hashlib
|
|
||||||
import typing
|
import typing
|
||||||
import random
|
import random
|
||||||
import json
|
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.util.request import ExtendedHttpRequest, ExtendedHttpRequestWithUser
|
||||||
from uds.core.auths import auth, exceptions
|
from uds.core.auths import auth, exceptions
|
||||||
|
from uds.core.util.config import GlobalConfig
|
||||||
from uds.core.managers import cryptoManager
|
from uds.core.managers import cryptoManager
|
||||||
from uds.web.util import errors
|
from uds.web.util import errors
|
||||||
from uds.web.forms.LoginForm import LoginForm
|
from uds.web.forms.LoginForm import LoginForm
|
||||||
@ -67,6 +67,7 @@ MFA_COOKIE_NAME = 'mfa_status'
|
|||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@never_cache
|
@never_cache
|
||||||
def index(request: HttpRequest) -> HttpResponse:
|
def index(request: HttpRequest) -> HttpResponse:
|
||||||
# Gets csrf token
|
# Gets csrf token
|
||||||
@ -220,6 +221,7 @@ def mfa(request: ExtendedHttpRequest) -> HttpResponse:
|
|||||||
)
|
)
|
||||||
return errors.errorView(request, errors.ACCESS_DENIED)
|
return errors.errorView(request, errors.ACCESS_DENIED)
|
||||||
|
|
||||||
|
tries = request.session.get('mfa_tries', 0)
|
||||||
if request.method == 'POST': # User has provided MFA code
|
if request.method == 'POST': # User has provided MFA code
|
||||||
form = MFAForm(request.POST)
|
form = MFAForm(request.POST)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
@ -252,6 +254,13 @@ def mfa(request: ExtendedHttpRequest) -> HttpResponse:
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
except exceptions.MFAError as e:
|
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)
|
logger.error('MFA error: %s', e)
|
||||||
return errors.errorView(request, errors.INVALID_MFA_CODE)
|
return errors.errorView(request, errors.INVALID_MFA_CODE)
|
||||||
else:
|
else:
|
||||||
@ -300,17 +309,22 @@ def mfa(request: ExtendedHttpRequest) -> HttpResponse:
|
|||||||
}
|
}
|
||||||
return index(request) # Render index with MFA data
|
return index(request) # Render index with MFA data
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@auth.denyNonAuthenticated
|
@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:
|
try:
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
# Get request body as json
|
# Get request body as json
|
||||||
data = json.loads(request.body)
|
data = json.loads(request.body)
|
||||||
|
|
||||||
# Update username andd password in ticket
|
# Update username andd password in ticket
|
||||||
username = data.get('username', None) or None # None if not present
|
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
|
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
|
domain = data.get('domain', None) or None # If empty string, set to None
|
||||||
|
|
||||||
if password:
|
if password:
|
||||||
@ -320,13 +334,14 @@ def update_transport_ticket(request: ExtendedHttpRequestWithUser, idTicket: str,
|
|||||||
if 'ticket-info' not in data:
|
if 'ticket-info' not in data:
|
||||||
return True
|
return True
|
||||||
try:
|
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:
|
if request.user == user:
|
||||||
return True
|
return True
|
||||||
except models.User.DoesNotExist:
|
except models.User.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
models.TicketStore.update(
|
models.TicketStore.update(
|
||||||
uuid=idTicket,
|
uuid=idTicket,
|
||||||
@ -335,10 +350,14 @@ def update_transport_ticket(request: ExtendedHttpRequestWithUser, idTicket: str,
|
|||||||
password=password,
|
password=password,
|
||||||
domain=domain,
|
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:
|
except Exception as e:
|
||||||
# fallback to error
|
# fallback to error
|
||||||
logger.warning('Error updating ticket: %s', e)
|
logger.warning('Error updating ticket: %s', e)
|
||||||
|
|
||||||
# Invalid request
|
# Invalid request
|
||||||
return HttpResponse('{"status": "Invalid Request"}', status=400, content_type='application/json')
|
return HttpResponse(
|
||||||
|
'{"status": "Invalid Request"}', status=400, content_type='application/json'
|
||||||
|
)
|
||||||
|
Loading…
Reference in New Issue
Block a user