decouple user session from tokens

This commit is contained in:
Torsten Dittmann 2021-02-19 13:12:47 +01:00
parent 312ab65794
commit d4bd9c0d06
8 changed files with 214 additions and 78 deletions

View file

@ -271,6 +271,16 @@ $collections = [
'required' => true,
'array' => false,
],
[
'$collection' => Database::SYSTEM_COLLECTION_RULES,
'label' => 'Sessions',
'key' => 'sessions',
'type' => Database::SYSTEM_VAR_TYPE_DOCUMENT,
'default' => [],
'required' => false,
'array' => true,
'list' => [Database::SYSTEM_COLLECTION_SESSIONS],
],
[
'$collection' => Database::SYSTEM_COLLECTION_RULES,
'label' => 'Tokens',
@ -293,11 +303,11 @@ $collections = [
],
],
],
Database::SYSTEM_COLLECTION_TOKENS => [
Database::SYSTEM_COLLECTION_SESSIONS => [
'$collection' => Database::SYSTEM_COLLECTION_COLLECTIONS,
'$id' => Database::SYSTEM_COLLECTION_TOKENS,
'$id' => Database::SYSTEM_COLLECTION_SESSIONS,
'$permissions' => ['read' => ['*']],
'name' => 'Token',
'name' => 'Sessions',
'structure' => true,
'rules' => [
[
@ -306,15 +316,15 @@ $collections = [
'key' => 'userId',
'type' => Database::SYSTEM_VAR_TYPE_TEXT,
'default' => null,
'required' => false,
'required' => true,
'array' => false,
],
[
'$collection' => Database::SYSTEM_COLLECTION_RULES,
'label' => 'Type',
'key' => 'type',
'type' => Database::SYSTEM_VAR_TYPE_NUMERIC,
'default' => null,
'label' => 'Secret',
'key' => 'secret',
'type' => Database::SYSTEM_VAR_TYPE_TEXT,
'default' => '',
'required' => true,
'array' => false,
],
@ -324,7 +334,7 @@ $collections = [
'key' => 'provider',
'type' => Database::SYSTEM_VAR_TYPE_TEXT,
'default' => '',
'required' => false,
'required' => true,
'array' => false,
],
[
@ -333,7 +343,7 @@ $collections = [
'key' => 'providerUid',
'type' => Database::SYSTEM_VAR_TYPE_TEXT,
'default' => '',
'required' => false,
'required' => true,
'array' => false,
],
[
@ -500,6 +510,69 @@ $collections = [
],
],
],
Database::SYSTEM_COLLECTION_TOKENS => [
'$collection' => Database::SYSTEM_COLLECTION_COLLECTIONS,
'$id' => Database::SYSTEM_COLLECTION_TOKENS,
'$permissions' => ['read' => ['*']],
'name' => 'Token',
'structure' => true,
'rules' => [
[
'$collection' => Database::SYSTEM_COLLECTION_RULES,
'label' => 'User ID',
'key' => 'userId',
'type' => Database::SYSTEM_VAR_TYPE_TEXT,
'default' => null,
'required' => false,
'array' => false,
],
[
'$collection' => Database::SYSTEM_COLLECTION_RULES,
'label' => 'Type',
'key' => 'type',
'type' => Database::SYSTEM_VAR_TYPE_NUMERIC,
'default' => null,
'required' => true,
'array' => false,
],
[
'$collection' => Database::SYSTEM_COLLECTION_RULES,
'label' => 'Secret',
'key' => 'secret',
'type' => Database::SYSTEM_VAR_TYPE_TEXT,
'default' => '',
'required' => true,
'array' => false,
],
[
'$collection' => Database::SYSTEM_COLLECTION_RULES,
'label' => 'Expire',
'key' => 'expire',
'type' => Database::SYSTEM_VAR_TYPE_NUMERIC,
'default' => 0,
'required' => true,
'array' => false,
],
[
'$collection' => Database::SYSTEM_COLLECTION_RULES,
'label' => 'User Agent',
'key' => 'userAgent',
'type' => Database::SYSTEM_VAR_TYPE_TEXT,
'default' => '',
'required' => true,
'array' => false,
],
[
'$collection' => Database::SYSTEM_COLLECTION_RULES,
'label' => 'IP',
'key' => 'ip',
'type' => Database::SYSTEM_VAR_TYPE_IP,
'default' => '',
'required' => true,
'array' => false,
],
],
],
Database::SYSTEM_COLLECTION_MEMBERSHIPS => [
'$collection' => Database::SYSTEM_COLLECTION_COLLECTIONS,
'$id' => Database::SYSTEM_COLLECTION_MEMBERSHIPS,

View file

@ -190,11 +190,10 @@ App::post('/v1/account/sessions')
$secret = Auth::tokenGenerator();
$session = new Document(array_merge(
[
'$collection' => Database::SYSTEM_COLLECTION_TOKENS,
'$collection' => Database::SYSTEM_COLLECTION_SESSIONS,
'$permissions' => ['read' => ['user:'.$profile->getId()], 'write' => ['user:'.$profile->getId()]],
'userId' => $profile->getId(),
'type' => Auth::TOKEN_TYPE_LOGIN,
'provider' => Auth::TOKEN_PROVIDER_EMAIL,
'provider' => Auth::SESSION_PROVIDER_EMAIL,
'providerUid' => $email,
'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak
'expire' => $expiry,
@ -212,7 +211,7 @@ App::post('/v1/account/sessions')
throw new Exception('Failed saving session to DB', 500);
}
$profile->setAttribute('tokens', $session, Document::SET_TYPE_APPEND);
$profile->setAttribute('sessions', $session, Document::SET_TYPE_APPEND);
$profile = $projectDB->updateDocument($profile->getArrayCopy());
@ -441,7 +440,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
throw new Exception('Missing ID from OAuth2 provider', 400);
}
$current = Auth::tokenVerify($user->getAttribute('tokens', []), Auth::TOKEN_TYPE_LOGIN, Auth::$secret);
$current = Auth::sessionVerify($user->getAttribute('sessions', []), Auth::$secret);
if ($current) {
$projectDB->deleteDocument($current); //throw new Exception('User already logged in', 401);
@ -451,8 +450,8 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
'limit' => 1,
'filters' => [
'$collection='.Database::SYSTEM_COLLECTION_USERS,
'tokens.provider='.$provider,
'tokens.providerUid='.$oauth2ID
'sessions.provider='.$provider,
'sessions.providerUid='.$oauth2ID
],
]) : $user;
@ -507,10 +506,9 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
$secret = Auth::tokenGenerator();
$expiry = \time() + Auth::TOKEN_EXPIRATION_LOGIN_LONG;
$session = new Document(array_merge([
'$collection' => Database::SYSTEM_COLLECTION_TOKENS,
'$collection' => Database::SYSTEM_COLLECTION_SESSIONS,
'$permissions' => ['read' => ['user:'.$user['$id']], 'write' => ['user:'.$user['$id']]],
'userId' => $user->getId(),
'type' => Auth::TOKEN_TYPE_LOGIN,
'provider' => $provider,
'providerUid' => $oauth2ID,
'providerToken' => $accessToken,
@ -523,7 +521,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
$user
->setAttribute('status', Auth::USER_STATUS_ACTIVATED)
->setAttribute('tokens', $session, Document::SET_TYPE_APPEND)
->setAttribute('sessions', $session, Document::SET_TYPE_APPEND)
;
Authorization::setRole('user:'.$user->getId());
@ -585,16 +583,18 @@ App::post('/v1/account/jwt')
/** @var Appwrite\Utopia\Response $response */
/** @var Appwrite\Database\Document $user */
$tokens = $user->getAttribute('tokens', []);
$session = new Document();
$sessions = $user->getAttribute('sessions', []);
$current = new Document();
foreach ($tokens as $token) { /** @var Appwrite\Database\Document $token */
if ($token->getAttribute('secret') == Auth::hash(Auth::$secret)) { // If current session delete the cookies too
$session = $token;
foreach ($sessions as $session) {
/** @var Appwrite\Database\Document $session */
if ($session->getAttribute('secret') == Auth::hash(Auth::$secret)) { // If current session delete the cookies too
$current = $session;
}
}
if($session->isEmpty()) {
if($current->isEmpty()) {
throw new Exception('No valid session found', 401);
}
@ -608,7 +608,7 @@ App::post('/v1/account/jwt')
// 'scopes' => ['user'],
// 'iss' => 'http://api.mysite.com',
'userId' => $user->getId(),
'sessionId' => $session->getId(),
'sessionId' => $current->getId(),
])]), Response::MODEL_JWT);
});
@ -673,22 +673,19 @@ App::get('/v1/account/sessions')
/** @var Appwrite\Database\Document $user */
/** @var Utopia\Locale\Locale $locale */
$tokens = $user->getAttribute('tokens', []);
$sessions = [];
$sessions = $user->getAttribute('sessions', []);
$countries = $locale->getText('countries');
$current = Auth::tokenVerify($tokens, Auth::TOKEN_TYPE_LOGIN, Auth::$secret);
$current = Auth::sessionVerify($sessions, Auth::$secret);
foreach ($tokens as $token) { /* @var $token Document */
if (Auth::TOKEN_TYPE_LOGIN != $token->getAttribute('type')) {
continue;
}
foreach ($sessions as $key => $session) {
/** @var Document $session */
$token->setAttribute('countryName', (isset($countries[$token->getAttribute('contryCode')]))
? $countries[$token->getAttribute('contryCode')]
$session->setAttribute('countryName', (isset($countries[$session->getAttribute('contryCode')]))
? $countries[$session->getAttribute('contryCode')]
: $locale->getText('locale.country.unknown'));
$token->setAttribute('current', ($current == $token->getId()) ? true : false);
$session->setAttribute('current', ($current == $session->getId()) ? true : false);
$sessions[] = $token;
$sessions[$key] = $session;
}
$response->dynamic(new Document([
@ -1052,14 +1049,14 @@ App::delete('/v1/account/sessions/:sessionId')
$protocol = $request->getProtocol();
$sessionId = ($sessionId === 'current')
? Auth::tokenVerify($user->getAttribute('tokens'), Auth::TOKEN_TYPE_LOGIN, Auth::$secret)
? Auth::sessionVerify($user->getAttribute('sessions'), Auth::$secret)
: $sessionId;
$tokens = $user->getAttribute('tokens', []);
$sessions = $user->getAttribute('sessions', []);
foreach ($tokens as $token) { /* @var $token Document */
if (($sessionId == $token->getId()) && Auth::TOKEN_TYPE_LOGIN == $token->getAttribute('type')) {
if (!$projectDB->deleteDocument($token->getId())) {
foreach ($sessions as $session) { /** @var Document $session */
if (($sessionId == $session->getId())) {
if (!$projectDB->deleteDocument($session->getId())) {
throw new Exception('Failed to remove token from DB', 500);
}
@ -1075,10 +1072,10 @@ App::delete('/v1/account/sessions/:sessionId')
;
}
$token->setAttribute('current', false);
$session->setAttribute('current', false);
if ($token->getAttribute('secret') == Auth::hash(Auth::$secret)) { // If current session delete the cookies too
$token->setAttribute('current', true);
if ($session->getAttribute('secret') == Auth::hash(Auth::$secret)) { // If current session delete the cookies too
$session->setAttribute('current', true);
$response
->addCookie(Auth::$cookieName.'_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
@ -1087,7 +1084,7 @@ App::delete('/v1/account/sessions/:sessionId')
}
$events
->setParam('payload', $response->output($token, Response::MODEL_SESSION))
->setParam('payload', $response->output($session, Response::MODEL_SESSION))
;
return $response->noContent();

View file

@ -595,11 +595,10 @@ App::patch('/v1/teams/:teamId/memberships/:inviteId/status')
$expiry = \time() + Auth::TOKEN_EXPIRATION_LOGIN_LONG;
$secret = Auth::tokenGenerator();
$session = new Document(array_merge([
'$collection' => Database::SYSTEM_COLLECTION_TOKENS,
'$collection' => Database::SYSTEM_COLLECTION_SESSIONS,
'$permissions' => ['read' => ['user:'.$user->getId()], 'write' => ['user:'.$user->getId()]],
'userId' => $user->getId(),
'type' => Auth::TOKEN_TYPE_LOGIN,
'provider' => Auth::TOKEN_PROVIDER_EMAIL,
'provider' => Auth::SESSION_PROVIDER_EMAIL,
'providerUid' => $user->getAttribute('email'),
'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak
'expire' => $expiry,
@ -608,7 +607,7 @@ App::patch('/v1/teams/:teamId/memberships/:inviteId/status')
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
], $detector->getOS(), $detector->getClient(), $detector->getDevice()));
$user->setAttribute('tokens', $session, Document::SET_TYPE_APPEND);
$user->setAttribute('sessions', $session, Document::SET_TYPE_APPEND);
Authorization::setRole('user:'.$userId);

View file

@ -196,21 +196,18 @@ App::get('/v1/users/:userId/sessions')
throw new Exception('User not found', 404);
}
$tokens = $user->getAttribute('tokens', []);
$sessions = [];
$sessions = $user->getAttribute('sessions', []);
$countries = $locale->getText('countries');
foreach ($tokens as $token) { /* @var $token Document */
if (Auth::TOKEN_TYPE_LOGIN != $token->getAttribute('type')) {
continue;
}
foreach ($sessions as $key => $session) {
/** @var Document $session */
$token->setAttribute('countryName', (isset($countries[$token->getAttribute('contryCode')]))
? $countries[$token->getAttribute('contryCode')]
$session->setAttribute('countryName', (isset($countries[$session->getAttribute('contryCode')]))
? $countries[$session->getAttribute('contryCode')]
: $locale->getText('locale.country.unknown'));
$token->setAttribute('current', false);
$session->setAttribute('current', false);
$sessions[] = $token;
$sessions[$key] = $session;
}
$response->dynamic(new Document([

View file

@ -419,7 +419,7 @@ App::setResource('user', function($mode, $project, $console, $request, $response
if (empty($user->getId()) // Check a document has been found in the DB
|| Database::SYSTEM_COLLECTION_USERS !== $user->getCollection() // Validate returned document is really a user document
|| !Auth::tokenVerify($user->getAttribute('tokens', []), Auth::TOKEN_TYPE_LOGIN, Auth::$secret)) { // Validate user has valid login token
|| !Auth::sessionVerify($user->getAttribute('sessions', []), Auth::$secret)) { // Validate user has valid login token
$user = new Document(['$id' => '', '$collection' => Database::SYSTEM_COLLECTION_USERS]);
}

View file

@ -28,7 +28,7 @@ class Auth
/**
* Token Types.
*/
const TOKEN_TYPE_LOGIN = 1;
const TOKEN_TYPE_LOGIN = 1; // Deprecated
const TOKEN_TYPE_VERIFICATION = 2;
const TOKEN_TYPE_RECOVERY = 3;
const TOKEN_TYPE_INVITE = 4;
@ -36,8 +36,8 @@ class Auth
/**
* Session Providers.
*/
const TOKEN_PROVIDER_EMAIL = 'email';
const TOKEN_PROVIDER_ANONYMOUS = 'anonymous';
const SESSION_PROVIDER_EMAIL = 'email';
const SESSION_PROVIDER_ANONYMOUS = 'anonymous';
/**
* Token Expiration times.
@ -213,6 +213,30 @@ class Auth
return false;
}
/**
* Verify session and check that its not expired.
*
* @param array $sessions
* @param string $secret
*
* @return bool|string
*/
public static function sessionVerify(array $sessions, string $secret)
{
foreach ($sessions as $session) { /** @var Document $session */
if ($session->isSet('secret') &&
$session->isSet('expire') &&
$session->isSet('provider') &&
$session->isSet('providerUid') &&
$session->getAttribute('secret') === self::hash($secret) &&
$session->getAttribute('expire') >= \time()) {
return (string)$session->getId();
}
}
return false;
}
/**
* Is Previligged User?
*

View file

@ -27,6 +27,7 @@ class Database
// Auth, Account and Users (private to user)
const SYSTEM_COLLECTION_USERS = 'users';
const SYSTEM_COLLECTION_SESSIONS = 'sessions';
const SYSTEM_COLLECTION_TOKENS = 'tokens';
// Teams (shared among team members)

View file

@ -62,41 +62,55 @@ class AuthTest extends TestCase
$this->assertEquals(\mb_strlen(Auth::tokenGenerator(5)), 10);
}
public function testTokenVerify()
public function testSessionVerify()
{
$secret = 'secret1';
$hash = Auth::hash($secret);
$tokens1 = [
new Document([
'$id' => 'token1',
'type' => Auth::TOKEN_TYPE_LOGIN,
'expire' => time() + 60 * 60 * 24,
'secret' => $hash,
'provider' => Auth::SESSION_PROVIDER_EMAIL,
'providerUid' => 'test@example.com',
]),
new Document([
'$id' => 'token2',
'type' => Auth::TOKEN_TYPE_LOGIN,
'expire' => time() - 60 * 60 * 24,
'secret' => 'secret2',
'provider' => Auth::SESSION_PROVIDER_EMAIL,
'providerUid' => 'test@example.com',
]),
];
$tokens2 = [
new Document([ // Correct secret and type time, wrong expire time
'$id' => 'token1',
'type' => Auth::TOKEN_TYPE_LOGIN,
'expire' => time() - 60 * 60 * 24,
'secret' => $hash,
'provider' => Auth::SESSION_PROVIDER_EMAIL,
'providerUid' => 'test@example.com',
]),
new Document([
'$id' => 'token2',
'type' => Auth::TOKEN_TYPE_LOGIN,
'expire' => time() - 60 * 60 * 24,
'secret' => 'secret2',
'provider' => Auth::SESSION_PROVIDER_EMAIL,
'providerUid' => 'test@example.com',
]),
];
$tokens3 = [ // Correct secret and expire time, wrong type
$this->assertEquals(Auth::sessionVerify($tokens1, $secret), 'token1');
$this->assertEquals(Auth::sessionVerify($tokens1, 'false-secret'), false);
$this->assertEquals(Auth::sessionVerify($tokens2, $secret), false);
$this->assertEquals(Auth::sessionVerify($tokens2, 'false-secret'), false);
}
public function testTokenVerify()
{
$secret = 'secret1';
$hash = Auth::hash($secret);
$tokens1 = [
new Document([
'$id' => 'token1',
'type' => Auth::TOKEN_TYPE_RECOVERY,
@ -105,20 +119,51 @@ class AuthTest extends TestCase
]),
new Document([
'$id' => 'token2',
'type' => Auth::TOKEN_TYPE_LOGIN,
'type' => Auth::TOKEN_TYPE_RECOVERY,
'expire' => time() - 60 * 60 * 24,
'secret' => 'secret2',
]),
];
$this->assertEquals(Auth::tokenVerify($tokens1, Auth::TOKEN_TYPE_LOGIN, $secret), 'token1');
$this->assertEquals(Auth::tokenVerify($tokens1, Auth::TOKEN_TYPE_LOGIN, 'false-secret'), false);
$this->assertEquals(Auth::tokenVerify($tokens2, Auth::TOKEN_TYPE_LOGIN, $secret), false);
$this->assertEquals(Auth::tokenVerify($tokens2, Auth::TOKEN_TYPE_LOGIN, 'false-secret'), false);
$this->assertEquals(Auth::tokenVerify($tokens3, Auth::TOKEN_TYPE_LOGIN, $secret), false);
$this->assertEquals(Auth::tokenVerify($tokens3, Auth::TOKEN_TYPE_LOGIN, 'false-secret'), false);
$tokens2 = [
new Document([ // Correct secret and type time, wrong expire time
'$id' => 'token1',
'type' => Auth::TOKEN_TYPE_RECOVERY,
'expire' => time() - 60 * 60 * 24,
'secret' => $hash,
]),
new Document([
'$id' => 'token2',
'type' => Auth::TOKEN_TYPE_RECOVERY,
'expire' => time() - 60 * 60 * 24,
'secret' => 'secret2',
]),
];
$tokens3 = [ // Correct secret and expire time, wrong type
new Document([
'$id' => 'token1',
'type' => Auth::TOKEN_TYPE_INVITE,
'expire' => time() + 60 * 60 * 24,
'secret' => $hash,
]),
new Document([
'$id' => 'token2',
'type' => Auth::TOKEN_TYPE_RECOVERY,
'expire' => time() - 60 * 60 * 24,
'secret' => 'secret2',
]),
];
$this->assertEquals(Auth::tokenVerify($tokens1, Auth::TOKEN_TYPE_RECOVERY, $secret), 'token1');
$this->assertEquals(Auth::tokenVerify($tokens1, Auth::TOKEN_TYPE_RECOVERY, 'false-secret'), false);
$this->assertEquals(Auth::tokenVerify($tokens2, Auth::TOKEN_TYPE_RECOVERY, $secret), false);
$this->assertEquals(Auth::tokenVerify($tokens2, Auth::TOKEN_TYPE_RECOVERY, 'false-secret'), false);
$this->assertEquals(Auth::tokenVerify($tokens3, Auth::TOKEN_TYPE_RECOVERY, $secret), false);
$this->assertEquals(Auth::tokenVerify($tokens3, Auth::TOKEN_TYPE_RECOVERY, 'false-secret'), false);
}
public function testIsPreviliggedUser()
{
$this->assertEquals(false, Auth::isPreviliggedUser([]));