1
0
mirror of https://github.com/dkmstr/openuds.git synced 2025-10-11 03:33:46 +03:00
Files
openuds/server/src/uds/REST/methods/authenticators.py
2025-04-09 16:47:17 +02:00

287 lines
12 KiB
Python

# -*- coding: utf-8 -*-
#
# Copyright (c) 2014-2024 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 collections.abc
import itertools
import logging
import re
import typing
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from uds.core import auths, consts, exceptions, types
from uds.core.environment import Environment
from uds.core.ui import gui
from uds.core.util import ensure, permissions
from uds.core.util.model import process_uuid
from uds.models import MFA, Authenticator, Network, Tag
from uds.REST.model import ModelHandler
from .users_groups import Groups, Users
# Not imported at runtime, just for type checking
if typing.TYPE_CHECKING:
from django.db.models import Model
from uds.core.module import Module
logger = logging.getLogger(__name__)
# Enclosed methods under /auth path
class Authenticators(ModelHandler):
model = Authenticator
# Custom get method "search" that requires authenticator id
custom_methods = [('search', True)]
detail = {'users': Users, 'groups': Groups}
save_fields = ['name', 'comments', 'tags', 'priority', 'small_name', 'mfa_id:_', 'state']
table_title = _('Authenticators')
table_fields = [
{'numeric_id': {'title': _('Id'), 'visible': True}},
{'name': {'title': _('Name'), 'visible': True, 'type': 'iconType'}},
{'type_name': {'title': _('Type')}},
{'comments': {'title': _('Comments')}},
{'priority': {'title': _('Priority'), 'type': 'numeric', 'width': '5rem'}},
{'small_name': {'title': _('Label')}},
{'users_count': {'title': _('Users'), 'type': 'numeric', 'width': '1rem'}},
{
'mfa_name': {
'title': _('MFA'),
}
},
{'tags': {'title': _('tags'), 'visible': False}},
]
def enum_types(self) -> collections.abc.Iterable[type[auths.Authenticator]]:
return auths.factory().providers().values()
def type_info(self, type_: type['Module']) -> typing.Optional[types.rest.AuthenticatorTypeInfo]:
if issubclass(type_, auths.Authenticator):
return types.rest.AuthenticatorTypeInfo(
search_users_supported=type_.search_users != auths.Authenticator.search_users,
search_groups_supported=type_.search_groups != auths.Authenticator.search_groups,
needs_password=type_.needs_password,
label_username=_(type_.label_username),
label_groupname=_(type_.label_groupname),
label_password=_(type_.label_password),
create_users_supported=type_.create_user != auths.Authenticator.create_user,
is_external=type_.external_source,
mfa_data_enabled=type_.mfa_data_enabled,
mfa_supported=type_.provides_mfa(),
)
# Not of my type
return None
def get_gui(self, type_: str) -> list[typing.Any]:
try:
auth_type = auths.factory().lookup(type_)
if auth_type:
# Create a new instance of the authenticator to access to its GUI
with Environment.temporary_environment() as env:
auth_instance = auth_type(env, None)
field = self.add_default_fields(
auth_instance.gui_description(),
['name', 'comments', 'tags', 'priority', 'small_name', 'networks'],
)
self.add_field(
field,
{
'name': 'state',
'value': consts.auth.VISIBLE,
'choices': [
{'id': consts.auth.VISIBLE, 'text': _('Visible')},
{'id': consts.auth.HIDDEN, 'text': _('Hidden')},
{'id': consts.auth.DISABLED, 'text': _('Disabled')},
],
'label': gettext('Access'),
'tooltip': gettext(
'Access type for this transport. Disabled means not only hidden, but also not usable as login method.'
),
'type': types.ui.FieldType.CHOICE,
'order': 107,
'tab': gettext('Display'),
},
)
# If supports mfa, add MFA provider selector field
if auth_type.provides_mfa():
self.add_field(
field,
{
'name': 'mfa_id',
'choices': [gui.choice_item('', str(_('None')))]
+ gui.sorted_choices(
[gui.choice_item(v.uuid, v.name) for v in MFA.objects.all()]
),
'label': gettext('MFA Provider'),
'tooltip': gettext('MFA provider to use for this authenticator'),
'type': types.ui.FieldType.CHOICE,
'order': 108,
'tab': types.ui.Tab.MFA,
},
)
return field
raise Exception() # Not found
except Exception as e:
logger.info('Type not found: %s', e)
raise exceptions.rest.NotFound('type not found') from e
def item_as_dict(self, item: 'Model') -> dict[str, typing.Any]:
summary = 'summarize' in self._params
item = ensure.is_instance(item, Authenticator)
v: dict[str, typing.Any] = {
'numeric_id': item.id,
'id': item.uuid,
'name': item.name,
'priority': item.priority,
}
if not summary:
type_ = item.get_type()
v.update(
{
'tags': [tag.tag for tag in typing.cast(collections.abc.Iterable[Tag], item.tags.all())],
'comments': item.comments,
'net_filtering': item.net_filtering,
'networks': [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_.mod_type(),
'type_name': type_.mod_name(),
'type_info': self.type_as_dict(type_),
'permission': permissions.effective_permissions(self._user, item),
}
)
return v
def post_save(self, item: 'Model') -> None:
item = ensure.is_instance(item, Authenticator)
try:
networks = self._params['networks']
except Exception: # No networks passed in, this is ok
logger.debug('No networks')
return
if networks is None: # None is not provided, empty list is ok and means no networks
return
logger.debug('Networks: %s', networks)
item.networks.set(Network.objects.filter(uuid__in=networks))
# Custom "search" method
def search(self, item: 'Model') -> list[types.rest.ItemDictType]:
item = ensure.is_instance(item, Authenticator)
self.ensure_has_access(item, types.permissions.PermissionType.READ)
try:
type_ = self._params['type']
if type_ not in ('user', 'group'):
raise self.invalid_request_response()
term = self._params['term']
limit = int(self._params.get('limit', '50'))
auth = item.get_instance()
# Cast to Any because we want to compare with the default method or if it's overriden
# Cast is neccesary to avoid mypy errors, for example
search_supported = (
type_ == 'user'
and (
typing.cast(typing.Any, auth.search_users)
!= typing.cast(typing.Any, auths.Authenticator.search_users)
)
or (
typing.cast(typing.Any, auth.search_groups)
!= typing.cast(typing.Any, auths.Authenticator.search_groups)
)
)
if search_supported is False:
raise self.not_supported_response()
if type_ == 'user':
iterable = auth.search_users(term)
else:
iterable = auth.search_groups(term)
return [i.as_dict() for i in itertools.islice(iterable, limit)]
except Exception as e:
logger.exception('Too many results: %s', e)
return [{'id': _('Too many results...'), 'name': _('Refine your query')}]
# self.invalidResponseException('{}'.format(e))
def test(self, type_: str) -> typing.Any:
auth_type = auths.factory().lookup(type_)
if not auth_type:
raise self.invalid_request_response(f'Invalid type: {type_}')
dct = self._params.copy()
dct['_request'] = self._request
with Environment.temporary_environment() as env:
res = auth_type.test(env, dct)
if res.success:
return self.success()
return res.error
def pre_save(
self, fields: dict[str, typing.Any]
) -> None: # pylint: disable=too-many-branches,too-many-statements
logger.debug(self._params)
if fields.get('mfa_id'):
try:
mfa = MFA.objects.get(uuid=process_uuid(fields['mfa_id']))
fields['mfa_id'] = mfa.id
except MFA.DoesNotExist:
pass # will set field to null
else:
fields['mfa_id'] = None
# If label has spaces, replace them with underscores
fields['small_name'] = fields['small_name'].strip().replace(' ', '_')
# And ensure small_name chars are valid [a-zA-Z0-9:-]+
if fields['small_name'] and not re.match(r'^[a-zA-Z0-9:.-]+$', fields['small_name']):
raise self.invalid_request_response(
_('Label must contain only letters, numbers, or symbols: - : .')
)
def delete_item(self, item: 'Model') -> None:
# For every user, remove assigned services (mark them for removal)
item = ensure.is_instance(item, Authenticator)
for user in item.users.all():
for userservice in user.userServices.all():
userservice.user = None
userservice.remove_or_cancel()
item.delete()