From 8ce669da6f5410f3afb9f57cd9daaedbb2af1af4 Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Wed, 8 Jun 2022 11:00:38 +0200 Subject: [PATCH] feat: initial phone authentication --- .env | 4 + Dockerfile | 4 + app/config/collections.php | 22 ++ app/config/variables.php | 42 ++++ app/controllers/api/account.php | 228 +++++++++++++++++++- app/controllers/api/users.php | 5 - app/init.php | 20 ++ docker-compose.yml | 4 + src/Appwrite/Auth/Auth.php | 35 ++- src/Appwrite/Auth/Phone.php | 88 ++++++++ src/Appwrite/Auth/Phone/Mock.php | 33 +++ src/Appwrite/Auth/Phone/Telesign.php | 39 ++++ src/Appwrite/Auth/Phone/TextMagic.php | 42 ++++ src/Appwrite/Auth/Phone/Twilio.php | 36 ++++ src/Appwrite/Auth/Validator/Phone.php | 61 ++++++ src/Appwrite/Utopia/Response/Model/User.php | 12 ++ tests/e2e/Services/Account/AccountBase.php | 127 +++++++++++ tests/unit/Auth/Validator/PhoneTest.php | 40 ++++ 18 files changed, 833 insertions(+), 9 deletions(-) create mode 100644 src/Appwrite/Auth/Phone.php create mode 100644 src/Appwrite/Auth/Phone/Mock.php create mode 100644 src/Appwrite/Auth/Phone/Telesign.php create mode 100644 src/Appwrite/Auth/Phone/TextMagic.php create mode 100644 src/Appwrite/Auth/Phone/Twilio.php create mode 100644 src/Appwrite/Auth/Validator/Phone.php create mode 100644 tests/unit/Auth/Validator/PhoneTest.php diff --git a/.env b/.env index 1f0e7a152e..4d4ba8e79a 100644 --- a/.env +++ b/.env @@ -56,6 +56,10 @@ _APP_SMTP_PORT=1025 _APP_SMTP_SECURE= _APP_SMTP_USERNAME= _APP_SMTP_PASSWORD= +_APP_PHONE_PROVIDER=mock +_APP_PHONE_USER= +_APP_PHONE_SECRET= +_APP_PHONE_FROM= _APP_STORAGE_LIMIT=30000000 _APP_STORAGE_PREVIEW_LIMIT=20000000 _APP_FUNCTIONS_SIZE_LIMIT=30000000 diff --git a/Dockerfile b/Dockerfile index e2b991cf6a..0dd0a8f6e7 100755 --- a/Dockerfile +++ b/Dockerfile @@ -190,6 +190,10 @@ ENV _APP_SERVER=swoole \ _APP_SMTP_SECURE= \ _APP_SMTP_USERNAME= \ _APP_SMTP_PASSWORD= \ + _APP_PHONE_PROVIDER= \ + _APP_PHONE_USER= \ + _APP_PHONE_KEY= \ + _APP_PHONE_FROM= \ _APP_FUNCTIONS_SIZE_LIMIT=30000000 \ _APP_FUNCTIONS_TIMEOUT=900 \ _APP_FUNCTIONS_CONTAINERS=10 \ diff --git a/app/config/collections.php b/app/config/collections.php index 1d19c3b848..035d7984df 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -981,6 +981,17 @@ $collections = [ 'array' => false, 'filters' => [], ], + [ + '$id' => 'phone', + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 320, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], [ '$id' => 'status', 'type' => Database::VAR_BOOLEAN, @@ -1047,6 +1058,17 @@ $collections = [ 'array' => false, 'filters' => [], ], + [ + '$id' => 'phoneVerification', + 'type' => Database::VAR_BOOLEAN, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], [ '$id' => 'reset', 'type' => Database::VAR_BOOLEAN, diff --git a/app/config/variables.php b/app/config/variables.php index df9b3c76c3..788c2d350d 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -389,6 +389,48 @@ return [ ], ], ], + [ + 'category' => 'Phone', + 'description' => '', + 'variables' => [ + [ + 'name' => '_APP_PHONE_PROVIDER', + 'description' => '', + 'introduction' => '', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_PHONE_USER', + 'description' => '', + 'introduction' => '', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_PHONE_SECRET', + 'description' => '', + 'introduction' => '', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_PHONE_FROM', + 'description' => '', + 'introduction' => '', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + ], + ], [ 'category' => 'Storage', 'description' => '', diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 10c9789882..a5d94cdb61 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2,7 +2,9 @@ use Ahc\Jwt\JWT; use Appwrite\Auth\Auth; +use Appwrite\Auth\Phone; use Appwrite\Auth\Validator\Password; +use Appwrite\Auth\Validator\Phone as ValidatorPhone; use Appwrite\Detector\Detector; use Appwrite\Event\Event; use Appwrite\Event\Mail; @@ -519,7 +521,7 @@ 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')); + $isAnonymousUser = Auth::isAnonymousUser($user); if ($isAnonymousUser) { $user @@ -824,6 +826,228 @@ App::put('/v1/account/sessions/magic-url') $response->dynamic($session, Response::MODEL_SESSION); }); +App::post('/v1/account/sessions/phone') + ->desc('Create Phone session') + ->groups(['api', 'account']) + ->label('scope', 'public') + ->label('auth.type', 'phone') + ->label('sdk.auth', []) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'createPhoneSession') + ->label('sdk.description', '/docs/references/account/create-phone-session.md') + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_TOKEN) + ->label('abuse-limit', 10) + ->label('abuse-key', 'url:{url},email:{param-email}') + ->param('userId', '', new CustomId(), 'Unique Id. Choose your own unique ID or pass the string "unique()" to auto generate it. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') + ->param('number', '', new ValidatorPhone, 'Phone number.') + ->inject('request') + ->inject('response') + ->inject('project') + ->inject('dbForProject') + ->inject('audits') + ->inject('events') + ->inject('phone') + ->action(function (string $userId, string $number, Request $request, Response $response, Document $project, Database $dbForProject, Audit $audits, Event $events, Phone $phone) { + if (empty(App::getEnv('_APP_PHONE_PROVIDER'))) { + throw new Exception('Phone Disabled', 503, Exception::GENERAL_SMTP_DISABLED); + } + + $roles = Authorization::getRoles(); + $isPrivilegedUser = Auth::isPrivilegedUser($roles); + $isAppUser = Auth::isAppUser($roles); + + $user = $dbForProject->findOne('users', [new Query('phone', Query::TYPE_EQUAL, [$number])]); + + if (!$user) { + $limit = $project->getAttribute('auths', [])['limit'] ?? 0; + + if ($limit !== 0) { + $total = $dbForProject->count('users', max: APP_LIMIT_USERS); + + if ($total >= $limit) { + throw new Exception('Project registration is restricted. Contact your administrator for more information.', 501, Exception::USER_COUNT_EXCEEDED); + } + } + + $userId = $userId == 'unique()' ? $dbForProject->getId() : $userId; + + $user = Authorization::skip(fn () => $dbForProject->createDocument('users', new Document([ + '$id' => $userId, + '$read' => ['role:all'], + '$write' => ['user:' . $userId], + 'email' => null, + 'phone' => $number, + 'emailVerification' => false, + 'phoneVerification' => false, + 'status' => true, + 'password' => null, + 'passwordUpdate' => 0, + 'registration' => \time(), + 'reset' => false, + 'prefs' => new \stdClass(), + 'sessions' => null, + 'tokens' => null, + 'memberships' => null, + 'search' => implode(' ', [$userId, $number]) + ]))); + } + + $secret = $phone->generateSecretDigits(); + + $expire = \time() + Auth::TOKEN_EXPIRATION_PHONE; + + $token = new Document([ + '$id' => $dbForProject->getId(), + 'userId' => $user->getId(), + 'type' => Auth::TOKEN_TYPE_PHONE, + 'secret' => $secret, + 'expire' => $expire, + 'userAgent' => $request->getUserAgent('UNKNOWN'), + 'ip' => $request->getIP(), + ]); + + Authorization::setRole('user:' . $user->getId()); + + $token = $dbForProject->createDocument('tokens', $token + ->setAttribute('$read', ['user:' . $user->getId()]) + ->setAttribute('$write', ['user:' . $user->getId()])); + + $dbForProject->deleteCachedDocument('users', $user->getId()); + + $phone->send(APP::getEnv('_APP_PHONE_FROM'), $number, $secret); + + $events->setPayload( + $response->output( + $token->setAttribute('secret', $secret), + Response::MODEL_TOKEN + ) + ); + + // Hide secret for clients + $token->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? $secret : ''); + + $audits + ->setResource('user/' . $user->getId()) + ->setUser($user) + ; + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($token, Response::MODEL_TOKEN) + ; + }); + +App::put('/v1/account/sessions/phone') + ->desc('Create Phone session (confirmation)') + ->groups(['api', 'account']) + ->label('scope', 'public') + ->label('event', 'users.[userId].sessions.[sessionId].create') + ->label('sdk.auth', []) + ->label('sdk.namespace', 'account') + ->label('sdk.method', 'updatePhoneSession') + ->label('sdk.description', '/docs/references/account/update-phone-session.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_SESSION) + ->label('abuse-limit', 10) + ->label('abuse-key', 'url:{url},userId:{param-userId}') + ->param('userId', '', new CustomId(), 'User ID.') + ->param('secret', '', new Text(256), 'Valid verification token.') + ->inject('request') + ->inject('response') + ->inject('dbForProject') + ->inject('locale') + ->inject('geodb') + ->inject('audits') + ->inject('events') + ->action(function (string $userId, string $secret, Request $request, Response $response, Database $dbForProject, Locale $locale, Reader $geodb, Audit $audits, Event $events) { + + $user = Authorization::skip(fn() => $dbForProject->getDocument('users', $userId)); + + if ($user->isEmpty()) { + throw new Exception('User not found', 404, Exception::USER_NOT_FOUND); + } + + $token = Auth::phoneTokenVerify($user->getAttribute('tokens', []), $secret); + + if (!$token) { + throw new Exception('Invalid login token', 401, Exception::USER_INVALID_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( + [ + '$id' => $dbForProject->getId(), + 'userId' => $user->getId(), + 'provider' => Auth::SESSION_PROVIDER_PHONE, + '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() + )); + + Authorization::setRole('user:' . $user->getId()); + + $session = $dbForProject->createDocument('sessions', $session + ->setAttribute('$read', ['user:' . $user->getId()]) + ->setAttribute('$write', ['user:' . $user->getId()])); + + $dbForProject->deleteCachedDocument('users', $user->getId()); + + /** + * We act like we're updating and validating + * the recovery token but actually we don't need it anymore. + */ + $dbForProject->deleteDocument('tokens', $token); + $dbForProject->deleteCachedDocument('users', $user->getId()); + + $user->setAttribute('phoneVerification', true); + + $user = $dbForProject->updateDocument('users', $user->getId(), $user); + + if (false === $user) { + throw new Exception('Failed saving user to DB', 500, Exception::GENERAL_SERVER_ERROR); + } + + $audits->setResource('user/' . $user->getId()); + + $events + ->setParam('userId', $user->getId()) + ->setParam('sessionId', $session->getId()) + ; + + if (!Config::getParam('domainVerification')) { + $response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)])); + } + + $protocol = $request->getProtocol(); + + $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) + ; + + $countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')); + + $session + ->setAttribute('current', true) + ->setAttribute('countryName', $countryName) + ; + + $response->dynamic($session, Response::MODEL_SESSION); + }); + App::post('/v1/account/sessions/anonymous') ->desc('Create Anonymous Session') ->groups(['api', 'account', 'auth']) @@ -1285,7 +1509,7 @@ App::patch('/v1/account/email') ->inject('events') ->action(function (string $email, string $password, Response $response, Document $user, Database $dbForProject, Audit $audits, Stats $usage, Event $events) { - $isAnonymousUser = is_null($user->getAttribute('email')) && is_null($user->getAttribute('password')); // Check if request is from an anonymous account for converting + $isAnonymousUser = Auth::isAnonymousUser($user); // Check if request is from an anonymous account for converting if ( !$isAnonymousUser && diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 569a9ab58f..cada9e51d7 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -551,11 +551,6 @@ App::patch('/v1/users/:userId/email') throw new Exception('User not found', 404, Exception::USER_NOT_FOUND); } - $isAnonymousUser = is_null($user->getAttribute('email')) && is_null($user->getAttribute('password')); // Check if request is from an anonymous account for converting - if (!$isAnonymousUser) { - //TODO: Remove previous unique ID. - } - $email = \strtolower($email); $user diff --git a/app/init.php b/app/init.php index 0e1848b2c3..574d009e37 100644 --- a/app/init.php +++ b/app/init.php @@ -23,6 +23,10 @@ use Ahc\Jwt\JWT; use Ahc\Jwt\JWTException; use Appwrite\Extend\Exception; use Appwrite\Auth\Auth; +use Appwrite\Auth\Phone\Mock; +use Appwrite\Auth\Phone\Telesign; +use Appwrite\Auth\Phone\TextMagic; +use Appwrite\Auth\Phone\Twilio; use Appwrite\Event\Audit; use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Delete; @@ -64,6 +68,8 @@ use Utopia\Storage\Device\S3; use Utopia\Storage\Device\Linode; use Utopia\Storage\Device\Wasabi; +use function PHPUnit\Framework\matches; + const APP_NAME = 'Appwrite'; const APP_DOMAIN = 'appwrite.io'; const APP_EMAIL_TEAM = 'team@localhost.test'; // Default email address @@ -960,3 +966,17 @@ App::setResource('geodb', function ($register) { /** @var Utopia\Registry\Registry $register */ return $register->get('geodb'); }, ['register']); + +App::setResource('phone', function () { + $provider = App::getEnv('_APP_PHONE_PROVIDER'); + $user = App::getEnv('_APP_PHONE_USER'); + $secret = App::getEnv('_APP_PHONE_SECRET'); + + return match ($provider) { + 'mock' => new Mock('', ''), // used for tests + 'twilio' => new Twilio($user, $secret), + 'text-magic' => new TextMagic($user, $secret), + 'telesign' => new Telesign($user, $secret), + default => null + }; +}); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index b6e1ed68b2..dca7d01dcd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -175,6 +175,10 @@ services: - _APP_MAINTENANCE_RETENTION_EXECUTION - _APP_MAINTENANCE_RETENTION_ABUSE - _APP_MAINTENANCE_RETENTION_AUDIT + - _APP_PHONE_PROVIDER + - _APP_PHONE_FROM + - _APP_PHONE_USER + - _APP_PHONE_SECRET appwrite-realtime: entrypoint: realtime diff --git a/src/Appwrite/Auth/Auth.php b/src/Appwrite/Auth/Auth.php index 40560371f4..a8005be704 100644 --- a/src/Appwrite/Auth/Auth.php +++ b/src/Appwrite/Auth/Auth.php @@ -27,6 +27,7 @@ class Auth public const TOKEN_TYPE_RECOVERY = 3; public const TOKEN_TYPE_INVITE = 4; public const TOKEN_TYPE_MAGIC_URL = 5; + public const TOKEN_TYPE_PHONE = 6; /** * Session Providers. @@ -34,6 +35,7 @@ class Auth public const SESSION_PROVIDER_EMAIL = 'email'; public const SESSION_PROVIDER_ANONYMOUS = 'anonymous'; public const SESSION_PROVIDER_MAGIC_URL = 'magic-url'; + public const SESSION_PROVIDER_PHONE = 'phone'; /** * Token Expiration times. @@ -42,6 +44,7 @@ class Auth public const TOKEN_EXPIRATION_LOGIN_SHORT = 3600; /* 1 hour */ public const TOKEN_EXPIRATION_RECOVERY = 3600; /* 1 hour */ public const TOKEN_EXPIRATION_CONFIRM = 3600 * 24 * 7; /* 7 days */ + public const TOKEN_EXPIRATION_PHONE = 60 * 15; /* 15 minutes */ /** * @var string @@ -195,7 +198,8 @@ class Auth */ public static function tokenVerify(array $tokens, int $type, string $secret) { - foreach ($tokens as $token) { /** @var Document $token */ + foreach ($tokens as $token) { + /** @var Document $token */ if ( $token->isSet('type') && $token->isSet('secret') && @@ -211,6 +215,25 @@ class Auth return false; } + public static function phoneTokenVerify(array $tokens, string $secret) + { + foreach ($tokens as $token) { + /** @var Document $token */ + if ( + $token->isSet('type') && + $token->isSet('secret') && + $token->isSet('expire') && + $token->getAttribute('type') == Auth::TOKEN_TYPE_PHONE && + $token->getAttribute('secret') === $secret && + $token->getAttribute('expire') >= \time() + ) { + return (string) $token->getId(); + } + } + + return false; + } + /** * Verify session and check that its not expired. * @@ -221,7 +244,8 @@ class Auth */ public static function sessionVerify(array $sessions, string $secret) { - foreach ($sessions as $session) { /** @var Document $session */ + foreach ($sessions as $session) { + /** @var Document $session */ if ( $session->isSet('secret') && $session->isSet('expire') && @@ -303,4 +327,11 @@ class Auth return $roles; } + + public static function isAnonymousUser(Document $user): bool + { + return (is_null($user->getAttribute('email')) + || is_null($user->getAttribute('phone')) + ) && is_null($user->getAttribute('password')); + } } diff --git a/src/Appwrite/Auth/Phone.php b/src/Appwrite/Auth/Phone.php new file mode 100644 index 0000000000..e3cc836316 --- /dev/null +++ b/src/Appwrite/Auth/Phone.php @@ -0,0 +1,88 @@ +user = $user; + $this->secret = $secret; + } + + /** + * Send Message to phone. + * @param string $from + * @param string $to + * @param string $message + * @return void + */ + abstract public function send(string $from, string $to, string $message): void; + + /** + * @param string $method + * @param string $url + * @param array $headers + * @param string $payload + * + * @return string + */ + protected function request(string $method, string $url, array $headers = [], ?string $payload = null, ?string $userpwd = null): string + { + $ch = \curl_init($url); + + \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + \curl_setopt($ch, CURLOPT_HEADER, 0); + \curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, CURLOPT_USERAGENT, 'Appwrite Phone Authentication'); + + if (!is_null($payload)) { + \curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); + } + + if (!is_null($userpwd)) { + \curl_setopt($ch, CURLOPT_USERPWD, $userpwd); + } + + $headers[] = 'Content-length: ' . \strlen($payload); + + \curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + + $response = (string) \curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + \curl_close($ch); + + if ($code >= 400) { + throw new Exception($response); + } + + return $response; + } + + /** + * Generate 6 random digits for phone verification. + * + * @param int $digits + * @return string + */ + public function generateSecretDigits(int $digits = 6): string + { + return substr(str_shuffle("0123456789"), 0, $digits); + } +} diff --git a/src/Appwrite/Auth/Phone/Mock.php b/src/Appwrite/Auth/Phone/Mock.php new file mode 100644 index 0000000000..b22ad7a2d8 --- /dev/null +++ b/src/Appwrite/Auth/Phone/Mock.php @@ -0,0 +1,33 @@ +request( + method: 'POST', + url: $this->endpoint, + payload: \http_build_query([ + 'message' => $message, + 'message_type' => 'otp', + 'phone_number' => $to + ]), + userpwd: "{$this->user}:{$this->secret}" + ); + } +} diff --git a/src/Appwrite/Auth/Phone/TextMagic.php b/src/Appwrite/Auth/Phone/TextMagic.php new file mode 100644 index 0000000000..62e7cbd925 --- /dev/null +++ b/src/Appwrite/Auth/Phone/TextMagic.php @@ -0,0 +1,42 @@ +request( + method: 'POST', + url: $this->endpoint . '/messages', + payload: \http_build_query([ + 'text' => $message, + 'from' => $from, + 'phones' => $to + ]), + headers: [ + "X-TM-Username: {$this->user}", + "X-TM-Key: {$this->secret}", + ] + ); + } +} diff --git a/src/Appwrite/Auth/Phone/Twilio.php b/src/Appwrite/Auth/Phone/Twilio.php new file mode 100644 index 0000000000..8d6e8bc8e1 --- /dev/null +++ b/src/Appwrite/Auth/Phone/Twilio.php @@ -0,0 +1,36 @@ +request( + method: 'POST', + url: "{$this->endpoint}/Accounts/{$this->user}/Messages.json", + payload: \http_build_query([ + 'Body' => $message, + 'From' => $from, + 'To' => $to + ]), + userpwd: "{$this->user}:{$this->secret}" + ); + } +} diff --git a/src/Appwrite/Auth/Validator/Phone.php b/src/Appwrite/Auth/Validator/Phone.php new file mode 100644 index 0000000000..0852a0cb6d --- /dev/null +++ b/src/Appwrite/Auth/Validator/Phone.php @@ -0,0 +1,61 @@ + '', 'example' => 'john@appwrite.io', ]) + ->addRule('phone', [ + 'type' => self::TYPE_STRING, + 'description' => 'User phone number.', + 'default' => '', + 'example' => '+49 30 901820', + ]) ->addRule('emailVerification', [ 'type' => self::TYPE_BOOLEAN, 'description' => 'Email verification status.', 'default' => false, 'example' => true, ]) + ->addRule('phoneVerification', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Phone verification status.', + 'default' => false, + 'example' => true, + ]) ->addRule('prefs', [ 'type' => Response::MODEL_PREFERENCES, 'description' => 'User preferences as a key-value object', diff --git a/tests/e2e/Services/Account/AccountBase.php b/tests/e2e/Services/Account/AccountBase.php index 7ffa9eb7c9..b65326c5c5 100644 --- a/tests/e2e/Services/Account/AccountBase.php +++ b/tests/e2e/Services/Account/AccountBase.php @@ -2,6 +2,7 @@ namespace Tests\E2E\Services\Account; +use Appwrite\Auth\Phone\Mock; use Tests\E2E\Client; trait AccountBase @@ -1329,6 +1330,132 @@ trait AccountBase return $data; } + public function testCreatePhone(): array + { + $number = '+1 234 56789'; + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/phone', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'userId' => 'unique()', + 'number' => $number, + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEmpty($response['body']['secret']); + $this->assertIsNumeric($response['body']['expire']); + + $userId = $response['body']['userId']; + + /** + * Test for FAILURE + */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/phone', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'userId' => 'unique()' + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + $data['token'] = Mock::$defaultDigits; + $data['id'] = $userId; + $data['number'] = $number; + + return $data; + } + + /** + * @depends testCreatePhone + */ + public function testCreateSessionWithPhone($data): void + { + $id = $data['id'] ?? ''; + $token = $data['token'] ?? ''; + $number = $data['number'] ?? ''; + + /** + * Test for FAILURE + */ + $response = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'userId' => 'ewewe', + 'secret' => $token, + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'userId' => $id, + 'secret' => 'sdasdasdasd', + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'userId' => $id, + 'secret' => $token, + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertIsArray($response['body']); + $this->assertNotEmpty($response['body']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertNotEmpty($response['body']['userId']); + + $session = $this->client->parseCookie((string)$response['headers']['set-cookie'])['a_session_' . $this->getProject()['$id']]; + + $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->assertNotEmpty($response['body']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertIsNumeric($response['body']['registration']); + $this->assertEquals($response['body']['phone'], $number); + $this->assertTrue($response['body']['phoneVerification']); + + /** + * Test for FAILURE + */ + $response = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'userId' => $id, + 'secret' => $token, + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + } + /** * @depends testCreateMagicUrl */ diff --git a/tests/unit/Auth/Validator/PhoneTest.php b/tests/unit/Auth/Validator/PhoneTest.php new file mode 100644 index 0000000000..39b6c9857e --- /dev/null +++ b/tests/unit/Auth/Validator/PhoneTest.php @@ -0,0 +1,40 @@ +object = new Phone(); + } + + public function tearDown(): void + { + } + + public function testValues() + { + $this->assertEquals($this->object->isValid(false), false); + $this->assertEquals($this->object->isValid(null), false); + $this->assertEquals($this->object->isValid(''), false); + $this->assertEquals($this->object->isValid('+1'), false); + $this->assertEquals($this->object->isValid('8989829304'), false); + $this->assertEquals($this->object->isValid('786-307-3615'), false); + $this->assertEquals($this->object->isValid('+16308A520397'), false); + $this->assertEquals($this->object->isValid('+0415553452342'), false); + + $this->assertEquals($this->object->isValid('+14155552'), true); + $this->assertEquals($this->object->isValid('+141555526'), true); + $this->assertEquals($this->object->isValid('+16308520394'), true); + $this->assertEquals($this->object->isValid('+163085205339'), true); + $this->assertEquals($this->object->isValid('+5511552563253'), true); + $this->assertEquals($this->object->isValid('+55115525632534'), true); + $this->assertEquals($this->object->isValid('+919367788755111'), true); + } +}