diff --git a/public-legacy/auth/password.php b/public-legacy/auth/password.php index 1bad0a7..92d1a8d 100644 --- a/public-legacy/auth/password.php +++ b/public-legacy/auth/password.php @@ -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 ?? '', ]); diff --git a/src/Auth/LoginAttempts.php b/src/Auth/LoginAttempts.php index b6ad00f..f1dba96 100644 --- a/src/Auth/LoginAttempts.php +++ b/src/Auth/LoginAttempts.php @@ -1,12 +1,8 @@ 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(); + } +} diff --git a/src/Auth/RecoveryTokens.php b/src/Auth/RecoveryTokens.php new file mode 100644 index 0000000..af7e169 --- /dev/null +++ b/src/Auth/RecoveryTokens.php @@ -0,0 +1,105 @@ +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(); + } +} diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php index 34423a1..33f96c2 100644 --- a/src/MisuzuContext.php +++ b/src/MisuzuContext.php @@ -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(); diff --git a/src/Users/UserRecoveryToken.php b/src/Users/UserRecoveryToken.php deleted file mode 100644 index 2af245a..0000000 --- a/src/Users/UserRecoveryToken.php +++ /dev/null @@ -1,115 +0,0 @@ -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; - } -}