diff --git a/database/migrations/structure/20200704_1954_password_forgotten.sql b/database/migrations/structure/20200704_1954_password_forgotten.sql
new file mode 100644
index 0000000..38ba86e
--- /dev/null
+++ b/database/migrations/structure/20200704_1954_password_forgotten.sql
@@ -0,0 +1,10 @@
+CREATE TABLE `user_password_resetters` (
+ `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+ `user_id` int(10) unsigned NOT NULL,
+ `token` varchar(32) CHARACTER SET ascii NOT NULL,
+ `expires` timestamp NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `user_id` (`user_id`),
+ KEY `token` (`token`),
+ CONSTRAINT `user_password_resetters_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
diff --git a/mail/password-reset.html b/mail/password-reset.html
new file mode 100644
index 0000000..bf5b60d
--- /dev/null
+++ b/mail/password-reset.html
@@ -0,0 +1,12 @@
+Hi,
+
+You recently requested password reset on {{APP_NAME}} with this email address ({{EMAIL}}).
+To reset the password to your account, please click on the following link:
+{{RESET_LINK}}
+(This link expires at {{EXPIRES}}.)
+
+If you did not requested password reset, no further action is required, your account is not touched.
+
+Regards,
+{{APP_NAME}}
+{{BASE_URL}}
diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php
index fb5573a..bc130c7 100644
--- a/src/Controller/LoginController.php
+++ b/src/Controller/LoginController.php
@@ -1,5 +1,6 @@
request = $request;
$this->pdm = new PersistentDataManager();
$this->userRepository = new UserRepository();
$this->userConfirmationRepository = new UserConfirmationRepository();
+ $this->userPasswordResetterRepository = new UserPasswordResetterRepository();
}
public function getLoginForm()
@@ -97,6 +103,41 @@ class LoginController
return new HtmlContent('login/google_signup', $data);
}
+ public function getRequestPasswordResetForm()
+ {
+ if ($this->request->user() !== null) {
+ return new Redirect(\Container::$routeCollection->getRoute('index')->generateLink(), IRedirect::TEMPORARY);
+ }
+
+ $data = ['email' => $this->request->query('email')];
+ return new HtmlContent('login/password_reset_request', $data);
+ }
+
+ public function getRequestPasswordResetSuccess(): IContent
+ {
+ return new HtmlContent('login/password_reset_request_success');
+ }
+
+ public function getResetPasswordForm()
+ {
+ if ($this->request->user() !== null) {
+ return new Redirect(\Container::$routeCollection->getRoute('index')->generateLink(), IRedirect::TEMPORARY);
+ }
+
+ $token = $this->request->query('token');
+ $resetter = $this->userPasswordResetterRepository->getByToken($token);
+
+ if ($resetter === null || $resetter->getExpiresDate() < new DateTime()) {
+ $data = ['success' => false];
+ return new HtmlContent('login/reset_password', $data);
+ }
+
+ $user = $this->userRepository->getById($resetter->getUserId());
+
+ $data = ['success' => true, 'token' => $token, 'email' => $user->getEmail()];
+ return new HtmlContent('login/reset_password', $data);
+ }
+
public function login(): IContent
{
if ($this->request->user() !== null) {
@@ -127,7 +168,7 @@ class LoginController
}
if (!$user->checkPassword($this->request->post('password'))) {
- $data = ['error' => ['errorText' => 'The given password is wrong.']];
+ $data = ['error' => ['errorText' => 'The given password is wrong. You can request password reset!']];
return new JsonContent($data);
}
@@ -196,7 +237,7 @@ class LoginController
if ($user !== null) {
if ($user->getActive()) {
if (!$user->checkPassword($this->request->post('password'))) {
- $data = ['error' => ['errorText' => 'There is a user already registered with the given email address, but the given password is wrong.']];
+ $data = ['error' => ['errorText' => 'There is a user already registered with the given email address, but the given password is wrong. You can request password reset!']];
return new JsonContent($data);
}
@@ -370,6 +411,84 @@ class LoginController
return new HtmlContent('login/cancel', $data);
}
+ public function requestPasswordReset(): IContent
+ {
+ if ($this->request->user() !== null) {
+ $data = ['redirect' => ['target' => '/' . \Container::$routeCollection->getRoute('home')->generateLink()]];
+ return new JsonContent($data);
+ }
+
+ $user = $this->userRepository->getByEmail($this->request->post('email'));
+
+ if ($user === null) {
+ $data = ['error' => ['errorText' => 'No user found with the given email address. You can sign up!']];
+ return new JsonContent($data);
+ }
+
+ if (!$user->getActive()) {
+ $data = ['error' => ['errorText' => 'User found with the given email address, but the account is not activated. Please check your email and click on the activation link!']];
+ return new JsonContent($data);
+ }
+
+ $token = bin2hex(random_bytes(16));
+ $expires = new DateTime('+1 hour');
+
+ $passwordResetter = new UserPasswordResetter();
+ $passwordResetter->setUser($user);
+ $passwordResetter->setToken($token);
+ $passwordResetter->setExpiresDate($expires);
+
+ $this->pdm->saveToDb($passwordResetter);
+
+ $this->sendPasswordResetEmail($user->getEmail(), $token, $expires);
+
+ $data = ['success' => true];
+ return new JsonContent($data);
+ }
+
+
+ public function resetPassword(): IContent
+ {
+ if ($this->request->user() !== null) {
+ $data = ['redirect' => ['target' => '/' . \Container::$routeCollection->getRoute('home')->generateLink()]];
+ return new JsonContent($data);
+ }
+
+ $token = $this->request->query('token');
+ $resetter = $this->userPasswordResetterRepository->getByToken($token);
+
+ if ($resetter === null || $resetter->getExpiresDate() < new DateTime()) {
+ $data = ['redirect' => ['target' => '/' . \Container::$routeCollection->getRoute('password-reset')->generateLink(['token' => $token])]];
+ return new JsonContent($data);
+ }
+
+ if (strlen($this->request->post('password')) < 6) {
+ $data = ['error' => ['errorText' => 'The given password is too short. Please choose a password that is at least 6 characters long!']];
+ return new JsonContent($data);
+ }
+
+ if ($this->request->post('password') !== $this->request->post('password_confirm')) {
+ $data = ['error' => ['errorText' => 'The given passwords do not match.']];
+ return new JsonContent($data);
+ }
+
+ \Container::$dbConnection->startTransaction();
+
+ $this->pdm->deleteFromDb($resetter);
+
+ $user = $this->userRepository->getById($resetter->getUserId());
+ $user->setPlainPassword($this->request->post('password'));
+
+ $this->pdm->saveToDb($user);
+
+ \Container::$dbConnection->commit();
+
+ $this->request->setUser($user);
+
+ $data = ['success' => true];
+ return new JsonContent($data);
+ }
+
private function sendConfirmationEmail(string $email, string $token): void
{
$mail = new Mail();
@@ -377,7 +496,7 @@ class LoginController
$mail->setSubject('Welcome to ' . $_ENV['APP_NAME'] . ' - Activate your account');
$mail->setBodyFromTemplate('signup', [
'EMAIL' => $email,
- 'ACTIVATE_LINK' => $this->request->getBase() . '/'. \Container::$routeCollection->getRoute('signup.activate')->generateLink(['token' => $token]),
+ 'ACTIVATE_LINK' => $this->request->getBase() . '/' . \Container::$routeCollection->getRoute('signup.activate')->generateLink(['token' => $token]),
'CANCEL_LINK' => $this->request->getBase() . '/' . \Container::$routeCollection->getRoute('signup.cancel')->generateLink(['token' => $token]),
]);
$mail->send();
@@ -393,4 +512,17 @@ class LoginController
]);
$mail->send();
}
+
+ private function sendPasswordResetEmail(string $email, string $token, DateTime $expires): void
+ {
+ $mail = new Mail();
+ $mail->addRecipient($email);
+ $mail->setSubject($_ENV['APP_NAME'] . ' - Password reset');
+ $mail->setBodyFromTemplate('password-reset', [
+ 'EMAIL' => $email,
+ 'RESET_LINK' => $this->request->getBase() . '/' . \Container::$routeCollection->getRoute('password-reset')->generateLink(['token' => $token]),
+ 'EXPIRES' => $expires->format('Y-m-d H:i:s T')
+ ]);
+ $mail->send();
+ }
}
diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php
index 25d14e9..9314dd7 100644
--- a/src/Controller/UserController.php
+++ b/src/Controller/UserController.php
@@ -1,18 +1,16 @@
request = $request;
$this->pdm = new PersistentDataManager();
$this->userConfirmationRepository = new UserConfirmationRepository();
+ $this->userPasswordResetterRepository = new UserPasswordResetterRepository();
}
public function authorize(): bool
@@ -181,6 +182,10 @@ class UserController implements ISecured
$this->pdm->deleteFromDb($userConfirmation);
}
+ foreach ($this->userPasswordResetterRepository->getByUser($user) as $userPasswordResetter) {
+ $this->pdm->deleteFromDb($userPasswordResetter);
+ }
+
$this->pdm->deleteFromDb($user);
\Container::$dbConnection->commit();
diff --git a/src/PersistentData/Model/UserPasswordResetter.php b/src/PersistentData/Model/UserPasswordResetter.php
new file mode 100644
index 0000000..ee28cdf
--- /dev/null
+++ b/src/PersistentData/Model/UserPasswordResetter.php
@@ -0,0 +1,70 @@
+ User::class];
+
+ private ?User $user = null;
+
+ private ?int $userId = null;
+
+ private string $token = '';
+
+ private DateTime $expires;
+
+ public function setUser(User $user): void
+ {
+ $this->user = $user;
+ }
+
+ public function setUserId(int $userId): void
+ {
+ $this->userId = $userId;
+ }
+
+ public function setToken(string $token): void
+ {
+ $this->token = $token;
+ }
+
+ public function setExpiresDate(DateTime $expires): void
+ {
+ $this->expires = $expires;
+ }
+
+ public function setExpires(string $expires): void
+ {
+ $this->expires = new DateTime($expires);
+ }
+
+ public function getUser(): ?User
+ {
+ return $this->user;
+ }
+
+ public function getUserId(): ?int
+ {
+ return $this->userId;
+ }
+
+ public function getToken(): string
+ {
+ return $this->token;
+ }
+
+ public function getExpiresDate(): DateTime
+ {
+ return $this->expires;
+ }
+
+ public function getExpires(): string
+ {
+ return $this->expires->format('Y-m-d H:i:s');
+ }
+}
diff --git a/src/Repository/UserPasswordResetterRepository.php b/src/Repository/UserPasswordResetterRepository.php
new file mode 100644
index 0000000..04a82af
--- /dev/null
+++ b/src/Repository/UserPasswordResetterRepository.php
@@ -0,0 +1,38 @@
+pdm = new PersistentDataManager();
+ }
+
+ public function getById(int $userConfirmationId): ?UserPasswordResetter
+ {
+ return $this->pdm->selectFromDbById($userConfirmationId, UserPasswordResetter::class);
+ }
+
+ public function getByToken(string $token): ?UserPasswordResetter
+ {
+ $select = new Select(\Container::$dbConnection);
+ $select->where('token', '=', $token);
+
+ return $this->pdm->selectFromDb($select, UserPasswordResetter::class);
+ }
+
+ public function getByUser(User $user): Generator
+ {
+ $select = new Select(\Container::$dbConnection);
+ $select->where('user_id', '=', $user->getId());
+
+ yield from $this->pdm->selectMultipleFromDb($select, UserPasswordResetter::class);
+ }
+}
diff --git a/views/login/activate.php b/views/login/activate.php
index e8dc7dd..90312e9 100644
--- a/views/login/activate.php
+++ b/views/login/activate.php
@@ -3,6 +3,6 @@
@section(main)
Activation failed. Please check the link you entered or retry sign up!
+Activation failed. Please check the link you entered, or retry signing up!
New to = $_ENV['APP_NAME'] ?>?
Password reset was successfully requested. Please check your email and click on the link to reset your password!
+Confirming your identity failed. Please check the link you entered, or retry requesting password reset!
+ +