diff --git a/.github/workflows/tests-special.yml b/.github/workflows/tests-special.yml index ca190230..eac57233 100644 --- a/.github/workflows/tests-special.yml +++ b/.github/workflows/tests-special.yml @@ -116,8 +116,8 @@ jobs: cd .. sleep 5s php occ app_api:daemon:register manual_install "Manual Install" manual-install 0 0 0 - php occ app_api:app:register nc_py_api manual_install --json-info \ - "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"port\":$APP_PORT,\"scopes\":{\"required\":[\"SYSTEM\", \"FILES\", \"FILES_SHARING\"],\"optional\":[\"USER_INFO\", \"USER_STATUS\", \"NOTIFICATIONS\", \"WEATHER_STATUS\", \"TALK\"]},\"protocol\":\"http\",\"system_app\":1}" \ + php occ app_api:app:register $APP_ID manual_install --json-info \ + "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"port\":$APP_PORT,\"scopes\":{\"required\":[\"SYSTEM\", \"NOTIFICATIONS\"],\"optional\":[\"USER_INFO\"]},\"protocol\":\"http\",\"system_app\":1}" \ --force-scopes --wait-finish kill -15 $(cat /tmp/_install.pid) timeout 3m tail --pid=$(cat /tmp/_install.pid) -f /dev/null @@ -134,9 +134,9 @@ jobs: path: data/nextcloud.log if-no-files-found: warn - no-init-endpoint: + auth-tests-no-init: runs-on: ubuntu-22.04 - name: Without Init Endpoint + name: Auth tests (no Init endpoint) services: postgres: @@ -164,13 +164,6 @@ jobs: repository: nextcloud/server ref: 'stable27' - - name: Checkout Notifications - uses: actions/checkout@v3 - with: - repository: nextcloud/notifications - ref: ${{ matrix.server-version }} - path: apps/notifications - - name: Checkout AppAPI uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: @@ -205,7 +198,6 @@ jobs: ./occ maintenance:install --verbose --database=pgsql --database-name=nextcloud --database-host=127.0.0.1 \ --database-port=$DB_PORT --database-user=root --database-pass=rootpassword \ --admin-user admin --admin-pass admin - ./occ app:enable notifications ./occ app:enable --force ${{ env.APP_NAME }} - name: Run Nextcloud @@ -221,18 +213,54 @@ jobs: working-directory: nc_py_api run: python3 -m pip -v install ".[dev]" - - name: Register NcPyApi + - name: Register App run: | python3 apps/${{ env.APP_NAME }}/tests/install_no_init.py & echo $! > /tmp/_install.pid sleep 5s php occ app_api:daemon:register manual_install "Manual Install" manual-install 0 0 0 - php occ app_api:app:register nc_py_api manual_install --json-info \ - "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"port\":$APP_PORT,\"scopes\":{\"required\":[\"SYSTEM\", \"FILES\", \"FILES_SHARING\"],\"optional\":[\"USER_INFO\", \"USER_STATUS\", \"NOTIFICATIONS\", \"WEATHER_STATUS\", \"TALK\"]},\"protocol\":\"http\",\"system_app\":1}" \ + php occ app_api:app:register $APP_ID manual_install --json-info \ + "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"port\":$APP_PORT,\"scopes\":{\"required\":[\"ALL\"],\"optional\":[]},\"protocol\":\"http\",\"system_app\":1}" \ --force-scopes --wait-finish kill -15 $(cat /tmp/_install.pid) timeout 3m tail --pid=$(cat /tmp/_install.pid) -f /dev/null + - name: Check logs + run: grep -q 'Hello from ' data/nextcloud.log || error + + - name: Test ALL Scope, System App + run: python3 apps/${{ env.APP_NAME }}/tests/auth_scopes_system.py 0 + + - name: Re-Register App + run: | + php occ app_api:app:unregister $APP_ID --silent || true + python3 apps/${{ env.APP_NAME }}/tests/install_no_init.py & + echo $! > /tmp/_install.pid + sleep 5s + php occ app_api:app:register $APP_ID manual_install --json-info \ + "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"port\":$APP_PORT,\"scopes\":{\"required\":[\"SYSTEM\"],\"optional\":[]},\"protocol\":\"http\",\"system_app\":1}" \ + --force-scopes --wait-finish + kill -15 $(cat /tmp/_install.pid) + timeout 3m tail --pid=$(cat /tmp/_install.pid) -f /dev/null + + - name: Test NO ALL Scope, System App + run: python3 apps/${{ env.APP_NAME }}/tests/auth_scopes_system.py 1 + + - name: Re-Register App + run: | + php occ app_api:app:unregister $APP_ID --silent || true + python3 apps/${{ env.APP_NAME }}/tests/install_no_init.py & + echo $! > /tmp/_install.pid + sleep 5s + php occ app_api:app:register $APP_ID manual_install --json-info \ + "{\"appid\":\"$APP_ID\",\"name\":\"$APP_ID\",\"daemon_config_name\":\"manual_install\",\"version\":\"$APP_VERSION\",\"secret\":\"$APP_SECRET\",\"host\":\"localhost\",\"port\":$APP_PORT,\"scopes\":{\"required\":[\"ALL\"],\"optional\":[]},\"protocol\":\"http\",\"system_app\":0}" \ + --force-scopes --wait-finish + kill -15 $(cat /tmp/_install.pid) + timeout 3m tail --pid=$(cat /tmp/_install.pid) -f /dev/null + + - name: Test NO ALL Scope, NO System App + run: python3 apps/${{ env.APP_NAME }}/tests/auth_scopes_system.py 2 + - name: Upload NC logs if: always() uses: actions/upload-artifact@v3 @@ -245,7 +273,7 @@ jobs: permissions: contents: none runs-on: ubuntu-22.04 - needs: [app-version-higher, no-init-endpoint] + needs: [app-version-higher, auth-tests-no-init] name: TestsSpecial-OK steps: - run: echo "Tests special passed successfully" diff --git a/CHANGELOG.md b/CHANGELOG.md index 08fe0a43..efff1c9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] -## [1.4.5 - 202x-xx-xx] +## [1.4.5 - 2024-01-02] + +### Added + +- Support for `ALL` API scope, that allows to call anyNextcloud endpoints bypassing API Scope check. #190 ### Fixed - Fixed incorrect DeployConfig SSL params parsing. #188 (Thanks to @raudraido) +- Incorrect HTTP status during invalid auth. #190 ## [1.4.4 - 2023-12-21] diff --git a/docs/tech_details/ApiScopes.rst b/docs/tech_details/ApiScopes.rst index f915dd7f..f5a8fbbd 100644 --- a/docs/tech_details/ApiScopes.rst +++ b/docs/tech_details/ApiScopes.rst @@ -20,18 +20,19 @@ but a subsequent version does, you can effortlessly specify the new API groups i The following API groups are currently supported: -* ``2`` SYSTEM -* ``10`` FILES -* ``11`` FILES_SHARING -* ``30`` USER_INFO -* ``31`` USER_STATUS -* ``32`` NOTIFICATIONS -* ``33`` WEATHER_STATUS -* ``50`` TALK -* ``60`` TALK_BOT -* ``61`` AI_PROVIDERS -* ``110`` ACTIVITIES -* ``120`` NOTES +* ``2`` SYSTEM +* ``10`` FILES +* ``11`` FILES_SHARING +* ``30`` USER_INFO +* ``31`` USER_STATUS +* ``32`` NOTIFICATIONS +* ``33`` WEATHER_STATUS +* ``50`` TALK +* ``60`` TALK_BOT +* ``61`` AI_PROVIDERS +* ``110`` ACTIVITIES +* ``120`` NOTES +* ``9999`` ALL These groups are identified using names. As time progresses, the list will steadily expand, comprehensively encompassing all potential APIs provided by Nextcloud. diff --git a/lib/Middleware/AppAPIAuthMiddleware.php b/lib/Middleware/AppAPIAuthMiddleware.php index f4fe81b6..c70cb730 100644 --- a/lib/Middleware/AppAPIAuthMiddleware.php +++ b/lib/Middleware/AppAPIAuthMiddleware.php @@ -23,26 +23,29 @@ class AppAPIAuthMiddleware extends Middleware { public function __construct( private AppAPIService $service, - protected IRequest $request, + protected IRequest $request, private IL10N $l, private LoggerInterface $logger, ) { } + /** + * @throws AppAPIAuthNotValidException when a security check fails + * @throws \ReflectionException + */ public function beforeController($controller, $methodName) { $reflectionMethod = new ReflectionMethod($controller, $methodName); $isAppAPIAuth = !empty($reflectionMethod->getAttributes(AppAPIAuth::class)); - if ($isAppAPIAuth) { if (!$this->service->validateExAppRequestToNC($this->request)) { - throw new AppAPIAuthNotValidException($this->l->t('AppAPIAuth authentication failed'), Http::STATUS_UNAUTHORIZED); + throw new AppAPIAuthNotValidException($this->l->t('AppAPI authentication failed'), Http::STATUS_UNAUTHORIZED); } } } /** - * If an AEAuthNotValidException is being caught + * If an AppAPIAuthNotValidException is being caught * * @param Controller $controller the controller that is being called * @param string $methodName the name of the method that will be called on @@ -52,21 +55,11 @@ public function beforeController($controller, $methodName) { * @throws Exception the passed in exception if it can't handle it */ public function afterException($controller, $methodName, Exception $exception): Response { - if ($exception instanceof AppAPIAuth) { - $response = new JSONResponse([ - 'message' => $exception->getMessage(), - ]); - if (stripos($this->request->getHeader('Accept'), 'html') === false) { - $response = new JSONResponse( - ['message' => $exception->getMessage()], - $exception->getCode() - ); - } - + if ($exception instanceof AppAPIAuthNotValidException) { $this->logger->debug($exception->getMessage(), [ 'exception' => $exception, ]); - return $response; + return new JSONResponse(['message' => $exception->getMessage()], $exception->getCode()); } throw $exception; diff --git a/lib/Service/AppAPIService.php b/lib/Service/AppAPIService.php index e2419219..a88e85a1 100644 --- a/lib/Service/AppAPIService.php +++ b/lib/Service/AppAPIService.php @@ -38,7 +38,6 @@ use SimpleXMLElement; class AppAPIService { - public const BASIC_API_SCOPE = 1; public const CACHE_TTL = 60 * 60; // 1 hour private ICache $cache; @@ -737,21 +736,27 @@ public function validateExAppRequestToNC(IRequest $request, bool $isDav = false) } else { $path = '/dav/'; } + + $allScopesFlag = (bool)$this->exAppScopesService->getByScope($exApp, ExAppApiScopeService::ALL_API_SCOPE); $apiScope = $this->exAppApiScopeService->getApiScopeByRoute($path); - if ($apiScope === null) { - $this->logger->error(sprintf('Failed to check apiScope %s', $path)); - return false; - } - // BASIC ApiScope is granted to all ExApps (all Api routes with BASIC scope group). - if ($apiScope->getScopeGroup() !== self::BASIC_API_SCOPE) { - if (!$this->exAppScopesService->passesScopeCheck($exApp, $apiScope->getScopeGroup())) { - $this->logger->error(sprintf('ExApp %s not passed scope group check %s', $exApp->getAppid(), $path)); + if (!$allScopesFlag) { + if ($apiScope === null) { + $this->logger->error(sprintf('Failed to check apiScope %s', $path)); return false; } + + // BASIC ApiScope is granted to all ExApps (all API routes with BASIC scope group). + if ($apiScope->getScopeGroup() !== ExAppApiScopeService::BASIC_API_SCOPE) { + if (!$this->exAppScopesService->passesScopeCheck($exApp, $apiScope->getScopeGroup())) { + $this->logger->error(sprintf('ExApp %s not passed scope group check %s', $exApp->getAppid(), $path)); + return false; + } + } } + // For APIs that not assuming work under user context we do not check ExApp users - if ($apiScope->getUserCheck()) { + if (($apiScope === null) or ($apiScope->getUserCheck())) { try { if (!$this->exAppUsersService->exAppUserExists($exApp->getAppid(), $userId)) { $this->logger->error(sprintf('ExApp %s user %s does not exist', $exApp->getAppid(), $userId)); diff --git a/lib/Service/ExAppApiScopeService.php b/lib/Service/ExAppApiScopeService.php index b38b990f..3a1dacc6 100644 --- a/lib/Service/ExAppApiScopeService.php +++ b/lib/Service/ExAppApiScopeService.php @@ -16,6 +16,8 @@ use Psr\Log\LoggerInterface; class ExAppApiScopeService { + public const BASIC_API_SCOPE = 1; + public const ALL_API_SCOPE = 9999; private ICache $cache; public function __construct( @@ -115,6 +117,9 @@ public function registerInitScopes(): bool { ['api_route' => '/apps/spreed/api/', 'scope_group' => 50, 'name' => 'TALK', 'user_check' => 1], ['api_route' => '/apps/activity/api/', 'scope_group' => 110, 'name' => 'ACTIVITIES', 'user_check' => 1], ['api_route' => '/apps/notes/api/', 'scope_group' => 120, 'name' => 'NOTES', 'user_check' => 1], + + //ALL Scope + ['api_route' => 'non-exist-all-api-route', 'scope_group' => self::ALL_API_SCOPE, 'name' => 'ALL', 'user_check' => 1], ]; $this->cache->clear('/all_api_scopes'); diff --git a/tests/auth_scopes_system.py b/tests/auth_scopes_system.py new file mode 100644 index 00000000..ef21c54d --- /dev/null +++ b/tests/auth_scopes_system.py @@ -0,0 +1,25 @@ +import sys +import pytest +import nc_py_api + +# sys.argv[1] = 0 -> System App, ALL Scope +# sys.argv[1] = 1 -> System App, No ALL Scope +# sys.argv[1] = 2 -> No System App, ALL Scope + +if __name__ == "__main__": + nc = nc_py_api.NextcloudApp(user="admin") + assert nc.capabilities + if int(sys.argv[1]) == 0: + nc.ocs("GET", "/ocs/v2.php/core/whatsnew") + else: + with pytest.raises(nc_py_api.NextcloudException) as e: + nc.ocs("GET", "/ocs/v2.php/core/whatsnew") + assert e.value.status_code == 401 + + if int(sys.argv[1]) == 2: + # as NextcloudApp was initialized with `user="admin"` this will fail for non-system app. + with pytest.raises(nc_py_api.NextcloudException) as e: + nc.users_list() + assert e.value.status_code == 401 + else: + assert nc.users_list() diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index 9bc4e2c0..eed90ead 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -96,17 +96,6 @@ IEventListener - - - $exception instanceof AppAPIAuth - - - getCode - getMessage - getMessage - getMessage - - Request