Integrating mfa branch on 4.0. Initial changes. Work to do
@ -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)
|
||||||
|
|
||||||
|
118
server/src/uds/REST/methods/mfas.py
Normal 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),
|
||||||
|
}
|
@ -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":
|
||||||
|
@ -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:
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
44
server/src/uds/core/mfas/__init__.py
Normal 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
After Width: | Height: | Size: 1.3 KiB |
180
server/src/uds/core/mfas/mfa.py
Normal 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)
|
||||||
|
|
61
server/src/uds/core/mfas/mfafactory.py
Normal 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)
|
@ -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[
|
||||||
|
@ -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
|
@ -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)
|
||||||
|
|
||||||
|
@ -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__()
|
||||||
|
@ -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")
|
||||||
|
@ -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
|
|
1
server/src/uds/mfas/Email/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import mfa
|
BIN
server/src/uds/mfas/Email/mail.png
Normal file
After Width: | Height: | Size: 11 KiB |
188
server/src/uds/mfas/Email/mfa.py
Normal 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
|
1
server/src/uds/mfas/Sample/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import mfa
|
39
server/src/uds/mfas/Sample/mfa.py
Normal 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
|
||||||
|
|
BIN
server/src/uds/mfas/Sample/sample.png
Executable file
After Width: | Height: | Size: 1.1 KiB |
69
server/src/uds/mfas/__init__.py
Normal 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__()
|
36
server/src/uds/migrations/0043_auto_20220623_1934.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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__)
|
||||||
|
@ -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
|
||||||
"""
|
"""
|
||||||
|
109
server/src/uds/models/mfa.py
Normal 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)
|
BIN
server/src/uds/static/admin/img/icons/authentication.png
Normal file
After Width: | Height: | Size: 710 B |
Before Width: | Height: | Size: 710 B After Width: | Height: | Size: 1.1 KiB |
BIN
server/src/uds/static/admin/img/icons/mfas.png
Executable file
After Width: | Height: | Size: 1.3 KiB |
@ -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");
|
||||||
|
@ -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>
|
0
server/src/uds/templates/uds/modern/mfa.html
Normal 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=-]+)$',
|
||||||
|
@ -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
|
||||||
|
46
server/src/uds/web/forms/MFAForm.py
Normal 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)
|
@ -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': ''}),
|
||||||
|
@ -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'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|