Rewrote password recovery token storage using new DB backend.
This commit is contained in:
parent
f6058823f1
commit
dd21fce6e3
6 changed files with 188 additions and 129 deletions
|
@ -3,7 +3,6 @@ namespace Misuzu;
|
|||
|
||||
use RuntimeException;
|
||||
use Misuzu\Users\User;
|
||||
use Misuzu\Users\UserRecoveryToken;
|
||||
use Misuzu\Users\UserSession;
|
||||
|
||||
if(UserSession::hasCurrent()) {
|
||||
|
@ -30,6 +29,7 @@ $ipAddress = $_SERVER['REMOTE_ADDR'];
|
|||
$siteIsPrivate = $cfg->getBoolean('private.enable');
|
||||
$canResetPassword = $siteIsPrivate ? $cfg->getBoolean('private.allow_password_reset', true) : true;
|
||||
|
||||
$recoveryTokens = $msz->getRecoveryTokens();
|
||||
$loginAttempts = $msz->getLoginAttempts();
|
||||
$remainingAttempts = $loginAttempts->countRemainingAttempts($ipAddress);
|
||||
|
||||
|
@ -40,15 +40,15 @@ while($canResetPassword) {
|
|||
break;
|
||||
}
|
||||
|
||||
$verificationCode = !empty($reset['verification']) && is_string($reset['verification']) ? $reset['verification'] : '';
|
||||
$verifyCode = !empty($reset['verification']) && is_string($reset['verification']) ? $reset['verification'] : '';
|
||||
|
||||
try {
|
||||
$tokenInfo = UserRecoveryToken::byToken($verificationCode);
|
||||
$tokenInfo = $recoveryTokens->getToken(verifyCode: $verifyCode);
|
||||
} catch(RuntimeException $ex) {
|
||||
unset($tokenInfo);
|
||||
}
|
||||
|
||||
if(empty($tokenInfo) || !$tokenInfo->isValid() || $tokenInfo->getUserId() !== $userInfo->getId()) {
|
||||
if(empty($tokenInfo) || !$tokenInfo->isValid() || $tokenInfo->getUserId() !== (string)$userInfo->getId()) {
|
||||
$notices[] = 'Invalid verification code!';
|
||||
break;
|
||||
}
|
||||
|
@ -76,7 +76,7 @@ while($canResetPassword) {
|
|||
|
||||
$msz->createAuditLog('PASSWORD_RESET', [], $userInfo);
|
||||
|
||||
$tokenInfo->invalidate();
|
||||
$recoveryTokens->invalidateToken($tokenInfo);
|
||||
|
||||
url_redirect('auth-login', ['redirect' => '/']);
|
||||
return;
|
||||
|
@ -110,13 +110,13 @@ while($canResetPassword) {
|
|||
}
|
||||
|
||||
try {
|
||||
$tokenInfo = UserRecoveryToken::byUserAndRemoteAddress($forgotUser, $ipAddress);
|
||||
$tokenInfo = $recoveryTokens->getToken(userInfo: $forgotUser, remoteAddr: $ipAddress);
|
||||
} catch(RuntimeException $ex) {
|
||||
$tokenInfo = UserRecoveryToken::create($forgotUser, $ipAddress);
|
||||
$tokenInfo = $recoveryTokens->createToken($forgotUser, $ipAddress);
|
||||
|
||||
$recoveryMessage = Mailer::template('password-recovery', [
|
||||
'username' => $forgotUser->getUsername(),
|
||||
'token' => $tokenInfo->getToken(),
|
||||
'token' => $tokenInfo->getCode(),
|
||||
]);
|
||||
|
||||
$recoveryMail = Mailer::sendMessage(
|
||||
|
@ -126,7 +126,7 @@ while($canResetPassword) {
|
|||
|
||||
if(!$recoveryMail) {
|
||||
$notices[] = "Failed to send reset email, please contact the administrator.";
|
||||
$tokenInfo->invalidate();
|
||||
$recoveryTokens->invalidateToken($tokenInfo);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -143,5 +143,5 @@ Template::render(isset($userInfo) ? 'auth.password_reset' : 'auth.password_forgo
|
|||
'password_email' => !empty($forget['email']) && is_string($forget['email']) ? $forget['email'] : '',
|
||||
'password_attempts_remaining' => $remainingAttempts,
|
||||
'password_user' => $userInfo ?? null,
|
||||
'password_verification' => $verificationCode ?? '',
|
||||
'password_verification' => $verifyCode ?? '',
|
||||
]);
|
||||
|
|
|
@ -1,12 +1,8 @@
|
|||
<?php
|
||||
namespace Misuzu\Auth;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Index\Data\DbStatementCache;
|
||||
use Index\Data\DbTools;
|
||||
use Index\Data\IDbConnection;
|
||||
use Index\Data\IDbResult;
|
||||
use Index\Net\IPAddress;
|
||||
use Misuzu\ClientInfo;
|
||||
use Misuzu\Pagination;
|
||||
|
|
66
src/Auth/RecoveryTokenInfo.php
Normal file
66
src/Auth/RecoveryTokenInfo.php
Normal file
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
namespace Misuzu\Auth;
|
||||
|
||||
use Index\DateTime;
|
||||
use Index\Data\IDbResult;
|
||||
use Index\Net\IPAddress;
|
||||
|
||||
class RecoveryTokenInfo {
|
||||
public const LIFETIME = 60 * 60;
|
||||
|
||||
private string $userId;
|
||||
private string $remoteAddr;
|
||||
private int $created;
|
||||
private ?string $code;
|
||||
|
||||
public function __construct(IDbResult $result) {
|
||||
$this->userId = (string)$result->getInteger(0);
|
||||
$this->remoteAddr = $result->getString(1);
|
||||
$this->created = $result->getInteger(2);
|
||||
$this->code = $result->isNull(3) ? null : $result->getString(3);
|
||||
}
|
||||
|
||||
public function getUserId(): string {
|
||||
return $this->userId;
|
||||
}
|
||||
|
||||
public function getRemoteAddressRaw(): string {
|
||||
return $this->remoteAddr;
|
||||
}
|
||||
|
||||
public function getRemoteAddress(): IPAddress {
|
||||
return IPAddress::parse($this->remoteAddr);
|
||||
}
|
||||
|
||||
public function getCreatedTime(): int {
|
||||
return $this->created;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTime {
|
||||
return DateTime::fromUnixTimeSeconds($this->created);
|
||||
}
|
||||
|
||||
public function getExpirationTime(): int {
|
||||
return $this->created + self::LIFETIME;
|
||||
}
|
||||
|
||||
public function getExpirationAt(): bool {
|
||||
return DateTime::fromUnixTimeSeconds($this->created + self::LIFETIME);
|
||||
}
|
||||
|
||||
public function hasExpired(): bool {
|
||||
return $this->getExpirationTime() <= time();
|
||||
}
|
||||
|
||||
public function hasCode(): bool {
|
||||
return $this->code !== null;
|
||||
}
|
||||
|
||||
public function getCode(): ?string {
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
public function isValid(): bool {
|
||||
return $this->hasCode() && !$this->hasExpired();
|
||||
}
|
||||
}
|
105
src/Auth/RecoveryTokens.php
Normal file
105
src/Auth/RecoveryTokens.php
Normal file
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
namespace Misuzu\Auth;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Index\Data\DbStatementCache;
|
||||
use Index\Data\IDbConnection;
|
||||
use Index\Net\IPAddress;
|
||||
use Index\Serialisation\Base32;
|
||||
use Misuzu\ClientInfo;
|
||||
use Misuzu\Pagination;
|
||||
use Misuzu\Users\User;
|
||||
|
||||
class RecoveryTokens {
|
||||
private DbStatementCache $cache;
|
||||
|
||||
public function __construct(IDbConnection $dbConn) {
|
||||
$this->cache = new DbStatementCache($dbConn);
|
||||
}
|
||||
|
||||
private static function generateCode(): string {
|
||||
// results in a 12 char code
|
||||
return Base32::encode(random_bytes(7));
|
||||
}
|
||||
|
||||
public function getToken(
|
||||
User|string|null $userInfo = null,
|
||||
IPAddress|string|null $remoteAddr = null,
|
||||
?string $verifyCode = null,
|
||||
?bool $isUnused = null
|
||||
): RecoveryTokenInfo {
|
||||
if($userInfo instanceof User)
|
||||
$userInfo = (string)$userInfo->getId();
|
||||
if($remoteAddr instanceof IPAddress)
|
||||
$remoteAddr = (string)$remoteAddr;
|
||||
|
||||
$hasUserInfo = $userInfo !== null;
|
||||
$hasRemoteAddr = $remoteAddr !== null;
|
||||
$hasVerifyCode = $verifyCode !== null;
|
||||
$hasIsUnused = $isUnused !== null;
|
||||
|
||||
if(!$hasUserInfo && !$hasRemoteAddr && !$hasVerifyCode)
|
||||
throw new InvalidArgumentException('At least one argument must be correctly specified.');
|
||||
if($hasIsUnused && $hasVerifyCode)
|
||||
throw new InvalidArgumentException('You may not specify $isUnused and $verifyCode at the same time.');
|
||||
|
||||
$args = 0;
|
||||
$query = 'SELECT user_id, INET6_NTOA(reset_ip), UNIX_TIMESTAMP(reset_requested), verification_code FROM msz_users_password_resets';
|
||||
if($hasUserInfo) {
|
||||
++$args;
|
||||
$query .= ' WHERE user_id = ?';
|
||||
}
|
||||
if($hasRemoteAddr)
|
||||
$query .= sprintf(' %s reset_ip = INET6_ATON(?)', ++$args > 1 ? 'AND' : 'WHERE');
|
||||
if($hasIsUnused)
|
||||
$query .= sprintf(' %s verification_code %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $isUnused ? 'IS NOT' : 'IS');
|
||||
elseif($hasVerifyCode)
|
||||
$query .= sprintf(' %s verification_code = ?', ++$args > 1 ? 'AND' : 'WHERE');
|
||||
|
||||
$args = 0;
|
||||
$stmt = $this->cache->get($query);
|
||||
if($hasUserInfo)
|
||||
$stmt->addParameter(++$args, $userInfo);
|
||||
if($hasRemoteAddr)
|
||||
$stmt->addParameter(++$args, $remoteAddr);
|
||||
if($hasVerifyCode)
|
||||
$stmt->addParameter(++$args, $verifyCode);
|
||||
$stmt->execute();
|
||||
$result = $stmt->getResult();
|
||||
|
||||
if(!$result->next())
|
||||
throw new RuntimeException('Recovery token not found.');
|
||||
|
||||
return new RecoveryTokenInfo($result);
|
||||
}
|
||||
|
||||
public function createToken(
|
||||
User|string $userInfo,
|
||||
IPAddress|string $remoteAddr
|
||||
): RecoveryTokenInfo {
|
||||
if($userInfo instanceof User)
|
||||
$userInfo = (string)$userInfo->getId();
|
||||
if($remoteAddr instanceof IPAddress)
|
||||
$remoteAddr = (string)$remoteAddr;
|
||||
$verifyCode = self::generateCode();
|
||||
|
||||
$stmt = $this->cache->get('INSERT INTO msz_users_password_resets (user_id, reset_ip, verification_code) VALUES (?, INET6_ATON(?), ?)');
|
||||
$stmt->addParameter(1, $userInfo);
|
||||
$stmt->addParameter(2, $remoteAddr);
|
||||
$stmt->addParameter(3, $verifyCode);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->getToken(verifyCode: $verifyCode);
|
||||
}
|
||||
|
||||
public function invalidateToken(RecoveryTokenInfo $tokenInfo): void {
|
||||
if(!$tokenInfo->hasCode())
|
||||
return;
|
||||
|
||||
$stmt = $this->cache->get('UPDATE msz_users_password_resets SET verification_code = NULL WHERE verification_code = ? AND user_id = ?');
|
||||
$stmt->addParameter(1, $tokenInfo->getCode());
|
||||
$stmt->addParameter(2, $tokenInfo->getUserId());
|
||||
$stmt->execute();
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@ namespace Misuzu;
|
|||
|
||||
use Misuzu\Template;
|
||||
use Misuzu\Auth\LoginAttempts;
|
||||
use Misuzu\Auth\RecoveryTokens;
|
||||
use Misuzu\AuditLog\AuditLog;
|
||||
use Misuzu\Changelog\Changelog;
|
||||
use Misuzu\Comments\Comments;
|
||||
|
@ -35,6 +36,7 @@ class MisuzuContext {
|
|||
private News $news;
|
||||
private Comments $comments;
|
||||
private LoginAttempts $loginAttempts;
|
||||
private RecoveryTokens $recoveryTokens;
|
||||
|
||||
public function __construct(IDbConnection $dbConn, IConfig $config) {
|
||||
$this->dbConn = $dbConn;
|
||||
|
@ -45,6 +47,7 @@ class MisuzuContext {
|
|||
$this->news = new News($this->dbConn);
|
||||
$this->comments = new Comments($this->dbConn);
|
||||
$this->loginAttempts = new LoginAttempts($this->dbConn);
|
||||
$this->recoveryTokens = new RecoveryTokens($this->dbConn);
|
||||
}
|
||||
|
||||
public function getDbConn(): IDbConnection {
|
||||
|
@ -96,6 +99,10 @@ class MisuzuContext {
|
|||
return $this->loginAttempts;
|
||||
}
|
||||
|
||||
public function getRecoveryTokens(): RecoveryTokens {
|
||||
return $this->recoveryTokens;
|
||||
}
|
||||
|
||||
public function createAuditLog(string $action, array $params = [], User|string|null $userInfo = null): void {
|
||||
if($userInfo === null && User::hasCurrent())
|
||||
$userInfo = User::getCurrent();
|
||||
|
|
|
@ -1,115 +0,0 @@
|
|||
<?php
|
||||
namespace Misuzu\Users;
|
||||
|
||||
use RuntimeException;
|
||||
use Misuzu\DB;
|
||||
|
||||
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, bool $return = true): ?self {
|
||||
$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 RuntimeException('Failed to create password recovery token.');
|
||||
if(!$return)
|
||||
return null;
|
||||
|
||||
$object = self::byToken($token);
|
||||
$object->user = $user;
|
||||
return $object;
|
||||
}
|
||||
|
||||
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 RuntimeException('Failed to fetch recovery token.');
|
||||
|
||||
return $object;
|
||||
}
|
||||
public static function byUserAndRemoteAddress(User $user, string $remoteAddr): self {
|
||||
$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 RuntimeException('Failed to fetch recovery token by user and address.');
|
||||
|
||||
return $object;
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue