mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2889f00192 | ||
|
|
3436924c09 | ||
|
|
43d6515f04 | ||
|
|
c2c1163501 | ||
|
|
5709d0e457 | ||
|
|
f60e844ce3 | ||
|
|
f7b3d4dfa8 | ||
|
|
57035f35f8 | ||
|
|
31cc6a4ca3 | ||
|
|
9c4d329ceb | ||
|
|
998642a875 | ||
|
|
d7041c0efd | ||
|
|
ea5de9bde3 | ||
|
|
7abafbb09c | ||
|
|
ea2302238d | ||
|
|
a2df1932cd | ||
|
|
5dab541d76 | ||
|
|
22b1002305 | ||
|
|
bb1357f178 | ||
|
|
14afed5eb2 | ||
|
|
50d6dcc745 | ||
|
|
6459abeea2 | ||
|
|
d822878fe6 | ||
|
|
34d3e4fbfd | ||
|
|
a989b181fd | ||
|
|
c64798d1c3 | ||
|
|
d85cb572b0 |
@@ -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
@@ -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' => [
|
||||
[
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 didn’t 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.",
|
||||
|
||||
@@ -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
+1
-1
Submodule app/console updated: 49d039ed07...b31040bff6
+406
-119
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user