diff --git a/CHANGES.md b/CHANGES.md index bc16e9a8b..f06be2e2f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,7 +1,9 @@ # Version 0.8.0 (Not Released Yet) ## Features -- Anonymous login (#914) + +- Added Anonymous Login ([RFC-010](https://github.com/appwrite/rfc/blob/main/010-anonymous-login.md), #914) +- Added new Environment Variable to enable or disable Anonymous Login - Added events for functions and executions (#971) ## Breaking Changes diff --git a/app/config/collections.php b/app/config/collections.php index 9170b0757..3307e7a12 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -204,7 +204,7 @@ $collections = [ 'key' => 'email', 'type' => Database::SYSTEM_VAR_TYPE_EMAIL, 'default' => '', - 'required' => true, + 'required' => false, 'array' => false, ], [ @@ -222,7 +222,7 @@ $collections = [ 'key' => 'password', 'type' => Database::SYSTEM_VAR_TYPE_TEXT, 'default' => '', - 'required' => true, + 'required' => false, 'array' => false, ], [ diff --git a/app/config/variables.php b/app/config/variables.php index cd7db9ba6..82baefb8f 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -118,7 +118,7 @@ return [ 'default' => 'enabled', 'required' => false, 'question' => '', - ], + ] ], ], [ diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index fd09aaa3d..067fe5876 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -109,7 +109,7 @@ App::post('/v1/account') throw new Exception('Account already exists', 409); } - Authorization::enable(); + Authorization::reset(); Authorization::unsetRole('role:'.Auth::USER_ROLE_GUEST); Authorization::setRole('user:'.$user->getId()); @@ -487,7 +487,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') throw new Exception('Account already exists', 409); } - Authorization::enable(); + Authorization::reset(); if (false === $user) { throw new Exception('Failed saving user to DB', 500); @@ -517,6 +517,15 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') 'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--', ], $detector->getOS(), $detector->getClient(), $detector->getDevice())); + $isAnonymousUser = is_null($user->getAttribute('email')) && is_null($user->getAttribute('password')); + + if ($isAnonymousUser) { + $user + ->setAttribute('name', $oauth2->getUserName($accessToken)) + ->setAttribute('email', $oauth2->getUserEmail($accessToken)) + ; + } + $user ->setAttribute('oauth2'.\ucfirst($provider), $oauth2ID) ->setAttribute('oauth2'.\ucfirst($provider).'AccessToken', $accessToken) @@ -566,6 +575,130 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') ; }); +App::post('/v1/account/sessions/anonymous') + ->desc('Create Anonymous Session') + ->groups(['api', 'account']) + ->label('event', 'account.sessions.create') + ->label('scope', 'public') + ->label('sdk.platform', [APP_PLATFORM_CLIENT]) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'createAnonymousSession') + ->label('sdk.description', '/docs/references/account/create-session-anonymous.md') + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_SESSION) + ->label('abuse-limit', 50) + ->label('abuse-key', 'ip:{ip}') + ->inject('request') + ->inject('response') + ->inject('locale') + ->inject('user') + ->inject('project') + ->inject('projectDB') + ->inject('geodb') + ->inject('audits') + ->action(function ($request, $response, $locale, $user, $project, $projectDB, $geodb, $audits) { + /** @var Utopia\Swoole\Request $request */ + /** @var Appwrite\Utopia\Response $response */ + /** @var Utopia\Locale\Locale $locale */ + /** @var Appwrite\Database\Document $user */ + /** @var Appwrite\Database\Document $project */ + /** @var Appwrite\Database\Database $projectDB */ + /** @var MaxMind\Db\Reader $geodb */ + /** @var Appwrite\Event\Event $audits */ + + $protocol = $request->getProtocol(); + + if ($user->getId() || 'console' === $project->getId()) { + throw new Exception('Failed to create anonymous user.', 401); + } + + Authorization::disable(); + try { + $user = $projectDB->createDocument([ + '$collection' => Database::SYSTEM_COLLECTION_USERS, + '$permissions' => [ + 'read' => ['*'], + 'write' => ['user:{self}'] + ], + 'email' => null, + 'emailVerification' => false, + 'status' => Auth::USER_STATUS_UNACTIVATED, + 'password' => null, + 'passwordUpdate' => \time(), + 'registration' => \time(), + 'reset' => false, + 'name' => null + ]); + } catch (Exception $th) { + throw new Exception('Failed saving user to DB', 500); + } + + Authorization::reset(); + + if (false === $user) { + throw new Exception('Failed saving user to DB', 500); + } + + // Create session token + + $detector = new Detector($request->getUserAgent('UNKNOWN')); + $record = $geodb->get($request->getIP()); + $secret = Auth::tokenGenerator(); + $expiry = \time() + Auth::TOKEN_EXPIRATION_LOGIN_LONG; + $session = new Document(array_merge( + [ + '$collection' => Database::SYSTEM_COLLECTION_TOKENS, + '$permissions' => ['read' => ['user:' . $user['$id']], 'write' => ['user:' . $user['$id']]], + 'userId' => $user->getId(), + 'type' => Auth::TOKEN_TYPE_LOGIN, + 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak + 'expire' => $expiry, + 'userAgent' => $request->getUserAgent('UNKNOWN'), + 'ip' => $request->getIP(), + 'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--', + ], + $detector->getOS(), + $detector->getClient(), + $detector->getDevice() + )); + + $user->setAttribute('tokens', $session, Document::SET_TYPE_APPEND); + + Authorization::setRole('user:'.$user->getId()); + + $user = $projectDB->updateDocument($user->getArrayCopy()); + + if (false === $user) { + throw new Exception('Failed saving user to DB', 500); + } + + $audits + ->setParam('userId', $user->getId()) + ->setParam('event', 'account.sessions.create') + ->setParam('resource', 'users/'.$user->getId()) + ; + + if (!Config::getParam('domainVerification')) { + $response + ->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)])) + ; + } + + $response + ->addCookie(Auth::$cookieName.'_legacy', Auth::encodeSession($user->getId(), $secret), $expiry, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) + ->addCookie(Auth::$cookieName, Auth::encodeSession($user->getId(), $secret), $expiry, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) + ->setStatusCode(Response::STATUS_CODE_CREATED) + ; + + $session + ->setAttribute('current', true) + ->setAttribute('countryName', (isset($countries[$session->getAttribute('countryCode')])) ? $countries[$session->getAttribute('countryCode')] : $locale->getText('locale.country.unknown')) + ; + + $response->dynamic($session, Response::MODEL_SESSION); + }); + App::post('/v1/account/jwt') ->desc('Create Account JWT') ->groups(['api', 'account']) @@ -880,7 +1013,12 @@ App::patch('/v1/account/email') /** @var Appwrite\Database\Database $projectDB */ /** @var Appwrite\Event\Event $audits */ - if (!Auth::passwordVerify($password, $user->getAttribute('password'))) { // Double check user password + $isAnonymousUser = is_null($user->getAttribute('email')) && is_null($user->getAttribute('password')); // Check if request is from an anonymous account for converting + + if ( + !$isAnonymousUser && + !Auth::passwordVerify($password, $user->getAttribute('password')) + ) { // Double check user password throw new Exception('Invalid credentials', 401); } @@ -898,10 +1036,14 @@ App::patch('/v1/account/email') // TODO after this user needs to confirm mail again - $user = $projectDB->updateDocument(\array_merge($user->getArrayCopy(), [ - 'email' => $email, - 'emailVerification' => false, - ])); + $user = $projectDB->updateDocument(\array_merge( + $user->getArrayCopy(), + ($isAnonymousUser ? [ 'password' => Auth::passwordHash($password) ] : []), + [ + 'email' => $email, + 'emailVerification' => false, + ] + )); if (false === $user) { throw new Exception('Failed saving user to DB', 500); diff --git a/docs/references/account/create-session-anonymous.md b/docs/references/account/create-session-anonymous.md new file mode 100644 index 000000000..61895604a --- /dev/null +++ b/docs/references/account/create-session-anonymous.md @@ -0,0 +1 @@ +Use this endpoint to allow a new user to register an anonymous account in your project. This route will also create a new session for the user. To allow the new user to convert an anonymous account to a normal account account, you need to update its [email and password](/docs/client/account#accountUpdateEmail). \ No newline at end of file diff --git a/docs/references/account/update-email.md b/docs/references/account/update-email.md index d99b9b4a1..7be36b7cd 100644 --- a/docs/references/account/update-email.md +++ b/docs/references/account/update-email.md @@ -1 +1,2 @@ -Update currently logged in user account email address. After changing user address, user confirmation status is being reset and a new confirmation mail is sent. For security measures, user password is required to complete this request. \ No newline at end of file +Update currently logged in user account email address. After changing user address, user confirmation status is being reset and a new confirmation mail is sent. For security measures, user password is required to complete this request. +This endpoint can also be used to convert an anonymous account to a normal one, by passing an email address and a new password. \ No newline at end of file diff --git a/tests/e2e/Services/Account/AccountBase.php b/tests/e2e/Services/Account/AccountBase.php index 5476e0455..38d985ab0 100644 --- a/tests/e2e/Services/Account/AccountBase.php +++ b/tests/e2e/Services/Account/AccountBase.php @@ -49,6 +49,39 @@ trait AccountBase $this->assertEquals($response['headers']['status-code'], 409); + $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'email' => '', + 'password' => '', + ]); + + $this->assertEquals($response['headers']['status-code'], 400); + + $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'email' => $email, + 'password' => '', + ]); + + $this->assertEquals($response['headers']['status-code'], 400); + + $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'email' => '', + 'password' => $password, + ]); + + $this->assertEquals($response['headers']['status-code'], 400); + return [ 'id' => $id, 'email' => $email, @@ -372,7 +405,6 @@ trait AccountBase $this->assertEquals($response['headers']['status-code'], 200); $this->assertIsArray($response['body']); $this->assertNotEmpty($response['body']); - $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertIsNumeric($response['body']['registration']); $this->assertEquals($response['body']['email'], $email); @@ -440,7 +472,6 @@ trait AccountBase $this->assertEquals($response['headers']['status-code'], 200); $this->assertIsArray($response['body']); $this->assertNotEmpty($response['body']); - $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertIsNumeric($response['body']['registration']); $this->assertEquals($response['body']['email'], $email); @@ -507,7 +538,6 @@ trait AccountBase $this->assertEquals($response['headers']['status-code'], 200); $this->assertIsArray($response['body']); $this->assertNotEmpty($response['body']); - $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertIsNumeric($response['body']['registration']); $this->assertEquals($response['body']['email'], $newEmail); @@ -565,7 +595,6 @@ trait AccountBase $this->assertEquals($response['headers']['status-code'], 200); $this->assertIsArray($response['body']); $this->assertNotEmpty($response['body']); - $this->assertNotEmpty($response['body']); $this->assertEquals('prefValue1', $response['body']['prefs']['prefKey1']); $this->assertEquals('prefValue2', $response['body']['prefs']['prefKey2']); diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index a11165530..e1dedd6be 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -6,6 +6,7 @@ use Tests\E2E\Client; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\ProjectCustom; use Tests\E2E\Scopes\SideClient; +use Utopia\App; class AccountCustomClientTest extends Scope { @@ -226,4 +227,198 @@ class AccountCustomClientTest extends Scope return []; } + + public function testCreateAnonymousAccount() + { + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/anonymous', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + $session = $this->client->parseCookie((string)$response['headers']['set-cookie'])['a_session_'.$this->getProject()['$id']]; + + /** + * Test for FAILURE + */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/anonymous', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_'.$this->getProject()['$id'].'=' . $session, + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + + return $session; + } + + /** + * @depends testCreateAnonymousAccount + */ + public function testUpdateAnonymousAccountPassword($session):array + { + /** + * Test for FAILURE + */ + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_'.$this->getProject()['$id'].'=' . $session, + ]), [ + 'password' => 'new-password', + 'oldPassword' => '', + ]); + + $this->assertEquals($response['headers']['status-code'], 400); + + return []; + } + + /** + * @depends testUpdateAnonymousAccountPassword + */ + public function testUpdateAnonymousAccountEmail($session):array + { + $email = uniqid().'new@localhost.test'; + + /** + * Test for FAILURE + */ + $response = $this->client->call(Client::METHOD_PATCH, '/account/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_'.$this->getProject()['$id'].'=' . $session, + ]), [ + 'email' => $email, + 'password' => '', + ]); + + $this->assertEquals($response['headers']['status-code'], 401); + + return []; + } + + public function testConvertAnonymousAccount():array + { + $session = $this->testCreateAnonymousAccount(); + $email = uniqid().'new@localhost.test'; + $password = 'new-password'; + + /** + * Test for FAILURE + */ + $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'email' => $email, + 'password' => $password + ]); + + $response = $this->client->call(Client::METHOD_PATCH, '/account/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_'.$this->getProject()['$id'].'=' . $session, + ]), [ + 'email' => $email, + 'password' => $password, + ]); + + $this->assertEquals($response['headers']['status-code'], 400); + + /** + * Test for SUCCESS + */ + $email = uniqid().'new@localhost.test'; + + $response = $this->client->call(Client::METHOD_PATCH, '/account/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_'.$this->getProject()['$id'].'=' . $session, + ]), [ + 'email' => $email, + 'password' => $password, + ]); + + $this->assertEquals($response['headers']['status-code'], 200); + $this->assertIsArray($response['body']); + $this->assertNotEmpty($response['body']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertIsNumeric($response['body']['registration']); + $this->assertEquals($response['body']['email'], $email); + + $response = $this->client->call(Client::METHOD_POST, '/account/sessions', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'email' => $email, + 'password' => $password, + ]); + + $this->assertEquals($response['headers']['status-code'], 201); + + return []; + } + + public function testConvertAnonymousAccountOAuth2():array + { + $session = $this->testCreateAnonymousAccount(); + $provider = 'mock'; + $appId = '1'; + $secret = '123456'; + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/'.$this->getProject()['$id'].'/oauth2', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'provider' => $provider, + 'appId' => $appId, + 'secret' => $secret, + ]); + + $this->assertEquals($response['headers']['status-code'], 200); + + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/'.$provider, array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_'.$this->getProject()['$id'].'=' . $session, + ]), [ + 'success' => 'http://localhost/v1/mock/tests/general/oauth2/success', + 'failure' => 'http://localhost/v1/mock/tests/general/oauth2/failure', + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('success', $response['body']['result']); + + $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_'.$this->getProject()['$id'].'=' . $session, + ])); + + $this->assertEquals($response['headers']['status-code'], 200); + $this->assertEquals($response['body']['name'], 'User Name'); + $this->assertEquals($response['body']['email'], 'user@localhost.test'); + + return []; + } } \ No newline at end of file