Merged in feature/MAPG-141-password-forgotten-functionality (pull request #170)
Feature/MAPG-141 password forgotten functionality
This commit is contained in:
commit
091afb0aab
@ -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;
|
12
mail/password-reset.html
Normal file
12
mail/password-reset.html
Normal file
@ -0,0 +1,12 @@
|
||||
Hi,
|
||||
<br><br>
|
||||
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:<br>
|
||||
<a href="{{RESET_LINK}}" title="Reset password">{{RESET_LINK}}</a><br>
|
||||
(This link expires at {{EXPIRES}}.)
|
||||
<br><br>
|
||||
If you did not requested password reset, no further action is required, your account is not touched.
|
||||
<br><br>
|
||||
Regards,<br>
|
||||
{{APP_NAME}}<br>
|
||||
<a href="{{BASE_URL}}" title="{{APP_NAME}}">{{BASE_URL}}</a>
|
@ -1,5 +1,6 @@
|
||||
<?php namespace MapGuesser\Controller;
|
||||
|
||||
use DateTime;
|
||||
use MapGuesser\Http\Request;
|
||||
use MapGuesser\Interfaces\Request\IRequest;
|
||||
use MapGuesser\Interfaces\Response\IContent;
|
||||
@ -8,8 +9,10 @@ use MapGuesser\Mailing\Mail;
|
||||
use MapGuesser\OAuth\GoogleOAuth;
|
||||
use MapGuesser\PersistentData\Model\User;
|
||||
use MapGuesser\PersistentData\Model\UserConfirmation;
|
||||
use MapGuesser\PersistentData\Model\UserPasswordResetter;
|
||||
use MapGuesser\PersistentData\PersistentDataManager;
|
||||
use MapGuesser\Repository\UserConfirmationRepository;
|
||||
use MapGuesser\Repository\UserPasswordResetterRepository;
|
||||
use MapGuesser\Repository\UserRepository;
|
||||
use MapGuesser\Response\HtmlContent;
|
||||
use MapGuesser\Response\JsonContent;
|
||||
@ -26,12 +29,15 @@ class LoginController
|
||||
|
||||
private UserConfirmationRepository $userConfirmationRepository;
|
||||
|
||||
private UserPasswordResetterRepository $userPasswordResetterRepository;
|
||||
|
||||
public function __construct(IRequest $request)
|
||||
{
|
||||
$this->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 <a href="/password/requestReset?email=' . urlencode($user->getEmail()) . '" title="Request password reset">request password reset</a>!']];
|
||||
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 <a href="/password/requestReset?email=' . urlencode($user->getEmail()) . '" title="Request password reset">request password reset</a>!']];
|
||||
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 <a href="/signup" title="Sign up">sign up</a>!']];
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
@ -1,18 +1,16 @@
|
||||
<?php namespace MapGuesser\Controller;
|
||||
|
||||
use DateTime;
|
||||
use MapGuesser\Database\Query\Select;
|
||||
use MapGuesser\Http\Request;
|
||||
use MapGuesser\Interfaces\Authorization\ISecured;
|
||||
use MapGuesser\Interfaces\Database\IResultSet;
|
||||
use MapGuesser\Interfaces\Request\IRequest;
|
||||
use MapGuesser\Interfaces\Response\IContent;
|
||||
use MapGuesser\Interfaces\Response\IRedirect;
|
||||
use MapGuesser\OAuth\GoogleOAuth;
|
||||
use MapGuesser\PersistentData\PersistentDataManager;
|
||||
use MapGuesser\PersistentData\Model\User;
|
||||
use MapGuesser\PersistentData\Model\UserConfirmation;
|
||||
use MapGuesser\Repository\UserConfirmationRepository;
|
||||
use MapGuesser\Repository\UserPasswordResetterRepository;
|
||||
use MapGuesser\Response\HtmlContent;
|
||||
use MapGuesser\Response\JsonContent;
|
||||
use MapGuesser\Response\Redirect;
|
||||
@ -26,11 +24,14 @@ class UserController implements ISecured
|
||||
|
||||
private UserConfirmationRepository $userConfirmationRepository;
|
||||
|
||||
private UserPasswordResetterRepository $userPasswordResetterRepository;
|
||||
|
||||
public function __construct(IRequest $request)
|
||||
{
|
||||
$this->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();
|
||||
|
70
src/PersistentData/Model/UserPasswordResetter.php
Normal file
70
src/PersistentData/Model/UserPasswordResetter.php
Normal file
@ -0,0 +1,70 @@
|
||||
<?php namespace MapGuesser\PersistentData\Model;
|
||||
|
||||
use DateTime;
|
||||
|
||||
class UserPasswordResetter extends Model
|
||||
{
|
||||
protected static string $table = 'user_password_resetters';
|
||||
|
||||
protected static array $fields = ['user_id', 'token', 'expires'];
|
||||
|
||||
protected static array $relations = ['user' => 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');
|
||||
}
|
||||
}
|
38
src/Repository/UserPasswordResetterRepository.php
Normal file
38
src/Repository/UserPasswordResetterRepository.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php namespace MapGuesser\Repository;
|
||||
|
||||
use Generator;
|
||||
use MapGuesser\Database\Query\Select;
|
||||
use MapGuesser\PersistentData\Model\User;
|
||||
use MapGuesser\PersistentData\Model\UserPasswordResetter;
|
||||
use MapGuesser\PersistentData\PersistentDataManager;
|
||||
|
||||
class UserPasswordResetterRepository
|
||||
{
|
||||
private PersistentDataManager $pdm;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
@ -3,6 +3,6 @@
|
||||
@section(main)
|
||||
<h2>Account activation</h2>
|
||||
<div class="box">
|
||||
<p class="error justify">Activation failed. Please check the link you entered or retry <a href="/signup" title="Sign up">sign up</a>!</p>
|
||||
<p class="error justify">Activation failed. Please check the link you entered, or retry <a href="/signup" title="Sign up">signing up</a>!</p>
|
||||
</div>
|
||||
@endsection
|
||||
|
@ -10,6 +10,8 @@
|
||||
<div class="right marginTop">
|
||||
<button type="submit">Login</button>
|
||||
</div>
|
||||
<p class="center marginTop"><a href="/password/requestReset" title="Request password reset">Forgot your password?</a></p>
|
||||
<p class="center marginTop"><a href="/signup" title="Sign up">New to <?= $_ENV['APP_NAME'] ?>?</a></p>
|
||||
<hr>
|
||||
<div class="center">
|
||||
<a class="button yellow" href="/login/google" title="Login with Google">Login with Google</a>
|
||||
|
14
views/login/password_reset_request.php
Normal file
14
views/login/password_reset_request.php
Normal file
@ -0,0 +1,14 @@
|
||||
@extends(templates/layout_normal)
|
||||
|
||||
@section(main)
|
||||
<h2>Request password reset</h2>
|
||||
<div class="box">
|
||||
<form id="passwordResetForm" action="/password/requestReset" method="post" data-redirect-on-success="/password/requestReset/success">
|
||||
<input class="big fullWidth" type="email" name="email" placeholder="Email address" value="<?= isset($email) ? $email : '' ?>" required autofocus>
|
||||
<p id="passwordResetFormError" class="formError justify marginTop"></p>
|
||||
<div class="right marginTop">
|
||||
<button type="submit">Continue</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@endsection
|
8
views/login/password_reset_request_success.php
Normal file
8
views/login/password_reset_request_success.php
Normal file
@ -0,0 +1,8 @@
|
||||
@extends(templates/layout_normal)
|
||||
|
||||
@section(main)
|
||||
<h2>Request password reset</h2>
|
||||
<div class="box">
|
||||
<p class="justify">Password reset was successfully requested. Please check your email and click on the link to reset your password!</p>
|
||||
</div>
|
||||
@endsection
|
20
views/login/reset_password.php
Normal file
20
views/login/reset_password.php
Normal file
@ -0,0 +1,20 @@
|
||||
@extends(templates/layout_normal)
|
||||
|
||||
@section(main)
|
||||
<h2>Reset password</h2>
|
||||
<div class="box">
|
||||
<?php if ($success) : ?>
|
||||
<form id="resetPasswordForm" action="/password/reset/<?= $token ?>" method="post" data-redirect-on-success="/">
|
||||
<input class="big fullWidth" type="email" name="email" placeholder="Email address" value="<?= $email ?>" disabled>
|
||||
<input class="big fullWidth marginTop" type="password" name="password" placeholder="Password" required minlength="6" autofocus>
|
||||
<input class="big fullWidth marginTop" type="password" name="password_confirm" placeholder="Password confirmation" required minlength="6">
|
||||
<p id="resetPasswordFormError" class="formError justify marginTop"></p>
|
||||
<div class="right">
|
||||
<button class="marginTop" type="submit">Reset password</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<p class="error justify">Confirming your identity failed. Please check the link you entered, or retry <a href="/password/requestReset" title="Request password reset">requesting password reset</a>!</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@endsection
|
@ -22,6 +22,7 @@
|
||||
--><button id="resetSignupButton" class="gray marginTop marginLeft" type="button">Reset</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<p class="center marginTop"><a href="/login" title="Login">Already have an account?</a></p>
|
||||
<hr>
|
||||
<div class="center">
|
||||
<a class="button yellow" href="/login/google" title="Signup with Google">Signup with Google</a>
|
||||
|
7
web.php
7
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']);
|
||||
|
Loading…
Reference in New Issue
Block a user