diff --git a/resources/templates/_special/login.twig b/resources/templates/_special/login.twig index caf63acd4f9..250b030be7f 100644 --- a/resources/templates/_special/login.twig +++ b/resources/templates/_special/login.twig @@ -5,7 +5,7 @@ {% set staticEmail = staticEmail ?? null %} {% set generalConfig = app.config.craft.general %} -{% set username = staticEmail ?? (generalConfig.rememberUsernameDuration ? craft.app.user.getRememberedUsername(): '') %} +{% set username = staticEmail ?? (generalConfig.rememberUsernameDuration ? rememberedUsername : '') %} {% if generalConfig.useEmailAsUsername %} {% set usernameLabel = 'Email'|t('app') %} diff --git a/resources/templates/set-password.twig b/resources/templates/set-password.twig index e0f5cfb89ad..d561286a64d 100644 --- a/resources/templates/set-password.twig +++ b/resources/templates/set-password.twig @@ -20,7 +20,7 @@ name: 'newPassword', autocomplete: 'new-password', autofocus: true, - errors: (errors is defined ? errors : null) + errors: errors.get('newPassword') }) }} diff --git a/routes/actions.php b/routes/actions.php index 7209cd83bcb..6a17f707bb0 100644 --- a/routes/actions.php +++ b/routes/actions.php @@ -5,7 +5,12 @@ use CraftCms\Cms\Cms; use CraftCms\Cms\Edition; use CraftCms\Cms\Http\Controllers\AddressesController; +use CraftCms\Cms\Http\Controllers\AnnouncementsController; use CraftCms\Cms\Http\Controllers\ApiController; +use CraftCms\Cms\Http\Controllers\Auth\LoginController; +use CraftCms\Cms\Http\Controllers\Auth\PasskeyController; +use CraftCms\Cms\Http\Controllers\Auth\SessionInfoController; +use CraftCms\Cms\Http\Controllers\Auth\TwoFactorAuthenticationController; use CraftCms\Cms\Http\Controllers\BaseUpdaterController; use CraftCms\Cms\Http\Controllers\ConfigSyncController; use CraftCms\Cms\Http\Controllers\Dashboard\Widgets\CraftSupportController; @@ -56,6 +61,7 @@ use CraftCms\Cms\Http\Middleware\RequireToken; use CraftCms\Cms\Support\Str; use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken; +use Illuminate\Session\Middleware\StartSession; use Illuminate\Support\Facades\Route; /** @@ -70,6 +76,29 @@ Cms::config()->cpTrigger.'/'.Cms::config()->actionTrigger.Str::start($route, '/'), ])->all()); +/** + * Actions that are accessible both with and without CP can be registered here. + */ +foreach ([ + Cms::config()->actionTrigger => [], + implode('/', [ + Cms::config()->cpTrigger, + Cms::config()->actionTrigger, + ]) => ['craft.cp'], +] as $prefix => $middleware) { + Route::prefix($prefix)->middleware($middleware)->group(function () { + // Auth + Route::post('users/login', [LoginController::class, 'attemptLogin']); + Route::post('auth/verify-totp', [TwoFactorAuthenticationController::class, 'verify']); + Route::post('auth/verify-recovery-code', [TwoFactorAuthenticationController::class, 'verifyRecoveryCode']); + Route::post('auth/passkey-request-options', [PasskeyController::class, 'requestOptions']); + Route::post('users/login-with-passkey', [PasskeyController::class, 'login']); + Route::post('users/login-modal', [LoginController::class, 'showLoginModal']); + Route::any('users/session-info', [SessionInfoController::class, 'show'])->withoutMiddleware(StartSession::class); + Route::any('users/get-elevated-session-timeout', [SessionInfoController::class, 'confirmTimeout']); + }); +} + /** * Actions that are accessible without CP can be registered here. */ @@ -276,6 +305,7 @@ Route::post('users/impersonate', [ImpersonationController::class, 'impersonate']); Route::post('users/get-impersonation-url', [ImpersonationController::class, 'getUrl']); }); + Route::post('users/mark-announcements-as-read', [AnnouncementsController::class, 'markRead']); Route::post('users/save-permissions', [PermissionsController::class, 'store']); Route::post('users/save-preferences', [PreferencesController::class, 'store']); diff --git a/routes/cp.php b/routes/cp.php index 4e55c703ee7..ecdfb130bba 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -2,7 +2,11 @@ declare(strict_types=1); +use CraftCms\Cms\Auth\Enums\CpAuthPath; use CraftCms\Cms\Edition; +use CraftCms\Cms\Http\Controllers\Auth\LoginController; +use CraftCms\Cms\Http\Controllers\Auth\SetPasswordController; +use CraftCms\Cms\Http\Controllers\Auth\TwoFactorAuthenticationController; use CraftCms\Cms\Http\Controllers\Dashboard\DashboardController; use CraftCms\Cms\Http\Controllers\Entries\CreateEntryController; use CraftCms\Cms\Http\Controllers\Entries\EntriesIndexController; @@ -39,10 +43,16 @@ Route::get('install', [InstallController::class, 'index']) ->middleware([HandleInertiaRequests::class]); +Route::get(CpAuthPath::Login->value, [LoginController::class, 'showLogin']); +Route::get(CpAuthPath::TwoFactorChallenge->value, [TwoFactorAuthenticationController::class, 'showForm']); +Route::get(CpAuthPath::SetPassword->value, [SetPasswordController::class, 'show']); +Route::post(CpAuthPath::SetPassword->value, [SetPasswordController::class, 'store']); + /** * Admin requests that require a login */ Route::middleware('auth:craft')->group(function () { + Route::get(CpAuthPath::Logout->value, [LoginController::class, 'logout']); Route::get('dashboard', DashboardController::class); Route::get('utilities', [UtilitiesController::class, 'index']); diff --git a/routes/routes.php b/routes/routes.php index 317da6bbe57..923fc37dfee 100644 --- a/routes/routes.php +++ b/routes/routes.php @@ -12,5 +12,5 @@ ->prefix(Cms::config()->cpTrigger) ->group(__DIR__.'/cp.php'); -Route::middleware(['web', 'craft']) +Route::middleware(['web', 'craft', 'craft.web']) ->group(__DIR__.'/web.php'); diff --git a/routes/web.php b/routes/web.php index b3d9bbc7f37..4278992994d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1 +1,32 @@ registersFrontendUserRoutes()) { + if (Cms::config()->loginPath !== false) { + Route::get(Cms::config()->loginPath, [LoginController::class, 'showLogin']); + Route::get(CpAuthPath::TwoFactorChallenge->value, [TwoFactorAuthenticationController::class, 'showForm']); + } + + Route::middleware('auth:craft')->group(function () { + if (Cms::config()->logoutPath !== false) { + Route::get(Cms::config()->logoutPath, [LoginController::class, 'logout']); + } + }); +} + +if (! is_null(Cms::config()->setPasswordRequestPath)) { + Route::get('.well-known/change-password', function (Sites $sites) { + $uri = Cms::config()->getSetPasswordRequestPath($sites->getCurrentSite()->handle); + + abort_if(is_null($uri), 404); + + return redirect($uri); + }); +} diff --git a/src/Auth/AuthServiceProvider.php b/src/Auth/AuthServiceProvider.php new file mode 100644 index 00000000000..3193b3bb7ce --- /dev/null +++ b/src/Auth/AuthServiceProvider.php @@ -0,0 +1,142 @@ +registerGuard(); + $this->registerPermissions(); + $this->registerEvents(); + } + + public function boot(): void + { + $this->bootRedirects(); + } + + private function bootRedirects(): void + { + Authenticate::redirectUsing(function (Request $request) { + if ($request->isCpRequest()) { + return Cms::config()->cpTrigger.'/login'; + } + + return Cms::config()->loginPath; + }); + + RedirectIfAuthenticated::redirectUsing(fn (Request $request) => URL::defaultReturnUrl()); + } + + private function registerGuard(): void + { + Auth::provider('craft', fn (Application $app) => new UserProvider($app->make(Hasher::class))); + + if (! Config::has('auth.guards.craft')) { + Config::set('auth.guards.craft', [ + 'driver' => 'session', + 'provider' => 'craft', + 'remember' => floor(Cms::config()->rememberedUserSessionDuration / 60), + ]); + } + + if (! Config::has('auth.providers.craft')) { + Config::set('auth.providers.craft', [ + 'driver' => 'craft', + 'model' => User::class, + ]); + } + } + + private function registerPermissions(): void + { + /** + * This hooks our permission system into + * Laravel's Gate authorization system + */ + Gate::after(function (Authorizable $user, string $ability, ?bool $result) { + if (! $user instanceof User) { + return null; + } + + /** + * Only check our permissions when the + * result was not explicitly set. + */ + if (! is_null($result)) { + return $result; + } + + if ( + $user->admin || + Edition::get() === Edition::Solo + ) { + return true; + } + + if (! isset($user->id)) { + return null; + } + + if (! app(UserPermissions::class)->doesUserHavePermission($user->id, $ability)) { + return null; + } + + return true; + }); + } + + private function registerEvents(): void + { + Event::listen(Login::class, function (Login $event) { + if (! $event->user instanceof User) { + return; + } + + Users::handleValidLogin($event->user); + + RememberedUsername::set($event->user); + + Session::passwordConfirmed(); + }); + + Event::listen(Failed::class, function (Failed $event) { + if (! $event->user instanceof User) { + return; + } + + Users::handleInvalidLogin($event->user); + }); + + Event::listen(Logout::class, function () { + app(Impersonation::class)->setImpersonatorId(null); + }); + } +} diff --git a/src/Auth/Enums/CpAuthPath.php b/src/Auth/Enums/CpAuthPath.php new file mode 100644 index 00000000000..4de488c1217 --- /dev/null +++ b/src/Auth/Enums/CpAuthPath.php @@ -0,0 +1,15 @@ +user = User::find() + * ->username($event->loginName) + * ->addSelect(['users.password', 'users.passwordResetRequired']) + * ->one(); + * } + * ); + * ``` + */ final class RetrievingLoginUser { public function __construct( diff --git a/src/Auth/Impersonation.php b/src/Auth/Impersonation.php new file mode 100644 index 00000000000..44dd89ee0b9 --- /dev/null +++ b/src/Auth/Impersonation.php @@ -0,0 +1,64 @@ +impersonator)) { + return $this->impersonator; + } + + $impersonatorId = $this->session->get(self::SESSION_KEY); + + if (! $impersonatorId) { + return null; + } + + $impersonator = User::find()->id($impersonatorId)->first(); + + if ($impersonator?->can('impersonateUsers')) { + return $this->impersonator = $impersonator; + } + + return null; + } + + public function getImpersonatorId(): ?int + { + return $this->getImpersonator()?->id; + } + + public function setImpersonatorId(?int $id): void + { + if ($id) { + $this->session->put(self::SESSION_KEY, $id); + + return; + } + + $this->session->forget(self::SESSION_KEY); + $this->impersonator = null; + } + + public function isImpersonating(): bool + { + return $this->getImpersonatorId() !== null; + } +} diff --git a/src/Auth/RememberedUsername.php b/src/Auth/RememberedUsername.php new file mode 100644 index 00000000000..23a9d6fe7b4 --- /dev/null +++ b/src/Auth/RememberedUsername.php @@ -0,0 +1,38 @@ +rememberUsernameDuration === 0) { + Cookie::unqueue(self::cookieName()); + Cookie::forget(self::cookieName()); + + return; + } + + Cookie::queue( + name: self::cookieName(), + value: $user->username, + minutes: floor(Cms::config()->rememberUsernameDuration / 60), + ); + } +} diff --git a/src/Auth/SessionAuth.php b/src/Auth/SessionAuth.php index c6a87a06819..66d42100c42 100644 --- a/src/Auth/SessionAuth.php +++ b/src/Auth/SessionAuth.php @@ -4,7 +4,6 @@ namespace CraftCms\Cms\Auth; -use CraftCms\Cms\Cms; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Session; @@ -27,7 +26,7 @@ final class SessionAuth public static function authorize(string $action): void { Cache::lock(self::AUTH_LOCK_NAME)->block(5, function () use ($action) { - $access = Session::get(self::authAccessParam(), []); + $access = Session::get(self::$authAccessParam, []); if (in_array($action, $access, true)) { return; @@ -35,7 +34,7 @@ public static function authorize(string $action): void $access[] = $action; - Session::put(self::authAccessParam(), $access); + Session::put(self::$authAccessParam, $access); }); } @@ -45,7 +44,7 @@ public static function authorize(string $action): void public static function deauthorize(string $action): void { Cache::lock(self::AUTH_LOCK_NAME)->block(5, function () use ($action) { - $access = Session::get(self::authAccessParam(), []); + $access = Session::get(self::$authAccessParam, []); $index = array_search($action, $access, true); if ($index === false) { @@ -54,7 +53,7 @@ public static function deauthorize(string $action): void array_splice($access, $index, 1); - Session::put(self::authAccessParam(), $access); + Session::put(self::$authAccessParam, $access); }); } @@ -63,15 +62,8 @@ public static function deauthorize(string $action): void */ public static function checkAuthorization(string $action): bool { - $access = Session::get(self::authAccessParam(), []); + $access = Session::get(self::$authAccessParam, []); return in_array($action, $access, true); } - - private static function authAccessParam(): string - { - $prefix = md5('CraftSession'.Cms::envId()); - - return $prefix.self::$authAccessParam; - } } diff --git a/src/Cms.php b/src/Cms.php index cbc199963b2..99a912b6668 100644 --- a/src/Cms.php +++ b/src/Cms.php @@ -41,9 +41,4 @@ public static function systemName(): string return $name ?: config('app.name', 'Craft'); } - - public static function envId(): string - { - return sprintf('%s--%s', self::systemName(), app()->environment()); - } } diff --git a/src/Config/GeneralConfig.php b/src/Config/GeneralConfig.php index f5445fb9e23..60fb7d7990d 100644 --- a/src/Config/GeneralConfig.php +++ b/src/Config/GeneralConfig.php @@ -2120,6 +2120,8 @@ class GeneralConfig extends BaseConfig * @see https://php.net/manual/en/function.session-name.php * * @group Session + * + * @deprecated 6.0.0 configure sessions using Laravel's session config instead. */ public string $phpSessionName = 'CraftSessionId'; @@ -2435,6 +2437,7 @@ class GeneralConfig extends BaseConfig * @defaultAlt 90 days * * @since 3.3.0 + * @deprecated 6.0.0 configure sessions using Laravel's session config instead. */ public mixed $purgeStaleUserSessionDuration = 7776000; @@ -2513,6 +2516,8 @@ class GeneralConfig extends BaseConfig * ::: * * @group Session + * + * @deprecated 6.0.0 */ public bool $requireMatchingUserAgentForSession = true; @@ -2529,6 +2534,8 @@ class GeneralConfig extends BaseConfig * ::: * * @group Session + * + * @deprecated 6.0.0 */ public bool $requireUserAgentAndIpForSession = true; @@ -3358,6 +3365,8 @@ class GeneralConfig extends BaseConfig * @group Session * * @defaultAlt 1 hour + * + * @deprecated 6.0.0 */ public mixed $userSessionDuration = 3600; @@ -3445,8 +3454,6 @@ class GeneralConfig extends BaseConfig */ public mixed $verifyEmailSuccessPath = ''; - protected ?DateInterval $_rememberedUserSessionDuration = null; - public function __construct() { // (Re-)normalize everything. @@ -3458,15 +3465,12 @@ public function __construct() ->cacheDuration($this->cacheDuration) ->cooldownDuration($this->cooldownDuration) ->defaultTokenDuration($this->defaultTokenDuration) - ->elevatedSessionDuration($this->elevatedSessionDuration) ->invalidLoginWindowDuration($this->invalidLoginWindowDuration) ->previewTokenDuration($this->previewTokenDuration ?? $this->defaultTokenDuration) ->purgePendingUsersDuration($this->purgePendingUsersDuration) ->purgeUnsavedDraftsDuration($this->purgeUnsavedDraftsDuration) ->rememberUsernameDuration($this->rememberUsernameDuration) - ->rememberedUserSessionDuration($this->rememberedUserSessionDuration) ->softDeleteDuration($this->softDeleteDuration) - ->userSessionDuration($this->userSessionDuration) ->verificationCodeDuration($this->verificationCodeDuration) // locales ->defaultCpLanguage($this->defaultCpLanguage) @@ -6117,10 +6121,8 @@ public function rasterizeSvgThumbs(bool $value = true): self * @throws InvalidConfigException * * @see $rememberedUserSessionDuration - * @see getRememberedUserSessionDuration() * @since 4.2.0 */ - #[Deprecated(message: 'in 6.0.0. Configure `auth.guards.web.remember` in minutes instead.')] public function rememberedUserSessionDuration(mixed $value): self { // Store the DateInterval separately for getRememberedUserSessionDuration() @@ -6131,10 +6133,12 @@ public function rememberedUserSessionDuration(mixed $value): self } $this->rememberedUserSessionDuration = $interval ? ConfigHelper::durationInSeconds($interval) : 0; - $this->_rememberedUserSessionDuration = $interval ?: null; app()->booting(function () { - Config::set('auth.guards.web.remember', floor($this->rememberedUserSessionDuration / 60)); + Config::set( + 'auth.guards.craft.remember', + floor($this->rememberedUserSessionDuration / 60), + ); }); return $this; @@ -7165,6 +7169,7 @@ public function useSslOnTokenizedUrls(string|bool $value): self * * @see $userSessionDuration */ + #[Deprecated(message: "configure sessions using Laravel's session config instead.", since: '6.0.0')] public function userSessionDuration(mixed $value): self { $this->userSessionDuration = ConfigHelper::durationInSeconds($value); @@ -7385,7 +7390,9 @@ public function getPostLogoutRedirect(?string $siteHandle = null): string */ public function getRememberedUserSessionDuration(): ?DateInterval { - return $this->_rememberedUserSessionDuration ?: null; + return $this->rememberedUserSessionDuration > 0 + ? DateTimeHelper::toDateInterval($this->rememberedUserSessionDuration) + : null; } /** diff --git a/src/Database/Migrations/Install.php b/src/Database/Migrations/Install.php index 91c3dbe3411..455e18ca291 100644 --- a/src/Database/Migrations/Install.php +++ b/src/Database/Migrations/Install.php @@ -623,14 +623,16 @@ public function createTables(): void $table->char('uid', 36)->default('0'); }); - Schema::create('sessions', function (Blueprint $table) { - $table->integer('id', true); - $table->integer('userId'); - $table->char('token', 100); - $table->dateTime('dateCreated'); - $table->dateTime('dateUpdated'); - $table->char('uid', 36)->default('0'); - }); + if (! Schema::hasTable('sessions')) { + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } Schema::create('shunnedmessages', function (Blueprint $table) { $table->integer('id', true); @@ -912,10 +914,6 @@ public function createIndexes(): void Schema::createIndex(Table::SECTIONS, ['dateDeleted']); Schema::createIndex(Table::SECTIONS_SITES, ['sectionId', 'siteId'], unique: true); Schema::createIndex(Table::SECTIONS_SITES, ['siteId']); - Schema::createIndex(Table::SESSIONS, ['uid']); - Schema::createIndex(Table::SESSIONS, ['token']); - Schema::createIndex(Table::SESSIONS, ['dateUpdated']); - Schema::createIndex(Table::SESSIONS, ['userId']); Schema::createIndex(Table::SHUNNEDMESSAGES, ['userId', 'message'], unique: true); Schema::createIndex(Table::SITES, ['dateDeleted']); Schema::createIndex(Table::SITES, ['handle']); @@ -1046,7 +1044,6 @@ public function addForeignKeys(): void Schema::table(Table::SECTIONS_ENTRYTYPES, fn (Blueprint $table) => $table->foreign('typeId')->references('id')->on(Table::ENTRYTYPES)->cascadeOnDelete()); Schema::table(Table::SECTIONS_SITES, fn (Blueprint $table) => $table->foreign('siteId')->references('id')->on(Table::SITES)->cascadeOnDelete()->cascadeOnUpdate()); Schema::table(Table::SECTIONS_SITES, fn (Blueprint $table) => $table->foreign('sectionId')->references('id')->on(Table::SECTIONS)->cascadeOnDelete()); - Schema::table(Table::SESSIONS, fn (Blueprint $table) => $table->foreign('userId')->references('id')->on(Table::USERS)->cascadeOnDelete()); Schema::table(Table::SHUNNEDMESSAGES, fn (Blueprint $table) => $table->foreign('userId')->references('id')->on(Table::USERS)->cascadeOnDelete()); Schema::table(Table::SITES, fn (Blueprint $table) => $table->foreign('groupId')->references('id')->on(Table::SITEGROUPS)->cascadeOnDelete()); Schema::table(Table::SSO_IDENTITIES, fn (Blueprint $table) => $table->foreign('userId')->references('id')->on(Table::USERS)->cascadeOnDelete()); diff --git a/src/Edition.php b/src/Edition.php index f4e3e9c827f..1285ed9e378 100644 --- a/src/Edition.php +++ b/src/Edition.php @@ -175,6 +175,11 @@ public static function require(Edition|int $edition, bool $orBetter = true): voi } } + public function registersFrontendUserRoutes(): bool + { + return $this->value >= self::Pro->value; + } + public function toArray(): array { return [ diff --git a/src/Field/Field.php b/src/Field/Field.php index 9f6d8665719..fb562e46c9e 100644 --- a/src/Field/Field.php +++ b/src/Field/Field.php @@ -593,7 +593,7 @@ protected function actionMenuItems(): array return $items; } - if (! Craft::$app->getUser()->getIsAdmin()) { + if (! Auth::user()?->isAdmin()) { return $items; } diff --git a/src/Http/Controllers/AnnouncementsController.php b/src/Http/Controllers/AnnouncementsController.php new file mode 100644 index 00000000000..a5862bab3fd --- /dev/null +++ b/src/Http/Controllers/AnnouncementsController.php @@ -0,0 +1,27 @@ +validate([ + 'ids' => ['required', 'array'], + 'ids.*' => ['int'], + ]); + + $announcements->markAsRead($request->array('ids')); + + return $this->asSuccess(); + } +} diff --git a/src/Http/Controllers/Auth/AuthenticationController.php b/src/Http/Controllers/Auth/AuthenticationController.php new file mode 100644 index 00000000000..ce26afc8eda --- /dev/null +++ b/src/Http/Controllers/Auth/AuthenticationController.php @@ -0,0 +1,182 @@ +login($user, $remember); + + return $this->handleSuccessfulLogin($request, $user); + } + + protected function handleSuccessfulLogin(Request $request, User $user): Response + { + $returnUrl = URL::returnUrl(); + + if ($request->wantsJson()) { + return $this->asModelSuccess($user, modelName: 'user', data: [ + 'returnUrl' => $returnUrl, + ]); + } + + return $this->redirectToPostedUrl($user, $returnUrl); + } + + protected function handleLoginFailure(Request $request, ?string $authError = null, ?User $user = null): Response + { + [$authError, $message] = UserHelper::getLoginFailureInfo($authError, $user); + + Event::dispatch(new Failed( + guard: 'craft', + user: $user, + credentials: $request->only('loginName', 'password'), + )); + + return $this->asFailure($message, ['errorCode' => $authError]); + } + + protected function renderViewWithFallback(string $cpTemplate, array $data = []): View + { + if (view()->exists(request()->path())) { + return view(request()->path(), $data); + } + + Craft::$app->getView()->setTemplateMode(\craft\web\View::TEMPLATE_MODE_CP); + + return view(Str::start($cpTemplate, 'craftcms::'), $data); + } + + protected function processTokenRequest(Request $request): Response|array + { + $request->validate([ + 'id' => ['required'], + 'code' => ['required'], + ]); + + /** @var User|null $user */ + $user = User::find() + ->uid($request->input('id')) + ->status(null) + ->addSelect(['users.password']) + ->one(); + + if (! $user) { + return $this->processInvalidToken($request); + } + + // If someone is logged in and it’s not this person, log them out + if ($request->user() && $request->user()->id !== $user->id) { + Auth::logout(); + } + + if (Event::hasListeners(VerifyingEmail::class)) { + Event::dispatch(new VerifyingEmail($user)); + } + + if (! Users::isVerificationCodeValidForUser($user, $request->input('code'))) { + return $this->processInvalidToken($request, $user); + } + + if (Event::hasListeners(EmailVerified::class)) { + Event::dispatch(new EmailVerified($user)); + } + + return [$user, $request->input('id'), $request->input('code')]; + } + + protected function processInvalidToken(Request $request, ?User $user = null): Response + { + Event::dispatch(new InvalidUserToken($user)); + + if ($request->wantsJson()) { + return $this->asFailure('InvalidVerificationCode'); + } + + // If they don't have a verification code at all, and they're already logged-in, just send them to the post-login URL + if ($user && ! $user->verificationCode && ! Auth::guest()) { + return redirect(URL::returnUrl()); + } + + // If the invalidUserTokenPath config setting is set, send them there + if (! $request->isCpRequest()) { + $url = Cms::config()->getInvalidUserTokenPath() ?? Cms::config()->getLoginPath(); + + return redirect(UrlHelper::siteUrl($url)); + } + + return redirect(CpAuthPath::Login->value); + } + + protected function onAfterActivateUser(Request $request, User $user): ?Response + { + $this->maybeLoginUserAfterAccountActivation($user); + + if ($request->wantsJson()) { + return $this->redirectUserToCp($user) ?? $this->redirectUserAfterAccountActivation($user); + } + + return null; + } + + protected function maybeLoginUserAfterAccountActivation(User $user): bool + { + if (! Cms::config()->autoLoginAfterAccountActivation) { + return false; + } + + Auth::login($user); + + return true; + } + + protected function redirectUserToCp(User $user): ?Response + { + if (! $user->can('accessCp')) { + return null; + } + + $postCpLoginRedirect = Cms::config()->getPostCpLoginRedirect(); + $url = UrlHelper::cpUrl($postCpLoginRedirect); + + return redirect($url); + } + + protected function redirectUserAfterAccountActivation(User $user): Response + { + $activateAccountSuccessPath = Cms::config()->getActivateAccountSuccessPath(); + $url = UrlHelper::siteUrl($activateAccountSuccessPath); + + return $this->redirectToPostedUrl($user, $url); + } +} diff --git a/src/Http/Controllers/Auth/LoginController.php b/src/Http/Controllers/Auth/LoginController.php new file mode 100644 index 00000000000..8f68576c549 --- /dev/null +++ b/src/Http/Controllers/Auth/LoginController.php @@ -0,0 +1,139 @@ +user()) { + return $this->handleSuccessfulLogin($request, $user); + } + + // should we be showing the 2FA form? + if ($request->input('verify')) { + return redirect()->action([TwoFactorAuthenticationController::class, 'showForm']); + } + + return $this->renderViewWithFallback('login'); + } + + public function showLoginModal(Request $request, Impersonation $impersonation): JsonResponse + { + $forElevatedSession = $request->boolean('forElevatedSession'); + + // If the current user is being impersonated, get the impersonator instead + if ($forElevatedSession && ($impersonator = $impersonation->getImpersonator())) { + $staticEmail = $impersonator->email; + } else { + $staticEmail = $request->validate(['email' => ['required']])['email']; + } + + $view = Craft::$app->getView(); + $html = $view->renderTemplate('_special/login-modal.twig', [ + 'staticEmail' => $staticEmail, + 'forElevatedSession' => $forElevatedSession, + ], View::TEMPLATE_MODE_CP); + + return new JsonResponse([ + 'html' => $html, + 'headHtml' => $view->getHeadHtml(), + 'bodyHtml' => $view->getBodyHtml(), + ]); + } + + public function attemptLogin(Request $request, Impersonation $impersonation): Response + { + $request->validate([ + 'loginName' => ['required', 'string'], + 'password' => ['required', 'string'], + 'rememberMe' => ['nullable'], + ]); + + /** @var \Illuminate\Auth\SessionGuard $guard */ + $guard = Auth::guard('craft'); + /** @var \CraftCms\Cms\Auth\UserProvider $provider */ + $provider = $guard->getProvider(); + + $user = $provider->retrieveByCredentials($request->only('loginName', 'password')); + + return new Timebox()->call(function () use ($request, $provider, $user, $impersonation) { + if (! $user || $user->password === null) { + return $this->handleLoginFailure($request, User::AUTH_INVALID_CREDENTIALS); + } + + if (! $provider->validateCredentials($user, ['password' => $request->input('password')])) { + return $this->handleLoginFailure($request, $user->authError, $user); + } + + // Valid credentials + if (config('hashing.rehash_on_login', true)) { + $provider->rehashPasswordIfRequired($user, ['password' => $request->input('password')]); + } + + $authService = Craft::$app->getAuth(); + if (! $this->generalConfig->disable2fa && $authService->hasActiveMethod($user)) { + $request->session()->put('user.id', $user->id); + + if (! $request->isCpRequest() && ! $request->wantsJson()) { + $loginPath = $this->generalConfig->getLoginPath(); + + if (! $loginPath) { + $request->session()->forget('user.id'); + throw new RuntimeException('User requires two-step verification, but the loginPath config setting is disabled.'); + } + + return redirect(UrlHelper::siteUrl($loginPath, array_filter([ + 'verify' => 1, + 'returnUrl' => $this->getPostedRedirectUrl($user), + ]))); + } + + return redirect()->action([TwoFactorAuthenticationController::class, 'showForm']); + } + + // if we're impersonating, pass the user we're impersonating to the complete method + if ($impersonation->isImpersonating()) { + $user = Auth::user() ?? $user; + } + + return $this->completeLogin($request, $user, $request->boolean('rememberMe')); + }, 30_000); + } + + public function logout(Request $request): Response + { + Auth::guard('craft')->logout(); + + if ($request->wantsJson()) { + return $this->asSuccess(); + } + + // Redirect to the login page if this is a control panel request + if ($request->isCpRequest()) { + return redirect(cp_url(CpAuthPath::Login->value)); + } + + return $this->asSuccess( + redirect: $this->generalConfig->getPostLogoutRedirect(), + ); + } +} diff --git a/src/Http/Controllers/Auth/PasskeyController.php b/src/Http/Controllers/Auth/PasskeyController.php new file mode 100644 index 00000000000..c6b2ee71f07 --- /dev/null +++ b/src/Http/Controllers/Auth/PasskeyController.php @@ -0,0 +1,60 @@ + Craft::$app->getAuth()->getPasskeyRequestOptions(), + ]); + } + + public function login(Request $request, Impersonation $impersonation): Response + { + $request->validate([ + 'requestOptions' => ['required'], + 'response' => ['required'], + ]); + + $requestOptions = $request->input('requestOptions'); + $response = $request->input('response'); + $credential = WebAuthn::where('credentialId', Json::decode($response)['id'])->first(); + + if ($credential === null) { + return $this->asFailure(t('Passkey authentication failed.')); + } + + $user = User::findOne(['id' => $credential->userId]); + + if ($user === null) { + return $this->handleLoginFailure($request); + } + + if (! $user->authenticateWithPasskey($requestOptions, $response)) { + return $this->handleLoginFailure($request, $user->authError, $user); + } + + // if we're impersonating, pass the user we're impersonating to the complete method + if ($impersonation->isImpersonating()) { + $user = Auth::user(); + } + + return $this->completeLogin($request, $user, true); + } +} diff --git a/src/Http/Controllers/Auth/SessionInfoController.php b/src/Http/Controllers/Auth/SessionInfoController.php new file mode 100644 index 00000000000..7d99a6c07f9 --- /dev/null +++ b/src/Http/Controllers/Auth/SessionInfoController.php @@ -0,0 +1,44 @@ + $request->user() === null, + ]; + + if ($generalConfig->enableCsrfProtection) { + $data['csrfTokenName'] = Cms::config()->csrfTokenName; + $data['csrfTokenValue'] = csrf_token(); + } + + if ($user = $request->user()) { + $data['id'] = $user->id; + $data['uid'] = $user->uid; + $data['username'] = $user->username; + $data['email'] = $user->email; + } + + return new JsonResponse($data); + } + + public function confirmTimeout(): JsonResponse + { + return new JsonResponse([ + 'timeout' => $this->confirmedPasswordTimeout(), + ]); + } +} diff --git a/src/Http/Controllers/Auth/SetPasswordController.php b/src/Http/Controllers/Auth/SetPasswordController.php new file mode 100644 index 00000000000..1a7a047dfba --- /dev/null +++ b/src/Http/Controllers/Auth/SetPasswordController.php @@ -0,0 +1,115 @@ +processTokenRequest($request))) { + return $info; + } + + /** + * @var User $user + * @var string $uid + * @var string $code + */ + [$user, $uid, $code] = $info; + + RememberedUsername::set($user); + + // Send them to the set password template. + return $this->renderViewWithFallback('set-password', [ + 'code' => $code, + 'id' => $uid, + 'newUser' => ! $user->password, + ]); + } + + public function store(Request $request, Users $users): Response|View + { + $request->validate([ + 'code' => ['required'], + 'id' => ['required'], + 'newPassword' => ['required', Password::default()], + ]); + + $user = User::find() + ->uid($request->input('id')) + ->status(null) + ->addSelect('users.password') + ->first(); + + abort_if(is_null($user), 400, 'Invalid user UUID: '.$request->input('id')); + + if (! $users->isVerificationCodeValidForUser($user, $request->input('code'))) { + return $this->processInvalidToken($request, $user); + } + + $user->newPassword = $request->input('newPassword'); + $user->setScenario(User::SCENARIO_PASSWORD); + + if (! Craft::$app->getElements()->saveElement($user)) { + if ($request->wantsJson()) { + return $this->asFailure( + t('Couldn’t update password.'), + $user->getErrors('newPassword'), + ); + } + + return $this->renderViewWithFallback('set-password', [ + 'errors' => $user->getErrors('newPassword'), + 'code' => $request->input('code'), + 'id' => $request->input('id'), + 'newUser' => ! $user->password, + ]); + } + + // If they're pending, try to activate them, and maybe treat this as an activation request + if ($user->getStatus() === User::STATUS_PENDING) { + try { + $users->activateUser($user); + if ($response = $this->onAfterActivateUser($request, $user)) { + return $response; + } + } catch (InvalidElementException) { + // NBD + } + } + + if ($request->wantsJson()) { + return $this->asSuccess(data: [ + 'status' => $user->getStatus(), + ]); + } + + if ($request->isCpRequest()) { + // Send them to the control panel login page by default + $url = UrlHelper::cpUrl(CpAuthPath::Login->value); + } else { + // Send them to the 'setPasswordSuccessPath' by default + $setPasswordSuccessPath = Cms::config()->getSetPasswordSuccessPath(); + $url = UrlHelper::siteUrl($setPasswordSuccessPath); + } + + return $this->redirectToPostedUrl($user, $url); + } +} diff --git a/src/Http/Controllers/Auth/TwoFactorAuthenticationController.php b/src/Http/Controllers/Auth/TwoFactorAuthenticationController.php new file mode 100644 index 00000000000..85d23d55bb8 --- /dev/null +++ b/src/Http/Controllers/Auth/TwoFactorAuthenticationController.php @@ -0,0 +1,138 @@ +getImpersonator() + ?? User::find()->id($request->session()->get('user.id'))->first(); + + if (! $user) { + if ($request->isSiteRequest()) { + if (! $loginPath = $this->generalConfig->getLoginPath()) { + throw new RuntimeException('The loginPath config setting is disabled.'); + } + + return redirect($loginPath); + } + + return redirect(CpAuthPath::Login->value); + } + + $activeMethods = Craft::$app->getAuth()->getActiveMethods($user); + $methodClass = $request->input('method'); + + if ($methodClass) { + /** @var AuthMethodInterface|null $method */ + $method = Arr::first( + $activeMethods, + fn (AuthMethodInterface $method) => $method::class === $methodClass, + ); + + abort_if(! $method, 400, 'Invalid method class: '.$methodClass); + + $activeMethods = array_values(array_filter($activeMethods, fn ($m) => $m !== $method)); + } else { + abort_if(empty($activeMethods), 400, 'User has no active two-step verification methods.'); + + $method = array_shift($activeMethods); + } + + $view = Craft::$app->getView(); + $templateMode = $view->getTemplateMode(); + $view->setTemplateMode(View::TEMPLATE_MODE_CP); + try { + $html = $method->getAuthFormHtml(); + } finally { + $view->setTemplateMode($templateMode); + } + + $returnUrl = $request->input('returnUrl'); + if (! $returnUrl) { + if ($request->isCpRequest()) { + // explicitly set the default return URL here, since checkPermission('accessCp') will be false + $defaultReturnUrl = UrlHelper::cpUrl($this->generalConfig->getPostCpLoginRedirect()); + } else { + $defaultReturnUrl = UrlHelper::siteUrl($this->generalConfig->getPostLoginRedirect()); + } + + $returnUrl = URL::returnUrl($defaultReturnUrl); + } + + $authFormData = [ + 'authMethod' => $method::class, + 'otherMethods' => array_map(fn (AuthMethodInterface $method) => [ + 'name' => $method::displayName(), + 'class' => $method::class, + ], $activeMethods), + 'authForm' => $html, + 'returnUrl' => $returnUrl, + ]; + + if ($request->wantsJson()) { + return new JsonResponse([ + ...$authFormData, + 'headHtml' => $view->getHeadHtml(), + 'bodyHtml' => $view->getBodyHtml(), + ]); + } + + return $view->renderTemplate('login.twig', compact('authFormData'), View::TEMPLATE_MODE_CP); + } + + public function verify(Request $request): Response + { + $code = $request->input('code'); + + $authService = Craft::$app->getAuth(); + + if (! $authService->verify(TOTP::class, $code)) { + return $this->asFailure($authService->getAuthErrorMessage()); + } + + return $this->asSuccess(t('Verification successful.')); + } + + public function verifyRecoveryCode(Request $request): Response + { + $code = $request->input('code'); + + $authService = Craft::$app->getAuth(); + + if (! $authService->verify(RecoveryCodes::class, $code)) { + return $this->asFailure($authService->getAuthErrorMessage(t('Invalid recovery code.'))); + } + + return $this->asSuccess(t('Verification successful.')); + } +} diff --git a/src/Http/Controllers/Users/ImpersonationController.php b/src/Http/Controllers/Users/ImpersonationController.php index 2d7b86f995a..2b9c99cbb18 100644 --- a/src/Http/Controllers/Users/ImpersonationController.php +++ b/src/Http/Controllers/Users/ImpersonationController.php @@ -4,7 +4,7 @@ namespace CraftCms\Cms\Http\Controllers\Users; -use Craft; +use CraftCms\Cms\Auth\Impersonation; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\Support\Flash; @@ -15,6 +15,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\URL; use Illuminate\Validation\Rule; use Symfony\Component\HttpFoundation\Response; use Throwable; @@ -28,6 +29,7 @@ public function __construct( private Request $request, private Users $users, + private Impersonation $impersonation, ) {} public function impersonate(): Response @@ -46,10 +48,10 @@ public function impersonate(): Response $this->enforceImpersonatePermission($user); - Craft::$app->getUser()->setImpersonatorId($this->request->user()->id); + $this->impersonation->setImpersonatorId($this->request->user()->id); try { - Auth::login($user); + Auth::guard('craft')->login($user); } catch (Throwable) { Flash::fail(t('There was a problem impersonating this user.')); @@ -97,10 +99,10 @@ public function withToken(): Response /** @var User $user */ $user = $this->users->getUserById($userId); - Craft::$app->getUser()->setImpersonatorId($prevUserId); + $this->impersonation->setImpersonatorId($prevUserId); try { - Auth::login($user); + Auth::guard('craft')->login($user); } catch (Throwable) { Flash::fail(t('There was a problem impersonating this user.')); @@ -113,11 +115,7 @@ public function withToken(): Response private function handleSuccessfulLogin(User $user): Response { // Get the return URL - $userSession = Craft::$app->getUser(); - $returnUrl = $userSession->getReturnUrl(); - - // Clear it out - $userSession->removeReturnUrl(); + $returnUrl = URL::returnUrl(); // If this was an Ajax request, just return success:true if ($this->request->wantsJson()) { diff --git a/src/Http/Controllers/Users/UnlockController.php b/src/Http/Controllers/Users/UnlockController.php index 5c654ea00d0..ce4ab0e675a 100644 --- a/src/Http/Controllers/Users/UnlockController.php +++ b/src/Http/Controllers/Users/UnlockController.php @@ -4,7 +4,7 @@ namespace CraftCms\Cms\Http\Controllers\Users; -use Craft; +use CraftCms\Cms\Auth\Impersonation; use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\User\Users; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -17,7 +17,7 @@ use AuthorizesRequests; use RespondsWithFlash; - public function __invoke(Request $request, Users $users) + public function __invoke(Request $request, Users $users, Impersonation $impersonation) { $this->authorize('moderateUsers'); @@ -31,7 +31,7 @@ public function __invoke(Request $request, Users $users) if ($user->admin) { abort_if(! $request->user()->isAdmin(), 403, 'Only admins can unlock other admins.'); - abort_if($user->id === Craft::$app->getUser()->getImpersonatorId(), 403, 'You can’t unlock yourself via impersonation.'); + abort_if($user->id === $impersonation->getImpersonatorId(), 403, 'You can’t unlock yourself via impersonation.'); } $users->unlockUser($user); diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index 664e8a58a0d..30d01331510 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -6,6 +6,7 @@ use Craft; use craft\helpers\FileHelper; +use craft\helpers\UrlHelper; use CraftCms\Aliases\Aliases; use CraftCms\Cms\Cms; use CraftCms\Cms\Edition; @@ -15,7 +16,6 @@ use CraftCms\Cms\Support\Env; use CraftCms\Cms\Support\Facades\Updates; use GuzzleHttp\Utils; -use Illuminate\Auth\Middleware\Authenticate; use Illuminate\Contracts\Config\Repository as ConfigRepository; use Illuminate\Foundation\Application; use Illuminate\Foundation\Console\AboutCommand; @@ -24,10 +24,15 @@ use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Routing\UrlGenerator; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Redirect; +use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; use IntlDateFormatter; use IntlException; @@ -35,6 +40,7 @@ use ReflectionClass; use RuntimeException; +use function CraftCms\Cms\action_url; use function CraftCms\Cms\t; final class AppServiceProvider extends ServiceProvider @@ -45,14 +51,6 @@ final class AppServiceProvider extends ServiceProvider public function register(): void { $this->registerMacros(); - - Authenticate::redirectUsing(function () { - if (\request()->isCpRequest()) { - return Cms::config()->cpTrigger.'/login'; - } - - return Cms::config()->loginPath; - }); } public function boot(): void @@ -210,6 +208,27 @@ private function registerMacros(): void return $this; }); + UrlGenerator::macro('defaultReturnUrl', function (): string { + if (request()->isCpRequest() && Gate::check('accessCp')) { + return UrlHelper::cpUrl(Cms::config()->getPostCpLoginRedirect()); + } + + return UrlHelper::siteUrl(Cms::config()->getPostLoginRedirect()); + }); + + UrlGenerator::macro('returnUrl', function (?string $defaultUrl = null): string { + $defaultUrl ??= Auth::guard('craft')->guest() + ? action_url('users/redirect') + : $this->defaultReturnUrl(); + + $url = Redirect::getIntendedUrl() ?? $defaultUrl; + + // Strip out any tags that may have gotten in there by accident + // i.e. if there was a {siteUrl} tag in the Site URL setting, but no matching environment variable, + // so they ended up on something like http://example.com/%7BsiteUrl%7D/some/path + return str_replace(['{', '}'], '', $url); + }); + Factory::macro('create', fn (array $options = []) => $this->throw() ->withUserAgent('Craft/'.Cms::VERSION.' '.Utils::defaultUserAgent()) ->when( diff --git a/src/Providers/CraftServiceProvider.php b/src/Providers/CraftServiceProvider.php index 713cc782804..994361e8a9d 100644 --- a/src/Providers/CraftServiceProvider.php +++ b/src/Providers/CraftServiceProvider.php @@ -5,6 +5,7 @@ namespace CraftCms\Cms\Providers; use CraftCms\Cms\Asset\AssetServiceProvider; +use CraftCms\Cms\Auth\AuthServiceProvider; use CraftCms\Cms\Config\ConfigServiceProvider; use CraftCms\Cms\Console\ConsoleServiceProvider; use CraftCms\Cms\Database\DatabaseServiceProvider; @@ -27,6 +28,7 @@ final class CraftServiceProvider extends AggregateServiceProvider { protected $providers = [ ConfigServiceProvider::class, + AuthServiceProvider::class, FilesystemServiceProvider::class, TranslationServiceProvider::class, DatabaseServiceProvider::class, diff --git a/src/Route/RouteServiceProvider.php b/src/Route/RouteServiceProvider.php index 2fd15a214d8..891a03734a9 100644 --- a/src/Route/RouteServiceProvider.php +++ b/src/Route/RouteServiceProvider.php @@ -21,6 +21,7 @@ use CraftCms\Cms\Support\Facades\ProjectConfig; use Illuminate\Contracts\Http\Kernel as HttpKernel; use Illuminate\Routing\Router; +use Illuminate\Session\Middleware\AuthenticateSession; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; use Override; @@ -87,6 +88,7 @@ private function bootMiddleware(Router $router): void collect([ 'web', + AuthenticateSession::class, ])->each(fn ($middleware) => $router->pushMiddlewareToGroup('craft.web', $middleware)); } } diff --git a/src/Twig/Engine.php b/src/Twig/Engine.php index 11da3504fd2..697347f95ad 100644 --- a/src/Twig/Engine.php +++ b/src/Twig/Engine.php @@ -5,13 +5,14 @@ namespace CraftCms\Cms\Twig; use Craft; +use craft\helpers\FileHelper; use CraftCms\Cms\Support\Str; class Engine implements \Illuminate\Contracts\View\Engine { public function get($path, array $data = []): string { - $template = Str::after($path, 'templates/'); + $template = Str::after(FileHelper::normalizePath($path), Craft::$app->getView()->getTemplatesPath()); return Craft::$app->getView()->renderPageTemplate($template, $data); } diff --git a/src/User/Commands/LogoutAllCommand.php b/src/User/Commands/LogoutAllCommand.php index c87cdb31e38..b7e96f03af0 100644 --- a/src/User/Commands/LogoutAllCommand.php +++ b/src/User/Commands/LogoutAllCommand.php @@ -5,9 +5,8 @@ namespace CraftCms\Cms\User\Commands; use CraftCms\Cms\Console\CraftCommand; -use CraftCms\Cms\Database\Table; use Illuminate\Console\Command; -use Illuminate\Support\Facades\DB; +use Illuminate\Session\SessionManager; final class LogoutAllCommand extends Command { @@ -19,11 +18,11 @@ final class LogoutAllCommand extends Command protected $aliases = ['users/logout-all', 'users/logoutAll', 'users:logoutAll']; - public function handle(): void + public function handle(SessionManager $sessionManager): void { $this->components->task( 'Logging out all users', - fn () => DB::table(Table::SESSIONS)->truncate(), + fn () => $sessionManager->getHandler()->gc(0), ); } } diff --git a/src/User/Elements/User.php b/src/User/Elements/User.php index e6a841af854..e687347cef5 100644 --- a/src/User/Elements/User.php +++ b/src/User/Elements/User.php @@ -39,6 +39,7 @@ use craft\web\twig\AllowedInSandbox; use craft\web\View; use CraftCms\Cms\Auth\Concerns\ConfirmsPasswords; +use CraftCms\Cms\Auth\Impersonation; use CraftCms\Cms\Auth\Models\WebAuthn; use CraftCms\Cms\Cms; use CraftCms\Cms\Database\Queries\ElementQuery; @@ -1916,7 +1917,7 @@ protected function safeActionMenuItems(): array $currentUser = Auth::user(); $view = Craft::$app->getView(); - $userSession = Craft::$app->getUser(); + Craft::$app->getUser(); $canAdministrateUsers = $currentUser->can('administrateUsers'); $canModerateUsers = $currentUser->can('moderateUsers'); @@ -1992,7 +1993,7 @@ protected function safeActionMenuItems(): array ($currentUser->admin || ! $this->admin) && $canModerateUsers && ( - ($impersonatorId = $userSession->getImpersonatorId()) === null || + ($impersonatorId = app(Impersonation::class)->getImpersonatorId()) === null || $this->id !== $impersonatorId ) ) { @@ -2618,16 +2619,7 @@ public function afterSave(bool $isNew): void } if (! $isNew && $changePassword && ! app()->runningInConsole()) { - $token = Craft::$app->getUser()->getToken(); - - // Destroy all other sessions for this user - DbFacade::table(Table::SESSIONS) - ->where('userId', $this->id) - ->when( - value: $this->getIsCurrent() && $token, - callback: fn ($query) => $query->where('token', '!=', $token), - ) - ->delete(); + Auth::logoutOtherDevices($this->newPassword); } } diff --git a/src/User/Users.php b/src/User/Users.php index 773bbae0729..446fa0fe63f 100644 --- a/src/User/Users.php +++ b/src/User/Users.php @@ -904,11 +904,11 @@ public function suspendUser(User $user): void // Destroy all sessions for this user DB::table(Table::SESSIONS) - ->where('userId', $user->id) + ->where('user_id', $user->id) ->delete(); if (Event::hasListeners(UserSuspended::class)) { - Event::dispatch($event = new UserSuspended($user)); + Event::dispatch(new UserSuspended($user)); } if ($indexAttributesChanged) { diff --git a/tests/Auth/ImpersonationTest.php b/tests/Auth/ImpersonationTest.php new file mode 100644 index 00000000000..2aa893697de --- /dev/null +++ b/tests/Auth/ImpersonationTest.php @@ -0,0 +1,28 @@ +impersonation = app(Impersonation::class); +}); + +test('impersonation', function () { + expect($this->impersonation->getImpersonator())->toBeNull(); + expect($this->impersonation->getImpersonatorId())->toBeNull(); + expect($this->impersonation->isImpersonating())->toBeFalse(); + + $id = User::findOne()->id; + + $this->impersonation->setImpersonatorId($id); + + expect($this->impersonation->getImpersonator())->toBeInstanceOf(User::class); + expect($this->impersonation->getImpersonatorId())->toBe($id); + expect($this->impersonation->isImpersonating())->toBeTrue(); + + $this->impersonation->setImpersonatorId(null); + + expect($this->impersonation->getImpersonator())->toBeNull(); + expect($this->impersonation->getImpersonatorId())->toBeNull(); + expect($this->impersonation->isImpersonating())->toBeFalse(); +}); diff --git a/tests/Auth/RememberedUsernameTest.php b/tests/Auth/RememberedUsernameTest.php new file mode 100644 index 00000000000..bc3b4a99b96 --- /dev/null +++ b/tests/Auth/RememberedUsernameTest.php @@ -0,0 +1,28 @@ +toBeNull(); + + $user = User::findOne(); + + RememberedUsername::set($user); + + expect(Cookie::hasQueued(RememberedUsername::cookieName()))->toBeTrue(); + + $cookie = Cookie::queued(RememberedUsername::cookieName()); + + expect($cookie->getValue())->toBe($user->username); + expect($cookie->getExpiresTime())->toBe(now()->timestamp + Cms::config()->rememberUsernameDuration); + + // Setting to 0 will remove the cookie. + Cms::config()->rememberUsernameDuration = 0; + + RememberedUsername::set($user); + + expect(Cookie::hasQueued(RememberedUsername::cookieName()))->toBeFalse(); +}); diff --git a/tests/Http/Controllers/AnnouncementsControllerTest.php b/tests/Http/Controllers/AnnouncementsControllerTest.php new file mode 100644 index 00000000000..1f27d54dfa8 --- /dev/null +++ b/tests/Http/Controllers/AnnouncementsControllerTest.php @@ -0,0 +1,38 @@ +unread() + ->create([ + 'userId' => auth()->user()->id, + ]); + + expect($announcement->fresh()->unread)->toBeTrue(); + + postJson(action([AnnouncementsController::class, 'markRead']), [ + 'ids' => [$announcement->id], + ])->assertOk(); + + expect($announcement->fresh()->unread)->toBeFalse(); +}); + +test('ids must be an array of integers', function () { + postJson(action([AnnouncementsController::class, 'markRead']), [ + 'ids' => 'foo', + ])->assertJsonValidationErrorFor('ids'); + + postJson(action([AnnouncementsController::class, 'markRead']), [ + 'ids' => ['foo'], + ])->assertJsonValidationErrorFor('ids.0'); +}); diff --git a/tests/Support/QueryTest.php b/tests/Support/QueryTest.php index ffb1ed6105d..c88994c9a12 100644 --- a/tests/Support/QueryTest.php +++ b/tests/Support/QueryTest.php @@ -1,8 +1,8 @@ delete(); - - DB::table(Table::SESSIONS)->insert([ - 'userId' => 1, - 'token' => 'test-today', + Volume::factory()->create([ + 'name' => 'test-today', 'dateCreated' => today(), - 'dateUpdated' => today(), - 'uid' => Str::uuid()->toString(), ]); - DB::table(Table::SESSIONS)->insert([ - 'userId' => 1, - 'token' => 'test-yesterday', + Volume::factory()->create([ + 'name' => 'test-yesterday', 'dateCreated' => now()->subDay()->startOfDay(), - 'dateUpdated' => now()->subDay()->startOfDay(), - 'uid' => Str::uuid()->toString(), ]); - DB::table(Table::SESSIONS)->insert([ - 'userId' => 1, - 'token' => 'test-tomorrow', + Volume::factory()->create([ + 'name' => 'test-tomorrow', 'dateCreated' => now()->addDay()->startOfDay(), - 'dateUpdated' => now()->addDay()->startOfDay(), - 'uid' => Str::uuid()->toString(), ]); - $query = DB::table(Table::SESSIONS)->whereDateParam('dateCreated', $param); + $query = DB::table(Table::VOLUMES)->whereDateParam('dateCreated', $param); - expect( - $query - ->pluck('token') - // Trim because on pgsql these are fixed length - ->map(fn ($token) => trim((string) $token)) - ->all() - )->toEqual($expected); + expect($query->pluck('name')->all())->toEqual($expected); })->with([ ['today', ['test-today']], ['tomorrow', ['test-tomorrow']], diff --git a/tests/TestCase.php b/tests/TestCase.php index 6dff41fa427..2b6eb420669 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -20,7 +20,6 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Schema; use Orchestra\Testbench\Concerns\WithWorkbench; use Orchestra\Testbench\TestCase as Orchestra; use Override; @@ -76,15 +75,9 @@ protected function tearDown(): void parent::tearDown(); } - protected function migrateDatabases() + protected function migrateDatabases(): void { - $this->artisan('migrate:fresh', $this->migrateFreshUsing()); - - /** Drop Laravel migrations */ - Schema::drop('migrations'); - Schema::drop('cache'); - Schema::drop('sessions'); - Schema::drop('users'); + $this->artisan('db:wipe'); $site = new Site( name: 'Craft test site', diff --git a/yii2-adapter/legacy/base/MissingComponentTrait.php b/yii2-adapter/legacy/base/MissingComponentTrait.php index 208b461cf6b..91a2f8ce9e1 100644 --- a/yii2-adapter/legacy/base/MissingComponentTrait.php +++ b/yii2-adapter/legacy/base/MissingComponentTrait.php @@ -13,6 +13,7 @@ use CraftCms\Cms\Component\Contracts\ComponentInterface; use CraftCms\Cms\Plugin\Exceptions\InvalidPluginException; use CraftCms\Cms\Plugin\Plugins; +use Illuminate\Support\Facades\Auth; use yii\base\Arrayable; /** @@ -73,7 +74,7 @@ public function getPlaceholderHtml(): string $iconSvg = null; if ( - Craft::$app->getUser()->getIsAdmin() && + Auth::user()?->isAdmin() && Cms::config()->allowAdminChanges ) { $pluginsService = app(Plugins::class); diff --git a/yii2-adapter/legacy/config/GeneralConfig.php b/yii2-adapter/legacy/config/GeneralConfig.php index ad7c1775ccd..b6abb4f8028 100644 --- a/yii2-adapter/legacy/config/GeneralConfig.php +++ b/yii2-adapter/legacy/config/GeneralConfig.php @@ -41,7 +41,6 @@ public function init(): void ->cacheDuration($this->cacheDuration) ->cooldownDuration($this->cooldownDuration) ->defaultTokenDuration($this->defaultTokenDuration) - ->elevatedSessionDuration($this->elevatedSessionDuration) ->invalidLoginWindowDuration($this->invalidLoginWindowDuration) ->previewTokenDuration($this->previewTokenDuration ?? $this->defaultTokenDuration) ->purgePendingUsersDuration($this->purgePendingUsersDuration) @@ -49,7 +48,6 @@ public function init(): void ->rememberUsernameDuration($this->rememberUsernameDuration) ->rememberedUserSessionDuration($this->rememberedUserSessionDuration) ->softDeleteDuration($this->softDeleteDuration) - ->userSessionDuration($this->userSessionDuration) ->verificationCodeDuration($this->verificationCodeDuration) // locales ->defaultCpLanguage($this->defaultCpLanguage) diff --git a/yii2-adapter/legacy/controllers/AuthController.php b/yii2-adapter/legacy/controllers/AuthController.php index 334784a193b..72055ae43c6 100644 --- a/yii2-adapter/legacy/controllers/AuthController.php +++ b/yii2-adapter/legacy/controllers/AuthController.php @@ -9,7 +9,6 @@ use Craft; use craft\auth\methods\RecoveryCodes; -use craft\auth\methods\TOTP; use craft\web\Controller; use craft\web\View; use CraftCms\Cms\Cms; @@ -42,8 +41,6 @@ class AuthController extends Controller */ protected array|bool|int $allowAnonymous = [ 'passkey-request-options' => self::ALLOW_ANONYMOUS_LIVE | self::ALLOW_ANONYMOUS_OFFLINE, - 'verify-recovery-code' => self::ALLOW_ANONYMOUS_LIVE | self::ALLOW_ANONYMOUS_OFFLINE, - 'verify-totp' => self::ALLOW_ANONYMOUS_LIVE | self::ALLOW_ANONYMOUS_OFFLINE, ]; /** @@ -135,46 +132,6 @@ public function actionRemoveMethod(): ?Response return $this->asSuccess(t('Authentication method removed.')); } - /** - * Verifies a TOTP code. - * - * @return Response - */ - public function actionVerifyTotp(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $code = $this->request->getRequiredBodyParam('code'); - $authService = Craft::$app->getAuth(); - - if (!$authService->verify(TOTP::class, $code)) { - return $this->asFailure($authService->getAuthErrorMessage()); - } - - return $this->asSuccess(t('Verification successful.')); - } - - /** - * Verifies a recovery code. - * - * @return Response - */ - public function actionVerifyRecoveryCode(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $code = $this->request->getRequiredBodyParam('code'); - $authService = Craft::$app->getAuth(); - - if (!$authService->verify(RecoveryCodes::class, $code)) { - return $this->asFailure($authService->getAuthErrorMessage(t('Invalid recovery code.'))); - } - - return $this->asSuccess(t('Verification successful.')); - } - /** * Generates new passkey credential creation options for the user. * @@ -194,23 +151,6 @@ public function actionPasskeyCreationOptions(): Response ]); } - /** - * Returns the available passkey options. - * - * @return Response - */ - public function actionPasskeyRequestOptions(): Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $options = Craft::$app->getAuth()->getPasskeyRequestOptions(); - - return $this->asJson([ - 'options' => $options, - ]); - } - /** * Verifies the new passkey credential creation. * diff --git a/yii2-adapter/legacy/controllers/SsoController.php b/yii2-adapter/legacy/controllers/SsoController.php index 32a1c9626a8..b53dce80624 100644 --- a/yii2-adapter/legacy/controllers/SsoController.php +++ b/yii2-adapter/legacy/controllers/SsoController.php @@ -18,6 +18,7 @@ use CraftCms\Cms\Support\Json; use Exception; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\URL; use Throwable; use yii\web\HttpException; use yii\web\Response; @@ -119,11 +120,7 @@ protected function handleFailedRequest(?string $message = null): ?Response protected function handleSuccessfulResponse(): Response { // Get the return URL - $userSession = Craft::$app->getUser(); - $returnUrl = $userSession->getReturnUrl(); - - // Clear it out - $userSession->removeReturnUrl(); + $returnUrl = URL::returnUrl(); // If this was an Ajax request, just return success:true if ($this->request->getAcceptsJson()) { diff --git a/yii2-adapter/legacy/controllers/UsersController.php b/yii2-adapter/legacy/controllers/UsersController.php index e6fe3bc9918..ab349df61c2 100644 --- a/yii2-adapter/legacy/controllers/UsersController.php +++ b/yii2-adapter/legacy/controllers/UsersController.php @@ -25,17 +25,17 @@ use craft\helpers\FileHelper; use craft\helpers\Image; use craft\helpers\UrlHelper; -use craft\helpers\User as UserHelper; use craft\web\Application; use craft\web\assets\authmethodsetup\AuthMethodSetupAsset; use craft\web\Controller; use craft\web\Request; -use craft\web\ServiceUnavailableHttpException; use craft\web\UploadedFile; use craft\web\View; -use CraftCms\Cms\Announcement\Announcements; use CraftCms\Cms\Auth\Concerns\ConfirmsPasswords; -use CraftCms\Cms\Auth\Models\WebAuthn; +use CraftCms\Cms\Auth\Events\LoginUserRetrieved; +use CraftCms\Cms\Auth\Events\RetrievingLoginUser; +use CraftCms\Cms\Auth\Impersonation; +use CraftCms\Cms\Auth\RememberedUsername; use CraftCms\Cms\Cms; use CraftCms\Cms\Edition; use CraftCms\Cms\Element\Exceptions\InvalidElementException; @@ -54,10 +54,11 @@ use CraftCms\Cms\User\Events\EmailVerified; use CraftCms\Cms\User\Events\GroupsAndPermissionsAssigned; use CraftCms\Cms\User\Events\VerifyingEmail; -use CraftCms\Yii2Adapter\IdentityWrapper; +use Illuminate\Auth\Events\Failed; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\URL; use Throwable; use yii\base\Exception; use yii\base\InvalidArgumentException; @@ -112,17 +113,20 @@ class UsersController extends Controller * ``` * * @since 4.2.0 + * @deprecated 6.0.0 use {@see RetrievingLoginUser} instead. */ public const EVENT_BEFORE_FIND_LOGIN_USER = 'beforeFindLoginUser'; /** * @event FindLoginUserEvent The event that is triggered after attempting to find a user to sign in * @since 4.2.0 + * @deprecated 6.0.0 use {@see LoginUserRetrieved} instead. */ public const EVENT_AFTER_FIND_LOGIN_USER = 'afterFindLoginUser'; /** * @event LoginFailureEvent The event that is triggered when a failed login attempt was made + * @deprecated 6.0.0 use {@see Failed} instead. */ public const EVENT_LOGIN_FAILURE = 'loginFailure'; @@ -196,184 +200,13 @@ public function beforeAction($action): bool { // Don't enable CSRF validation for login requests if the user is already logged-in. // (Guards against double-clicking a Login button.) - if ($action->id === 'login' && !Craft::$app->getUser()->getIsGuest()) { + if ($action->id === 'login' && !Auth::guest()) { $this->enableCsrfValidation = false; } return parent::beforeAction($action); } - /** - * Displays the login template, and handles login post requests for logging in with a password. - * - * @return Response|null - * @throws BadRequestHttpException - * @throws ServiceUnavailableHttpException - */ - public function actionLogin(): ?Response - { - // Set the default response format to HTML, in case it was set to JSON for headless mode - if (!$this->request->getAcceptsJson()) { - $this->response->format = Response::FORMAT_HTML; - } - - if ($this->request->getIsGet()) { - // see if they're already logged in - $user = static::currentUser(); - if ($user) { - return $this->_handleSuccessfulLogin($user); - } - - // should we be showing the 2FA form? - if ($this->request->getQueryParam('verify')) { - return $this->runAction('auth-form'); - } - - return $this->_rerouteWithFallbackTemplate('login.twig'); - } - - $loginName = $this->request->getRequiredBodyParam('loginName'); - $password = $this->request->getRequiredBodyParam('password'); - $rememberMe = (bool)$this->request->getBodyParam('rememberMe'); - - $user = $this->_findLoginUser($loginName); - - if (!$user || $user->password === null) { - // Match $user->authenticate()'s delay - $this->_hashCheck(); - return $this->_handleLoginFailure(User::AUTH_INVALID_CREDENTIALS); - } - - // Did they submit a valid password, and is the user capable of being logged-in? - if (!$user->authenticate($password)) { - return $this->_handleLoginFailure($user->authError, $user); - } - - // Get the session duration - $generalConfig = Cms::config(); - if ($rememberMe && $generalConfig->rememberedUserSessionDuration !== 0) { - $duration = $generalConfig->rememberedUserSessionDuration; - } else { - $duration = $generalConfig->userSessionDuration; - } - - $userSession = Craft::$app->getUser(); - - // if user has an active 2SV method, move on to that - if (!$generalConfig->disable2fa) { - $authService = Craft::$app->getAuth(); - if ($authService->hasActiveMethod($user)) { - $authService->setUser($user, $duration); - - if ($this->request->getIsSiteRequest() && !$this->request->getAcceptsJson()) { - $loginPath = $generalConfig->getLoginPath(); - if (!$loginPath) { - $authService->setUser(null); - throw new InvalidConfigException('User requires two-step verification, but the loginPath config setting is disabled.'); - } - return $this->redirect(UrlHelper::siteUrl($loginPath, array_filter([ - 'verify' => 1, - 'returnUrl' => $this->getPostedRedirectUrl($user), - ]))); - } - - return $this->runAction('auth-form'); - } - } - - // if we're impersonating, pass the user we're impersonating to the complete method - $impersonator = $userSession->getImpersonator(); - if ($impersonator !== null) { - $user = Auth::user() ?? $user; - } - - return $this->_completeLogin($user, $duration); - } - - /** - * Logs a user in with a passkey. - * - * @return Response|null - * @since 5.0.0 - */ - public function actionLoginWithPasskey(): ?Response - { - $this->requirePostRequest(); - $this->requireAcceptsJson(); - - $duration = Cms::config()->userSessionDuration; - - $requestOptions = $this->request->getRequiredBodyParam('requestOptions'); - $response = $this->request->getRequiredBodyParam('response'); - $credential = WebAuthn::where('credentialId', Json::decode($response)['id'])->first(); - - if ($credential === null) { - return $this->asFailure(t('Passkey authentication failed.')); - } - - $user = User::findOne(['id' => $credential->userId]); - - if ($user === null) { - return $this->_handleLoginFailure(); - } - - if (!$user->authenticateWithPasskey($requestOptions, $response)) { - return $this->_handleLoginFailure($user->authError, $user); - } - - // if we're impersonating, pass the user we're impersonating to the complete method - $userSession = Craft::$app->getUser(); - if ($userSession->getImpersonator() !== null) { - $user = Auth::user(); - } - - return $this->_completeLogin($user, $duration); - } - - /** - * Finish logging user in. - * - * Used for logging in with a password or passkey. - * - * @param User $user - * @param int $duration - * @return Response - * @throws ServiceUnavailableHttpException - * @since 5.0.0 - */ - private function _completeLogin(User $user, int $duration): Response - { - Auth::setRememberDuration($duration)->login($user, $duration > 0); - - return $this->_handleSuccessfulLogin($user); - } - - private function _findLoginUser(string $loginName): ?User - { - // Fire a 'beforeFindLoginUser' event - if ($this->hasEventHandlers(self::EVENT_BEFORE_FIND_LOGIN_USER)) { - $event = new FindLoginUserEvent(['loginName' => $loginName]); - $this->trigger(self::EVENT_BEFORE_FIND_LOGIN_USER, $event); - $user = $event->user; - } else { - $user = null; - } - - $user ??= Users::getUserByUsernameOrEmail($loginName); - - // Fire an 'afterFindLoginUser' event - if ($this->hasEventHandlers(self::EVENT_AFTER_FIND_LOGIN_USER)) { - $event = new FindLoginUserEvent([ - 'loginName' => $loginName, - 'user' => $user, - ]); - $this->trigger(self::EVENT_AFTER_FIND_LOGIN_USER, $event); - return $event->user; - } - - return $user; - } - /** * Redirects the user to the default post-login URL. * @@ -381,122 +214,7 @@ private function _findLoginUser(string $loginName): ?User */ public function actionRedirect(): Response { - return $this->redirect(Craft::$app->getUser()->getDefaultReturnUrl()); - } - - /** - * Returns information about the current user session, if any. - * - * @return Response - * @since 3.4.0 - */ - public function actionSessionInfo(): Response - { - $this->requireAcceptsJson(); - - $userSession = Craft::$app->getUser(); - /** @var User|null $user */ - $user = Auth::user(); - - $return = [ - 'isGuest' => $user === null, - 'timeout' => $userSession->getRemainingSessionTime(), - ]; - - if (Cms::config()->enableCsrfProtection) { - $return['csrfTokenName'] = Cms::config()->csrfTokenName; - $return['csrfTokenValue'] = $this->request->getCsrfToken(); - } - - if ($user !== null) { - $return['id'] = $user->id; - $return['uid'] = $user->uid; - $return['username'] = $user->username; - $return['email'] = $user->email; - } - - return $this->asJson($return); - } - - /** - * Renders the login modal for logged-out control panel uses. - * - * @return Response - * @since 5.0.0 - */ - public function actionLoginModal(): Response - { - $this->requireAcceptsJson(); - $this->requirePostRequest(); - - $forElevatedSession = (bool)$this->request->getBodyParam('forElevatedSession'); - - // If the current user is being impersonated, get the impersonator instead - if ($forElevatedSession && ($impersonator = Craft::$app->getUser()->getImpersonator())) { - $staticEmail = $impersonator->email; - } else { - $staticEmail = $this->request->getRequiredBodyParam('email'); - } - - $view = $this->getView(); - $html = $view->renderTemplate('_special/login-modal.twig', [ - 'staticEmail' => $staticEmail, - 'forElevatedSession' => $forElevatedSession, - ], View::TEMPLATE_MODE_CP); - - return $this->asJson([ - 'html' => $html, - 'headHtml' => $view->getHeadHtml(), - 'bodyHtml' => $view->getBodyHtml(), - ]); - } - - /** - * Returns how many seconds are left in the current elevated user session. - * - * @return Response - */ - public function actionGetElevatedSessionTimeout(): Response - { - return $this->asJson([ - 'timeout' => $this->confirmedPasswordTimeout(), - ]); - } - - /** - * @return Response - */ - public function actionLogout(): Response - { - // Set the default response format to HTML, in case it was set to JSON for headless mode - if (!$this->request->getAcceptsJson()) { - $this->response->format = Response::FORMAT_HTML; - } - - // Passing false here for reasons. - Craft::$app->getUser()->logout(false); - - $data = []; - - if ($this->request->getAcceptsJson()) { - if (Cms::config()->enableCsrfProtection) { - $data['csrfTokenValue'] = $this->request->getCsrfToken(); - } - - return $this->asSuccess( - data: $data, - ); - } - - // Redirect to the login page if this is a control panel request - if ($this->request->getIsCpRequest()) { - return $this->redirect(Request::CP_PATH_LOGIN); - } - - return $this->asSuccess( - data: $data, - redirect: Cms::config()->getPostLogoutRedirect() - ); + return $this->redirect(URL::defaultReturnUrl()); } /** @@ -681,108 +399,6 @@ public function actionRemovePasswordResetRequirement(): ?Response ])); } - /** - * Sets a user’s password once they’ve verified they have access to their email. - * - * @return Response - */ - public function actionSetPassword(): Response - { - // Set the default response format to HTML, in case it was set to JSON for headless mode - if (!$this->request->getAcceptsJson()) { - $this->response->format = Response::FORMAT_HTML; - } - - // Have they just submitted a password, or are we just displaying the page? - if (!$this->request->getIsPost()) { - if (!is_array($info = $this->_processTokenRequest())) { - return $info; - } - - /** @var User $user */ - /** @var string $uid */ - /** @var string $code */ - [$user, $uid, $code] = $info; - - Craft::$app->getUser()->sendUsernameCookie($user); - - // Send them to the set password template. - return $this->_renderSetPasswordTemplate([ - 'code' => $code, - 'id' => $uid, - 'newUser' => !$user->password, - ]); - } - - // POST request. They've just set the password. - $code = $this->request->getRequiredBodyParam('code'); - $uid = $this->request->getRequiredParam('id'); - $user = User::find() - ->uid($uid) - ->status(null) - ->addSelect(['users.password']) - ->one(); - - if (!$user) { - throw new BadRequestHttpException("Invalid user UUID: $uid"); - } - - // Make sure we still have a valid token. - /** @var User $user */ - if (!Users::isVerificationCodeValidForUser($user, $code)) { - return $this->_processInvalidToken($user); - } - - $user->newPassword = $this->request->getRequiredBodyParam('newPassword'); - $user->setScenario(User::SCENARIO_PASSWORD); - - if (!Craft::$app->getElements()->saveElement($user)) { - return $this->asFailure( - t('Couldn’t update password.'), - $user->getErrors('newPassword'), - ) ?? $this->_renderSetPasswordTemplate([ - 'errors' => $user->getErrors('newPassword'), - 'code' => $code, - 'id' => $uid, - 'newUser' => !$user->password, - ]); - } - - // If they're pending, try to activate them, and maybe treat this as an activation request - if ($user->getStatus() === User::STATUS_PENDING) { - try { - Users::activateUser($user); - $response = $this->_onAfterActivateUser($user); - if ($response !== null) { - return $response; - } - } catch (InvalidElementException) { - // NBD - } - } - - if ($this->request->getAcceptsJson()) { - $return = [ - 'status' => $user->getStatus(), - ]; - if (!Craft::$app->getUser()->getIsGuest() && Cms::config()->enableCsrfProtection) { - $return['csrfTokenValue'] = $this->request->getCsrfToken(); - } - return $this->asSuccess(data: $return); - } - - if ($this->request->getIsCpRequest()) { - // Send them to the control panel login page by default - $url = UrlHelper::cpUrl(Request::CP_PATH_LOGIN); - } else { - // Send them to the 'setPasswordSuccessPath' by default - $setPasswordSuccessPath = Cms::config()->getSetPasswordSuccessPath(); - $url = UrlHelper::siteUrl($setPasswordSuccessPath); - } - - return $this->redirectToPostedUrl($user, $url); - } - /** * Verifies that a user has access to an email address. * @@ -805,7 +421,7 @@ public function actionVerifyEmail(): Response /** @var string $code */ [$user, $uid, $code] = $info; - Craft::$app->getUser()->sendUsernameCookie($user); + RememberedUsername::set($user); // Send them to the set verify-email template return $this->_rerouteWithFallbackTemplate('verify-email.twig', [ @@ -845,7 +461,7 @@ public function actionVerifyEmail(): Response } // If they're logged in, give them a success notice - if (!Craft::$app->getUser()->getIsGuest()) { + if (!Auth::guest()) { $this->setSuccessFlash(t('Email verified')); } @@ -1175,7 +791,7 @@ public function actionSaveUser(): ?Response /** @noinspection PhpUndefinedVariableInspection */ if ($isCurrentUser && $user->username !== $oldUsername) { // Update the username cookie - $userSession->sendUsernameCookie($user); + RememberedUsername::set($user); } // Save the user’s photo, if it was submitted @@ -1427,49 +1043,12 @@ public function actionVerifyPassword(): ?Response return $this->asFailure(t('Invalid password.')); } - /** - * Handles a failed login attempt. - * - * @param string|null $authError - * @param User|null $user - * @return Response|null - * @throws ServiceUnavailableHttpException - */ - private function _handleLoginFailure(?string $authError = null, ?User $user = null): ?Response - { - [$authError, $message] = UserHelper::getLoginFailureInfo($authError, $user); - - // Fire a 'loginFailure' event - if ($this->hasEventHandlers(self::EVENT_LOGIN_FAILURE)) { - $event = new LoginFailureEvent([ - 'authError' => $authError, - 'message' => $message, - 'user' => $user, - ]); - $this->trigger(self::EVENT_LOGIN_FAILURE, $event); - $message = $event->message; - } - - return $this->asFailure( - $message, - data: [ - 'errorCode' => $authError, - ], - routeParams: [ - 'loginName' => $this->request->getBodyParam('loginName'), - 'rememberMe' => (bool)$this->request->getBodyParam('rememberMe'), - 'errorCode' => $authError, - 'errorMessage' => $message, - ] - ); - } - public function actionAuthForm(): Response { // If the current user is being impersonated, use the impersonator $userSession = Craft::$app->getUser(); $authService = Craft::$app->getAuth(); - $user = $userSession->getImpersonator() ?? $authService->getUser(); + $user = app(Impersonation::class)->getImpersonator() ?? Auth::user(); if (!$user) { if ($this->request->getIsSiteRequest()) { @@ -1520,7 +1099,7 @@ public function actionAuthForm(): Response } else { $defaultReturnUrl = UrlHelper::siteUrl(Cms::config()->getPostLoginRedirect()); } - $returnUrl = $userSession->getReturnUrl($defaultReturnUrl); + $returnUrl = URL::returnUrl($defaultReturnUrl); } $authFormData = [ @@ -1544,49 +1123,6 @@ public function actionAuthForm(): Response return $this->renderTemplate('login.twig', compact('authFormData'), View::TEMPLATE_MODE_CP); } - /** - * Redirects the user after a successful login attempt, or if they visited the Login page while they were already - * logged in. - * - * @param User $user - * @return Response - */ - 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->getAcceptsJson()) { - $return = [ - 'returnUrl' => $returnUrl, - ]; - - if (Cms::config()->enableCsrfProtection) { - $return['csrfTokenValue'] = $this->request->getCsrfToken(); - } - - return $this->asModelSuccess($user, modelName: 'user', data: $return); - } - - return $this->redirectToPostedUrl(Auth::user(), $returnUrl); - } - - /** - * Renders the Set Password template for a given user. - * - * @param array $variables - * @return Response - */ - private function _renderSetPasswordTemplate(array $variables): Response - { - return $this->_rerouteWithFallbackTemplate('set-password.twig', $variables); - } - private function _rerouteWithFallbackTemplate(string $cpTemplate, array $variables = []): ?Response { // If this is a site request, try handling the request like normal @@ -1752,9 +1288,8 @@ private function _processTokenRequest(): Response|array } // If someone is logged in and it’s not this person, log them out - $userSession = Craft::$app->getUser(); - if (!$userSession->getIsGuest() && $userSession->getId() != $user->id) { - $userSession->logout(); + if (!Auth::guest() && Auth::id() !== $user->id) { + Auth::logout(); } if (Event::hasListeners(VerifyingEmail::class)) { @@ -1789,11 +1324,8 @@ private function _processInvalidToken(?User $user = null): Response // If they don't have a verification code at all, and they're already logged-in, just send them to the post-login URL if ($user && !$user->verificationCode) { - $userSession = Craft::$app->getUser(); - if (!$userSession->getIsGuest()) { - $returnUrl = $userSession->getReturnUrl(); - $userSession->removeReturnUrl(); - return $this->redirect($returnUrl); + if (!Auth::guest()) { + return $this->redirect(URL::returnUrl()); } } @@ -1833,11 +1365,13 @@ private function _onAfterActivateUser(User $user): ?Response */ private function _maybeLoginUserAfterAccountActivation(User $user): bool { - $generalConfig = Cms::config(); - if (!$generalConfig->autoLoginAfterAccountActivation) { + if (!Cms::config()->autoLoginAfterAccountActivation) { return false; } - return Craft::$app->getUser()->login(new IdentityWrapper($user), $generalConfig->userSessionDuration); + + Auth::login($user); + + return true; } /** @@ -1906,11 +1440,6 @@ private function _handleSendPasswordResetError(array $errors, ?string $loginName ); } - private function _hashCheck() - { - Craft::$app->getSecurity()->validatePassword('p@ss1w0rd', '$2y$13$nj9aiBeb7RfEfYP3Cum6Revyu14QelGGxwcnFUKXIrQUitSodEPRi'); - } - private function _randomlyDelayResponse(float $maxOffset = 0) { // Delay randomly between 0.5 and 1.5 seconds. @@ -1920,19 +1449,6 @@ private function _randomlyDelayResponse(float $maxOffset = 0) } } - /** - * Marks the user’s feature announcements as read. - * - * @return Response - */ - public function actionMarkAnnouncementsAsRead(): Response - { - $this->requirePostRequest(); - $ids = $this->request->getRequiredBodyParam('ids'); - app(Announcements::class)->markAsRead($ids); - return $this->asSuccess(); - } - private function populateNameAttributes(object $model): void { /** @var object|NameTrait $model */ @@ -2021,14 +1537,46 @@ public static function registerEvents(): void Event::listen(GroupsAndPermissionsAssigned::class, function(GroupsAndPermissionsAssigned $event) { if (YiiEvent::hasHandlers(UsersController::class, UsersController::EVENT_AFTER_ASSIGN_GROUPS_AND_PERMISSIONS)) { - $user = User::find()->id($event->user->id)->one(); - $yiiEvent = new UserEvent([ - 'user' => $user, + 'user' => $event->user, ]); YiiEvent::trigger(UsersController::class, UsersController::EVENT_AFTER_ASSIGN_GROUPS_AND_PERMISSIONS, $yiiEvent); } }); + + Event::listen(RetrievingLoginUser::class, function(RetrievingLoginUser $event) { + if (YiiEvent::hasHandlers(UsersController::class, UsersController::EVENT_BEFORE_FIND_LOGIN_USER)) { + $yiiEvent = new FindLoginUserEvent([ + 'loginName' => $event->loginName, + 'user' => $event->user, + ]); + + YiiEvent::trigger(UsersController::class, UsersController::EVENT_BEFORE_FIND_LOGIN_USER, $yiiEvent); + + $event->user = $yiiEvent->user; + } + }); + + Event::listen(LoginUserRetrieved::class, function(LoginUserRetrieved $event) { + if (YiiEvent::hasHandlers(UsersController::class, UsersController::EVENT_AFTER_FIND_LOGIN_USER)) { + $yiiEvent = new FindLoginUserEvent([ + 'loginName' => $event->loginName, + 'user' => $event->user, + ]); + + YiiEvent::trigger(UsersController::class, UsersController::EVENT_AFTER_FIND_LOGIN_USER, $yiiEvent); + } + }); + + Event::listen(Failed::class, function(Failed $event) { + if (YiiEvent::hasHandlers(UsersController::class, UsersController::EVENT_LOGIN_FAILURE)) { + $yiiEvent = new LoginFailureEvent([ + 'user' => $event->user, + ]); + + YiiEvent::trigger(UsersController::class, UsersController::EVENT_LOGIN_FAILURE, $yiiEvent); + } + }); } } diff --git a/yii2-adapter/legacy/elements/Entry.php b/yii2-adapter/legacy/elements/Entry.php index efed3265ea2..1e51c996b34 100644 --- a/yii2-adapter/legacy/elements/Entry.php +++ b/yii2-adapter/legacy/elements/Entry.php @@ -2210,7 +2210,7 @@ protected function safeActionMenuItems(): array $actions = parent::safeActionMenuItems(); if ( - Craft::$app->getUser()->getIsAdmin() && + Auth::user()?->isAdmin() && Cms::config()->allowAdminChanges ) { // Entry type settings diff --git a/yii2-adapter/legacy/events/FindLoginUserEvent.php b/yii2-adapter/legacy/events/FindLoginUserEvent.php index db79e077b55..08e24e3a1a2 100644 --- a/yii2-adapter/legacy/events/FindLoginUserEvent.php +++ b/yii2-adapter/legacy/events/FindLoginUserEvent.php @@ -16,6 +16,7 @@ * @author Pixel & Tonic, Inc. * @author Bert Oost * @since 4.2.0 + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Auth\Events\RetrievingLoginUser} or {@see \CraftCms\Cms\Auth\Events\LoginUserRetrieved} instead. */ class FindLoginUserEvent extends Event { diff --git a/yii2-adapter/legacy/events/LoginFailureEvent.php b/yii2-adapter/legacy/events/LoginFailureEvent.php index f80ff99b2f0..fa48b43dbc1 100644 --- a/yii2-adapter/legacy/events/LoginFailureEvent.php +++ b/yii2-adapter/legacy/events/LoginFailureEvent.php @@ -15,6 +15,7 @@ * * @author Pixel & Tonic, Inc. * @since 3.0.0 + * @deprecated 6.0.0 use {@see \Illuminate\Auth\Events\Failed} instead. */ class LoginFailureEvent extends Event { diff --git a/yii2-adapter/legacy/events/UserEvent.php b/yii2-adapter/legacy/events/UserEvent.php index bae35ac2162..ce8ce318b2a 100644 --- a/yii2-adapter/legacy/events/UserEvent.php +++ b/yii2-adapter/legacy/events/UserEvent.php @@ -14,6 +14,7 @@ * * @author Pixel & Tonic, Inc. * @since 3.0.0 + * @deprecated 6.0.0 */ class UserEvent extends CancelableEvent { diff --git a/yii2-adapter/legacy/events/UserGroupPermissionsEvent.php b/yii2-adapter/legacy/events/UserGroupPermissionsEvent.php index 305854d8fe7..c33d7972860 100644 --- a/yii2-adapter/legacy/events/UserGroupPermissionsEvent.php +++ b/yii2-adapter/legacy/events/UserGroupPermissionsEvent.php @@ -8,12 +8,14 @@ namespace craft\events; use craft\base\Event; +use CraftCms\Cms\User\Events\UserGroupPermissionsSaved; /** * UserGroupPermissionsEvent class. * * @author Pixel & Tonic, Inc. * @since 4.3.0 + * @deprecated 6.0.0 use {@see UserGroupPermissionsSaved} instead. */ class UserGroupPermissionsEvent extends Event { diff --git a/yii2-adapter/legacy/events/UserGroupsAssignEvent.php b/yii2-adapter/legacy/events/UserGroupsAssignEvent.php index e0f59225e57..7dc7ea82824 100644 --- a/yii2-adapter/legacy/events/UserGroupsAssignEvent.php +++ b/yii2-adapter/legacy/events/UserGroupsAssignEvent.php @@ -7,11 +7,14 @@ namespace craft\events; +use CraftCms\Cms\User\Events\AssigningUserToGroups; + /** * User Groups assign event class. * * @author Pixel & Tonic, Inc. * @since 3.0.0 + * @deprecated 6.0.0 use {@see AssigningUserToGroups} or {@see \CraftCms\Cms\User\Events\UserAssignedToGroups} instead. */ class UserGroupsAssignEvent extends CancelableEvent { diff --git a/yii2-adapter/legacy/events/UserPermissionsEvent.php b/yii2-adapter/legacy/events/UserPermissionsEvent.php index 9034f6b1692..7a6051eed3c 100644 --- a/yii2-adapter/legacy/events/UserPermissionsEvent.php +++ b/yii2-adapter/legacy/events/UserPermissionsEvent.php @@ -8,12 +8,14 @@ namespace craft\events; use craft\base\Event; +use CraftCms\Cms\User\Events\UserPermissionsSaved; /** * UserPermissionsEvent class. * * @author Pixel & Tonic, Inc. * @since 4.3.0 + * @deprecated 6.0.0 use {@see UserPermissionsSaved} instead. */ class UserPermissionsEvent extends Event { diff --git a/yii2-adapter/legacy/fieldlayoutelements/FullNameField.php b/yii2-adapter/legacy/fieldlayoutelements/FullNameField.php index df5c6c89dc3..757b8bd116c 100644 --- a/yii2-adapter/legacy/fieldlayoutelements/FullNameField.php +++ b/yii2-adapter/legacy/fieldlayoutelements/FullNameField.php @@ -7,11 +7,11 @@ namespace craft\fieldlayoutelements; -use Craft; use craft\base\ElementInterface; use craft\helpers\Cp; use CraftCms\Cms\Cms; use CraftCms\Cms\Support\Html as HtmlHelper; +use Illuminate\Support\Facades\Auth; use function CraftCms\Cms\t; /** @@ -83,7 +83,7 @@ private function firstAndLastNameFields(?ElementInterface $element, bool $static $statusClass = $this->statusClass($element); $status = $statusClass ? [$statusClass, $this->statusLabel($element, $static) ?? ucfirst($statusClass)] : null; $required = !$static && $this->required; - $isAdmin = Craft::$app->getUser()->getIsAdmin(); + $isAdmin = Auth::user()?->isAdmin(); return HtmlHelper::beginTag('div', ['class' => ['flex', 'flex-nowrap', 'fullwidth']]) . Cp::textFieldHtml([ diff --git a/yii2-adapter/legacy/fieldlayoutelements/TextField.php b/yii2-adapter/legacy/fieldlayoutelements/TextField.php index 97fc3980d09..f163a8b5edb 100644 --- a/yii2-adapter/legacy/fieldlayoutelements/TextField.php +++ b/yii2-adapter/legacy/fieldlayoutelements/TextField.php @@ -9,6 +9,7 @@ use Craft; use craft\base\ElementInterface; +use Illuminate\Support\Facades\Auth; /** * TextField represents a text field that can be included in field layouts. @@ -190,7 +191,7 @@ protected function actionMenuItems(?ElementInterface $element = null, bool $stat { $items = []; - if (Craft::$app->getUser()->getIsAdmin()) { + if (Auth::user()?->isAdmin()) { $items[] = $this->copyAttributeAction(); } diff --git a/yii2-adapter/legacy/fieldlayoutelements/TextareaField.php b/yii2-adapter/legacy/fieldlayoutelements/TextareaField.php index 197028b2a6b..bf8f3e36c5e 100644 --- a/yii2-adapter/legacy/fieldlayoutelements/TextareaField.php +++ b/yii2-adapter/legacy/fieldlayoutelements/TextareaField.php @@ -10,6 +10,7 @@ use Craft; use craft\base\ElementInterface; use CraftCms\Cms\Support\Html as HtmlHelper; +use Illuminate\Support\Facades\Auth; /** * TextareaField represents a textarea field that can be included in field layouts. @@ -146,7 +147,7 @@ protected function actionMenuItems(?ElementInterface $element = null, bool $stat { $items = []; - if (Craft::$app->getUser()->getIsAdmin()) { + if (Auth::user()?->isAdmin()) { $items[] = $this->copyAttributeAction(); } diff --git a/yii2-adapter/legacy/fieldlayoutelements/TitleField.php b/yii2-adapter/legacy/fieldlayoutelements/TitleField.php index 18ad3b6a914..890c9d097ad 100644 --- a/yii2-adapter/legacy/fieldlayoutelements/TitleField.php +++ b/yii2-adapter/legacy/fieldlayoutelements/TitleField.php @@ -11,6 +11,7 @@ use craft\base\ElementInterface; use craft\helpers\ElementHelper; use CraftCms\Cms\Support\Str; +use Illuminate\Support\Facades\Auth; use function CraftCms\Cms\t; /** @@ -117,7 +118,7 @@ public function formHtml(?ElementInterface $element = null, bool $static = false if (slugInput.length && !slugInput.val().length) { new Craft.SlugGenerator($('#' + $titleId), slugInput, { charMap: $charMap, - }); + }) } })(); JS, [ @@ -145,7 +146,7 @@ protected function actionMenuItems(?ElementInterface $element = null, bool $stat { $items = []; - if (Craft::$app->getUser()->getIsAdmin()) { + if (Auth::user()?->isAdmin()) { $items[] = $this->copyAttributeAction(); } diff --git a/yii2-adapter/legacy/fieldlayoutelements/addresses/CountryCodeField.php b/yii2-adapter/legacy/fieldlayoutelements/addresses/CountryCodeField.php index ba54e75a5c9..1521ed057bc 100644 --- a/yii2-adapter/legacy/fieldlayoutelements/addresses/CountryCodeField.php +++ b/yii2-adapter/legacy/fieldlayoutelements/addresses/CountryCodeField.php @@ -8,13 +8,13 @@ namespace craft\fieldlayoutelements\addresses; use CommerceGuys\Addressing\Country\Country; -use Craft; use craft\base\ElementInterface; use craft\elements\Address; use craft\fieldlayoutelements\BaseNativeField; use craft\helpers\Cp; use CraftCms\Cms\Address\Addresses; use CraftCms\Cms\Support\Html; +use Illuminate\Support\Facades\Auth; use yii\base\InvalidArgumentException; use function CraftCms\Cms\t; @@ -141,7 +141,7 @@ protected function actionMenuItems(?ElementInterface $element = null, bool $stat { $items = []; - if (Craft::$app->getUser()->getIsAdmin()) { + if (Auth::user()?->isAdmin()) { $items[] = $this->copyAttributeAction(); } diff --git a/yii2-adapter/legacy/fieldlayoutelements/addresses/LatLongField.php b/yii2-adapter/legacy/fieldlayoutelements/addresses/LatLongField.php index c94a7f097c1..c7a00ab4c27 100644 --- a/yii2-adapter/legacy/fieldlayoutelements/addresses/LatLongField.php +++ b/yii2-adapter/legacy/fieldlayoutelements/addresses/LatLongField.php @@ -7,12 +7,12 @@ namespace craft\fieldlayoutelements\addresses; -use Craft; use craft\base\ElementInterface; use craft\elements\Address; use craft\fieldlayoutelements\BaseNativeField; use craft\helpers\Cp; use CraftCms\Cms\Support\Html; +use Illuminate\Support\Facades\Auth; use yii\base\InvalidArgumentException; use function CraftCms\Cms\t; @@ -123,7 +123,7 @@ protected function inputHtml(ElementInterface $element = null, bool $static = fa throw new InvalidArgumentException(sprintf('%s can only be used in address field layouts.', self::class)); } - $isAdmin = Craft::$app->getUser()->getIsAdmin(); + $isAdmin = Auth::user()?->isAdmin(); return Html::beginTag('div', ['class' => 'flex-fields']) . diff --git a/yii2-adapter/legacy/fieldlayoutelements/users/AffiliatedSiteField.php b/yii2-adapter/legacy/fieldlayoutelements/users/AffiliatedSiteField.php index 1486d4f7e97..136d76e17a0 100644 --- a/yii2-adapter/legacy/fieldlayoutelements/users/AffiliatedSiteField.php +++ b/yii2-adapter/legacy/fieldlayoutelements/users/AffiliatedSiteField.php @@ -7,13 +7,13 @@ namespace craft\fieldlayoutelements\users; -use Craft; use craft\base\ElementInterface; use craft\fieldlayoutelements\BaseNativeField; use craft\helpers\Cp; use CraftCms\Cms\Site\Data\Site; use CraftCms\Cms\Support\Facades\Sites; use CraftCms\Cms\User\Elements\User; +use Illuminate\Support\Facades\Auth; use yii\base\InvalidArgumentException; use function CraftCms\Cms\t; @@ -113,7 +113,7 @@ protected function actionMenuItems(?ElementInterface $element = null, bool $stat { $items = []; - if (Craft::$app->getUser()->getIsAdmin()) { + if (Auth::user()?->isAdmin()) { $items[] = $this->copyAttributeAction([ 'attribute' => 'affiliatedSite', ]); diff --git a/yii2-adapter/legacy/fieldlayoutelements/users/PhotoField.php b/yii2-adapter/legacy/fieldlayoutelements/users/PhotoField.php index b4550c18ac6..a587a6c8002 100644 --- a/yii2-adapter/legacy/fieldlayoutelements/users/PhotoField.php +++ b/yii2-adapter/legacy/fieldlayoutelements/users/PhotoField.php @@ -13,6 +13,7 @@ use craft\web\assets\userphoto\UserPhotoAsset; use CraftCms\Cms\ProjectConfig\ProjectConfig; use CraftCms\Cms\User\Elements\User; +use Illuminate\Support\Facades\Auth; use yii\base\InvalidArgumentException; use function CraftCms\Cms\t; @@ -100,7 +101,7 @@ protected function inputHtml(?ElementInterface $element = null, bool $static = f $view->registerJsWithVars(fn($userId, $inputId, $isCurrentUser) => <<id, $view->namespaceInputId($inputId), @@ -120,7 +121,7 @@ protected function actionMenuItems(?ElementInterface $element = null, bool $stat { $items = []; - if (Craft::$app->getUser()->getIsAdmin()) { + if (Auth::user()?->isAdmin()) { $items[] = $this->copyAttributeAction(); } diff --git a/yii2-adapter/legacy/helpers/App.php b/yii2-adapter/legacy/helpers/App.php index 2fc3f034d70..45b3309bf7c 100644 --- a/yii2-adapter/legacy/helpers/App.php +++ b/yii2-adapter/legacy/helpers/App.php @@ -26,6 +26,7 @@ use craft\web\Response as WebResponse; use craft\web\User as WebUser; use craft\web\View; +use CraftCms\Cms\Auth\RememberedUsername; use CraftCms\Cms\Cms; use CraftCms\Cms\Edition; use CraftCms\Cms\License\License; @@ -37,6 +38,7 @@ use CraftCms\Cms\Translation\Locale; use CraftCms\Cms\User\Elements\User; use CraftCms\Yii2Adapter\Cache; +use Illuminate\Support\Facades\Cookie; use yii\base\Event; use yii\base\Exception; use yii\base\InvalidArgumentException; @@ -817,8 +819,6 @@ public static function userConfig(): array $loginUrl = UrlHelper::cpUrl(Request::CP_PATH_LOGIN); } - $stateKeyPrefix = md5('Craft.' . WebUser::class . '.' . Craft::$app->getEnvId()); - return [ 'class' => WebUser::class, 'identityClass' => User::class, @@ -826,7 +826,7 @@ public static function userConfig(): array 'autoRenewCookie' => true, 'loginUrl' => $loginUrl, 'authTimeout' => $generalConfig->userSessionDuration ?: null, - 'usernameCookie' => Craft::cookieConfig(['name' => $stateKeyPrefix . '_username']), + 'usernameCookie' => Craft::cookieConfig(['name' => RememberedUsername::cookieName()]), ]; } diff --git a/yii2-adapter/legacy/helpers/Cp.php b/yii2-adapter/legacy/helpers/Cp.php index 0f58af74c54..63969f27de0 100644 --- a/yii2-adapter/legacy/helpers/Cp.php +++ b/yii2-adapter/legacy/helpers/Cp.php @@ -1717,10 +1717,9 @@ public static function fieldHtml(string|callable $input, array $config = []): st $errors ? 'has-errors' : null, ]), Html::explodeClass($config['fieldClass'] ?? [])); - $userSessionService = Craft::$app->getUser(); $showAttribute = ( ($config['showAttribute'] ?? false) && - $userSessionService->getIsAdmin() && + Auth::user()->isAdmin() && Auth::user()->getPreference('showFieldHandles') ); $showActionMenu = ( diff --git a/yii2-adapter/legacy/models/CategoryGroup.php b/yii2-adapter/legacy/models/CategoryGroup.php index 7ab7c8d52df..d0bbcecadd2 100644 --- a/yii2-adapter/legacy/models/CategoryGroup.php +++ b/yii2-adapter/legacy/models/CategoryGroup.php @@ -22,6 +22,7 @@ use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Str; use DateTime; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use function CraftCms\Cms\t; @@ -140,7 +141,7 @@ public function getUiLabel(): string */ public function getCpEditUrl(): ?string { - if (!$this->id || !Craft::$app->getUser()->getIsAdmin()) { + if (!$this->id || !Auth::user()?->isAdmin()) { return null; } return UrlHelper::cpUrl("settings/categories/$this->id"); diff --git a/yii2-adapter/legacy/models/Volume.php b/yii2-adapter/legacy/models/Volume.php index 7eb1a38882b..28ef958fce4 100644 --- a/yii2-adapter/legacy/models/Volume.php +++ b/yii2-adapter/legacy/models/Volume.php @@ -28,6 +28,7 @@ use CraftCms\Cms\Support\Str; use Generator; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Facades\Auth; use yii\base\InvalidConfigException; use function CraftCms\Cms\t; @@ -204,7 +205,7 @@ public function getUiLabel(): string */ public function getCpEditUrl(): ?string { - if (!$this->id || !Craft::$app->getUser()->getIsAdmin()) { + if (!$this->id || !Auth::user()?->isAdmin()) { return null; } return UrlHelper::cpUrl("settings/assets/volumes/$this->id"); diff --git a/yii2-adapter/legacy/queue/Queue.php b/yii2-adapter/legacy/queue/Queue.php index 5ccd3e87184..6b471688b35 100644 --- a/yii2-adapter/legacy/queue/Queue.php +++ b/yii2-adapter/legacy/queue/Queue.php @@ -22,6 +22,7 @@ use CraftCms\Cms\Support\Json; use CraftCms\Cms\Support\Str; use DateTime; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; use yii\base\Exception; use yii\base\InvalidArgumentException; @@ -552,7 +553,7 @@ public function getJobInfo(?int $limit = null): array $info = []; foreach ($results as $result) { - if (!app()->hasDebugModeEnabled() && !Craft::$app->getUser()->getIsAdmin()) { + if (!app()->hasDebugModeEnabled() && !Auth::user()?->isAdmin()) { $result['error'] = t('A server error occurred.'); } @@ -626,7 +627,7 @@ public function handleResponse(): void (function(){ try { var req = new XMLHttpRequest(); - req.open('GET', $url, true); + req.open('GET', $url, true) req.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); if (req.readyState === 4) return; req.send(); diff --git a/yii2-adapter/legacy/services/Auth.php b/yii2-adapter/legacy/services/Auth.php index 53088e75ab5..ef449e68407 100644 --- a/yii2-adapter/legacy/services/Auth.php +++ b/yii2-adapter/legacy/services/Auth.php @@ -20,6 +20,7 @@ use craft\helpers\User as UserHelper; use craft\web\Session; use craft\web\View; +use CraftCms\Cms\Auth\Impersonation; use CraftCms\Cms\Auth\Models\WebAuthn; use CraftCms\Cms\Cms; use CraftCms\Cms\Edition; @@ -27,7 +28,6 @@ use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Json; use CraftCms\Cms\User\Elements\User; -use CraftCms\Yii2Adapter\IdentityWrapper; use DateTime; use GuzzleHttp\Psr7\ServerRequest; use Illuminate\Support\Collection; @@ -133,7 +133,7 @@ public function getUser(?int &$sessionDuration = null): ?User $this->_user = false; $this->_sessionDuration = false; $session = Craft::$app->getSession(); - $userId = $session->get($this->userIdParam); + $userId = $session->get('user.id'); if ($userId) { $user = User::findOne($userId); @@ -220,13 +220,12 @@ public function verify(string $methodClass, mixed ...$args): bool $this->setUser(null); // if we're impersonating, pass the user we're impersonating to the complete the login - $userSession = Craft::$app->getUser(); - if ($userSession->getImpersonator() !== null) { + if (app(Impersonation::class)->isImpersonating()) { /** @var User $user */ $user = AuthFacade::user(); } - $userSession->login(new IdentityWrapper($user), $sessionDuration); + AuthFacade::setRememberDuration($sessionDuration)->login($user, true); } return true; diff --git a/yii2-adapter/legacy/services/Gql.php b/yii2-adapter/legacy/services/Gql.php index 1db82323dbf..4dbfffd3a18 100644 --- a/yii2-adapter/legacy/services/Gql.php +++ b/yii2-adapter/legacy/services/Gql.php @@ -94,6 +94,7 @@ use GraphQL\Validator\Rules\QueryComplexity; use GraphQL\Validator\Rules\QueryDepth; use Illuminate\Database\Query\Builder; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Throwable; @@ -469,7 +470,7 @@ public function getValidationRules(bool $debug = false, bool $isIntrospectionQue } } - if (!$generalConfig->enableGraphqlIntrospection && Craft::$app->getUser()->getIsGuest()) { + if (!$generalConfig->enableGraphqlIntrospection && Auth::guest()) { $validationRules[DisableIntrospection::class] = new DisableIntrospection(); } diff --git a/yii2-adapter/legacy/services/Sso.php b/yii2-adapter/legacy/services/Sso.php index c5fd86d722f..b0dc484e7ac 100644 --- a/yii2-adapter/legacy/services/Sso.php +++ b/yii2-adapter/legacy/services/Sso.php @@ -14,11 +14,11 @@ use craft\errors\SsoFailedException; use craft\helpers\User as UserHelper; use CraftCms\Cms\Auth\Models\SsoIdentity; -use CraftCms\Cms\Cms; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Edition; use CraftCms\Cms\User\Elements\User; -use CraftCms\Yii2Adapter\IdentityWrapper; +use Illuminate\Support\Facades\Auth; +use Throwable; use Tpetry\QueryExpressions\Language\Alias; use yii\base\Component; use yii\base\InvalidConfigException; @@ -256,21 +256,10 @@ public function linkUserToIdentity(User $user, ProviderInterface $provider, stri */ public function loginUser(ProviderInterface $provider, User $user, ?int $sessionDuration = null, bool $rememberMe = false): bool { - $userSession = Craft::$app->getUser(); - if (!$userSession->getIsGuest()) { + if (Auth::check()) { return true; } - if (empty($sessionDuration)) { - // Get the session duration - $generalConfig = Cms::config(); - if ($rememberMe && $generalConfig->rememberedUserSessionDuration !== 0) { - $sessionDuration = $generalConfig->rememberedUserSessionDuration; - } else { - $sessionDuration = $generalConfig->userSessionDuration; - } - } - $user->authError = UserHelper::getAuthStatus($user); if (!empty($user->authError)) { @@ -278,8 +267,10 @@ public function loginUser(ProviderInterface $provider, User $user, ?int $session } // Try logging them in - if (!$userSession->login(new IdentityWrapper($user), $sessionDuration)) { - throw new SsoFailedException($provider, $user, t("Unable to login", category: 'auth')); + try { + Auth::setRememberDuration($sessionDuration ?? config('auth.guards.craft.remember', 576000))->login($user, $rememberMe); + } catch (Throwable $e) { + throw new SsoFailedException($provider, $user, t("Unable to login", category: 'auth'), previous: $e); } return true; diff --git a/yii2-adapter/legacy/test/CraftConnector.php b/yii2-adapter/legacy/test/CraftConnector.php index 3853419e0ee..dc3b2a66860 100644 --- a/yii2-adapter/legacy/test/CraftConnector.php +++ b/yii2-adapter/legacy/test/CraftConnector.php @@ -16,7 +16,6 @@ use craft\helpers\Db; use craft\helpers\Session; use craft\web\View; -use CraftCms\Cms\Cms; use CraftCms\Cms\Plugin\Contracts\PluginInterface; use CraftCms\Cms\Plugin\Exceptions\InvalidPluginException; use CraftCms\Cms\Plugin\Plugins; @@ -53,25 +52,6 @@ public function getEmails(): array return $this->emails; } - /** - * We override to prevent a bug with the matching of user agent and session. - * - * @throws ConfigurationException - */ - public function findAndLoginUser(mixed $user, bool $disableRequiredUserAgent = true): void - { - $oldRequirement = Cms::config()->requireUserAgentAndIpForSession; - if ($disableRequiredUserAgent) { - Cms::config()->requireUserAgentAndIpForSession = false; - } - - parent::findAndLoginUser($user); - - if ($disableRequiredUserAgent) { - Cms::config()->requireUserAgentAndIpForSession = $oldRequirement; - } - } - /** * {@inheritdoc} */ diff --git a/yii2-adapter/legacy/web/Application.php b/yii2-adapter/legacy/web/Application.php index 4cc5d71ea24..ec8c0f2e412 100644 --- a/yii2-adapter/legacy/web/Application.php +++ b/yii2-adapter/legacy/web/Application.php @@ -245,14 +245,14 @@ public function handleRequest($request, bool $skipSpecialHandling = false): Base ($firstSeg = $request->getSegment(1)) !== null && ($plugin = app(Plugins::class)->getPlugin($firstSeg)) !== null ) { - if ($userSession->getIsGuest()) { + if (Auth::guest()) { return $userSession->loginRequired(); } Gate::authorize('accessPlugin-' . $plugin->handle); } - if (!$userSession->getIsGuest()) { + if (!Auth::guest()) { // See if the user is expected to have 2FA enabled if (!$generalConfig->disable2fa) { $auth = $this->getAuth(); diff --git a/yii2-adapter/legacy/web/Controller.php b/yii2-adapter/legacy/web/Controller.php index 8501e56455b..63887bef225 100644 --- a/yii2-adapter/legacy/web/Controller.php +++ b/yii2-adapter/legacy/web/Controller.php @@ -214,7 +214,7 @@ private function _enforceAllowAnonymous(Action $action): void if ($isCpRequest) { $this->requireLogin(); $this->requirePermission('accessCp'); - } elseif (Craft::$app->getUser()->getIsGuest()) { + } elseif (Auth::guest()) { if ($isLive) { throw new ForbiddenHttpException(); } else { @@ -463,7 +463,7 @@ public function requireLogin(): void { $userSession = Craft::$app->getUser(); - if ($userSession->getIsGuest()) { + if (Auth::guest()) { $userSession->loginRequired(); Craft::$app->end(); } @@ -473,12 +473,13 @@ public function requireLogin(): void * Redirects the user to the account template if they are logged in. * * @since 3.4.0 + * @deprecated 6.0.0 use the "guest" middleware instead. */ public function requireGuest(): void { $userSession = Craft::$app->getUser(); - if (!$userSession->getIsGuest()) { + if (!Auth::guest()) { $userSession->guestRequired(); Craft::$app->end(); } @@ -497,7 +498,7 @@ public function requireAdmin(bool $requireAdminChanges = true): void $this->requireLogin(); // Make sure they're an admin - if (!Craft::$app->getUser()->getIsAdmin()) { + if (!Auth::user()?->isAdmin()) { throw new ForbiddenHttpException('User is not permitted to perform this action.'); } diff --git a/yii2-adapter/legacy/web/Request.php b/yii2-adapter/legacy/web/Request.php index f90edd4f3c1..132c6e13b41 100644 --- a/yii2-adapter/legacy/web/Request.php +++ b/yii2-adapter/legacy/web/Request.php @@ -9,6 +9,7 @@ use Craft; use craft\base\RequestTrait; +use CraftCms\Cms\Auth\Enums\CpAuthPath; use CraftCms\Cms\Cms; use CraftCms\Cms\Config\GeneralConfig; use CraftCms\Cms\Shared\Models\Info; @@ -54,11 +55,11 @@ class Request extends \CraftCms\Yii2Adapter\Web\Request { use RequestTrait; - public const CP_PATH_LOGIN = 'login'; - public const CP_PATH_LOGOUT = 'logout'; - public const CP_PATH_SET_PASSWORD = 'set-password'; - public const CP_PATH_VERIFY_EMAIL = 'verify-email'; - public const CP_PATH_UPDATE = 'update'; + public const CP_PATH_LOGIN = CpAuthPath::Login->value; + public const CP_PATH_LOGOUT = CpAuthPath::Logout->value; + public const CP_PATH_SET_PASSWORD = CpAuthPath::SetPassword->value; + public const CP_PATH_VERIFY_EMAIL = CpAuthPath::VerifyEmail->value; + public const CP_PATH_UPDATE = CpAuthPath::Update->value; /** * @inheritdoc diff --git a/yii2-adapter/legacy/web/UrlManager.php b/yii2-adapter/legacy/web/UrlManager.php index e7c038f47f0..253d81fcae5 100644 --- a/yii2-adapter/legacy/web/UrlManager.php +++ b/yii2-adapter/legacy/web/UrlManager.php @@ -374,11 +374,6 @@ private function _getRequestRoute(Request $request): mixed return $route; } - // Is this a "well-known" request? - if (($route = $this->_getMatchedDiscoverableUrlRoute($request)) !== false) { - return $route; - } - // Does it look like they're trying to access a public template path? return $this->_getTemplateRoute($request); } @@ -459,39 +454,6 @@ private function _getMatchedUrlRoute(Request $request): array|false return false; } - /** - * Attempts to match a path with a “well-known” URL. - * - * @param Request $request - * @return array|false - */ - private function _getMatchedDiscoverableUrlRoute(Request $request): array|false - { - $redirectUri = $request->getPathInfo() === '.well-known/change-password' - ? Cms::config()->getSetPasswordRequestPath(Sites::getCurrentSite()->handle) - : null; - - if (app()->hasDebugModeEnabled()) { - Craft::debug([ - 'rule' => 'Discoverable change password URL', - 'match' => $redirectUri !== null, - 'parent' => null, - ], __METHOD__); - } - - if (!$redirectUri) { - return false; - } - - return [ - 'redirect', - [ - 'url' => Craft::$app->getSecurity()->hashData($redirectUri), - 'statusCode' => 302, - ], - ]; - } - /** * Returns whether the current path is "public" (no segments that start with the privateTemplateTrigger). * diff --git a/yii2-adapter/legacy/web/User.php b/yii2-adapter/legacy/web/User.php index 816f2f3d66f..8970ea39b21 100644 --- a/yii2-adapter/legacy/web/User.php +++ b/yii2-adapter/legacy/web/User.php @@ -10,18 +10,16 @@ use Craft; use craft\helpers\DateTimeHelper; use craft\helpers\Session as SessionHelper; -use craft\helpers\UrlHelper; use CraftCms\Cms\Auth\Concerns\ConfirmsPasswords; +use CraftCms\Cms\Auth\Impersonation; +use CraftCms\Cms\Auth\RememberedUsername; use CraftCms\Cms\Cms; -use CraftCms\Cms\Database\Table; use CraftCms\Cms\Support\Config; use CraftCms\Cms\Support\Facades\Users; -use CraftCms\Cms\Support\Str; use CraftCms\Cms\User\Elements\User as UserElement; -use CraftCms\Yii2Adapter\IdentityWrapper; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\URL; use yii\web\Cookie; use yii\web\ForbiddenHttpException; use yii\web\IdentityInterface; @@ -59,17 +57,6 @@ class User extends \CraftCms\Yii2Adapter\Web\User */ public array $usernameCookie; - /** - * @var string The session variable name used to store the original user ID, when impersonating another user. - * @since 5.6.0 - */ - public string $impersonatorIdParam = '__impersonator_id'; - - /** - * @see getImpersonator() - */ - private UserElement|false $impersonator; - // Authentication // ------------------------------------------------------------------------- @@ -85,13 +72,7 @@ class User extends \CraftCms\Yii2Adapter\Web\User */ public function loginByUserId(int $userId, int $duration = 0): bool { - $user = Users::getUserById($userId); - - if (!$user) { - return false; - } - - return $this->login(new IdentityWrapper($user), $duration); + return Auth::loginUsingId($userId, $duration > 0) !== false; } /** @@ -105,39 +86,16 @@ public function loginByUserId(int $userId, int $duration = 0): bool */ public function sendUsernameCookie(UserElement $user): void { - $generalConfig = Cms::config(); - - if ($generalConfig->rememberUsernameDuration !== 0) { - $cookie = new Cookie($this->usernameCookie); - $cookie->value = $user->username; - $seconds = Config::durationInSeconds($generalConfig->rememberUsernameDuration); - $cookie->expire = DateTimeHelper::currentTimeStamp() + $seconds; - Craft::$app->getResponse()->getCookies()->add($cookie); - } else { - Craft::$app->getResponse()->getCookies()->remove(new Cookie($this->usernameCookie)); - } + RememberedUsername::set($user); } /** * @inheritdoc + * @deprecated 6.0.0 use {@see URL::returnUrl()} instead. */ public function getReturnUrl($defaultUrl = null): string { - // Set the default based on the config, if it’s not specified - if ($defaultUrl === null) { - if ($this->getIsGuest()) { - $defaultUrl = UrlHelper::actionUrl('users/redirect'); - } else { - $defaultUrl = $this->getDefaultReturnUrl(); - } - } - - $url = parent::getReturnUrl($defaultUrl); - - // Strip out any tags that may have gotten in there by accident - // i.e. if there was a {siteUrl} tag in the Site URL setting, but no matching environment variable, - // so they ended up on something like http://example.com/%7BsiteUrl%7D/some/path - return str_replace(['{', '}'], '', $url); + return URL::returnUrl($defaultUrl); } /** @@ -145,25 +103,22 @@ public function getReturnUrl($defaultUrl = null): string * * @return string * @since 5.6.2 + * @deprecated 6.0.0 use {@see URL::defaultReturnUrl()} instead. */ public function getDefaultReturnUrl(): string { - // Is this a control panel request and can they access the control panel? - if (Craft::$app->getRequest()->getIsCpRequest() && Gate::check('accessCp')) { - return UrlHelper::cpUrl(Cms::config()->getPostCpLoginRedirect()); - } - - return UrlHelper::siteUrl(Cms::config()->getPostLoginRedirect()); + return URL::defaultReturnUrl(); } /** * Removes the stored return URL, if there is one. * * @see getReturnUrl() + * @deprecated 6.0.0 use {@see \Illuminate\Support\Facades\Session::forget('_previous.url')} instead. */ public function removeReturnUrl(): void { - SessionHelper::remove($this->returnUrlParam); + \Illuminate\Support\Facades\Session::forget('_previous.url'); } /** @@ -200,10 +155,11 @@ public function getToken(): ?string * ``` * * @return string|null + * @deprecated 6.0.0 use {@see \Illuminate\Support\Facades\Cookie::getRememberedUsername()} instead. */ public function getRememberedUsername(): ?string { - return Craft::$app->getRequest()->getCookies()->getValue($this->usernameCookie['name']); + return RememberedUsername::get(); } /** @@ -230,7 +186,7 @@ public function getRememberedUsername(): ?string */ public function getIsGuest(): bool { - return parent::getIsGuest(); + return Auth::guest(); } /** @@ -239,13 +195,14 @@ public function getIsGuest(): bool * @return Response the redirection response * @throws ForbiddenHttpException if the request doesn’t accept a redirect response * @since 3.4.0 + * @deprecated 6.0.0 use the "guest" middleware instead. */ public function guestRequired(): Response { if (!$this->checkRedirectAcceptable()) { throw new ForbiddenHttpException(t('Guest Required')); } - return Craft::$app->getResponse()->redirect($this->getReturnUrl()); + return Craft::$app->getResponse()->redirect(URL::returnUrl()); } /** @@ -256,7 +213,7 @@ public function guestRequired(): Response public function getRemainingSessionTime(): int { // Are they logged in? - if (!$this->getIsGuest()) { + if (!Auth::guest()) { if (!isset($this->authTimeout)) { // The session duration must have been empty (expire when the HTTP session ends) return -1; @@ -278,25 +235,11 @@ public function getRemainingSessionTime(): int * * @return UserElement|null * @since 5.6.0 + * @deprecated 6.0.0 use {@see Impersonation::getImpersonator()} instead. */ public function getImpersonator(): ?UserElement { - if (!isset($this->impersonator)) { - $impersonatorId = SessionHelper::get($this->impersonatorIdParam); - if (!$impersonatorId) { - return null; - } - - $impersonator = UserElement::find() - ->id($impersonatorId) - ->one(); - - $this->impersonator = $impersonator?->can('impersonateUsers') - ? $impersonator - : false; - } - - return $this->impersonator ?: null; + return app(Impersonation::class)->getImpersonator(); } /** @@ -304,25 +247,24 @@ public function getImpersonator(): ?UserElement * * @return int|null * @since 5.6.0 + * @deprecated 6.0.0 use {@see Impersonation::getImpersonatorId()} instead. */ public function getImpersonatorId(): ?int { - return $this->getImpersonator()?->id; + return app(Impersonation::class)->getImpersonatorId(); } /** * Sets the ID of the original user, if the current user is being impersonated. * * @param int|null $id + * * @since 5.6.0 + * @deprecated 6.0.0 use {@see Impersonation::setImpersonatorId()} instead. */ public function setImpersonatorId(?int $id): void { - if ($id) { - SessionHelper::set($this->impersonatorIdParam, $id); - } else { - SessionHelper::remove($this->impersonatorIdParam); - } + app(Impersonation::class)->setImpersonatorId($id); } // Authorization @@ -332,6 +274,7 @@ public function setImpersonatorId(?int $id): void * Returns whether the current user is an admin. * * @return bool Whether the current user is an admin. + * @deprecated 6.0.0 use `Auth::user()?->isAdmin()` instead. */ public function getIsAdmin(): bool { @@ -399,20 +342,6 @@ public function login(IdentityInterface $identity, $duration = 0): bool return $success; } - /** - * @inheritdoc - */ - protected function beforeLogin($identity, $cookieBased, $duration): bool - { - // Only allow the login if the request meets our user agent and IP requirements - if (!$this->_validateUserAgentAndIp()) { - Craft::warning('Request didn’t meet the user agent and IP requirements for creating a user session.', __METHOD__); - return false; - } - - return parent::beforeLogin($identity, $cookieBased, $duration); - } - /** * @inheritdoc */ @@ -428,9 +357,9 @@ protected function afterLogin($identity, $cookieBased, $duration): void $this->_clearOtherSessionParams(); // Save the username cookie if they're not being impersonated - $impersonator = $this->getImpersonator(); + $impersonator = app(Impersonation::class)->getImpersonator(); if (!$impersonator) { - $this->sendUsernameCookie(UserElement::find()->id($identity->getId())->firstOrFail()); + RememberedUsername::set(UserElement::find()->id($identity->getId())->firstOrFail()); } // Update the user record @@ -441,48 +370,6 @@ protected function afterLogin($identity, $cookieBased, $duration): void parent::afterLogin($identity, $cookieBased, $duration); } - /** - * @inheritdoc - */ - public function switchIdentity($identity, $duration = 0): void - { - if ($this->enableSession) { - SessionHelper::remove($this->tokenParam); - - if ($identity) { - /** @var UserElement $identity */ - // Generate a new session token - $this->generateToken($identity->id); - } - } - - $this->_clearOtherSessionParams(); - - parent::switchIdentity($identity, $duration); - } - - /** - * Generates a new user session token. - * - * @param int $userId - * @since 3.1.1 - */ - public function generateToken(int $userId): void - { - $token = Craft::$app->getSecurity()->generateRandomString(100); - - DB::table(Table::SESSIONS) - ->insert([ - 'userId' => $userId, - 'token' => $token, - 'dateCreated' => $now = now(), - 'dateUpdated' => $now, - 'uid' => Str::uuid(), - ]); - - SessionHelper::set($this->tokenParam, $token); - } - /** * @inheritdoc */ @@ -501,14 +388,6 @@ protected function renewAuthStatus(): void return; } - if (!$this->_validateUserAgentAndIp()) { - // Only log a warning if a PHP session exists - if (Craft::$app->getSession()->getHasSessionId()) { - Craft::warning('Request didn’t meet the user agent and IP requirements for maintaining a user session.', __METHOD__); - } - return; - } - // Should we be extending the user’s session on this request? $extendSession = !Craft::$app->getRequest()->getParam('dontExtendSession'); @@ -548,17 +427,6 @@ protected function beforeLogout($identity): bool // Stop keeping track of the session duration specified on login SessionHelper::remove($this->authDurationParam); - // Delete the session token in the database - $token = $this->getToken(); - if ($token !== null) { - SessionHelper::remove($this->tokenParam); - - DB::table(Table::SESSIONS) - ->where('token', $token) - ->where('userId', $identity->getId()) - ->delete(); - } - return true; } @@ -567,10 +435,6 @@ protected function beforeLogout($identity): bool */ protected function afterLogout($identity): void { - // Delete the impersonation session, if there is one - SessionHelper::remove($this->impersonatorIdParam); - $this->impersonator = false; - $this->_clearOtherSessionParams(); if (Cms::config()->enableCsrfProtection) { @@ -581,23 +445,6 @@ protected function afterLogout($identity): void parent::afterLogout($identity); } - /** - * Validates that the request has a user agent and IP associated with it, - * if the 'requireUserAgentAndIpForSession' config setting is enabled. - * - * @return bool - */ - private function _validateUserAgentAndIp(): bool - { - if (!Cms::config()->requireUserAgentAndIpForSession) { - return true; - } - - $request = Craft::$app->getRequest(); - - return $request->getUserAgent() !== null && $request->getUserIP() !== null; - } - private function _clearOtherSessionParams(): void { // Make sure 2FA data doesn't bleed over diff --git a/yii2-adapter/legacy/web/View.php b/yii2-adapter/legacy/web/View.php index 5b80c34ef89..3c642236440 100644 --- a/yii2-adapter/legacy/web/View.php +++ b/yii2-adapter/legacy/web/View.php @@ -26,6 +26,7 @@ use craft\web\twig\TemplateLoader; use CraftCms\Cms\Cms; use CraftCms\Cms\Element\ElementSources; +use CraftCms\Cms\Shared\Models\Info; use CraftCms\Cms\Support\Facades\Deprecator; use CraftCms\Cms\Support\Facades\Sites; use CraftCms\Cms\Support\Html; @@ -1064,7 +1065,7 @@ private function _resolveTemplateInternal(string $name, bool $publicOnly): strin $basePaths = []; // Should we be looking for a localized version of the template? - if ($this->_templateMode === self::TEMPLATE_MODE_SITE && Craft::$app->getIsInstalled()) { + if ($this->_templateMode === self::TEMPLATE_MODE_SITE && Info::isInstalled()) { /** @noinspection PhpUnhandledExceptionInspection */ $sitePath = $this->_templatesPath . DIRECTORY_SEPARATOR . Sites::getCurrentSite()->handle; if (is_dir($sitePath)) { diff --git a/yii2-adapter/legacy/web/assets/cp/CpAsset.php b/yii2-adapter/legacy/web/assets/cp/CpAsset.php index ecc0cd50d3c..9ad18cfae5f 100644 --- a/yii2-adapter/legacy/web/assets/cp/CpAsset.php +++ b/yii2-adapter/legacy/web/assets/cp/CpAsset.php @@ -33,6 +33,7 @@ use craft\web\assets\xregexp\XregexpAsset; use craft\web\View; use CraftCms\Cms\Announcement\Announcements; +use CraftCms\Cms\Auth\Impersonation; use CraftCms\Cms\Cms; use CraftCms\Cms\Config\GeneralConfig; use CraftCms\Cms\Edition; @@ -648,7 +649,7 @@ private function _craftData(): array 'siteToken' => $generalConfig->siteToken, 'slugWordSeparator' => $generalConfig->slugWordSeparator, 'userEmail' => $currentUser->email, - 'userHasPasskeys' => Craft::$app->getAuth()->hasPasskeys($userSession->getImpersonator() ?? $currentUser), + 'userHasPasskeys' => Craft::$app->getAuth()->hasPasskeys(app(Impersonation::class)->getImpersonator() ?? $currentUser), 'userId' => $currentUser->id, 'userIsAdmin' => $currentUser->admin, 'username' => $currentUser->username, diff --git a/yii2-adapter/legacy/web/twig/Extension.php b/yii2-adapter/legacy/web/twig/Extension.php index 894d8591540..f872f8878f6 100644 --- a/yii2-adapter/legacy/web/twig/Extension.php +++ b/yii2-adapter/legacy/web/twig/Extension.php @@ -54,6 +54,7 @@ use craft\web\twig\variables\CraftVariable; use craft\web\View; use CraftCms\Cms\Address\Addresses; +use CraftCms\Cms\Auth\RememberedUsername; use CraftCms\Cms\Cms; use CraftCms\Cms\Database\Queries\AddressQuery; use CraftCms\Cms\Database\Queries\AssetQuery; @@ -1828,6 +1829,7 @@ public function getGlobals(): array 'pluginAssets' => app(Plugins::class)->getAssetsHtml(), 'currentSite' => $currentSite, 'currentUser' => $currentUser, + 'rememberedUsername' => RememberedUsername::get(), 'primarySite' => $primarySite, 'siteName' => $siteName, 'siteUrl' => $siteUrl, diff --git a/yii2-adapter/legacy/web/twig/variables/Cp.php b/yii2-adapter/legacy/web/twig/variables/Cp.php index d24832cc5c6..2abfb7d8334 100644 --- a/yii2-adapter/legacy/web/twig/variables/Cp.php +++ b/yii2-adapter/legacy/web/twig/variables/Cp.php @@ -32,6 +32,7 @@ use CraftCms\Cms\Utility\Utility; use DateTime; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Gate; use RecursiveCallbackFilterIterator; @@ -224,7 +225,7 @@ public function craftIdAccountUrl(): string */ public function nav(): array { - $isAdmin = Craft::$app->getUser()->getIsAdmin(); + $isAdmin = Auth::user()?->isAdmin(); $generalConfig = Cms::config(); $navItems = [ diff --git a/yii2-adapter/src/Console/MigrateSessionsTableCommand.php b/yii2-adapter/src/Console/MigrateSessionsTableCommand.php new file mode 100644 index 00000000000..5ce41ed7bc2 --- /dev/null +++ b/yii2-adapter/src/Console/MigrateSessionsTableCommand.php @@ -0,0 +1,63 @@ +info('Sessions table already migrated.'); + + return; + } + + if (!$this->confirmToProceed('Application In Production or allowAdminChanges is disabled.')) { + return; + } + + $this->components->task( + 'Dropping old sessions table', + fn() => Schema::dropIfExists(Table::SESSIONS), + ); + + $this->components->task( + 'Creating new sessions table', + function() { + Schema::create('sessions', function(Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + }, + ); + } + + protected function getDefaultConfirmCallback(): Closure + { + return function() { + return $this->getLaravel()->environment() === 'production' || !Cms::config()->allowAdminChanges; + }; + } +} diff --git a/yii2-adapter/src/Http/LegacyMiddleware.php b/yii2-adapter/src/Http/LegacyMiddleware.php index 767b3c7e098..be78093837f 100644 --- a/yii2-adapter/src/Http/LegacyMiddleware.php +++ b/yii2-adapter/src/Http/LegacyMiddleware.php @@ -11,12 +11,12 @@ use Closure; use Craft; +use craft\helpers\App; use CraftCms\Cms\Support\Json; use CraftCms\Yii2Adapter\Web\DummyResponse; use Illuminate\Foundation\Application; use Illuminate\Http\Request; use Illuminate\Http\Response; -use Symfony\Component\HttpKernel\Exception\HttpException; use yii\base\ExitException as YiiExitException; use yii\web\HttpException as YiiHttpException; @@ -62,7 +62,7 @@ public function handle(Request $request, Closure $next): mixed /** * Reset the user as it could have been set before. */ - Craft::$app->set('user', Craft::createObject(\craft\helpers\App::userConfig())); + Craft::$app->set('user', Craft::createObject(App::userConfig())); Craft::$app->run(); return $this->createResponse(); @@ -74,7 +74,7 @@ public function handle(Request $request, Closure $next): mixed return $next($request); } - throw new HttpException($e->statusCode, $e->getMessage(), $e, [], $e->getCode()); + throw $e; } catch (YiiExitException $e) { // In case Yii requests application termination - request is considered handled return $this->createResponse(); diff --git a/yii2-adapter/src/IdentityWrapper.php b/yii2-adapter/src/IdentityWrapper.php index d924e44fb2f..b38004dec5e 100644 --- a/yii2-adapter/src/IdentityWrapper.php +++ b/yii2-adapter/src/IdentityWrapper.php @@ -4,8 +4,7 @@ namespace CraftCms\Yii2Adapter; -use Craft; -use CraftCms\Cms\Cms; +use CraftCms\Cms\Auth\Impersonation; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Support\Json; use CraftCms\Cms\User\Elements\User; @@ -67,7 +66,7 @@ public static function findIdentity($id): ?self // Only accept active users, unless they're being impersonated if ( $user->getStatus() !== User::STATUS_ACTIVE && - !Craft::$app->getUser()->getImpersonator() + !app(Impersonation::class)->isImpersonating() ) { return null; } @@ -87,13 +86,13 @@ public function getId(): int|null public function getAuthKey(): string { - $token = Craft::$app->getUser()->getToken(); + $token = session()->id(); if ($token === null) { throw new Exception('No user session token exists.'); } - $userAgent = Craft::$app->getRequest()->getUserAgent(); + $userAgent = request()->userAgent(); // The auth key is a combination of the hashed token, its row's UID, and the user agent string return Json::encode([ @@ -113,13 +112,9 @@ public function validateAuthKey($authKey): bool [$token, , $userAgent] = $data; - if (!$this->_validateUserAgent($userAgent)) { - return false; - } - $tokenId = DbFacade::table(Table::SESSIONS) ->where('token', $token) - ->where('userId', $this->id) + ->where('user_id', $this->id) ->value('id'); if (!$tokenId) { @@ -129,34 +124,7 @@ public function validateAuthKey($authKey): bool // Update the session row's dateUpdated value so it doesn't get GC'd DbFacade::table(Table::SESSIONS) ->where('id', $tokenId) - ->update([ - 'dateUpdated' => now(), - ]); - - return true; - } - - /** - * Validates a cookie's stored user agent against the current request's user agent string, - * if the 'requireMatchingUserAgentForSession' config setting is enabled. - */ - private function _validateUserAgent(string $userAgent): bool - { - if (!Cms::config()->requireMatchingUserAgentForSession) { - return true; - } - - $requestUserAgent = Craft::$app->getRequest()->getUserAgent(); - - if (!$requestUserAgent) { - return false; - } - - if (!hash_equals($userAgent, md5($requestUserAgent))) { - Craft::warning('Tried to restore session from the the identity cookie, but the saved user agent (' . $userAgent . ') does not match the current request’s (' . $requestUserAgent . ').', __METHOD__); - - return false; - } + ->update(['last_activity' => now()]); return true; } diff --git a/yii2-adapter/src/Yii2ServiceProvider.php b/yii2-adapter/src/Yii2ServiceProvider.php index bcac966dd50..2e36cc49e3f 100644 --- a/yii2-adapter/src/Yii2ServiceProvider.php +++ b/yii2-adapter/src/Yii2ServiceProvider.php @@ -103,6 +103,7 @@ use CraftCms\Yii2Adapter\Console\DropTagsSupportCommand; use CraftCms\Yii2Adapter\Console\LegacyCraftCommand; use CraftCms\Yii2Adapter\Console\MigrateMigrationTableCommand; +use CraftCms\Yii2Adapter\Console\MigrateSessionsTableCommand; use CraftCms\Yii2Adapter\Console\RepairCategoryGroupStructureCommand; use CraftCms\Yii2Adapter\Http\Controller; use GraphQL\Type\Definition\Type; @@ -322,6 +323,7 @@ public function boot(): void DropGlobalSetsSupportCommand::class, DropTagsSupportCommand::class, MigrateMigrationTableCommand::class, + MigrateSessionsTableCommand::class, RepairCategoryGroupStructureCommand::class, ]); @@ -367,6 +369,7 @@ public function boot(): void }); $this->ensureNewMigrationTable(); + $this->ensureNewSessionsTable(); if (!$this->app->runningInConsole()) { return; @@ -1211,4 +1214,31 @@ private function ensureNewMigrationTable(): void // No database connection } } + + /** + * Check if we're dealing with an older sessions table. + * In that case we'll need to migrate this on the fly. + */ + private function ensureNewSessionsTable(): void + { + try { + if (Schema::hasColumn(Table::SESSIONS, 'payload')) { + return; + } + + if (!Cms::config()->allowAdminChanges) { + throw new RuntimeException('The sessions table has the wrong schema structure and allowAdminChanges is disabled. Run `php craft migrate:sessions-table` to migrate the table to the new format.'); + } + + if (app()->environment('workbench')) { + return; + } + + Artisan::call('craft:migrate:sessions-table', [ + '--force' => true, + ]); + } catch (PDOException) { + // No database connection + } + } } diff --git a/yii2-adapter/tests/unit/db/ActiveRecordTest.php b/yii2-adapter/tests/unit/db/ActiveRecordTest.php deleted file mode 100644 index 9e17a5d5444..00000000000 --- a/yii2-adapter/tests/unit/db/ActiveRecordTest.php +++ /dev/null @@ -1,143 +0,0 @@ - - * @author Global Network Group | Giel Tettelaar - * @since 3.2 - */ -class ActiveRecordTest extends TestCase -{ - /** - * @var UnitTester - */ - public UnitTester $tester; - - /** - * Note this test is just here to verify that these are indeed craft\db\ActiveRecord classes. - */ - public function testIsCraftAr(): void - { - self::assertInstanceOf(ActiveRecord::class, new Volume()); - self::assertInstanceOf(ActiveRecord::class, new Session()); - } - - /** - * @throws Exception - */ - public function testDateCreated(): void - { - $date = new DateTime('now', new DateTimeZone('UTC')); - $session = $this->ensureSession(); - - $this->tester->assertEqualDates($this, $session->dateCreated, $date->format('Y-m-d H:i:s'), 2); - - $session->delete(); - } - - /** - * @throws Exception - */ - public function testDateUpdated(): void - { - $session = $this->ensureSession(); - - // Ensure that there is a diff in dates.... - sleep(5); - - $dateTimeZone = new DateTimeZone('UTC'); - $oldDate = new DateTime($session->dateUpdated, $dateTimeZone); - - // Save it again. Ensure dateUpdated is the same, as nothing has changed. - $session->save(); - $this->tester->assertEqualDates($this, $session->dateUpdated, $oldDate->format('Y-m-d H:i:s'), 1); - - // Save it again with a new value. Ensure dateUpdated is now current. - $date = new DateTime('now', $dateTimeZone); - self::assertGreaterThan($oldDate, $date); - - $session->token = 'test2'; - $session->save(); - $this->tester->assertEqualDates($this, $session->dateUpdated, $date->format('Y-m-d H:i:s'), 1); - - $session->delete(); - } - - /** - * - */ - public function testUuid(): void - { - $session = $this->ensureSession(); - - self::assertTrue(Str::isUuid($session->uid)); - - $session->delete(); - } - - /** - * - */ - public function testUUIDThatIsntValid(): void - { - $session = new Session(); - $session->userId = 1; - $session->token = 'test'; - $session->uid = '00000000|0000|0000|0000|000000000000'; - $save = $session->save(); - - self::assertTrue($save); - self::assertSame('00000000|0000|0000|0000|000000000000', $session->uid); - - $session->delete(); - } - - /** - * - */ - public function testNoUUid(): void - { - $session = new Session(); - $session->userId = 1; - $session->token = 'test'; - $save = $session->save(); - - self::assertTrue($save); - self::assertTrue(Str::isUuid($session->uid)); - - $session->delete(); - } - - /** - * @return Session - */ - public function ensureSession(): Session - { - $session = new Session(); - $session->userId = 1; - $session->token = 'test' . Str::random(); - $save = $session->save(); - - self::assertTrue($save); - return $session; - } -} diff --git a/yii2-adapter/tests/unit/db/CommandTest.php b/yii2-adapter/tests/unit/db/CommandTest.php deleted file mode 100644 index 01789c384ab..00000000000 --- a/yii2-adapter/tests/unit/db/CommandTest.php +++ /dev/null @@ -1,109 +0,0 @@ - - * @author Global Network Group | Giel Tettelaar - * @since 3.2 - */ -class CommandTest extends TestCase -{ - /** - * @var DateTime - */ - protected DateTime $sessionDate; - - /** - * @var array - */ - private array $_sessionData = [ - 'userId' => 1, - 'token' => 'test', - ]; - - /** - * - */ - public function testEnsureCommand(): void - { - self::assertInstanceOf(Command::class, Craft::$app->getDb()->createCommand()); - } - - /** - * Ensure a session row exists - * - * @return array - * @throws Exception - */ - public function ensureSession(): array - { - $this->sessionDate = new DateTime('now', new DateTimeZone('UTC')); - - $command = Craft::$app->getDb()->createCommand() - ->insert(Table::SESSIONS, $this->_sessionData) - ->execute(); - - self::assertGreaterThan(0, $command); - - return $this->getSession($this->_sessionData); - } - - /** - * @throws Exception - */ - public function clearSession() - { - DB::table(\CraftCms\Cms\Database\Table::SESSIONS)->truncate(); - } - - /** - * Updates a session row. - * - * @param array $values - * @return array - * @throws Exception - */ - public function updateSession(array $values): array - { - $condition = [ - 'userId' => $values['userId'], - 'token' => $values['token'], - ]; - - Craft::$app->getDb()->createCommand() - ->update(Table::SESSIONS, $values, $condition) - ->execute(); - - return $this->getSession($condition); - } - - /** - * Gets a session row - * - * @param array $condition - * @return array - */ - public function getSession(array $condition): array - { - return (new Query())->from([Table::SESSIONS])->where($condition)->one(); - } -} diff --git a/yii2-adapter/tests/unit/db/PaginatorTest.php b/yii2-adapter/tests/unit/db/PaginatorTest.php deleted file mode 100644 index d904f8bad7b..00000000000 --- a/yii2-adapter/tests/unit/db/PaginatorTest.php +++ /dev/null @@ -1,194 +0,0 @@ - - * @author Global Network Group | Giel Tettelaar - * @since 3.2 - */ -class PaginatorTest extends TestCase -{ - /** - * @var Paginator - */ - private Paginator $paginator; - - /** - * @var UnitTester - */ - protected UnitTester $tester; - - /** - * - */ - public function testTotalResults(): void - { - $this->setPaginator([], [], 10); - self::assertSame('10', (string)$this->paginator->getTotalResults()); - $this->resetPaginator(); - } - - /** - * - */ - public function testTotalResultsWithQueryLimit(): void - { - $this->setPaginator(['limit' => 10], [], 25); - self::assertSame(10, $this->paginator->getTotalResults()); - $this->resetPaginator(); - } - - /** - * - */ - public function testTotalResultsWithQueryOffset(): void - { - $this->setPaginator(['offset' => 5], [], 10); - self::assertSame(5, $this->paginator->getTotalResults()); - $this->resetPaginator(); - } - - /** - * - */ - public function testTotalPages(): void - { - $this->setPaginator([], ['pageSize' => '25']); - self::assertSame(4, $this->paginator->getTotalPages()); - $this->resetPaginator(); - } - - /** - * - */ - public function testTotalPagesWithOneOverflow(): void - { - $this->setPaginator([], ['pageSize' => '25'], 101); - self::assertSame(5, $this->paginator->getTotalPages()); - $this->resetPaginator(); - } - - /** - * - */ - public function testGetPageResults(): void - { - $this->setPaginator([], ['pageSize' => '2']); - - $desiredResults = (new Query())->from([Table::SESSIONS])->limit(2)->all(); - self::assertSame($desiredResults, $this->paginator->getPageResults()); - $this->resetPaginator(); - } - - /** - * - */ - public function testGetPageResultsSlices(): void - { - $this->setPaginator([], ['pageSize' => '2'], 10); - - $desiredResults = (new Query())->from(Table::SESSIONS)->limit(4)->all(); - - // Should get the first two... - self::assertSame([$desiredResults[0], $desiredResults[1]], $this->paginator->getPageResults()); - - // Next page. Other two results. - $this->paginator->setCurrentPage(2); - self::assertSame([$desiredResults[2], $desiredResults[3]], $this->paginator->getPageResults()); - $this->resetPaginator(); - } - - /** - * - */ - public function testGetPageResultsIncompleteResults(): void - { - $this->setPaginator([], ['pageSize' => '2'], 1); - - $desiredResults = (new Query())->from([Table::SESSIONS])->limit(1)->all(); - self::assertSame($desiredResults, $this->paginator->getPageResults()); - $this->resetPaginator(); - } - - /** - * - */ - public function testGetPageOffset(): void - { - $this->setPaginator([], [], 10); - self::assertSame(0, $this->paginator->getPageOffset()); - $this->resetPaginator(); - } - - /** - * - */ - public function testSetPageResultValidation(): void - { - $this->setPaginator([], [], 10); - $this->paginator->setCurrentPage(5); - self::assertSame(1, $this->paginator->getCurrentPage()); - $this->resetPaginator(); - } - - /** - * - */ - public function testSetPageResultValidationLastPage(): void - { - $this->setPaginator([], ['pageSize' => '5'], 10); - $this->paginator->setCurrentPage(2); - self::assertSame(2, $this->paginator->getCurrentPage()); - - $this->paginator->setCurrentPage(3); - self::assertSame(2, $this->paginator->getCurrentPage()); - $this->resetPaginator(); - } - - /** - * @param array $queryParams - * @param array $config - * @param int $requiredSessions - * @return void - */ - protected function setPaginator(array $queryParams = [], array $config = [], int $requiredSessions = 100) - { - $this->tester->haveMultiple(Session::class, $requiredSessions); - - $query = (new Query())->from(Table::SESSIONS); - foreach ($queryParams as $key => $value) { - $query->$key = $value; - } - - $this->paginator = new Paginator( - $query, - $config - ); - } - - /** - * @throws Exception - */ - protected function resetPaginator() - { - DB::table(\CraftCms\Cms\Database\Table::SESSIONS)->truncate(); - } -} diff --git a/yii2-adapter/tests/unit/elements/UserElementTest.php b/yii2-adapter/tests/unit/elements/UserElementTest.php index 5337c3a4dd1..50e7137a5ba 100644 --- a/yii2-adapter/tests/unit/elements/UserElementTest.php +++ b/yii2-adapter/tests/unit/elements/UserElementTest.php @@ -10,14 +10,12 @@ use Craft; use craft\db\Query; use craft\db\Table; -use craft\helpers\Session; use craft\services\Users; use craft\test\TestCase; use CraftCms\Cms\Cms; use CraftCms\Cms\Element\Exceptions\InvalidElementException; use CraftCms\Cms\Support\Str; use CraftCms\Cms\User\Elements\User; -use CraftCms\Yii2Adapter\IdentityWrapper; use DateInterval; use DateTime; use DateTimeZone; @@ -130,111 +128,6 @@ public function testUserStatusChange(): void $this->tester->saveElement($this->activeUser); } - /** - * @throws Exception - */ - public function testGetAuthKey(): void - { - Session::reset(); - - $this->tester->mockCraftMethods('session', [ - 'getHasSessionId' => fn() => true, - 'get' => function($tokenParam) { - self::assertSame(Craft::$app->getUser()->tokenParam, $tokenParam); - - return 'TOKEN'; - }, - ]); - - $this->tester->mockCraftMethods('request', [ - 'getUserAgent' => 'Mozilla/5.0 (iPad; U; CPU OS 3_2_1 like Mac OS X; en-us)', - ]); - - self::assertSame( - '["TOKEN",null,"' . md5('Mozilla/5.0 (iPad; U; CPU OS 3_2_1 like Mac OS X; en-us)') . '"]', - new IdentityWrapper($this->activeUser)->getAuthKey() - ); - - Session::reset(); - } - - /** - * - */ - public function testGetAuthKeyException(): void - { - $this->tester->mockCraftMethods('session', [ - 'get' => null, - ]); - - $this->tester->expectThrowable(Exception::class, function() { - new IdentityWrapper($this->activeUser)->getAuthKey(); - }); - } - - /** - * @throws \yii\db\Exception - */ - public function testValidateAuthKey(): void - { - $validUserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36'; - Craft::$app->getDb()->createCommand() - ->insert(Table::SESSIONS, [ - 'userId' => $this->activeUser->id, - 'token' => 'EXAMPLE_TOKEN', - ])->execute(); - - self::assertFalse(new IdentityWrapper($this->activeUser)->validateAuthKey('NOT_JSON')); - self::assertFalse(new IdentityWrapper($this->activeUser)->validateAuthKey('["JSON_ONE_ITEM"]')); - self::assertFalse( - new IdentityWrapper($this->activeUser)->validateAuthKey( - '["EXAMPLE_TOKEN",null,"NOT_A_USER_AGENT"]' - ) - ); - self::assertFalse( - new IdentityWrapper($this->activeUser)->validateAuthKey( - '["NOT_A_VALID_TOKEN",null,"' . $validUserAgent . '"]' - ) - ); - - Cms::config()->requireMatchingUserAgentForSession = true; - - // Valid token, user agent, and json string - $this->tester->mockCraftMethods('request', [ - 'getUserAgent' => $validUserAgent, - ]); - self::assertTrue( - new IdentityWrapper($this->activeUser)->validateAuthKey( - '["EXAMPLE_TOKEN",null,"' . md5($validUserAgent) . '"]' - ) - ); - } - - /** - * @throws \yii\db\Exception - */ - public function testValidateAuthKeyWithConfigDisabled(): void - { - $validUserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36'; - - Cms::config()->requireMatchingUserAgentForSession = false; - $this->tester->mockCraftMethods('request', [ - 'getUserAgent' => $validUserAgent, - ]); - - Craft::$app->getDb()->createCommand() - ->insert(Table::SESSIONS, [ - 'userId' => $this->activeUser->id, - 'token' => 'EXAMPLE_TOKEN', - ])->execute(); - - self::assertTrue( - new IdentityWrapper($this->activeUser)->validateAuthKey( - '["EXAMPLE_TOKEN",null,"INVALID_USER_AGENT"]' - ) - ); - } - /** * @throws \Exception */ @@ -289,8 +182,8 @@ public function testChangePasswordNukesSessions(): void { Craft::$app->getDb()->createCommand() ->batchInsert(Table::SESSIONS, [ - 'userId', - 'token', + 'user_id', + 'id', ], [ [ $this->activeUser->id, @@ -304,7 +197,7 @@ public function testChangePasswordNukesSessions(): void $this->activeUser->newPassword = 'random_password'; $this->tester->saveElement($this->activeUser); - $exists = (new Query())->from(Table::SESSIONS)->where(['userId' => $this->activeUser->id])->exists(); + $exists = (new Query())->from(Table::SESSIONS)->where(['user_id' => $this->activeUser->id])->exists(); self::assertFalse($exists); } diff --git a/yii2-adapter/tests/unit/web/UserTest.php b/yii2-adapter/tests/unit/web/UserTest.php index 686eca54928..09a8e649d58 100644 --- a/yii2-adapter/tests/unit/web/UserTest.php +++ b/yii2-adapter/tests/unit/web/UserTest.php @@ -13,9 +13,9 @@ use craft\services\Config; use craft\test\TestCase; use craft\web\User as WebUser; -use CraftCms\Cms\Cms; use CraftCms\Cms\User\Elements\User as UserElement; use CraftCms\Yii2Adapter\IdentityWrapper; +use Illuminate\Support\Facades\Auth; use UnitTester; /** @@ -47,44 +47,6 @@ class UserTest extends TestCase */ public WebUser $user; - /** - * - */ - public function testSendUsernameCookie(): void - { - DateTimeHelper::pause(); - - // Send the cookie with a hardcoded time value - Cms::config()->rememberUsernameDuration = 20; - $this->user->sendUsernameCookie($this->userElement); - - // Assert that the cookie is correct - $cookie = Craft::$app->getResponse()->getCookies()->get($this->_getUsernameCookieName()); - - self::assertSame($this->userElement->username, $cookie->value); - self::assertSame(DateTimeHelper::currentTimeStamp() + 20, $cookie->expire); - - DateTimeHelper::resume(); - } - - /** - * - */ - public function testSendUsernameCookieDeletes(): void - { - // Ensure something is set - $this->user->sendUsernameCookie($this->userElement); - - // Setting this to (int)0 will trigger sendUsernameCookie to set the values to null in the existing cookie. - Cms::config()->rememberUsernameDuration = 0; - $this->user->sendUsernameCookie($this->userElement); - - $cookie = Craft::$app->getResponse()->getCookies()->get($this->_getUsernameCookieName()); - - self::assertSame('', $cookie->value); - self::assertSame(1, $cookie->expire); - } - /** * */ @@ -95,6 +57,7 @@ public function testGetRemainingSessionTime(): void self::assertSame(0, $this->user->getRemainingSessionTime()); // With a user and authTimeout null it should return -1 + Auth::login($this->userElement); $this->user->setIdentity(new IdentityWrapper($this->userElement)); $this->user->authTimeout = null; self::assertSame(-1, $this->user->getRemainingSessionTime()); diff --git a/yii2-adapter/tests/unit/web/twig/ExtensionTest.php b/yii2-adapter/tests/unit/web/twig/ExtensionTest.php index 618e9066d7e..62307752277 100644 --- a/yii2-adapter/tests/unit/web/twig/ExtensionTest.php +++ b/yii2-adapter/tests/unit/web/twig/ExtensionTest.php @@ -17,6 +17,7 @@ use craft\web\View; use CraftCms\Cms\Cms; use CraftCms\Cms\Edition; +use CraftCms\Cms\Element\Models\Element; use CraftCms\Cms\Entry\Models\EntryType; use CraftCms\Cms\Field\MissingField; use CraftCms\Cms\Field\PlainText; @@ -71,11 +72,12 @@ public function test_globals(): void { // We want web for this part. Craft::$app->getRequest()->setIsConsoleRequest(false); - $user = new User([ + $user = \CraftCms\Cms\User\Models\User::create([ + 'id' => Element::create(['type' => User::class])->id, 'active' => true, 'firstName' => 'John', 'lastName' => 'Smith', - ]); + ])->asElement(); Craft::$app->getUser()->setIdentity(new IdentityWrapper($user)); Craft::$app->getRequest()->setRawBody('This is a raw body');