Merge pull request #922 from TorstenDittmann/refactor-user-tokens

refactor-user-tokens
This commit is contained in:
Eldad A. Fux 2021-03-29 21:00:12 +03:00 committed by GitHub
commit cd6263e8b7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 467 additions and 112 deletions

View file

@ -271,6 +271,16 @@ $collections = [
'required' => true, 'required' => true,
'array' => false, '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, '$collection' => Database::SYSTEM_COLLECTION_RULES,
'label' => 'Tokens', 'label' => 'Tokens',
@ -293,11 +303,11 @@ $collections = [
], ],
], ],
], ],
Database::SYSTEM_COLLECTION_TOKENS => [ Database::SYSTEM_COLLECTION_SESSIONS => [
'$collection' => Database::SYSTEM_COLLECTION_COLLECTIONS, '$collection' => Database::SYSTEM_COLLECTION_COLLECTIONS,
'$id' => Database::SYSTEM_COLLECTION_TOKENS, '$id' => Database::SYSTEM_COLLECTION_SESSIONS,
'$permissions' => ['read' => ['*']], '$permissions' => ['read' => ['*']],
'name' => 'Token', 'name' => 'Session',
'structure' => true, 'structure' => true,
'rules' => [ 'rules' => [
[ [
@ -306,16 +316,34 @@ $collections = [
'key' => 'userId', 'key' => 'userId',
'type' => Database::SYSTEM_VAR_TYPE_TEXT, 'type' => Database::SYSTEM_VAR_TYPE_TEXT,
'default' => null, 'default' => null,
'required' => true,
'array' => false,
],
[
'$collection' => Database::SYSTEM_COLLECTION_RULES,
'label' => 'Provider',
'key' => 'provider',
'type' => Database::SYSTEM_VAR_TYPE_TEXT,
'default' => '',
'required' => true,
'array' => false,
],
[
'$collection' => Database::SYSTEM_COLLECTION_RULES,
'label' => 'Provider User Identifier',
'key' => 'providerUid',
'type' => Database::SYSTEM_VAR_TYPE_TEXT,
'default' => '',
'required' => false, 'required' => false,
'array' => false, 'array' => false,
], ],
[ [
'$collection' => Database::SYSTEM_COLLECTION_RULES, '$collection' => Database::SYSTEM_COLLECTION_RULES,
'label' => 'Type', 'label' => 'Provider Token',
'key' => 'type', 'key' => 'providerToken',
'type' => Database::SYSTEM_VAR_TYPE_NUMERIC, 'type' => Database::SYSTEM_VAR_TYPE_TEXT,
'default' => null, 'default' => '',
'required' => true, 'required' => false,
'array' => false, 'array' => false,
], ],
[ [
@ -473,6 +501,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 => [ Database::SYSTEM_COLLECTION_MEMBERSHIPS => [
'$collection' => Database::SYSTEM_COLLECTION_COLLECTIONS, '$collection' => Database::SYSTEM_COLLECTION_COLLECTIONS,
'$id' => Database::SYSTEM_COLLECTION_MEMBERSHIPS, '$id' => Database::SYSTEM_COLLECTION_MEMBERSHIPS,
@ -1617,26 +1708,6 @@ foreach ($providers as $index => $provider) {
'array' => false, 'array' => false,
'filter' => ['encrypt'], 'filter' => ['encrypt'],
]; ];
$collections[Database::SYSTEM_COLLECTION_USERS]['rules'][] = [
'$collection' => Database::SYSTEM_COLLECTION_RULES,
'label' => 'OAuth2 '.\ucfirst($index).' ID',
'key' => 'oauth2'.\ucfirst($index),
'type' => Database::SYSTEM_VAR_TYPE_TEXT,
'default' => '',
'required' => false,
'array' => false,
];
$collections[Database::SYSTEM_COLLECTION_USERS]['rules'][] = [
'$collection' => Database::SYSTEM_COLLECTION_RULES,
'label' => 'OAuth2 '.\ucfirst($index).' Access Token',
'key' => 'oauth2'.\ucfirst($index).'AccessToken',
'type' => Database::SYSTEM_VAR_TYPE_TEXT,
'default' => '',
'required' => false,
'array' => false,
];
} }
return $collections; return $collections;

View file

@ -190,10 +190,11 @@ App::post('/v1/account/sessions')
$secret = Auth::tokenGenerator(); $secret = Auth::tokenGenerator();
$session = new Document(array_merge( $session = new Document(array_merge(
[ [
'$collection' => Database::SYSTEM_COLLECTION_TOKENS, '$collection' => Database::SYSTEM_COLLECTION_SESSIONS,
'$permissions' => ['read' => ['user:'.$profile->getId()], 'write' => ['user:'.$profile->getId()]], '$permissions' => ['read' => ['user:'.$profile->getId()], 'write' => ['user:'.$profile->getId()]],
'userId' => $profile->getId(), 'userId' => $profile->getId(),
'type' => Auth::TOKEN_TYPE_LOGIN, 'provider' => Auth::SESSION_PROVIDER_EMAIL,
'providerUid' => $email,
'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak
'expire' => $expiry, 'expire' => $expiry,
'userAgent' => $request->getUserAgent('UNKNOWN'), 'userAgent' => $request->getUserAgent('UNKNOWN'),
@ -210,7 +211,7 @@ App::post('/v1/account/sessions')
throw new Exception('Failed saving session to DB', 500); 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()); $profile = $projectDB->updateDocument($profile->getArrayCopy());
@ -441,7 +442,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
throw new Exception('Missing ID from OAuth2 provider', 400); 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) { if ($current) {
$projectDB->deleteDocument($current); //throw new Exception('User already logged in', 401); $projectDB->deleteDocument($current); //throw new Exception('User already logged in', 401);
@ -451,7 +452,8 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
'limit' => 1, 'limit' => 1,
'filters' => [ 'filters' => [
'$collection='.Database::SYSTEM_COLLECTION_USERS, '$collection='.Database::SYSTEM_COLLECTION_USERS,
'oauth2'.\ucfirst($provider).'='.$oauth2ID, 'sessions.provider='.$provider,
'sessions.providerUid='.$oauth2ID
], ],
]) : $user; ]) : $user;
@ -506,10 +508,12 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
$secret = Auth::tokenGenerator(); $secret = Auth::tokenGenerator();
$expiry = \time() + Auth::TOKEN_EXPIRATION_LOGIN_LONG; $expiry = \time() + Auth::TOKEN_EXPIRATION_LOGIN_LONG;
$session = new Document(array_merge([ $session = new Document(array_merge([
'$collection' => Database::SYSTEM_COLLECTION_TOKENS, '$collection' => Database::SYSTEM_COLLECTION_SESSIONS,
'$permissions' => ['read' => ['user:'.$user['$id']], 'write' => ['user:'.$user['$id']]], '$permissions' => ['read' => ['user:'.$user['$id']], 'write' => ['user:'.$user['$id']]],
'userId' => $user->getId(), 'userId' => $user->getId(),
'type' => Auth::TOKEN_TYPE_LOGIN, 'provider' => $provider,
'providerUid' => $oauth2ID,
'providerToken' => $accessToken,
'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak
'expire' => $expiry, 'expire' => $expiry,
'userAgent' => $request->getUserAgent('UNKNOWN'), 'userAgent' => $request->getUserAgent('UNKNOWN'),
@ -527,10 +531,8 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
} }
$user $user
->setAttribute('oauth2'.\ucfirst($provider), $oauth2ID)
->setAttribute('oauth2'.\ucfirst($provider).'AccessToken', $accessToken)
->setAttribute('status', Auth::USER_STATUS_ACTIVATED) ->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()); Authorization::setRole('user:'.$user->getId());
@ -648,10 +650,10 @@ App::post('/v1/account/sessions/anonymous')
$expiry = \time() + Auth::TOKEN_EXPIRATION_LOGIN_LONG; $expiry = \time() + Auth::TOKEN_EXPIRATION_LOGIN_LONG;
$session = new Document(array_merge( $session = new Document(array_merge(
[ [
'$collection' => Database::SYSTEM_COLLECTION_TOKENS, '$collection' => Database::SYSTEM_COLLECTION_SESSIONS,
'$permissions' => ['read' => ['user:' . $user['$id']], 'write' => ['user:' . $user['$id']]], '$permissions' => ['read' => ['user:' . $user['$id']], 'write' => ['user:' . $user['$id']]],
'userId' => $user->getId(), 'userId' => $user->getId(),
'type' => Auth::TOKEN_TYPE_LOGIN, 'provider' => Auth::SESSION_PROVIDER_ANONYMOUS,
'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak
'expire' => $expiry, 'expire' => $expiry,
'userAgent' => $request->getUserAgent('UNKNOWN'), 'userAgent' => $request->getUserAgent('UNKNOWN'),
@ -663,7 +665,7 @@ App::post('/v1/account/sessions/anonymous')
$detector->getDevice() $detector->getDevice()
)); ));
$user->setAttribute('tokens', $session, Document::SET_TYPE_APPEND); $user->setAttribute('sessions', $session, Document::SET_TYPE_APPEND);
Authorization::setRole('user:'.$user->getId()); Authorization::setRole('user:'.$user->getId());
@ -716,16 +718,18 @@ App::post('/v1/account/jwt')
/** @var Appwrite\Utopia\Response $response */ /** @var Appwrite\Utopia\Response $response */
/** @var Appwrite\Database\Document $user */ /** @var Appwrite\Database\Document $user */
$tokens = $user->getAttribute('tokens', []); $sessions = $user->getAttribute('sessions', []);
$session = new Document(); $current = new Document();
foreach ($tokens as $token) { /** @var Appwrite\Database\Document $token */ foreach ($sessions as $session) {
if ($token->getAttribute('secret') == Auth::hash(Auth::$secret)) { // If current session delete the cookies too /** @var Appwrite\Database\Document $session */
$session = $token;
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); throw new Exception('No valid session found', 401);
} }
@ -739,7 +743,7 @@ App::post('/v1/account/jwt')
// 'scopes' => ['user'], // 'scopes' => ['user'],
// 'iss' => 'http://api.mysite.com', // 'iss' => 'http://api.mysite.com',
'userId' => $user->getId(), 'userId' => $user->getId(),
'sessionId' => $session->getId(), 'sessionId' => $current->getId(),
])]), Response::MODEL_JWT); ])]), Response::MODEL_JWT);
}); });
@ -804,22 +808,19 @@ App::get('/v1/account/sessions')
/** @var Appwrite\Database\Document $user */ /** @var Appwrite\Database\Document $user */
/** @var Utopia\Locale\Locale $locale */ /** @var Utopia\Locale\Locale $locale */
$tokens = $user->getAttribute('tokens', []); $sessions = $user->getAttribute('sessions', []);
$sessions = [];
$countries = $locale->getText('countries'); $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 */ foreach ($sessions as $key => $session) {
if (Auth::TOKEN_TYPE_LOGIN != $token->getAttribute('type')) { /** @var Document $session */
continue;
}
$token->setAttribute('countryName', (isset($countries[strtoupper($token->getAttribute('countryCode'))])) $session->setAttribute('countryName', (isset($countries[strtoupper($session->getAttribute('countryCode'))]))
? $countries[strtoupper($token->getAttribute('countryCode'))] ? $countries[strtoupper($session->getAttribute('countryCode'))]
: $locale->getText('locale.country.unknown')); : $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([ $response->dynamic(new Document([
@ -1192,14 +1193,16 @@ App::delete('/v1/account/sessions/:sessionId')
$protocol = $request->getProtocol(); $protocol = $request->getProtocol();
$sessionId = ($sessionId === 'current') $sessionId = ($sessionId === 'current')
? Auth::tokenVerify($user->getAttribute('tokens'), Auth::TOKEN_TYPE_LOGIN, Auth::$secret) ? Auth::sessionVerify($user->getAttribute('sessions'), Auth::$secret)
: $sessionId; : $sessionId;
$tokens = $user->getAttribute('tokens', []); $sessions = $user->getAttribute('sessions', []);
foreach ($tokens as $token) { /* @var $token Document */ foreach ($sessions as $session) {
if (($sessionId == $token->getId()) && Auth::TOKEN_TYPE_LOGIN == $token->getAttribute('type')) { /** @var Document $session */
if (!$projectDB->deleteDocument($token->getId())) {
if (($sessionId == $session->getId())) {
if (!$projectDB->deleteDocument($session->getId())) {
throw new Exception('Failed to remove token from DB', 500); throw new Exception('Failed to remove token from DB', 500);
} }
@ -1215,10 +1218,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 if ($session->getAttribute('secret') == Auth::hash(Auth::$secret)) { // If current session delete the cookies too
$token->setAttribute('current', true); $session->setAttribute('current', true);
$response $response
->addCookie(Auth::$cookieName.'_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) ->addCookie(Auth::$cookieName.'_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
@ -1227,7 +1230,7 @@ App::delete('/v1/account/sessions/:sessionId')
} }
$events $events
->setParam('payload', $response->output($token, Response::MODEL_SESSION)) ->setParam('payload', $response->output($session, Response::MODEL_SESSION))
; ;
return $response->noContent(); return $response->noContent();
@ -1264,10 +1267,12 @@ App::delete('/v1/account/sessions')
/** @var Appwrite\Event\Event $events */ /** @var Appwrite\Event\Event $events */
$protocol = $request->getProtocol(); $protocol = $request->getProtocol();
$tokens = $user->getAttribute('tokens', []); $sessions = $user->getAttribute('sessions', []);
foreach ($tokens as $token) { /* @var $token Document */ foreach ($sessions as $session) {
if (!$projectDB->deleteDocument($token->getId())) { /** @var Document $session */
if (!$projectDB->deleteDocument($session->getId())) {
throw new Exception('Failed to remove token from DB', 500); throw new Exception('Failed to remove token from DB', 500);
} }
@ -1283,10 +1288,10 @@ App::delete('/v1/account/sessions')
; ;
} }
$token->setAttribute('current', false); $session->setAttribute('current', false);
if ($token->getAttribute('secret') == Auth::hash(Auth::$secret)) { // If current session delete the cookies too if ($session->getAttribute('secret') == Auth::hash(Auth::$secret)) { // If current session delete the cookies too
$token->setAttribute('current', true); $session->setAttribute('current', true);
$response $response
->addCookie(Auth::$cookieName.'_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) ->addCookie(Auth::$cookieName.'_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
->addCookie(Auth::$cookieName, '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) ->addCookie(Auth::$cookieName, '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'))
@ -1296,8 +1301,8 @@ App::delete('/v1/account/sessions')
$events $events
->setParam('payload', $response->output(new Document([ ->setParam('payload', $response->output(new Document([
'sum' => count($tokens), 'sum' => count($sessions),
'sessions' => $tokens 'sessions' => $sessions
]), Response::MODEL_SESSION_LIST)) ]), Response::MODEL_SESSION_LIST))
; ;

View file

@ -327,6 +327,7 @@ App::post('/v1/teams/:teamId/memberships')
'registration' => \time(), 'registration' => \time(),
'reset' => false, 'reset' => false,
'name' => $name, 'name' => $name,
'sessions' => [],
'tokens' => [], 'tokens' => [],
], ['email' => $email]); ], ['email' => $email]);
} catch (Duplicate $th) { } catch (Duplicate $th) {
@ -595,10 +596,11 @@ App::patch('/v1/teams/:teamId/memberships/:inviteId/status')
$expiry = \time() + Auth::TOKEN_EXPIRATION_LOGIN_LONG; $expiry = \time() + Auth::TOKEN_EXPIRATION_LOGIN_LONG;
$secret = Auth::tokenGenerator(); $secret = Auth::tokenGenerator();
$session = new Document(array_merge([ $session = new Document(array_merge([
'$collection' => Database::SYSTEM_COLLECTION_TOKENS, '$collection' => Database::SYSTEM_COLLECTION_SESSIONS,
'$permissions' => ['read' => ['user:'.$user->getId()], 'write' => ['user:'.$user->getId()]], '$permissions' => ['read' => ['user:'.$user->getId()], 'write' => ['user:'.$user->getId()]],
'userId' => $user->getId(), 'userId' => $user->getId(),
'type' => Auth::TOKEN_TYPE_LOGIN, 'provider' => Auth::SESSION_PROVIDER_EMAIL,
'providerUid' => $user->getAttribute('email'),
'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak
'expire' => $expiry, 'expire' => $expiry,
'userAgent' => $request->getUserAgent('UNKNOWN'), 'userAgent' => $request->getUserAgent('UNKNOWN'),
@ -606,7 +608,7 @@ App::patch('/v1/teams/:teamId/memberships/:inviteId/status')
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--', 'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
], $detector->getOS(), $detector->getClient(), $detector->getDevice())); ], $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); Authorization::setRole('user:'.$userId);

View file

@ -196,21 +196,18 @@ App::get('/v1/users/:userId/sessions')
throw new Exception('User not found', 404); throw new Exception('User not found', 404);
} }
$tokens = $user->getAttribute('tokens', []); $sessions = $user->getAttribute('sessions', []);
$sessions = [];
$countries = $locale->getText('countries'); $countries = $locale->getText('countries');
foreach ($tokens as $token) { /* @var $token Document */ foreach ($sessions as $key => $session) {
if (Auth::TOKEN_TYPE_LOGIN != $token->getAttribute('type')) { /** @var Document $session */
continue;
}
$token->setAttribute('countryName', (isset($countries[strtoupper($token->getAttribute('countryCode'))])) $session->setAttribute('countryName', (isset($countries[strtoupper($session->getAttribute('countryCode'))]))
? $countries[strtoupper($token->getAttribute('countryCode'))] ? $countries[strtoupper($session->getAttribute('countryCode'))]
: $locale->getText('locale.country.unknown')); : $locale->getText('locale.country.unknown'));
$token->setAttribute('current', false); $session->setAttribute('current', false);
$sessions[] = $token; $sessions[$key] = $session;
} }
$response->dynamic(new Document([ $response->dynamic(new Document([
@ -434,11 +431,13 @@ App::delete('/v1/users/:userId/sessions/:sessionId')
throw new Exception('User not found', 404); throw new Exception('User not found', 404);
} }
$tokens = $user->getAttribute('tokens', []); $sessions = $user->getAttribute('sessions', []);
foreach ($tokens as $token) { /* @var $token Document */ foreach ($sessions as $session) {
if ($sessionId == $token->getId()) { /** @var Document $session */
if (!$projectDB->deleteDocument($token->getId())) {
if ($sessionId == $session->getId()) {
if (!$projectDB->deleteDocument($session->getId())) {
throw new Exception('Failed to remove token from DB', 500); throw new Exception('Failed to remove token from DB', 500);
} }
@ -478,10 +477,12 @@ App::delete('/v1/users/:userId/sessions')
throw new Exception('User not found', 404); throw new Exception('User not found', 404);
} }
$tokens = $user->getAttribute('tokens', []); $sessions = $user->getAttribute('sessions', []);
foreach ($tokens as $token) { /* @var $token Document */ foreach ($sessions as $session) {
if (!$projectDB->deleteDocument($token->getId())) { /** @var Document $session */
if (!$projectDB->deleteDocument($session->getId())) {
throw new Exception('Failed to remove token from DB', 500); throw new Exception('Failed to remove token from DB', 500);
} }
} }

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 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 || 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]); $user = new Document(['$id' => '', '$collection' => Database::SYSTEM_COLLECTION_USERS]);
} }

View file

@ -119,6 +119,14 @@ class DeletesV1
} }
} }
$sessions = $document->getAttribute('sessions', []);
foreach ($sessions as $session) {
if (!$this->getProjectDB($projectId)->deleteDocument($session->getId())) {
throw new Exception('Failed to remove session from DB');
}
}
// Delete Memberships // Delete Memberships
$this->deleteByGroup([ $this->deleteByGroup([
'$collection='.Database::SYSTEM_COLLECTION_MEMBERSHIPS, '$collection='.Database::SYSTEM_COLLECTION_MEMBERSHIPS,

View file

@ -28,11 +28,17 @@ class Auth
/** /**
* Token Types. * Token Types.
*/ */
const TOKEN_TYPE_LOGIN = 1; const TOKEN_TYPE_LOGIN = 1; // Deprecated
const TOKEN_TYPE_VERIFICATION = 2; const TOKEN_TYPE_VERIFICATION = 2;
const TOKEN_TYPE_RECOVERY = 3; const TOKEN_TYPE_RECOVERY = 3;
const TOKEN_TYPE_INVITE = 4; const TOKEN_TYPE_INVITE = 4;
/**
* Session Providers.
*/
const SESSION_PROVIDER_EMAIL = 'email';
const SESSION_PROVIDER_ANONYMOUS = 'anonymous';
/** /**
* Token Expiration times. * Token Expiration times.
*/ */
@ -207,6 +213,29 @@ class Auth
return false; 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->getAttribute('secret') === self::hash($secret) &&
$session->getAttribute('expire') >= \time()) {
return (string)$session->getId();
}
}
return false;
}
/** /**
* Is Previligged User? * Is Previligged User?
* *

View file

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

View file

@ -0,0 +1,70 @@
<?php
namespace Appwrite\Migration\Version;
use Appwrite\Migration\Migration;
use Utopia\Config\Config;
use Utopia\CLI\Console;
use Appwrite\Auth\Auth;
use Appwrite\Database\Database;
use Appwrite\Database\Document;
class V07 extends Migration
{
public function execute(): void
{
$db = $this->db;
$project = $this->project;
Console::log('Migrating project: ' . $project->getAttribute('name') . ' (' . $project->getId() . ')');
$this->forEachDocument([$this, 'fixDocument']);
}
protected function fixDocument(Document $document)
{
$providers = Config::getParam('providers');
switch ($document->getAttribute('$collection')) {
case Database::SYSTEM_COLLECTION_USERS:
foreach ($providers as $key => $provider) {
/**
* Remove deprecated OAuth2 properties in the Users Documents.
*/
if (!empty($document->getAttribute('oauth2' . \ucfirst($key)))) {
$document->removeAttribute('oauth2' . \ucfirst($key));
}
if (!empty($document->getAttribute('oauth2' . \ucfirst($key) . 'AccessToken'))) {
$document->removeAttribute('oauth2' . \ucfirst($key) . 'AccessToken');
}
/**
* Invalidate all Login Tokens, since they can't be migrated to the new structure.
* Reason for it is the missing distinction between E-Mail and OAuth2 tokens.
*/
$tokens = array_filter($document->getAttribute('tokens', []), function($token) {
return ($token->getAttribute('type') != Auth::TOKEN_TYPE_LOGIN);
});
$document->setAttribute('tokens', array_values($tokens));
}
break;
}
foreach ($document as &$attr) { // Handle child documents
if ($attr instanceof Document) {
$attr = $this->fixDocument($attr);
}
if (\is_array($attr)) {
foreach ($attr as &$child) {
if ($child instanceof Document) {
$child = $this->fixDocument($child);
}
}
}
}
return $document;
}
}

View file

@ -28,6 +28,24 @@ class Session extends Model
'default' => 0, 'default' => 0,
'example' => 1592981250, 'example' => 1592981250,
]) ])
->addRule('provider', [
'type' => self::TYPE_STRING,
'description' => 'Session Provider.',
'default' => '',
'example' => 'email',
])
->addRule('providerUid', [
'type' => self::TYPE_STRING,
'description' => 'Session Provider User ID.',
'default' => '',
'example' => 'user@example.com',
])
->addRule('providerToken', [
'type' => self::TYPE_STRING,
'description' => 'Session Provider Token.',
'default' => '',
'example' => 'MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3',
])
->addRule('ip', [ ->addRule('ip', [
'type' => self::TYPE_STRING, 'type' => self::TYPE_STRING,
'description' => 'IP in use when the session was created.', 'description' => 'IP in use when the session was created.',

View file

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

View file

@ -0,0 +1,36 @@
<?php
namespace Appwrite\Tests;
use PHPUnit\Framework\TestCase;
class CollectionsTest extends TestCase
{
protected $collections;
public function setUp(): void
{
$this->collections = require('app/config/collections.php');
}
public function tearDown(): void
{
}
public function testDuplicateRules()
{
foreach ($this->collections as $collection) {
if ($collection['rules']) {
foreach ($collection['rules'] as $check) {
$occurences = 0;
foreach ($collection['rules'] as $rule) {
if ($rule['key'] == $check['key']) {
$occurences++;
}
}
$this->assertEquals(1, $occurences);
}
}
}
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace Appwrite\Tests;
use ReflectionClass;
use Appwrite\Migration\Version\V07;
use Appwrite\Database\Database;
use Appwrite\Database\Document;
use Appwrite\Auth\Auth;
use Utopia\Config\Config;
class MigrationV07Test extends MigrationTest
{
public function setUp(): void
{
Config::load('providers', __DIR__ . '/../../../app/config/providers.php');
$this->pdo = new \PDO('sqlite::memory:');
$this->migration = new V07($this->pdo);
$reflector = new ReflectionClass('Appwrite\Migration\Version\V07');
$this->method = $reflector->getMethod('fixDocument');
$this->method->setAccessible(true);
}
public function testMigration()
{
$document = $this->fixDocument(new Document([
'$id' => 'unique',
'$collection' => Database::SYSTEM_COLLECTION_USERS,
'oauth2Github' => 123,
'oauth2GithubAccessToken' => 456,
'tokens' => [
new Document([
'$collection' => Database::SYSTEM_COLLECTION_TOKENS,
'userId' => 'unique',
'type' => Auth::TOKEN_TYPE_LOGIN,
'secret' => 'login',
]),
new Document([
'$collection' => Database::SYSTEM_COLLECTION_TOKENS,
'userId' => 'unique',
'type' => Auth::TOKEN_TYPE_INVITE,
'secret' => 'invite',
]),
new Document([
'$collection' => Database::SYSTEM_COLLECTION_TOKENS,
'userId' => 'unique',
'type' => Auth::TOKEN_TYPE_RECOVERY,
'secret' => 'recovery',
]),
new Document([
'$collection' => Database::SYSTEM_COLLECTION_TOKENS,
'userId' => 'unique',
'type' => Auth::TOKEN_TYPE_VERIFICATION,
'secret' => 'verification',
]),
]
]));
$this->assertEquals($document->getAttribute('oauth2Github', null), null);
$this->assertEquals($document->getAttribute('oauth2GithubAccessToken', null), null);
$this->assertCount(3, $document->getAttribute('tokens', []));
$this->assertEquals(Auth::TOKEN_TYPE_INVITE, $document->getAttribute('tokens', [])[0]['type']);
$this->assertEquals(Auth::TOKEN_TYPE_RECOVERY, $document->getAttribute('tokens', [])[1]['type']);
$this->assertEquals(Auth::TOKEN_TYPE_VERIFICATION, $document->getAttribute('tokens', [])[2]['type']);
}
}