Made password recovery OOP.
This commit is contained in:
parent
8bd45209a2
commit
2b5faa0aac
5 changed files with 170 additions and 81 deletions
28
database/2020_05_29_142907_recovery_table_fixes.php
Normal file
28
database/2020_05_29_142907_recovery_table_fixes.php
Normal 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`;
|
||||
");
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
125
src/Users/UserRecoveryToken.php
Normal file
125
src/Users/UserRecoveryToken.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue