1
0
mirror of https://github.com/dkmstr/openuds.git synced 2024-12-25 23:21:41 +03:00

Integrating mfa branch on 4.0. Initial changes. Work to do

This commit is contained in:
Adolfo Gómez García 2022-06-27 21:58:21 +02:00
commit 4ba8bc9c5a
41 changed files with 1201 additions and 114 deletions

View File

@ -34,7 +34,7 @@ import logging
import typing import typing
from django.utils.translation import gettext, gettext_lazy as _ from django.utils.translation import gettext, gettext_lazy as _
from uds.models import Authenticator, Network from uds.models import Authenticator, Network, MFA
from uds.core import auths from uds.core import auths
from uds.REST import NotFound from uds.REST import NotFound
@ -46,6 +46,7 @@ from .users_groups import Users, Groups
# Not imported at runtime, just for type checking # Not imported at runtime, just for type checking
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from django.db import models
from uds.core import Module from uds.core import Module
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -76,16 +77,10 @@ class Authenticators(ModelHandler):
{'type_name': {'title': _('Type')}}, {'type_name': {'title': _('Type')}},
{'comments': {'title': _('Comments')}}, {'comments': {'title': _('Comments')}},
{'priority': {'title': _('Priority'), 'type': 'numeric', 'width': '5em'}}, {'priority': {'title': _('Priority'), 'type': 'numeric', 'width': '5em'}},
{ {'visible': {'title': _('Visible'), 'type': 'callback', 'width': '3em'}},
'state': {
'title': _('Access'),
'type': 'dict',
'dict': {'v': _('Visible'), 'h': _('Hidden'), 'd': 'Disabled'},
'width': '3em',
}
},
{'small_name': {'title': _('Label')}}, {'small_name': {'title': _('Label')}},
{'users_count': {'title': _('Users'), 'type': 'numeric', 'width': '5em'}}, {'users_count': {'title': _('Users'), 'type': 'numeric', 'width': '5em'}},
{'mfa': {'title': _('MFA'), 'type': 'callback', 'width': '3em'}},
{'tags': {'title': _('tags'), 'visible': False}}, {'tags': {'title': _('tags'), 'visible': False}},
] ]
@ -134,6 +129,28 @@ class Authenticators(ModelHandler):
'tab': gettext('Display'), 'tab': gettext('Display'),
}, },
) )
# If supports mfa, add MFA provider selector field
if authType.providesMfa():
self.addField(
field,
{
'name': 'mfa_id',
'values': [gui.choiceItem('', _('None'))]
+ gui.sortedChoices(
[
gui.choiceItem(v.uuid, v.name)
for v in MFA.objects.all()
]
),
'label': ugettext('MFA Provider'),
'tooltip': ugettext(
'MFA provider to use for this authenticator'
),
'type': gui.InputField.CHOICE_TYPE,
'order': 108,
'tab': gui.MFA_TAB,
},
)
return field return field
raise Exception() # Not found raise Exception() # Not found
except Exception: except Exception:
@ -151,6 +168,7 @@ class Authenticators(ModelHandler):
'net_filtering': item.net_filtering, 'net_filtering': item.net_filtering,
'networks': [{'id': n.uuid} for n in item.networks.all()], 'networks': [{'id': n.uuid} for n in item.networks.all()],
'state': item.state, 'state': item.state,
'mfa_id': item.mfa.uuid if item.mfa else '',
'small_name': item.small_name, 'small_name': item.small_name,
'users_count': item.users.count(), 'users_count': item.users.count(),
'type': type_.type(), 'type': type_.type(),
@ -218,6 +236,20 @@ class Authenticators(ModelHandler):
return self.success() return self.success()
return res[1] return res[1]
def beforeSave(
self, fields: typing.Dict[str, typing.Any]
) -> None: # pylint: disable=too-many-branches,too-many-statements
logger.debug(self._params)
try:
mfa = MFA.objects.get(
uuid=processUuid(fields['mfa_id'])
)
fields['mfa_id'] = mfa.id
except Exception: # not found
del fields['mfa_id']
def deleteItem(self, item: Authenticator): def deleteItem(self, item: Authenticator):
# For every user, remove assigned services (mark them for removal) # For every user, remove assigned services (mark them for removal)

View File

@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2014-2021 Virtual Cable S.L.U.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors
# may be used to endorse or promote products derived from this software
# without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
'''
@itemor: Adolfo Gómez, dkmaster at dkmon dot com
'''
import logging
import typing
from django.utils.translation import gettext_lazy as _, gettext
from uds import models
from uds.core import mfas
from uds.core.ui import gui
from uds.core.util import permissions
from uds.REST.model import ModelHandler
logger = logging.getLogger(__name__)
# Enclosed methods under /item path
class MFA(ModelHandler):
model = models.MFA
save_fields = ['name', 'comments', 'tags', 'remember_device']
table_title = _('Multi Factor Authentication')
table_fields = [
{'name': {'title': _('Name'), 'visible': True, 'type': 'iconType'}},
{'type_name': {'title': _('Type')}},
{'comments': {'title': _('Comments')}},
{'tags': {'title': _('tags'), 'visible': False}},
]
def enum_types(self) -> typing.Iterable[typing.Type[mfas.MFA]]:
return mfas.factory().providers().values()
def getGui(self, type_: str) -> typing.List[typing.Any]:
mfa = mfas.factory().lookup(type_)
if not mfa:
raise self.invalidItemException()
localGui = self.addDefaultFields(
mfa.guiDescription(), ['name', 'comments', 'tags']
)
self.addField(
localGui,
{
'name': 'remember_device',
'value': '0',
'minValue': '0',
'label': gettext('Device Caching'),
'tooltip': gettext(
'Time in hours to cache device so MFA is not required again. User based.'
),
'type': gui.InputField.NUMERIC_TYPE,
'order': 111,
},
)
self.addField(
localGui,
{
'name': 'validity',
'value': '5',
'minValue': '0',
'label': gettext('MFA code validity'),
'tooltip': gettext(
'Time in minutes to allow MFA code to be used.'
),
'type': gui.InputField.NUMERIC_TYPE,
'order': 112,
},
)
return localGui
def item_as_dict(self, item: models.MFA) -> typing.Dict[str, typing.Any]:
type_ = item.getType()
return {
'id': item.uuid,
'name': item.name,
'remember_device': item.remember_device,
'validity': item.validity,
'tags': [tag.tag for tag in item.tags.all()],
'comments': item.comments,
'type': type_.type(),
'type_name': type_.name(),
'permission': permissions.getEffectivePermission(self._user, item),
}

View File

@ -72,6 +72,7 @@ class UDSAppConfig(AppConfig):
# To make sure that the packages are initialized at this point # To make sure that the packages are initialized at this point
from . import services from . import services
from . import auths from . import auths
from . import mfas # To make sure mfas are loaded on memory
from . import osmanagers from . import osmanagers
from . import notifiers from . import notifiers
from . import transports from . import transports
@ -95,7 +96,6 @@ default_app_config = 'uds.UDSAppConfig'
# Sets up several sqlite non existing methods # Sets up several sqlite non existing methods
@receiver(connection_created) @receiver(connection_created)
def extend_sqlite(connection=None, **kwargs): def extend_sqlite(connection=None, **kwargs):
if connection and connection.vendor == "sqlite": if connection and connection.vendor == "sqlite":

View File

@ -69,6 +69,7 @@ authLogger = logging.getLogger('authLog')
USER_KEY = 'uk' USER_KEY = 'uk'
PASS_KEY = 'pk' PASS_KEY = 'pk'
EXPIRY_KEY = 'ek' EXPIRY_KEY = 'ek'
AUTHORIZED_KEY = 'ak'
ROOT_ID = -20091204 # Any negative number will do the trick ROOT_ID = -20091204 # Any negative number will do the trick
UDS_COOKIE_LENGTH = 48 UDS_COOKIE_LENGTH = 48
@ -128,7 +129,9 @@ def getRootUser() -> models.User:
# Decorator to make easier protect pages that needs to be logged in # Decorator to make easier protect pages that needs to be logged in
def webLoginRequired( def webLoginRequired(
admin: typing.Union[bool, str] = False admin: typing.Union[bool, str] = False
) -> typing.Callable[[typing.Callable[..., RT]], typing.Callable[..., RT]]: ) -> typing.Callable[
[typing.Callable[..., HttpResponse]], typing.Callable[..., HttpResponse]
]:
""" """
Decorator to set protection to access page Decorator to set protection to access page
Look for samples at uds.core.web.views Look for samples at uds.core.web.views
@ -136,23 +139,25 @@ def webLoginRequired(
if admin == 'admin', needs admin if admin == 'admin', needs admin
""" """
def decorator(view_func: typing.Callable[..., RT]) -> typing.Callable[..., RT]: def decorator(
def _wrapped_view(request: 'ExtendedHttpRequest', *args, **kwargs) -> RT: view_func: typing.Callable[..., HttpResponse]
) -> typing.Callable[..., HttpResponse]:
@wraps(view_func)
def _wrapped_view(
request: 'ExtendedHttpRequest', *args, **kwargs
) -> HttpResponse:
""" """
Wrapped function for decorator Wrapped function for decorator
""" """
if not request.user: # If no user or user authorization is not completed...
# url = request.build_absolute_uri(GlobalConfig.LOGIN_URL.get()) if not request.user or not request.authorized:
# if GlobalConfig.REDIRECT_TO_HTTPS.getBool() is True: return HttpResponseRedirect(reverse('page.login'))
# url = url.replace('http://', 'https://')
# logger.debug('No user found, redirecting to %s', url)
return HttpResponseRedirect(reverse('page.login')) # type: ignore
if admin is True or admin == 'admin': # bool or string "admin" if admin is True or admin == 'admin': # bool or string "admin"
if request.user.isStaff() is False or ( if request.user.isStaff() is False or (
admin == 'admin' and not request.user.is_admin admin == 'admin' and not request.user.is_admin
): ):
return HttpResponseForbidden(_('Forbidden')) # type: ignore return HttpResponseForbidden(_('Forbidden'))
return view_func(request, *args, **kwargs) return view_func(request, *args, **kwargs)
@ -199,7 +204,7 @@ def denyNonAuthenticated(
) -> typing.Callable[..., RT]: ) -> typing.Callable[..., RT]:
@wraps(view_func) @wraps(view_func)
def _wrapped_view(request: 'ExtendedHttpRequest', *args, **kwargs) -> RT: def _wrapped_view(request: 'ExtendedHttpRequest', *args, **kwargs) -> RT:
if not request.user: if not request.user or not request.authorized:
return HttpResponseForbidden() # type: ignore return HttpResponseForbidden() # type: ignore
return view_func(request, *args, **kwargs) return view_func(request, *args, **kwargs)
@ -400,6 +405,9 @@ def webLogin(
cookie = getUDSCookie(request, response) cookie = getUDSCookie(request, response)
user.updateLastAccess() user.updateLastAccess()
request.authorized = (
False # For now, we don't know if the user is authorized until MFA is checked
)
request.session[USER_KEY] = user.id request.session[USER_KEY] = user.id
request.session[PASS_KEY] = cryptoManager().symCrypt( request.session[PASS_KEY] = cryptoManager().symCrypt(
password, cookie password, cookie
@ -443,13 +451,12 @@ def webLogout(
by django in regular basis. by django in regular basis.
""" """
if exit_url is None: if exit_url is None:
exit_url = GlobalConfig.LOGOUT_URL.get(force=True).strip() or reverse('page.login') exit_url = request.build_absolute_uri(reverse('page.login'))
try:
if request.user: if request.user:
authenticator = request.user.manager.getInstance() authenticator = request.user.manager.getInstance()
username = request.user.name username = request.user.name
# Success/fail result is now ignored exit_url = authenticator.logout(username) or exit_url
exit_url = authenticator.logout(request, username).url or exit_url
if request.user.id != ROOT_ID: if request.user.id != ROOT_ID:
# Log the event if not root user # Log the event if not root user
events.addEvent( events.addEvent(
@ -460,9 +467,12 @@ def webLogout(
) )
else: # No user, redirect to / else: # No user, redirect to /
return HttpResponseRedirect(reverse('page.login')) return HttpResponseRedirect(reverse('page.login'))
except Exception:
raise
finally:
# Try to delete session # Try to delete session
request.session.flush() request.session.flush()
request.authorized = False
response = HttpResponseRedirect(request.build_absolute_uri(exit_url)) response = HttpResponseRedirect(request.build_absolute_uri(exit_url))
if authenticator: if authenticator:

View File

@ -327,6 +327,25 @@ class Authenticator(Module):
""" """
return [] return []
def mfaIdentifier(self, username: str) -> str:
"""
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
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)
"""
return ''
@classmethod
def providesMfa(cls) -> bool:
"""
Returns if this authenticator provides a MFA identifier
"""
return cls.mfaIdentifier is not Authenticator.mfaIdentifier
def authenticate( def authenticate(
self, self,
username: str, username: str,

View File

@ -37,27 +37,45 @@ class AuthenticatorException(Exception):
Generic authentication exception Generic authentication exception
""" """
pass
class InvalidUserException(AuthenticatorException): class InvalidUserException(AuthenticatorException):
""" """
Invalid user specified. The user cant access the requested service Invalid user specified. The user cant access the requested service
""" """
pass
class InvalidAuthenticatorException(AuthenticatorException): class InvalidAuthenticatorException(AuthenticatorException):
""" """
Invalida authenticator has been specified Invalida authenticator has been specified
""" """
pass
class Redirect(Exception):
class Redirect(AuthenticatorException):
""" """
This exception indicates that a redirect is required. This exception indicates that a redirect is required.
Used in authUrlCallback to indicate that redirect is needed Used in authUrlCallback to indicate that redirect is needed
""" """
pass
class Logout(Exception):
class Logout(AuthenticatorException):
""" """
This exceptions redirects logouts an user and redirects to an url This exceptions redirects logouts an user and redirects to an url
""" """
pass
class MFAError(AuthenticatorException):
"""
This exceptions indicates than an MFA error has ocurred
"""
pass

View File

@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2022 Virtual Cable S.L.U.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * Neither the name of Virtual Cable S.L. nor the names of its contributors
# may be used to endorse or promote products derived from this software
# without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
UDS os managers related interfaces and classes
.. moduleauthor:: Adolfo Gómez, dkmaster at dkmon dot com
"""
from .mfa import MFA
def factory():
"""
Returns factory for register/access to authenticators
"""
from .mfafactory import MFAsFactory
return MFAsFactory.factory()

BIN
server/src/uds/core/mfas/mfa.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,180 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2022 Virtual Cable S.L.U.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors
# may be used to endorse or promote products derived from this software
# without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
@author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import datetime
import random
import logging
import typing
from django.utils.translation import ugettext_noop as _
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
logger = logging.getLogger(__name__)
class MFA(Module):
"""
this class provides an abstraction of a Multi Factor Authentication
"""
# informational related data
# : Name of type, used at administration interface to identify this
# : notifier type (e.g. "Email", "SMS", etc.)
# : This string will be translated when provided to admin interface
# : using gettext, so you can mark it as "_" at derived classes (using gettext_noop)
# : if you want so it can be translated.
typeName: typing.ClassVar[str] = _('Base MFA')
# : Name of type used by Managers to identify this type of service
# : We could have used here the Class name, but we decided that the
# : module implementator will be the one that will provide a name that
# : will relation the class (type) and that name.
typeType: typing.ClassVar[str] = 'baseMFA'
# : Description shown at administration level for this authenticator.
# : This string will be translated when provided to admin interface
# : using gettext, so you can mark it as "_" at derived classes (using gettext_noop)
# : if you want so it can be translated.
typeDescription: typing.ClassVar[str] = _('Base MFA')
# : Icon file, used to represent this authenticator at administration interface
# : This file should be at same folder as this class is, except if you provide
# : 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 minutes
# : Note: This value is used by default "process" methos, but you can
# : override it in your own implementation.
cacheTime: typing.ClassVar[int] = 5
def __init__(self, environment: 'Environment', values: Module.ValuesType):
super().__init__(environment, values)
self.initialize(values)
def initialize(self, values: Module.ValuesType) -> None:
"""
This method will be invoked from __init__ constructor.
This is provided so you don't have to provide your own __init__ method,
and invoke base methods.
This will get invoked when all initialization stuff is done
Args:
values: If values is not none, this object is being initialized
from administration interface, and not unmarshal will be done.
If it's None, this is initialized internally, and unmarshal will
be called after this.
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, userId: str, identifier: str, 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, validity: typing.Optional[int] = None) -> 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)
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 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))
logger.debug('Generated OTP is %s', code)
# Store the code in the database, own storage space
self.storage.putPickle(userId, (getSqlDatetime(), code))
# Send the code to the user
self.sendCode(userId, identifier, code)
def validate(self, userId: 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.
"""
# Validate the code
try:
err = _('Invalid MFA code')
data = self.storage.getPickle(userId)
if data and len(data) == 2:
validity = validity if validity is not None else self.validity() * 60
if validity and data[0] + datetime.timedelta(seconds=validity) > getSqlDatetime():
# if it is no more valid, raise an error
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(userId)
return
except Exception as e:
# Any error means invalid code
err = str(e)
raise exceptions.MFAError(err)

View File

@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2022 Virtual Cable S.L.U.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors
# may be used to endorse or promote products derived from this software
# without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
@author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import logging
import typing
from uds.core.util import singleton
if typing.TYPE_CHECKING:
from .mfa import MFA
logger = logging.getLogger(__name__)
class MFAsFactory(metaclass=singleton.Singleton):
_factory: typing.Optional['MFAsFactory'] = None
_mfas: typing.MutableMapping[str, typing.Type['MFA']] = {}
@staticmethod
def factory() -> 'MFAsFactory':
return MFAsFactory()
def providers(self) -> typing.Mapping[str, typing.Type['MFA']]:
return self._mfas
def insert(self, type_: typing.Type['MFA']) -> None:
logger.debug('Adding Multi Factor Auth %s as %s', type_.type(), type_)
typeName = type_.type().lower()
self._mfas[typeName] = type_
def lookup(self, typeName: str) -> typing.Optional[typing.Type['MFA']]:
return self._mfas.get(typeName.lower(), None)

View File

@ -111,6 +111,7 @@ class gui:
CREDENTIALS_TAB: typing.ClassVar[str] = gettext_noop('Credentials') CREDENTIALS_TAB: typing.ClassVar[str] = gettext_noop('Credentials')
TUNNEL_TAB: typing.ClassVar[str] = gettext_noop('Tunnel') TUNNEL_TAB: typing.ClassVar[str] = gettext_noop('Tunnel')
DISPLAY_TAB: typing.ClassVar[str] = gettext_noop('Display') DISPLAY_TAB: typing.ClassVar[str] = gettext_noop('Display')
MFA_TAB: typing.ClassVar[str] = ugettext_noop('MFA')
# : Static Callbacks simple registry # : Static Callbacks simple registry
callbacks: typing.Dict[ callbacks: typing.Dict[

View File

@ -34,6 +34,7 @@ from functools import wraps
import logging import logging
import inspect import inspect
import typing import typing
import threading
from uds.core.util.html import checkBrowser from uds.core.util.html import checkBrowser
from uds.core.util.cache import Cache from uds.core.util.cache import Cache
@ -196,3 +197,14 @@ def allowCache(
return wrapper return wrapper
return allowCacheDecorator return allowCacheDecorator
# Decorator to execute method in a thread
def threaded(func: typing.Callable[..., None]) -> typing.Callable[..., None]:
"""Decorator to execute method in a thread"""
@wraps(func)
def wrapper(*args, **kwargs) -> None:
thread = threading.Thread(target=func, args=args, kwargs=kwargs)
thread.start()
return wrapper

View File

@ -56,7 +56,7 @@ class GlobalRequestMiddleware:
def __init__(self, get_response: typing.Callable[[HttpRequest], HttpResponse]): def __init__(self, get_response: typing.Callable[[HttpRequest], HttpResponse]):
self._get_response: typing.Callable[[HttpRequest], HttpResponse] = get_response self._get_response: typing.Callable[[HttpRequest], HttpResponse] = get_response
def _process_response(self, request: 'ExtendedHttpRequest', response: HttpResponse): def _process_response(self, request: ExtendedHttpRequest, response: HttpResponse):
# Remove IP from global cache (processing responses after this will make global request unavailable, # Remove IP from global cache (processing responses after this will make global request unavailable,
# but can be got from request again) # but can be got from request again)

View File

@ -48,7 +48,9 @@ class ExtendedHttpRequest(HttpRequest):
ip: str ip: str
ip_proxy: str ip_proxy: str
os: DictAsObj os: DictAsObj
user: typing.Optional[User] # type: ignore user: typing.Optional[User]
authorized: bool
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()

View File

@ -190,6 +190,14 @@ def checkValidBasename(baseName: str, length: int = -1) -> None:
gettext('The machine name can\'t be only numbers') gettext('The machine name can\'t be only numbers')
) )
def removeControlCharacters(s: str) -> str: def removeControlCharacters(s: str) -> str:
"""
Removes control characters from an unicode string
Arguments:
s {str} -- string to remove control characters from
Returns:
str -- string without control characters
"""
return ''.join(ch for ch in s if unicodedata.category(ch)[0] != "C") return ''.join(ch for ch in s if unicodedata.category(ch)[0] != "C")

View File

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
# Copyright (c) 2014-2019 Virtual Cable S.L.U. # Copyright (c) 2014-2022 Virtual Cable S.L.U.
# All rights reserved. # All rights reserved.
# #
# Redistribution and use in source and binary forms, with or without modification, # Redistribution and use in source and binary forms, with or without modification,
@ -170,21 +170,3 @@ def validateMacRange(macRange: str) -> str:
) )
return macRange return macRange
def validateEmail(email: str) -> str:
"""
Validates that an email is valid
:param email: email to validate
:return: Raises Module.Validation exception if is invalid, else return the value "fixed"
"""
if len(email) > 254:
raise Module.ValidationException(
_('Email address is too long')
)
if not re.match(r"[^@]+@[^@]+\.[^@]+", email):
raise Module.ValidationException(
_('Email address is not valid')
)
return email

View File

@ -0,0 +1 @@
from . import mfa

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -0,0 +1,188 @@
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import smtplib
import ssl
import typing
import logging
from django.utils.translation import ugettext_noop as _
from uds.core import mfas
from uds.core.ui import gui
from uds.core.util import validators, decorators
if typing.TYPE_CHECKING:
from uds.core.module import Module
logger = logging.getLogger(__name__)
class EmailMFA(mfas.MFA):
typeName = _('Email Multi Factor')
typeType = 'emailMFA'
typeDescription = _('Email Multi Factor Authenticator')
iconFile = 'mail.png'
hostname = gui.TextField(
length=128,
label=_('SMTP Host'),
order=1,
tooltip=_(
'SMTP Server hostname or IP address. If you are using a '
'non-standard port, add it after a colon, for example: '
'smtp.gmail.com:587'
),
required=True,
tab=_('SMTP Server'),
)
security = gui.ChoiceField(
label=_('Security'),
tooltip=_('Security protocol to use'),
values=[
gui.choiceItem('tls', _('TLS')),
gui.choiceItem('ssl', _('SSL')),
gui.choiceItem('none', _('None')),
],
order=2,
required=True,
tab=_('SMTP Server'),
)
username = gui.TextField(
length=128,
label=_('Username'),
order=9,
tooltip=_('User with access to SMTP server'),
required=False,
defvalue='',
tab=_('SMTP Server'),
)
password = gui.PasswordField(
lenth=128,
label=_('Password'),
order=10,
tooltip=_('Password of the user with access to SMTP server'),
required=False,
defvalue='',
tab=_('SMTP Server'),
)
emailSubject = gui.TextField(
length=128,
defvalue='Verification Code',
label=_('Subject'),
order=3,
tooltip=_('Subject of the email'),
required=True,
tab=_('Config'),
)
fromEmail = gui.TextField(
length=128,
label=_('From Email'),
order=11,
tooltip=_('Email address that will be used as sender'),
required=True,
tab=_('Config'),
)
enableHTML = gui.CheckBoxField(
label=_('Enable HTML'),
order=13,
tooltip=_('Enable HTML in emails'),
defvalue=True,
tab=_('Config'),
)
def initialize(self, values: 'Module.ValuesType' = None):
"""
We will use the "autosave" feature for form fields
"""
if not values:
return
# check hostname for stmp server si valid and is in the right format
# that is a hostname or ip address with optional port
# if hostname is not valid, we will raise an exception
hostname = self.hostname.cleanStr()
if not hostname:
raise EmailMFA.ValidationException(_('Invalid SMTP hostname'))
# Now check is valid format
if ':' in hostname:
host, port = validators.validateHostPortPair(hostname)
self.hostname.value = '{}:{}'.format(host, port)
else:
host = self.hostname.cleanStr()
self.hostname.value = validators.validateHostname(
host, 128, asPattern=False
)
# now check from email and to email
self.fromEmail.value = validators.validateEmail(self.fromEmail.value)
# Done
def label(self) -> str:
return 'OTP received via email'
@decorators.threaded
def sendCode(self, userId: str, identifier: str, code: str) -> None:
# Send and email with the notification
with self.login() as smtp:
try:
# Create message container
msg = MIMEMultipart('alternative')
msg['Subject'] = self.emailSubject.cleanStr()
msg['From'] = self.fromEmail.cleanStr()
msg['To'] = identifier
msg.attach(MIMEText(f'Your verification code is {code}', 'plain'))
if self.enableHTML.value:
msg.attach(MIMEText(f'<p>Your OTP code is <b>{code}</b></p>', 'html'))
smtp.sendmail(self.fromEmail.value, identifier, msg.as_string())
except smtplib.SMTPException as e:
logger.error('Error sending email: {}'.format(e))
raise
def login(self) -> smtplib.SMTP:
"""
Login to SMTP server
"""
host = self.hostname.cleanStr()
if ':' in host:
host, ports = host.split(':')
port = int(ports)
else:
port = None
if self.security.value in ('tls', 'ssl'):
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
if self.security.value == 'tls':
if port:
smtp = smtplib.SMTP(
host,
port,
)
else:
smtp = smtplib.SMTP(host)
smtp.starttls(context=context)
else:
if port:
smtp = smtplib.SMTP_SSL(host, port, context=context)
else:
smtp = smtplib.SMTP_SSL(host, context=context)
else:
if port:
smtp = smtplib.SMTP(host, port)
else:
smtp = smtplib.SMTP(host)
if self.username.value and self.password.value:
smtp.login(self.username.value, self.password.value)
return smtp

View File

@ -0,0 +1 @@
from . import mfa

View File

@ -0,0 +1,39 @@
import typing
import logging
from django.utils.translation import ugettext_noop as _
from uds.core import mfas
from uds.core.ui import gui
if typing.TYPE_CHECKING:
from uds.core.module import Module
logger = logging.getLogger(__name__)
class SampleMFA(mfas.MFA):
typeName = _('Sample Multi Factor')
typeType = 'sampleMFA'
typeDescription = _('Sample Multi Factor Authenticator')
iconFile = 'sample.png'
useless = gui.CheckBoxField(
label=_('Sample useless field'),
order=90,
tooltip=_(
'This is a useless field, for sample and testing pourposes'
),
tab=gui.ADVANCED_TAB,
defvalue=gui.TRUE,
)
def initialize(self, values: 'Module.ValuesType') -> None:
return super().initialize(values)
def label(self) -> str:
return 'Code is in log'
def sendCode(self, userId: str, identifier: str, code: str) -> None:
logger.debug('Sending code: %s', code)
return

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2012-2019 Virtual Cable S.L.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * Neither the name of Virtual Cable S.L. nor the names of its contributors
# may be used to endorse or promote products derived from this software
# without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
Authentication modules for uds are contained inside this module.
To create a new authentication module, you will need to follow this steps:
1.- Create the authentication module, probably based on an existing one
2.- Insert the module as child of this module
3.- Import the class of your authentication module at __init__. For example::
from Authenticator import SimpleAthenticator
4.- Done. At Server restart, the module will be recognized, loaded and treated
The registration of modules is done locating subclases of :py:class:`uds.core.auths.Authentication`
.. moduleauthor:: Adolfo Gómez, dkmaster at dkmon dot com
"""
import os.path
import pkgutil
import importlib
import sys
import typing
def __init__():
"""
This imports all packages that are descendant of this package, and, after that,
it register all subclases of authenticator as
"""
from uds.core import mfas
# Dinamycally import children of this package. The __init__.py files must declare mfs as subclasses of mfas.MFA
pkgpath = os.path.dirname(typing.cast(str, sys.modules[__name__].__file__))
for _, name, _ in pkgutil.iter_modules([pkgpath]):
# __import__(name, globals(), locals(), [], 1)
importlib.import_module('.' + name, __name__) # import module
importlib.invalidate_caches()
a = mfas.MFA
for cls in a.__subclasses__():
mfas.factory().insert(cls)
__init__()

View File

@ -0,0 +1,36 @@
# Generated by Django 3.2.10 on 2022-06-23 19:34
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('uds', '0042_auto_20210628_1533'),
]
operations = [
migrations.CreateModel(
name='MFA',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.CharField(default=None, max_length=50, null=True, unique=True)),
('name', models.CharField(db_index=True, max_length=128)),
('data_type', models.CharField(max_length=128)),
('data', models.TextField(default='')),
('comments', models.CharField(max_length=256)),
('remember_device', models.IntegerField(default=0)),
('validity', models.IntegerField(default=0)),
('tags', models.ManyToManyField(to='uds.Tag')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='authenticator',
name='mfa',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='authenticators', to='uds.mfa'),
),
]

View File

@ -113,5 +113,7 @@ from .tunnel_token import TunnelToken
# Notifications & Alerts # Notifications & Alerts
from .notifications import Notification, Notifier, NotificationLevel from .notifications import Notification, Notifier, NotificationLevel
# Multi factor authentication
from .mfa import MFA
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -46,7 +46,7 @@ from .util import NEVER
# Not imported at runtime, just for type checking # Not imported at runtime, just for type checking
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from uds.models import User, Group, Network from uds.models import User, Group, Network, MFA
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -81,6 +81,8 @@ class Authenticator(ManagedObjectModel, TaggingMixin):
groups: 'models.manager.RelatedManager[Group]' groups: 'models.manager.RelatedManager[Group]'
networks: 'models.manager.RelatedManager[Network]' networks: 'models.manager.RelatedManager[Network]'
# MFA associated to this authenticator. Can be null
mfa = models.ForeignKey(MFA, on_delete=models.SET_NULL, null=True, blank=True, related_name='authenticators')
class Meta(ManagedObjectModel.Meta): # pylint: disable=too-few-public-methods class Meta(ManagedObjectModel.Meta): # pylint: disable=too-few-public-methods
""" """

View File

@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2012-2022 Virtual Cable S.L.U.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors
# may be used to endorse or promote products derived from this software
# without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
.. moduleauthor:: Adolfo Gómez, dkmaster at dkmon dot com
"""
import logging
import typing
from django.db import models
from .managed_object_model import ManagedObjectModel
from .tag import TaggingMixin
# Not imported at runtime, just for type checking
if typing.TYPE_CHECKING:
from .authenticator import Authenticator
from uds.core import mfas
logger = logging.getLogger(__name__)
class MFA(ManagedObjectModel, TaggingMixin): # type: ignore
"""
An OS Manager represents a manager for responding requests for agents inside services.
"""
# "fake" declarations for type checking
objects: 'models.BaseManager[MFA]'
authenticators: 'models.manager.RelatedManager[Authenticator]'
remember_device = models.IntegerField(default=0) # Time to remember the device MFA in hours
validity = models.IntegerField(default=0) # Limit of time for this MFA to be used, in seconds
def getInstance(
self, values: typing.Optional[typing.Dict[str, str]] = None
) -> 'mfas.MFA':
return typing.cast('mfas.MFA', super().getInstance(values=values))
def getType(self) -> typing.Type['mfas.MFA']:
"""
Get the type of the object this record represents.
The type is Python type, it obtains this OsManagersFactory and associated record field.
Returns:
The python type for this record object
:note: We only need to get info from this, not access specific data (class specific info)
"""
# We only need to get info from this, not access specific data (class specific info)
from uds.core import mfas
return mfas.factory().lookup(self.data_type) or mfas.MFA
def __str__(self) -> str:
return "{0} of type {1} (id:{2})".format(self.name, self.data_type, self.id)
@staticmethod
def beforeDelete(sender, **kwargs) -> None:
"""
Used to invoke the Service class "Destroy" before deleting it from database.
The main purpuse of this hook is to call the "destroy" method of the object to delete and
to clear related data of the object (environment data such as own storage, cache, etc...
:note: If destroy raises an exception, the deletion is not taken.
"""
toDelete: 'MFA' = kwargs['instance']
# Only tries to get instance if data is not empty
if toDelete.data:
try:
s = toDelete.getInstance()
s.destroy()
s.env.clearRelatedData()
except Exception as e:
logger.error('Error processing deletion of notifier %s: %s (forced deletion)', toDelete.name, e)
logger.debug('Before delete mfa provider %s', toDelete)
# : Connects a pre deletion signal to OS Manager
models.signals.pre_delete.connect(MFA.beforeDelete, sender=MFA)

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 710 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

View File

@ -73,6 +73,7 @@ gettext("October");
gettext("November"); gettext("November");
gettext("December"); gettext("December");
gettext("Never"); gettext("Never");
<<<<<<< HEAD
gettext("Pool"); gettext("Pool");
gettext("State"); gettext("State");
gettext("User Services"); gettext("User Services");
@ -111,6 +112,17 @@ gettext("Delete group");
gettext("New Authenticator"); gettext("New Authenticator");
gettext("Edit Authenticator"); gettext("Edit Authenticator");
gettext("Delete Authenticator"); gettext("Delete Authenticator");
=======
gettext("New Network");
gettext("Edit Network");
gettext("Delete Network");
gettext("New Proxy");
gettext("Edit Proxy");
gettext("Delete Proxy");
gettext("New Transport");
gettext("Edit Transport");
gettext("Delete Transport");
>>>>>>> origin/v3.5-mfa
gettext("New OS Manager"); gettext("New OS Manager");
gettext("Edit OS Manager"); gettext("Edit OS Manager");
gettext("Delete OS Manager"); gettext("Delete OS Manager");
@ -257,6 +269,7 @@ gettext("Report finished");
gettext("dismiss"); gettext("dismiss");
gettext("Generate report"); gettext("Generate report");
gettext("Delete tunnel token - USE WITH EXTREME CAUTION!!!"); gettext("Delete tunnel token - USE WITH EXTREME CAUTION!!!");
<<<<<<< HEAD
gettext("New Notifier"); gettext("New Notifier");
gettext("Edit Notifier"); gettext("Edit Notifier");
gettext("Delete actor token - USE WITH EXTREME CAUTION!!!"); gettext("Delete actor token - USE WITH EXTREME CAUTION!!!");
@ -266,6 +279,49 @@ gettext("Delete Network");
gettext("New Transport"); gettext("New Transport");
gettext("Edit Transport"); gettext("Edit Transport");
gettext("Delete Transport"); gettext("Delete Transport");
=======
gettext("Information");
gettext("In Maintenance");
gettext("Active");
gettext("Delete user");
gettext("Delete group");
gettext("Pool");
gettext("State");
gettext("User Services");
gettext("Name");
gettext("Real Name");
gettext("state");
gettext("Last access");
gettext("Group");
gettext("Comments");
gettext("Enabled");
gettext("Disabled");
gettext("Blocked");
gettext("Service pools");
gettext("Users");
gettext("Groups");
gettext("Any");
gettext("All");
gettext("Group");
gettext("Comments");
gettext("Pool");
gettext("State");
gettext("User Services");
gettext("Unique ID");
gettext("Friendly Name");
gettext("In Use");
gettext("IP");
gettext("Services Pool");
gettext("Groups");
gettext("Services Pools");
gettext("Assigned services");
gettext("New Authenticator");
gettext("Edit Authenticator");
gettext("Delete Authenticator");
gettext("New Authenticator");
gettext("Edit Authenticator");
gettext("Delete Authenticator");
>>>>>>> origin/v3.5-mfa
gettext("Error saving: "); gettext("Error saving: ");
gettext("Error saving element"); gettext("Error saving element");
gettext("Error handling your request"); gettext("Error handling your request");
@ -311,8 +367,13 @@ gettext("User mode");
gettext("Logout"); gettext("Logout");
gettext("Summary"); gettext("Summary");
gettext("Services"); gettext("Services");
<<<<<<< HEAD
gettext("Networks"); gettext("Networks");
=======
gettext("Authentication");
>>>>>>> origin/v3.5-mfa
gettext("Authenticators"); gettext("Authenticators");
gettext("Multi Factor");
gettext("Os Managers"); gettext("Os Managers");
gettext("Transports"); gettext("Transports");
gettext("Pools"); gettext("Pools");
@ -330,47 +391,6 @@ gettext("Actor");
gettext("Tunnel"); gettext("Tunnel");
gettext("Configuration"); gettext("Configuration");
gettext("Flush Cache"); gettext("Flush Cache");
gettext("Information for");
gettext("Services Pools");
gettext("Users");
gettext("Groups");
gettext("Ok");
gettext("Edit group");
gettext("New group");
gettext("Meta group name");
gettext("Comments");
gettext("State");
gettext("Enabled");
gettext("Disabled");
gettext("Service Pools");
gettext("Match mode");
gettext("Selected Groups");
gettext("Cancel");
gettext("Ok");
gettext("Edit user");
gettext("New user");
gettext("Real name");
gettext("Comments");
gettext("State");
gettext("Enabled");
gettext("Disabled");
gettext("Blocked");
gettext("Role");
gettext("Admin");
gettext("Staff member");
gettext("User");
gettext("Groups");
gettext("Cancel");
gettext("Ok");
gettext("Information for");
gettext("Groups");
gettext("Services Pools");
gettext("Assigned Services");
gettext("Ok");
gettext("Summary");
gettext("Users");
gettext("Groups");
gettext("Logs");
gettext("Account usage"); gettext("Account usage");
gettext("Edit rule"); gettext("Edit rule");
gettext("New rule"); gettext("New rule");
@ -489,3 +509,44 @@ gettext("For optimal results, use "squared" images.");
gettext("The image will be resized on upload to"); gettext("The image will be resized on upload to");
gettext("Cancel"); gettext("Cancel");
gettext("Ok"); gettext("Ok");
gettext("Summary");
gettext("Users");
gettext("Groups");
gettext("Logs");
gettext("Information for");
gettext("Services Pools");
gettext("Users");
gettext("Groups");
gettext("Ok");
gettext("Edit group");
gettext("New group");
gettext("Meta group name");
gettext("Comments");
gettext("State");
gettext("Enabled");
gettext("Disabled");
gettext("Service Pools");
gettext("Match mode");
gettext("Selected Groups");
gettext("Cancel");
gettext("Ok");
gettext("Edit user");
gettext("New user");
gettext("Real name");
gettext("Comments");
gettext("State");
gettext("Enabled");
gettext("Disabled");
gettext("Blocked");
gettext("Role");
gettext("Admin");
gettext("Staff member");
gettext("User");
gettext("Groups");
gettext("Cancel");
gettext("Ok");
gettext("Information for");
gettext("Groups");
gettext("Services Pools");
gettext("Assigned Services");
gettext("Ok");

View File

@ -99,7 +99,11 @@
</svg> </svg>
</div> </div>
</uds-root> </uds-root>
<<<<<<< HEAD
<script src="/uds/res/admin/runtime.js?stamp=1654535153" type="module"></script><script src="/uds/res/admin/polyfills.js?stamp=1654535153" type="module"></script><script src="/uds/res/admin/main.js?stamp=1654535153" type="module"></script> <script src="/uds/res/admin/runtime.js?stamp=1654535153" type="module"></script><script src="/uds/res/admin/polyfills.js?stamp=1654535153" type="module"></script><script src="/uds/res/admin/main.js?stamp=1654535153" type="module"></script>
=======
<script src="/uds/res/admin/runtime.js?stamp=1656004218" defer></script><script src="/uds/res/admin/polyfills-es5.js?stamp=1656004218" nomodule defer></script><script src="/uds/res/admin/polyfills.js?stamp=1656004218" defer></script><script src="/uds/res/admin/main.js?stamp=1656004218" defer></script>
>>>>>>> origin/v3.5-mfa
</body></html> </body></html>

View File

@ -91,6 +91,8 @@ urlpatterns = [
name='page.login.tag', name='page.login.tag',
), ),
path(r'uds/page/logout', uds.web.views.modern.logout, name='page.logout'), path(r'uds/page/logout', uds.web.views.modern.logout, name='page.logout'),
# MFA authentication
path(r'uds/page/mfa/', uds.web.views.modern.mfa, name='page.mfa'),
# Error URL (just a placeholder, calls index with data on url for angular) # Error URL (just a placeholder, calls index with data on url for angular)
re_path( re_path(
r'^uds/page/error/(?P<err>[a-zA-Z0-9=-]+)$', r'^uds/page/error/(?P<err>[a-zA-Z0-9=-]+)$',

View File

@ -70,4 +70,8 @@ class LoginForm(forms.Form):
continue continue
choices.append((a.uuid, a.name)) choices.append((a.uuid, a.name))
<<<<<<< HEAD
typing.cast(forms.ChoiceField, self.fields['authenticator']).choices = choices typing.cast(forms.ChoiceField, self.fields['authenticator']).choices = choices
=======
self.fields['authenticator'].choices = choices # type: ignore
>>>>>>> origin/v3.5-mfa

View File

@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2012-2019 Virtual Cable S.L.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * Neither the name of Virtual Cable S.L. nor the names of its contributors
# may be used to endorse or promote products derived from this software
# without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
@author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import logging
from django.utils.translation import ugettext_lazy as _
from django import forms
logger = logging.getLogger(__name__)
class MFAForm(forms.Form):
code = forms.CharField(max_length=64, widget=forms.TextInput())
remember = forms.BooleanField(required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

View File

@ -64,7 +64,7 @@ def udsJs(request: 'ExtendedHttpRequest') -> str:
) # Last one is a placeholder in case we can't locate host name ) # Last one is a placeholder in case we can't locate host name
role: str = 'user' role: str = 'user'
user: typing.Optional['User'] = request.user user: typing.Optional['User'] = request.user if request.authorized else None
if user: if user:
role = ( role = (
@ -158,6 +158,7 @@ def udsJs(request: 'ExtendedHttpRequest') -> str:
'authenticators': [ 'authenticators': [
getAuthInfo(auth) for auth in authenticators if auth.getType() getAuthInfo(auth) for auth in authenticators if auth.getType()
], ],
'mfa': request.session.get('mfa', None),
'tag': tag, 'tag': tag,
'os': request.os['OS'].value[0], 'os': request.os['OS'].value[0],
'image_size': Image.MAX_IMAGE_SIZE, 'image_size': Image.MAX_IMAGE_SIZE,
@ -178,6 +179,7 @@ def udsJs(request: 'ExtendedHttpRequest') -> str:
'urls': { 'urls': {
'changeLang': reverse('set_language'), 'changeLang': reverse('set_language'),
'login': reverse('page.login'), 'login': reverse('page.login'),
'mfa': reverse('page.mfa'),
'logout': reverse('page.logout'), 'logout': reverse('page.logout'),
'user': reverse('page.index'), 'user': reverse('page.index'),
'customAuth': reverse('uds.web.views.customAuth', kwargs={'idAuth': ''}), 'customAuth': reverse('uds.web.views.customAuth', kwargs={'idAuth': ''}),

View File

@ -72,6 +72,7 @@ SERVICE_CALENDAR_DENIED = 15
PAGE_NOT_FOUND = 16 PAGE_NOT_FOUND = 16
INTERNAL_SERVER_ERROR = 17 INTERNAL_SERVER_ERROR = 17
RELOAD_NOT_SUPPORTED = 18 RELOAD_NOT_SUPPORTED = 18
INVALID_MFA_CODE = 19
strings = [ strings = [
_('Unknown error'), _('Unknown error'),
@ -97,6 +98,7 @@ strings = [
_('Page not found'), _('Page not found'),
_('Unexpected error'), _('Unexpected error'),
_('Reloading this page is not supported. Please, reopen service from origin.'), _('Reloading this page is not supported. Please, reopen service from origin.'),
_('Invalid Multi-Factor Authentication code'),
] ]

View File

@ -134,6 +134,16 @@ def authCallback_stage2(
# Now we render an intermediate page, so we get Java support from user # Now we render an intermediate page, so we get Java support from user
# It will only detect java, and them redirect to Java # It will only detect java, and them redirect to Java
# If MFA is provided, we need to redirect to MFA page
request.authorized = True
if authenticator.getType().providesMfa() and authenticator.mfa:
authInstance = authenticator.getInstance()
if authInstance.mfaIdentifier(user.name):
request.authorized = False # We can ask for MFA so first disauthorize user
response = HttpResponseRedirect(
reverse('page.mfa')
)
return response return response
except auths.exceptions.Redirect as e: except auths.exceptions.Redirect as e:
return HttpResponseRedirect( return HttpResponseRedirect(
@ -148,9 +158,6 @@ def authCallback_stage2(
logger.exception('authCallback') logger.exception('authCallback')
return errors.exceptionView(request, e) return errors.exceptionView(request, e)
# Will never reach this
raise RuntimeError('Unreachable point reached!!!')
@csrf_exempt @csrf_exempt
def authInfo(request: 'HttpRequest', authName: str) -> HttpResponse: def authInfo(request: 'HttpRequest', authName: str) -> HttpResponse:
@ -248,6 +255,7 @@ def ticketAuth(
webLogin(request, None, usr, password) webLogin(request, None, usr, password)
request.user = usr # Temporarily store this user as "authenticated" user, next requests will be done using session request.user = usr # Temporarily store this user as "authenticated" user, next requests will be done using session
request.authorized = True # User is authorized
request.session['ticket'] = '1' # Store that user access is done using ticket request.session['ticket'] = '1' # Store that user access is done using ticket
# Transport must always be automatic for ticket authentication # Transport must always be automatic for ticket authentication

View File

@ -28,8 +28,10 @@
""" """
@author: Adolfo Gómez, dkmaster at dkmon dot com @author: Adolfo Gómez, dkmaster at dkmon dot com
""" """
import datetime
import time import time
import logging import logging
import hashlib
import typing import typing
from django.middleware import csrf from django.middleware import csrf
@ -37,9 +39,11 @@ from django.shortcuts import render
from django.http import HttpRequest, HttpResponse, JsonResponse, HttpResponseRedirect from django.http import HttpRequest, HttpResponse, JsonResponse, HttpResponseRedirect
from django.views.decorators.cache import never_cache from django.views.decorators.cache import never_cache
from django.urls import reverse from django.urls import reverse
from uds.core.util.request import ExtendedHttpRequest, ExtendedHttpRequestWithUser
from uds.core.auths import auth
from uds.core.util.request import ExtendedHttpRequest, ExtendedHttpRequestWithUser
from django.views.decorators.cache import never_cache
from uds.core.auths import auth, exceptions
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
from uds.web.util.authentication import checkLogin from uds.web.util.authentication import checkLogin
@ -88,6 +92,10 @@ def login(
response: typing.Optional[HttpResponse] = None response: typing.Optional[HttpResponse] = None
if request.method == 'POST': if request.method == 'POST':
request.session['restricted'] = False # Access is from login request.session['restricted'] = False # Access is from login
request.authorized = (
False # Ensure that on login page, user is unauthorized first
)
form = LoginForm(request.POST, tag=tag) form = LoginForm(request.POST, tag=tag)
loginResult = checkLogin(request, form, tag) loginResult = checkLogin(request, form, tag)
if loginResult.user: if loginResult.user:
@ -97,6 +105,17 @@ def login(
auth.webLogin(request, response, loginResult.user, loginResult.password) auth.webLogin(request, response, loginResult.user, loginResult.password)
# And restore tag # And restore tag
request.session['tag'] = tag request.session['tag'] = tag
# If MFA is provided, we need to redirect to MFA page
request.authorized = True
if user.manager.getType().providesMfa() and user.manager.mfa:
authInstance = user.manager.getInstance()
if authInstance.mfaIdentifier():
request.authorized = (
False # We can ask for MFA so first disauthorize user
)
response = HttpResponseRedirect(reverse('page.mfa'))
else: else:
# If redirection on login failure is found, honor it # If redirection on login failure is found, honor it
if loginResult.url: # Redirection if loginResult.url: # Redirection
@ -121,6 +140,7 @@ def login(
def logout(request: ExtendedHttpRequestWithUser) -> HttpResponse: def logout(request: ExtendedHttpRequestWithUser) -> HttpResponse:
auth.authLogLogout(request) auth.authLogLogout(request)
request.session['restricted'] = False # Remove restricted request.session['restricted'] = False # Remove restricted
request.authorized = False
logoutResponse = request.user.logout(request) logoutResponse = request.user.logout(request)
return auth.webLogout( return auth.webLogout(
request, logoutResponse.url or request.session.get('logouturl', None) request, logoutResponse.url or request.session.get('logouturl', None)