diff --git a/Events.php b/Events.php index 4dac1cb9..94325c57 100644 --- a/Events.php +++ b/Events.php @@ -8,6 +8,7 @@ namespace humhub\modules\rest; +use humhub\components\api\AuthMethodsEvent; use humhub\components\Event; use humhub\modules\activity\models\Activity; use humhub\modules\comment\models\Comment; @@ -17,6 +18,7 @@ use humhub\modules\like\models\Like; use humhub\modules\notification\models\Notification; use humhub\modules\post\models\Post; +use humhub\modules\rest\components\auth\AuthMethods; use humhub\modules\rest\definitions\ActivityDefinitions; use humhub\modules\rest\definitions\CommentDefinitions; use humhub\modules\rest\definitions\FileDefinitions; @@ -41,6 +43,21 @@ class Events { public static function onBeforeRequest($event) { + // The bare `/rest/...` URL space is reserved for the admin config page and an + // explicit catch-all. These rules must be registered for EVERY request (not only + // `api/` requests): otherwise a bare `/rest//` URL falls through + // to Yii's default routing and resolves straight to a REST controller action — an + // unconstrained, CSRF-exempt plain GET (the same reasoning behind core's own + // off-prefix guard, see the defence-in-depth check in BaseController::beforeAction()). + Yii::$app->urlManager->addRules([ + + // API Config + ['pattern' => 'rest/admin/index', 'route' => 'rest/admin', 'verb' => ['POST', 'GET']], + + // Catch all to ensure verbs + ['pattern' => 'rest/', 'route' => 'rest/error/notfound'], + + ], true); // Only prepare if API request if (!str_starts_with(Yii::$app->request->pathInfo, 'api/')) { @@ -166,19 +183,22 @@ public static function onBeforeRequest($event) ]); - Yii::$app->urlManager->addRules([ - - // API Config - ['pattern' => 'rest/admin/index', 'route' => 'rest/admin', 'verb' => ['POST', 'GET']], - - // Catch all to ensure verbs - ['pattern' => 'rest/', 'route' => 'rest/error/notfound'], - - ], true); - Event::trigger(Module::class, Module::EVENT_REST_API_ADD_RULES); } + /** + * Contributes this module's authentication methods to every API controller of the + * platform, so the core endpoints (`/api/v2`) can be called with a token too - see + * {@see AuthMethods} for the list and `docs/api-stack.md` for the model. Core appends its own + * session authentication after them, keeping the "token wins" ordering intact. + * + * @since 0.13 + */ + public static function onCollectApiAuthMethods(AuthMethodsEvent $event): void + { + $event->authMethods = array_merge($event->authMethods, AuthMethods::collect()); + } + private static function addModuleNotFoundRoutes($moduleId) { /* @var Module $module */ diff --git a/components/BaseController.php b/components/BaseController.php index 215a24cd..dac367a2 100644 --- a/components/BaseController.php +++ b/components/BaseController.php @@ -11,22 +11,18 @@ use humhub\components\access\ControllerAccess; use humhub\components\Controller; use humhub\modules\content\models\Content; -use humhub\modules\rest\components\auth\ImpersonateAuth; +use humhub\modules\rest\components\auth\AuthMethods; use humhub\modules\rest\components\behaviors\LanguagePickerBehavior; use humhub\modules\rest\components\User as UserComponent; -use humhub\modules\rest\components\auth\JwtAuth; -use humhub\modules\rest\controllers\auth\AuthController; -use humhub\modules\rest\models\ConfigureForm; +use humhub\modules\rest\Module; use humhub\modules\user\models\User; use Yii; use yii\data\Pagination; use yii\db\ActiveQuery; use yii\filters\auth\CompositeAuth; -use yii\filters\auth\HttpBasicAuth; -use yii\filters\auth\HttpBearerAuth; -use yii\filters\auth\QueryParamAuth; use yii\helpers\ArrayHelper; use yii\web\JsonParser; +use yii\web\NotFoundHttpException; /** * Class BaseController @@ -58,30 +54,10 @@ public function behaviors() return ArrayHelper::merge([ 'authenticator' => [ 'class' => CompositeAuth::class, - 'authMethods' => ArrayHelper::merge( - ConfigureForm::getInstance()->enableJwtAuth ? [[ - 'class' => JwtAuth::class, - ]] : [], - ConfigureForm::getInstance()->enableBearerAuth ? [[ - 'class' => HttpBearerAuth::class, - ]] : [], - ConfigureForm::getInstance()->enableBearerAuth && ConfigureForm::getInstance()->enableQueryParamAuth ? [[ - 'class' => QueryParamAuth::class, - ]] : [], - ConfigureForm::getInstance()->enableBasicAuth ? [[ - 'class' => HttpBasicAuth::class, - 'auth' => function ($username, $password) { - if (($identity = AuthController::authByUserAndPassword($username, $password)) && $this->isUserEnabled($identity)) { - return $identity; - } - - return null; - }, - ]] : [], - [[ - 'class' => ImpersonateAuth::class, - ]], - ), + // The same methods this module contributes to core's API controllers, see + // {@see AuthMethods}. `/api/v1` is token-only: browser-session + // authentication is a core opt-in, per controller. + 'authMethods' => AuthMethods::collect(), ], 'languagePicker' => [ 'class' => LanguagePickerBehavior::class, @@ -94,9 +70,21 @@ public function behaviors() */ public function beforeAction($action) { + // Defence in depth: hard-fail any request that reached a REST controller off the API + // URL rules — i.e. whose path is not under the API prefix (a bare `/rest// + // ` URL). Together with the `rest/` catch-all in + // Events::onBeforeRequest() this guarantees a mutating action can never be executed + // off-rule as an unconstrained, CSRF-exempt plain request. Must run before auth. + if (!str_starts_with(Yii::$app->request->pathInfo, Module::API_URL_PREFIX)) { + Yii::$app->response->format = 'json'; + throw new NotFoundHttpException(); + } + Yii::$app->set('user', [ 'class' => UserComponent::class, 'identityClass' => User::class, + // Always session-less: token logins (`yii\web\User::login()`) must never write + // into the browser session. 'enableSession' => false, ]); @@ -129,18 +117,7 @@ public function actionNotSupported() */ public function isUserEnabled(User $user) { - $config = new ConfigureForm(); - $config->loadSettings(); - - if (!empty($config->enabledForAllUsers)) { - return true; - } - - if (in_array($user->guid, (array)$config->enabledUsers)) { - return true; - } - - return false; + return AuthMethods::isUserEnabled($user); } diff --git a/components/auth/AuthMethods.php b/components/auth/AuthMethods.php new file mode 100644 index 00000000..97600744 --- /dev/null +++ b/components/auth/AuthMethods.php @@ -0,0 +1,91 @@ +enableJwtAuth ? [[ + 'class' => JwtAuth::class, + ]] : [], + $config->enableBearerAuth ? [[ + 'class' => HttpBearerAuth::class, + ]] : [], + $config->enableBearerAuth && $config->enableQueryParamAuth ? [[ + 'class' => QueryParamAuth::class, + ]] : [], + $config->enableBasicAuth ? [[ + 'class' => HttpBasicAuth::class, + 'auth' => function ($username, $password) { + if (($identity = AuthController::authByUserAndPassword($username, $password)) && static::isUserEnabled($identity)) { + return $identity; + } + + return null; + }, + ]] : [], + [[ + 'class' => ImpersonateAuth::class, + ]], + ); + } + + /** + * Whether the given user may use the API at all - the "Enabled for all registered users" + * setting and the user allowlist below it, which per its own admin hint applies to the + * JWT and HTTP Basic methods only. + */ + public static function isUserEnabled(User $user): bool + { + $config = ConfigureForm::getInstance(); + + if (!empty($config->enabledForAllUsers)) { + return true; + } + + return in_array($user->guid, (array)$config->enabledUsers); + } +} diff --git a/components/auth/ImpersonateAuth.php b/components/auth/ImpersonateAuth.php index cad6c184..62115711 100644 --- a/components/auth/ImpersonateAuth.php +++ b/components/auth/ImpersonateAuth.php @@ -9,7 +9,6 @@ namespace humhub\modules\rest\components\auth; use humhub\modules\rest\models\ImpersonateAuthToken; -use Yii; use yii\db\Expression; use yii\filters\auth\HttpBearerAuth; use yii\helpers\StringHelper; @@ -41,8 +40,14 @@ public function authenticate($user, $request, $response) ->one(); if ($accessToken && ($identity = $accessToken->user)) { + // Note: up to HumHub 1.18 this additionally set `Yii::$app->user->isImpersonated`, + // a flag of the `Impersonator` user-component behavior. HumHub 1.19 replaced that + // behavior with the session-bound `Impersonation` component + // (`Yii::$app->user->impersonation`, core #8372), so the flag no longer exists — + // and the API user component is deliberately session-less, so there is no session + // state to mark either. Applying 1.19's impersonation restrictions (hidden private + // content) to impersonate-token requests is a separate follow-up. $user->login($identity); - Yii::$app->user->isImpersonated = true; } else { $identity = null; } diff --git a/config.php b/config.php index f8a42122..dfa30b93 100644 --- a/config.php +++ b/config.php @@ -6,6 +6,7 @@ * @license https://www.humhub.com/licences */ +use humhub\components\api\BaseController; use humhub\components\Application; return [ @@ -14,6 +15,9 @@ 'namespace' => 'humhub\modules\rest', 'events' => [ [Application::class, Application::EVENT_BEFORE_REQUEST, ['\humhub\modules\rest\Events', 'onBeforeRequest']], + // Token authentication for the platform's own API controllers (`/api/v2`), see + // Events::onCollectApiAuthMethods() + [BaseController::class, BaseController::EVENT_COLLECT_AUTH_METHODS, ['\humhub\modules\rest\Events', 'onCollectApiAuthMethods']], ['humhub\modules\legal\services\ExportService', 'collectUserData', ['humhub\modules\rest\Events', 'onLegalModuleUserDataExport']], ], ]; diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7da7b215..97a4264f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,6 +1,15 @@ Changelog ========= +0.13.0 (Unreleased) +------------------- +- Chg: Raised minimum HumHub version to 1.20 — the module builds on the HTTP API framework of core (`humhub\components\api\`) +- Enh: The module's authentication methods (JWT, Bearer, query param, Basic, Impersonate) now apply to the API endpoints of core 1.20 as well (`/api/v2`), contributed through the core API framework; `/api/v1` stays token-only, browser-session authentication is a core opt-in per endpoint (see `docs/api-stack.md`) +- Chg: The `/api/v2` documentation lives in core (`docs/api/`), not in this module — this module's Swagger sources cover its own `/api/v1` surface again; `build-all.sh` no longer builds a page for the shared-components file +- Enh: Hardened the `/rest/...` URL space — the admin-page and catch-all rules are registered for every request, and a REST controller reached off the `api/v1/` prefix (e.g. through Yii's fallback routing) now fails with a 404 before authentication runs +- Fix: Impersonate token authentication crashed on HumHub 1.19 (`isImpersonated` was removed by the core impersonation refactor, core #8372) +- Chg: Replies are validated to nest at most one level (core `Comment` model rule, surfaced through the validation envelope) + 0.12.2 (July 16, 2026) ---------------------- - Chg: Removed the obsolete `twofa.beforeCheck` listener (`Module::ignoreTwofaCheck()`) — the event no longer exists since the twofa module moved to the core user gate system; token-authenticated API requests are not intercepted by the gate, so the REST API keeps working for users with 2FA enabled without any opt-out diff --git a/docs/MANUAL.md b/docs/MANUAL.md index 6d8977f9..ca88dd25 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -6,14 +6,19 @@ Following RESTful API endpoints are available. **Base URL:** -The base url for all APIs is: `https://yourhost/api/v1/` +The base url for the endpoints of this module is: `https://yourhost/api/v1/` + +Since HumHub 1.20 the platform itself ships API endpoints under +`https://yourhost/api/v2/`, in modernized conventions (ISO-8601 timestamps, camelCase, +plain HTTP status codes). This module's authentication methods apply to them as well — see +the **v2 APIs** below and `docs/api-stack.md`. **Language** Logged-in user's language will be used. Can be overwritten by `Accept-Language` header. -**Core APIs:** +**v1 APIs (this module):** - [User](https://marketplace.humhub.com/module/rest/docs/html/user.html) - [Content](https://marketplace.humhub.com/module/rest/docs/html/content.html) @@ -27,6 +32,11 @@ Logged-in user's language will be used. Can be overwritten by `Accept-Language` - [Space](https://marketplace.humhub.com/module/rest/docs/html/space.html) - [Content Topics](https://marketplace.humhub.com/module/rest/docs/html/topic.html) +**v2 APIs (endpoints shipped by HumHub core, 1.20+):** + +Documented by core itself and served by every installation at `/docs/api/` — this module only +contributes the token authentication those endpoints accept. + **Module APIs** - [Calendar](https://marketplace.humhub.com/module/calendar/docs/swagger/calendar.html) diff --git a/docs/api-stack.md b/docs/api-stack.md new file mode 100644 index 00000000..e9311327 --- /dev/null +++ b/docs/api-stack.md @@ -0,0 +1,107 @@ +# This module and the platform API stack + +HumHub 1.20 ships an HTTP API framework in core (`humhub\components\api\`): base +controller, request/response conventions, URL-rule registration, the serialize +extension point and browser-session authentication. Core's own endpoints live next +to the modules that own them (`humhub\modules\\controllers\api\`) and answer +under `/api/v2`. The full picture is in the core docs, +`docs/develop/concept-api.md`. + +This module builds on that framework and adds what a platform API needs beyond it. + +## What this module provides + +- **Machine authentication** — JWT, HTTP Bearer, query-param bearer, HTTP Basic and + impersonate tokens, plus the user allowlist and the admin configuration UI. + Collected in `components/auth/AuthMethods.php`. +- **`/api/v1`** — this module's own endpoint surface and wire shapes, unchanged + (`docs/swagger/*.yaml` → `docs/html/`). +- **`components/BaseController`** — the base class other modules extend for their + own `/api/v1` endpoints (see `DEVELOPER.md`). + +## Authentication model + +Two questions worth keeping apart: + +**Which methods exist?** Core ships browser-session authentication only. This module +adds the token methods and contributes them to *every* API controller of the platform +through core's collect event: + +```php +// config.php +[BaseController::class, BaseController::EVENT_COLLECT_AUTH_METHODS, [Events::class, 'onCollectApiAuthMethods']], +``` + +So on an installation with this module, the core endpoints can be called with a token +too; on a core-only installation they can only be reached from a logged-in browser +session. + +**Which endpoints accept which method?** Contributed token methods apply everywhere. +Browser-session authentication is an opt-in *per controller* +(`humhub\components\api\BaseController::$enableSessionAuth`, default off) and core +enables it only for the endpoints its own UI needs. `/api/v1` is therefore +token-only: a session cookie authenticates nothing here. + +That asymmetry is deliberate. Session authentication bypasses the user allowlist — +it has to, since the browser UI must work for every logged-in user — and endpoints +written for token clients may have their authorization written for that narrower +threat model. They must not silently become reachable from any logged-in browser +session. + +Ordering is part of the contract: contributed token methods run BEFORE session +authentication, so a request carrying a valid token authenticates as the token user +even when a session cookie is present ("token wins"). The fall-through is not +uniform: `JwtAuth` returns `null` on failure (the next method gets a turn), while the +Bearer/QueryParam/Basic/Impersonate methods throw on an invalid credential, so a +malformed token yields 401 instead of downgrading. On core endpoints that allow guests +(`humhub\components\api\BaseController::$guestAllowedActions`, honored only while guest +access is enabled platform-wide) a missing or invalid credential downgrades to guest; +`/api/v1` has no guest-readable actions. + +## Guarding the URL space + +API controllers are reachable through Yii's fallback routing (`/rest// +`, `//api//`), which would bypass the API URL +rules and their verb constraints. Both layers guard against it: + +- this module prepends a `rest/` catch-all for every request and + `BaseController::beforeAction()` hard-fails any request whose path is not under + `api/v1/`, +- core does the same for its own controllers (`ApiRules::offPrefixGuard()` plus the + `api/v2/` check in its base controller). + +Both require pretty URLs, as the API always has. + +## Documentation layout + +`docs/swagger/` holds the OpenAPI sources of **this module's `/api/v1` surface**, one +document per module, rendered to `docs/html/` by `build-all.sh`. + +The endpoints **core** ships (`/api/v2`) are documented by core itself, in `docs/api/` of the +core repository — sources in `docs/api/src/`, the rendered references next to them, and an +index page an installation serves at `/docs/api/`. This module documented them here while core +had no place for them; the move left every published `/api/v1` documentation URL untouched. + +## Version bounds + +This module version requires core 1.20 (`humhub.minVersion`) — it uses the core +framework's collect event. The previous module line still needs a `humhub.maxVersion`, so +the marketplace does not offer a version without the core stack for 1.20+. Those fields +are marketplace metadata, not runtime enforcement: core does not evaluate them when +loading a module, so an administrator copying an outdated module in by hand bypasses +them. + +## Open points + +1. **Rate limiting** — browser-session traffic (the core Vue islands) multiplies API + request volume. Throttling should be considered before a stable release. +2. **Impersonate-token restriction** — applying core 1.19's impersonation + private-content restriction to impersonate-token API access is a tracked + follow-up. +3. **v1 on the core stack** — `/api/v1` still carries its own base controller, error + envelope and serializers. Reimplementing it over the core framework and + serializers is the next consolidation step; its wire shapes stay as documented in + `docs/swagger/`. +4. **v2 conventions** — ISO-8601 timestamps and camelCase field names are what core's + `/api/v2` uses; the corresponding modernization of the v1 shapes is collected in + the issue "v2: ISO-8601 timestamps and shape modernization". diff --git a/docs/html/common.html b/docs/html/common.html deleted file mode 100644 index 5b71e5fe..00000000 --- a/docs/html/common.html +++ /dev/null @@ -1,145 +0,0 @@ - - - - - - Common API Components - - - - - - - - - -

Common API Components (1.0.0)

Download OpenAPI specification:

- - - - diff --git a/docs/swagger/build-all.sh b/docs/swagger/build-all.sh index a1bddcfb..c54f3250 100755 --- a/docs/swagger/build-all.sh +++ b/docs/swagger/build-all.sh @@ -1,6 +1,17 @@ #!/bin/bash +# Renders every endpoint document to ../html/. `common.yaml` holds shared components only +# and is skipped — it is referenced by the endpoint documents, not read on its own. +# +# This module documents its own `/api/v1` surface here. The endpoints core ships (`/api/v2`) +# are documented by core itself, in `docs/api/` of the core repository (see ../api-stack.md). + +cd "$(dirname "$0")" || exit 1 + +mkdir -p ../html + for filename in *.yaml; do + [ "$filename" = "common.yaml" ] && continue echo "--------- $filename ---------------------" - npx @redocly/cli build-docs $filename -o ../html/$(basename "$filename" .yaml).html -done \ No newline at end of file + npx @redocly/cli build-docs "$filename" -o "../html/$(basename "$filename" .yaml).html" +done diff --git a/module.json b/module.json index 51dd4fc0..c5f448d3 100644 --- a/module.json +++ b/module.json @@ -6,10 +6,10 @@ "api", "rest" ], - "version": "0.12.2", + "version": "0.13.0", "homepage": "https://github.com/humhub/rest", "humhub": { - "minVersion": "1.19" + "minVersion": "1.20" }, "screenshots": [] } \ No newline at end of file