Compare commits

...
Author SHA1 Message Date
Matej Bačo 2889f00192 Use new console 2023-12-07 07:57:36 +01:00
Khushboo Verma 3436924c09 Removed empty properties from email template 2023-12-07 00:16:03 +01:00
Khushboo Verma 43d6515f04 Merge branch 'feat-1.5-auth' of github.com:appwrite/appwrite into feat-1.5-auth 2023-12-07 00:08:00 +01:00
Khushboo Verma c2c1163501 Fix endpoint 2023-12-07 00:07:40 +01:00
Matej Bačo 5709d0e457 Fix lengthy sessions 2023-12-06 23:57:58 +01:00
Matej Bačo f60e844ce3 Fix bugs 2023-12-06 23:56:48 +01:00
Khushboo Verma f7b3d4dfa8 Add notify to phone and oauth 2023-12-06 23:55:31 +01:00
Khushboo Verma 57035f35f8 Updated the arrays 2023-12-06 23:36:15 +01:00
Khushboo Verma 31cc6a4ca3 Updated array 2023-12-06 23:19:35 +01:00
Khushboo Verma 9c4d329ceb Fix the order 2023-12-06 23:00:05 +01:00
Khushboo Verma 998642a875 Merge branch 'feat-1.5-auth' of github.com:appwrite/appwrite into feat-1.5-auth 2023-12-06 22:38:59 +01:00
Khushboo Verma d7041c0efd WIP: Add security phrase to magic URL 2023-12-06 22:38:38 +01:00
Matej Bačo ea5de9bde3 Fix notify feature 2023-12-06 18:40:18 +01:00
Khushboo Verma 7abafbb09c Alert on new session creation 2023-12-06 17:35:09 +01:00
Khushboo Verma ea2302238d Alert on new session creation 2023-12-06 17:30:37 +01:00
Matej Bačo a2df1932cd Merge branch 'feat-1.5-auth' of https://github.com/appwrite/appwrite into feat-1.5-auth 2023-12-06 12:52:18 +01:00
Matej Bačo 5dab541d76 Auth features 2023-12-06 12:51:34 +01:00
Khushboo Verma 22b1002305 Add checks for 100k, 1m, 10m password dictionary 2023-12-06 12:02:38 +01:00
Khushboo Verma bb1357f178 Resolved merge conflicts 2023-12-05 14:09:20 +01:00
Khushboo Verma 14afed5eb2 Password dictionary WIP 2023-12-05 14:07:17 +01:00
Matej Bačo 50d6dcc745 Add auth features 2023-12-05 13:57:28 +01:00
Matej Bačo 6459abeea2 Add ai password 2023-12-05 12:58:36 +01:00
Khushboo Verma d822878fe6 Update projects endpoint 2023-12-05 12:57:26 +01:00
Khushboo VermaandGitHub 34d3e4fbfd Add files via upload 2023-12-05 11:59:42 +01:00
Matej BačoandGitHub a989b181fd Merge pull request #7239 from appwrite/feat-session-extension 2023-12-05 11:56:55 +01:00
Khushboo Verma c64798d1c3 Logout from all sessions except current 2023-12-05 11:47:39 +01:00
Matej Bačo d85cb572b0 Implement session extension 2023-12-04 17:13:14 +01:00
29 changed files with 1101026 additions and 159 deletions
+3 -1
View File
@@ -98,7 +98,9 @@ _APP_VCS_GITHUB_CLIENT_SECRET=
_APP_VCS_GITHUB_WEBHOOK_SECRET=
_APP_MIGRATIONS_FIREBASE_CLIENT_ID=
_APP_MIGRATIONS_FIREBASE_CLIENT_SECRET=
_APP_ASSISTANT_OPENAI_API_KEY=
_APP_ASSISTANT_OPENAI_API_KEY=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
_APP_MESSAGE_SMS_TEST_DSN=
_APP_MESSAGE_EMAIL_TEST_DSN=
_APP_MESSAGE_PUSH_TEST_DSN=
_APP_CONSOLE_SUPABASE_APP_ID=d64cd75f-2786-4b94-aeda-eae01e165941
_APP_CONSOLE_SUPABASE_SECRET=sba_d5a802fbdcdf86d27f7d071a2778bff0419040de
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+11
View File
@@ -681,6 +681,17 @@ $commonCollections = [
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('expire'),
'type' => Database::VAR_DATETIME,
'format' => '',
'size' => 0,
'signed' => false,
'required' => false,
'default' => null,
'array' => false,
'filters' => ['datetime'],
],
],
'indexes' => [
[
+10
View File
@@ -200,6 +200,16 @@ return [
'description' => 'The password you are trying to use contains references to your name, email, phone or userID. For your security, please choose a different password and try again.',
'code' => 400,
],
Exception::USER_PASSWORD_DICTIONARY => [
'name' => Exception::USER_PASSWORD_DICTIONARY,
'description' => 'Password must be at least 8 characters and should not be one of the commonly used password.',
'code' => 400,
],
Exception::USER_PASSWORD_AI => [
'name' => Exception::USER_PASSWORD_AI,
'description' => 'As per AI, your password is not strong enough. Please choose a different password and try again.',
'code' => 400,
],
Exception::USER_SESSION_NOT_FOUND => [
'name' => Exception::USER_SESSION_NOT_FOUND,
'description' => 'The current user session could not be found.',
@@ -6,6 +6,10 @@
<a href="{{redirect}}" target="_blank">{{redirect}}</a>
<b>{{code}}</b>
<i>{{securityPhrase}}</i>
<p>{{footer}}</p>
<br>
+7
View File
@@ -12,9 +12,16 @@
"emails.magicSession.subject": "Login",
"emails.magicSession.hello": "Hey,",
"emails.magicSession.body": "Follow this link to login.",
"emails.magicSession.codeBody": "Enter this code to login.",
"emails.magicSession.footer": "If you didnt ask to login using this email, you can ignore this message.",
"emails.magicSession.thanks": "Thanks",
"emails.magicSession.signature": "{{project}} team",
"emails.authNotify.subject": "New session alert for {{project}}",
"emails.authNotify.hello": "Hey {{user}},",
"emails.authNotify.body": "We're writing to inform you that a new session has been initiated on your {{project}} account on {{date}} at {{time}}. <br><hr><br>Here are the details: <br>Device: {{device}}<br>Location: {{country}}<br>IP Address: {{ip}} <br><hr><br>If you did not initiate this session, we strongly recommend that you review your account settings.",
"emails.authNotify.footer": "If you recognize this activity, no further action is required.",
"emails.authNotify.thanks": "Thanks",
"emails.authNotify.signature": "{{project}} team",
"emails.recovery.subject": "Password Reset",
"emails.recovery.hello": "Hello {{user}}",
"emails.recovery.body": "Follow this link to reset your {{project}} password.",
+10
View File
@@ -152,6 +152,16 @@ return [
'beta' => false,
'mock' => false,
],
'supabase' => [
'name' => 'Supabase',
'developers' => 'https://supabase.com/docs',
'icon' => 'icon-supabase',
'enabled' => true,
'sandbox' => false,
'form' => false,
'beta' => false,
'mock' => false,
],
'gitlab' => [
'name' => 'GitLab',
'developers' => 'https://docs.gitlab.com/ee/api/',
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+406 -119
View File
@@ -4,6 +4,7 @@ use Ahc\Jwt\JWT;
use Appwrite\Auth\Auth;
use Appwrite\Auth\OAuth2\Exception as OAuth2Exception;
use Appwrite\Auth\Validator\Password;
use Appwrite\Auth\Validator\PasswordAi;
use Appwrite\Auth\Validator\Phone;
use Appwrite\Detector\Detector;
use Appwrite\Event\Event;
@@ -45,6 +46,7 @@ use Appwrite\Auth\Validator\PasswordHistory;
use Appwrite\Auth\Validator\PasswordDictionary;
use Appwrite\Auth\Validator\PersonalData;
use Appwrite\Event\Messaging;
use Utopia\Validator\Boolean;
$oauthDefaultSuccess = '/auth/oauth2/success';
$oauthDefaultFailure = '/auth/oauth2/failure';
@@ -69,7 +71,7 @@ App::post('/v1/account')
->label('abuse-limit', 10)
->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('password', '', fn ($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, $project->getAttribute('auths', [])['passwordDictionary'] ?? false), 'New user password. Must be at least 8 chars.', false, ['project', 'passwordsDictionary'])
->param('password', '', fn ($project, $passwordsDictionary10k) => new PasswordDictionary($passwordsDictionary10k, $project->getAttribute('auths', [])['passwordDictionary'] ?? false), 'New user password. Must be at least 8 chars.', false, ['project', 'passwordsDictionary10k'])
->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true)
->inject('request')
->inject('response')
@@ -77,7 +79,11 @@ App::post('/v1/account')
->inject('project')
->inject('dbForProject')
->inject('queueForEvents')
->action(function (string $userId, string $email, string $password, string $name, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Event $queueForEvents) {
->inject('passwordsDictionary10k')
->inject('passwordsDictionary100k')
->inject('passwordsDictionary1M')
->inject('passwordsDictionary10M')
->action(function (string $userId, string $email, string $password, string $name, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Event $queueForEvents, array $passwordsDictionary10k, array $passwordsDictionary100k, array $passwordsDictionary1M, array $passwordsDictionary10M) {
$email = \strtolower($email);
if ('console' === $project->getId()) {
@@ -118,6 +124,32 @@ App::post('/v1/account')
}
}
if ($project->getAttribute('auths', [])['passwordDictionary'] ?? false) {
$passwordDictionaryLength = $project->getAttribute('auths', [])['passwordDictionaryLength'] ?? '10k';
if ($passwordDictionaryLength == '10k') {
$passwordDictionary = $passwordsDictionary10k;
} elseif ($passwordDictionaryLength == '100k') {
$passwordDictionary = $passwordsDictionary100k;
} elseif ($passwordDictionaryLength == '1m') {
$passwordDictionary = $passwordsDictionary1M;
} elseif ($passwordDictionaryLength == '10m') {
$passwordDictionary = $passwordsDictionary10M;
} else {
throw new Exception('Password dictionary length is not supported');
}
$passwordDictionaryValidator = new PasswordDictionary($passwordDictionary, true);
if (!$passwordDictionaryValidator->isValid($password)) {
throw new Exception(Exception::USER_PASSWORD_DICTIONARY);
}
}
if ($project->getAttribute('auths', [])['passwordAi'] ?? false) {
$passwordAiValidator = new PasswordAi();
if (!$passwordAiValidator->isValid($password)) {
throw new Exception(Exception::USER_PASSWORD_AI);
}
}
$passwordHistory = $project->getAttribute('auths', [])['passwordHistory'] ?? 0;
$password = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS);
try {
@@ -148,9 +180,9 @@ App::post('/v1/account')
'accessedAt' => DateTime::now(),
]);
$user->removeAttribute('$internalId');
$user = Authorization::skip(fn() => $dbForProject->createDocument('users', $user));
$user = Authorization::skip(fn () => $dbForProject->createDocument('users', $user));
try {
$target = Authorization::skip(fn() => $dbForProject->createDocument('targets', new Document([
$target = Authorization::skip(fn () => $dbForProject->createDocument('targets', new Document([
'userId' => $user->getId(),
'userInternalId' => $user->getInternalId(),
'providerType' => MESSAGE_TYPE_EMAIL,
@@ -210,7 +242,8 @@ App::post('/v1/account/sessions/email')
->inject('locale')
->inject('geodb')
->inject('queueForEvents')
->action(function (string $email, string $password, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents) {
->inject('queueForMails')
->action(function (string $email, string $password, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails) {
$email = \strtolower($email);
$protocol = $request->getProtocol();
@@ -246,6 +279,7 @@ App::post('/v1/account/sessions/email')
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
'expire' => $expire
],
$detector->getOS(),
$detector->getClient(),
@@ -274,28 +308,96 @@ App::post('/v1/account/sessions/email')
if (!Config::getParam('domainVerification')) {
$response
->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)]))
;
->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)]));
}
if ($project->getAttribute('auths', [])['notify'] ?? false && !empty($user->getAttribute('email', ''))) {
$body = $locale->getText("emails.authNotify.body");
$subject = $locale->getText("emails.authNotify.subject");
$message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-inner-base.tpl');
$message
->setParam('{{body}}', $body)
->setParam('{{hello}}', $locale->getText("emails.authNotify.hello"))
->setParam('{{footer}}', $locale->getText("emails.authNotify.footer"))
->setParam('{{thanks}}', $locale->getText("emails.authNotify.thanks"))
->setParam('{{signature}}', $locale->getText("emails.authNotify.signature"));
$body = $message->render();
$smtp = $project->getAttribute('smtp', []);
$smtpEnabled = $smtp['enabled'] ?? false;
$senderEmail = App::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
$senderName = App::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
$replyTo = "";
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
$senderEmail = $smtp['senderEmail'];
}
if (!empty($smtp['senderName'])) {
$senderName = $smtp['senderName'];
}
if (!empty($smtp['replyTo'])) {
$replyTo = $smtp['replyTo'];
}
$queueForMails
->setSmtpHost($smtp['host'] ?? '')
->setSmtpPort($smtp['port'] ?? '')
->setSmtpUsername($smtp['username'] ?? '')
->setSmtpPassword($smtp['password'] ?? '')
->setSmtpSecure($smtp['secure'] ?? '');
$queueForMails
->setSmtpReplyTo($replyTo)
->setSmtpSenderEmail($senderEmail)
->setSmtpSenderName($senderName);
}
$device = $detector->getDevice();
$sessionCreatedAt = $session->getCreatedAt();
$dateTime = new DateTimeImmutable($sessionCreatedAt);
$date = $dateTime->format('Y-m-d');
$time = $dateTime->format('H:i:s');
$emailVariables = [
'direction' => $locale->getText('settings.direction'),
'user' => $user->getAttribute('name'),
'project' => $project->getAttribute('name'),
'date' => $date,
'time' => $time,
'device' => $device['deviceBrand'] . " " . $device['deviceModel'] . " " . $device['deviceName'],
'country' => $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')),
'ip' => $request->getIP(),
'redirect' => '',
'code' => '',
'securityPhrase' => '',
];
$queueForMails
->setSubject($subject)
->setBody($body)
->setVariables($emailVariables)
->setRecipient($user->getAttribute('email'))
->trigger();
}
$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'))
->setStatusCode(Response::STATUS_CODE_CREATED)
;
->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)
->setAttribute('expire', $expire)
;
->setAttribute('countryName', $countryName);
$queueForEvents
->setParam('userId', $user->getId())
->setParam('sessionId', $session->getId())
;
->setParam('sessionId', $session->getId());
$response->dynamic($session, Response::MODEL_SESSION);
});
@@ -314,9 +416,9 @@ App::get('/v1/account/sessions/oauth2/:provider')
->label('sdk.methodType', 'webAuth')
->label('abuse-limit', 50)
->label('abuse-key', 'ip:{ip}')
->param('provider', '', new WhiteList(\array_keys(Config::getParam('oAuthProviders')), true), 'OAuth2 Provider. Currently, supported providers are: ' . \implode(', ', \array_keys(\array_filter(Config::getParam('oAuthProviders'), fn($node) => (!$node['mock'])))) . '.')
->param('success', '', fn($clients) => new Host($clients), 'URL to redirect back to your app after a successful login attempt. Only URLs from hostnames in your project\'s 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('failure', '', fn($clients) => new Host($clients), 'URL to redirect back to your app after a failed login attempt. Only URLs from hostnames in your project\'s 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('provider', '', new WhiteList(\array_keys(Config::getParam('oAuthProviders')), true), 'OAuth2 Provider. Currently, supported providers are: ' . \implode(', ', \array_keys(\array_filter(Config::getParam('oAuthProviders'), fn ($node) => (!$node['mock'])))) . '.')
->param('success', '', fn ($clients) => new Host($clients), 'URL to redirect back to your app after a successful login attempt. Only URLs from hostnames in your project\'s 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('failure', '', fn ($clients) => new Host($clients), 'URL to redirect back to your app after a failed login attempt. Only URLs from hostnames in your project\'s 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('scopes', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'A list of custom OAuth2 scopes. Check each provider internal docs for a list of supported scopes. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true)
->inject('request')
->inject('response')
@@ -457,7 +559,9 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
->inject('dbForProject')
->inject('geodb')
->inject('queueForEvents')
->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Document $user, Database $dbForProject, Reader $geodb, Event $queueForEvents) use ($oauthDefaultSuccess) {
->inject('queueForMails')
->inject('locale')
->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Document $user, Database $dbForProject, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Locale $locale) use ($oauthDefaultSuccess) {
$protocol = $request->getProtocol();
$callback = $protocol . '://' . $request->getHostname() . '/v1/account/sessions/oauth2/callback/' . $provider . '/' . $project->getId();
@@ -671,7 +775,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
'accessedAt' => DateTime::now(),
]);
$user->removeAttribute('$internalId');
$userDoc = Authorization::skip(fn() => $dbForProject->createDocument('users', $user));
$userDoc = Authorization::skip(fn () => $dbForProject->createDocument('users', $user));
$dbForProject->createDocument('targets', new Document([
'$permissions' => [
Permission::read(Role::any()),
@@ -757,6 +861,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
'expire' => $expire
], $detector->getOS(), $detector->getClient(), $detector->getDevice()));
if (empty($user->getAttribute('email'))) {
@@ -768,8 +873,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
}
$user
->setAttribute('status', true)
;
->setAttribute('status', true);
Authorization::setRole(Role::user($user->getId())->toString());
@@ -783,13 +887,10 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
$dbForProject->deleteCachedDocument('users', $user->getId());
$session->setAttribute('expire', $expire);
$queueForEvents
->setParam('userId', $user->getId())
->setParam('sessionId', $session->getId())
->setPayload($response->output($session, Response::MODEL_SESSION))
;
->setPayload($response->output($session, Response::MODEL_SESSION));
if (!Config::getParam('domainVerification')) {
$response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)]));
@@ -807,13 +908,85 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
$state['success'] = URLParser::unparse($state['success']);
}
if ($project->getAttribute('auths', [])['notify'] ?? false && !empty($user->getAttribute('email', ''))) {
$body = $locale->getText("emails.authNotify.body");
$subject = $locale->getText("emails.authNotify.subject");
$message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-inner-base.tpl');
$message
->setParam('{{body}}', $body)
->setParam('{{hello}}', $locale->getText("emails.authNotify.hello"))
->setParam('{{footer}}', $locale->getText("emails.authNotify.footer"))
->setParam('{{thanks}}', $locale->getText("emails.authNotify.thanks"))
->setParam('{{signature}}', $locale->getText("emails.authNotify.signature"));
$body = $message->render();
$smtp = $project->getAttribute('smtp', []);
$smtpEnabled = $smtp['enabled'] ?? false;
$senderEmail = App::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
$senderName = App::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
$replyTo = "";
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
$senderEmail = $smtp['senderEmail'];
}
if (!empty($smtp['senderName'])) {
$senderName = $smtp['senderName'];
}
if (!empty($smtp['replyTo'])) {
$replyTo = $smtp['replyTo'];
}
$queueForMails
->setSmtpHost($smtp['host'] ?? '')
->setSmtpPort($smtp['port'] ?? '')
->setSmtpUsername($smtp['username'] ?? '')
->setSmtpPassword($smtp['password'] ?? '')
->setSmtpSecure($smtp['secure'] ?? '');
$queueForMails
->setSmtpReplyTo($replyTo)
->setSmtpSenderEmail($senderEmail)
->setSmtpSenderName($senderName);
}
$device = $detector->getDevice();
$sessionCreatedAt = $session->getCreatedAt();
$dateTime = new DateTimeImmutable($sessionCreatedAt);
$date = $dateTime->format('Y-m-d');
$time = $dateTime->format('H:i:s');
$emailVariables = [
'direction' => $locale->getText('settings.direction'),
'user' => $user->getAttribute('name'),
'project' => $project->getAttribute('name'),
'date' => $date,
'time' => $time,
'device' => $device['deviceBrand'] . " " . $device['deviceModel'] . " " . $device['deviceName'],
'country' => $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')),
'ip' => $request->getIP(),
'redirect' => '',
'code' => '',
'securityPhrase' => '',
];
$queueForMails
->setSubject($subject)
->setBody($body)
->setVariables($emailVariables)
->setRecipient($user->getAttribute('email'))
->trigger();
}
$response
->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
->addHeader('Pragma', 'no-cache')
->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'))
->redirect($state['success'])
;
->redirect($state['success']);
});
App::get('/v1/account/identities')
@@ -918,6 +1091,8 @@ 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('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('type', 'link', new WhiteList(['link', 'code']), 'The type of verification email to be sent. ', true)
->param('securityPhrase', false, new Boolean(), 'Security phrase to verify that the user is the one who initiated the action. This phrase will be sent to the user email address.', true)
->inject('request')
->inject('response')
->inject('user')
@@ -926,7 +1101,7 @@ App::post('/v1/account/sessions/magic-url')
->inject('locale')
->inject('queueForEvents')
->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, string $type, string $securityPhrase, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails) {
if (empty(App::getEnv('_APP_SMTP_HOST'))) {
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP disabled');
@@ -991,6 +1166,21 @@ App::post('/v1/account/sessions/magic-url')
$loginSecret = Auth::tokenGenerator();
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_CONFIRM));
if ($type === 'code') {
$loginSecret = Auth::codeGenerator();
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_PHONE));
} else if($type === 'link') {
$loginSecret = Auth::tokenGenerator();
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_CONFIRM));
}
if ($securityPhrase) {
$wordArray1 = array("Abundant", "Adaptable", "Adventurous", "Affectionate", "Agile", "Amiable", "Amazing", "Ambitious", "Amicable", "Amusing", "Astonishing", "Attentive", "Authentic", "Awesome", "Balanced", "Beautiful", "Bold", "Brave", "Bright", "Bubbly", "Calm", "Capable", "Charismatic", "Charming", "Cheerful", "Clever", "Colorful", "Compassionate", "Confident", "Cooperative", "Courageous", "Courteous", "Creative", "Curious", "Dazzling", "Dedicated", "Delightful", "Determined", "Diligent", "Dynamic", "Easygoing", "Effervescent", "Efficient", "Elegant", "Empathetic", "Energetic", "Enthusiastic", "Exuberant", "Faithful", "Fantastic", "Fearless", "Flexible", "Friendly", "Fun-loving", "Generous", "Gentle", "Genuine", "Graceful", "Gracious", "Happy", "Hardworking", "Harmonious", "Helpful", "Honest", "Hopeful", "Humble", "Imaginative", "Impressive", "Incredible", "Inspiring", "Intelligent", "Joyful", "Kind", "Knowledgeable", "Lively", "Lovable", "Lovely", "Loyal", "Majestic", "Magnificent", "Mindful", "Modest", "Passionate", "Patient", "Peaceful", "Perseverant", "Playful", "Polite", "Positive", "Powerful", "Practical", "Precious", "Proactive", "Productive", "Punctual", "Quick-witted", "Radiant", "Reliable", "Resilient", "Resourceful", "Respectful", "Responsible", "Sensitive", "Serene", "Sincere", "Skillful", "Soothing", "Spirited", "Splendid", "Steadfast", "Strong", "Supportive", "Sweet", "Talented", "Thankful", "Thoughtful", "Thriving", "Tranquil", "Trustworthy", "Upbeat", "Versatile", "Vibrant", "Vigilant", "Warmhearted", "Welcoming", "Wholesome", "Witty", "Wonderful", "Zealous");
$wordArray2 = array("apple", "banana", "cat", "dog", "elephant", "fish", "guitar", "hat", "ice cream", "jacket", "kangaroo", "lemon", "moon", "notebook", "orange", "piano", "quilt", "rabbit", "sun", "tree", "umbrella", "violin", "watermelon", "xylophone", "yogurt", "zebra", "airplane", "ball", "cloud", "diamond", "eagle", "fire", "giraffe", "hammer", "island", "jellyfish", "kiwi", "lamp", "mango", "needle", "ocean", "pear", "quasar", "rose", "star", "turtle", "unicorn", "volcano", "whale", "xylograph", "yarn", "zephyr", "ant", "book", "candle", "door", "envelope", "feather", "globe", "harp", "insect", "jar", "kite", "lighthouse", "magnet", "necklace", "owl", "puzzle", "queen", "rainbow", "sailboat", "telescope", "umbrella", "vase", "wallet", "xylograph", "yacht", "zeppelin", "accordion", "brush", "chocolate", "dolphin", "easel", "fountain", "globe", "hairbrush", "iceberg", "jigsaw", "kettle", "leopard", "marble", "nutmeg", "obstacle", "penguin", "quiver", "raccoon", "sphinx", "trampoline", "utensil", "velvet", "wagon", "xerox", "yodel", "zipper");
$securityPhraseString = $wordArray1[array_rand($wordArray1)] . " " . $wordArray2[array_rand($wordArray2)];
$securityPhraseMessage = "<br><hr><br>We have received a login attempt with the following phrase:<br><b>" . $securityPhraseString . "</b><hr>";
}
$token = new Document([
'$id' => ID::unique(),
'userId' => $user->getId(),
@@ -1021,7 +1211,7 @@ App::post('/v1/account/sessions/magic-url')
$url['query'] = Template::mergeQuery(((isset($url['query'])) ? $url['query'] : ''), ['userId' => $user->getId(), 'secret' => $loginSecret, 'expire' => $expire, 'project' => $project->getId()]);
$url = Template::unParseURL($url);
$body = $locale->getText("emails.magicSession.body");
$body = $type === 'code' ? $locale->getText("emails.magicSession.codeBody") : $locale->getText("emails.magicSession.body");
$subject = $locale->getText("emails.magicSession.subject");
$customTemplate = $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? [];
@@ -1086,7 +1276,9 @@ App::post('/v1/account/sessions/magic-url')
'user' => '',
'team' => '',
'project' => $project->getAttribute('name'),
'redirect' => $url
'redirect' => $type === 'link' ? $url : '',
'code' => $type === 'code' ? $loginSecret : '',
'securityPhrase' => $securityPhrase ? $securityPhraseMessage : '',
];
$queueForMails
@@ -1105,11 +1297,11 @@ App::post('/v1/account/sessions/magic-url')
// Hide secret for clients
$token->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? $loginSecret : '');
$token->setAttribute('securityPhrase', $securityPhrase ? $securityPhraseString : '');
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($token, Response::MODEL_TOKEN)
;
->dynamic($token, Response::MODEL_TOKEN);
});
App::put('/v1/account/sessions/magic-url')
@@ -1145,7 +1337,7 @@ App::put('/v1/account/sessions/magic-url')
/** @var Utopia\Database\Document $user */
$userFromRequest = Authorization::skip(fn() => $dbForProject->getDocument('users', $userId));
$userFromRequest = Authorization::skip(fn () => $dbForProject->getDocument('users', $userId));
if ($userFromRequest->isEmpty()) {
throw new Exception(Exception::USER_NOT_FOUND);
@@ -1175,6 +1367,7 @@ App::put('/v1/account/sessions/magic-url')
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
'expire' => $expire
],
$detector->getOS(),
$detector->getClient(),
@@ -1228,8 +1421,7 @@ App::put('/v1/account/sessions/magic-url')
$session
->setAttribute('current', true)
->setAttribute('countryName', $countryName)
->setAttribute('expire', $expire);
->setAttribute('countryName', $countryName);
$response->dynamic($session, Response::MODEL_SESSION);
});
@@ -1312,7 +1504,7 @@ App::post('/v1/account/sessions/phone')
$user->removeAttribute('$internalId');
Authorization::skip(fn () => $dbForProject->createDocument('users', $user));
try {
$target = Authorization::skip(fn() => $dbForProject->createDocument('targets', new Document([
$target = Authorization::skip(fn () => $dbForProject->createDocument('targets', new Document([
'userId' => $user->getId(),
'userInternalId' => $user->getInternalId(),
'providerType' => MESSAGE_TYPE_SMS,
@@ -1390,8 +1582,7 @@ App::post('/v1/account/sessions/phone')
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($token, Response::MODEL_TOKEN)
;
->dynamic($token, Response::MODEL_TOKEN);
});
App::put('/v1/account/sessions/phone')
@@ -1418,9 +1609,10 @@ App::put('/v1/account/sessions/phone')
->inject('locale')
->inject('geodb')
->inject('queueForEvents')
->action(function (string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents) {
->inject('queueForMails')
->action(function (string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails) {
$userFromRequest = Authorization::skip(fn() => $dbForProject->getDocument('users', $userId));
$userFromRequest = Authorization::skip(fn () => $dbForProject->getDocument('users', $userId));
if ($userFromRequest->isEmpty()) {
throw new Exception(Exception::USER_NOT_FOUND);
@@ -1450,6 +1642,7 @@ App::put('/v1/account/sessions/phone')
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
'expire' => $expire
],
$detector->getOS(),
$detector->getClient(),
@@ -1484,8 +1677,7 @@ App::put('/v1/account/sessions/phone')
$queueForEvents
->setParam('userId', $user->getId())
->setParam('sessionId', $session->getId())
;
->setParam('sessionId', $session->getId());
if (!Config::getParam('domainVerification')) {
$response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)]));
@@ -1496,16 +1688,86 @@ App::put('/v1/account/sessions/phone')
$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'))
->setStatusCode(Response::STATUS_CODE_CREATED)
;
->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)
->setAttribute('expire', $expire)
;
->setAttribute('countryName', $countryName);
if ($project->getAttribute('auths', [])['notify'] ?? false && !empty($user->getAttribute('email', ''))) {
$body = $locale->getText("emails.authNotify.body");
$subject = $locale->getText("emails.authNotify.subject");
$message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-inner-base.tpl');
$message
->setParam('{{body}}', $body)
->setParam('{{hello}}', $locale->getText("emails.authNotify.hello"))
->setParam('{{footer}}', $locale->getText("emails.authNotify.footer"))
->setParam('{{thanks}}', $locale->getText("emails.authNotify.thanks"))
->setParam('{{signature}}', $locale->getText("emails.authNotify.signature"));
$body = $message->render();
$smtp = $project->getAttribute('smtp', []);
$smtpEnabled = $smtp['enabled'] ?? false;
$senderEmail = App::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM);
$senderName = App::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
$replyTo = "";
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
$senderEmail = $smtp['senderEmail'];
}
if (!empty($smtp['senderName'])) {
$senderName = $smtp['senderName'];
}
if (!empty($smtp['replyTo'])) {
$replyTo = $smtp['replyTo'];
}
$queueForMails
->setSmtpHost($smtp['host'] ?? '')
->setSmtpPort($smtp['port'] ?? '')
->setSmtpUsername($smtp['username'] ?? '')
->setSmtpPassword($smtp['password'] ?? '')
->setSmtpSecure($smtp['secure'] ?? '');
$queueForMails
->setSmtpReplyTo($replyTo)
->setSmtpSenderEmail($senderEmail)
->setSmtpSenderName($senderName);
}
$device = $detector->getDevice();
$sessionCreatedAt = $session->getCreatedAt();
$dateTime = new DateTimeImmutable($sessionCreatedAt);
$date = $dateTime->format('Y-m-d');
$time = $dateTime->format('H:i:s');
$emailVariables = [
'direction' => $locale->getText('settings.direction'),
'user' => $user->getAttribute('name'),
'project' => $project->getAttribute('name'),
'date' => $date,
'time' => $time,
'device' => $device['deviceBrand'] . " " . $device['deviceModel'] . " " . $device['deviceName'],
'country' => $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')),
'ip' => $request->getIP(),
'redirect' => '',
'code' => '',
'securityPhrase' => '',
];
$queueForMails
->setSubject($subject)
->setBody($body)
->setVariables($emailVariables)
->setRecipient($user->getAttribute('email'))
->trigger();
}
$response->dynamic($session, Response::MODEL_SESSION);
});
@@ -1586,7 +1848,7 @@ App::post('/v1/account/sessions/anonymous')
'accessedAt' => DateTime::now(),
]);
$user->removeAttribute('$internalId');
Authorization::skip(fn() => $dbForProject->createDocument('users', $user));
Authorization::skip(fn () => $dbForProject->createDocument('users', $user));
// Create session token
$duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
@@ -1605,6 +1867,7 @@ App::post('/v1/account/sessions/anonymous')
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
'expire' => $expire
],
$detector->getOS(),
$detector->getClient(),
@@ -1613,18 +1876,17 @@ App::post('/v1/account/sessions/anonymous')
Authorization::setRole(Role::user($user->getId())->toString());
$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())),
]));
$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())),
]));
$dbForProject->deleteCachedDocument('users', $user->getId());
$queueForEvents
->setParam('userId', $user->getId())
->setParam('sessionId', $session->getId())
;
->setParam('sessionId', $session->getId());
if (!Config::getParam('domainVerification')) {
$response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)]));
@@ -1633,16 +1895,13 @@ App::post('/v1/account/sessions/anonymous')
$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'))
->setStatusCode(Response::STATUS_CODE_CREATED)
;
->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)
->setAttribute('expire', $expire)
;
->setAttribute('countryName', $countryName);
$response->dynamic($session, Response::MODEL_SESSION);
});
@@ -1670,7 +1929,8 @@ App::post('/v1/account/jwt')
$sessions = $user->getAttribute('sessions', []);
$current = new Document();
foreach ($sessions as $session) { /** @var Utopia\Database\Document $session */
foreach ($sessions as $session) {
/** @var Utopia\Database\Document $session */
if ($session->getAttribute('secret') == Auth::hash(Auth::$secret)) { // If current session delete the cookies too
$current = $session;
}
@@ -1685,13 +1945,13 @@ App::post('/v1/account/jwt')
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic(new Document(['jwt' => $jwt->encode([
// 'uid' => 1,
// 'aud' => 'http://site.com',
// 'scopes' => ['user'],
// 'iss' => 'http://api.mysite.com',
'userId' => $user->getId(),
'sessionId' => $current->getId(),
])]), Response::MODEL_JWT);
// 'uid' => 1,
// 'aud' => 'http://site.com',
// 'scopes' => ['user'],
// 'iss' => 'http://api.mysite.com',
'userId' => $user->getId(),
'sessionId' => $current->getId(),
])]), Response::MODEL_JWT);
});
App::post('/v1/account/targets/push')
@@ -1836,12 +2096,12 @@ App::get('/v1/account/sessions')
$authDuration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
$current = Auth::sessionVerify($sessions, Auth::$secret, $authDuration);
foreach ($sessions as $key => $session) {/** @var Document $session */
foreach ($sessions as $key => $session) {
/** @var Document $session */
$countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown'));
$session->setAttribute('countryName', $countryName);
$session->setAttribute('current', ($current == $session->getId()) ? true : false);
$session->setAttribute('expire', DateTime::formatTz(DateTime::addSeconds(new \DateTime($session->getCreatedAt()), $authDuration)));
$sessions[$key] = $session;
}
@@ -1941,15 +2201,14 @@ App::get('/v1/account/sessions/:sessionId')
? Auth::sessionVerify($user->getAttribute('sessions'), Auth::$secret, $authDuration)
: $sessionId;
foreach ($sessions as $session) {/** @var Document $session */
foreach ($sessions as $session) {
/** @var Document $session */
if ($sessionId == $session->getId()) {
$countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown'));
$session
->setAttribute('current', ($session->getAttribute('secret') == Auth::hash(Auth::$secret)))
->setAttribute('countryName', $countryName)
->setAttribute('expire', DateTime::formatTz(DateTime::addSeconds(new \DateTime($session->getCreatedAt()), $authDuration)))
;
->setAttribute('countryName', $countryName);
return $response->dynamic($session, Response::MODEL_SESSION);
}
@@ -2010,7 +2269,7 @@ App::patch('/v1/account/password')
->label('sdk.response.model', Response::MODEL_USER)
->label('sdk.offline.model', '/account')
->label('sdk.offline.key', 'current')
->param('password', '', fn ($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, $project->getAttribute('auths', [])['passwordDictionary'] ?? false), 'New user password. Must be at least 8 chars.', false, ['project', 'passwordsDictionary'])
->param('password', '', fn ($project, $passwordsDictionary10k) => new PasswordDictionary($passwordsDictionary10k, $project->getAttribute('auths', [])['passwordDictionary'] ?? false), 'New user password. Must be at least 8 chars.', false, ['project', 'passwordsDictionary10k'])
->param('oldPassword', '', new Password(), 'Current user password. Must be at least 8 chars.', true)
->inject('requestTimestamp')
->inject('response')
@@ -2018,7 +2277,11 @@ App::patch('/v1/account/password')
->inject('project')
->inject('dbForProject')
->inject('queueForEvents')
->action(function (string $password, string $oldPassword, ?\DateTime $requestTimestamp, Response $response, Document $user, Document $project, Database $dbForProject, Event $queueForEvents) {
->inject('passwordsDictionary10k')
->inject('passwordsDictionary100k')
->inject('passwordsDictionary1M')
->inject('passwordsDictionary10M')
->action(function (string $password, string $oldPassword, ?\DateTime $requestTimestamp, Response $response, Document $user, Document $project, Database $dbForProject, Event $queueForEvents, array $passwordsDictionary10k, array $passwordsDictionary100k, array $passwordsDictionary1M, array $passwordsDictionary10M) {
// Check old password only if its an existing user.
if (!empty($user->getAttribute('passwordUpdate')) && !Auth::passwordVerify($oldPassword, $user->getAttribute('password'), $user->getAttribute('hash'), $user->getAttribute('hashOptions'))) { // Double check user password
@@ -2045,6 +2308,32 @@ App::patch('/v1/account/password')
}
}
if ($project->getAttribute('auths', [])['passwordAi'] ?? false) {
$passwordAiValidator = new PasswordAi();
if (!$passwordAiValidator->isValid($password)) {
throw new Exception(Exception::USER_PASSWORD_AI);
}
}
if ($project->getAttribute('auths', [])['passwordDictionary'] ?? false) {
$passwordDictionaryLength = $project->getAttribute('auths', [])['passwordDictionaryLength'] ?? '10k';
if ($passwordDictionaryLength == '10k') {
$passwordDictionary = $passwordsDictionary10k;
} elseif ($passwordDictionaryLength == '100k') {
$passwordDictionary = $passwordsDictionary100k;
} elseif ($passwordDictionaryLength == '1m') {
$passwordDictionary = $passwordsDictionary1M;
} elseif ($passwordDictionaryLength == '10m') {
$passwordDictionary = $passwordsDictionary10M;
} else {
throw new Exception('Password dictionary length is not supported');
}
$passwordDictionaryValidator = new PasswordDictionary($passwordDictionary, true);
if (!$passwordDictionaryValidator->isValid($password)) {
throw new Exception(Exception::USER_PASSWORD_DICTIONARY);
}
}
$user
->setAttribute('password', $newPassword)
->setAttribute('passwordHistory', $history)
@@ -2297,8 +2586,7 @@ App::patch('/v1/account/status')
$protocol = $request->getProtocol();
$response
->addCookie(Auth::$cookieName . '_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
->addCookie(Auth::$cookieName, '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'))
;
->addCookie(Auth::$cookieName, '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'));
$response->dynamic($user, Response::MODEL_ACCOUNT);
});
@@ -2337,7 +2625,8 @@ App::delete('/v1/account/sessions/:sessionId')
$sessions = $user->getAttribute('sessions', []);
foreach ($sessions as $key => $session) {/** @var Document $session */
foreach ($sessions as $key => $session) {
/** @var Document $session */
if ($sessionId == $session->getId()) {
$dbForProject->withRequestTimestamp($requestTimestamp, function () use ($dbForProject, $session) {
return $dbForProject->deleteDocument('sessions', $session->getId());
@@ -2350,19 +2639,16 @@ App::delete('/v1/account/sessions/:sessionId')
if ($session->getAttribute('secret') == Auth::hash(Auth::$secret)) { // If current session delete the cookies too
$session
->setAttribute('current', true)
->setAttribute('countryName', $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')))
;
->setAttribute('countryName', $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')));
if (!Config::getParam('domainVerification')) {
$response
->addHeader('X-Fallback-Cookies', \json_encode([]))
;
->addHeader('X-Fallback-Cookies', \json_encode([]));
}
$response
->addCookie(Auth::$cookieName . '_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
->addCookie(Auth::$cookieName, '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'))
;
->addCookie(Auth::$cookieName, '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'));
}
$dbForProject->deleteCachedDocument('users', $user->getId());
@@ -2370,8 +2656,7 @@ App::delete('/v1/account/sessions/:sessionId')
$queueForEvents
->setParam('userId', $user->getId())
->setParam('sessionId', $session->getId())
->setPayload($response->output($session, Response::MODEL_SESSION))
;
->setPayload($response->output($session, Response::MODEL_SESSION));
return $response->noContent();
}
}
@@ -2412,7 +2697,8 @@ App::patch('/v1/account/sessions/:sessionId')
$sessions = $user->getAttribute('sessions', []);
foreach ($sessions as $key => $session) {/** @var Document $session */
foreach ($sessions as $key => $session) {
/** @var Document $session */
if ($sessionId == $session->getId()) {
// Comment below would skip re-generation if token is still valid
// We decided to not include this because developer can get expiration date from the session
@@ -2450,13 +2736,10 @@ App::patch('/v1/account/sessions/:sessionId')
$authDuration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
$session->setAttribute('expire', DateTime::formatTz(DateTime::addSeconds(new \DateTime($session->getCreatedAt()), $authDuration)));
$queueForEvents
->setParam('userId', $user->getId())
->setParam('sessionId', $session->getId())
->setPayload($response->output($session, Response::MODEL_SESSION))
;
->setParam('userId', $user->getId())
->setParam('sessionId', $session->getId())
->setPayload($response->output($session, Response::MODEL_SESSION));
return $response->dynamic($session, Response::MODEL_SESSION);
}
@@ -2480,18 +2763,28 @@ App::delete('/v1/account/sessions')
->label('sdk.response.code', Response::STATUS_CODE_NOCONTENT)
->label('sdk.response.model', Response::MODEL_NONE)
->label('abuse-limit', 100)
->param('current', true, new Boolean(), 'Specify whether the current session should be deleted too.', true)
->inject('request')
->inject('response')
->inject('user')
->inject('dbForProject')
->inject('locale')
->inject('queueForEvents')
->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Locale $locale, Event $queueForEvents) {
->inject('project')
->action(function (bool $current, Request $request, Response $response, Document $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Document $project) {
$protocol = $request->getProtocol();
$sessions = $user->getAttribute('sessions', []);
foreach ($sessions as $session) {/** @var Document $session */
$authDuration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
$currentSessionId = Auth::sessionVerify($user->getAttribute('sessions'), Auth::$secret, $authDuration);
foreach ($sessions as $session) {
/** @var Document $session */
if (!$current && $currentSessionId == $session->getId()) {
continue;
}
$dbForProject->deleteDocument('sessions', $session->getId());
if (!Config::getParam('domainVerification')) {
@@ -2500,14 +2793,12 @@ App::delete('/v1/account/sessions')
$session
->setAttribute('current', false)
->setAttribute('countryName', $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')))
;
->setAttribute('countryName', $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')));
if ($session->getAttribute('secret') == Auth::hash(Auth::$secret)) {
$session->setAttribute('current', true);
$session->setAttribute('expire', DateTime::addSeconds(new \DateTime($session->getCreatedAt()), Auth::TOKEN_EXPIRATION_LOGIN_LONG));
// If current session delete the cookies too
// If current session delete the cookies too
$response
->addCookie(Auth::$cookieName . '_legacy', '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
->addCookie(Auth::$cookieName, '', \time() - 3600, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'));
@@ -2546,6 +2837,7 @@ App::post('/v1/account/recovery')
->label('abuse-key', ['url:{url},email:{param-email}', 'ip:{ip}'])
->param('email', '', new Email(), 'User email.')
->param('url', '', fn ($clients) => new Host($clients), 'URL to redirect the user back to your app from the recovery email. 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.', false, ['clients'])
// TODO: Implement type (url/link)
->inject('request')
->inject('response')
->inject('user')
@@ -2693,8 +2985,7 @@ App::post('/v1/account/recovery')
->setPayload($response->output(
$recovery->setAttribute('secret', $secret),
Response::MODEL_TOKEN
))
;
));
// Hide secret for clients
$recovery->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? $secret : '');
@@ -2766,12 +3057,12 @@ App::put('/v1/account/recovery')
}
$profile = $dbForProject->updateDocument('users', $profile->getId(), $profile
->setAttribute('password', $newPassword)
->setAttribute('passwordHistory', $history)
->setAttribute('passwordUpdate', DateTime::now())
->setAttribute('hash', Auth::DEFAULT_ALGO)
->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS)
->setAttribute('emailVerification', true));
->setAttribute('password', $newPassword)
->setAttribute('passwordHistory', $history)
->setAttribute('passwordUpdate', DateTime::now())
->setAttribute('hash', Auth::DEFAULT_ALGO)
->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS)
->setAttribute('emailVerification', true));
$user->setAttributes($profile->getArrayCopy());
@@ -2786,8 +3077,7 @@ App::put('/v1/account/recovery')
$queueForEvents
->setParam('userId', $profile->getId())
->setParam('tokenId', $recoveryDocument->getId())
;
->setParam('tokenId', $recoveryDocument->getId());
$response->dynamic($recoveryDocument, Response::MODEL_TOKEN);
});
@@ -2809,7 +3099,7 @@ App::post('/v1/account/verification')
->label('sdk.response.model', Response::MODEL_TOKEN)
->label('abuse-limit', 10)
->label('abuse-key', 'url:{url},userId:{userId}')
->param('url', '', fn($clients) => new Host($clients), 'URL to redirect the user back to your app from the verification email. 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.', false, ['clients']) // TODO add built-in confirm page
->param('url', '', fn ($clients) => new Host($clients), 'URL to redirect the user back to your app from the verification email. 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.', false, ['clients']) // TODO add built-in confirm page
->inject('request')
->inject('response')
->inject('project')
@@ -2978,7 +3268,7 @@ App::put('/v1/account/verification')
->inject('queueForEvents')
->action(function (string $userId, string $secret, Response $response, Document $user, Database $dbForProject, Event $queueForEvents) {
$profile = Authorization::skip(fn() => $dbForProject->getDocument('users', $userId));
$profile = Authorization::skip(fn () => $dbForProject->getDocument('users', $userId));
if ($profile->isEmpty()) {
throw new Exception(Exception::USER_NOT_FOUND);
@@ -3008,8 +3298,7 @@ App::put('/v1/account/verification')
$queueForEvents
->setParam('userId', $userId)
->setParam('tokenId', $verificationDocument->getId())
;
->setParam('tokenId', $verificationDocument->getId());
$response->dynamic($verificationDocument, Response::MODEL_TOKEN);
});
@@ -3110,8 +3399,7 @@ App::post('/v1/account/verification/phone')
->setPayload($response->output(
$verification->setAttribute('secret', $secret),
Response::MODEL_TOKEN
))
;
));
// Hide secret for clients
$verification->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? $secret : '');
@@ -3146,7 +3434,7 @@ App::put('/v1/account/verification/phone')
->inject('queueForEvents')
->action(function (string $userId, string $secret, Response $response, Document $user, Database $dbForProject, Event $queueForEvents) {
$profile = Authorization::skip(fn() => $dbForProject->getDocument('users', $userId));
$profile = Authorization::skip(fn () => $dbForProject->getDocument('users', $userId));
if ($profile->isEmpty()) {
throw new Exception(Exception::USER_NOT_FOUND);
@@ -3174,8 +3462,7 @@ App::put('/v1/account/verification/phone')
$queueForEvents
->setParam('userId', $user->getId())
->setParam('tokenId', $verificationDocument->getId())
;
->setParam('tokenId', $verificationDocument->getId());
$response->dynamic($verificationDocument, Response::MODEL_TOKEN);
});
+72 -4
View File
@@ -85,7 +85,7 @@ App::post('/v1/projects')
}
$auth = Config::getParam('auth', []);
$auths = ['limit' => 0, 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT, 'passwordHistory' => 0, 'passwordDictionary' => false, 'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG, 'personalDataCheck' => false];
$auths = ['limit' => 0, 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT, 'passwordHistory' => 0, 'passwordAi' => false, 'renewal' => false, 'passwordDictionary' => false, 'passwordDictionaryLength' => '10k', 'notify' => false, 'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG, 'personalDataCheck' => false];
foreach ($auth as $index => $method) {
$auths[$method['key'] ?? ''] = true;
}
@@ -674,9 +674,10 @@ App::patch('/v1/projects/:projectId/auth/duration')
->label('sdk.response.model', Response::MODEL_PROJECT)
->param('projectId', '', new UID(), 'Project unique ID.')
->param('duration', 31536000, new Range(0, 31536000), 'Project session length in seconds. Max length: 31536000 seconds.')
->param('renewal', false, new Boolean(), 'Automatic session refresh. If enabled, sessions are automatically extended to session duration on every request.', true)
->inject('response')
->inject('dbForConsole')
->action(function (string $projectId, int $duration, Response $response, Database $dbForConsole) {
->action(function (string $projectId, int $duration, bool $renewal, Response $response, Database $dbForConsole) {
$project = $dbForConsole->getDocument('projects', $projectId);
@@ -686,6 +687,7 @@ App::patch('/v1/projects/:projectId/auth/duration')
$auths = $project->getAttribute('auths', []);
$auths['duration'] = $duration;
$auths['renewal'] = $renewal;
$dbForConsole->updateDocument('projects', $project->getId(), $project
->setAttribute('auths', $auths));
@@ -759,7 +761,7 @@ App::patch('/v1/projects/:projectId/auth/password-history')
});
App::patch('/v1/projects/:projectId/auth/password-dictionary')
->desc('Update authentication password dictionary status. Use this endpoint to enable or disable the dicitonary check for user password')
->desc('Update authentication password dictionary status. Use this endpoint to enable or disable the dictionary check for user password')
->groups(['api', 'projects'])
->label('scope', 'projects.write')
->label('sdk.auth', [APP_AUTH_TYPE_ADMIN])
@@ -770,6 +772,39 @@ App::patch('/v1/projects/:projectId/auth/password-dictionary')
->label('sdk.response.model', Response::MODEL_PROJECT)
->param('projectId', '', new UID(), 'Project unique ID.')
->param('enabled', false, new Boolean(false), 'Set whether or not to enable checking user\'s password against most commonly used passwords. Default is false.')
->param('length', '10k', new WhiteList(['10k', '100k', '1m', '10m'], true), 'Set the length of the password dictionary to use', true)
->inject('response')
->inject('dbForConsole')
->action(function (string $projectId, bool $enabled, string $length, Response $response, Database $dbForConsole) {
$project = $dbForConsole->getDocument('projects', $projectId);
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
$auths = $project->getAttribute('auths', []);
$auths['passwordDictionary'] = $enabled;
$auths['passwordDictionaryLength'] = $length;
$dbForConsole->updateDocument('projects', $project->getId(), $project
->setAttribute('auths', $auths));
$response->dynamic($project, Response::MODEL_PROJECT);
});
App::patch('/v1/projects/:projectId/auth/password-ai')
->desc('Update authentication password ai status.')
->groups(['api', 'projects'])
->label('scope', 'projects.write')
->label('sdk.auth', [APP_AUTH_TYPE_ADMIN])
->label('sdk.namespace', 'projects')
->label('sdk.method', 'updateAuthPasswordAi')
->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('enabled', false, new Boolean(false), 'Set whether or not to enable checking user\'s password against AI opinion. Default is false.')
->inject('response')
->inject('dbForConsole')
->action(function (string $projectId, bool $enabled, Response $response, Database $dbForConsole) {
@@ -781,7 +816,40 @@ App::patch('/v1/projects/:projectId/auth/password-dictionary')
}
$auths = $project->getAttribute('auths', []);
$auths['passwordDictionary'] = $enabled;
$auths['passwordAi'] = $enabled;
$dbForConsole->updateDocument('projects', $project->getId(), $project
->setAttribute('auths', $auths));
$response->dynamic($project, Response::MODEL_PROJECT);
});
App::patch('/v1/projects/:projectId/auth/notify')
->desc('Update authentication notify status. Enable or disable notifications for new session creations.')
->groups(['api', 'projects'])
->label('scope', 'projects.write')
->label('sdk.auth', [APP_AUTH_TYPE_ADMIN])
->label('sdk.namespace', 'projects')
->label('sdk.method', 'updateAuthNotify')
->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('enabled', false, new Boolean(false), 'Set whether or not to enable notifications for new session creations. Default is false.')
->inject('response')
->inject('dbForConsole')
->action(function (string $projectId, bool $enabled, Response $response, Database $dbForConsole) {
if (!empty($projectId)) {
$project = $dbForConsole->getDocument('projects', $projectId);
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
}
$auths = $project->getAttribute('auths', []);
$auths['notify'] = $enabled;
$dbForConsole->updateDocument('projects', $project->getId(), $project
->setAttribute('auths', $auths));
+64 -4
View File
@@ -2,6 +2,7 @@
use Appwrite\Auth\Auth;
use Appwrite\Auth\Validator\Password;
use Appwrite\Auth\Validator\PasswordAi;
use Appwrite\Auth\Validator\Phone;
use Appwrite\Detector\Detector;
use Appwrite\Event\Delete;
@@ -71,6 +72,13 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e
}
}
if ($project->getAttribute('auths', [])['passwordAi'] ?? false) {
$passwordAiValidator = new PasswordAi();
if (!$passwordAiValidator->isValid($password)) {
throw new Exception(Exception::USER_PASSWORD_AI);
}
}
$password = (!empty($password)) ? ($hash === 'plaintext' ? Auth::passwordHash($password, $hash, $hashOptionsObject) : $password) : null;
$user = $dbForProject->createDocument('users', new Document([
'$id' => $userId,
@@ -162,13 +170,35 @@ App::post('/v1/users')
->param('userId', '', new CustomId(), 'User 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', null, new Email(), 'User email.', true)
->param('phone', null, new Phone(), 'Phone number. Format this number with a leading \'+\' and a country code, e.g., +16175551212.', true)
->param('password', '', fn ($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, $project->getAttribute('auths', [])['passwordDictionary'] ?? false), 'Plain text user password. Must be at least 8 chars.', true, ['project', 'passwordsDictionary'])
->param('password', '', fn ($project, $passwordsDictionary10k) => new PasswordDictionary($passwordsDictionary10k, $project->getAttribute('auths', [])['passwordDictionary'] ?? false), 'Plain text user password. Must be at least 8 chars.', true, ['project', 'passwordsDictionary10k'])
->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true)
->inject('response')
->inject('project')
->inject('dbForProject')
->inject('queueForEvents')
->action(function (string $userId, ?string $email, ?string $phone, ?string $password, string $name, Response $response, Document $project, Database $dbForProject, Event $queueForEvents) {
->inject('passwordsDictionary10k')
->inject('passwordsDictionary100k')
->inject('passwordsDictionary1M')
->inject('passwordsDictionary10M')
->action(function (string $userId, ?string $email, ?string $phone, ?string $password, string $name, Response $response, Document $project, Database $dbForProject, Event $queueForEvents, array $passwordsDictionary10k, array $passwordsDictionary100k, array $passwordsDictionary1M, array $passwordsDictionary10M) {
if ($project->getAttribute('auths', [])['passwordDictionary'] ?? false) {
$passwordDictionaryLength = $project->getAttribute('auths', [])['passwordDictionaryLength'] ?? '10k';
if ($passwordDictionaryLength == '10k') {
$passwordDictionary = $passwordsDictionary10k;
} elseif ($passwordDictionaryLength == '100k') {
$passwordDictionary = $passwordsDictionary100k;
} elseif ($passwordDictionaryLength == '1m') {
$passwordDictionary = $passwordsDictionary1M;
} elseif ($passwordDictionaryLength == '10m') {
$passwordDictionary = $passwordsDictionary10M;
} else {
throw new Exception('Password dictionary length is not supported');
}
$passwordDictionaryValidator = new PasswordDictionary($passwordDictionary, true);
if (!$passwordDictionaryValidator->isValid($password)) {
throw new Exception(Exception::USER_PASSWORD_DICTIONARY);
}
}
$user = createUser('plaintext', '{}', $userId, $email, $password, $phone, $name, $project, $dbForProject, $queueForEvents);
$response
@@ -1069,12 +1099,16 @@ App::patch('/v1/users/:userId/password')
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_USER)
->param('userId', '', new UID(), 'User ID.')
->param('password', '', fn ($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, $project->getAttribute('auths', [])['passwordDictionary'] ?? false), 'New user password. Must be at least 8 chars.', false, ['project', 'passwordsDictionary'])
->param('password', '', fn ($project, $passwordsDictionary10k) => new PasswordDictionary($passwordsDictionary10k, $project->getAttribute('auths', [])['passwordDictionary'] ?? false), 'New user password. Must be at least 8 chars.', false, ['project', 'passwordsDictionary10k'])
->inject('response')
->inject('project')
->inject('dbForProject')
->inject('queueForEvents')
->action(function (string $userId, string $password, Response $response, Document $project, Database $dbForProject, Event $queueForEvents) {
->inject('passwordsDictionary10k')
->inject('passwordsDictionary100k')
->inject('passwordsDictionary1M')
->inject('passwordsDictionary10M')
->action(function (string $userId, string $password, Response $response, Document $project, Database $dbForProject, Event $queueForEvents, array $passwordsDictionary10k, array $passwordsDictionary100k, array $passwordsDictionary1M, array $passwordsDictionary10M) {
$user = $dbForProject->getDocument('users', $userId);
@@ -1088,6 +1122,32 @@ App::patch('/v1/users/:userId/password')
throw new Exception(Exception::USER_PASSWORD_PERSONAL_DATA);
}
}
if ($project->getAttribute('auths', [])['passwordAi'] ?? false) {
$passwordAiValidator = new PasswordAi();
if (!$passwordAiValidator->isValid($password)) {
throw new Exception(Exception::USER_PASSWORD_AI);
}
}
if ($project->getAttribute('auths', [])['passwordDictionary'] ?? false) {
$passwordDictionaryLength = $project->getAttribute('auths', [])['passwordDictionaryLength'] ?? '10k';
if ($passwordDictionaryLength == '10k') {
$passwordDictionary = $passwordsDictionary10k;
} elseif ($passwordDictionaryLength == '100k') {
$passwordDictionary = $passwordsDictionary100k;
} elseif ($passwordDictionaryLength == '1m') {
$passwordDictionary = $passwordsDictionary1M;
} elseif ($passwordDictionaryLength == '10m') {
$passwordDictionary = $passwordsDictionary10M;
} else {
throw new Exception('Password dictionary length is not supported');
}
$passwordDictionaryValidator = new PasswordDictionary($passwordDictionary, true);
if (!$passwordDictionaryValidator->isValid($password)) {
throw new Exception(Exception::USER_PASSWORD_DICTIONARY);
}
}
$newPassword = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS);
+29 -10
View File
@@ -154,8 +154,7 @@ App::init()
$response
->addHeader('X-RateLimit-Limit', $limit)
->addHeader('X-RateLimit-Remaining', $remaining)
->addHeader('X-RateLimit-Reset', $time)
;
->addHeader('X-RateLimit-Reset', $time);
}
$enabled = App::getEnv('_APP_OPTIONS_ABUSE', 'enabled') !== 'disabled';
@@ -170,6 +169,27 @@ App::init()
}
}
/*
* Session refresh
*/
if ($project->getAttribute('auths', [])['renewal'] ?? false) {
if ($user && !$user->isEmpty()) {
$authDuration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
$currentSessionId = Auth::sessionVerify($user->getAttribute('sessions'), Auth::$secret, $authDuration);
if ($currentSessionId) {
$currentSession = $dbForProject->getDocument('sessions', $currentSessionId);
if (!$currentSession->isEmpty()) {
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $authDuration));
$currentSession = $currentSession->setAttribute('expire', $expire);
$dbForProject->updateDocument('sessions', $currentSession->getId(), $currentSession);
$dbForProject->deleteCachedDocument('users', $user->getId());
}
}
}
}
/*
* Background Jobs
*/
@@ -240,7 +260,7 @@ App::init()
if ($fileSecurity && !$valid) {
$file = $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId);
} else {
$file = Authorization::skip(fn() => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId));
$file = Authorization::skip(fn () => $dbForProject->getDocument('bucket_' . $bucket->getInternalId(), $fileId));
}
if ($file->isEmpty()) {
@@ -252,8 +272,7 @@ App::init()
->addHeader('Expires', \date('D, d M Y H:i:s', \time() + $timestamp) . ' GMT')
->addHeader('X-Appwrite-Cache', 'hit')
->setContentType($data['contentType'])
->send(base64_decode($data['payload']))
;
->send(base64_decode($data['payload']));
} else {
$response->addHeader('X-Appwrite-Cache', 'miss');
}
@@ -495,7 +514,7 @@ App::shutdown()
'resource' => $resource,
'contentType' => $response->getContentType(),
'payload' => base64_encode($data['payload']),
]) ;
]);
$signature = md5($data);
$cacheLog = Authorization::skip(fn () => $dbForProject->getDocument('cache', $key));
@@ -503,10 +522,10 @@ App::shutdown()
$now = DateTime::now();
if ($cacheLog->isEmpty()) {
Authorization::skip(fn () => $dbForProject->createDocument('cache', new Document([
'$id' => $key,
'resource' => $resource,
'accessedAt' => $now,
'signature' => $signature,
'$id' => $key,
'resource' => $resource,
'accessedAt' => $now,
'signature' => $signature,
])));
} elseif (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_CACHE_UPDATE)) > $accessedAt) {
$cacheLog->setAttribute('accessedAt', $now);
+47 -4
View File
@@ -175,6 +175,8 @@ const COMPRESSION_TYPE_NONE = 'none';
const COMPRESSION_TYPE_GZIP = 'gzip';
const COMPRESSION_TYPE_ZSTD = 'zstd';
// Mail Types
const MAIL_TYPE_VERIFICATION_URL = 'verificationUrl';
const MAIL_TYPE_VERIFICATION_CODE = 'verificationCode';
const MAIL_TYPE_VERIFICATION = 'verification';
const MAIL_TYPE_MAGIC_SESSION = 'magicSession';
const MAIL_TYPE_RECOVERY = 'recovery';
@@ -925,12 +927,34 @@ $register->set('smtp', function () {
$register->set('geodb', function () {
return new Reader(__DIR__ . '/assets/dbip/dbip-country-lite-2023-01.mmdb');
});
$register->set('passwordsDictionary', function () {
$register->set('passwordsDictionary10k', function () {
$content = \file_get_contents(__DIR__ . '/assets/security/10k-common-passwords');
$content = explode("\n", $content);
$content = array_flip($content);
return $content;
});
$register->set('passwordsDictionary100k', function () {
$content = \file_get_contents(__DIR__ . '/assets/security/100k-common-passwords.txt');
$content = explode("\n", $content);
$content = array_flip($content);
return $content;
});
$register->set('passwordsDictionary1M', function () {
$content = \file_get_contents(__DIR__ . '/assets/security/1m-common-passwords.txt');
$content = explode("\n", $content);
$content = array_flip($content);
return $content;
});
$register->set('passwordsDictionary10M', function () {
$content = \file_get_contents(__DIR__ . '/assets/security/1m-common-passwords.txt');
$content = explode("\n", $content);
$content = array_flip($content);
return $content;
});
$register->set('promiseAdapter', function () {
return new Swoole();
});
@@ -1202,13 +1226,17 @@ App::setResource('console', function () {
'invites' => App::getEnv('_APP_CONSOLE_INVITES', 'enabled') === 'enabled',
'limit' => (App::getEnv('_APP_CONSOLE_WHITELIST_ROOT', 'enabled') === 'enabled') ? 1 : 0, // limit signup to 1 user
'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG, // 1 Year in seconds
'notify' => true,
],
'authWhitelistEmails' => (!empty(App::getEnv('_APP_CONSOLE_WHITELIST_EMAILS', null))) ? \explode(',', App::getEnv('_APP_CONSOLE_WHITELIST_EMAILS', null)) : [],
'authWhitelistIPs' => (!empty(App::getEnv('_APP_CONSOLE_WHITELIST_IPS', null))) ? \explode(',', App::getEnv('_APP_CONSOLE_WHITELIST_IPS', null)) : [],
'oAuthProviders' => [
'githubEnabled' => true,
'githubSecret' => App::getEnv('_APP_CONSOLE_GITHUB_SECRET', ''),
'githubAppid' => App::getEnv('_APP_CONSOLE_GITHUB_APP_ID', '')
'githubAppid' => App::getEnv('_APP_CONSOLE_GITHUB_APP_ID', ''),
'supabaseEnabled' => true,
'supabaseSecret' => App::getEnv('_APP_CONSOLE_SUPABASE_SECRET', ''),
'supabaseAppid' => App::getEnv('_APP_CONSOLE_SUPABASE_APP_ID', ''),
],
]);
}, []);
@@ -1424,12 +1452,27 @@ App::setResource('geodb', function ($register) {
return $register->get('geodb');
}, ['register']);
App::setResource('passwordsDictionary', function ($register) {
App::setResource('passwordsDictionary10k', function ($register) {
/** @var Utopia\Registry\Registry $register */
return $register->get('passwordsDictionary');
return $register->get('passwordsDictionary10k');
}, ['register']);
App::setResource('passwordsDictionary100k', function ($register) {
/** @var Utopia\Registry\Registry $register */
return $register->get('passwordsDictionary100k');
}, ['register']);
App::setResource('passwordsDictionary1M', function ($register) {
/** @var Utopia\Registry\Registry $register */
return $register->get('passwordsDictionary1M');
}, ['register']);
App::setResource('passwordsDictionary10M', function ($register) {
/** @var Utopia\Registry\Registry $register */
return $register->get('passwordsDictionary10M');
}, ['register']);
App::setResource('servers', function () {
$platforms = Config::getParam('platforms');
$server = $platforms[APP_PLATFORM_SERVER];
+2
View File
@@ -191,6 +191,8 @@ services:
- _APP_MESSAGE_SMS_TEST_DSN
- _APP_MESSAGE_EMAIL_TEST_DSN
- _APP_MESSAGE_PUSH_TEST_DSN
- _APP_CONSOLE_SUPABASE_SECRET
- _APP_CONSOLE_SUPABASE_APP_ID
appwrite-realtime:
entrypoint: realtime
<<: *x-logging
+1 -1
View File
@@ -363,7 +363,7 @@ class Auth
$session->isSet('secret') &&
$session->isSet('provider') &&
$session->getAttribute('secret') === self::hash($secret) &&
DateTime::formatTz(DateTime::addSeconds(new \DateTime($session->getCreatedAt()), $expires)) >= DateTime::formatTz(DateTime::now())
DateTime::formatTz($session->getAttribute('expire')) >= DateTime::formatTz(DateTime::now())
) {
return $session->getId();
}
+182
View File
@@ -0,0 +1,182 @@
<?php
namespace Appwrite\Auth\OAuth2;
use Appwrite\Auth\OAuth2;
// Reference Material
// https://supabase.com/docs/guides/platform/oauth-apps/build-a-supabase-integration
class Supabase extends OAuth2
{
/**
* @var string
*/
private string $endpoint = 'https://api.supabase.com/v1';
/**
* @var array
*/
protected array $user = [];
/**
* @var array
*/
protected array $tokens = [];
/**
* @return string
*/
public function getName(): string
{
return 'supabase';
}
/**
* @return string
*/
public function getLoginURL(): string
{
$url = $this->endpoint . '/oauth/authorize?' .
\http_build_query([
'response_type' => 'code',
'client_id' => $this->appID,
'state' => \json_encode($this->state),
'redirect_uri' => $this->callback
]);
return $url;
}
/**
* @param string $code
*
* @return array
*/
protected function getTokens(string $code): array
{
if (empty($this->tokens)) {
$this->tokens = \json_decode($this->request(
'POST',
$this->endpoint . '/oauth/token',
['Content-Type: application/x-www-form-urlencoded'],
\http_build_query([
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $this->callback,
'client_id' => $this->appID,
'client_secret' => $this->appSecret,
])
), true);
}
return $this->tokens;
}
/**
* @param string $refreshToken
*
* @return array
*/
public function refreshTokens(string $refreshToken): array
{
$this->tokens = \json_decode($this->request(
'POST',
$this->endpoint . '/oauth/token',
['Content-Type: application/x-www-form-urlencoded'],
\http_build_query([
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
'client_id' => $this->appID,
'client_secret' => $this->appSecret,
])
), true);
if (empty($this->tokens['refresh_token'])) {
$this->tokens['refresh_token'] = $refreshToken;
}
return $this->tokens;
}
/**
* @param string $accessToken
*
* @return string
*/
public function getUserID(string $accessToken): string
{
$user = $this->getUser($accessToken);
return $user['id'] ?? '';
}
/**
* @param string $accessToken
*
* @return string
*/
public function getUserEmail(string $accessToken): string
{
$user = $this->getUser($accessToken);
return $user['email'] ?? '';
}
/**
* Check if the OAuth email is verified
*
* @link https://discord.com/developers/docs/resources/user
*
* @param string $accessToken
*
* @return bool
*/
public function isEmailVerified(string $accessToken): bool
{
$user = $this->getUser($accessToken);
if ($user['verified'] ?? false) {
return true;
}
return false;
}
/**
* @param string $accessToken
*
* @return string
*/
public function getUserName(string $accessToken): string
{
$user = $this->getUser($accessToken);
return $user['username'] ?? '';
}
/**
* @param string $accessToken
*
* @return array
*/
protected function getUser(string $accessToken): array
{
if (empty($this->user)) {
$user = $this->request(
'GET',
$this->endpoint . '/organizations',
['Authorization: Bearer ' . \urlencode($accessToken)]
);
$this->user = \json_decode($user, true);
$this->user = [
'username' => $this->user[0]['name'],
'verified' => false,
'email' => $this->user[0]['name'] . '@supabase.internal',
'id' => $this->user[0]['id']
];
}
return $this->user;
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace Appwrite\Auth\Validator;
/**
* Password.
*
* Validates user password string
*/
class PasswordAi extends Password
{
public function __construct()
{
}
/**
* Get Description.
*
* Returns validator description
*
* @return string
*/
public function getDescription(): string
{
return 'Password must be at least 8 characters and must be approved by AI.';
}
/**
* Is valid.
*
* @param mixed $value
*
* @return bool
*/
public function isValid($value): bool
{
if (!parent::isValid($value)) {
return false;
}
$prompt = 'My password is "' . $value . '". Check my password and tell me if it could include day, year, month, date, name, surname, animal name, location, city, animal breed, or state in any shape or form. Answer "Yes" if it does, and "no" if it doesnt. Only answer with 1 word.';
$answer = '';
$ch = curl_init('http://appwrite-assistant:3003/');
$responseHeaders = [];
$query = json_encode(['prompt' => $prompt]);
$headers = ['accept: text/event-stream'];
$handleEvent = function ($ch, $data) use ($answer) {
$answer .= $data;
return \strlen($data);
};
curl_setopt($ch, CURLOPT_WRITEFUNCTION, $handleEvent);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 0);
curl_setopt($ch, CURLOPT_TIMEOUT, 9000);
curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($curl, $header) use (&$responseHeaders) {
$len = strlen($header);
$header = explode(':', $header, 2);
if (count($header) < 2) { // ignore invalid headers
return $len;
}
$responseHeaders[strtolower(trim($header[0]))] = trim($header[1]);
return $len;
});
curl_setopt($ch, CURLOPT_POSTFIELDS, $query);
curl_exec($ch);
curl_close($ch);
if ($answer !== 'Yes') {
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;
}
}
+2
View File
@@ -74,6 +74,8 @@ class Exception extends \Exception
public const USER_NOT_FOUND = 'user_not_found';
public const USER_PASSWORD_RECENTLY_USED = 'password_recently_used';
public const USER_PASSWORD_PERSONAL_DATA = 'password_personal_data';
public const USER_PASSWORD_DICTIONARY = 'password_dictionary';
public const USER_PASSWORD_AI = 'password_ai';
public const USER_EMAIL_ALREADY_EXISTS = 'user_email_already_exists';
public const USER_PASSWORD_MISMATCH = 'user_password_mismatch';
public const USER_SESSION_NOT_FOUND = 'user_session_not_found';
@@ -9,6 +9,7 @@ class Users extends Base
'email',
'phone',
'status',
'hash',
'passwordUpdate',
'registration',
'emailVerification',
+43 -6
View File
@@ -132,6 +132,42 @@ class Project extends Model
'default' => false,
'example' => true,
])
->addRule('authPasswordDictionaryLength', [
'type' => self::TYPE_STRING,
'description' => 'How many most commonly used password to check against. Possible values are: 10k, 100k, 1m, 10m',
'default' => '10k',
'example' => '1m',
])
->addRule('authPasswordAi', [
'type' => self::TYPE_BOOLEAN,
'description' => 'Whether or not to check user\'s password against against AI opinion',
'default' => false,
'example' => true,
])
->addRule('authNotify', [
'type' => self::TYPE_BOOLEAN,
'description' => 'Whether or not to send notification on new session creation.',
'default' => false,
'example' => true,
])
->addRule('authSessionRefresh', [
'type' => self::TYPE_BOOLEAN,
'description' => 'Whether or not sessions are automatically extended to session duration on every request',
'default' => false,
'example' => true,
])
->addRule('authPasswordAi', [
'type' => self::TYPE_BOOLEAN,
'description' => 'Whether or not to check user\'s password against against AI opinion',
'default' => false,
'example' => true,
])
->addRule('authRenewal', [
'type' => self::TYPE_BOOLEAN,
'description' => 'Whether or not sessions are automatically extended to session duration on every request',
'default' => false,
'example' => true,
])
->addRule('authPersonalDataCheck', [
'type' => self::TYPE_BOOLEAN,
'description' => 'Whether or not to check the user password for similarity with their personal data.',
@@ -220,8 +256,7 @@ class Project extends Model
'description' => 'SMTP server secure protocol',
'default' => '',
'example' => 'tls',
])
;
]);
$services = Config::getParam('services', []);
$auth = Config::getParam('auth', []);
@@ -236,8 +271,7 @@ class Project extends Model
'description' => $name . ' auth method status',
'example' => true,
'default' => true,
])
;
]);
}
foreach ($services as $service) {
@@ -254,8 +288,7 @@ class Project extends Model
'description' => $name . ' service status',
'example' => true,
'default' => true,
])
;
]);
}
}
@@ -320,6 +353,10 @@ class Project extends Model
$document->setAttribute('authSessionsLimit', $authValues['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT);
$document->setAttribute('authPasswordHistory', $authValues['passwordHistory'] ?? 0);
$document->setAttribute('authPasswordDictionary', $authValues['passwordDictionary'] ?? false);
$document->setAttribute('authPasswordDictionaryLength', $authValues['passwordDictionaryLength'] ?? false);
$document->setAttribute('authPasswordAi', $authValues['passwordAi'] ?? false);
$document->setAttribute('authNotify', $authValues['notify'] ?? false);
$document->setAttribute('authRenewal', $authValues['renewal'] ?? false);
$document->setAttribute('authPersonalDataCheck', $authValues['personalDataCheck'] ?? false);
foreach ($auth as $index => $method) {
@@ -40,6 +40,12 @@ class Token extends Model
'default' => '',
'example' => self::TYPE_DATETIME_EXAMPLE,
])
->addRule('securityPhrase', [
'type' => self::TYPE_STRING,
'description' => 'Token security phrase.',
'default' => '',
'example' => 'Tiger Whisper',
])
;
}
+11 -3
View File
@@ -415,7 +415,7 @@ trait AccountBase
'x-appwrite-project' => $this->getProject()['$id'],
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
]), [
'queries' => [ 'limit(1)' ],
'queries' => ['limit(1)'],
]);
$this->assertEquals($responseLimit['headers']['status-code'], 200);
@@ -432,7 +432,7 @@ trait AccountBase
'x-appwrite-project' => $this->getProject()['$id'],
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
]), [
'queries' => [ 'offset(1)' ],
'queries' => ['offset(1)'],
]);
$this->assertEquals($responseOffset['headers']['status-code'], 200);
@@ -449,7 +449,7 @@ trait AccountBase
'x-appwrite-project' => $this->getProject()['$id'],
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
]), [
'queries' => [ 'limit(1)', 'offset(1)' ],
'queries' => ['limit(1)', 'offset(1)'],
]);
$this->assertEquals($responseLimitOffset['headers']['status-code'], 200);
@@ -1115,6 +1115,14 @@ trait AccountBase
$this->assertEquals($response['headers']['status-code'], 204);
$response = $this->client->call(Client::METHOD_GET, '/account/sessions', 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'], 401);
/**
* Test for FAILURE
*/