1
0
mirror of https://github.com/dkmstr/openuds.git synced 2025-01-18 06:03:54 +03:00

Adding support for companion auth apps

This commit is contained in:
Adolfo Gómez García 2024-09-26 21:02:05 +02:00
parent 602374dad6
commit c60332a4aa
No known key found for this signature in database
GPG Key ID: DD1ABF20724CDA23
9 changed files with 235 additions and 112 deletions

View File

@ -0,0 +1,26 @@
#
# Copyright (c) 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.

View File

@ -0,0 +1,74 @@
#
# Copyright (c) 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 typing
import logging
from unittest import mock
from uds import models
from uds.core.util import config
from uds.core.auths import callbacks
from tests.utils.test import UDSTestCase
from tests.fixtures import authenticators as authenticators_fixtures
if typing.TYPE_CHECKING:
pass
logger = logging.getLogger(__name__)
class AuthCallbackTest(UDSTestCase):
auth: 'models.Authenticator'
groups: list['models.Group']
user: 'models.User'
def setUp(self) -> None:
super().setUp()
self.auth = authenticators_fixtures.create_db_authenticator()
self.groups = authenticators_fixtures.create_db_groups(self.auth, 1)
self.user = authenticators_fixtures.create_db_users(self.auth, number_of_users=1, groups=self.groups)[0]
def test_no_callback(self) -> None:
config.GlobalConfig.LOGIN_CALLBACK_URL.set('') # Clean callback url
with mock.patch('requests.post') as mock_post:
callbacks.perform_login_callback(self.user)
mock_post.assert_not_called()
def test_callback_failed_url(self) -> None:
config.GlobalConfig.LOGIN_CALLBACK_URL.set('http://localhost:1234') # Sample non existent url
callbacks.FAILURE_CACHE.set('notify_failure', 3) # Already failed 3 times
with mock.patch('requests.post') as mock_post:
callbacks.perform_login_callback(self.user)
mock_post.assert_not_called()

View File

@ -38,9 +38,9 @@ from uds.core.util import model
from uds.core import consts
from uds.models.account_usage import AccountUsage
from ...fixtures import services as services_fixtures
from tests.fixtures import services as services_fixtures
from ...utils.test import UDSTestCase
from tests.utils.test import UDSTestCase
if typing.TYPE_CHECKING:
pass

View File

@ -78,7 +78,7 @@ def get_service_pools_for_groups(
class Users(DetailHandler):
custom_methods = ['servicesPools', 'userServices', 'cleanRelated']
custom_methods = ['servicesPools', 'userServices', 'cleanRelated', 'addToGroup']
def get_items(self, parent: 'Model', item: typing.Optional[str]) -> typing.Any:
parent = ensure.is_instance(parent, Authenticator)
@ -326,6 +326,13 @@ class Users(DetailHandler):
user = parent.users.get(uuid=process_uuid(uuid))
user.clean_related_data()
return {'status': 'ok'}
def addToGroup(self, parent: 'Authenticator', item: str) -> dict[str, str]:
uuid = process_uuid(item)
user = parent.users.get(uuid=process_uuid(uuid))
group = parent.groups.get(uuid=process_uuid(self._params['group']))
user.groups.add(group)
return {'status': 'ok'}
class Groups(DetailHandler):
@ -431,9 +438,11 @@ class Groups(DetailHandler):
is_meta = self._params['type'] == 'meta'
meta_if_any = self._params.get('meta_if_any', False)
pools = self._params.get('pools', None)
skip_check = self._params.get('skip_check', False)
logger.debug('Saving group %s / %s', parent, item)
logger.debug('Meta any %s', meta_if_any)
logger.debug('Pools: %s', pools)
logger.debug('Skip check: %s', skip_check)
valid_fields = ['name', 'comments', 'state', 'skip_mfa']
if self._params.get('name', '') == '':
raise exceptions.rest.RequestError(_('Group name is required'))
@ -442,7 +451,7 @@ class Groups(DetailHandler):
auth = parent.get_instance()
to_save: dict[str, typing.Any] = {}
if not item: # Create new
if not is_meta and not is_pattern:
if not is_meta and not is_pattern and not skip_check:
auth.create_group(
fields
) # this throws an exception if there is an error (for example, this auth can't create groups)

View File

@ -1,7 +1,5 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2012-2019 Virtual Cable S.L.
# Copyright (c) 2012-2024 Virtual Cable S.L.U.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,

View File

@ -46,7 +46,7 @@ from django.utils.translation import gettext as _
from uds import models
from uds.core import auths, consts, exceptions, types
from uds.core.auths import Authenticator as AuthenticatorInstance
from uds.core.auths import Authenticator as AuthenticatorInstance, callbacks
from uds.core.util import config, log, net
from uds.core.util.stats import events
from uds.core.managers.crypto import CryptoManager
@ -239,6 +239,9 @@ def register_user(
browser=request.os.browser,
version=request.os.version,
)
# Try to notify callback if needed
callbacks.perform_login_callback(usr)
return types.auth.LoginResult(user=usr)
return types.auth.LoginResult()

View File

@ -1,103 +0,0 @@
#
# Copyright (c) 2012-2023 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.
"""
Base module for all authenticators
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import logging
import re
import typing
import collections.abc
import requests
from uds import models
from uds.core.util import config, cache
logger = logging.getLogger(__name__)
FAILURE_CACHE: typing.Final[cache.Cache] = cache.Cache('callback_auth_failure', 60 * 60) # 1 hour
# Groups only A-Z, a-z, 0-9 and _ or - are allowed
RE_GROUPS: typing.Final[typing.Pattern[str]] = re.compile(r'^[A-Za-z0-9_-]+$')
def perform_login_callback(user: models.User) -> None:
"""
This method is called when a user logs in. It can be used to perform any action needed when a user logs in.
"""
notify_url = config.GlobalConfig.LOGIN_CALLBACK_URL.as_str()
if not notify_url.startswith('https'):
fail_count: int = FAILURE_CACHE.get('notify_failure', 0) + 1
if fail_count >= 3:
return # Eventually, cache will expire and we will try again
# We are going to notify the login to the callback URL
# This is a POST with a JSON payload
try:
response = requests.post(
notify_url,
json={
'authenticator_uuid': user.manager.uuid,
'user_uuid': user.uuid,
'username': user.name,
'groups': [group.name for group in user.groups.all()],
},
)
response.raise_for_status()
FAILURE_CACHE.delete('notify_failure')
# Get response json, and check if there is any "new" information
# new information can be:
# - Groups added (new_groups)
# - Groups removed (removed_groups)
data = response.json()
def _clean_list_groups(groups: collections.abc.Iterable[str]) -> collections.abc.Iterable[str]:
for grp_name in groups:
if not RE_GROUPS.match(grp_name):
logger.error('Invalid group name received from callback URL: %s', group_name)
continue
yield group_name
# Add groups to user if they are in the list
for group_name in _clean_list_groups(data.get('new_groups', [])):
group, _ = models.Group.objects.get_or_create(name=group_name)
user.groups.add(group)
# Remove groups from user if they are in the list
for group_name in _clean_list_groups(data.get('removed_groups', [])):
try:
group = models.Group.objects.get(name=group_name)
except models.Group.DoesNotExist:
continue
user.groups.remove(group)
except requests.RequestException as e:
logger.error('Error notifying login to callback URL: %s', e)
FAILURE_CACHE.set('notify_failure', fail_count)
fail_count: int = FAILURE_CACHE.get('notify_failure', 0) + 1

View File

@ -0,0 +1,116 @@
#
# Copyright (c) 2012-2023 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.
"""
Base module for all authenticators
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import logging
import re
import typing
import collections.abc
import requests
from uds import models
from uds.core import types
from uds.core.util import config, cache, log
logger = logging.getLogger(__name__)
FAILURE_CACHE: typing.Final[cache.Cache] = cache.Cache('callback_auth_failure', 5 * 60) # 5 minutes
# Groups only A-Z, a-z, 0-9 and _ or - are allowed
RE_GROUPS: typing.Final[typing.Pattern[str]] = re.compile(r'^[A-Za-z0-9_-]+$')
def perform_login_callback(user: models.User) -> None:
"""
This method is called when a user logs in. It can be used to perform any action needed when a user logs in.
"""
notify_url = config.GlobalConfig.LOGIN_CALLBACK_URL.as_str()
if not notify_url.startswith('https') or (fail_count := FAILURE_CACHE.get('notify_failure', 0)) >= 3:
return
# We are going to notify the login to the callback URL
# This is a POST with a JSON payload
try:
response = requests.post(
notify_url,
json={
'authenticator_uuid': user.manager.uuid,
'user_uuid': user.uuid,
'username': user.name,
'groups': [group.name for group in user.groups.all()],
},
timeout=3,
)
response.raise_for_status()
FAILURE_CACHE.delete('notify_failure')
# Get response json, and check if there is any "new" information
# new information can be:
# - Groups added (new_groups)
# - Groups removed (removed_groups)
data = response.json()
def _clean_list_groups(groups: collections.abc.Iterable[str]) -> collections.abc.Iterable[str]:
for grp_name in groups:
if not RE_GROUPS.match(grp_name):
logger.error('Invalid group name received from callback URL: %s', group_name)
continue
yield group_name
# Add groups to user if they are in the list
changed_grps: list[str] = []
for group_name in _clean_list_groups(data.get('new_groups', [])):
group, _ = models.Group.objects.get_or_create(name=group_name)
changed_grps += [f'+{group_name}']
user.groups.add(group)
# Remove groups from user if they are in the list
for group_name in _clean_list_groups(data.get('removed_groups', [])):
try:
group = models.Group.objects.get(name=group_name)
except models.Group.DoesNotExist:
continue
changed_grps += [f'-{group_name}']
user.groups.remove(group)
# Log if groups were changed to keep track of changes
if changed_grps:
log.log(
user,
types.log.LogLevel.INFO,
f'Groups changed by callback URL: {",".join(changed_grps)}',
types.log.LogSource.INTERNAL,
)
except requests.RequestException as e:
logger.error('Error notifying login to callback URL: %s', e)
FAILURE_CACHE.set('notify_failure', fail_count)
fail_count: int = FAILURE_CACHE.get('notify_failure', 0) + 1

View File

@ -797,7 +797,7 @@ class GlobalConfig:
help=_('Enable VNC menu for user services'),
)
LOGIN_CALLBACK_URL: Config.Value = Config.section(Config.SectionType.GLOBAL).value(
'Login Callback URL',
'loginCallbackURL',
'',
type=Config.FieldType.HIDDEN,
help=''