Rewrote password recovery token storage using new DB backend.

This commit is contained in:
flash 2023-07-22 21:20:03 +00:00
parent f6058823f1
commit dd21fce6e3
6 changed files with 188 additions and 129 deletions

View file

@ -3,7 +3,6 @@ namespace Misuzu;
use RuntimeException; use RuntimeException;
use Misuzu\Users\User; use Misuzu\Users\User;
use Misuzu\Users\UserRecoveryToken;
use Misuzu\Users\UserSession; use Misuzu\Users\UserSession;
if(UserSession::hasCurrent()) { if(UserSession::hasCurrent()) {
@ -30,6 +29,7 @@ $ipAddress = $_SERVER['REMOTE_ADDR'];
$siteIsPrivate = $cfg->getBoolean('private.enable'); $siteIsPrivate = $cfg->getBoolean('private.enable');
$canResetPassword = $siteIsPrivate ? $cfg->getBoolean('private.allow_password_reset', true) : true; $canResetPassword = $siteIsPrivate ? $cfg->getBoolean('private.allow_password_reset', true) : true;
$recoveryTokens = $msz->getRecoveryTokens();
$loginAttempts = $msz->getLoginAttempts(); $loginAttempts = $msz->getLoginAttempts();
$remainingAttempts = $loginAttempts->countRemainingAttempts($ipAddress); $remainingAttempts = $loginAttempts->countRemainingAttempts($ipAddress);
@ -40,15 +40,15 @@ while($canResetPassword) {
break; break;
} }
$verificationCode = !empty($reset['verification']) && is_string($reset['verification']) ? $reset['verification'] : ''; $verifyCode = !empty($reset['verification']) && is_string($reset['verification']) ? $reset['verification'] : '';
try { try {
$tokenInfo = UserRecoveryToken::byToken($verificationCode); $tokenInfo = $recoveryTokens->getToken(verifyCode: $verifyCode);
} catch(RuntimeException $ex) { } catch(RuntimeException $ex) {
unset($tokenInfo); 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!'; $notices[] = 'Invalid verification code!';
break; break;
} }
@ -76,7 +76,7 @@ while($canResetPassword) {
$msz->createAuditLog('PASSWORD_RESET', [], $userInfo); $msz->createAuditLog('PASSWORD_RESET', [], $userInfo);
$tokenInfo->invalidate(); $recoveryTokens->invalidateToken($tokenInfo);
url_redirect('auth-login', ['redirect' => '/']); url_redirect('auth-login', ['redirect' => '/']);
return; return;
@ -110,13 +110,13 @@ while($canResetPassword) {
} }
try { try {
$tokenInfo = UserRecoveryToken::byUserAndRemoteAddress($forgotUser, $ipAddress); $tokenInfo = $recoveryTokens->getToken(userInfo: $forgotUser, remoteAddr: $ipAddress);
} catch(RuntimeException $ex) { } catch(RuntimeException $ex) {
$tokenInfo = UserRecoveryToken::create($forgotUser, $ipAddress); $tokenInfo = $recoveryTokens->createToken($forgotUser, $ipAddress);
$recoveryMessage = Mailer::template('password-recovery', [ $recoveryMessage = Mailer::template('password-recovery', [
'username' => $forgotUser->getUsername(), 'username' => $forgotUser->getUsername(),
'token' => $tokenInfo->getToken(), 'token' => $tokenInfo->getCode(),
]); ]);
$recoveryMail = Mailer::sendMessage( $recoveryMail = Mailer::sendMessage(
@ -126,7 +126,7 @@ while($canResetPassword) {
if(!$recoveryMail) { if(!$recoveryMail) {
$notices[] = "Failed to send reset email, please contact the administrator."; $notices[] = "Failed to send reset email, please contact the administrator.";
$tokenInfo->invalidate(); $recoveryTokens->invalidateToken($tokenInfo);
break; 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_email' => !empty($forget['email']) && is_string($forget['email']) ? $forget['email'] : '',
'password_attempts_remaining' => $remainingAttempts, 'password_attempts_remaining' => $remainingAttempts,
'password_user' => $userInfo ?? null, 'password_user' => $userInfo ?? null,
'password_verification' => $verificationCode ?? '', 'password_verification' => $verifyCode ?? '',
]); ]);

View file

@ -1,12 +1,8 @@
<?php <?php
namespace Misuzu\Auth; namespace Misuzu\Auth;
use InvalidArgumentException;
use RuntimeException;
use Index\Data\DbStatementCache; use Index\Data\DbStatementCache;
use Index\Data\DbTools;
use Index\Data\IDbConnection; use Index\Data\IDbConnection;
use Index\Data\IDbResult;
use Index\Net\IPAddress; use Index\Net\IPAddress;
use Misuzu\ClientInfo; use Misuzu\ClientInfo;
use Misuzu\Pagination; use Misuzu\Pagination;

View 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
View 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();
}
}

View file

@ -3,6 +3,7 @@ namespace Misuzu;
use Misuzu\Template; use Misuzu\Template;
use Misuzu\Auth\LoginAttempts; use Misuzu\Auth\LoginAttempts;
use Misuzu\Auth\RecoveryTokens;
use Misuzu\AuditLog\AuditLog; use Misuzu\AuditLog\AuditLog;
use Misuzu\Changelog\Changelog; use Misuzu\Changelog\Changelog;
use Misuzu\Comments\Comments; use Misuzu\Comments\Comments;
@ -35,6 +36,7 @@ class MisuzuContext {
private News $news; private News $news;
private Comments $comments; private Comments $comments;
private LoginAttempts $loginAttempts; private LoginAttempts $loginAttempts;
private RecoveryTokens $recoveryTokens;
public function __construct(IDbConnection $dbConn, IConfig $config) { public function __construct(IDbConnection $dbConn, IConfig $config) {
$this->dbConn = $dbConn; $this->dbConn = $dbConn;
@ -45,6 +47,7 @@ class MisuzuContext {
$this->news = new News($this->dbConn); $this->news = new News($this->dbConn);
$this->comments = new Comments($this->dbConn); $this->comments = new Comments($this->dbConn);
$this->loginAttempts = new LoginAttempts($this->dbConn); $this->loginAttempts = new LoginAttempts($this->dbConn);
$this->recoveryTokens = new RecoveryTokens($this->dbConn);
} }
public function getDbConn(): IDbConnection { public function getDbConn(): IDbConnection {
@ -96,6 +99,10 @@ class MisuzuContext {
return $this->loginAttempts; return $this->loginAttempts;
} }
public function getRecoveryTokens(): RecoveryTokens {
return $this->recoveryTokens;
}
public function createAuditLog(string $action, array $params = [], User|string|null $userInfo = null): void { public function createAuditLog(string $action, array $params = [], User|string|null $userInfo = null): void {
if($userInfo === null && User::hasCurrent()) if($userInfo === null && User::hasCurrent())
$userInfo = User::getCurrent(); $userInfo = User::getCurrent();

View file

@ -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;
}
}