1
0
Fork 0
mirror of synced 2024-09-14 16:38:28 +12:00

Merge pull request #7565 from appwrite/mock-numbers

Mock OTP and phone numbers
This commit is contained in:
Damodar Lohani 2024-07-03 15:21:58 +05:45 committed by GitHub
commit 71a9694f86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 437 additions and 62 deletions

View file

@ -2371,7 +2371,18 @@ App::post('/v1/account/tokens/phone')
$dbForProject->purgeCachedDocument('users', $user->getId());
}
$secret = Auth::codeGenerator();
$secret = null;
$sendSMS = true;
$mockNumbers = $project->getAttribute('auths', [])['mockNumbers'] ?? [];
foreach ($mockNumbers as $mockNumber) {
if ($mockNumber['phone'] === $phone) {
$secret = $mockNumber['otp'];
$sendSMS = false;
break;
}
}
$secret ??= Auth::codeGenerator();
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_OTP));
$token = new Document([
@ -2396,35 +2407,37 @@ App::post('/v1/account/tokens/phone')
$dbForProject->purgeCachedDocument('users', $user->getId());
$message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl');
if ($sendSMS) {
$message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl');
$customTemplate = $project->getAttribute('templates', [])['sms.login-' . $locale->default] ?? [];
if (!empty($customTemplate)) {
$message = $customTemplate['message'] ?? $message;
$customTemplate = $project->getAttribute('templates', [])['sms.login-' . $locale->default] ?? [];
if (!empty($customTemplate)) {
$message = $customTemplate['message'] ?? $message;
}
$messageContent = Template::fromString($locale->getText("sms.verification.body"));
$messageContent
->setParam('{{project}}', $project->getAttribute('name'))
->setParam('{{secret}}', $secret);
$messageContent = \strip_tags($messageContent->render());
$message = $message->setParam('{{token}}', $messageContent);
$message = $message->render();
$messageDoc = new Document([
'$id' => $token->getId(),
'data' => [
'content' => $message,
],
]);
$queueForMessaging
->setType(MESSAGE_SEND_TYPE_INTERNAL)
->setMessage($messageDoc)
->setRecipients([$phone])
->setProviderType(MESSAGE_TYPE_SMS);
}
$messageContent = Template::fromString($locale->getText("sms.verification.body"));
$messageContent
->setParam('{{project}}', $project->getAttribute('name'))
->setParam('{{secret}}', $secret);
$messageContent = \strip_tags($messageContent->render());
$message = $message->setParam('{{token}}', $messageContent);
$message = $message->render();
$messageDoc = new Document([
'$id' => $token->getId(),
'data' => [
'content' => $message,
],
]);
$queueForMessaging
->setType(MESSAGE_SEND_TYPE_INTERNAL)
->setMessage($messageDoc)
->setRecipients([$phone])
->setProviderType(MESSAGE_TYPE_SMS);
// Set to unhashed secret for events and server responses
$token->setAttribute('secret', $secret);
@ -3439,7 +3452,8 @@ App::post('/v1/account/verification/phone')
throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured');
}
if (empty($user->getAttribute('phone'))) {
$phone = $user->getAttribute('phone');
if (empty($phone)) {
throw new Exception(Exception::USER_PHONE_NOT_FOUND);
}
@ -3450,7 +3464,19 @@ App::post('/v1/account/verification/phone')
$roles = Authorization::getRoles();
$isPrivilegedUser = Auth::isPrivilegedUser($roles);
$isAppUser = Auth::isAppUser($roles);
$secret = Auth::codeGenerator();
$secret = null;
$sendSMS = true;
$mockNumbers = $project->getAttribute('auths', [])['mockNumbers'] ?? [];
foreach ($mockNumbers as $mockNumber) {
if ($mockNumber['phone'] === $phone) {
$secret = $mockNumber['otp'];
$sendSMS = false;
break;
}
}
$secret ??= Auth::codeGenerator();
$expire = DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_CONFIRM);
$verification = new Document([
@ -3475,35 +3501,37 @@ App::post('/v1/account/verification/phone')
$dbForProject->purgeCachedDocument('users', $user->getId());
$message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl');
if ($sendSMS) {
$message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl');
$customTemplate = $project->getAttribute('templates', [])['sms.verification-' . $locale->default] ?? [];
if (!empty($customTemplate)) {
$message = $customTemplate['message'] ?? $message;
$customTemplate = $project->getAttribute('templates', [])['sms.verification-' . $locale->default] ?? [];
if (!empty($customTemplate)) {
$message = $customTemplate['message'] ?? $message;
}
$messageContent = Template::fromString($locale->getText("sms.verification.body"));
$messageContent
->setParam('{{project}}', $project->getAttribute('name'))
->setParam('{{secret}}', $secret);
$messageContent = \strip_tags($messageContent->render());
$message = $message->setParam('{{token}}', $messageContent);
$message = $message->render();
$messageDoc = new Document([
'$id' => $verification->getId(),
'data' => [
'content' => $message,
],
]);
$queueForMessaging
->setType(MESSAGE_SEND_TYPE_INTERNAL)
->setMessage($messageDoc)
->setRecipients([$user->getAttribute('phone')])
->setProviderType(MESSAGE_TYPE_SMS);
}
$messageContent = Template::fromString($locale->getText("sms.verification.body"));
$messageContent
->setParam('{{project}}', $project->getAttribute('name'))
->setParam('{{secret}}', $secret);
$messageContent = \strip_tags($messageContent->render());
$message = $message->setParam('{{token}}', $messageContent);
$message = $message->render();
$messageDoc = new Document([
'$id' => $verification->getId(),
'data' => [
'content' => $message,
],
]);
$queueForMessaging
->setType(MESSAGE_SEND_TYPE_INTERNAL)
->setMessage($messageDoc)
->setRecipients([$user->getAttribute('phone')])
->setProviderType(MESSAGE_TYPE_SMS);
// Set to unhashed secret for events and server responses
$verification->setAttribute('secret', $secret);

View file

@ -2,6 +2,7 @@
use Ahc\Jwt\JWT;
use Appwrite\Auth\Auth;
use Appwrite\Auth\Validator\MockNumber;
use Appwrite\Event\Delete;
use Appwrite\Event\Mail;
use Appwrite\Event\Validator\Event;
@ -105,6 +106,7 @@ App::post('/v1/projects')
'passwordDictionary' => false,
'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG,
'personalDataCheck' => false,
'mockNumbers' => [],
'sessionAlerts' => false,
];
@ -856,6 +858,37 @@ App::patch('/v1/projects/:projectId/auth/max-sessions')
$response->dynamic($project, Response::MODEL_PROJECT);
});
App::patch('/v1/projects/:projectId/auth/mock-numbers')
->desc('Update the mock numbers for the project')
->groups(['api', 'projects'])
->label('scope', 'projects.write')
->label('sdk.auth', [APP_AUTH_TYPE_ADMIN])
->label('sdk.namespace', 'projects')
->label('sdk.method', 'updateMockNumbers')
->label('sdk.response.code', Response::STATUS_CODE_OK)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_PROJECT)
->param('projectId', '', new UID(), 'Project unique ID.')
->param('numbers', '', new ArrayList(new MockNumber(), 10), 'An array of mock numbers and their corresponding verification codes (OTPs). Each number should be a valid E.164 formatted phone number. Maximum of 10 numbers are allowed.')
->inject('response')
->inject('dbForConsole')
->action(function (string $projectId, array $numbers, Response $response, Database $dbForConsole) {
$project = $dbForConsole->getDocument('projects', $projectId);
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
$auths = $project->getAttribute('auths', []);
$auths['mockNumbers'] = $numbers;
$project = $dbForConsole->updateDocument('projects', $project->getId(), $project->setAttribute('auths', $auths));
$response->dynamic($project, Response::MODEL_PROJECT);
});
App::delete('/v1/projects/:projectId')
->desc('Delete project')
->groups(['api', 'projects'])

View file

@ -777,6 +777,7 @@ $register->set('logger', function () {
$adapter = new $classname($providerConfig);
return new Logger($adapter);
});
$register->set('pools', function () {
$group = new Group();
@ -1320,6 +1321,7 @@ App::setResource('console', function () {
'legalAddress' => '',
'legalTaxId' => '',
'auths' => [
'mockNumbers' => [],
'invites' => System::getEnv('_APP_CONSOLE_INVITES', 'enabled') === 'enabled',
'limit' => (System::getEnv('_APP_CONSOLE_WHITELIST_ROOT', 'enabled') === 'enabled') ? 1 : 0, // limit signup to 1 user
'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG, // 1 Year in seconds

View file

@ -0,0 +1,81 @@
<?php
namespace Appwrite\Auth\Validator;
use Utopia\Validator;
use Utopia\Validator\Text;
/**
* MockNumber.
*
* Validates if a given object represents a valid phone and OTP pair
*/
class MockNumber extends Validator
{
private $message = '';
/**
* Get Description.
*
* Returns validator description
*
* @return string
*/
public function getDescription(): string
{
return $this->message;
}
/**
* Is valid.
*
* @param mixed $value
*
* @return bool
*/
public function isValid($value): bool
{
if (!\is_array($value) || !isset($value['phone']) || !isset($value['otp'])) {
$this->message = 'Invalid payload structure. Please check the "phone" and "otp" fields';
return false;
}
$phone = new Phone();
if (!$phone->isValid($value['phone'])) {
$this->message = $phone->getDescription();
return false;
}
$otp = new Text(6, 6);
if (!$otp->isValid($value['otp'])) {
$this->message = 'OTP must be a valid string and exactly 6 characters.';
return false;
}
return true;
}
/**
* Is array
*
* Function will return true if object is array.
*
* @return bool
*/
public function isArray(): bool
{
return false;
}
/**
* Get Type
*
* Returns validator type.
*
* @return string
*/
public function getType(): string
{
return self::TYPE_STRING;
}
}

View file

@ -72,6 +72,7 @@ use Appwrite\Utopia\Response\Model\Migration;
use Appwrite\Utopia\Response\Model\MigrationFirebaseProject;
use Appwrite\Utopia\Response\Model\MigrationReport;
use Appwrite\Utopia\Response\Model\Mock;
use Appwrite\Utopia\Response\Model\MockNumber;
use Appwrite\Utopia\Response\Model\None;
use Appwrite\Utopia\Response\Model\Phone;
use Appwrite\Utopia\Response\Model\Platform;
@ -269,6 +270,8 @@ class Response extends SwooleResponse
public const MODEL_WEBHOOK_LIST = 'webhookList';
public const MODEL_KEY = 'key';
public const MODEL_KEY_LIST = 'keyList';
public const MODEL_MOCK_NUMBER = 'mockNumber';
public const MODEL_MOCK_NUMBER_LIST = 'mockNumberList';
public const MODEL_AUTH_PROVIDER = 'authProvider';
public const MODEL_AUTH_PROVIDER_LIST = 'authProviderList';
public const MODEL_PLATFORM = 'platform';
@ -348,6 +351,7 @@ class Response extends SwooleResponse
->setModel(new BaseList('Projects List', self::MODEL_PROJECT_LIST, 'projects', self::MODEL_PROJECT, true, false))
->setModel(new BaseList('Webhooks List', self::MODEL_WEBHOOK_LIST, 'webhooks', self::MODEL_WEBHOOK, true, false))
->setModel(new BaseList('API Keys List', self::MODEL_KEY_LIST, 'keys', self::MODEL_KEY, true, false))
->setModel(new BaseList('Mock Numbers List', self::MODEL_MOCK_NUMBER_LIST, 'numbers', self::MODEL_MOCK_NUMBER, true, false))
->setModel(new BaseList('Auth Providers List', self::MODEL_AUTH_PROVIDER_LIST, 'platforms', self::MODEL_AUTH_PROVIDER, true, false))
->setModel(new BaseList('Platforms List', self::MODEL_PLATFORM_LIST, 'platforms', self::MODEL_PLATFORM, true, false))
->setModel(new BaseList('Countries List', self::MODEL_COUNTRY_LIST, 'countries', self::MODEL_COUNTRY))
@ -419,6 +423,7 @@ class Response extends SwooleResponse
->setModel(new Project())
->setModel(new Webhook())
->setModel(new Key())
->setModel(new MockNumber())
->setModel(new AuthProvider())
->setModel(new Platform())
->setModel(new Variable())

View file

@ -0,0 +1,47 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
class MockNumber extends Model
{
public function __construct()
{
$this
->addRule('phone', [
'type' => self::TYPE_STRING,
'description' => 'Mock phone number for testing phone authentication. Useful for testing phone authentication without sending an SMS.',
'default' => '',
'example' => '+1612842323',
])
->addRule('otp', [
'type' => self::TYPE_STRING,
'description' => 'Mock OTP for the number. ',
'default' => '',
'example' => '123456',
])
;
}
/**
* Get Name
*
* @return string
*/
public function getName(): string
{
return 'Mock Number';
}
/**
* Get Type
*
* @return string
*/
public function getType(): string
{
return Response::MODEL_MOCK_NUMBER;
}
}

View file

@ -138,6 +138,12 @@ class Project extends Model
'default' => false,
'example' => true,
])
->addRule('authMockNumbers', [
'type' => Response::MODEL_MOCK_NUMBER_LIST,
'description' => 'An array of mock numbers and their corresponding verification codes (OTPs).',
'default' => [],
'example' => true,
])
->addRule('authSessionAlerts', [
'type' => self::TYPE_BOOLEAN,
'description' => 'Whether or not to send session alert emails to users.',
@ -327,6 +333,7 @@ class Project extends Model
$document->setAttribute('authPasswordHistory', $authValues['passwordHistory'] ?? 0);
$document->setAttribute('authPasswordDictionary', $authValues['passwordDictionary'] ?? false);
$document->setAttribute('authPersonalDataCheck', $authValues['personalDataCheck'] ?? false);
$document->setAttribute('authMockNumbers', $authValues['mockNumbers'] ?? []);
$document->setAttribute('authSessionAlerts', $authValues['sessionAlerts'] ?? false);
foreach ($auth as $index => $method) {

View file

@ -610,7 +610,7 @@ class ProjectsConsoleClientTest extends Scope
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'emails' => [ 'testuser@appwrite.io', 'testusertwo@appwrite.io' ],
'emails' => ['testuser@appwrite.io', 'testusertwo@appwrite.io'],
'senderEmail' => 'custommailer@appwrite.io',
'senderName' => 'Custom Mailer',
'replyTo' => 'reply@appwrite.io',
@ -647,7 +647,7 @@ class ProjectsConsoleClientTest extends Scope
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'emails' => [ 'u1@appwrite.io', 'u2@appwrite.io', 'u3@appwrite.io', 'u4@appwrite.io', 'u5@appwrite.io', 'u6@appwrite.io', 'u7@appwrite.io', 'u8@appwrite.io', 'u9@appwrite.io', 'u10@appwrite.io' ],
'emails' => ['u1@appwrite.io', 'u2@appwrite.io', 'u3@appwrite.io', 'u4@appwrite.io', 'u5@appwrite.io', 'u6@appwrite.io', 'u7@appwrite.io', 'u8@appwrite.io', 'u9@appwrite.io', 'u10@appwrite.io'],
'senderEmail' => 'custommailer@appwrite.io',
'senderName' => 'Custom Mailer',
'replyTo' => 'reply@appwrite.io',
@ -663,7 +663,7 @@ class ProjectsConsoleClientTest extends Scope
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'emails' => [ 'u1@appwrite.io', 'u2@appwrite.io', 'u3@appwrite.io', 'u4@appwrite.io', 'u5@appwrite.io', 'u6@appwrite.io', 'u7@appwrite.io', 'u8@appwrite.io', 'u9@appwrite.io', 'u10@appwrite.io', 'u11@appwrite.io' ],
'emails' => ['u1@appwrite.io', 'u2@appwrite.io', 'u3@appwrite.io', 'u4@appwrite.io', 'u5@appwrite.io', 'u6@appwrite.io', 'u7@appwrite.io', 'u8@appwrite.io', 'u9@appwrite.io', 'u10@appwrite.io', 'u11@appwrite.io'],
'senderEmail' => 'custommailer@appwrite.io',
'senderName' => 'Custom Mailer',
'replyTo' => 'reply@appwrite.io',
@ -1529,8 +1529,8 @@ class ProjectsConsoleClientTest extends Scope
/**
* Reset
*/
* Reset
*/
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
@ -1543,6 +1543,178 @@ class ProjectsConsoleClientTest extends Scope
return $data;
}
/**
* @group smtpAndTemplates
* @group projectsCRUD
*
* @depends testCreateProject
* */
public function testUpdateMockNumbers($data)
{
$id = $data['projectId'] ?? '';
/**
* Test for Failure
*/
/** Trying to pass an empty body to the endpoint */
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), []);
$this->assertEquals(400, $response['headers']['status-code']);
$this->assertEquals('Param "numbers" is not optional.', $response['body']['message']);
/** Trying to pass body with incorrect structure */
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'numbers' => [
'phone' => '+1655513432',
'otp' => '123456'
]
]);
$this->assertEquals(400, $response['headers']['status-code']);
$this->assertEquals('Invalid `numbers` param: Value must a valid array no longer than 10 items and Invalid payload structure. Please check the "phone" and "otp" fields', $response['body']['message']);
/** Trying to pass an OTP longer than 6 characters*/
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'numbers' => [
[
'phone' => '+1655513432',
'otp' => '12345678'
]
]
]);
$this->assertEquals(400, $response['headers']['status-code']);
$this->assertEquals('Invalid `numbers` param: Value must a valid array no longer than 10 items and OTP must be a valid string and exactly 6 characters.', $response['body']['message']);
/** Trying to pass an OTP shorter than 6 characters*/
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'numbers' => [
[
'phone' => '+1655513432',
'otp' => '123'
]
]
]);
$this->assertEquals(400, $response['headers']['status-code']);
$this->assertEquals('Invalid `numbers` param: Value must a valid array no longer than 10 items and OTP must be a valid string and exactly 6 characters.', $response['body']['message']);
/** Trying to pass an invalid phone number */
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'numbers' => [
[
'phone' => '1655234',
'otp' => '123456'
]
]
]);
$this->assertEquals(400, $response['headers']['status-code']);
$this->assertEquals('Invalid `numbers` param: Value must a valid array no longer than 10 items and Phone number must start with a \'+\' can have a maximum of fifteen digits.', $response['body']['message']);
/** Trying to pass a number longer than 15 digits */
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'numbers' => [
[
'phone' => '+1234567890987654',
'otp' => '123456'
]
]
]);
$this->assertEquals(400, $response['headers']['status-code']);
$this->assertEquals('Invalid `numbers` param: Value must a valid array no longer than 10 items and Phone number must start with a \'+\' can have a maximum of fifteen digits.', $response['body']['message']);
$numbers = [];
for ($i = 0; $i < 11; $i++) {
$numbers[] = [
'phone' => '+1655513432',
'otp' => '123456'
];
}
/** Trying to pass more than 10 values */
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'numbers' => $numbers
]);
$this->assertEquals(400, $response['headers']['status-code']);
$this->assertEquals('Invalid `numbers` param: Value must a valid array no longer than 10 items and Phone number must start with a \'+\' can have a maximum of fifteen digits.', $response['body']['message']);
/**
* Test for success
*/
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'numbers' => []
]);
$this->assertEquals(200, $response['headers']['status-code']);
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'numbers' => [
[
'phone' => '+1655513432',
'otp' => '123456'
]
]
]);
$this->assertEquals(200, $response['headers']['status-code']);
// Create phone session for this project and check if the mock number is used
$response = $this->client->call(Client::METHOD_POST, '/account/sessions/phone', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $id,
]), [
'userId' => 'unique()',
'phone' => '+1655513432',
]);
$this->assertEquals(201, $response['headers']['status-code']);
$userId = $response['body']['userId'];
$response = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $id,
]), [
'userId' => $userId,
'secret' => '654321', // Try a random code
]);
$this->assertEquals(401, $response['headers']['status-code']);
$response = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $id,
]), [
'userId' => $userId,
'secret' => '123456',
]);
$this->assertEquals(201, $response['headers']['status-code']);
}
/**
* @depends testUpdateProjectAuthLimit
*/
@ -1646,8 +1818,8 @@ class ProjectsConsoleClientTest extends Scope
/**
* Reset
*/
* Reset
*/
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],