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'));
+?>
+
+
+
+
+
+
+
+ = Form::tag()
+ // note: the reset function actually appears in the ResetController
+ ->post($urlGenerator->generate('auth/reset'))
+ ->csrf($csrf)
+ ->id('resetForm')
+ ->open() ?>
+
+ = Field::text($formModel, 'login')->addInputAttributes(['value'=> $login ?? '', 'readonly'=>'readonly']) ?>
+ = Field::password($formModel, 'password') ?>
+ = Field::password($formModel, 'password_verify') ?>
+ = Field::password($formModel, 'new_password') ?>
+ = Field::password($formModel, 'new_password_verify') ?>
+ = Field::submitButton()
+ ->buttonId('reset-button')
+ ->name('reset-button')
+ ->content($translator->translate('layout.submit'))
+ ?>
+ = Form::tag()->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