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:
parent
602374dad6
commit
c60332a4aa
26
server/src/tests/core/auths/__init__.py
Normal file
26
server/src/tests/core/auths/__init__.py
Normal 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.
|
74
server/src/tests/core/auths/test_callbacks.py
Normal file
74
server/src/tests/core/auths/test_callbacks.py
Normal 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()
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
@ -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()
|
||||
|
@ -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
|
116
server/src/uds/core/auths/callbacks.py
Normal file
116
server/src/uds/core/auths/callbacks.py
Normal 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
|
@ -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=''
|
||||
|
Loading…
x
Reference in New Issue
Block a user