Skip to content

Commit

Permalink
Merge pull request #16052 from craftcms/feature/cms-1346-impersonatin…
Browse files Browse the repository at this point in the history
…g-user-and-elevated-sessions

Feature/cms 1346 impersonating user and elevated sessions
  • Loading branch information
brandonkelly authored Nov 9, 2024
2 parents 3ec37c3 + f136922 commit f27f5a3
Show file tree
Hide file tree
Showing 5 changed files with 69 additions and 12 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG-WIP.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
- It’s now possible to control whether entry types’ Title fields are required. ([#15942](https://github.com/craftcms/cms/pull/15942))
- Added the “Step Size” Number field setting.
- Added the “Default View Mode” element source setting. ([#15824](https://github.com/craftcms/cms/pull/15824))
- User impersonation now requires an elevated session. ([#16052](https://github.com/craftcms/cms/pull/16052))
- Elevated session prompts now authenticate against the original user, when impersonating a user. ([#16052](https://github.com/craftcms/cms/pull/16052))
- Added several new icons.
- Added `pc/*` commands as an alias of `project-config/*`.
- Added the `resave/all` command.
Expand Down
40 changes: 37 additions & 3 deletions src/controllers/UsersController.php
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,11 @@ public function actionLogin(): ?Response
return $this->runAction('auth-form');
}

// if we're impersonating, pass the user we're impersonating to the complete method
if (Session::get(User::IMPERSONATE_KEY) !== null) {
$user = Craft::$app->getUser()->getIdentity() ?? $user;
}

return $this->_completeLogin($user, $duration);
}

Expand Down Expand Up @@ -277,6 +282,11 @@ public function actionLoginWithPasskey(): ?Response
return $this->_handleLoginFailure($user->authError, $user);
}

// if we're impersonating, pass the user we're impersonating to the complete method
if (Session::get(User::IMPERSONATE_KEY) !== null) {
$user = Craft::$app->getUser()->getIdentity();
}

return $this->_completeLogin($user, $duration);
}

Expand Down Expand Up @@ -340,6 +350,7 @@ private function _findLoginUser(string $loginName): ?User
public function actionImpersonate(): ?Response
{
$this->requirePostRequest();
$this->requireElevatedSession();

$userSession = Craft::$app->getUser();
$userId = $this->request->getRequiredBodyParam('userId');
Expand Down Expand Up @@ -378,6 +389,7 @@ public function actionImpersonate(): ?Response
public function actionGetImpersonationUrl(): Response
{
$this->requirePostRequest();
$this->requireElevatedSession();

$userId = $this->request->getBodyParam('userId');
$user = Craft::$app->getUsers()->getUserById($userId);
Expand Down Expand Up @@ -504,10 +516,23 @@ public function actionLoginModal(): Response
$this->requirePostRequest();
$this->requireCpRequest();

$forElevatedSession = (bool)$this->request->getBodyParam('forElevatedSession');

// If the current user is being impersonated get the "original" user instead
if ($forElevatedSession && $previousUserId = Session::get(User::IMPERSONATE_KEY)) {
/** @var User $user */
$user = User::find()
->id($previousUserId)
->one();
$staticEmail = $user->email;
} else {
$staticEmail = $this->request->getRequiredBodyParam('email');
}

$view = $this->getView();
$html = $view->renderTemplate('_special/login-modal.twig', [
'staticEmail' => $this->request->getRequiredBodyParam('email'),
'forElevatedSession' => (bool)$this->request->getBodyParam('forElevatedSession'),
'staticEmail' => $staticEmail,
'forElevatedSession' => $forElevatedSession,
]);

return $this->asJson([
Expand Down Expand Up @@ -2319,7 +2344,16 @@ private function _handleLoginFailure(?string $authError = null, ?User $user = nu

public function actionAuthForm(): Response
{
$activeMethods = Craft::$app->getAuth()->getActiveMethods();
$user = null;
// If the current user is being impersonated get the "original" user instead
if ($previousUserId = Session::get(User::IMPERSONATE_KEY)) {
/** @var User $user */
$user = User::find()
->id($previousUserId)
->one();
}

$activeMethods = Craft::$app->getAuth()->getActiveMethods($user);
$methodClass = $this->request->getParam('method');

if ($methodClass) {
Expand Down
19 changes: 11 additions & 8 deletions src/elements/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,7 @@ public static function findIdentity($id): ?self
return $user;
}

// If the current user is being impersonated by an admin, ignore their status
// If the current user is being impersonated, ignore their status
if ($previousUserId = Session::get(self::IMPERSONATE_KEY)) {
/** @var self|null $previousUser */
$previousUser = self::find()
Expand Down Expand Up @@ -1974,6 +1974,7 @@ protected function safeActionMenuItems(): array
'params' => [
'userId' => $this->id,
],
'requireElevatedSession' => true,
];

$copyImpersonationUrlId = sprintf('action-copy-impersonation-url-%s', mt_rand());
Expand All @@ -1985,13 +1986,15 @@ protected function safeActionMenuItems(): array

$view->registerJsWithVars(fn($id, $userId, $message) => <<<JS
$('#' + $id).on('activate', () => {
Craft.sendActionRequest('POST', 'users/get-impersonation-url', {
data: {userId: $userId},
}).then((response) => {
Craft.ui.createCopyTextPrompt({
label: $message,
value: response.data.url,
});
Craft.elevatedSessionManager.requireElevatedSession(() => {
Craft.sendActionRequest('POST', 'users/get-impersonation-url', {
data: {userId: $userId},
}).then((response) => {
Craft.ui.createCopyTextPrompt({
label: $message,
value: response.data.url,
});
});
});
});
JS, [
Expand Down
8 changes: 8 additions & 0 deletions src/services/Auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use craft\helpers\Component as ComponentHelper;
use craft\helpers\DateTimeHelper;
use craft\helpers\Json;
use craft\helpers\Session as SessionHelper;
use craft\models\UserGroup;
use craft\records\WebAuthn as WebAuthnRecord;
use craft\web\Session;
Expand Down Expand Up @@ -197,6 +198,13 @@ public function verify(string $methodClass, mixed ...$args): bool
// success!
if ($user) {
$this->setUser(null);

// if we're impersonating, pass the user we're impersonating to the complete the login
if (SessionHelper::get(User::IMPERSONATE_KEY) !== null) {
/** @var User $user */
$user = Craft::$app->getUser()->getIdentity();
}

Craft::$app->getUser()->login($user, $sessionDuration);
}

Expand Down
12 changes: 11 additions & 1 deletion src/web/assets/cp/CpAsset.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use craft\helpers\DateTimeHelper;
use craft\helpers\Html;
use craft\helpers\Json;
use craft\helpers\Session;
use craft\helpers\StringHelper;
use craft\helpers\UrlHelper;
use craft\i18n\Locale;
Expand Down Expand Up @@ -515,6 +516,15 @@ private function _craftData(): array
];
}

$impersonator = null;
// if we're impersonating, we need to check if the original user has passkey
if ($previousUserId = Session::get(User::IMPERSONATE_KEY)) {
/** @var User|null $impersonator */
$impersonator = User::find()
->id($previousUserId)
->one();
}

$data += [
'allowAdminChanges' => $generalConfig->allowAdminChanges,
'allowUpdates' => $generalConfig->allowUpdates,
Expand Down Expand Up @@ -550,7 +560,7 @@ private function _craftData(): array
'siteToken' => $generalConfig->siteToken,
'slugWordSeparator' => $generalConfig->slugWordSeparator,
'userEmail' => $currentUser->email,
'userHasPasskeys' => Craft::$app->getAuth()->hasPasskeys($currentUser),
'userHasPasskeys' => Craft::$app->getAuth()->hasPasskeys($impersonator ?? $currentUser),
'userIsAdmin' => $currentUser->admin,
'username' => $currentUser->username,
];
Expand Down

0 comments on commit f27f5a3

Please sign in to comment.