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)

Account activation

-

Activation failed. Please check the link you entered or retry sign up!

+

Activation failed. Please check the link you entered, or retry signing up!

@endsection diff --git a/views/login/login.php b/views/login/login.php index aa60932..3319030 100644 --- a/views/login/login.php +++ b/views/login/login.php @@ -10,6 +10,8 @@
+

Forgot your password?

+

New to ?


Login with Google diff --git a/views/login/password_reset_request.php b/views/login/password_reset_request.php new file mode 100644 index 0000000..4f0b862 --- /dev/null +++ b/views/login/password_reset_request.php @@ -0,0 +1,14 @@ +@extends(templates/layout_normal) + +@section(main) +

Request password reset

+
+
+ +

+
+ +
+
+
+@endsection diff --git a/views/login/password_reset_request_success.php b/views/login/password_reset_request_success.php new file mode 100644 index 0000000..4e2eb1e --- /dev/null +++ b/views/login/password_reset_request_success.php @@ -0,0 +1,8 @@ +@extends(templates/layout_normal) + +@section(main) +

Request password reset

+
+

Password reset was successfully requested. Please check your email and click on the link to reset your password!

+
+@endsection diff --git a/views/login/reset_password.php b/views/login/reset_password.php new file mode 100644 index 0000000..ad8750a --- /dev/null +++ b/views/login/reset_password.php @@ -0,0 +1,20 @@ +@extends(templates/layout_normal) + +@section(main) +

Reset password

+
+ +
+ + + +

+
+ +
+
+ +

Confirming your identity failed. Please check the link you entered, or retry requesting password reset!

+ +
+@endsection diff --git a/views/login/signup.php b/views/login/signup.php index 1e3ee61..1af3863 100644 --- a/views/login/signup.php +++ b/views/login/signup.php @@ -22,6 +22,7 @@ -->
+

Already have an account?


Signup with Google diff --git a/web.php b/web.php index 6df0859..5576441 100644 --- a/web.php +++ b/web.php @@ -31,6 +31,13 @@ Container::$routeCollection->group('signup', function (MapGuesser\Routing\RouteC $routeCollection->get('signup.activate', 'activate/{token}', [MapGuesser\Controller\LoginController::class, 'activate']); $routeCollection->get('signup.cancel', 'cancel/{token}', [MapGuesser\Controller\LoginController::class, 'cancel']); }); +Container::$routeCollection->group('password', function (MapGuesser\Routing\RouteCollection $routeCollection) { + $routeCollection->get('password-requestReset', 'requestReset', [MapGuesser\Controller\LoginController::class, 'getRequestPasswordResetForm']); + $routeCollection->post('password-requestReset-action', 'requestReset', [MapGuesser\Controller\LoginController::class, 'requestPasswordReset']); + $routeCollection->get('password-requestReset.success', 'requestReset/success', [MapGuesser\Controller\LoginController::class, 'getRequestPasswordResetSuccess']); + $routeCollection->get('password-reset', 'reset/{token}', [MapGuesser\Controller\LoginController::class, 'getResetPasswordForm']); + $routeCollection->post('password-reset.action', 'reset/{token}', [MapGuesser\Controller\LoginController::class, 'resetPassword']); +}); Container::$routeCollection->get('logout', 'logout', [MapGuesser\Controller\LoginController::class, 'logout']); Container::$routeCollection->group('account', function (MapGuesser\Routing\RouteCollection $routeCollection) { $routeCollection->get('account', '', [MapGuesser\Controller\UserController::class, 'getAccount']);