Integrating mfa branch on 4.0. Initial changes. Work to do
@ -34,7 +34,7 @@ import logging
|
||||
import typing
|
||||
|
||||
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.REST import NotFound
|
||||
@ -46,6 +46,7 @@ from .users_groups import Users, Groups
|
||||
|
||||
# Not imported at runtime, just for type checking
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db import models
|
||||
from uds.core import Module
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -76,16 +77,10 @@ class Authenticators(ModelHandler):
|
||||
{'type_name': {'title': _('Type')}},
|
||||
{'comments': {'title': _('Comments')}},
|
||||
{'priority': {'title': _('Priority'), 'type': 'numeric', 'width': '5em'}},
|
||||
{
|
||||
'state': {
|
||||
'title': _('Access'),
|
||||
'type': 'dict',
|
||||
'dict': {'v': _('Visible'), 'h': _('Hidden'), 'd': 'Disabled'},
|
||||
'width': '3em',
|
||||
}
|
||||
},
|
||||
{'visible': {'title': _('Visible'), 'type': 'callback', 'width': '3em'}},
|
||||
{'small_name': {'title': _('Label')}},
|
||||
{'users_count': {'title': _('Users'), 'type': 'numeric', 'width': '5em'}},
|
||||
{'mfa': {'title': _('MFA'), 'type': 'callback', 'width': '3em'}},
|
||||
{'tags': {'title': _('tags'), 'visible': False}},
|
||||
]
|
||||
|
||||
@ -134,6 +129,28 @@ class Authenticators(ModelHandler):
|
||||
'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
|
||||
raise Exception() # Not found
|
||||
except Exception:
|
||||
@ -151,6 +168,7 @@ class Authenticators(ModelHandler):
|
||||
'net_filtering': item.net_filtering,
|
||||
'networks': [{'id': n.uuid} for n in item.networks.all()],
|
||||
'state': item.state,
|
||||
'mfa_id': item.mfa.uuid if item.mfa else '',
|
||||
'small_name': item.small_name,
|
||||
'users_count': item.users.count(),
|
||||
'type': type_.type(),
|
||||
@ -218,6 +236,20 @@ class Authenticators(ModelHandler):
|
||||
return self.success()
|
||||
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):
|
||||
# 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
|
||||
from . import services
|
||||
from . import auths
|
||||
from . import mfas # To make sure mfas are loaded on memory
|
||||
from . import osmanagers
|
||||
from . import notifiers
|
||||
from . import transports
|
||||
@ -95,7 +96,6 @@ default_app_config = 'uds.UDSAppConfig'
|
||||
|
||||
# Sets up several sqlite non existing methods
|
||||
|
||||
|
||||
@receiver(connection_created)
|
||||
def extend_sqlite(connection=None, **kwargs):
|
||||
if connection and connection.vendor == "sqlite":
|
||||
|
@ -69,6 +69,7 @@ authLogger = logging.getLogger('authLog')
|
||||
USER_KEY = 'uk'
|
||||
PASS_KEY = 'pk'
|
||||
EXPIRY_KEY = 'ek'
|
||||
AUTHORIZED_KEY = 'ak'
|
||||
ROOT_ID = -20091204 # Any negative number will do the trick
|
||||
UDS_COOKIE_LENGTH = 48
|
||||
|
||||
@ -128,7 +129,9 @@ def getRootUser() -> models.User:
|
||||
# Decorator to make easier protect pages that needs to be logged in
|
||||
def webLoginRequired(
|
||||
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
|
||||
Look for samples at uds.core.web.views
|
||||
@ -136,23 +139,25 @@ def webLoginRequired(
|
||||
if admin == 'admin', needs admin
|
||||
"""
|
||||
|
||||
def decorator(view_func: typing.Callable[..., RT]) -> typing.Callable[..., RT]:
|
||||
def _wrapped_view(request: 'ExtendedHttpRequest', *args, **kwargs) -> RT:
|
||||
def decorator(
|
||||
view_func: typing.Callable[..., HttpResponse]
|
||||
) -> typing.Callable[..., HttpResponse]:
|
||||
@wraps(view_func)
|
||||
def _wrapped_view(
|
||||
request: 'ExtendedHttpRequest', *args, **kwargs
|
||||
) -> HttpResponse:
|
||||
"""
|
||||
Wrapped function for decorator
|
||||
"""
|
||||
if not request.user:
|
||||
# url = request.build_absolute_uri(GlobalConfig.LOGIN_URL.get())
|
||||
# if GlobalConfig.REDIRECT_TO_HTTPS.getBool() is True:
|
||||
# url = url.replace('http://', 'https://')
|
||||
# logger.debug('No user found, redirecting to %s', url)
|
||||
return HttpResponseRedirect(reverse('page.login')) # type: ignore
|
||||
# If no user or user authorization is not completed...
|
||||
if not request.user or not request.authorized:
|
||||
return HttpResponseRedirect(reverse('page.login'))
|
||||
|
||||
if admin is True or admin == 'admin': # bool or string "admin"
|
||||
if request.user.isStaff() is False or (
|
||||
admin == 'admin' and not request.user.is_admin
|
||||
):
|
||||
return HttpResponseForbidden(_('Forbidden')) # type: ignore
|
||||
return HttpResponseForbidden(_('Forbidden'))
|
||||
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
@ -199,7 +204,7 @@ def denyNonAuthenticated(
|
||||
) -> typing.Callable[..., RT]:
|
||||
@wraps(view_func)
|
||||
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 view_func(request, *args, **kwargs)
|
||||
|
||||
@ -400,6 +405,9 @@ def webLogin(
|
||||
cookie = getUDSCookie(request, response)
|
||||
|
||||
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[PASS_KEY] = cryptoManager().symCrypt(
|
||||
password, cookie
|
||||
@ -443,13 +451,12 @@ def webLogout(
|
||||
by django in regular basis.
|
||||
"""
|
||||
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:
|
||||
authenticator = request.user.manager.getInstance()
|
||||
username = request.user.name
|
||||
# Success/fail result is now ignored
|
||||
exit_url = authenticator.logout(request, username).url or exit_url
|
||||
exit_url = authenticator.logout(username) or exit_url
|
||||
if request.user.id != ROOT_ID:
|
||||
# Log the event if not root user
|
||||
events.addEvent(
|
||||
@ -460,9 +467,12 @@ def webLogout(
|
||||
)
|
||||
else: # No user, redirect to /
|
||||
return HttpResponseRedirect(reverse('page.login'))
|
||||
|
||||
except Exception:
|
||||
raise
|
||||
finally:
|
||||
# Try to delete session
|
||||
request.session.flush()
|
||||
request.authorized = False
|
||||
|
||||
response = HttpResponseRedirect(request.build_absolute_uri(exit_url))
|
||||
if authenticator:
|
||||
|
@ -327,6 +327,25 @@ class Authenticator(Module):
|
||||
"""
|
||||
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(
|
||||
self,
|
||||
username: str,
|
||||
|
@ -37,27 +37,45 @@ class AuthenticatorException(Exception):
|
||||
Generic authentication exception
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidUserException(AuthenticatorException):
|
||||
"""
|
||||
Invalid user specified. The user cant access the requested service
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidAuthenticatorException(AuthenticatorException):
|
||||
"""
|
||||
Invalida authenticator has been specified
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
class Redirect(Exception):
|
||||
|
||||
class Redirect(AuthenticatorException):
|
||||
"""
|
||||
This exception indicates that a redirect is required.
|
||||
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
|
||||
"""
|
||||
|
||||
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')
|
||||
TUNNEL_TAB: typing.ClassVar[str] = gettext_noop('Tunnel')
|
||||
DISPLAY_TAB: typing.ClassVar[str] = gettext_noop('Display')
|
||||
MFA_TAB: typing.ClassVar[str] = ugettext_noop('MFA')
|
||||
|
||||
# : Static Callbacks simple registry
|
||||
callbacks: typing.Dict[
|
||||
|
@ -34,6 +34,7 @@ from functools import wraps
|
||||
import logging
|
||||
import inspect
|
||||
import typing
|
||||
import threading
|
||||
|
||||
from uds.core.util.html import checkBrowser
|
||||
from uds.core.util.cache import Cache
|
||||
@ -196,3 +197,14 @@ def allowCache(
|
||||
return wrapper
|
||||
|
||||
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]):
|
||||
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,
|
||||
# but can be got from request again)
|
||||
|
||||
|
@ -48,7 +48,9 @@ class ExtendedHttpRequest(HttpRequest):
|
||||
ip: str
|
||||
ip_proxy: str
|
||||
os: DictAsObj
|
||||
user: typing.Optional[User] # type: ignore
|
||||
user: typing.Optional[User]
|
||||
authorized: bool
|
||||
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
@ -190,6 +190,14 @@ def checkValidBasename(baseName: str, length: int = -1) -> None:
|
||||
gettext('The machine name can\'t be only numbers')
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
|
@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
#
|
||||
# Copyright (c) 2014-2019 Virtual Cable S.L.U.
|
||||
# Copyright (c) 2014-2022 Virtual Cable S.L.U.
|
||||
# All rights reserved.
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without modification,
|
||||
@ -170,21 +170,3 @@ def validateMacRange(macRange: str) -> str:
|
||||
)
|
||||
|
||||
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
|
||||
from .notifications import Notification, Notifier, NotificationLevel
|
||||
# Multi factor authentication
|
||||
from .mfa import MFA
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -46,7 +46,7 @@ from .util import NEVER
|
||||
|
||||
# Not imported at runtime, just for 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__)
|
||||
@ -81,6 +81,8 @@ class Authenticator(ManagedObjectModel, TaggingMixin):
|
||||
groups: 'models.manager.RelatedManager[Group]'
|
||||
|
||||
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
|
||||
"""
|
||||
|
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("December");
|
||||
gettext("Never");
|
||||
<<<<<<< HEAD
|
||||
gettext("Pool");
|
||||
gettext("State");
|
||||
gettext("User Services");
|
||||
@ -111,6 +112,17 @@ gettext("Delete group");
|
||||
gettext("New Authenticator");
|
||||
gettext("Edit 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("Edit OS Manager");
|
||||
gettext("Delete OS Manager");
|
||||
@ -257,6 +269,7 @@ gettext("Report finished");
|
||||
gettext("dismiss");
|
||||
gettext("Generate report");
|
||||
gettext("Delete tunnel token - USE WITH EXTREME CAUTION!!!");
|
||||
<<<<<<< HEAD
|
||||
gettext("New Notifier");
|
||||
gettext("Edit Notifier");
|
||||
gettext("Delete actor token - USE WITH EXTREME CAUTION!!!");
|
||||
@ -266,6 +279,49 @@ gettext("Delete Network");
|
||||
gettext("New Transport");
|
||||
gettext("Edit 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 element");
|
||||
gettext("Error handling your request");
|
||||
@ -311,8 +367,13 @@ gettext("User mode");
|
||||
gettext("Logout");
|
||||
gettext("Summary");
|
||||
gettext("Services");
|
||||
<<<<<<< HEAD
|
||||
gettext("Networks");
|
||||
=======
|
||||
gettext("Authentication");
|
||||
>>>>>>> origin/v3.5-mfa
|
||||
gettext("Authenticators");
|
||||
gettext("Multi Factor");
|
||||
gettext("Os Managers");
|
||||
gettext("Transports");
|
||||
gettext("Pools");
|
||||
@ -330,47 +391,6 @@ gettext("Actor");
|
||||
gettext("Tunnel");
|
||||
gettext("Configuration");
|
||||
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("Edit 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("Cancel");
|
||||
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>
|
||||
</div>
|
||||
</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=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>
|
0
server/src/uds/templates/uds/modern/mfa.html
Normal file
@ -91,6 +91,8 @@ urlpatterns = [
|
||||
name='page.login.tag',
|
||||
),
|
||||
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)
|
||||
re_path(
|
||||
r'^uds/page/error/(?P<err>[a-zA-Z0-9=-]+)$',
|
||||
|
@ -70,4 +70,8 @@ class LoginForm(forms.Form):
|
||||
continue
|
||||
choices.append((a.uuid, a.name))
|
||||
|
||||
<<<<<<< HEAD
|
||||
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
|
||||
|
||||
role: str = 'user'
|
||||
user: typing.Optional['User'] = request.user
|
||||
user: typing.Optional['User'] = request.user if request.authorized else None
|
||||
|
||||
if user:
|
||||
role = (
|
||||
@ -158,6 +158,7 @@ def udsJs(request: 'ExtendedHttpRequest') -> str:
|
||||
'authenticators': [
|
||||
getAuthInfo(auth) for auth in authenticators if auth.getType()
|
||||
],
|
||||
'mfa': request.session.get('mfa', None),
|
||||
'tag': tag,
|
||||
'os': request.os['OS'].value[0],
|
||||
'image_size': Image.MAX_IMAGE_SIZE,
|
||||
@ -178,6 +179,7 @@ def udsJs(request: 'ExtendedHttpRequest') -> str:
|
||||
'urls': {
|
||||
'changeLang': reverse('set_language'),
|
||||
'login': reverse('page.login'),
|
||||
'mfa': reverse('page.mfa'),
|
||||
'logout': reverse('page.logout'),
|
||||
'user': reverse('page.index'),
|
||||
'customAuth': reverse('uds.web.views.customAuth', kwargs={'idAuth': ''}),
|
||||
|
@ -72,6 +72,7 @@ SERVICE_CALENDAR_DENIED = 15
|
||||
PAGE_NOT_FOUND = 16
|
||||
INTERNAL_SERVER_ERROR = 17
|
||||
RELOAD_NOT_SUPPORTED = 18
|
||||
INVALID_MFA_CODE = 19
|
||||
|
||||
strings = [
|
||||
_('Unknown error'),
|
||||
@ -97,6 +98,7 @@ strings = [
|
||||
_('Page not found'),
|
||||
_('Unexpected error'),
|
||||
_('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
|
||||
# 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
|
||||
except auths.exceptions.Redirect as e:
|
||||
return HttpResponseRedirect(
|
||||
@ -148,9 +158,6 @@ def authCallback_stage2(
|
||||
logger.exception('authCallback')
|
||||
return errors.exceptionView(request, e)
|
||||
|
||||
# Will never reach this
|
||||
raise RuntimeError('Unreachable point reached!!!')
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def authInfo(request: 'HttpRequest', authName: str) -> HttpResponse:
|
||||
@ -248,6 +255,7 @@ def ticketAuth(
|
||||
webLogin(request, None, usr, password)
|
||||
|
||||
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
|
||||
|
||||
# Transport must always be automatic for ticket authentication
|
||||
|
@ -28,8 +28,10 @@
|
||||
"""
|
||||
@author: Adolfo Gómez, dkmaster at dkmon dot com
|
||||
"""
|
||||
import datetime
|
||||
import time
|
||||
import logging
|
||||
import hashlib
|
||||
import typing
|
||||
|
||||
from django.middleware import csrf
|
||||
@ -37,9 +39,11 @@ from django.shortcuts import render
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse, HttpResponseRedirect
|
||||
from django.views.decorators.cache import never_cache
|
||||
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.forms.LoginForm import LoginForm
|
||||
from uds.web.util.authentication import checkLogin
|
||||
@ -88,6 +92,10 @@ def login(
|
||||
response: typing.Optional[HttpResponse] = None
|
||||
if request.method == 'POST':
|
||||
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)
|
||||
loginResult = checkLogin(request, form, tag)
|
||||
if loginResult.user:
|
||||
@ -97,6 +105,17 @@ def login(
|
||||
auth.webLogin(request, response, loginResult.user, loginResult.password)
|
||||
# And restore 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:
|
||||
# If redirection on login failure is found, honor it
|
||||
if loginResult.url: # Redirection
|
||||
@ -121,6 +140,7 @@ def login(
|
||||
def logout(request: ExtendedHttpRequestWithUser) -> HttpResponse:
|
||||
auth.authLogLogout(request)
|
||||
request.session['restricted'] = False # Remove restricted
|
||||
request.authorized = False
|
||||
logoutResponse = request.user.logout(request)
|
||||
return auth.webLogout(
|
||||
request, logoutResponse.url or request.session.get('logouturl', None)
|
||||
|