Skip to content

Commit

Permalink
"Copy impersonation URL" user action
Browse files Browse the repository at this point in the history
Resolves #7281
  • Loading branch information
brandonkelly committed Dec 17, 2020
1 parent cc4ef93 commit 024a71d
Show file tree
Hide file tree
Showing 8 changed files with 121 additions and 8 deletions.
1 change: 1 addition & 0 deletions CHANGELOG-v3.6.md
Expand Up @@ -8,6 +8,7 @@
- Craft now requires PHP 7.2.5 or later.
- Entries now begin life as “unpublished drafts” rather than “unsaved drafts”. They are no longer ephemeral; they will continue to exist until explicitly published or deleted. ([#5661](https://github.com/craftcms/cms/issues/5661), [#7216](https://github.com/craftcms/cms/issues/7216))
- It’s now possible to delete entries for a specific site, if their section’s propagation method is set to “Let each entry choose which sites it should be saved to”. ([#7190](https://github.com/craftcms/cms/issues/7190))
- Added the “Copy impersonation URL” user action, which generates a URL that can be pasted into a private window to impersonate the user without losing the current session. ([#7281](https://github.com/craftcms/cms/issues/7281))
- User indexes can now include a “Groups” column. ([#7211](https://github.com/craftcms/cms/issues/7211))
- Volumes now have “Title Translation Method” and “Title Translation Key Format” settings, like entry types. ([#7135](https://github.com/craftcms/cms/issues/7135))
- It’s now possible to set sites’ Name settings to environment variables.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -3,6 +3,7 @@
## Unreleased

### Added
- Added the “Copy impersonation URL” user action, which generates a URL that can be pasted into a private window to impersonate the user without losing the current session. ([#7281](https://github.com/craftcms/cms/issues/7281))
- Added `craft\console\Request::getHadToken()`.
- Added `craft\console\Request::setToken()`.
- Added `craft\web\Request::getHadToken()`.
Expand Down
98 changes: 92 additions & 6 deletions src/controllers/UsersController.php
Expand Up @@ -35,12 +35,14 @@
use craft\web\ServiceUnavailableHttpException;
use craft\web\UploadedFile;
use craft\web\View;
use DateTime;
use yii\base\InvalidArgumentException;
use yii\web\BadRequestHttpException;
use yii\web\ForbiddenHttpException;
use yii\web\HttpException;
use yii\web\NotFoundHttpException;
use yii\web\Response;
use yii\web\ServerErrorHttpException;

/** @noinspection ClassOverridesFieldOfSuperClassInspection */

Expand Down Expand Up @@ -105,6 +107,7 @@ class UsersController extends Controller
'session-info' => self::ALLOW_ANONYMOUS_LIVE | self::ALLOW_ANONYMOUS_OFFLINE,
'login' => self::ALLOW_ANONYMOUS_LIVE | self::ALLOW_ANONYMOUS_OFFLINE,
'logout' => self::ALLOW_ANONYMOUS_LIVE | self::ALLOW_ANONYMOUS_OFFLINE,
'impersonate-with-token' => self::ALLOW_ANONYMOUS_LIVE | self::ALLOW_ANONYMOUS_OFFLINE,
'save-user' => self::ALLOW_ANONYMOUS_LIVE,
'send-activation-email' => self::ALLOW_ANONYMOUS_LIVE | self::ALLOW_ANONYMOUS_OFFLINE,
'send-password-reset-email' => self::ALLOW_ANONYMOUS_LIVE | self::ALLOW_ANONYMOUS_OFFLINE,
Expand Down Expand Up @@ -183,6 +186,7 @@ public function actionLogin()
* Logs a user in for impersonation.
*
* @return Response|null
* @throws BadRequestHttpException
* @throws ForbiddenHttpException
*/
public function actionImpersonate()
Expand All @@ -191,15 +195,16 @@ public function actionImpersonate()

$userSession = Craft::$app->getUser();
$session = Craft::$app->getSession();
$userId = $this->request->getBodyParam('userId');
$userId = $this->request->getRequiredBodyParam('userId');
$user = Craft::$app->getUsers()->getUserById($userId);

// Make sure they're allowed to impersonate this user
$usersService = Craft::$app->getUsers();
$impersonatee = $usersService->getUserById($userId);
if (!$usersService->canImpersonate($userSession->getIdentity(), $impersonatee)) {
throw new ForbiddenHttpException('You do not have sufficient permissions to impersonate this user');
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
$session->set(User::IMPERSONATE_KEY, $userSession->getId());
Expand All @@ -214,6 +219,83 @@ public function actionImpersonate()
return $this->_handleSuccessfulLogin();
}

/**
* 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();

$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,
]
], 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|null $userId
* @return Response|null
* @throws BadRequestHttpException
* @throws ForbiddenHttpException
* @since 3.6.0
*/
public function actionImpersonateWithToken(?int $userId)
{
$this->requireToken();

$userSession = Craft::$app->getUser();

if (!$userSession->loginByUserId($userId)) {
$this->setFailFlash(Craft::t('app', '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();
}

/**
* 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(Craft::$app->getUser()->getIdentity(), $user)) {
throw new ForbiddenHttpException('You do not have sufficient permissions to impersonate this user');
}
}

/**
* Returns how many seconds are left in the current user session.
*
Expand Down Expand Up @@ -728,6 +810,10 @@ public function actionEditUser($userId = null, User $user = null, array $errors
'action' => 'users/impersonate',
'label' => Craft::t('app', 'Login as {user}', ['user' => $user->getName()])
];
$sessionActions[] = [
'id' => 'copy-impersonation-url',
'label' => Craft::t('app', 'Copy impersonation URL')
];
}

if ($userSession->checkPermission('moderateUsers') && $user->getStatus() != User::STATUS_SUSPENDED) {
Expand Down
2 changes: 2 additions & 0 deletions src/translations/en/app.php
Expand Up @@ -276,10 +276,12 @@
'Copied to clipboard.' => 'Copied to clipboard.',
'Copy URL' => 'Copy URL',
'Copy activation URL' => 'Copy activation URL',
'Copy impersonation URL' => 'Copy impersonation URL',
'Copy password reset URL' => 'Copy password reset URL',
'Copy reference tag' => 'Copy reference tag',
'Copy the URL' => 'Copy the URL',
'Copy the activation URL' => 'Copy the activation URL',
'Copy the impersonation URL, and open it in a new private window.' => 'Copy the impersonation URL, and open it in a new private window.',
'Copy the package’s name for this plugin.' => 'Copy the package’s name for this plugin.',
'Copy the reference tag' => 'Copy the reference tag',
'Copy to clipboard' => 'Copy to clipboard',
Expand Down
1 change: 1 addition & 0 deletions src/web/assets/edituser/EditUserAsset.php
Expand Up @@ -54,6 +54,7 @@ public function registerAssetFiles($view)
if ($view instanceof View) {
$view->registerTranslations('app', [
'Copy the activation URL',
'Copy the impersonation URL, and open it in a new private window.',
'Please enter your current password.',
'Please enter your password.',
]);
Expand Down
2 changes: 1 addition & 1 deletion src/web/assets/edituser/dist/AccountSettingsForm.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 024a71d

Please sign in to comment.