nextcloud/lib/private/Authentication/Token/PublicKeyTokenMapper.php
Christoph Wurst 4e41638101 Prevent duplicate auth token activity updates
The auth token activity logic works as follows
* Read auth token
* Compare last activity time stamp to current time
* Update auth token activity if it's older than x seconds

This works fine in isolation but with concurrency that means that
occasionally the same token is read simultaneously by two processes and
both of these processes will trigger an update of the same row.
Affectively the second update doesn't add much value. It might set the
time stamp to the exact same time stamp or one a few seconds later. But
the last activity is no precise science, we don't need this accuracy.

This patch changes the UPDATE query to include the expected value in a
comparison with the current data. This results in an affected row when
the data in the DB still has an old time stamp, but won't affect a row
if the time stamp is (nearly) up to date.

This is a micro optimization and will possibly not show any significant
performance improvement. Yet in setups with a DB cluster it means that
the write node has to send fewer changes to the read nodes due to the
lower number of actual changes.

Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
2021-10-22 09:16:05 +00:00

232 lines
8 KiB
PHP

<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2018 Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @author Daniel Kesselberg <mail@danielkesselberg.de>
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OC\Authentication\Token;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/**
* @template-extends QBMapper<PublicKeyToken>
*/
class PublicKeyTokenMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, 'authtoken');
}
/**
* Invalidate (delete) a given token
*
* @param string $token
*/
public function invalidate(string $token) {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->delete('authtoken')
->where($qb->expr()->eq('token', $qb->createNamedParameter($token)))
->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT)))
->execute();
}
/**
* @param int $olderThan
* @param int $remember
*/
public function invalidateOld(int $olderThan, int $remember = IToken::DO_NOT_REMEMBER) {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->delete('authtoken')
->where($qb->expr()->lt('last_activity', $qb->createNamedParameter($olderThan, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('type', $qb->createNamedParameter(IToken::TEMPORARY_TOKEN, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('remember', $qb->createNamedParameter($remember, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT)))
->execute();
}
/**
* Get the user UID for the given token
*
* @throws DoesNotExistException
*/
public function getToken(string $token): PublicKeyToken {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$result = $qb->select('*')
->from('authtoken')
->where($qb->expr()->eq('token', $qb->createNamedParameter($token)))
->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT)))
->execute();
$data = $result->fetch();
$result->closeCursor();
if ($data === false) {
throw new DoesNotExistException('token does not exist');
}
return PublicKeyToken::fromRow($data);
}
/**
* Get the token for $id
*
* @throws DoesNotExistException
*/
public function getTokenById(int $id): PublicKeyToken {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$result = $qb->select('*')
->from('authtoken')
->where($qb->expr()->eq('id', $qb->createNamedParameter($id)))
->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT)))
->execute();
$data = $result->fetch();
$result->closeCursor();
if ($data === false) {
throw new DoesNotExistException('token does not exist');
}
return PublicKeyToken::fromRow($data);
}
/**
* Get all tokens of a user
*
* The provider may limit the number of result rows in case of an abuse
* where a high number of (session) tokens is generated
*
* @param string $uid
* @return PublicKeyToken[]
*/
public function getTokenByUser(string $uid): array {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from('authtoken')
->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid)))
->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT)))
->setMaxResults(1000);
$result = $qb->execute();
$data = $result->fetchAll();
$result->closeCursor();
$entities = array_map(function ($row) {
return PublicKeyToken::fromRow($row);
}, $data);
return $entities;
}
public function deleteById(string $uid, int $id) {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->delete('authtoken')
->where($qb->expr()->eq('id', $qb->createNamedParameter($id)))
->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter($uid)))
->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT)));
$qb->execute();
}
/**
* delete all auth token which belong to a specific client if the client was deleted
*
* @param string $name
*/
public function deleteByName(string $name) {
$qb = $this->db->getQueryBuilder();
$qb->delete('authtoken')
->where($qb->expr()->eq('name', $qb->createNamedParameter($name), IQueryBuilder::PARAM_STR))
->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT)));
$qb->execute();
}
public function deleteTempToken(PublicKeyToken $except) {
$qb = $this->db->getQueryBuilder();
$qb->delete('authtoken')
->where($qb->expr()->eq('uid', $qb->createNamedParameter($except->getUID())))
->andWhere($qb->expr()->eq('type', $qb->createNamedParameter(IToken::TEMPORARY_TOKEN)))
->andWhere($qb->expr()->neq('id', $qb->createNamedParameter($except->getId())))
->andWhere($qb->expr()->eq('version', $qb->createNamedParameter(PublicKeyToken::VERSION, IQueryBuilder::PARAM_INT)));
$qb->execute();
}
public function hasExpiredTokens(string $uid): bool {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from('authtoken')
->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid)))
->andWhere($qb->expr()->eq('password_invalid', $qb->createNamedParameter(true), IQueryBuilder::PARAM_BOOL))
->setMaxResults(1);
$cursor = $qb->execute();
$data = $cursor->fetchAll();
$cursor->closeCursor();
return count($data) === 1;
}
/**
* Update the last activity timestamp
*
* In highly concurrent setups it can happen that two parallel processes
* trigger the update at (nearly) the same time. In that special case it's
* not necessary to hit the database with two actual updates. Therefore the
* target last activity is included in the WHERE clause with a few seconds
* of tolerance.
*
* Example:
* - process 1 (P1) reads the token at timestamp 1500
* - process 1 (P2) reads the token at timestamp 1501
* - activity update interval is 100
*
* This means
*
* - P1 will see a last_activity smaller than the current time and update
* the token row
* - If P2 reads after P1 had written, it will see 1600 as last activity
* and the comparison on last_activity won't be truthy. This means no rows
* need to be updated a second time
* - If P2 reads before P1 had written, it will see 1501 as last activity,
* but the comparison on last_activity will still not be truthy and the
* token row is not updated a second time
*
* @param IToken $token
* @param int $now
*/
public function updateActivity(IToken $token, int $now): void {
$qb = $this->db->getQueryBuilder();
$update = $qb->update($this->getTableName())
->set('last_activity', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT))
->where(
$qb->expr()->eq('id', $qb->createNamedParameter($token->getId(), IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT),
$qb->expr()->lt('last_activity', $qb->createNamedParameter($now - 15, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)
);
$update->executeStatement();
}
}