diff --git a/app/config/collections.php b/app/config/collections.php index 3307e7a12c..d023cae514 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -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' => 'Session', 'structure' => true, 'rules' => [ [ @@ -306,16 +316,34 @@ $collections = [ 'key' => 'userId', 'type' => Database::SYSTEM_VAR_TYPE_TEXT, '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, 'array' => false, ], [ '$collection' => Database::SYSTEM_COLLECTION_RULES, - 'label' => 'Type', - 'key' => 'type', - 'type' => Database::SYSTEM_VAR_TYPE_NUMERIC, - 'default' => null, - 'required' => true, + 'label' => 'Provider Token', + 'key' => 'providerToken', + 'type' => Database::SYSTEM_VAR_TYPE_TEXT, + 'default' => '', + 'required' => 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 => [ '$collection' => Database::SYSTEM_COLLECTION_COLLECTIONS, '$id' => Database::SYSTEM_COLLECTION_MEMBERSHIPS, @@ -1617,26 +1708,6 @@ foreach ($providers as $index => $provider) { 'array' => false, '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; \ No newline at end of file diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 067fe58767..b2f382c895 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -190,10 +190,11 @@ 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::SESSION_PROVIDER_EMAIL, + 'providerUid' => $email, 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak 'expire' => $expiry, 'userAgent' => $request->getUserAgent('UNKNOWN'), @@ -210,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 +442,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,7 +452,8 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') 'limit' => 1, 'filters' => [ '$collection='.Database::SYSTEM_COLLECTION_USERS, - 'oauth2'.\ucfirst($provider).'='.$oauth2ID, + 'sessions.provider='.$provider, + 'sessions.providerUid='.$oauth2ID ], ]) : $user; @@ -506,10 +508,12 @@ 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, 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak 'expire' => $expiry, 'userAgent' => $request->getUserAgent('UNKNOWN'), @@ -527,10 +531,8 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') } $user - ->setAttribute('oauth2'.\ucfirst($provider), $oauth2ID) - ->setAttribute('oauth2'.\ucfirst($provider).'AccessToken', $accessToken) ->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()); @@ -648,10 +650,10 @@ App::post('/v1/account/sessions/anonymous') $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' => Auth::SESSION_PROVIDER_ANONYMOUS, 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak 'expire' => $expiry, 'userAgent' => $request->getUserAgent('UNKNOWN'), @@ -663,7 +665,7 @@ App::post('/v1/account/sessions/anonymous') $detector->getDevice() )); - $user->setAttribute('tokens', $session, Document::SET_TYPE_APPEND); + $user->setAttribute('sessions', $session, Document::SET_TYPE_APPEND); Authorization::setRole('user:'.$user->getId()); @@ -716,16 +718,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); } @@ -739,7 +743,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); }); @@ -804,22 +808,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[strtoupper($token->getAttribute('countryCode'))])) - ? $countries[strtoupper($token->getAttribute('countryCode'))] + $session->setAttribute('countryName', (isset($countries[strtoupper($session->getAttribute('countryCode'))])) + ? $countries[strtoupper($session->getAttribute('countryCode'))] : $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([ @@ -1192,14 +1193,16 @@ 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); } @@ -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 - $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) @@ -1227,7 +1230,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(); @@ -1264,10 +1267,12 @@ App::delete('/v1/account/sessions') /** @var Appwrite\Event\Event $events */ $protocol = $request->getProtocol(); - $tokens = $user->getAttribute('tokens', []); + $sessions = $user->getAttribute('sessions', []); - foreach ($tokens as $token) { /* @var $token Document */ - if (!$projectDB->deleteDocument($token->getId())) { + foreach ($sessions as $session) { + /** @var Document $session */ + + if (!$projectDB->deleteDocument($session->getId())) { 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 - $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) ->addCookie(Auth::$cookieName, '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) @@ -1296,8 +1301,8 @@ App::delete('/v1/account/sessions') $events ->setParam('payload', $response->output(new Document([ - 'sum' => count($tokens), - 'sessions' => $tokens + 'sum' => count($sessions), + 'sessions' => $sessions ]), Response::MODEL_SESSION_LIST)) ; diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index c4a9e4875d..f75f072e76 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -327,6 +327,7 @@ App::post('/v1/teams/:teamId/memberships') 'registration' => \time(), 'reset' => false, 'name' => $name, + 'sessions' => [], 'tokens' => [], ], ['email' => $email]); } catch (Duplicate $th) { @@ -595,10 +596,11 @@ 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::SESSION_PROVIDER_EMAIL, + 'providerUid' => $user->getAttribute('email'), 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak 'expire' => $expiry, 'userAgent' => $request->getUserAgent('UNKNOWN'), @@ -606,7 +608,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); diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 958dfc1488..63a218b264 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -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[strtoupper($token->getAttribute('countryCode'))])) - ? $countries[strtoupper($token->getAttribute('countryCode'))] + $session->setAttribute('countryName', (isset($countries[strtoupper($session->getAttribute('countryCode'))])) + ? $countries[strtoupper($session->getAttribute('countryCode'))] : $locale->getText('locale.country.unknown')); - $token->setAttribute('current', false); + $session->setAttribute('current', false); - $sessions[] = $token; + $sessions[$key] = $session; } $response->dynamic(new Document([ @@ -434,11 +431,13 @@ App::delete('/v1/users/:userId/sessions/:sessionId') throw new Exception('User not found', 404); } - $tokens = $user->getAttribute('tokens', []); + $sessions = $user->getAttribute('sessions', []); - foreach ($tokens as $token) { /* @var $token Document */ - if ($sessionId == $token->getId()) { - 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); } @@ -478,10 +477,12 @@ App::delete('/v1/users/:userId/sessions') throw new Exception('User not found', 404); } - $tokens = $user->getAttribute('tokens', []); + $sessions = $user->getAttribute('sessions', []); - foreach ($tokens as $token) { /* @var $token Document */ - if (!$projectDB->deleteDocument($token->getId())) { + foreach ($sessions as $session) { + /** @var Document $session */ + + if (!$projectDB->deleteDocument($session->getId())) { throw new Exception('Failed to remove token from DB', 500); } } diff --git a/app/init.php b/app/init.php index db36ea73fa..b6dd2432fe 100644 --- a/app/init.php +++ b/app/init.php @@ -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]); } diff --git a/app/workers/deletes.php b/app/workers/deletes.php index 426dcf1ed6..cda60b78e5 100644 --- a/app/workers/deletes.php +++ b/app/workers/deletes.php @@ -112,13 +112,21 @@ class DeletesV1 protected function deleteUser(Document $document, $projectId) { $tokens = $document->getAttribute('tokens', []); - + foreach ($tokens as $token) { if (!$this->getProjectDB($projectId)->deleteDocument($token->getId())) { throw new Exception('Failed to remove token from DB'); } } + $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 $this->deleteByGroup([ '$collection='.Database::SYSTEM_COLLECTION_MEMBERSHIPS, diff --git a/src/Appwrite/Auth/Auth.php b/src/Appwrite/Auth/Auth.php index f2d786c937..1e8dbc93d8 100644 --- a/src/Appwrite/Auth/Auth.php +++ b/src/Appwrite/Auth/Auth.php @@ -28,11 +28,17 @@ 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; + /** + * Session Providers. + */ + const SESSION_PROVIDER_EMAIL = 'email'; + const SESSION_PROVIDER_ANONYMOUS = 'anonymous'; + /** * Token Expiration times. */ @@ -207,6 +213,29 @@ 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->getAttribute('secret') === self::hash($secret) && + $session->getAttribute('expire') >= \time()) { + return (string)$session->getId(); + } + } + + return false; + } + /** * Is Previligged User? * diff --git a/src/Appwrite/Database/Database.php b/src/Appwrite/Database/Database.php index 4154214b82..d0defdec03 100644 --- a/src/Appwrite/Database/Database.php +++ b/src/Appwrite/Database/Database.php @@ -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) diff --git a/src/Appwrite/Migration/Version/V07.php b/src/Appwrite/Migration/Version/V07.php new file mode 100644 index 0000000000..0b97a83f80 --- /dev/null +++ b/src/Appwrite/Migration/Version/V07.php @@ -0,0 +1,70 @@ +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; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/Session.php b/src/Appwrite/Utopia/Response/Model/Session.php index 4bc23ab79a..f431f3f431 100644 --- a/src/Appwrite/Utopia/Response/Model/Session.php +++ b/src/Appwrite/Utopia/Response/Model/Session.php @@ -28,6 +28,24 @@ class Session extends Model 'default' => 0, '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', [ 'type' => self::TYPE_STRING, 'description' => 'IP in use when the session was created.', diff --git a/tests/unit/Auth/AuthTest.php b/tests/unit/Auth/AuthTest.php index 5860d1efb9..6a9b8ab3b0 100644 --- a/tests/unit/Auth/AuthTest.php +++ b/tests/unit/Auth/AuthTest.php @@ -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([])); diff --git a/tests/unit/General/CollectionsTest.php b/tests/unit/General/CollectionsTest.php new file mode 100644 index 0000000000..bf7aea7c0c --- /dev/null +++ b/tests/unit/General/CollectionsTest.php @@ -0,0 +1,36 @@ +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); + } + } + } + } +} diff --git a/tests/unit/Migration/MigrationV07Test.php b/tests/unit/Migration/MigrationV07Test.php new file mode 100644 index 0000000000..ca73c3229d --- /dev/null +++ b/tests/unit/Migration/MigrationV07Test.php @@ -0,0 +1,69 @@ +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']); + + } +}