Added security phrase to magic URL
This commit is contained in:
parent
07b6ee58fe
commit
931efe24a9
6 changed files with 52 additions and 4 deletions
|
@ -18,3 +18,7 @@
|
||||||
|
|
||||||
<p style="margin-bottom: 0px;">{{thanks}}</p>
|
<p style="margin-bottom: 0px;">{{thanks}}</p>
|
||||||
<p style="margin-top: 0px;">{{signature}}</p>
|
<p style="margin-top: 0px;">{{signature}}</p>
|
||||||
|
|
||||||
|
<hr style="margin-block-start: 1rem; margin-block-end: 1rem;">
|
||||||
|
|
||||||
|
<p style="opacity: 0.7;">{{securityPhrase}}</p>
|
|
@ -15,6 +15,7 @@
|
||||||
"emails.magicSession.buttonText": "Sign in to {{project}}",
|
"emails.magicSession.buttonText": "Sign in to {{project}}",
|
||||||
"emails.magicSession.optionUrl": "If the button above doesn't show, use the following link:",
|
"emails.magicSession.optionUrl": "If the button above doesn't show, use the following link:",
|
||||||
"emails.magicSession.clientInfo": "This sign in was requested using {{agentClient}} on {{agentDevice}} {{agentOs}}. If you didn't request the sign in, you can safely ignore this email.",
|
"emails.magicSession.clientInfo": "This sign in was requested using {{agentClient}} on {{agentDevice}} {{agentOs}}. If you didn't request the sign in, you can safely ignore this email.",
|
||||||
|
"emails.magicSession.securityPhrase": "Security phrase for this email is {{phrase}}. You can trust this email if this phrase matches the phrase shown during sign in.",
|
||||||
"emails.magicSession.thanks": "Thanks,",
|
"emails.magicSession.thanks": "Thanks,",
|
||||||
"emails.magicSession.signature": "{{project}} team",
|
"emails.magicSession.signature": "{{project}} team",
|
||||||
"emails.recovery.subject": "Password Reset",
|
"emails.recovery.subject": "Password Reset",
|
||||||
|
|
|
@ -12,6 +12,7 @@ use Appwrite\Extend\Exception;
|
||||||
use Appwrite\Network\Validator\Email;
|
use Appwrite\Network\Validator\Email;
|
||||||
use Utopia\Validator\Host;
|
use Utopia\Validator\Host;
|
||||||
use Utopia\Validator\URL;
|
use Utopia\Validator\URL;
|
||||||
|
use Utopia\Validator\Boolean;
|
||||||
use Appwrite\OpenSSL\OpenSSL;
|
use Appwrite\OpenSSL\OpenSSL;
|
||||||
use Appwrite\Template\Template;
|
use Appwrite\Template\Template;
|
||||||
use Appwrite\URL\URL as URLParser;
|
use Appwrite\URL\URL as URLParser;
|
||||||
|
@ -932,6 +933,7 @@ App::post('/v1/account/sessions/magic-url')
|
||||||
->param('userId', '', new CustomId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. 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('userId', '', new CustomId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. 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('email', '', new Email(), 'User email.')
|
->param('email', '', new Email(), 'User email.')
|
||||||
->param('url', '', fn($clients) => new Host($clients), 'URL to redirect the user back to your app from the magic URL login. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['clients'])
|
->param('url', '', fn($clients) => new Host($clients), 'URL to redirect the user back to your app from the magic URL login. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['clients'])
|
||||||
|
->param('securityPhrase', false, new Boolean(), 'Toggle for security phrase. If enabled, email will be send with a randomly generated phrase and the phrase will also be included in the response. Confirming phrases match incrases the security of authentication flow.', true)
|
||||||
->inject('request')
|
->inject('request')
|
||||||
->inject('response')
|
->inject('response')
|
||||||
->inject('user')
|
->inject('user')
|
||||||
|
@ -940,12 +942,16 @@ App::post('/v1/account/sessions/magic-url')
|
||||||
->inject('locale')
|
->inject('locale')
|
||||||
->inject('queueForEvents')
|
->inject('queueForEvents')
|
||||||
->inject('queueForMails')
|
->inject('queueForMails')
|
||||||
->action(function (string $userId, string $email, string $url, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails) {
|
->action(function (string $userId, string $email, string $url, bool $securityPhrase, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails) {
|
||||||
|
|
||||||
if (empty(App::getEnv('_APP_SMTP_HOST'))) {
|
if (empty(App::getEnv('_APP_SMTP_HOST'))) {
|
||||||
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP disabled');
|
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP disabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if($securityPhrase === true) {
|
||||||
|
$securityPhrase = 'Golden Fox'; // TODO: Random phrase
|
||||||
|
}
|
||||||
|
|
||||||
$roles = Authorization::getRoles();
|
$roles = Authorization::getRoles();
|
||||||
$isPrivilegedUser = Auth::isPrivilegedUser($roles);
|
$isPrivilegedUser = Auth::isPrivilegedUser($roles);
|
||||||
$isAppUser = Auth::isAppUser($roles);
|
$isAppUser = Auth::isAppUser($roles);
|
||||||
|
@ -1054,6 +1060,13 @@ App::post('/v1/account/sessions/magic-url')
|
||||||
->setParam('{{clientInfo}}', $locale->getText("emails.magicSession.clientInfo"))
|
->setParam('{{clientInfo}}', $locale->getText("emails.magicSession.clientInfo"))
|
||||||
->setParam('{{thanks}}', $locale->getText("emails.magicSession.thanks"))
|
->setParam('{{thanks}}', $locale->getText("emails.magicSession.thanks"))
|
||||||
->setParam('{{signature}}', $locale->getText("emails.magicSession.signature"));
|
->setParam('{{signature}}', $locale->getText("emails.magicSession.signature"));
|
||||||
|
|
||||||
|
if(!empty($securityPhrase)) {
|
||||||
|
$message->setParam('{{securityPhrase}}', $locale->getText("emails.magicSession.securityPhrase"));
|
||||||
|
} else {
|
||||||
|
$message->setParam('{{securityPhrase}}', '');
|
||||||
|
}
|
||||||
|
|
||||||
$body = $message->render();
|
$body = $message->render();
|
||||||
|
|
||||||
$smtp = $project->getAttribute('smtp', []);
|
$smtp = $project->getAttribute('smtp', []);
|
||||||
|
@ -1111,7 +1124,8 @@ App::post('/v1/account/sessions/magic-url')
|
||||||
'redirect' => $url,
|
'redirect' => $url,
|
||||||
'agentDevice' => $agentDevice['deviceBrand'] ?? $agentDevice['deviceBrand'] ?? 'UNKNOWN',
|
'agentDevice' => $agentDevice['deviceBrand'] ?? $agentDevice['deviceBrand'] ?? 'UNKNOWN',
|
||||||
'agentClient' => $agentClient['clientName'] ?? 'UNKNOWN',
|
'agentClient' => $agentClient['clientName'] ?? 'UNKNOWN',
|
||||||
'agentOs' => $agentOs['osName'] ?? 'UNKNOWN'
|
'agentOs' => $agentOs['osName'] ?? 'UNKNOWN',
|
||||||
|
'phrase' => '<strong>' . (!empty($securityPhrase) ? $securityPhrase : '') . '</strong>'
|
||||||
];
|
];
|
||||||
|
|
||||||
$queueForMails
|
$queueForMails
|
||||||
|
@ -1131,6 +1145,10 @@ App::post('/v1/account/sessions/magic-url')
|
||||||
// Hide secret for clients
|
// Hide secret for clients
|
||||||
$token->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? $loginSecret : '');
|
$token->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? $loginSecret : '');
|
||||||
|
|
||||||
|
if(!empty($securityPhrase)) {
|
||||||
|
$token->setAttribute('securityPhrase', $securityPhrase);
|
||||||
|
}
|
||||||
|
|
||||||
$response
|
$response
|
||||||
->setStatusCode(Response::STATUS_CODE_CREATED)
|
->setStatusCode(Response::STATUS_CODE_CREATED)
|
||||||
->dynamic($token, Response::MODEL_TOKEN)
|
->dynamic($token, Response::MODEL_TOKEN)
|
||||||
|
|
|
@ -79,7 +79,7 @@ class DevGenerateTranslations extends Action
|
||||||
'content-type' => Client::CONTENT_TYPE_APPLICATION_JSON,
|
'content-type' => Client::CONTENT_TYPE_APPLICATION_JSON,
|
||||||
'Authorization' => 'Bearer ' . $this->apiKey
|
'Authorization' => 'Bearer ' . $this->apiKey
|
||||||
], Client::METHOD_POST, [
|
], Client::METHOD_POST, [
|
||||||
'model' => 'gpt-4-1106-preview', // https://platform.openai.com/docs/models/gpt-4-and-gpt-4-turbo
|
'model' => 'gpt-3.5-turbo-1106', // https://platform.openai.com/docs/models/gpt-4-and-gpt-4-turbo
|
||||||
'messages' => [
|
'messages' => [
|
||||||
[
|
[
|
||||||
'role' => 'system',
|
'role' => 'system',
|
||||||
|
|
|
@ -40,6 +40,12 @@ class Token extends Model
|
||||||
'default' => '',
|
'default' => '',
|
||||||
'example' => self::TYPE_DATETIME_EXAMPLE,
|
'example' => self::TYPE_DATETIME_EXAMPLE,
|
||||||
])
|
])
|
||||||
|
->addRule('securityPhrase', [
|
||||||
|
'type' => self::TYPE_STRING,
|
||||||
|
'description' => 'Security phrase of a token. Empty if security phrase was not requested when creating a token. It includes randomly generated phrase which is also sent in the external resource such as email.',
|
||||||
|
'default' => '',
|
||||||
|
'example' => 'Golden Fox',
|
||||||
|
])
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1353,6 +1353,7 @@ trait AccountBase
|
||||||
$this->assertEquals(201, $response['headers']['status-code']);
|
$this->assertEquals(201, $response['headers']['status-code']);
|
||||||
$this->assertNotEmpty($response['body']['$id']);
|
$this->assertNotEmpty($response['body']['$id']);
|
||||||
$this->assertEmpty($response['body']['secret']);
|
$this->assertEmpty($response['body']['secret']);
|
||||||
|
$this->assertEmpty($response['body']['securityPhrase']);
|
||||||
$this->assertEquals(true, (new DatetimeValidator())->isValid($response['body']['expire']));
|
$this->assertEquals(true, (new DatetimeValidator())->isValid($response['body']['expire']));
|
||||||
|
|
||||||
$userId = $response['body']['userId'];
|
$userId = $response['body']['userId'];
|
||||||
|
@ -1360,6 +1361,7 @@ trait AccountBase
|
||||||
$lastEmail = $this->getLastEmail();
|
$lastEmail = $this->getLastEmail();
|
||||||
$this->assertEquals($email, $lastEmail['to'][0]['address']);
|
$this->assertEquals($email, $lastEmail['to'][0]['address']);
|
||||||
$this->assertEquals($this->getProject()['name'] . ' Login', $lastEmail['subject']);
|
$this->assertEquals($this->getProject()['name'] . ' Login', $lastEmail['subject']);
|
||||||
|
$this->assertStringNotContainsStringIgnoringCase('security phrase', $lastEmail['text']);
|
||||||
|
|
||||||
$token = substr($lastEmail['text'], strpos($lastEmail['text'], '&secret=', 0) + 8, 64);
|
$token = substr($lastEmail['text'], strpos($lastEmail['text'], '&secret=', 0) + 8, 64);
|
||||||
|
|
||||||
|
@ -1412,6 +1414,23 @@ trait AccountBase
|
||||||
|
|
||||||
$this->assertEquals(400, $response['headers']['status-code']);
|
$this->assertEquals(400, $response['headers']['status-code']);
|
||||||
|
|
||||||
|
$response = $this->client->call(Client::METHOD_POST, '/account/sessions/magic-url', array_merge([
|
||||||
|
'origin' => 'http://localhost',
|
||||||
|
'content-type' => 'application/json',
|
||||||
|
'x-appwrite-project' => $this->getProject()['$id'],
|
||||||
|
]), [
|
||||||
|
'userId' => ID::unique(),
|
||||||
|
'email' => $email,
|
||||||
|
'securityPhrase' => true
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertEquals(201, $response['headers']['status-code']);
|
||||||
|
$this->assertNotEmpty($response['body']['$id']);
|
||||||
|
$this->assertNotEmpty($response['body']['securityPhrase']);
|
||||||
|
|
||||||
|
$lastEmail = $this->getLastEmail();
|
||||||
|
$this->assertStringContainsStringIgnoringCase($response['body']['securityPhrase'], $lastEmail['text']);
|
||||||
|
|
||||||
$data['token'] = $token;
|
$data['token'] = $token;
|
||||||
$data['id'] = $userId;
|
$data['id'] = $userId;
|
||||||
$data['email'] = $email;
|
$data['email'] = $email;
|
||||||
|
|
Loading…
Reference in a new issue