diff --git a/blog/README.md b/blog/README.md index e126bbf8..fa1d8ea5 100644 --- a/blog/README.md +++ b/blog/README.md @@ -67,6 +67,11 @@ The code is statically analyzed with [Psalm](https://psalm.dev/). To run static ./vendor/bin/psalm ``` +### Installing Bootstrap Node Modules with WAMP + +1. Make a directory node_modules in the blog directory. +2. After including npm in the environment variable settings in windows, use the command npm i. + ### Support the project [![Open Collective](https://img.shields.io/badge/Open%20Collective-sponsor-7eadf1?logo=open%20collective&logoColor=7eadf1&labelColor=555555)](https://opencollective.com/yiisoft) diff --git a/blog/config/common/routes/routes.php b/blog/config/common/routes/routes.php index 342b7861..8267f862 100644 --- a/blog/config/common/routes/routes.php +++ b/blog/config/common/routes/routes.php @@ -4,6 +4,7 @@ use App\Auth\Controller\AuthController; use App\Auth\Controller\SignupController; +use App\Auth\Controller\ResetController; use App\Blog\Archive\ArchiveController; use App\Blog\BlogController; use App\Blog\CommentController; @@ -61,6 +62,13 @@ ) => new LimitRequestsMiddleware(new Counter($storage, 10, 10), $responseFactory)) ->action([SignupController::class, 'signup']) ->name('auth/signup'), + Route::methods([Method::GET, Method::POST], '/reset') + ->middleware(fn( + ResponseFactoryInterface $responseFactory, + StorageInterface $storage + ) => new LimitRequestsMiddleware(new Counter($storage, 10, 10), $responseFactory)) + ->action([ResetController::class, 'reset']) + ->name('auth/reset'), Group::create('/user') ->routes( diff --git a/blog/resources/messages/en/app.php b/blog/resources/messages/en/app.php index d26e3d3c..bff63116 100644 --- a/blog/resources/messages/en/app.php +++ b/blog/resources/messages/en/app.php @@ -31,6 +31,8 @@ 'layout.page.not-found' => 'The page {url} could not be found.', 'layout.pagination-summary' => 'Showing {pageSize} out of {total} posts', 'layout.password-verify' => 'Confirm password', + 'layout.password-verify.new' => 'Confirm New Password', + 'layout.password.new' => 'New Password', 'layout.password' => 'Password', 'layout.rbac.assign-role' => 'Assign RBAC role to user', 'layout.remember' => 'Remember me', @@ -45,6 +47,7 @@ 'menu.language' => 'Language', 'menu.login' => 'Login', 'menu.logout' => 'Logout ({login})', + 'menu.password.change' => 'Change Password', 'menu.signup' => 'Signup', 'menu.swagger' => 'Swagger', 'menu.users' => 'Users', diff --git a/blog/resources/views/layout/main.php b/blog/resources/views/layout/main.php index 3751e71f..2be6e09f 100644 --- a/blog/resources/views/layout/main.php +++ b/blog/resources/views/layout/main.php @@ -137,6 +137,11 @@ 'url' => $urlGenerator->generate('auth/signup'), 'visible' => $isGuest, ], + [ + 'label' => $translator->translate('menu.password.change'), + 'url' => $urlGenerator->generate('auth/reset'), + 'visible' => !$isGuest, + ], $isGuest ? '' : Form::tag() ->post($urlGenerator->generate('auth/logout')) ->csrf($csrf) diff --git a/blog/resources/views/reset/reset.php b/blog/resources/views/reset/reset.php new file mode 100644 index 00000000..e67db25d --- /dev/null +++ b/blog/resources/views/reset/reset.php @@ -0,0 +1,53 @@ +setTitle($translator->translate('layout.reset')); +?> + +
+
+
+
+
+

getTitle()) ?>

+
+
+ post($urlGenerator->generate('auth/reset')) + ->csrf($csrf) + ->id('resetForm') + ->open() ?> + + addInputAttributes(['value'=> $login ?? '', 'readonly'=>'readonly']) ?> + + + + + buttonId('reset-button') + ->name('reset-button') + ->content($translator->translate('layout.submit')) + ?> + close() ?> +
+
+
+
+
\ No newline at end of file diff --git a/blog/src/Auth/Controller/ResetController.php b/blog/src/Auth/Controller/ResetController.php new file mode 100644 index 00000000..ba9da86f --- /dev/null +++ b/blog/src/Auth/Controller/ResetController.php @@ -0,0 +1,78 @@ +current_user = $current_user; + $this->translator = $translator; + $this->viewRenderer = $viewRenderer->withControllerName('reset'); + } + + public function reset( + AuthService $authService, + Identity $identity, + IdentityRepository $identityRepository, + ServerRequestInterface $request, + FormHydrator $formHydrator, + ResetForm $resetForm + ): ResponseInterface { + // permit an authenticated user with permission editPost (ie. not a guest) only and null!== current user + if (!$authService->isGuest()) { + // see demo/blog/resources/rbac + if ($this->current_user->can('editPost',[])) { + // readonly the login detail on the reset form + $identity_id = $this->current_user->getIdentity()->getId(); + if (null!==$identity_id) { + $identity = $identityRepository->findIdentity($identity_id); + if (null!==$identity) { + // Identity and User are in a HasOne relationship so no null value + $login = $identity->getUser()?->getLogin(); + if ($request->getMethod() === Method::POST + && $formHydrator->populate($resetForm, $request->getParsedBody()) + && $resetForm->reset() + ) { + // Identity implements CookieLoginIdentityInterface: ensure the regeneration of the cookie auth key by means of $authService->logout(); + // @see vendor\yiisoft\user\src\Login\Cookie\CookieLoginIdentityInterface + + // Specific note: "Make sure to invalidate earlier issued keys when you implement force user logout, + // PASSWORD CHANGE and other scenarios, that require forceful access revocation for old sessions. + // The authService logout function will regenerate the auth key here => overwriting any auth key + $authService->logout(); + return $this->redirectToMain(); + } + return $this->viewRenderer->render('reset', ['formModel' => $resetForm, 'login' => $login]); + } // identity + } // identity_id + } // current user + } // auth service + return $this->redirectToMain(); + } // reset + + private function redirectToMain(): ResponseInterface + { + return $this->webService->getRedirectResponse('site/index'); + } +} diff --git a/blog/src/Auth/Form/ResetForm.php b/blog/src/Auth/Form/ResetForm.php new file mode 100644 index 00000000..d7224d9d --- /dev/null +++ b/blog/src/Auth/Form/ResetForm.php @@ -0,0 +1,175 @@ +validator->validate($this)->isValid()) { + $user = $this->userRepository->findByLogin($this->getLogin()); + if (null!==$user) { + $user->setPassword($this->getNewPassword()); + // The cookie identity auth_key is regenerated on logout + // Refer to ResetController reset function + $this->userRepository->save($user); + return true; + } + } + return false; + } + + /** + * @return string[] + * + * @psalm-return array{login: string, password: string, password_verify: string, new_password: string, new_password_verify: string} + */ + public function getAttributeLabels(): array + { + return [ + 'login' => $this->translator->translate('layout.login'), + 'password' => $this->translator->translate('layout.password'), + 'password_verify' => $this->translator->translate('layout.password-verify'), + 'new_password' => $this->translator->translate('layout.password.new'), + 'new_password_verify' => $this->translator->translate('layout.password-verify.new'), + ]; + } + + /** + * @return string + * + * @psalm-return 'Reset' + */ + public function getFormName(): string + { + return 'Reset'; + } + + public function getLogin(): string + { + return $this->login; + } + + public function getPassword(): string + { + return $this->password; + } + + public function getPasswordVerify(): string + { + return $this->password_verify; + } + + public function getNewPassword(): string + { + return $this->new_password; + } + + public function getNewPasswordVerify() : string + { + return $this->new_password_verify; + } + + public function getRules(): array + { + return [ + 'login' => [ + new Required(), + new Length(min: 1, max: 48, skipOnError: true), + function (mixed $value): Result { + $result = new Result(); + if ($this->userRepository->findByLogin((string)$value) == null) { + $result->addError($this->translator->translate('validator.user.exist.not')); + } + return $result; + }, + ], + 'password' => $this->PasswordRules(), + 'password_verify' => $this->PasswordVerifyRules(), + 'new_password' => [ + new Required(), + new Length(min: 8), + ], + 'new_password_verify' => $this->NewPasswordVerifyRules() + ]; + } + + private function PasswordRules(): array + { + return [ + new Required(), + new Callback( + callback: function (): Result { + $result = new Result(); + if (!$this->authService->login($this->login, $this->password)) { + $result->addError($this->translator->translate('validator.invalid.login.password')); + } + + return $result; + }, + skipOnEmpty: true, + ), + ]; + } + + private function PasswordVerifyRules(): array + { + return [ + new Required(), + new Callback( + callback: function (): Result { + $result = new Result(); + if (!($this->password === $this->password_verify)) { + $result->addError($this->translator->translate('validator.password.not.match')); + } + return $result; + }, + skipOnEmpty: true, + ), + ]; + } + + private function NewPasswordVerifyRules(): array + { + return [ + new Required(), + new Callback( + callback: function (): Result { + $result = new Result(); + if (!($this->new_password === $this->new_password_verify)) { + $result->addError($this->translator->translate('validator.password.not.match.new')); + } + return $result; + }, + skipOnEmpty: true, + ), + ]; + } +} diff --git a/blog/src/Auth/Identity.php b/blog/src/Auth/Identity.php index a8ca1a5e..9dbb17df 100644 --- a/blog/src/Auth/Identity.php +++ b/blog/src/Auth/Identity.php @@ -22,16 +22,18 @@ class Identity implements CookieLoginIdentityInterface #[BelongsTo(target: User::class, nullable: false, load: 'eager')] private ?User $user = null; - private ?int $user_id = null; public function __construct() { - $this->regenerateCookieLoginKey(); + $this->authKey = $this->regenerateCookieLoginKey(); } public function getId(): ?string { - return $this->user->getId(); + if ($this->user) { + return $this->user->getId(); + } + return null; } public function getCookieLoginKey(): string @@ -48,9 +50,14 @@ public function validateCookieLoginKey(string $key): bool { return $this->authKey === $key; } - - public function regenerateCookieLoginKey(): void + + /** + * Regenerate after logout + * @see src\Auth\AuthService logout function + * @return string + */ + public function regenerateCookieLoginKey(): string { - $this->authKey = Random::string(32); + return Random::string(32); } } diff --git a/blog/src/Auth/IdentityRepository.php b/blog/src/Auth/IdentityRepository.php index 559280c3..74ea43d6 100644 --- a/blog/src/Auth/IdentityRepository.php +++ b/blog/src/Auth/IdentityRepository.php @@ -6,19 +6,29 @@ use Cycle\ORM\Select; use Throwable; +use App\Auth\Identity; use Yiisoft\Auth\IdentityRepositoryInterface; use Yiisoft\Yii\Cycle\Data\Writer\EntityWriter; +/** + * @template TEntity of Identity + * @extends Select\Repository + */ final class IdentityRepository extends Select\Repository implements IdentityRepositoryInterface { + /** + * + * @param EntityWriter $entityWriter + * @param Select $select + */ public function __construct(private EntityWriter $entityWriter, Select $select) { parent::__construct($select); } - + /** + * * @param string $id - * * @return Identity|null */ public function findIdentity(string $id): ?Identity