diff --git a/assets/less/auth/classes/form.less b/assets/less/auth/classes/form.less index 3aa918d5..57b30325 100644 --- a/assets/less/auth/classes/form.less +++ b/assets/less/auth/classes/form.less @@ -84,6 +84,7 @@ &--message { padding-left: 10px; color: #aaa; + max-width: 220px; } &--error { diff --git a/assets/less/auth/classes/text.less b/assets/less/auth/classes/text.less index 2dc6f9ba..9a37f002 100644 --- a/assets/less/auth/classes/text.less +++ b/assets/less/auth/classes/text.less @@ -14,4 +14,9 @@ &__label { display: block; } + + &--disabled { + // same colour as placeholder (in firefox) + color: #959595; + } } diff --git a/misuzu.php b/misuzu.php index 4d7271cf..1660d75e 100644 --- a/misuzu.php +++ b/misuzu.php @@ -82,6 +82,12 @@ if (PHP_SAPI === 'cli') { WHERE `expires_on` < NOW() '); + // Remove old password reset records, left for a week for possible review + Database::exec(' + DELETE FROM `msz_users_password_resets` + WHERE `reset_requested` < NOW() - INTERVAL 1 WEEK + '); + // Cleans up the login history table Database::exec(' DELETE FROM `msz_login_attempts` diff --git a/public/auth.php b/public/auth.php index 15765008..ef22511a 100644 --- a/public/auth.php +++ b/public/auth.php @@ -1,5 +1,6 @@ getConfig(); -$templating = $app->getTemplating(); +$tpl = $app->getTemplating(); $usernameValidationErrors = [ 'trim' => 'Your username may not start or end with spaces!', @@ -21,9 +22,9 @@ $usernameValidationErrors = [ $authMode = $_GET['m'] ?? 'login'; $preventRegistration = $config->get('Auth', 'prevent_registration', 'bool', false); -$templating->addPath('auth', __DIR__ . '/../views/auth'); +$tpl->addPath('auth', __DIR__ . '/../views/auth'); -$templating->vars([ +$tpl->vars([ 'prevent_registration' => $preventRegistration, 'auth_mode' => $authMode, 'auth_username' => $_REQUEST['username'] ?? '', @@ -45,7 +46,182 @@ switch ($authMode) { return; } - echo $templating->render('@auth.logout'); + echo $tpl->render('@auth.logout'); + break; + + case 'reset': + if ($app->hasActiveSession()) { + header('Location: /settings.php'); + break; + } + + $resetUser = (int)($_POST['user'] ?? $_GET['u'] ?? 0); + $getResetUser = Database::prepare(' + SELECT `user_id`, `username` + FROM `msz_users` + WHERE `user_id` = :user_id + '); + $getResetUser->bindValue('user_id', $resetUser); + $resetUser = $getResetUser->execute() ? $getResetUser->fetch(PDO::FETCH_ASSOC) : []; + + if (empty($resetUser)) { + header('Location: ?m=forgot'); + break; + } + + $tpl->var('auth_reset_message', 'A verification code should\'ve been sent to your e-mail address.'); + + while ($_SERVER['REQUEST_METHOD'] === 'POST') { + $validateRequest = Database::prepare(' + SELECT COUNT(`user_id`) > 0 + FROM `msz_users_password_resets` + WHERE `user_id` = :user + AND `verification_code` = :code + AND `verification_code` IS NOT NULL + AND `reset_requested` > NOW() - INTERVAL 1 HOUR + '); + $validateRequest->bindValue('user', $resetUser['user_id']); + $validateRequest->bindValue('code', $_POST['verification'] ?? ''); + $validateRequest = $validateRequest->execute() + ? (bool)$validateRequest->fetchColumn() + : false; + + if (!$validateRequest) { + $tpl->var('auth_reset_error', 'Invalid verification code!'); + break; + } + + $tpl->var('reset_verify', $_POST['verification']); + + if (empty($_POST['password']['new']) + || empty($_POST['password']['confirm']) + || $_POST['password']['new'] !== $_POST['password']['confirm']) { + $tpl->var('auth_reset_error', 'Your passwords didn\'t match!'); + break; + } + + if (user_validate_password($_POST['password']['new']) !== '') { + $tpl->var('auth_reset_error', 'Your password is too weak!'); + break; + } + + $updatePassword = Database::prepare(' + UPDATE `msz_users` + SET `password` = :password + WHERE `user_id` = :user + '); + $updatePassword->bindValue('user', $resetUser['user_id']); + $updatePassword->bindValue('password', user_password_hash($_POST['password']['new'])); + + if ($updatePassword->execute()) { + audit_log('PASSWORD_RESET', $resetUser['user_id']); + } else { + throw new UnexpectedValueException('Password reset failed.'); + } + + $invalidateCode = Database::prepare(' + UPDATE `msz_users_password_resets` + SET `verification_code` = NULL + WHERE `verification_code` = :code + AND `user_id` = :user + '); + $invalidateCode->bindValue('user', $resetUser['user_id']); + $invalidateCode->bindValue('code', $_POST['verification']); + + if (!$invalidateCode->execute()) { + throw new UnexpectedValueException('Verification code invalidation failed.'); + } + + header('Location: /auth.php?m=login'); + break; + } + + echo $tpl->render('@auth.password', [ + 'reset_user' => $resetUser, + ]); + break; + + case 'forgot': + if ($app->hasActiveSession()) { + header('Location: /'); + break; + } + + while ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (empty($_POST['email'])) { + $tpl->var('auth_forgot_error', 'Please enter an e-mail address.'); + break; + } + + $forgotUser = Database::prepare(' + SELECT `user_id`, `username`, `email` + FROM `msz_users` + WHERE LOWER(`email`) = LOWER(:email) + '); + $forgotUser->bindValue('email', $_POST['email']); + $forgotUser = $forgotUser->execute() ? $forgotUser->fetch(PDO::FETCH_ASSOC) : []; + + if (empty($forgotUser)) { + break; + } + + $ipAddress = IPAddress::remote()->getString(); + $emailSent = Database::prepare(' + SELECT COUNT(`verification_code`) > 0 + FROM `msz_users_password_resets` + WHERE `user_id` = :user + AND `reset_ip` = INET6_ATON(:ip) + AND `reset_requested` > NOW() - INTERVAL 1 HOUR + AND `verification_code` IS NOT NULL + '); + $emailSent->bindValue('user', $forgotUser['user_id']); + $emailSent->bindValue('ip', $ipAddress); + $emailSent = $emailSent->execute() + ? (bool)$emailSent->fetchColumn() + : false; + + if (!$emailSent) { + $verificationCode = bin2hex(random_bytes(6)); + $insertResetKey = Database::prepare(' + REPLACE INTO `msz_users_password_resets` + (`user_id`, `reset_ip`, `verification_code`) + VALUES + (:user, INET6_ATON(:ip), :code) + '); + $insertResetKey->bindValue('user', $forgotUser['user_id']); + $insertResetKey->bindValue('ip', $ipAddress); + $insertResetKey->bindValue('code', $verificationCode); + + if (!$insertResetKey->execute()) { + throw new UnexpectedValueException('A verification code failed to insert.'); + } + + $messageBody = <<setFrom([ + $config->get('Mail', 'sender_email', 'string', 'sys@misuzu.lh') => + $config->get('Mail', 'sender_name', 'string', 'Misuzu') + ]) + ->setTo([$forgotUser['email'] => $forgotUser['username']]) + ->setBody($messageBody); + + Application::mailer()->send($message); + } + + header("Location: ?m=reset&u={$forgotUser['user_id']}"); + break; + } + + echo $tpl->render('@auth.auth'); break; case 'login': @@ -122,10 +298,10 @@ switch ($authMode) { } if (!empty($authLoginError)) { - $templating->var('auth_login_error', $authLoginError); + $tpl->var('auth_login_error', $authLoginError); } - echo $templating->render('auth'); + echo $tpl->render('@auth.auth'); break; case 'register': @@ -183,14 +359,14 @@ switch ($authMode) { user_role_add($createUser, MSZ_ROLE_MAIN); - $templating->var('auth_register_message', 'Welcome to Flashii! You may now log in.'); + $tpl->var('auth_register_message', 'Welcome to Flashii! You may now log in.'); break; } if (!empty($authRegistrationError)) { - $templating->var('auth_register_error', $authRegistrationError); + $tpl->var('auth_register_error', $authRegistrationError); } - echo $templating->render('@auth.auth'); + echo $tpl->render('@auth.auth'); break; } diff --git a/views/auth/auth.twig b/views/auth/auth.twig index 38178fb2..69cafda5 100644 --- a/views/auth/auth.twig +++ b/views/auth/auth.twig @@ -10,13 +10,16 @@ + +
+
@@ -38,12 +41,15 @@ + + +
{{ auth_register_message|default(auth_register_error|default('')) }} @@ -57,24 +63,25 @@
- {#
- -
- -
+
+ +
+ + +
-
- {{ auth_restore_message|default(auth_restore_error|default('')) }} +
+ {{ auth_forgot_message|default(auth_forgot_error|default('')) }}
- +
-
#} +
{% endblock %} diff --git a/views/auth/master.twig b/views/auth/master.twig index d7a47807..f24ac6e7 100644 --- a/views/auth/master.twig +++ b/views/auth/master.twig @@ -4,7 +4,7 @@ Authentication - +
diff --git a/views/auth/password.twig b/views/auth/password.twig new file mode 100644 index 00000000..352b14b6 --- /dev/null +++ b/views/auth/password.twig @@ -0,0 +1,37 @@ +{% extends '@auth/master.twig' %} + +{% block content %} +
+
+ +
+ +
+ + + + + + + + +
+
+ {{ auth_reset_error|default(auth_reset_message|default('')) }} +
+ +
+ +
+
+
+
+
+
+{% endblock %}