From 5f56c45c6cdc1ccaa1f404bae1bb9e5c51e82e12 Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Tue, 19 Nov 2024 16:47:14 +0100 Subject: [PATCH 1/2] fix: team invites with existing session --- app/controllers/api/teams.php | 101 +++++++++++-------- tests/e2e/Services/Teams/TeamsBaseClient.php | 74 +++++++++++++- 2 files changed, 130 insertions(+), 45 deletions(-) diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index ba1858c5aa..1e15e4b81b 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -1060,7 +1060,8 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') throw new Exception(Exception::TEAM_INVITE_MISMATCH, 'Invite does not belong to current user (' . $user->getAttribute('email') . ')'); } - if ($user->isEmpty()) { + $hasSession = !$user->isEmpty(); + if (!$hasSession) { $user->setAttributes($dbForProject->getDocument('users', $userId)->getArrayCopy()); // Get user } @@ -1079,39 +1080,64 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') Authorization::skip(fn () => $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('emailVerification', true))); - // Log user in + // Create session for the user if not logged in + if (!$hasSession) { + Authorization::setRole(Role::user($user->getId())->toString()); - Authorization::setRole(Role::user($user->getId())->toString()); + $detector = new Detector($request->getUserAgent('UNKNOWN')); + $record = $geodb->get($request->getIP()); + $authDuration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; + $expire = DateTime::addSeconds(new \DateTime(), $authDuration); + $secret = Auth::tokenGenerator(); + $session = new Document(array_merge([ + '$id' => ID::unique(), + '$permissions' => [ + Permission::read(Role::user($user->getId())), + Permission::update(Role::user($user->getId())), + Permission::delete(Role::user($user->getId())), + ], + 'userId' => $user->getId(), + 'userInternalId' => $user->getInternalId(), + 'provider' => Auth::SESSION_PROVIDER_EMAIL, + 'providerUid' => $user->getAttribute('email'), + 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak + 'userAgent' => $request->getUserAgent('UNKNOWN'), + 'ip' => $request->getIP(), + 'factors' => ['email'], + 'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--', + 'expire' => DateTime::addSeconds(new \DateTime(), $authDuration) + ], $detector->getOS(), $detector->getClient(), $detector->getDevice())); - $detector = new Detector($request->getUserAgent('UNKNOWN')); - $record = $geodb->get($request->getIP()); - $authDuration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; - $expire = DateTime::addSeconds(new \DateTime(), $authDuration); - $secret = Auth::tokenGenerator(); - $session = new Document(array_merge([ - '$id' => ID::unique(), - 'userId' => $user->getId(), - 'userInternalId' => $user->getInternalId(), - 'provider' => Auth::SESSION_PROVIDER_EMAIL, - 'providerUid' => $user->getAttribute('email'), - 'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak - 'userAgent' => $request->getUserAgent('UNKNOWN'), - 'ip' => $request->getIP(), - 'factors' => ['email'], - 'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--', - 'expire' => DateTime::addSeconds(new \DateTime(), $authDuration) - ], $detector->getOS(), $detector->getClient(), $detector->getDevice())); + $session = $dbForProject->createDocument('sessions', $session); - $session = $dbForProject->createDocument('sessions', $session - ->setAttribute('$permissions', [ - Permission::read(Role::user($user->getId())), - Permission::update(Role::user($user->getId())), - Permission::delete(Role::user($user->getId())), - ])); + Authorization::setRole(Role::user($userId)->toString()); - $dbForProject->purgeCachedDocument('users', $user->getId()); + if (!Config::getParam('domainVerification')) { + $response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)])); + } - Authorization::setRole(Role::user($userId)->toString()); + $response + ->addCookie( + name: Auth::$cookieName . '_legacy', + value: Auth::encodeSession($user->getId(), $secret), + expire: (new \DateTime($expire))->getTimestamp(), + path:'/', + domain: Config::getParam('cookieDomain'), + secure: ('https' === $protocol), + httponly:true + ) + ->addCookie( + name:Auth::$cookieName, + value:Auth::encodeSession($user->getId(), $secret), + expire: (new \DateTime($expire))->getTimestamp(), + path:'/', + domain:Config::getParam('cookieDomain'), + secure:('https' === $protocol), + httponly:true, + sameSite: Config::getParam('cookieSamesite') + ) + ; + } $membership = $dbForProject->updateDocument('memberships', $membership->getId(), $membership); @@ -1125,22 +1151,11 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') ->setParam('membershipId', $membership->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), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) - ->addCookie(Auth::$cookieName, Auth::encodeSession($user->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) - ; - $response->dynamic( $membership - ->setAttribute('teamName', $team->getAttribute('name')) - ->setAttribute('userName', $user->getAttribute('name')) - ->setAttribute('userEmail', $user->getAttribute('email')), + ->setAttribute('teamName', $team->getAttribute('name')) + ->setAttribute('userName', $user->getAttribute('name')) + ->setAttribute('userEmail', $user->getAttribute('email')), Response::MODEL_MEMBERSHIP ); }); diff --git a/tests/e2e/Services/Teams/TeamsBaseClient.php b/tests/e2e/Services/Teams/TeamsBaseClient.php index 9188575932..ca6082f6d0 100644 --- a/tests/e2e/Services/Teams/TeamsBaseClient.php +++ b/tests/e2e/Services/Teams/TeamsBaseClient.php @@ -559,6 +559,76 @@ trait TeamsBaseClient return $data; } + + /** + * @depends testCreateTeam + */ + public function testUpdateMembershipWithSession(array $data): void + { + $teamUid = $data['teamUid'] ?? ''; + + // create user + $response = $this->client->call(Client::METHOD_POST, '/account', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'userId' => 'unique()', + 'email' => uniqid() . 'foe@localhost.test', + 'password' => 'password', + 'name' => 'test' + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $user = $response['body']; + + // create session + $response = $this->client->call(Client::METHOD_POST, '/account/sessions', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $user['email'], + 'password' => 'password' + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + $session = $response['cookies']['a_session_' . $this->getProject()['$id']]; + + $response = $this->client->call(Client::METHOD_POST, '/teams/' . $teamUid . '/memberships', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'email' => $user['email'], + 'roles' => ['developer'], + 'url' => 'http://localhost:5000/join-us#title' + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + $lastEmail = $this->getLastEmail(); + + $secret = substr($lastEmail['text'], strpos($lastEmail['text'], '&secret=', 0) + 8, 256); + $membershipUid = substr($lastEmail['text'], strpos($lastEmail['text'], '?membershipId=', 0) + 14, 20); + $userUid = substr($lastEmail['text'], strpos($lastEmail['text'], '&userId=', 0) + 8, 20); + + $response = $this->client->call(Client::METHOD_PATCH, '/teams/' . $teamUid . '/memberships/' . $membershipUid . '/status', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, + ], [ + 'secret' => $secret, + 'userId' => $userUid, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertNotEmpty($response['body']['userId']); + $this->assertNotEmpty($response['body']['teamId']); + $this->assertCount(1, $response['body']['roles']); + $this->assertEmpty($response['cookies']); + } + /** * @depends testUpdateTeamMembership */ @@ -648,7 +718,7 @@ trait TeamsBaseClient ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(3, $response['body']['total']); + $this->assertEquals(4, $response['body']['total']); $ownerMembershipUid = $response['body']['memberships'][0]['$id']; @@ -703,7 +773,7 @@ trait TeamsBaseClient ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(2, $response['body']['total']); + $this->assertEquals(3, $response['body']['total']); /** * Test for when the owner tries to delete their membership From c97a94ec984243462435f919274a1374495eb0f1 Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Tue, 19 Nov 2024 16:50:31 +0100 Subject: [PATCH 2/2] chore: run formatter --- app/controllers/api/teams.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 1e15e4b81b..f829800b98 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -1121,19 +1121,19 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status') name: Auth::$cookieName . '_legacy', value: Auth::encodeSession($user->getId(), $secret), expire: (new \DateTime($expire))->getTimestamp(), - path:'/', + path: '/', domain: Config::getParam('cookieDomain'), secure: ('https' === $protocol), - httponly:true + httponly: true ) ->addCookie( - name:Auth::$cookieName, - value:Auth::encodeSession($user->getId(), $secret), + name: Auth::$cookieName, + value: Auth::encodeSession($user->getId(), $secret), expire: (new \DateTime($expire))->getTimestamp(), - path:'/', - domain:Config::getParam('cookieDomain'), - secure:('https' === $protocol), - httponly:true, + path: '/', + domain: Config::getParam('cookieDomain'), + secure: ('https' === $protocol), + httponly: true, sameSite: Config::getParam('cookieSamesite') ) ;