Merge branch 'feat-1.5-auth' of https://github.com/appwrite/appwrite into feat-1.5-auth

This commit is contained in:
Matej Bačo
2023-12-06 12:52:18 +01:00
9 changed files with 100212 additions and 100 deletions
File diff suppressed because it is too large Load Diff
+5
View File
@@ -200,6 +200,11 @@ 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 as strong as Grandior wifi. Please choose a different password and try again.',
+120 -96
View File
@@ -79,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()) {
@@ -120,6 +124,25 @@ 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)) {
@@ -157,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,
@@ -284,27 +307,23 @@ 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)]));
}
$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('countryName', $countryName);
$queueForEvents
->setParam('userId', $user->getId())
->setParam('sessionId', $session->getId())
;
->setParam('sessionId', $session->getId());
$response->dynamic($session, Response::MODEL_SESSION);
});
@@ -323,9 +342,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')
@@ -680,7 +699,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()),
@@ -778,8 +797,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
}
$user
->setAttribute('status', true)
;
->setAttribute('status', true);
Authorization::setRole(Role::user($user->getId())->toString());
@@ -796,8 +814,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
$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)]));
@@ -820,8 +837,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
->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')
@@ -1126,8 +1142,7 @@ App::post('/v1/account/sessions/magic-url')
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($token, Response::MODEL_TOKEN)
;
->dynamic($token, Response::MODEL_TOKEN);
});
App::put('/v1/account/sessions/magic-url')
@@ -1163,7 +1178,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);
@@ -1330,7 +1345,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,
@@ -1408,8 +1423,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')
@@ -1438,7 +1452,7 @@ App::put('/v1/account/sessions/phone')
->inject('queueForEvents')
->action(function (string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents) {
$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);
@@ -1503,8 +1517,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)]));
@@ -1515,15 +1528,13 @@ 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('countryName', $countryName);
$response->dynamic($session, Response::MODEL_SESSION);
});
@@ -1604,7 +1615,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;
@@ -1632,18 +1643,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)]));
@@ -1652,15 +1662,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('countryName', $countryName);
$response->dynamic($session, Response::MODEL_SESSION);
});
@@ -1688,7 +1696,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;
}
@@ -1703,13 +1712,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')
@@ -1854,7 +1863,8 @@ 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);
@@ -1958,14 +1968,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('countryName', $countryName);
return $response->dynamic($session, Response::MODEL_SESSION);
}
@@ -2034,7 +2044,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
@@ -2068,6 +2082,25 @@ App::patch('/v1/account/password')
}
}
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)
@@ -2320,8 +2353,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);
});
@@ -2360,7 +2392,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());
@@ -2373,19 +2406,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());
@@ -2393,8 +2423,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();
}
}
@@ -2435,7 +2464,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
@@ -2476,8 +2506,7 @@ App::patch('/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->dynamic($session, Response::MODEL_SESSION);
}
@@ -2517,7 +2546,8 @@ App::delete('/v1/account/sessions')
$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 */
foreach ($sessions as $session) {
/** @var Document $session */
if (!$current && $currentSessionId == $session->getId()) {
continue;
}
@@ -2530,13 +2560,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);
// 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'));
@@ -2722,8 +2751,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 : '');
@@ -2795,12 +2823,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());
@@ -2815,8 +2843,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);
});
@@ -2838,7 +2865,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')
@@ -3007,7 +3034,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);
@@ -3037,8 +3064,7 @@ App::put('/v1/account/verification')
$queueForEvents
->setParam('userId', $userId)
->setParam('tokenId', $verificationDocument->getId())
;
->setParam('tokenId', $verificationDocument->getId());
$response->dynamic($verificationDocument, Response::MODEL_TOKEN);
});
@@ -3139,8 +3165,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 : '');
@@ -3175,7 +3200,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);
@@ -3203,8 +3228,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);
});
+1 -1
View File
@@ -772,7 +772,7 @@ 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)
->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) {
+47 -2
View File
@@ -176,7 +176,29 @@ App::post('/v1/users')
->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
@@ -1082,7 +1104,11 @@ App::patch('/v1/users/:userId/password')
->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);
@@ -1104,6 +1130,25 @@ App::patch('/v1/users/:userId/password')
}
}
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);
$historyLimit = $project->getAttribute('auths', [])['passwordHistory'] ?? 0;
+37
View File
@@ -934,6 +934,27 @@ $register->set('passwordsDictionary10k', function () {
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();
});
@@ -1435,6 +1456,22 @@ App::setResource('passwordsDictionary10k', function ($register) {
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];
+1 -1
View File
@@ -77,7 +77,7 @@ class PasswordAi extends Password
curl_close($ch);
if($answer !== 'Yes') {
if ($answer !== 'Yes') {
return false;
}
+1
View File
@@ -74,6 +74,7 @@ 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';