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:
commit
4e00e66611
@ -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()
|
||||
|
@ -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'),
|
||||
)
|
||||
|
||||
|
@ -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')
|
||||
]
|
||||
)
|
||||
|
@ -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'
|
||||
)
|
||||
|
Loading…
Reference in New Issue
Block a user