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 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 ?? '',
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
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\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();
|
||||||
|
|
|
@ -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