Made password recovery OOP.

This commit is contained in:
flash 2020-05-29 15:30:49 +00:00
parent 8bd45209a2
commit 2b5faa0aac
5 changed files with 170 additions and 81 deletions

View file

@ -0,0 +1,28 @@
<?php
namespace Misuzu\DatabaseMigrations\RecoveryTableFixes;
use PDO;
function migrate_up(PDO $conn): void {
$conn->exec("
ALTER TABLE `msz_users_password_resets`
CHANGE COLUMN `verification_code` `verification_code` CHAR(12) NULL DEFAULT NULL COLLATE 'ascii_bin' AFTER `reset_requested`,
DROP INDEX `msz_users_password_resets_unique`,
ADD UNIQUE INDEX `users_password_resets_user_unique` (`user_id`, `reset_ip`),
DROP INDEX `msz_users_password_resets_index`,
ADD INDEX `users_password_resets_created_index` (`reset_requested`),
ADD UNIQUE INDEX `users_password_resets_token_unique` (`verification_code`);
");
}
function migrate_down(PDO $conn): void {
$conn->exec("
ALTER TABLE `msz_users_password_resets`
CHANGE COLUMN `verification_code` `verification_code` CHAR(12) NULL DEFAULT NULL COLLATE 'utf8mb4_bin' AFTER `reset_requested`,
DROP INDEX `users_password_resets_user_unique`,
ADD UNIQUE INDEX `msz_users_password_resets_unique` (`user_id`, `reset_ip`),
DROP INDEX `users_password_resets_created_index`,
ADD INDEX `msz_users_password_resets_index` (`reset_requested`),
DROP INDEX `users_password_resets_token_unique`;
");
}

View file

@ -80,7 +80,6 @@ require_once 'src/Forum/topic.php';
require_once 'src/Forum/validate.php';
require_once 'src/Users/avatar.php';
require_once 'src/Users/background.php';
require_once 'src/Users/recovery.php';
require_once 'src/Users/relations.php';
require_once 'src/Users/role.php';
require_once 'src/Users/user_legacy.php';

View file

@ -1,12 +1,13 @@
<?php
namespace Misuzu;
use UnexpectedValueException;
use Misuzu\AuditLog;
use Misuzu\Net\IPAddress;
use Misuzu\Users\User;
use Misuzu\Users\UserNotFoundException;
use Misuzu\Users\UserLoginAttempt;
use Misuzu\Users\UserRecoveryToken;
use Misuzu\Users\UserRecoveryTokenNotFoundException;
use Misuzu\Users\UserRecoveryTokenCreationFailedException;
use Misuzu\Users\UserSession;
require_once '../../misuzu.php';
@ -33,7 +34,6 @@ if($userId > 0)
$notices = [];
$siteIsPrivate = Config::get('private.enable', Config::TYPE_BOOL);
$canResetPassword = $siteIsPrivate ? Config::get('private.allow_password_reset', Config::TYPE_BOOL, true) : true;
$ipAddress = IPAddress::remote();
$remainingAttempts = UserLoginAttempt::remaining();
while($canResetPassword) {
@ -45,7 +45,13 @@ while($canResetPassword) {
$verificationCode = !empty($reset['verification']) && is_string($reset['verification']) ? $reset['verification'] : '';
if(!user_recovery_token_validate($userId, $verificationCode)) {
try {
$tokenInfo = UserRecoveryToken::byToken($verificationCode);
} catch(UserRecoveryTokenNotFoundException $ex) {
unset($tokenInfo);
}
if(empty($tokenInfo) || !$tokenInfo->isValid() || $tokenInfo->getUserId() !== $userInfo->getId()) {
$notices[] = 'Invalid verification code!';
break;
}
@ -72,7 +78,7 @@ while($canResetPassword) {
// this behaviour should really be replaced with recovery keys...
user_totp_update($userId, null);
user_recovery_token_invalidate($userId, $verificationCode);
$tokenInfo->invalidate();
url_redirect('auth-login', ['redirect' => '/']);
return;
@ -105,16 +111,14 @@ while($canResetPassword) {
break;
}
if(!user_recovery_token_sent($forgotUser->getId(), $ipAddress)) {
$verificationCode = user_recovery_token_create($forgotUser->getId(), $ipAddress);
if(empty($verificationCode)) {
throw new UnexpectedValueException('A verification code failed to insert.');
}
try {
$tokenInfo = UserRecoveryToken::byUserAndRemoteAddress($forgotUser);
} catch(UserRecoveryTokenNotFoundException $ex) {
$tokenInfo = UserRecoveryToken::create($forgotUser);
$recoveryMessage = Mailer::template('password-recovery', [
'username' => $forgotUser->getUsername(),
'token' => $verificationCode,
'token' => $tokenInfo->getToken(),
]);
$recoveryMail = Mailer::sendMessage(
@ -124,7 +128,7 @@ while($canResetPassword) {
if(!$recoveryMail) {
$notices[] = "Failed to send reset email, please contact the administrator.";
user_recovery_token_invalidate($forgotUser->getId(), $verificationCode);
$tokenInfo->invalidate();
break;
}
}

View file

@ -0,0 +1,125 @@
<?php
namespace Misuzu\Users;
use Misuzu\DB;
use Misuzu\Net\IPAddress;
class UserRecoveryTokenException extends UsersException {}
class UserRecoveryTokenNotFoundException extends UserRecoveryTokenException {}
class UserRecoveryTokenCreationFailedException extends UserRecoveryTokenException {}
class UserRecoveryToken {
// Database fields
private $user_id = -1;
private $reset_ip = '::1';
private $reset_requested = null;
private $verification_code = null;
private $user = null;
public const TOKEN_WIDTH = 6;
public const TOKEN_LIFETIME = 60 * 60;
public const TABLE = 'users_password_resets';
private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE;
private const SELECT = '%1$s.`user_id`, %1$s.`verification_code`'
. ', INET6_NTOA(%1$s.`reset_ip`) AS `reset_ip`'
. ', UNIX_TIMESTAMP(%1$s.`reset_requested`) AS `reset_requested`';
public function getUserId(): int {
return $this->user_id < 1 ? -1 : $this->user_id;
}
public function getUser(): User {
if($this->user === null)
$this->user = User::byId($this->getUserId());
return $this->user;
}
public function getRemoteAddress(): string {
return $this->reset_ip;
}
public function getToken(): string {
return $this->verification_code ?? '';
}
public function hasToken(): bool {
return !empty($this->verification_code);
}
public function getCreationTime(): int {
return $this->reset_requested === null ? -1 : $this->reset_requested;
}
public function getExpirationTime(): int {
return $this->getCreationTime() + self::TOKEN_LIFETIME;
}
public function hasExpired(): bool {
return $this->getExpirationTime() <= time();
}
public function isValid(): bool {
return $this->hasToken() && !$this->hasExpired();
}
public function invalidate(): void {
DB::prepare(
'UPDATE `' . DB::PREFIX . self::TABLE . '` SET `verification_code` = NULL'
. ' WHERE `verification_code` = :token AND `user_id` = :user'
) ->bind('token', $this->verification_code)
->bind('user', $this->user_id)
->execute();
}
public static function generateToken(): string {
return bin2hex(random_bytes(self::TOKEN_WIDTH));
}
public static function create(User $user, ?string $remoteAddr = null, bool $return = true): ?self {
$remoteAddr = $remoteAddr ?? IPAddress::remote();
$token = self::generateToken();
$created = DB::prepare('INSERT INTO `' . DB::PREFIX . self::TABLE . '` (`user_id`, `reset_ip`, `verification_code`) VALUES (:user, INET6_ATON(:address), :token)')
->bind('user', $user->getId())
->bind('address', $remoteAddr)
->bind('token', $token)
->execute();
if(!$created)
throw new UserRecoveryTokenCreationFailedException;
if(!$return)
return null;
try {
$object = self::byToken($token);
$object->user = $user;
return $object;
} catch(UserRecoveryTokenNotFoundException $ex) {
throw new UserRecoveryTokenCreationFailedException;
}
}
private static function byQueryBase(): string {
return sprintf(self::QUERY_SELECT, sprintf(self::SELECT, self::TABLE));
}
public static function byToken(string $token): self {
$object = DB::prepare(self::byQueryBase() . ' WHERE `verification_code` = :token')
->bind('token', $token)
->fetchObject(self::class);
if(!$object)
throw new UserRecoveryTokenNotFoundException;
return $object;
}
public static function byUserAndRemoteAddress(User $user, ?string $remoteAddr = null): self {
$remoteAddr = $remoteAddr ?? IPAddress::remote();
$object = DB::prepare(self::byQueryBase() . ' WHERE `user_id` = :user AND `reset_ip` = INET6_ATON(:address)')
->bind('user', $user->getId())
->bind('address', $remoteAddr)
->fetchObject(self::class);
if(!$object)
throw new UserRecoveryTokenNotFoundException;
return $object;
}
}

View file

@ -1,67 +0,0 @@
<?php
define('MSZ_USER_RECOVERY_TOKEN_LENGTH', 6); // * 2
function user_recovery_token_sent(int $userId, string $ipAddress): bool {
$tokenSent = \Misuzu\DB::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
');
$tokenSent->bind('user', $userId);
$tokenSent->bind('ip', $ipAddress);
return (bool)$tokenSent->fetchColumn();
}
function user_recovery_token_validate(int $userId, string $token): bool {
$validateToken = \Misuzu\DB::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
');
$validateToken->bind('user', $userId);
$validateToken->bind('code', $token);
return (bool)$validateToken->fetchColumn();
}
function user_recovery_token_generate(): string {
return bin2hex(random_bytes(MSZ_USER_RECOVERY_TOKEN_LENGTH));
}
function user_recovery_token_create(int $userId, string $ipAddress): string {
$code = user_recovery_token_generate();
$insertResetKey = \Misuzu\DB::prepare('
REPLACE INTO `msz_users_password_resets`
(`user_id`, `reset_ip`, `verification_code`)
VALUES
(:user, INET6_ATON(:ip), :code)
');
$insertResetKey->bind('user', $userId);
$insertResetKey->bind('ip', $ipAddress);
$insertResetKey->bind('code', $code);
return $insertResetKey->execute() ? $code : '';
}
function user_recovery_token_invalidate(int $userId, string $token): void {
$invalidateCode = \Misuzu\DB::prepare('
UPDATE `msz_users_password_resets`
SET `verification_code` = NULL
WHERE `verification_code` = :code
AND `user_id` = :user
');
$invalidateCode->bind('user', $userId);
$invalidateCode->bind('code', $token);
$invalidateCode->execute();
}