Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(session): Replace remember-me tokens in transaction #40628

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 4 additions & 9 deletions lib/private/AllConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -345,19 +345,12 @@ public function getUserKeys($userId, $appName) {
}
}

/**
* Delete a user value
*
* @param string $userId the userId of the user that we want to store the value under
* @param string $appName the appName that we stored the value under
* @param string $key the key under which the value is being stored
*/
public function deleteUserValue($userId, $appName, $key) {
public function deleteUserValue($userId, $appName, $key): bool {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this in general 👍

// TODO - FIXME
$this->fixDIInit();

$qb = $this->connection->getQueryBuilder();
$qb->delete('preferences')
$rowsAffected = $qb->delete('preferences')
->where($qb->expr()->eq('userid', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR)))
->andWhere($qb->expr()->eq('appid', $qb->createNamedParameter($appName, IQueryBuilder::PARAM_STR)))
->andWhere($qb->expr()->eq('configkey', $qb->createNamedParameter($key, IQueryBuilder::PARAM_STR)))
Expand All @@ -366,6 +359,8 @@ public function deleteUserValue($userId, $appName, $key) {
if (isset($this->userCache[$userId][$appName])) {
unset($this->userCache[$userId][$appName][$key]);
}

return $rowsAffected > 0;
}

/**
Expand Down
1 change: 1 addition & 0 deletions lib/private/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,7 @@ public function __construct($webRoot, \OC\Config $config) {
$c->get(ISecureRandom::class),
$c->getLockdownManager(),
$c->get(LoggerInterface::class),
$c->get(IDBConnection::class),
$c->get(IEventDispatcher::class)
);
/** @deprecated 21.0.0 use BeforeUserCreatedEvent event with the IEventDispatcher instead */
Expand Down
64 changes: 49 additions & 15 deletions lib/private/User/Session.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,15 @@
use OC_User;
use OC_Util;
use OCA\DAV\Connector\Sabre\Auth;
use OCP\AppFramework\Db\TTransactional;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Authentication\Exceptions\ExpiredTokenException;
use OCP\Authentication\Exceptions\InvalidTokenException;
use OCP\EventDispatcher\GenericEvent;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\NotPermittedException;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IRequest;
use OCP\ISession;
use OCP\IUser;
Expand All @@ -67,6 +69,7 @@
use OCP\User\Events\UserFirstTimeLoggedInEvent;
use OCP\Util;
use Psr\Log\LoggerInterface;
use function in_array;

/**
* Class Session
Expand All @@ -91,6 +94,9 @@
* @package OC\User
*/
class Session implements IUserSession, Emitter {

use TTransactional;

/** @var Manager $manager */
private $manager;

Expand All @@ -116,6 +122,7 @@
private $lockdownManager;

private LoggerInterface $logger;
private IDBConnection $dbConnection;
/** @var IEventDispatcher */
private $dispatcher;

Expand All @@ -127,6 +134,7 @@
ISecureRandom $random,
ILockdownManager $lockdownManager,
LoggerInterface $logger,
IDBConnection $dbConnection,
IEventDispatcher $dispatcher
) {
$this->manager = $manager;
Expand All @@ -137,6 +145,7 @@
$this->random = $random;
$this->lockdownManager = $lockdownManager;
$this->logger = $logger;
$this->dbConnection = $dbConnection;
$this->dispatcher = $dispatcher;
}

Expand Down Expand Up @@ -905,24 +914,49 @@
return false;
}

// get stored tokens
$tokens = $this->config->getUserKeys($uid, 'login_token');
// test cookies token against stored tokens
if (!in_array($currentToken, $tokens, true)) {
$this->logger->info('Tried to log in but could not verify token', [
/*
* Run token lookup and replacement in a transaction
*
* The READ COMMITTED isolation level causes the database to serialize
* the DELETE query, making it possible to detect if two concurrent
* processes try to replace the same login token.
* Replacing more than once doesn't work because the app token behind
* the session can only be replaced once.
*/
$newToken = $this->atomic(function () use ($uid, $currentToken): ?string {
// get stored tokens
$tokens = $this->config->getUserKeys($uid, 'login_token');
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

food for thought: we might be able to skip the SELECT and go for the DELETE directly

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you doing the select so you have that explict? As to ensure no database funny buisness goes on when you do a delete+insert in the transaction?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the DELETE and INSERT should not trigger a conflict, even in concurrent situations, because the unique contraint is on appid and configkey. configkey is the new, random token.

only in theory there might be two processes with the same random token ;-)

// test cookies token against stored tokens
if (!in_array($currentToken, $tokens, true)) {
$this->logger->error('Tried to log in {uid} but could not find token {token} in database', [
'app' => 'core',
'token' => $currentToken,
'uid' => $uid,
'user' => $uid,
]);
return false;

Check failure on line 937 in lib/private/User/Session.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

FalsableReturnStatement

lib/private/User/Session.php:937:12: FalsableReturnStatement: The declared return type 'null|string' for /home/runner/actions-runner/_work/server/server/lib/private/user/session.php:926:26323:-:closure does not allow false, but the function returns 'false' (see https://psalm.dev/137)

Check failure

Code scanning / Psalm

FalsableReturnStatement Error

The declared return type 'null|string' for /home/runner/actions-runner/_work/server/server/lib/private/user/session.php:926:26323:-:closure does not allow false, but the function returns 'false'
}
// replace successfully used token with a new one
if (!$this->config->deleteUserValue($uid, 'login_token', $currentToken)) {
$this->logger->error('Tried to log in {uid} but ran into concurrent session revival', [
'app' => 'core',
'token' => $currentToken,
'uid' => $uid,
'user' => $uid,
]);
return null;
}
$newToken = $this->random->generate(32);
$this->config->setUserValue($uid, 'login_token', $newToken, (string)$this->timeFactory->getTime());
$this->logger->debug('Remember-me token {token} for {uid} replaced by {newToken}', [
'app' => 'core',
'token' => $currentToken,
'newToken' => $newToken,
'uid' => $uid,
'user' => $uid,
]);
return false;
}
// replace successfully used token with a new one
$this->config->deleteUserValue($uid, 'login_token', $currentToken);
$newToken = $this->random->generate(32);
$this->config->setUserValue($uid, 'login_token', $newToken, (string)$this->timeFactory->getTime());
$this->logger->debug('Remember-me token replaced', [
'app' => 'core',
'user' => $uid,
]);
return $newToken;
}, $this->dbConnection);

try {
$sessionId = $this->session->getId();
Expand Down
3 changes: 2 additions & 1 deletion lib/public/IConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -241,9 +241,10 @@ public function getAllUserValues(string $userId): array;
* @param string $userId the userId of the user that we want to store the value under
* @param string $appName the appName that we stored the value under
* @param string $key the key under which the value is being stored
* @return bool whether a value has been deleted
* @since 8.0.0
*/
public function deleteUserValue($userId, $appName, $key);
public function deleteUserValue($userId, $appName, $key): bool;

/**
* Delete all user values
Expand Down
Loading