diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index dbb7c4ae93e..ded87e37cb2 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -354,6 +354,11 @@ Moved the following controllers: - Deprecated `craft\models\SystemMessage` and `craft\records\SystemMessage`. `CraftCms\Cms\SystemMessage\Models\SystemMessage` should be used instead. - Replaced `craft\controllers\SystemMessagesController` with `CraftCms\Cms\Http\Controllers\Utilities\SystemMessagesController` +## Tokens + +- Deprecated `craft\services\Tokens`. `CraftCms\Cms\RouteToken\RouteTokens` should be used instead. +- Deprecated `craft\records\Token`. `CraftCms\Cms\RouteToken\Models\RouteToken` should be used instead. + ## Translations - Deprecated `craft\i18n\FormatConverter`. `CraftCms\Cms\Translation\FormatConverter` should be used instead. diff --git a/routes/actions.php b/routes/actions.php index f2a9231af1c..210a63adc6b 100644 --- a/routes/actions.php +++ b/routes/actions.php @@ -21,6 +21,7 @@ use CraftCms\Cms\Http\Controllers\PluginStore\InstallController as PluginStoreInstallController; use CraftCms\Cms\Http\Controllers\PluginStore\PluginStoreController; use CraftCms\Cms\Http\Controllers\PluginStore\RemoveController; +use CraftCms\Cms\Http\Controllers\PreviewController; use CraftCms\Cms\Http\Controllers\Settings\EntryTypesController; use CraftCms\Cms\Http\Controllers\Settings\GeneralSettingsController; use CraftCms\Cms\Http\Controllers\Settings\RoutesController; @@ -30,6 +31,7 @@ use CraftCms\Cms\Http\Controllers\StructuresController; use CraftCms\Cms\Http\Controllers\Updates\UpdaterController; use CraftCms\Cms\Http\Controllers\Updates\UpdatesController; +use CraftCms\Cms\Http\Controllers\Users\ImpersonationController; use CraftCms\Cms\Http\Controllers\Utilities\ClearCachesController; use CraftCms\Cms\Http\Controllers\Utilities\DbBackupController; use CraftCms\Cms\Http\Controllers\Utilities\DeprecationErrorsController; @@ -40,6 +42,22 @@ use CraftCms\Cms\Http\Controllers\Utilities\UtilitiesController; use CraftCms\Cms\Http\Middleware\RequireAdmin; use CraftCms\Cms\Http\Middleware\RequireAdminChanges; +use CraftCms\Cms\Http\Middleware\RequireElevatedSession; +use CraftCms\Cms\Http\Middleware\RequireToken; +use CraftCms\Cms\Support\Str; +use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken; + +/** + * Actions that should not have CSRF token verification. These are automatically + * mapped to `/{cpTrigger}/{actionTrigger}/route` and `/{actionTrigger}/{route}` + */ +VerifyCsrfToken::except(collect([ + 'preview/preview', +])->flatMap(fn (string $route) => [ + $route, + Cms::config()->actionTrigger.Str::start($route, '/'), + Cms::config()->cpTrigger.'/'.Cms::config()->actionTrigger.Str::start($route, '/'), +])->all()); /** * Actions that are accessible without CP can be registered here. @@ -50,6 +68,11 @@ Route::middleware(['auth'])->group(function () { Route::post('entries/save-entry', StoreEntryController::class); }); + + Route::middleware([RequireToken::class])->group(function () { + Route::any('preview/preview', [PreviewController::class, 'preview'])->name('preview'); + Route::any('users/impersonate-with-token', [ImpersonationController::class, 'withToken']); + }); }); Route::prefix(implode('/', [ @@ -142,6 +165,9 @@ // Migrations Route::post('utilities/apply-new-migrations', MigrationsController::class); + // Preview + Route::any('preview/create-token', [PreviewController::class, 'createToken']); + // Widgets Route::post('dashboard/create-widget', [WidgetsController::class, 'store']); Route::post('dashboard/save-widget-settings', [WidgetsController::class, 'update']); @@ -238,6 +264,12 @@ Route::post('app/check-for-updates', [UpdatesController::class, 'check']); Route::post('app/cache-updates', [UpdatesController::class, 'cache']); + // Users + Route::middleware([RequireElevatedSession::class])->group(function () { + Route::post('users/impersonate', [ImpersonationController::class, 'impersonate']); + Route::post('users/get-impersonation-url', [ImpersonationController::class, 'getUrl']); + }); + // Pluginstore Route::middleware([ RequireAdmin::class, diff --git a/src/Database/Migrations/0000_00_00_000005_rename_tokens_to_routetokens.php b/src/Database/Migrations/0000_00_00_000005_rename_tokens_to_routetokens.php new file mode 100644 index 00000000000..e1179c7c70f --- /dev/null +++ b/src/Database/Migrations/0000_00_00_000005_rename_tokens_to_routetokens.php @@ -0,0 +1,25 @@ +char('uid', 36)->default('0'); }); + Schema::create('routetokens', function (Blueprint $table) { + $table->integer('id', true); + $table->char('token', 32); + $table->text('route')->nullable(); + $table->unsignedTinyInteger('usageLimit')->nullable(); + $table->unsignedTinyInteger('usageCount')->nullable(); + $table->dateTime('expiryDate'); + $table->dateTime('dateCreated'); + $table->dateTime('dateUpdated'); + $table->char('uid', 36)->default('0'); + }); + Schema::create(Table::SEARCHINDEXQUEUE, function (Blueprint $table) { $table->integer('id', true); $table->integer('elementId'); @@ -739,18 +751,6 @@ public function createTables(): void $table->dateTime('dateUpdated'); }); - Schema::create(Table::TOKENS, function (Blueprint $table) { - $table->integer('id', true); - $table->char('token', 32); - $table->text('route')->nullable(); - $table->unsignedTinyInteger('usageLimit')->nullable(); - $table->unsignedTinyInteger('usageCount')->nullable(); - $table->dateTime('expiryDate'); - $table->dateTime('dateCreated'); - $table->dateTime('dateUpdated'); - $table->char('uid', 36)->default('0'); - }); - Schema::create(Table::USERGROUPS, function (Blueprint $table) { $table->integer('id', true); $table->string('name'); @@ -996,8 +996,8 @@ public function createIndexes(): void Schema::createIndex(Table::TAGGROUPS, ['handle']); Schema::createIndex(Table::TAGGROUPS, ['dateDeleted']); Schema::createIndex(Table::TAGS, ['groupId']); - Schema::createIndex(Table::TOKENS, ['token'], unique: true); - Schema::createIndex(Table::TOKENS, ['expiryDate']); + Schema::createIndex(Table::ROUTETOKENS, ['token'], unique: true); + Schema::createIndex(Table::ROUTETOKENS, ['expiryDate']); Schema::createIndex(Table::USERGROUPS, ['handle']); Schema::createIndex(Table::USERGROUPS, ['name']); Schema::createIndex(Table::USERGROUPS_USERS, ['groupId', 'userId'], unique: true); diff --git a/src/Database/Table.php b/src/Database/Table.php index 7c1af4f49e4..ffbac14ec33 100644 --- a/src/Database/Table.php +++ b/src/Database/Table.php @@ -93,6 +93,8 @@ public const string REVISIONS = 'revisions'; + public const string ROUTETOKENS = 'routetokens'; + public const string SEARCHINDEX = 'searchindex'; public const string SEARCHINDEXQUEUE = 'searchindexqueue'; @@ -127,8 +129,6 @@ public const string TAGS = 'tags'; - public const string TOKENS = 'tokens'; - public const string USERGROUPS = 'usergroups'; public const string USERGROUPS_USERS = 'usergroups_users'; diff --git a/src/Http/Controllers/PreviewController.php b/src/Http/Controllers/PreviewController.php new file mode 100644 index 00000000000..b0de8f24281 --- /dev/null +++ b/src/Http/Controllers/PreviewController.php @@ -0,0 +1,104 @@ +draftId) => $this->requirePermission("previewDraft:{$tokenData->draftId}"), + isset($tokenData->revisionId) => $this->requirePermission("previewRevision:{$tokenData->revisionId}"), + default => $this->requirePermission("previewElement:{$tokenData->getCanonicalId()}"), + }; + + $token = $tokens->createPreviewToken([ + route('craft.actions.preview', absolute: false), [ + 'elementType' => $tokenData->elementType, + 'canonicalId' => $tokenData->getCanonicalId(), + 'siteId' => $tokenData->siteId, + 'draftId' => $tokenData->draftId ?? null, + 'revisionId' => $tokenData->revisionId ?? null, + 'userId' => $request->user()->id, + ], + ], token: $tokenData->previewToken); + + abort_if($token === false, 500, t('Could not create a preview token.')); + + if (isset($tokenData->redirect)) { + return redirect($tokenData->redirect); + } + + return new JsonResponse(compact('token')); + } + + public function preview(Request $request, Kernel $kernel, RouteToken $tokenData): mixed + { + $query = $tokenData->elementType::find() + ->siteId($tokenData->siteId) + ->status(null); + + $elementFn = match (true) { + ! is_null($tokenData->draftId) => fn () => $query->draftId($tokenData->draftId)->one(), + ! is_null($tokenData->revisionId) => fn () => $query->revisionId($tokenData->revisionId)->one(), + ! is_null($tokenData->userId) => function () use ($tokenData, $query) { + ElementHelper::setProvisionalDraftUser($tokenData->userId); + + $element = (clone $query) + ->draftOf($tokenData->canonicalId) + ->provisionalDrafts() + ->draftCreator($tokenData->userId) + ->one(); + + return $element ?? $query->id($tokenData->canonicalId)->one(); + }, + default => fn () => null, + }; + + if ($element = $elementFn()) { + if (! $element->lft && $element->getIsDerivative()) { + // See if we can add structure data to it + $canonical = $element->getCanonical(true); + $element->structureId = $canonical->structureId; + $element->root = $canonical->root; + $element->lft = $canonical->lft; + $element->rgt = $canonical->rgt; + $element->level = $canonical->level; + } + + $element->previewing = true; + \Craft::$app->getElements()->setPlaceholderElement($element); + } + + /** @var \Illuminate\Support\Uri $originalUri */ + $originalUri = Context::pullHidden(HandleTokenRequest::ORIGINAL_URI_KEY); + + $response = $kernel->handle($request->duplicateWithUri( + newUri: $originalUri->value(), + query: $originalUri->query()->all() + )); + + return match (true) { + $response instanceof Response => $response->setNoCacheHeaders(), + default => $response, + }; + } +} diff --git a/src/Http/Controllers/Settings/SectionsController.php b/src/Http/Controllers/Settings/SectionsController.php index 02bc2a64d99..a6577c408f0 100644 --- a/src/Http/Controllers/Settings/SectionsController.php +++ b/src/Http/Controllers/Settings/SectionsController.php @@ -121,7 +121,7 @@ public function store( Sections $sections, Section $section, ): Response { - $sectionId = $request->input('sectionId'); + $sectionId = $request->integer('sectionId'); if ($sectionId) { abort_if(is_null($sections->getSectionById($sectionId)), 404, "Invalid section ID: $sectionId"); diff --git a/src/Http/Controllers/Users/ImpersonationController.php b/src/Http/Controllers/Users/ImpersonationController.php new file mode 100644 index 00000000000..c9cedc910a7 --- /dev/null +++ b/src/Http/Controllers/Users/ImpersonationController.php @@ -0,0 +1,143 @@ +request->validate([ + 'userId' => ['required', 'integer', Rule::exists(Table::USERS, 'id')], + ]); + + $userId = $this->request->integer('userId'); + + abort_if( + is_null($user = Craft::$app->getUsers()->getUserById($userId)), + 400, + "Invalid user ID: $userId", + ); + + $this->enforceImpersonatePermission($user); + + Craft::$app->getUser()->setImpersonatorId($this->request->user()->id); + + try { + Auth::login(UserModel::findOrFail($userId)); + } catch (Throwable) { + Flash::fail(t('There was a problem impersonating this user.')); + + Log::error($this->request->user()->username.' tried to impersonate userId: '.$userId.' but something went wrong.'); + + return back(); + } + + return $this->handleSuccessfulLogin($user); + } + + public function getUrl(GetImpersonationUrlAction $getImpersonationUrlAction): JsonResponse + { + $this->request->validate([ + 'userId' => ['required', 'integer', Rule::exists(Table::USERS, 'id')], + ]); + + $userId = $this->request->integer('userId'); + + abort_if( + is_null($user = Craft::$app->getUsers()->getUserById($userId)), + 400, + "Invalid user ID: $userId", + ); + + $this->enforceImpersonatePermission($user); + + $url = $getImpersonationUrlAction(UserModel::findOrFail($user->id)); + + abort_if($url === false, 500, 'Unable to generate impersonation URL.'); + + return new JsonResponse(compact('url')); + } + + public function withToken(): Response + { + $this->request->validate([ + 'userId' => ['required', 'integer', Rule::exists(Table::USERS, 'id')], + 'prevUserId' => ['required', 'integer', Rule::exists(Table::USERS, 'id')], + ]); + + $userId = $this->request->integer('userId'); + $prevUserId = $this->request->integer('prevUserId'); + + $user = UserModel::findOrFail($userId); + + Craft::$app->getUser()->setImpersonatorId($prevUserId); + + try { + Auth::login(UserModel::findOrFail($userId)); + } catch (Throwable) { + Flash::fail(t('There was a problem impersonating this user.')); + + return back(); + } + + return $this->handleSuccessfulLogin(Craft::$app->getUsers()->getUserById($user->id)); + } + + private function handleSuccessfulLogin(User $user): Response + { + // Get the return URL + $userSession = Craft::$app->getUser(); + $returnUrl = $userSession->getReturnUrl(); + + // Clear it out + $userSession->removeReturnUrl(); + + // If this was an Ajax request, just return success:true + if ($this->request->wantsJson()) { + $return = [ + 'returnUrl' => $returnUrl, + 'csrfTokenValue' => csrf_token(), + ]; + + return $this->asModelSuccess($user, modelName: 'user', data: $return); + } + + return $this->redirectToPostedUrl($userSession->getIdentity(), $returnUrl); + } + + private function enforceImpersonatePermission(User $user): void + { + $yiiCurrentUser = Craft::$app->getUsers()->getUserById($this->request->user()->id); + + abort_unless( + Craft::$app->getUsers()->canImpersonate($yiiCurrentUser, $user), + 403, + t('You do not have sufficient permissions to impersonate this user'), + ); + } +} diff --git a/src/Http/Middleware/HandleActionRequest.php b/src/Http/Middleware/HandleActionRequest.php index cffd5aeb6c6..8528666e1c7 100644 --- a/src/Http/Middleware/HandleActionRequest.php +++ b/src/Http/Middleware/HandleActionRequest.php @@ -5,30 +5,19 @@ namespace CraftCms\Cms\Http\Middleware; use Closure; -use CraftCms\Cms\Config\GeneralConfig; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; use yii\web\NotFoundHttpException; final readonly class HandleActionRequest { - public function __construct( - private GeneralConfig $generalConfig, - ) {} - public function handle(Request $request, Closure $next): mixed { if (! $request->isActionRequest()) { return $next($request); } - $actionSegments = $request->actionSegments(); - $route = implode('/', array_filter([ - '', - $request->isCpRequest() ? $this->generalConfig->cpTrigger : null, - $this->generalConfig->actionTrigger, - ...$actionSegments, - ], fn ($value) => ! is_null($value))); + $route = $request->actionSegmentsToRoute(); if ($request->path() === $route) { return $next($request); diff --git a/src/Http/Middleware/HandleTokenRequest.php b/src/Http/Middleware/HandleTokenRequest.php new file mode 100644 index 00000000000..3bd86ab48f6 --- /dev/null +++ b/src/Http/Middleware/HandleTokenRequest.php @@ -0,0 +1,63 @@ +input($this->generalConfig->tokenParam, $request->header(self::TOKEN_HEADER)); + + if (! $token) { + return $next($request); + } + + abort_unless(preg_match('/^[A-Za-z0-9_-]+$/', (string) $token), 400, 'Invalid token'); + + Context::addHidden(self::TOKEN_KEY, $token); + + /** + * If we POST to a route with a valid token, we don't + * need to verify the CSRF token as well. + */ + VerifyCsrfToken::except([ + $request->path(), + ]); + + $tokenRoute = $this->tokens->getTokenRoute($token); + + if ($tokenRoute === false) { + return $next($request); + } + + Context::addHidden(self::ORIGINAL_URI_KEY, $request->uri()->withoutQuery([ + 'token', + 'x-craft-preview', + 'x-craft-live-preview', + ])); + + $newRequest = $request->duplicateWithUri((string) $tokenRoute[0], $tokenRoute[1] ?? []); + + return $next($newRequest); + } +} diff --git a/src/Http/Middleware/RequireElevatedSession.php b/src/Http/Middleware/RequireElevatedSession.php new file mode 100644 index 00000000000..356c105bfe9 --- /dev/null +++ b/src/Http/Middleware/RequireElevatedSession.php @@ -0,0 +1,28 @@ +getUser()->setIdentity( + \Craft::$app->getUsers()->getUserById($request->user()->id), + ); + + abort_unless( + \Craft::$app->getUser()->getHasElevatedSession(), + 403, + t('This action may only be performed with an elevated session.') + ); + + return $next($request); + } +} diff --git a/src/Http/Middleware/RequireToken.php b/src/Http/Middleware/RequireToken.php new file mode 100644 index 00000000000..465cfc7dc64 --- /dev/null +++ b/src/Http/Middleware/RequireToken.php @@ -0,0 +1,23 @@ +headers->remove(HandleTokenRequest::TOKEN_HEADER); + + return $next($request); + } +} diff --git a/src/Http/RespondsWithFlash.php b/src/Http/RespondsWithFlash.php index 3e0179e132c..2fa5b6bc098 100644 --- a/src/Http/RespondsWithFlash.php +++ b/src/Http/RespondsWithFlash.php @@ -87,6 +87,11 @@ public function asModelSuccess( return $this->asSuccess($message, $data, $redirect); } + public function redirectToPostedUrl(?object $object = null, ?string $redirect = null): Response + { + return redirect($this->getPostedRedirectUrl($object) ?? $redirect); + } + protected function getPostedRedirectUrl(?object $object = null): ?string { $url = request('redirect'); diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index 2ff3a1f9afe..ae0afd9f4b8 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -22,6 +22,7 @@ use Illuminate\Http\Client\Factory; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Request; +use Illuminate\Http\Response; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Event; @@ -159,6 +160,24 @@ private function registerMacros(): void return []; }); + Request::macro('actionSegmentsToRoute', function (?array $actionSegments = null): string { + $actionSegments ??= $this->actionSegments(); + + return implode('/', array_filter([ + '', + $this->isCpRequest() ? Cms::config()->cpTrigger : null, + Cms::config()->actionTrigger, + ...$actionSegments, + ], fn ($value) => ! is_null($value))); + }); + + Request::macro('duplicateWithUri', fn (string $newUri, ?array $query = null, array $server = []): Request => $this->duplicate( + query: $query ?? $this->query->all(), + server: array_merge($this->server->all(), $server, [ + 'REQUEST_URI' => $newUri, + ]), + )); + Request::macro('getSigned', function (string $key, mixed $default = null): mixed { $value = $this->get($key); @@ -173,6 +192,14 @@ private function registerMacros(): void return $value; }); + Response::macro('setNoCacheHeaders', function (bool $replace = true) { + $this->header('Expires', '0', $replace); + $this->header('Pragma', 'no-cache', $replace); + $this->header('Cache-Control', 'no-cache, no-store, must-revalidate', $replace); + + return $this; + }); + Factory::macro('create', fn (array $options = []) => $this->throw() ->withUserAgent('Craft/'.Cms::VERSION.' '.Utils::defaultUserAgent()) ->when( diff --git a/src/Route/RouteServiceProvider.php b/src/Route/RouteServiceProvider.php index c89d1cc7a83..f375da9c6eb 100644 --- a/src/Route/RouteServiceProvider.php +++ b/src/Route/RouteServiceProvider.php @@ -10,6 +10,7 @@ use CraftCms\Cms\Http\Middleware\ExtractNamespace; use CraftCms\Cms\Http\Middleware\FlushProjectConfig; use CraftCms\Cms\Http\Middleware\HandleActionRequest; +use CraftCms\Cms\Http\Middleware\HandleTokenRequest; use CraftCms\Cms\Http\Middleware\RequireCpRequest; use CraftCms\Cms\Http\Middleware\SendPoweredByHeader; use CraftCms\Cms\Http\Middleware\UpdateLocale; @@ -35,6 +36,7 @@ public function register(): void $kernel = $this->app->get(HttpKernel::class); $kernel->setGlobalMiddleware(array_merge([ ExtractNamespace::class, + HandleTokenRequest::class, HandleActionRequest::class, ], $kernel->getGlobalMiddleware())); } diff --git a/src/RouteToken/Data/RouteToken.php b/src/RouteToken/Data/RouteToken.php new file mode 100644 index 00000000000..ab89fc5d7b8 --- /dev/null +++ b/src/RouteToken/Data/RouteToken.php @@ -0,0 +1,31 @@ + */ + public string $elementType, + public int $siteId, + #[RequiredWithout('sourceId')] + public ?int $canonicalId, + #[RequiredWithout('canonicalId')] + public ?int $sourceId, + public ?int $draftId = null, + public ?int $revisionId = null, + public ?int $userId = null, + public ?string $previewToken = null, + public ?string $redirect = null, + ) {} + + public function getCanonicalId(): int + { + return $this->canonicalId ?? $this->sourceId; + } +} diff --git a/src/RouteToken/Model/RouteToken.php b/src/RouteToken/Model/RouteToken.php new file mode 100644 index 00000000000..3fbdae381ad --- /dev/null +++ b/src/RouteToken/Model/RouteToken.php @@ -0,0 +1,23 @@ + 'int', + 'usageCount' => 'int', + 'expiryDate' => 'datetime', + 'route' => 'json', + ]; +} diff --git a/src/RouteToken/RouteTokens.php b/src/RouteToken/RouteTokens.php new file mode 100644 index 00000000000..10ce2539e66 --- /dev/null +++ b/src/RouteToken/RouteTokens.php @@ -0,0 +1,153 @@ +createToken('action/path'); + * + * // Route to a controller action with params + * app(Tokens::class)->createToken(['action/path', [ + * 'foo' => 'bar' + * ]]); + * + * // Route to a template + * app(Tokens::class)->createToken([ + * 'templates/render', + * [ + * 'template' => 'template/path', + * ] + * ]); + * ``` + * + * @param array|string $route Where matching requests should be routed to. + * @param int|null $usageLimit The maximum number of times this token can be + * used. Defaults to no limit. + * @param DateTime|null $expiryDate The date that the token expires. + * Defaults to the 'defaultTokenDuration' config setting. + * @param string|null $token The token to use, if it was pre-generated. Must be exactly 32 characters. + * @return string|false The generated token, or `false` if there was an error. + */ + public function createToken(array|string $route, ?int $usageLimit = null, ?DateTime $expiryDate = null, ?string $token = null): string|false + { + if ($token !== null && strlen($token) !== 32) { + throw new InvalidArgumentException("Invalid token: $token"); + } + + $tokenModel = new RouteToken; + $tokenModel->token = $token ?? \Craft::$app->getSecurity()->generateRandomString(); + $tokenModel->route = $route; + $tokenModel->expiryDate = Date::parse($expiryDate ?? now()->addSeconds(Cms::config()->defaultTokenDuration)); + + if ($usageLimit !== null) { + $tokenModel->usageCount = 0; + $tokenModel->usageLimit = $usageLimit; + } + + if ($tokenModel->save()) { + return $tokenModel->token; + } + + return false; + } + + /** + * Creates a new token for previewing content, using the to determine the duration, if set. + * + * @param mixed $route Where matching requests should be routed to. + * @param int|null $usageLimit The maximum number of times this token can be + * used. Defaults to no limit. + * @param string|null $token The token to use, if it was pre-generated. Must be exactly 32 characters. + * @return string|false The generated token, or `false` if there was an error. + */ + public function createPreviewToken(mixed $route, ?int $usageLimit = null, ?string $token = null): string|false + { + return $this->createToken($route, $usageLimit, null, $token); + } + + /** + * Searches for a token, and possibly returns a route for the request. + */ + public function getTokenRoute(string $token): array|false + { + // Take the opportunity to delete any expired tokens + $this->deleteExpiredTokens(); + + $result = RouteToken::where('token', $token)->first(); + + if (! $result) { + // Remove it from the request so it doesn’t get added to generated URLs + \Craft::$app->getRequest()->setToken(null); + + return false; + } + + // Usage limit enforcement (for future requests) + if ($result->usageLimit) { + // Does it have any more life after this? + if ($result->usageCount < $result->usageLimit - 1) { + // Increment its count + $this->incrementTokenUsageCountById($result->id); + } else { + // Just delete it + $this->deleteTokenById($result->id); + + Context::forgetHidden(HandleTokenRequest::TOKEN_KEY); + } + } + + return (array) Json::decodeIfJson($result->route); + } + + public function incrementTokenUsageCountById(int $tokenId): bool + { + return (bool) DB::table(Table::ROUTETOKENS) + ->where('id', $tokenId) + ->increment('usageCount'); + } + + public function deleteTokenById(int $tokenId): bool + { + DB::table(Table::ROUTETOKENS)->delete($tokenId); + + return true; + } + + public function deleteExpiredTokens(): bool + { + // Ignore if we've already done this once during the request + if ($this->deletedExpiredTokens) { + return false; + } + + $affectedRows = DB::table(Table::ROUTETOKENS) + ->where('expiryDate', '<=', now()) + ->delete(); + + $this->deletedExpiredTokens = true; + + return (bool) $affectedRows; + } +} diff --git a/src/User/Actions/GetImpersonationUrlAction.php b/src/User/Actions/GetImpersonationUrlAction.php new file mode 100644 index 00000000000..f6678178dbb --- /dev/null +++ b/src/User/Actions/GetImpersonationUrlAction.php @@ -0,0 +1,37 @@ +tokens->createToken([ + action_url('/users/impersonate-with-token'), [ + 'userId' => $user->id, + 'prevUserId' => Auth::user()->id ?? $user->id, + ], + ], 1, now()->addHour()); + + if (! $token) { + return false; + } + + $url = $user->can('accessCp') ? UrlHelper::cpUrl() : UrlHelper::siteUrl(); + + return UrlHelper::urlWithToken($url, $token); + } +} diff --git a/src/User/Commands/ImpersonateCommand.php b/src/User/Commands/ImpersonateCommand.php index dda2540be68..6a1d66b3f80 100644 --- a/src/User/Commands/ImpersonateCommand.php +++ b/src/User/Commands/ImpersonateCommand.php @@ -4,10 +4,9 @@ namespace CraftCms\Cms\User\Commands; -use Craft; -use craft\helpers\UrlHelper; use CraftCms\Cms\Console\CraftCommand; -use DateTime; +use CraftCms\Cms\User\Actions\GetImpersonationUrlAction; +use CraftCms\Cms\User\Models\User; use Illuminate\Console\Command; use Illuminate\Contracts\Console\PromptsForMissingInput; use Laravel\Prompts\Concerns\Colors; @@ -26,28 +25,20 @@ final class ImpersonateCommand extends Command implements PromptsForMissingInput protected $aliases = ['users/impersonate']; - public function handle(): int + public function handle(GetImpersonationUrlAction $getImpersonationUrlAction): int { if (! $user = $this->getUser()) { return self::FAILURE; } - $token = Craft::$app->getTokens()->createToken([ - 'users/impersonate-with-token', [ - 'userId' => $user->id, - 'prevUserId' => $user->id, - ], - ], 1, new DateTime('+1 hour')); + $url = $getImpersonationUrlAction(User::findOrFail($user->id)); - if (! $token) { + if ($url === false) { $this->components->error('Unable to create the impersonation token.'); return self::FAILURE; } - $url = $user->can('accessCp') ? UrlHelper::cpUrl() : UrlHelper::siteUrl(); - $url = UrlHelper::urlWithToken($url, $token); - info("Impersonation URL for “{$user->username}”: ".$this->cyan($url).' (Expires in one hour)'); return self::SUCCESS; diff --git a/src/helpers.php b/src/helpers.php index 4ca57d7c9fd..ba960d9458c 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -18,6 +18,11 @@ function t(string|Stringable|null $id, array $parameters = [], ?string $category return I18N::translate($id ?? '', $parameters, $category, $locale); } +function action_url(string $url): string +{ + return Str::start($url, Str::finish(Cms::config()->actionTrigger, '/')); +} + function cp_url(string $url): string { return Str::start($url, Str::finish(Cms::config()->cpTrigger, '/')); diff --git a/tests/Database/Commands/MigrateCommandTest.php b/tests/Database/Commands/MigrateCommandTest.php index fd0eba09510..f3d9e417749 100644 --- a/tests/Database/Commands/MigrateCommandTest.php +++ b/tests/Database/Commands/MigrateCommandTest.php @@ -8,6 +8,8 @@ use function Pest\Laravel\artisan; it('runs migrations', function () { + DB::table(Table::MIGRATIONS)->delete(); + expect(DB::table(Table::MIGRATIONS)->count())->toBe(0); artisan('craft:migrate:all') diff --git a/tests/Http/Controllers/PreviewControllerTest.php b/tests/Http/Controllers/PreviewControllerTest.php new file mode 100644 index 00000000000..0b646aa284a --- /dev/null +++ b/tests/Http/Controllers/PreviewControllerTest.php @@ -0,0 +1,92 @@ +entry = Entry::factory()->create(); +}); + +it('can create a token', function () { + expect(RouteToken::count())->toBe(0); + + postJson(action([PreviewController::class, 'createToken']), [ + 'elementType' => \craft\elements\Entry::class, + 'siteId' => Site::firstOrFail()->id, + 'canonicalId' => $this->entry->id, + ])->assertOk(); + + expect(RouteToken::count())->toBe(1); +}); + +test('elementType is required', function () { + postJson(action([PreviewController::class, 'createToken']), [ + 'siteId' => Site::firstOrFail()->id, + 'canonicalId' => $this->entry->id, + ])->assertJsonValidationErrorFor('elementType'); +}); + +test('siteId is required', function () { + postJson(action([PreviewController::class, 'createToken']), [ + 'elementType' => \craft\elements\Entry::class, + 'canonicalId' => $this->entry->id, + ])->assertJsonValidationErrorFor('siteId'); +}); + +test('canonicalId is required without sourceId', function () { + postJson(action([PreviewController::class, 'createToken']), [ + 'elementType' => \craft\elements\Entry::class, + 'siteId' => Site::firstOrFail()->id, + ])->assertJsonValidationErrorFor('sourceId'); + + postJson(action([PreviewController::class, 'createToken']), [ + 'elementType' => \craft\elements\Entry::class, + 'siteId' => Site::firstOrFail()->id, + 'sourceId' => $this->entry->id, + ])->assertOk(); +}); + +it('redirects when a redirect is passed', function () { + postJson(action([PreviewController::class, 'createToken']), [ + 'elementType' => \craft\elements\Entry::class, + 'siteId' => Site::firstOrFail()->id, + 'canonicalId' => $this->entry->id, + 'redirect' => 'https://example.com', + ])->assertRedirect('https://example.com'); +}); + +test('preview requires a valid token', function () { + get(route('craft.actions.preview')) + ->assertUnauthorized(); +}); + +test('it can preview elements', function () { + $token = postJson(action([PreviewController::class, 'createToken']), [ + 'elementType' => \craft\elements\Entry::class, + 'siteId' => Site::firstOrFail()->id, + 'canonicalId' => $this->entry->id, + ])->json('token'); + + $entryId = $this->entry->id; + Route::get('/', function () use ($entryId) { + $entry = Craft::$app->elements->getElementById($entryId, \craft\elements\Entry::class); + + return $entry?->previewing ? 'previewing' : 'not previewing'; + }); + + get('/?token='.$token) + ->assertOk() + ->assertSee('previewing') + ->assertDontSee('not previewing'); +}); diff --git a/tests/Http/Middleware/HandleTokenRequestTest.php b/tests/Http/Middleware/HandleTokenRequestTest.php new file mode 100644 index 00000000000..18afb4d4d58 --- /dev/null +++ b/tests/Http/Middleware/HandleTokenRequestTest.php @@ -0,0 +1,66 @@ +middleware = app(HandleTokenRequest::class); +}); + +it('does nothing if there is no token or token header', function () { + expect($this->middleware->handle(Request::create('foo'), fn () => 'bar'))->toBe('bar'); +}); + +it('throws if an invalid token is passed', function () { + $this->expectExceptionMessage('Invalid token'); + + $this->middleware->handle(Request::create('foo', parameters: [ + Cms::config()->tokenParam => 'invalid token', + ]), fn () => 'bar'); +}); + +it('adds the token to the context', function () { + $this->middleware->handle(Request::create('foo', parameters: [ + Cms::config()->tokenParam => Str::random(32), + ]), fn () => 'bar'); + + expect(Context::getHidden(HandleTokenRequest::TOKEN_KEY)) + ->not() + ->toBeNull(); +}); + +it('does nothing more when the token does not return a route', function () { + $result = $this->middleware->handle(Request::create('foo', parameters: [ + Cms::config()->tokenParam => Str::random(32), + ]), fn () => 'bar'); + + expect($result)->toBe('bar'); +}); + +it('returns the response of the token route', function () { + $token = app(RouteTokens::class)->createToken('token/route'); + + $result = $this->middleware->handle(Request::create('foo', parameters: [ + Cms::config()->tokenParam => $token, + ]), function (?Request $request = null) { + if (! is_null($request)) { + return $request->path(); + } + + return 'bar'; + }); + + expect($result)->toBe('token/route'); + + /** @var ?\Illuminate\Support\Uri $originalUri */ + $originalUri = Context::getHidden(HandleTokenRequest::ORIGINAL_URI_KEY); + + expect($originalUri)->not()->toBeNull(); + expect($originalUri->value())->toBe(url('foo')); +}); diff --git a/tests/Http/Middleware/RequireTokenTest.php b/tests/Http/Middleware/RequireTokenTest.php new file mode 100644 index 00000000000..c8d4bec7cee --- /dev/null +++ b/tests/Http/Middleware/RequireTokenTest.php @@ -0,0 +1,19 @@ +expectExceptionMessage('Valid token required'); + + app(RequireToken::class)->handle(Request::create('foo'), fn () => 'bar'); +}); + +it('returns next if token is found', function () { + Context::addHidden(HandleTokenRequest::TOKEN_KEY, 'token'); + + expect(app(RequireToken::class)->handle(Request::create('foo'), fn () => 'bar'))->toBe('bar'); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 512f1770357..e4346423a96 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -7,6 +7,7 @@ use Craft; use craft\test\TestSetup; use CraftCms\Cms\Database\Migrations\Install; +use CraftCms\Cms\Database\Migrator; use CraftCms\Cms\Edition; use CraftCms\Cms\ProjectConfig\ProjectConfig; use CraftCms\Cms\Site\Data\Site; @@ -95,6 +96,12 @@ protected function migrateDatabases() ); $migration->up(); + + // Mark all existing migrations as applied + $migrator = app(Migrator::class)->track('craft'); + foreach ($migrator->getPendingMigrations() as $file) { + $migrator->getRepository()->log($migrator->getMigrationName($file), 1); + } } #[\Override] diff --git a/tests/Token/TokensTest.php b/tests/Token/TokensTest.php new file mode 100644 index 00000000000..b88092897b1 --- /dev/null +++ b/tests/Token/TokensTest.php @@ -0,0 +1,135 @@ +tokens = app(RouteTokens::class); +}); + +it('can create tokens', function () { + $token = $this->tokens->createToken('do/stuff', 1, $expiryDate = now()->addDay()); + + $tokenModel = RouteToken::where('token', $token)->firstOrFail(); + + expect($tokenModel->route)->toBe('do/stuff'); + expect($tokenModel->usageLimit)->toBe(1); + expect($tokenModel->usageCount)->toBe(0); + expect($tokenModel->expiryDate->isSameDay($expiryDate))->toBeTrue(); + expect(strlen((string) $token))->toBe(32); +}); + +it('can create a preview token', function () { + $token = $this->tokens->createPreviewToken('do/stuff'); + + $tokenModel = RouteToken::where('token', $token)->firstOrFail(); + + expect($tokenModel->route)->toBe('do/stuff'); + expect(strlen((string) $token))->toBe(32); +}); + +it('creates tokens with config defaults', function () { + Cms::config()->defaultTokenDuration = 10000; + + $expiryDate = now()->addSeconds(10000); + + $token = $this->tokens->createToken('do/stuff'); + + expect(strlen((string) $token))->toBe(32); + + $model = RouteToken::where('token', $token)->firstOrFail(); + + expect($model->usageLimit)->toBeNull(); + expect($model->usageCount)->toBeNull(); + expect($model->expiryDate->isSameMinute($expiryDate))->toBeTrue(); +}); + +it('can get a token route', function () { + $token = $this->tokens->createToken('do/stuff'); + + $route = $this->tokens->getTokenRoute($token); + + expect($route)->not()->toBeFalse(); + expect($route[0])->toBe('do/stuff'); +}); + +it('increments usage count when there is a usage limit', function () { + $token = $this->tokens->createToken('do/stuff', 10); + + $model = RouteToken::where('token', $token)->firstOrFail(); + + expect($model->usageLimit)->toBe(10); + expect($model->usageCount)->toBe(0); + + $this->tokens->getTokenRoute($token); + + expect($model->fresh()->usageCount)->toBe(1); +}); + +it('deletes the token when the usage limit is reached', function () { + $token = $this->tokens->createToken('do/stuff', 1); + + $model = RouteToken::where('token', $token)->firstOrFail(); + + $this->tokens->getTokenRoute($token); + + expect($model->fresh())->toBeNull(); +}); + +it('decodes if the route is json', function () { + $token = $this->tokens->createToken(Json::encode([ + 'route' => 'do/stuff', + ])); + + $route = $this->tokens->getTokenRoute($token); + + expect($route['route'])->toBe('do/stuff'); +}); + +it('can increment usage count for a token', function () { + $token = $this->tokens->createToken('do/stuff', 10); + + $model = RouteToken::where('token', $token)->firstOrFail(); + + expect($model->usageCount)->toBe(0); + + $this->tokens->incrementTokenUsageCountById($model->id); + + expect($model->fresh()->usageCount)->toBe(1); +}); + +it('can delete tokens by id', function () { + $token = $this->tokens->createToken('do/stuff', 10); + + $model = RouteToken::where('token', $token)->firstOrFail(); + + $this->tokens->deleteTokenById($model->id); + + expect($model->fresh())->toBeNull(); +}); + +it('can delete expired tokens', function () { + $token = $this->tokens->createToken('do/stuff', 10); + + $model = RouteToken::where('token', $token)->firstOrFail(); + + $model->update(['expiryDate' => now()->subSecond()]); + + $this->tokens->deleteExpiredTokens(); + + expect($model->fresh())->toBeNull(); +}); + +it('deletes expired tokens when getting token routes', function () { + $token = $this->tokens->createToken('do/stuff', 10); + $model = RouteToken::where('token', $token)->firstOrFail(); + $model->update(['expiryDate' => now()->subSecond()]); + + expect($this->tokens->getTokenRoute($token))->toBeFalse(); + + expect($model->fresh())->toBeNull(); +}); diff --git a/yii2-adapter/legacy/base/ApplicationTrait.php b/yii2-adapter/legacy/base/ApplicationTrait.php index 295c26988bd..3bd3c6a9071 100644 --- a/yii2-adapter/legacy/base/ApplicationTrait.php +++ b/yii2-adapter/legacy/base/ApplicationTrait.php @@ -1245,6 +1245,7 @@ public function getTemplateCaches(): TemplateCaches * Returns the tokens service. * * @return Tokens The tokens service + * @deprecated 6.0.0 use {@see \CraftCms\Cms\RouteToken\RouteTokens} instead. */ public function getTokens(): Tokens { diff --git a/yii2-adapter/legacy/controllers/LivePreviewController.php b/yii2-adapter/legacy/controllers/LivePreviewController.php index 935539192d9..d9929b3003c 100644 --- a/yii2-adapter/legacy/controllers/LivePreviewController.php +++ b/yii2-adapter/legacy/controllers/LivePreviewController.php @@ -11,6 +11,7 @@ use craft\elements\db\UserQuery; use craft\elements\User; use craft\web\Controller; +use CraftCms\Cms\RouteToken\RouteTokens; use yii\base\InvalidRouteException; use yii\console\Exception; use yii\web\BadRequestHttpException; @@ -61,7 +62,7 @@ public function actionCreateToken(): Response } // Create the token - $token = Craft::$app->getTokens()->createPreviewToken([ + $token = app(RouteTokens::class)->createPreviewToken([ 'live-preview/preview', [ 'previewAction' => $action, 'userId' => Craft::$app->getUser()->getId(), diff --git a/yii2-adapter/legacy/controllers/PreviewController.php b/yii2-adapter/legacy/controllers/PreviewController.php deleted file mode 100644 index 06e50cebff9..00000000000 --- a/yii2-adapter/legacy/controllers/PreviewController.php +++ /dev/null @@ -1,184 +0,0 @@ - - * @since 3.2.0 - */ -class PreviewController extends Controller -{ - /** - * @inheritdoc - */ - protected array|bool|int $allowAnonymous = [ - 'preview' => self::ALLOW_ANONYMOUS_LIVE | self::ALLOW_ANONYMOUS_OFFLINE, - ]; - - /** - * @inheritdoc - */ - public function beforeAction($action): bool - { - // Don't require CSRF validation for POSTed preview requests - if ($action->id === 'preview') { - $this->enableCsrfValidation = false; - } - - return parent::beforeAction($action); - } - - /** - * Creates a token for previewing/sharing an element. - * - * @throws ServerErrorHttpException if the token couldn't be created - * @throws BadRequestHttpException - * @throws Exception - * @return Response - */ - public function actionCreateToken(): Response - { - $elementType = $this->request->getRequiredParam('elementType'); - $canonicalId = $this->request->getParam('canonicalId') ?? $this->request->getRequiredBodyParam('sourceId'); - $siteId = $this->request->getRequiredParam('siteId'); - $draftId = $this->request->getParam('draftId'); - $revisionId = $this->request->getParam('revisionId'); - $token = $this->request->getParam('previewToken'); - $redirect = $this->request->getParam('redirect'); - - if ($draftId) { - $this->requireAuthorization('previewDraft:' . $draftId); - } elseif ($revisionId) { - $this->requireAuthorization('previewRevision:' . $revisionId); - } else { - $this->requireAuthorization('previewElement:' . $canonicalId); - } - - // Create the token - $token = Craft::$app->getTokens()->createPreviewToken([ - 'preview/preview', [ - 'elementType' => $elementType, - 'canonicalId' => (int)$canonicalId, - 'siteId' => (int)$siteId, - 'draftId' => (int)$draftId ?: null, - 'revisionId' => (int)$revisionId ?: null, - 'userId' => Craft::$app->getUser()->getId(), - ], - ], null, $token); - - if (!$token) { - throw new ServerErrorHttpException(t('Could not create a preview token.')); - } - - if ($redirect) { - return $this->redirect($redirect); - } - - return $this->asJson(compact('token')); - } - - /** - * Substitutes an element for the element being previewed for the remainder of the request, and reroutes the request. - * - * @param class-string $elementType - * @param int $canonicalId - * @param int $siteId - * @param int|null $draftId - * @param int|null $revisionId - * @param int|null $userId - * @return Response - * @throws BadRequestHttpException - * @throws Throwable - */ - public function actionPreview( - string $elementType, - int $canonicalId, - int $siteId, - ?int $draftId = null, - ?int $revisionId = null, - ?int $userId = null, - ): Response { - // Make sure a token was used to get here - $this->requireToken(); - - $query = $elementType::find() - ->siteId($siteId) - ->status(null); - - if ($draftId) { - $element = $query - ->draftId($draftId) - ->one(); - } elseif ($revisionId) { - $element = $query - ->revisionId($revisionId) - ->one(); - } else { - if ($userId) { - // First check if there's a provisional draft - $user = Craft::$app->getUsers()->getUserById($userId); - ElementHelper::setProvisionalDraftUser($user); - $element = (clone $query) - ->draftOf($canonicalId) - ->provisionalDrafts() - ->draftCreator($userId) - ->one(); - } - - if (!isset($element)) { - $element = $query - ->id($canonicalId) - ->one(); - } - } - - if ($element) { - if (!$element->lft && $element->getIsDerivative()) { - // See if we can add structure data to it - $canonical = $element->getCanonical(true); - $element->structureId = $canonical->structureId; - $element->root = $canonical->root; - $element->lft = $canonical->lft; - $element->rgt = $canonical->rgt; - $element->level = $canonical->level; - } - - $element->previewing = true; - Craft::$app->getElements()->setPlaceholderElement($element); - } - - // Prevent the browser from caching the response - $this->response->setNoCacheHeaders(); - - // Recheck whether this is an action request, this time ignoring the token - $this->request->checkIfActionRequest(true, false); - - // Re-route the request, this time ignoring the token - /** @var Application $app */ - $app = Craft::$app; - $urlManager = $app->getUrlManager(); - $urlManager->checkToken = false; - $urlManager->setRouteParams([], false); - $urlManager->setMatchedElement(null); - return $app->handleRequest($this->request, true); - } -} diff --git a/yii2-adapter/legacy/controllers/UsersController.php b/yii2-adapter/legacy/controllers/UsersController.php index 9ece30ba55b..285f40e82c5 100644 --- a/yii2-adapter/legacy/controllers/UsersController.php +++ b/yii2-adapter/legacy/controllers/UsersController.php @@ -56,7 +56,6 @@ use CraftCms\Cms\Support\Html; use CraftCms\Cms\Support\Json; use CraftCms\Cms\Translation\Locale; -use DateTime; use Throwable; use yii\base\Exception; use yii\base\InvalidArgumentException; @@ -67,7 +66,6 @@ use yii\web\HttpException; use yii\web\NotFoundHttpException; use yii\web\Response; -use yii\web\ServerErrorHttpException; use function CraftCms\Cms\t; /** @noinspection ClassOverridesFieldOfSuperClassInspection */ @@ -389,136 +387,6 @@ public function actionRedirect(): Response return $this->redirect(Craft::$app->getUser()->getDefaultReturnUrl()); } - /** - * Logs a user in for impersonation. - * - * @return Response|null - * @throws BadRequestHttpException - * @throws ForbiddenHttpException - */ - public function actionImpersonate(): ?Response - { - $this->requirePostRequest(); - $this->requireElevatedSession(); - - $userSession = Craft::$app->getUser(); - $userId = $this->request->getRequiredBodyParam('userId'); - $user = Craft::$app->getUsers()->getUserById($userId); - - if (!$user) { - throw new BadRequestHttpException("Invalid user ID: $userId"); - } - - // Make sure they're allowed to impersonate this user - $this->_enforceImpersonatePermission($user); - - // Save the original user ID to the session now so User::findIdentity() - // knows not to worry if the user isn't active yet - $userSession->setImpersonatorId($userSession->getId()); - - if (!$userSession->loginByUserId($userId)) { - $userSession->setImpersonatorId(null); - $this->setFailFlash(t('There was a problem impersonating this user.')); - Craft::error($userSession->getIdentity()->username . ' tried to impersonate userId: ' . $userId . ' but something went wrong.', __METHOD__); - return null; - } - - return $this->_handleSuccessfulLogin($user); - } - - /** - * Generates and returns a new impersonation URL - * - * @return Response - * @throws BadRequestHttpException - * @throws ForbiddenHttpException - * @throws ServerErrorHttpException - * @since 3.6.0 - */ - public function actionGetImpersonationUrl(): Response - { - $this->requirePostRequest(); - $this->requireElevatedSession(); - - $userId = $this->request->getBodyParam('userId'); - $user = Craft::$app->getUsers()->getUserById($userId); - - if (!$user) { - throw new BadRequestHttpException("Invalid user ID: $userId"); - } - - // Make sure they're allowed to impersonate this user - $this->_enforceImpersonatePermission($user); - - // Create a single-use token that expires in an hour - $token = Craft::$app->getTokens()->createToken([ - 'users/impersonate-with-token', [ - 'userId' => $userId, - 'prevUserId' => Craft::$app->getUser()->getId(), - ], - ], 1, new DateTime('+1 hour')); - - if (!$token) { - throw new ServerErrorHttpException('Unable to create the invalidation token.'); - } - - $url = $user->can('accessCp') ? UrlHelper::cpUrl() : UrlHelper::siteUrl(); - $url = UrlHelper::urlWithToken($url, $token); - - return $this->asJson(compact('url')); - } - - /** - * Logs a user in for impersonation via an impersonation token. - * - * @param int $userId - * @param int $prevUserId - * @return Response|null - * @throws BadRequestHttpException - * @throws ForbiddenHttpException - * @since 3.6.0 - */ - public function actionImpersonateWithToken(int $userId, int $prevUserId): ?Response - { - $this->requireToken(); - - $userSession = Craft::$app->getUser(); - $user = Craft::$app->getUsers()->getUserById($userId); - $success = false; - - if ($user) { - // Save the original user ID to the session now so User::findIdentity() - // knows not to worry if the user isn't active yet - $userSession->setImpersonatorId($prevUserId); - $success = $userSession->login($user); - if (!$success) { - $userSession->setImpersonatorId(null); - } - } - - if (!$success) { - $this->setFailFlash(t('There was a problem impersonating this user.')); - Craft::error(sprintf('%s tried to impersonate userId: %s but something went wrong.', - $userSession->getIdentity()->username, $userId), __METHOD__); - return null; - } - - return $this->_handleSuccessfulLogin($user); - } - - /** - * Ensures that the current user has permission to impersonate the given user. - * - * @param User $user - * @throws ForbiddenHttpException - */ - private function _enforceImpersonatePermission(User $user): void - { - if (!Craft::$app->getUsers()->canImpersonate(static::currentUser(), $user)) { - throw new ForbiddenHttpException('You do not have sufficient permissions to impersonate this user'); - } - } - /** * Returns information about the current user session, if any. * diff --git a/yii2-adapter/legacy/helpers/ElementHelper.php b/yii2-adapter/legacy/helpers/ElementHelper.php index 383fe73fc31..8768e6dca62 100644 --- a/yii2-adapter/legacy/helpers/ElementHelper.php +++ b/yii2-adapter/legacy/helpers/ElementHelper.php @@ -1179,12 +1179,16 @@ public static function isMultiSite(ElementInterface $element): bool /** * Sets user to be used for swapping in provisional drafts. * - * @param UserElement|null $user + * @param UserElement|int|null $user * * @since 5.8.0 */ - public static function setProvisionalDraftUser(?UserElement $user): void + public static function setProvisionalDraftUser(UserElement|int|null $user): void { + if (is_int($user)) { + $user = \Craft::$app->getUsers()->getUserById($user); + } + self::$provisionalDraftUser = $user; } } diff --git a/yii2-adapter/legacy/records/Token.php b/yii2-adapter/legacy/records/Token.php index e46f30079b1..f98c0c3e840 100644 --- a/yii2-adapter/legacy/records/Token.php +++ b/yii2-adapter/legacy/records/Token.php @@ -22,6 +22,7 @@ * @property string|null $expiryDate Expiry date * @author Pixel & Tonic, Inc. * @since 3.0.0 + * @deprecated 6.0.0 use {@see \CraftCms\Cms\RouteToken\Model\Token} instead. */ class Token extends ActiveRecord { diff --git a/yii2-adapter/legacy/services/Tokens.php b/yii2-adapter/legacy/services/Tokens.php index ac27e66ea53..3d58a62ef62 100644 --- a/yii2-adapter/legacy/services/Tokens.php +++ b/yii2-adapter/legacy/services/Tokens.php @@ -7,17 +7,8 @@ namespace craft\services; -use Craft; -use craft\helpers\DateTimeHelper; -use craft\helpers\Db as DbHelper; -use craft\records\Token as TokenRecord; -use CraftCms\Cms\Cms; -use CraftCms\Cms\Database\Table; -use CraftCms\Cms\Support\Json; use DateTime; -use Illuminate\Support\Facades\DB; use yii\base\Component; -use yii\base\InvalidArgumentException; /** * The Tokens service. @@ -26,28 +17,24 @@ * * @author Pixel & Tonic, Inc. * @since 3.0.0 + * @deprecated 6.0.0 use {@see \CraftCms\Cms\RouteToken\RouteTokens} instead. */ class Tokens extends Component { - /** - * @var bool - */ - private bool $_deletedExpiredTokens = false; - /** * Creates a new token and returns it. * --- * ```php * // Route to a controller action - * Craft::$app->tokens->createToken('action/path'); + * app(Tokens::class)->createToken('action/path'); * * // Route to a controller action with params - * Craft::$app->tokens->createToken(['action/path', [ + * app(Tokens::class)->createToken(['action/path', [ * 'foo' => 'bar' * ]]); * * // Route to a template - * Craft::$app->tokens->createToken([ + * app(Tokens::class)->createToken([ * 'templates/render', * [ * 'template' => 'template/path', @@ -65,34 +52,7 @@ class Tokens extends Component */ public function createToken(array|string $route, ?int $usageLimit = null, ?DateTime $expiryDate = null, ?string $token = null): string|false { - if ($token !== null && strlen($token) !== 32) { - throw new InvalidArgumentException("Invalid token: $token"); - } - - if (!$expiryDate) { - $generalConfig = Cms::config(); - $interval = DateTimeHelper::secondsToInterval($generalConfig->defaultTokenDuration); - $expiryDate = DateTimeHelper::currentUTCDateTime(); - $expiryDate->add($interval); - } - - $tokenRecord = new TokenRecord(); - $tokenRecord->token = $token ?? Craft::$app->getSecurity()->generateRandomString(); - $tokenRecord->route = $route; - - if ($usageLimit !== null) { - $tokenRecord->usageCount = 0; - $tokenRecord->usageLimit = $usageLimit; - } - - $tokenRecord->expiryDate = DbHelper::prepareDateForDb($expiryDate); - $success = $tokenRecord->save(); - - if ($success) { - return $tokenRecord->token; - } - - return false; + return app(\CraftCms\Cms\RouteToken\RouteTokens::class)->createToken($route, $usageLimit, $expiryDate, $token); } /** @@ -107,9 +67,7 @@ public function createToken(array|string $route, ?int $usageLimit = null, ?DateT */ public function createPreviewToken(mixed $route, ?int $usageLimit = null, ?string $token = null): string|false { - $interval = DateTimeHelper::secondsToInterval(Cms::config()->previewTokenDuration); - $expiryDate = DateTimeHelper::currentUTCDateTime()->add($interval); - return $this->createToken($route, $usageLimit, $expiryDate, $token); + return app(\CraftCms\Cms\RouteToken\RouteTokens::class)->createPreviewToken($route, $usageLimit, $token); } /** @@ -120,36 +78,7 @@ public function createPreviewToken(mixed $route, ?int $usageLimit = null, ?strin */ public function getTokenRoute(string $token): array|false { - // Take the opportunity to delete any expired tokens - $this->deleteExpiredTokens(); - $result = DB::table(Table::TOKENS) - ->select(['id', 'route', 'usageLimit', 'usageCount']) - ->where('token', $token) - ->first(); - - if (!$result) { - // Remove it from the request so it doesn’t get added to generated URLs - Craft::$app->getRequest()->setToken(null); - - return false; - } - - // Usage limit enforcement (for future requests) - if ($result->usageLimit) { - // Does it have any more life after this? - if ($result->usageCount < $result->usageLimit - 1) { - // Increment its count - $this->incrementTokenUsageCountById($result->id); - } else { - // Just delete it - $this->deleteTokenById($result->id); - - // Remove it from the request as well so it doesn’t get added to generated URLs - Craft::$app->getRequest()->setToken(null); - } - } - - return (array)Json::decodeIfJson($result->route); + return app(\CraftCms\Cms\RouteToken\RouteTokens::class)->getTokenRoute($token); } /** @@ -160,9 +89,7 @@ public function getTokenRoute(string $token): array|false */ public function incrementTokenUsageCountById(int $tokenId): bool { - return (bool) DB::table(Table::TOKENS) - ->where('id', $tokenId) - ->increment('usageCount'); + return app(\CraftCms\Cms\RouteToken\RouteTokens::class)->incrementTokenUsageCountById($tokenId); } /** @@ -173,9 +100,7 @@ public function incrementTokenUsageCountById(int $tokenId): bool */ public function deleteTokenById(int $tokenId): bool { - DB::table(Table::TOKENS)->delete($tokenId); - - return true; + return app(\CraftCms\Cms\RouteToken\RouteTokens::class)->deleteTokenById($tokenId); } /** @@ -185,17 +110,6 @@ public function deleteTokenById(int $tokenId): bool */ public function deleteExpiredTokens(): bool { - // Ignore if we've already done this once during the request - if ($this->_deletedExpiredTokens) { - return false; - } - - $affectedRows = DB::table(Table::TOKENS) - ->where('expiryDate', '<=', now()) - ->delete(); - - $this->_deletedExpiredTokens = true; - - return (bool)$affectedRows; + return app(\CraftCms\Cms\RouteToken\RouteTokens::class)->deleteExpiredTokens(); } } diff --git a/yii2-adapter/legacy/web/UrlManager.php b/yii2-adapter/legacy/web/UrlManager.php index 40e43e64c95..e7c038f47f0 100644 --- a/yii2-adapter/legacy/web/UrlManager.php +++ b/yii2-adapter/legacy/web/UrlManager.php @@ -15,6 +15,7 @@ use craft\web\UrlRule as CraftUrlRule; use CraftCms\Cms\Cms; use CraftCms\Cms\Edition; +use CraftCms\Cms\RouteToken\RouteTokens; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\Sites; use yii\web\UrlRule as YiiUrlRule; @@ -563,6 +564,6 @@ private function _getTokenRoute(Request $request): array|false return false; } - return Craft::$app->getTokens()->getTokenRoute($token); + return app(RouteTokens::class)->getTokenRoute($token); } } diff --git a/yii2-adapter/src/Http/LegacyMiddleware.php b/yii2-adapter/src/Http/LegacyMiddleware.php index 0130c470719..767b3c7e098 100644 --- a/yii2-adapter/src/Http/LegacyMiddleware.php +++ b/yii2-adapter/src/Http/LegacyMiddleware.php @@ -51,6 +51,14 @@ public function handle(Request $request, Closure $next): mixed $this->restoreEmptyStrings($request); try { + /** @var \craft\web\Request $request */ + $request = Craft::$app->get('request'); + + // Remove any token as it was already handled by Laravel's HandleTokenRequest + $request->setToken(null); + + Craft::$app->set('request', $request); + /** * Reset the user as it could have been set before. */ diff --git a/yii2-adapter/tests/unit/services/TokenTest.php b/yii2-adapter/tests/unit/services/TokenTest.php deleted file mode 100644 index fb5d314948d..00000000000 --- a/yii2-adapter/tests/unit/services/TokenTest.php +++ /dev/null @@ -1,90 +0,0 @@ - - * @author Global Network Group | Giel Tettelaar - * @since 3.2 - */ -class TokenTest extends TestCase -{ - /** - * @var Tokens - */ - protected Tokens $token; - - /** - * @throws Exception - */ - public function testCreateToken(): void - { - $dt = (new DateTime('now', new DateTimeZone('UTC')))->add(new DateInterval('P1D')); - $token = $this->token->createToken('do/stuff', 1, $dt); - - // What actually exists now? - $tokenRec = Token::findOne(['token' => $token]); - - // And does it match - self::assertSame('do/stuff', $tokenRec->route); - self::assertSame(1, $tokenRec->usageLimit); - self::assertSame(0, $tokenRec->usageCount); - self::assertSame($dt->format('Y-m-d H:i:s'), $tokenRec->expiryDate); - self::assertEquals(32, strlen($token)); - - $tokenRec->delete(); - } - - /** - * @throws Exception - */ - public function testCreateTokenDefaults(): void - { - Cms::config()->defaultTokenDuration = 10000; - - // Determine what the expiry date is *supposed* to be - $expiryDate = (new DateTime('now', new DateTimeZone('UTC')))->add(new DateInterval('PT10000S')); - - // Create the token - $token = $this->token->createToken('do/stuff'); - self::assertSame(32, strlen($token)); - - // What actually exists now? - $tokenRec = Token::findOne(['token' => $token]); - - // And does it match - self::assertNull($tokenRec->usageLimit); - self::assertNull($tokenRec->usageCount); - self::assertSame($expiryDate->format('Y-m-d H:i:s'), $tokenRec->expiryDate); - - $tokenRec->delete(); - } - - /** - * @inheritdoc - */ - protected function _before(): void - { - parent::_before(); - - $this->token = Craft::$app->getTokens(); - } -}