diff --git a/Web/Services/index.php b/Web/Services/index.php index 615a13912..49aa7dfd9 100644 --- a/Web/Services/index.php +++ b/Web/Services/index.php @@ -56,6 +56,20 @@ 'You must be authenticated in order to access this service.
' . $server->GetFullServiceUrl(WebServices::Login) ); } + + $userSession = ServiceLocator::GetUserSession(); + // Admin users can always use the API + if ($userSession->IsAdmin) { + return; + } + + // Check if the user is allowed API access to the route + if (!$registry->IsUserAllowedApiAccess(routeName: $routeName, userId: $userSession->UserId)) { + $app->halt( + RestResponse::FORBIDDEN, + 'You are not authorized to access this service.
' + ); + } } }); @@ -84,7 +98,12 @@ function RegisterHelp(SlimWebServiceRegistry $registry, \Slim\Slim $app) function RegisterAuthentication(SlimServer $server, SlimWebServiceRegistry $registry) { - $webService = new AuthenticationWebService($server, new WebServiceAuthentication(PluginManager::Instance()->LoadAuthentication(), new UserSessionRepository())); + $api_access_group_id = GetConfigGroup(config_group: "Authentication.group"); + $webService = new AuthenticationWebService( + $server, + new WebServiceAuthentication(PluginManager::Instance()->LoadAuthentication(), new UserSessionRepository()), + api_access_group_id: $api_access_group_id + ); $category = new SlimWebServiceRegistryCategory('Authentication'); $category->AddPost('SignOut/', [$webService, 'SignOut'], WebServices::Logout); @@ -97,7 +116,10 @@ function RegisterReservations(SlimServer $server, SlimWebServiceRegistry $regist $readService = new ReservationsWebService($server, new ReservationViewRepository(), new PrivacyFilter(new ReservationAuthorization(PluginManager::Instance()->LoadAuthorization())), new AttributeService(new AttributeRepository())); $writeService = new ReservationWriteWebService($server, new ReservationSaveController(new ReservationPresenterFactory())); - $category = new SlimWebServiceRegistryCategory('Reservations'); + $roGroupId = GetConfigGroup('Reservations.ro.group'); + $rwGroupId = GetConfigGroup('Reservations.rw.group'); + $category = new SlimWebServiceRegistryCategory('Reservations', roGroupId: $roGroupId, rwGroupId: $rwGroupId); + $category->AddSecurePost('/', [$writeService, 'Create'], WebServices::CreateReservation); $category->AddSecureGet('/', [$readService, 'GetReservations'], WebServices::AllReservations); $category->AddSecureGet('/:referenceNumber', [$readService, 'GetReservation'], WebServices::GetReservation); @@ -116,7 +138,10 @@ function RegisterResources(SlimServer $server, SlimWebServiceRegistry $registry) $attributeService = new AttributeService(new AttributeRepository()); $webService = new ResourcesWebService($server, $resourceRepository, $attributeService, new ReservationViewRepository()); $writeWebService = new ResourcesWriteWebService($server, new ResourceSaveController($resourceRepository, new ResourceRequestValidator($attributeService))); - $category = new SlimWebServiceRegistryCategory('Resources'); + + $roGroupId = GetConfigGroup('Resources.ro.group'); + $category = new SlimWebServiceRegistryCategory('Resources', roGroupId: $roGroupId); + $category->AddGet('/Status', [$webService, 'GetStatuses'], WebServices::GetStatuses); $category->AddSecureGet('/', [$webService, 'GetAll'], WebServices::AllResources); $category->AddSecureGet('/Status/Reasons', [$webService, 'GetStatusReasons'], WebServices::GetStatusReasons); @@ -133,7 +158,10 @@ function RegisterResources(SlimServer $server, SlimWebServiceRegistry $registry) function RegisterAccessories(SlimServer $server, SlimWebServiceRegistry $registry) { $webService = new AccessoriesWebService($server, new ResourceRepository(), new AccessoryRepository()); - $category = new SlimWebServiceRegistryCategory('Accessories'); + + $roGroupId = GetConfigGroup('Accessories.ro.group'); + $category = new SlimWebServiceRegistryCategory('Accessories', roGroupId: $roGroupId); + $category->AddSecureGet('/', [$webService, 'GetAll'], WebServices::AllAccessories); $category->AddSecureGet('/:accessoryId', [$webService, 'GetAccessory'], WebServices::GetAccessory); $registry->AddCategory($category); @@ -147,7 +175,10 @@ function RegisterUsers(SlimServer $server, SlimWebServiceRegistry $registry) $server, new UserSaveController(new ManageUsersServiceFactory(), new UserRequestValidator($attributeService, new UserRepository())) ); - $category = new SlimWebServiceRegistryCategory('Users'); + + $roGroupId = GetConfigGroup('Users.ro.group'); + $category = new SlimWebServiceRegistryCategory('Users', roGroupId: $roGroupId); + $category->AddSecureGet('/', [$webService, 'GetUsers'], WebServices::AllUsers); $category->AddSecureGet('/:userId', [$webService, 'GetUser'], WebServices::GetUser); $category->AddAdminPost('/', [$writeWebService, 'Create'], WebServices::CreateUser); @@ -160,7 +191,10 @@ function RegisterUsers(SlimServer $server, SlimWebServiceRegistry $registry) function RegisterSchedules(SlimServer $server, SlimWebServiceRegistry $registry) { $webService = new SchedulesWebService($server, new ScheduleRepository(), new PrivacyFilter(new ReservationAuthorization(PluginManager::Instance()->LoadAuthorization()))); - $category = new SlimWebServiceRegistryCategory('Schedules'); + + $roGroupId = GetConfigGroup('Schedules.ro.group'); + $category = new SlimWebServiceRegistryCategory('Schedules', roGroupId: $roGroupId); + $category->AddSecureGet('/', [$webService, 'GetSchedules'], WebServices::AllSchedules); $category->AddSecureGet('/:scheduleId', [$webService, 'GetSchedule'], WebServices::GetSchedule); $category->AddSecureGet('/:scheduleId/Slots', [$webService, 'GetSlots'], WebServices::GetScheduleSlots); @@ -172,7 +206,9 @@ function RegisterAttributes(SlimServer $server, SlimWebServiceRegistry $registry $webService = new AttributesWebService($server, new AttributeService(new AttributeRepository())); $writeWebService = new AttributesWriteWebService($server, new AttributeSaveController(new AttributeRepository())); - $category = new SlimWebServiceRegistryCategory('Attributes'); + $roGroupId = GetConfigGroup('Attributes.ro.group'); + $category = new SlimWebServiceRegistryCategory('Attributes', roGroupId: $roGroupId); + $category->AddSecureGet('Category/:categoryId', [$webService, 'GetAttributes'], WebServices::AllCustomAttributes); $category->AddSecureGet('/:attributeId', [$webService, 'GetAttribute'], WebServices::GetCustomAttribute); $category->AddAdminPost('/', [$writeWebService, 'Create'], WebServices::CreateCustomAttribute); @@ -187,7 +223,8 @@ function RegisterGroups(SlimServer $server, SlimWebServiceRegistry $registry) $webService = new GroupsWebService($server, $groupRepository, $groupRepository); $writeWebService = new GroupsWriteWebService($server, new GroupSaveController($groupRepository, new ResourceRepository(), new ScheduleRepository())); - $category = new SlimWebServiceRegistryCategory('Groups'); + $roGroupId = GetConfigGroup('Groups.ro.group'); + $category = new SlimWebServiceRegistryCategory('Groups', roGroupId: $roGroupId); $category->AddSecureGet('/', [$webService, 'GetGroups'], WebServices::AllGroups); $category->AddSecureGet('/:groupId', [$webService, 'GetGroup'], WebServices::GetGroup); @@ -211,7 +248,10 @@ function RegisterAccounts(SlimServer $server, SlimWebServiceRegistry $registry) $webService = new AccountWebService($server, $controller); - $category = new SlimWebServiceRegistryCategory('Accounts'); + $roGroupId = GetConfigGroup('Accounts.ro.group'); + $rwGroupId = GetConfigGroup('Accounts.rw.group'); + $category = new SlimWebServiceRegistryCategory('Accounts', roGroupId: $roGroupId, rwGroupId: $rwGroupId); + $category->AddPost('/', [$webService, 'Create'], WebServices::CreateAccount); $category->AddSecurePost('/:userId', [$webService, 'Update'], WebServices::UpdateAccount); $category->AddSecurePost('/:userId/Password', [$webService, 'UpdatePassword'], WebServices::UpdateAccountPassword); @@ -219,3 +259,30 @@ function RegisterAccounts(SlimServer $server, SlimWebServiceRegistry $registry) $registry->AddCategory($category); } + +function GetConfigGroup(string $config_group): string|null { + $group_name = Configuration::Instance()->GetSectionKey(ConfigSection::API, $config_group) ?? ''; + if ($group_name == '') { + return null; + } + $groupRepository = new GroupRepository(); + $groups = $groupRepository->GetList()->Results(); + foreach ($groups as $group) { + if ($group->Name == $group_name) { + return $group->Id(); + } + } + die("Unable to find group: '$group_name' for API group '$config_group'. Please contact the administrator to resolve this issue in the `config.php` file."); + return null; +} + +function IsUserInGroup(string|int $groupId, string|int $userId): bool { + $groupRepository = new GroupRepository(); + $group = $groupRepository->LoadById($groupId); + foreach ($group->UserIds() as $groupUserId) { + if ($groupUserId == $userId) { + return true; + } + } + return false; +} diff --git a/WebServices/AuthenticationWebService.php b/WebServices/AuthenticationWebService.php index 76f23de83..a76f1ce3f 100644 --- a/WebServices/AuthenticationWebService.php +++ b/WebServices/AuthenticationWebService.php @@ -25,11 +25,13 @@ class AuthenticationWebService * @var IWebServiceAuthentication */ private $authentication; + private int|string|null $api_access_group_id; // If specified and user not in group then authentication will be denied - public function __construct(IRestServer $server, IWebServiceAuthentication $authentication) + public function __construct(IRestServer $server, IWebServiceAuthentication $authentication, int|string|null $api_access_group_id = null) { $this->server = $server; $this->authentication = $authentication; + $this->api_access_group_id = $api_access_group_id; } /** @@ -51,6 +53,15 @@ public function Authenticate() $isValid = $this->authentication->Validate($username, $password); if ($isValid) { Log::Debug('WebService Authenticate, user %s was authenticated', $username); + $session = $this->authentication->Login($username); + + if (!$session->IsAdmin && is_numeric($this->api_access_group_id)) { + if (!IsUserInGroup(groupId: $this->api_access_group_id, userId: $session->UserId)) { + Log::Debug('WebService Authenticate, user %s was denied API access', $username); + $this->server->WriteResponse(AuthenticationResponse::NotAuthorized(), statusCode: RestResponse::FORBIDDEN); + return; + } + } $version = 0; $reader = ServiceLocator::GetDatabase()->Query(new GetVersionCommand()); @@ -59,7 +70,6 @@ public function Authenticate() } $reader->Free(); - $session = $this->authentication->Login($username); Log::Debug('SessionToken=%s', $session->SessionToken); $this->server->WriteResponse(AuthenticationResponse::Success($this->server, $session, $version)); } else { diff --git a/WebServices/Responses/AuthenticationResponse.php b/WebServices/Responses/AuthenticationResponse.php index 4939e537c..ac938aec5 100644 --- a/WebServices/Responses/AuthenticationResponse.php +++ b/WebServices/Responses/AuthenticationResponse.php @@ -45,6 +45,13 @@ public static function Failed() return $response; } + public static function NotAuthorized() + { + $response = new AuthenticationResponse(); + $response->message = 'Login failed. API access not authorized.'; + return $response; + } + public static function Example() { return new ExampleAuthenticationResponse(); diff --git a/config/config.devel.php b/config/config.devel.php index 8f9438095..f9df64c1d 100644 --- a/config/config.devel.php +++ b/config/config.devel.php @@ -213,3 +213,49 @@ $conf['settings']['logging']['folder'] = '/var/log/librebooking/log'; //Absolute path to folder were the log will be written, writing permissions to the folder are required $conf['settings']['logging']['level'] = 'debug'; //Set to none disable logs, error to only log errors or debug to log all messages to the app.log file $conf['settings']['logging']['sql'] = 'false'; //Set to true no enable the creation of and sql.log file + + +/** + * API Granularity Settings + */ +$conf['settings']['api']['Authentication.group'] = ''; // If a group is specified then a user must be in the group in order to sucessfully authenticate. Unless the user is an Admin. +/** + * API access restrictions. These only provide additional restrictions. They do + * not provide additional permissions. + * + * If desired can specify a single group to limit access to an API category. + * Access per category can be limited to RO (Read-Only) and/or RW (Read-Write) + * access. + * RO access means they can only do GET actions. + * RW access means they can do GET/POST/PUT/DELETE actions. + * If a group is specified and the user is not in the group then they will be + * denied access, unless the user is an Admin. + * If a group is NOT specified then normal access permissions will apply. + */ + +$conf['settings']['api']['Accessories.ro.group'] = ''; +// NOTE: There are no "write" APIs for `Accessories` + +$conf['settings']['api']['Accounts.ro.group'] = ''; +$conf['settings']['api']['Accounts.rw.group'] = ''; + +$conf['settings']['api']['Attributes.ro.group'] = ''; +// NOTE: Only application administrators can "write" to `Attributes` + +$conf['settings']['api']['Groups.ro.group'] = ''; +// NOTE: Only application administrators can "write" to `Groups` + +$conf['settings']['api']['Reservations.ro.group'] = ''; +$conf['settings']['api']['Reservations.rw.group'] = ''; + +$conf['settings']['api']['Resources.ro.group'] = ''; +// NOTE: Only application administrators can "write" to `Resources` + +$conf['settings']['api']['Schedules.ro.group'] = ''; +// NOTE: There are no "write" APIs for `Schedules` + +$conf['settings']['api']['Users.ro.group'] = ''; +// NOTE: Only application administrators can "write" to `Users` + +$conf['settings']['api']['Schedules.ro.group'] = ''; +// NOTE: There are no "write" APIs for `Schedules` diff --git a/config/config.dist.php b/config/config.dist.php index 15a2ee7f5..2def7023f 100644 --- a/config/config.dist.php +++ b/config/config.dist.php @@ -269,3 +269,49 @@ $conf['settings']['delete.old.data']['delete.old.announcements'] = 'false'; //Choose if this feature deletes old announcements from database $conf['settings']['delete.old.data']['delete.old.blackouts'] = 'false'; //Choose if this feature deletes old blackouts from database $conf['settings']['delete.old.data']['delete.old.reservations'] = 'false'; //Choose if this feature deletes old reservations from database + + +/** + * API Granularity Settings + */ +$conf['settings']['api']['Authentication.group'] = ''; // If a group is specified then a user must be in the group in order to sucessfully authenticate. Unless the user is an Admin. +/** + * API access restrictions. These only provide additional restrictions. They do + * not provide additional permissions. + * + * If desired can specify a single group to limit access to an API category. + * Access per category can be limited to RO (Read-Only) and/or RW (Read-Write) + * access. + * RO access means they can only do GET actions. + * RW access means they can do GET/POST/PUT/DELETE actions. + * If a group is specified and the user is not in the group then they will be + * denied access, unless the user is an Admin. + * If a group is NOT specified then normal access permissions will apply. + */ + +$conf['settings']['api']['Accessories.ro.group'] = ''; +// NOTE: There are no "write" APIs for `Accessories` + +$conf['settings']['api']['Accounts.ro.group'] = ''; +$conf['settings']['api']['Accounts.rw.group'] = ''; + +$conf['settings']['api']['Attributes.ro.group'] = ''; +// NOTE: Only application administrators can "write" to `Attributes` + +$conf['settings']['api']['Groups.ro.group'] = ''; +// NOTE: Only application administrators can "write" to `Groups` + +$conf['settings']['api']['Reservations.ro.group'] = ''; +$conf['settings']['api']['Reservations.rw.group'] = ''; + +$conf['settings']['api']['Resources.ro.group'] = ''; +// NOTE: Only application administrators can "write" to `Resources` + +$conf['settings']['api']['Schedules.ro.group'] = ''; +// NOTE: There are no "write" APIs for `Schedules` + +$conf['settings']['api']['Users.ro.group'] = ''; +// NOTE: Only application administrators can "write" to `Users` + +$conf['settings']['api']['Schedules.ro.group'] = ''; +// NOTE: There are no "write" APIs for `Schedules` diff --git a/lib/WebService/RestResponse.php b/lib/WebService/RestResponse.php index 8eaf6fa8d..b1cd6b3c5 100644 --- a/lib/WebService/RestResponse.php +++ b/lib/WebService/RestResponse.php @@ -6,6 +6,7 @@ class RestResponse public const CREATED_CODE = 201; public const BAD_REQUEST_CODE = 400; public const UNAUTHORIZED_CODE = 401; + public const FORBIDDEN = 403; public const NOT_FOUND_CODE = 404; public const SERVER_ERROR = 500; diff --git a/lib/WebService/Slim/SlimWebServiceRegistry.php b/lib/WebService/Slim/SlimWebServiceRegistry.php index 12f7cd7aa..07a34351a 100644 --- a/lib/WebService/Slim/SlimWebServiceRegistry.php +++ b/lib/WebService/Slim/SlimWebServiceRegistry.php @@ -3,6 +3,40 @@ require_once(ROOT_DIR . 'lib/external/Slim/Slim.php'); require_once(ROOT_DIR . 'lib/Common/namespace.php'); +class ApiPermissions +{ + public function __construct( + public bool $isWrite, + public int|string|null $roGroupId, + public int|string|null $rwGroupId + ) { } + + public function IsUserAllowedApiAccess(int|string $userId): bool + { + if ($this->isWrite) { + // If a write API, then check if a RW group is set and verify access. + // If no RW group, then check if a RO group is set and verify access + if (is_numeric($this->rwGroupId)) { + return IsUserInGroup(groupId: $this->rwGroupId, userId: $userId); + } + if (is_numeric($this->roGroupId)) { + return IsUserInGroup(groupId: $this->roGroupId, userId: $userId); + } + return true; + } + + if (is_numeric($this->roGroupId)) { + return IsUserInGroup(groupId: $this->roGroupId, userId: $userId); + } + return true; + } + + public function IsSet(): bool { + return (is_numeric($this->roGroupId) || is_numeric($this->rwGroupId)); + } + +} + class SlimWebServiceRegistry { /** @@ -25,6 +59,11 @@ class SlimWebServiceRegistry */ private $adminRoutes = []; + /** + * @var array + */ + private $apiPermissionRoutes = []; + public function __construct(Slim\Slim $slim) { $this->slim = $slim; @@ -37,17 +76,26 @@ public function AddCategory(SlimWebServiceRegistryCategory $category) { foreach ($category->Gets() as $registration) { $this->slim->get($registration->Route(), $registration->Callback())->name($registration->RouteName()); - $this->SecureRegistration($registration); + $this->SecureRegistration( + $registration, + apiPermissions: new ApiPermissions(isWrite: false, roGroupId: $category->GetRoGroupId(), rwGroupId: $category->GetRwGroupId()) + ); } foreach ($category->Posts() as $registration) { $this->slim->post($registration->Route(), $registration->Callback())->name($registration->RouteName()); - $this->SecureRegistration($registration); + $this->SecureRegistration( + $registration, + apiPermissions: new ApiPermissions(isWrite: true, roGroupId: $category->GetRoGroupId(), rwGroupId: $category->GetRwGroupId()) + ); } foreach ($category->Deletes() as $registration) { $this->slim->delete($registration->Route(), $registration->Callback())->name($registration->RouteName()); - $this->SecureRegistration($registration); + $this->SecureRegistration( + $registration, + apiPermissions: new ApiPermissions(isWrite: true, roGroupId: $category->GetRoGroupId(), rwGroupId: $category->GetRwGroupId()) + ); } $this->categories[] = $category; @@ -90,8 +138,15 @@ public function IsLimitedToAdmin($routeName) return array_key_exists($routeName, $this->adminRoutes); } - private function SecureRegistration(SlimServiceRegistration $registration) + public function IsUserAllowedApiAccess(string $routeName, int|string $userId): bool { + if (!array_key_exists($routeName, $this->apiPermissionRoutes)) { + return true; + } + return $this->apiPermissionRoutes[$routeName]->IsUserAllowedApiAccess(userId: $userId); + } + + private function SecureRegistration(SlimServiceRegistration $registration, ApiPermissions $apiPermissions) { if ($registration->IsSecure()) { $this->secureRoutes[$registration->RouteName()] = true; } @@ -99,5 +154,9 @@ private function SecureRegistration(SlimServiceRegistration $registration) if ($registration->IsLimitedToAdmin()) { $this->adminRoutes[$registration->RouteName()] = true; } + + if ($apiPermissions->IsSet()) { + $this->apiPermissionRoutes[$registration->RouteName()] = $apiPermissions; + } } } diff --git a/lib/WebService/Slim/SlimWebServiceRegistryCategory.php b/lib/WebService/Slim/SlimWebServiceRegistryCategory.php index b425a2037..42bff0913 100644 --- a/lib/WebService/Slim/SlimWebServiceRegistryCategory.php +++ b/lib/WebService/Slim/SlimWebServiceRegistryCategory.php @@ -6,10 +6,14 @@ class SlimWebServiceRegistryCategory private $gets = []; private $posts = []; private $deletes = []; + private int|string|null $roGroupId; // User group allowed Read-Only access to API Category + private int|string|null $rwGroupId; // User group allowed Read-Write access to API Category - public function __construct($name) + public function __construct($name, int|string|null $roGroupId = null, int|string|null $rwGroupId = null) { $this->name = $name; + $this->roGroupId = $roGroupId; + $this->rwGroupId = $rwGroupId; } /** @@ -36,6 +40,28 @@ public function Deletes() return $this->deletes; } + public function GetRoGroupId(): int|string|null { + return $this->roGroupId; + } + + public function GetRwGroupId(): int|string|null { + return $this->rwGroupId; + } + + public function UserAllowedRoAccess(int|string $userId): bool { + if (is_null($this->roGroupId)) { + return true; + } + return IsUserInGroup(groupId: $this->roGroupId, userId: $userId); + } + + public function UserAllowedRwAccess(int|string $userId): bool { + if (is_null($this->rwGroupId)) { + return true; + } + return IsUserInGroup(groupId: $this->rwGroupId, userId: $userId); + } + public function AddGet($route, $callback, $routeName) { $this->gets[] = new SlimServiceRegistration($this->name, $route, $callback, $routeName);