Merge pull request #4866 from appwrite/feat-password-history
feat: check password history
This commit is contained in:
commit
6540749559
15 changed files with 10730 additions and 44 deletions
|
@ -1,3 +1,9 @@
|
||||||
|
## Version 1.3.0
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Password dictionary setting allows to compare user's password against command password database [4906](https://github.com/appwrite/appwrite/pull/4906)
|
||||||
|
- Password history setting allows to save user's last used password so that it may not be used again. Maximum number of history saved is 20, which can be configured. Minimum is 0 which means disabled. [#4866](https://github.com/appwrite/appwrite/pull/4866)
|
||||||
|
|
||||||
# Version 1.2.0
|
# Version 1.2.0
|
||||||
## Features
|
## Features
|
||||||
- Added GraphQL API [#974](https://github.com/appwrite/appwrite/pull/974)
|
- Added GraphQL API [#974](https://github.com/appwrite/appwrite/pull/974)
|
||||||
|
|
10000
app/assets/security/10k-common-passwords
Normal file
10000
app/assets/security/10k-common-passwords
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1234,6 +1234,17 @@ $collections = [
|
||||||
'array' => false,
|
'array' => false,
|
||||||
'filters' => [],
|
'filters' => [],
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'$id' => ID::custom('passwordHistory'),
|
||||||
|
'type' => Database::VAR_STRING,
|
||||||
|
'format' => '',
|
||||||
|
'size' => 16384,
|
||||||
|
'signed' => true,
|
||||||
|
'required' => false,
|
||||||
|
'default' => null,
|
||||||
|
'array' => true,
|
||||||
|
'filters' => [],
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'$id' => ID::custom('password'),
|
'$id' => ID::custom('password'),
|
||||||
'type' => Database::VAR_STRING,
|
'type' => Database::VAR_STRING,
|
||||||
|
|
|
@ -40,6 +40,8 @@ use Utopia\Validator\ArrayList;
|
||||||
use Utopia\Validator\Assoc;
|
use Utopia\Validator\Assoc;
|
||||||
use Utopia\Validator\Text;
|
use Utopia\Validator\Text;
|
||||||
use Utopia\Validator\WhiteList;
|
use Utopia\Validator\WhiteList;
|
||||||
|
use Appwrite\Auth\Validator\PasswordHistory;
|
||||||
|
use Appwrite\Auth\Validator\PasswordDictionary;
|
||||||
|
|
||||||
$oauthDefaultSuccess = '/auth/oauth2/success';
|
$oauthDefaultSuccess = '/auth/oauth2/success';
|
||||||
$oauthDefaultFailure = '/auth/oauth2/failure';
|
$oauthDefaultFailure = '/auth/oauth2/failure';
|
||||||
|
@ -64,7 +66,7 @@ App::post('/v1/account')
|
||||||
->label('abuse-limit', 10)
|
->label('abuse-limit', 10)
|
||||||
->param('userId', '', new CustomId(), 'Unique Id. Choose your own unique ID or pass the string `ID.unique()` to auto generate it. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.')
|
->param('userId', '', new CustomId(), 'Unique Id. Choose your own unique ID or pass the string `ID.unique()` to auto generate it. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.')
|
||||||
->param('email', '', new Email(), 'User email.')
|
->param('email', '', new Email(), 'User email.')
|
||||||
->param('password', '', new Password(), 'User password. Must be at least 8 chars.')
|
->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('name', '', new Text(128), 'User name. Max length: 128 chars.', true)
|
->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true)
|
||||||
->inject('request')
|
->inject('request')
|
||||||
->inject('response')
|
->inject('response')
|
||||||
|
@ -72,7 +74,6 @@ App::post('/v1/account')
|
||||||
->inject('dbForProject')
|
->inject('dbForProject')
|
||||||
->inject('events')
|
->inject('events')
|
||||||
->action(function (string $userId, string $email, string $password, string $name, Request $request, Response $response, Document $project, Database $dbForProject, Event $events) {
|
->action(function (string $userId, string $email, string $password, string $name, Request $request, Response $response, Document $project, Database $dbForProject, Event $events) {
|
||||||
|
|
||||||
$email = \strtolower($email);
|
$email = \strtolower($email);
|
||||||
if ('console' === $project->getId()) {
|
if ('console' === $project->getId()) {
|
||||||
$whitelistEmails = $project->getAttribute('authWhitelistEmails');
|
$whitelistEmails = $project->getAttribute('authWhitelistEmails');
|
||||||
|
@ -97,6 +98,8 @@ App::post('/v1/account')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$passwordHistory = $project->getAttribute('auths', [])['passwordHistory'] ?? 0;
|
||||||
|
$password = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS);
|
||||||
try {
|
try {
|
||||||
$userId = $userId == 'unique()' ? ID::unique() : $userId;
|
$userId = $userId == 'unique()' ? ID::unique() : $userId;
|
||||||
$user = Authorization::skip(fn() => $dbForProject->createDocument('users', new Document([
|
$user = Authorization::skip(fn() => $dbForProject->createDocument('users', new Document([
|
||||||
|
@ -109,10 +112,11 @@ App::post('/v1/account')
|
||||||
'email' => $email,
|
'email' => $email,
|
||||||
'emailVerification' => false,
|
'emailVerification' => false,
|
||||||
'status' => true,
|
'status' => true,
|
||||||
'password' => Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS),
|
'password' => $password,
|
||||||
|
'passwordHistory' => $passwordHistory > 0 ? [$password] : [],
|
||||||
|
'passwordUpdate' => DateTime::now(),
|
||||||
'hash' => Auth::DEFAULT_ALGO,
|
'hash' => Auth::DEFAULT_ALGO,
|
||||||
'hashOptions' => Auth::DEFAULT_ALGO_OPTIONS,
|
'hashOptions' => Auth::DEFAULT_ALGO_OPTIONS,
|
||||||
'passwordUpdate' => DateTime::now(),
|
|
||||||
'registration' => DateTime::now(),
|
'registration' => DateTime::now(),
|
||||||
'reset' => false,
|
'reset' => false,
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
|
@ -489,8 +493,11 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$passwordHistory = $project->getAttribute('auths', [])['passwordHistory'] ?? 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$userId = ID::unique();
|
$userId = ID::unique();
|
||||||
|
$password = Auth::passwordHash(Auth::passwordGenerator(), Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS);
|
||||||
$user = Authorization::skip(fn() => $dbForProject->createDocument('users', new Document([
|
$user = Authorization::skip(fn() => $dbForProject->createDocument('users', new Document([
|
||||||
'$id' => $userId,
|
'$id' => $userId,
|
||||||
'$permissions' => [
|
'$permissions' => [
|
||||||
|
@ -501,7 +508,8 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
|
||||||
'email' => $email,
|
'email' => $email,
|
||||||
'emailVerification' => true,
|
'emailVerification' => true,
|
||||||
'status' => true, // Email should already be authenticated by OAuth2 provider
|
'status' => true, // Email should already be authenticated by OAuth2 provider
|
||||||
'password' => Auth::passwordHash(Auth::passwordGenerator(), Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS),
|
'passwordHistory' => $passwordHistory > 0 ? [$password] : null,
|
||||||
|
'password' => $password,
|
||||||
'hash' => Auth::DEFAULT_ALGO,
|
'hash' => Auth::DEFAULT_ALGO,
|
||||||
'hashOptions' => Auth::DEFAULT_ALGO_OPTIONS,
|
'hashOptions' => Auth::DEFAULT_ALGO_OPTIONS,
|
||||||
'passwordUpdate' => null,
|
'passwordUpdate' => null,
|
||||||
|
@ -1525,24 +1533,40 @@ App::patch('/v1/account/password')
|
||||||
->label('sdk.response.code', Response::STATUS_CODE_OK)
|
->label('sdk.response.code', Response::STATUS_CODE_OK)
|
||||||
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
|
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
|
||||||
->label('sdk.response.model', Response::MODEL_ACCOUNT)
|
->label('sdk.response.model', Response::MODEL_ACCOUNT)
|
||||||
->param('password', '', new Password(), 'New user password. Must be at least 8 chars.')
|
->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('oldPassword', '', new Password(), 'Current user password. Must be at least 8 chars.', true)
|
->param('oldPassword', '', new Password(), 'Current user password. Must be at least 8 chars.', true)
|
||||||
->inject('response')
|
->inject('response')
|
||||||
->inject('user')
|
->inject('user')
|
||||||
|
->inject('project')
|
||||||
->inject('dbForProject')
|
->inject('dbForProject')
|
||||||
->inject('events')
|
->inject('events')
|
||||||
->action(function (string $password, string $oldPassword, Response $response, Document $user, Database $dbForProject, Event $events) {
|
->action(function (string $password, string $oldPassword, Response $response, Document $user, Document $project, Database $dbForProject, Event $events) {
|
||||||
|
|
||||||
// Check old password only if its an existing user.
|
// 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
|
if (!empty($user->getAttribute('passwordUpdate')) && !Auth::passwordVerify($oldPassword, $user->getAttribute('password'), $user->getAttribute('hash'), $user->getAttribute('hashOptions'))) { // Double check user password
|
||||||
throw new Exception(Exception::USER_INVALID_CREDENTIALS);
|
throw new Exception(Exception::USER_INVALID_CREDENTIALS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$newPassword = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS);
|
||||||
|
$historyLimit = $project->getAttribute('auths', [])['passwordHistory'] ?? 0;
|
||||||
|
$history = [];
|
||||||
|
if ($historyLimit > 0) {
|
||||||
|
$history = $user->getAttribute('passwordHistory', []);
|
||||||
|
$validator = new PasswordHistory($history, $user->getAttribute('hash'), $user->getAttribute('hashOptions'));
|
||||||
|
if (!$validator->isValid($password)) {
|
||||||
|
throw new Exception(Exception::USER_PASSWORD_RECENTLY_USED, 'The password was recently used', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$history[] = $newPassword;
|
||||||
|
array_slice($history, (count($history) - $historyLimit), $historyLimit);
|
||||||
|
}
|
||||||
|
|
||||||
$user = $dbForProject->updateDocument('users', $user->getId(), $user
|
$user = $dbForProject->updateDocument('users', $user->getId(), $user
|
||||||
->setAttribute('password', Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS))
|
->setAttribute('password', $newPassword)
|
||||||
|
->setAttribute('passwordHistory', $history)
|
||||||
|
->setAttribute('passwordUpdate', DateTime::now()))
|
||||||
->setAttribute('hash', Auth::DEFAULT_ALGO)
|
->setAttribute('hash', Auth::DEFAULT_ALGO)
|
||||||
->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS)
|
->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS);
|
||||||
->setAttribute('passwordUpdate', DateTime::now()));
|
|
||||||
|
|
||||||
$events->setParam('userId', $user->getId());
|
$events->setParam('userId', $user->getId());
|
||||||
|
|
||||||
|
@ -2110,9 +2134,9 @@ App::put('/v1/account/recovery')
|
||||||
|
|
||||||
$profile = $dbForProject->updateDocument('users', $profile->getId(), $profile
|
$profile = $dbForProject->updateDocument('users', $profile->getId(), $profile
|
||||||
->setAttribute('password', Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS))
|
->setAttribute('password', Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS))
|
||||||
|
->setAttribute('passwordUpdate', DateTime::now())
|
||||||
->setAttribute('hash', Auth::DEFAULT_ALGO)
|
->setAttribute('hash', Auth::DEFAULT_ALGO)
|
||||||
->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS)
|
->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS)
|
||||||
->setAttribute('passwordUpdate', DateTime::now())
|
|
||||||
->setAttribute('emailVerification', true));
|
->setAttribute('emailVerification', true));
|
||||||
|
|
||||||
$recoveryDocument = $dbForProject->getDocument('tokens', $recovery);
|
$recoveryDocument = $dbForProject->getDocument('tokens', $recovery);
|
||||||
|
|
|
@ -81,7 +81,7 @@ App::post('/v1/projects')
|
||||||
}
|
}
|
||||||
|
|
||||||
$auth = Config::getParam('auth', []);
|
$auth = Config::getParam('auth', []);
|
||||||
$auths = ['limit' => 0, 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT, 'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG];
|
$auths = ['limit' => 0, 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT, 'passwordHistory' => 0, 'passwordDictionary' => false, 'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG];
|
||||||
foreach ($auth as $index => $method) {
|
foreach ($auth as $index => $method) {
|
||||||
$auths[$method['key'] ?? ''] = true;
|
$auths[$method['key'] ?? ''] = true;
|
||||||
}
|
}
|
||||||
|
@ -576,6 +576,68 @@ App::patch('/v1/projects/:projectId/auth/:method')
|
||||||
$response->dynamic($project, Response::MODEL_PROJECT);
|
$response->dynamic($project, Response::MODEL_PROJECT);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
App::patch('/v1/projects/:projectId/auth/password-history')
|
||||||
|
->desc('Update authentication password history. Use this endpoint to set the number of password history to save and 0 to disable password history.')
|
||||||
|
->groups(['api', 'projects'])
|
||||||
|
->label('scope', 'projects.write')
|
||||||
|
->label('sdk.auth', [APP_AUTH_TYPE_ADMIN])
|
||||||
|
->label('sdk.namespace', 'projects')
|
||||||
|
->label('sdk.method', 'updateAuthPasswordHistory')
|
||||||
|
->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('limit', 0, new Range(0, APP_LIMIT_USER_PASSWORD_HISTORY), 'Set the max number of passwords to store in user history. User can\'t choose a new password that is already stored in the password history list. Max number of passwords allowed in history is' . APP_LIMIT_USER_PASSWORD_HISTORY . '. Default value is 0')
|
||||||
|
->inject('response')
|
||||||
|
->inject('dbForConsole')
|
||||||
|
->action(function (string $projectId, int $limit, Response $response, Database $dbForConsole) {
|
||||||
|
|
||||||
|
$project = $dbForConsole->getDocument('projects', $projectId);
|
||||||
|
|
||||||
|
if ($project->isEmpty()) {
|
||||||
|
throw new Exception(Exception::PROJECT_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
$auths = $project->getAttribute('auths', []);
|
||||||
|
$auths['passwordHistory'] = $limit;
|
||||||
|
|
||||||
|
$dbForConsole->updateDocument('projects', $project->getId(), $project
|
||||||
|
->setAttribute('auths', $auths));
|
||||||
|
|
||||||
|
$response->dynamic($project, Response::MODEL_PROJECT);
|
||||||
|
});
|
||||||
|
|
||||||
|
App::patch('/v1/projects/:projectId/auth/password-dictionary')
|
||||||
|
->desc('Update authentication password disctionary status. Use this endpoint to enable or disable the dicitonary check for user password')
|
||||||
|
->groups(['api', 'projects'])
|
||||||
|
->label('scope', 'projects.write')
|
||||||
|
->label('sdk.auth', [APP_AUTH_TYPE_ADMIN])
|
||||||
|
->label('sdk.namespace', 'projects')
|
||||||
|
->label('sdk.method', 'updateAuthPasswordDictionary')
|
||||||
|
->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 most commonly used passwords. Default is false.')
|
||||||
|
->inject('response')
|
||||||
|
->inject('dbForConsole')
|
||||||
|
->action(function (string $projectId, bool $enabled, 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;
|
||||||
|
|
||||||
|
$dbForConsole->updateDocument('projects', $project->getId(), $project
|
||||||
|
->setAttribute('auths', $auths));
|
||||||
|
|
||||||
|
$response->dynamic($project, Response::MODEL_PROJECT);
|
||||||
|
});
|
||||||
|
|
||||||
App::patch('/v1/projects/:projectId/auth/max-sessions')
|
App::patch('/v1/projects/:projectId/auth/max-sessions')
|
||||||
->desc('Update Project user sessions limit')
|
->desc('Update Project user sessions limit')
|
||||||
->groups(['api', 'projects'])
|
->groups(['api', 'projects'])
|
||||||
|
|
|
@ -34,11 +34,14 @@ use Utopia\Validator\Text;
|
||||||
use Utopia\Validator\Boolean;
|
use Utopia\Validator\Boolean;
|
||||||
use MaxMind\Db\Reader;
|
use MaxMind\Db\Reader;
|
||||||
use Utopia\Validator\Integer;
|
use Utopia\Validator\Integer;
|
||||||
|
use Appwrite\Auth\Validator\PasswordHistory;
|
||||||
|
use Appwrite\Auth\Validator\PasswordDictionary;
|
||||||
|
|
||||||
/** TODO: Remove function when we move to using utopia/platform */
|
/** TODO: Remove function when we move to using utopia/platform */
|
||||||
function createUser(string $hash, mixed $hashOptions, string $userId, ?string $email, ?string $password, ?string $phone, string $name, Database $dbForProject, Event $events): Document
|
function createUser(string $hash, mixed $hashOptions, string $userId, ?string $email, ?string $password, ?string $phone, string $name, Document $project, Database $dbForProject, Event $events): Document
|
||||||
{
|
{
|
||||||
$hashOptionsObject = (\is_string($hashOptions)) ? \json_decode($hashOptions, true) : $hashOptions; // Cast to JSON array
|
$hashOptionsObject = (\is_string($hashOptions)) ? \json_decode($hashOptions, true) : $hashOptions; // Cast to JSON array
|
||||||
|
$passwordHistory = $project->getAttribute('auths', [])['passwordHistory'] ?? 0;
|
||||||
|
|
||||||
if (!empty($email)) {
|
if (!empty($email)) {
|
||||||
$email = \strtolower($email);
|
$email = \strtolower($email);
|
||||||
|
@ -49,6 +52,7 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e
|
||||||
? ID::unique()
|
? ID::unique()
|
||||||
: ID::custom($userId);
|
: ID::custom($userId);
|
||||||
|
|
||||||
|
$password = (!empty($password)) ? ($hash === 'plaintext' ? Auth::passwordHash($password, $hash, $hashOptionsObject) : $password) : null;
|
||||||
$user = $dbForProject->createDocument('users', new Document([
|
$user = $dbForProject->createDocument('users', new Document([
|
||||||
'$id' => $userId,
|
'$id' => $userId,
|
||||||
'$permissions' => [
|
'$permissions' => [
|
||||||
|
@ -61,10 +65,11 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e
|
||||||
'phone' => $phone,
|
'phone' => $phone,
|
||||||
'phoneVerification' => false,
|
'phoneVerification' => false,
|
||||||
'status' => true,
|
'status' => true,
|
||||||
'password' => (!empty($password)) ? ($hash === 'plaintext' ? Auth::passwordHash($password, $hash, $hashOptionsObject) : $password) : null,
|
'password' => $password,
|
||||||
|
'passwordHistory' => is_null($password) && $passwordHistory === 0 ? [] : [$password],
|
||||||
|
'passwordUpdate' => (!empty($password)) ? DateTime::now() : null,
|
||||||
'hash' => $hash === 'plaintext' ? Auth::DEFAULT_ALGO : $hash,
|
'hash' => $hash === 'plaintext' ? Auth::DEFAULT_ALGO : $hash,
|
||||||
'hashOptions' => $hash === 'plaintext' ? Auth::DEFAULT_ALGO_OPTIONS : $hashOptionsObject + ['type' => $hash],
|
'hashOptions' => $hash === 'plaintext' ? Auth::DEFAULT_ALGO_OPTIONS : $hashOptionsObject + ['type' => $hash],
|
||||||
'passwordUpdate' => (!empty($password)) ? DateTime::now() : null,
|
|
||||||
'registration' => DateTime::now(),
|
'registration' => DateTime::now(),
|
||||||
'reset' => false,
|
'reset' => false,
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
|
@ -101,13 +106,15 @@ App::post('/v1/users')
|
||||||
->param('userId', '', new CustomId(), 'User ID. Choose your own unique ID or pass the string `ID.unique()` to auto generate it. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.')
|
->param('userId', '', new CustomId(), 'User ID. Choose your own unique ID or pass the string `ID.unique()` to auto generate it. 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('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('phone', null, new Phone(), 'Phone number. Format this number with a leading \'+\' and a country code, e.g., +16175551212.', true)
|
||||||
->param('password', null, new Password(), 'Plain text user password. Must be at least 8 chars.', 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('name', '', new Text(128), 'User name. Max length: 128 chars.', true)
|
->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true)
|
||||||
->inject('response')
|
->inject('response')
|
||||||
|
->inject('project')
|
||||||
->inject('dbForProject')
|
->inject('dbForProject')
|
||||||
->inject('events')
|
->inject('events')
|
||||||
->action(function (string $userId, ?string $email, ?string $phone, ?string $password, string $name, Response $response, Database $dbForProject, Event $events) {
|
->action(function (string $userId, ?string $email, ?string $phone, ?string $password, string $name, Response $response, Document $project, Database $dbForProject, Event $events) {
|
||||||
$user = createUser('plaintext', '{}', $userId, $email, $password, $phone, $name, $dbForProject, $events);
|
|
||||||
|
$user = createUser('plaintext', '{}', $userId, $email, $password, $phone, $name, $project, $dbForProject, $events);
|
||||||
|
|
||||||
$response
|
$response
|
||||||
->setStatusCode(Response::STATUS_CODE_CREATED)
|
->setStatusCode(Response::STATUS_CODE_CREATED)
|
||||||
|
@ -134,10 +141,11 @@ App::post('/v1/users/bcrypt')
|
||||||
->param('password', '', new Password(), 'User password hashed using Bcrypt.')
|
->param('password', '', new Password(), 'User password hashed using Bcrypt.')
|
||||||
->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true)
|
->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true)
|
||||||
->inject('response')
|
->inject('response')
|
||||||
|
->inject('project')
|
||||||
->inject('dbForProject')
|
->inject('dbForProject')
|
||||||
->inject('events')
|
->inject('events')
|
||||||
->action(function (string $userId, string $email, string $password, string $name, Response $response, Database $dbForProject, Event $events) {
|
->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Event $events) {
|
||||||
$user = createUser('bcrypt', '{}', $userId, $email, $password, null, $name, $dbForProject, $events);
|
$user = createUser('bcrypt', '{}', $userId, $email, $password, null, $name, $project, $dbForProject, $events);
|
||||||
|
|
||||||
$response
|
$response
|
||||||
->setStatusCode(Response::STATUS_CODE_CREATED)
|
->setStatusCode(Response::STATUS_CODE_CREATED)
|
||||||
|
@ -164,10 +172,11 @@ App::post('/v1/users/md5')
|
||||||
->param('password', '', new Password(), 'User password hashed using MD5.')
|
->param('password', '', new Password(), 'User password hashed using MD5.')
|
||||||
->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true)
|
->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true)
|
||||||
->inject('response')
|
->inject('response')
|
||||||
|
->inject('project')
|
||||||
->inject('dbForProject')
|
->inject('dbForProject')
|
||||||
->inject('events')
|
->inject('events')
|
||||||
->action(function (string $userId, string $email, string $password, string $name, Response $response, Database $dbForProject, Event $events) {
|
->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Event $events) {
|
||||||
$user = createUser('md5', '{}', $userId, $email, $password, null, $name, $dbForProject, $events);
|
$user = createUser('md5', '{}', $userId, $email, $password, null, $name, $project, $dbForProject, $events);
|
||||||
|
|
||||||
$response
|
$response
|
||||||
->setStatusCode(Response::STATUS_CODE_CREATED)
|
->setStatusCode(Response::STATUS_CODE_CREATED)
|
||||||
|
@ -194,10 +203,11 @@ App::post('/v1/users/argon2')
|
||||||
->param('password', '', new Password(), 'User password hashed using Argon2.')
|
->param('password', '', new Password(), 'User password hashed using Argon2.')
|
||||||
->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true)
|
->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true)
|
||||||
->inject('response')
|
->inject('response')
|
||||||
|
->inject('project')
|
||||||
->inject('dbForProject')
|
->inject('dbForProject')
|
||||||
->inject('events')
|
->inject('events')
|
||||||
->action(function (string $userId, string $email, string $password, string $name, Response $response, Database $dbForProject, Event $events) {
|
->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Event $events) {
|
||||||
$user = createUser('argon2', '{}', $userId, $email, $password, null, $name, $dbForProject, $events);
|
$user = createUser('argon2', '{}', $userId, $email, $password, null, $name, $project, $dbForProject, $events);
|
||||||
|
|
||||||
$response
|
$response
|
||||||
->setStatusCode(Response::STATUS_CODE_CREATED)
|
->setStatusCode(Response::STATUS_CODE_CREATED)
|
||||||
|
@ -225,16 +235,17 @@ App::post('/v1/users/sha')
|
||||||
->param('passwordVersion', '', new WhiteList(['sha1', 'sha224', 'sha256', 'sha384', 'sha512/224', 'sha512/256', 'sha512', 'sha3-224', 'sha3-256', 'sha3-384', 'sha3-512']), "Optional SHA version used to hash password. Allowed values are: 'sha1', 'sha224', 'sha256', 'sha384', 'sha512/224', 'sha512/256', 'sha512', 'sha3-224', 'sha3-256', 'sha3-384', 'sha3-512'", true)
|
->param('passwordVersion', '', new WhiteList(['sha1', 'sha224', 'sha256', 'sha384', 'sha512/224', 'sha512/256', 'sha512', 'sha3-224', 'sha3-256', 'sha3-384', 'sha3-512']), "Optional SHA version used to hash password. Allowed values are: 'sha1', 'sha224', 'sha256', 'sha384', 'sha512/224', 'sha512/256', 'sha512', 'sha3-224', 'sha3-256', 'sha3-384', 'sha3-512'", true)
|
||||||
->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true)
|
->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true)
|
||||||
->inject('response')
|
->inject('response')
|
||||||
|
->inject('project')
|
||||||
->inject('dbForProject')
|
->inject('dbForProject')
|
||||||
->inject('events')
|
->inject('events')
|
||||||
->action(function (string $userId, string $email, string $password, string $passwordVersion, string $name, Response $response, Database $dbForProject, Event $events) {
|
->action(function (string $userId, string $email, string $password, string $passwordVersion, string $name, Response $response, Document $project, Database $dbForProject, Event $events) {
|
||||||
$options = '{}';
|
$options = '{}';
|
||||||
|
|
||||||
if (!empty($passwordVersion)) {
|
if (!empty($passwordVersion)) {
|
||||||
$options = '{"version":"' . $passwordVersion . '"}';
|
$options = '{"version":"' . $passwordVersion . '"}';
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = createUser('sha', $options, $userId, $email, $password, null, $name, $dbForProject, $events);
|
$user = createUser('sha', $options, $userId, $email, $password, null, $name, $project, $dbForProject, $events);
|
||||||
|
|
||||||
$response
|
$response
|
||||||
->setStatusCode(Response::STATUS_CODE_CREATED)
|
->setStatusCode(Response::STATUS_CODE_CREATED)
|
||||||
|
@ -261,10 +272,11 @@ App::post('/v1/users/phpass')
|
||||||
->param('password', '', new Password(), 'User password hashed using PHPass.')
|
->param('password', '', new Password(), 'User password hashed using PHPass.')
|
||||||
->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true)
|
->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true)
|
||||||
->inject('response')
|
->inject('response')
|
||||||
|
->inject('project')
|
||||||
->inject('dbForProject')
|
->inject('dbForProject')
|
||||||
->inject('events')
|
->inject('events')
|
||||||
->action(function (string $userId, string $email, string $password, string $name, Response $response, Database $dbForProject, Event $events) {
|
->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Event $events) {
|
||||||
$user = createUser('phpass', '{}', $userId, $email, $password, null, $name, $dbForProject, $events);
|
$user = createUser('phpass', '{}', $userId, $email, $password, null, $name, $project, $dbForProject, $events);
|
||||||
|
|
||||||
$response
|
$response
|
||||||
->setStatusCode(Response::STATUS_CODE_CREATED)
|
->setStatusCode(Response::STATUS_CODE_CREATED)
|
||||||
|
@ -296,9 +308,10 @@ App::post('/v1/users/scrypt')
|
||||||
->param('passwordLength', 64, new Integer(), 'Optional hash length used to hash password.')
|
->param('passwordLength', 64, new Integer(), 'Optional hash length used to hash password.')
|
||||||
->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true)
|
->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true)
|
||||||
->inject('response')
|
->inject('response')
|
||||||
|
->inject('project')
|
||||||
->inject('dbForProject')
|
->inject('dbForProject')
|
||||||
->inject('events')
|
->inject('events')
|
||||||
->action(function (string $userId, string $email, string $password, string $passwordSalt, int $passwordCpu, int $passwordMemory, int $passwordParallel, int $passwordLength, string $name, Response $response, Database $dbForProject, Event $events) {
|
->action(function (string $userId, string $email, string $password, string $passwordSalt, int $passwordCpu, int $passwordMemory, int $passwordParallel, int $passwordLength, string $name, Response $response, Document $project, Database $dbForProject, Event $events) {
|
||||||
$options = [
|
$options = [
|
||||||
'salt' => $passwordSalt,
|
'salt' => $passwordSalt,
|
||||||
'costCpu' => $passwordCpu,
|
'costCpu' => $passwordCpu,
|
||||||
|
@ -307,7 +320,7 @@ App::post('/v1/users/scrypt')
|
||||||
'length' => $passwordLength
|
'length' => $passwordLength
|
||||||
];
|
];
|
||||||
|
|
||||||
$user = createUser('scrypt', \json_encode($options), $userId, $email, $password, null, $name, $dbForProject, $events);
|
$user = createUser('scrypt', \json_encode($options), $userId, $email, $password, null, $name, $project, $dbForProject, $events);
|
||||||
|
|
||||||
$response
|
$response
|
||||||
->setStatusCode(Response::STATUS_CODE_CREATED)
|
->setStatusCode(Response::STATUS_CODE_CREATED)
|
||||||
|
@ -337,10 +350,11 @@ App::post('/v1/users/scrypt-modified')
|
||||||
->param('passwordSignerKey', '', new Text(128), 'Signer key used to hash password.')
|
->param('passwordSignerKey', '', new Text(128), 'Signer key used to hash password.')
|
||||||
->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true)
|
->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true)
|
||||||
->inject('response')
|
->inject('response')
|
||||||
|
->inject('project')
|
||||||
->inject('dbForProject')
|
->inject('dbForProject')
|
||||||
->inject('events')
|
->inject('events')
|
||||||
->action(function (string $userId, string $email, string $password, string $passwordSalt, string $passwordSaltSeparator, string $passwordSignerKey, string $name, Response $response, Database $dbForProject, Event $events) {
|
->action(function (string $userId, string $email, string $password, string $passwordSalt, string $passwordSaltSeparator, string $passwordSignerKey, string $name, Response $response, Document $project, Database $dbForProject, Event $events) {
|
||||||
$user = createUser('scryptMod', '{"signerKey":"' . $passwordSignerKey . '","saltSeparator":"' . $passwordSaltSeparator . '","salt":"' . $passwordSalt . '"}', $userId, $email, $password, null, $name, $dbForProject, $events);
|
$user = createUser('scryptMod', '{"signerKey":"' . $passwordSignerKey . '","saltSeparator":"' . $passwordSaltSeparator . '","salt":"' . $passwordSalt . '"}', $userId, $email, $password, null, $name, $project, $dbForProject, $events);
|
||||||
|
|
||||||
$response
|
$response
|
||||||
->setStatusCode(Response::STATUS_CODE_CREATED)
|
->setStatusCode(Response::STATUS_CODE_CREATED)
|
||||||
|
@ -779,11 +793,12 @@ App::patch('/v1/users/:userId/password')
|
||||||
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
|
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
|
||||||
->label('sdk.response.model', Response::MODEL_USER)
|
->label('sdk.response.model', Response::MODEL_USER)
|
||||||
->param('userId', '', new UID(), 'User ID.')
|
->param('userId', '', new UID(), 'User ID.')
|
||||||
->param('password', '', new Password(), 'New user password. Must be at least 8 chars.')
|
->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'])
|
||||||
->inject('response')
|
->inject('response')
|
||||||
|
->inject('project')
|
||||||
->inject('dbForProject')
|
->inject('dbForProject')
|
||||||
->inject('events')
|
->inject('events')
|
||||||
->action(function (string $userId, string $password, Response $response, Database $dbForProject, Event $events) {
|
->action(function (string $userId, string $password, Response $response, Document $project, Database $dbForProject, Event $events) {
|
||||||
|
|
||||||
$user = $dbForProject->getDocument('users', $userId);
|
$user = $dbForProject->getDocument('users', $userId);
|
||||||
|
|
||||||
|
@ -791,11 +806,27 @@ App::patch('/v1/users/:userId/password')
|
||||||
throw new Exception(Exception::USER_NOT_FOUND);
|
throw new Exception(Exception::USER_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$newPassword = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS);
|
||||||
|
|
||||||
|
$historyLimit = $project->getAttribute('auths', [])['passwordHistory'] ?? 0;
|
||||||
|
$history = [];
|
||||||
|
if ($historyLimit > 0) {
|
||||||
|
$history = $user->getAttribute('passwordHistory', []);
|
||||||
|
$validator = new PasswordHistory($history, $user->getAttribute('hash'), $user->getAttribute('hashOptions'));
|
||||||
|
if (!$validator->isValid($password)) {
|
||||||
|
throw new Exception(Exception::USER_PASSWORD_RECENTLY_USED, 'The password was recently used', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$history[] = $newPassword;
|
||||||
|
array_slice($history, (count($history) - $historyLimit), $historyLimit);
|
||||||
|
}
|
||||||
|
|
||||||
$user
|
$user
|
||||||
->setAttribute('password', Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS))
|
->setAttribute('password', $newPassword)
|
||||||
|
->setAttribute('passwordHistory', $history)
|
||||||
|
->setAttribute('passwordUpdate', DateTime::now())
|
||||||
->setAttribute('hash', Auth::DEFAULT_ALGO)
|
->setAttribute('hash', Auth::DEFAULT_ALGO)
|
||||||
->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS)
|
->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS);
|
||||||
->setAttribute('passwordUpdate', DateTime::now());
|
|
||||||
|
|
||||||
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
|
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
|
||||||
|
|
||||||
|
|
12
app/init.php
12
app/init.php
|
@ -85,6 +85,7 @@ const APP_MODE_ADMIN = 'admin';
|
||||||
const APP_PAGING_LIMIT = 12;
|
const APP_PAGING_LIMIT = 12;
|
||||||
const APP_LIMIT_COUNT = 5000;
|
const APP_LIMIT_COUNT = 5000;
|
||||||
const APP_LIMIT_USERS = 10000;
|
const APP_LIMIT_USERS = 10000;
|
||||||
|
const APP_LIMIT_USER_PASSWORD_HISTORY = 20;
|
||||||
const APP_LIMIT_USER_SESSIONS_MAX = 100;
|
const APP_LIMIT_USER_SESSIONS_MAX = 100;
|
||||||
const APP_LIMIT_USER_SESSIONS_DEFAULT = 10;
|
const APP_LIMIT_USER_SESSIONS_DEFAULT = 10;
|
||||||
const APP_LIMIT_ANTIVIRUS = 20000000; //20MB
|
const APP_LIMIT_ANTIVIRUS = 20000000; //20MB
|
||||||
|
@ -606,6 +607,12 @@ $register->set('smtp', function () {
|
||||||
$register->set('geodb', function () {
|
$register->set('geodb', function () {
|
||||||
return new Reader(__DIR__ . '/assets/dbip/dbip-country-lite-2022-06.mmdb');
|
return new Reader(__DIR__ . '/assets/dbip/dbip-country-lite-2022-06.mmdb');
|
||||||
});
|
});
|
||||||
|
$register->set('passwordsDictionary', function () {
|
||||||
|
$content = \file_get_contents(__DIR__ . '/assets/security/10k-common-passwords');
|
||||||
|
$content = explode("\n", $content);
|
||||||
|
$content = array_flip($content);
|
||||||
|
return $content;
|
||||||
|
});
|
||||||
$register->set('db', function () {
|
$register->set('db', function () {
|
||||||
// This is usually for our workers or CLI commands scope
|
// This is usually for our workers or CLI commands scope
|
||||||
$dbHost = App::getEnv('_APP_DB_HOST', '');
|
$dbHost = App::getEnv('_APP_DB_HOST', '');
|
||||||
|
@ -1026,6 +1033,11 @@ App::setResource('geodb', function ($register) {
|
||||||
return $register->get('geodb');
|
return $register->get('geodb');
|
||||||
}, ['register']);
|
}, ['register']);
|
||||||
|
|
||||||
|
App::setResource('passwordsDictionary', function ($register) {
|
||||||
|
/** @var Utopia\Registry\Registry $register */
|
||||||
|
return $register->get('passwordsDictionary');
|
||||||
|
}, ['register']);
|
||||||
|
|
||||||
App::setResource('sms', function () {
|
App::setResource('sms', function () {
|
||||||
$dsn = new DSN(App::getEnv('_APP_SMS_PROVIDER'));
|
$dsn = new DSN(App::getEnv('_APP_SMS_PROVIDER'));
|
||||||
$user = $dsn->getUser();
|
$user = $dsn->getUser();
|
||||||
|
|
77
src/Appwrite/Auth/Validator/PasswordDictionary.php
Normal file
77
src/Appwrite/Auth/Validator/PasswordDictionary.php
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Appwrite\Auth\Validator;
|
||||||
|
|
||||||
|
use Utopia\Database\Document;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Password.
|
||||||
|
*
|
||||||
|
* Validates user password string
|
||||||
|
*/
|
||||||
|
class PasswordDictionary extends Password
|
||||||
|
{
|
||||||
|
protected array $dictionary;
|
||||||
|
protected bool $enabled;
|
||||||
|
|
||||||
|
public function __construct(array $dictionary, bool $enabled = false)
|
||||||
|
{
|
||||||
|
$this->dictionary = $dictionary;
|
||||||
|
$this->enabled = $enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Description.
|
||||||
|
*
|
||||||
|
* Returns validator description
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Password must be at least 8 characters and should not be one of the commonly used password.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is valid.
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isValid($value): bool
|
||||||
|
{
|
||||||
|
if (!parent::isValid($value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->enabled && array_key_exists($value, $this->dictionary)) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
75
src/Appwrite/Auth/Validator/PasswordHistory.php
Normal file
75
src/Appwrite/Auth/Validator/PasswordHistory.php
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Appwrite\Auth\Validator;
|
||||||
|
|
||||||
|
use Appwrite\Auth\Auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Password.
|
||||||
|
*
|
||||||
|
* Validates user password string
|
||||||
|
*/
|
||||||
|
class PasswordHistory extends Password
|
||||||
|
{
|
||||||
|
protected array $history;
|
||||||
|
|
||||||
|
public function __construct(array $history, string $algo, array $algoOptions = [])
|
||||||
|
{
|
||||||
|
$this->history = $history;
|
||||||
|
$this->algo = $algo;
|
||||||
|
$this->algoOptions = $algoOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Description.
|
||||||
|
*
|
||||||
|
* Returns validator description
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Password shouldn\'t be in the history.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is valid.
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isValid($value): bool
|
||||||
|
{
|
||||||
|
foreach ($this->history as $hash) {
|
||||||
|
if (Auth::passwordVerify($value, $hash, $this->algo, $this->algoOptions)) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -65,6 +65,7 @@ class Exception extends \Exception
|
||||||
public const USER_ANONYMOUS_CONSOLE_PROHIBITED = 'user_anonymous_console_prohibited';
|
public const USER_ANONYMOUS_CONSOLE_PROHIBITED = 'user_anonymous_console_prohibited';
|
||||||
public const USER_SESSION_ALREADY_EXISTS = 'user_session_already_exists';
|
public const USER_SESSION_ALREADY_EXISTS = 'user_session_already_exists';
|
||||||
public const USER_NOT_FOUND = 'user_not_found';
|
public const USER_NOT_FOUND = 'user_not_found';
|
||||||
|
public const USER_PASSWORD_RECENTLY_USED = 'password_recently_used';
|
||||||
public const USER_EMAIL_ALREADY_EXISTS = 'user_email_already_exists';
|
public const USER_EMAIL_ALREADY_EXISTS = 'user_email_already_exists';
|
||||||
public const USER_PASSWORD_MISMATCH = 'user_password_mismatch';
|
public const USER_PASSWORD_MISMATCH = 'user_password_mismatch';
|
||||||
public const USER_SESSION_NOT_FOUND = 'user_session_not_found';
|
public const USER_SESSION_NOT_FOUND = 'user_session_not_found';
|
||||||
|
|
104
src/Appwrite/Migration/Version/V18.php
Normal file
104
src/Appwrite/Migration/Version/V18.php
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Appwrite\Migration\Version;
|
||||||
|
|
||||||
|
use Appwrite\Auth\Auth;
|
||||||
|
use Appwrite\Migration\Migration;
|
||||||
|
use Utopia\CLI\Console;
|
||||||
|
use Utopia\Config\Config;
|
||||||
|
use Utopia\Database\Database;
|
||||||
|
use Utopia\Database\Document;
|
||||||
|
|
||||||
|
class V18 extends Migration
|
||||||
|
{
|
||||||
|
public function execute(): void
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Disable SubQueries for Performance.
|
||||||
|
*/
|
||||||
|
foreach (['subQueryIndexes', 'subQueryPlatforms', 'subQueryDomains', 'subQueryKeys', 'subQueryWebhooks', 'subQuerySessions', 'subQueryTokens', 'subQueryMemberships', 'subqueryVariables'] as $name) {
|
||||||
|
Database::addFilter(
|
||||||
|
$name,
|
||||||
|
fn () => null,
|
||||||
|
fn () => []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Console::log('Migrating Project: ' . $this->project->getAttribute('name') . ' (' . $this->project->getId() . ')');
|
||||||
|
|
||||||
|
Console::info('Migrating Collections');
|
||||||
|
$this->migrateCollections();
|
||||||
|
|
||||||
|
Console::info('Migrating Documents');
|
||||||
|
$this->forEachDocument([$this, 'fixDocument']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate all Collections.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function migrateCollections(): void
|
||||||
|
{
|
||||||
|
foreach ($this->collections as $collection) {
|
||||||
|
$id = $collection['$id'];
|
||||||
|
|
||||||
|
Console::log("Migrating Collection \"{$id}\"");
|
||||||
|
|
||||||
|
$this->projectDB->setNamespace("_{$this->project->getInternalId()}");
|
||||||
|
|
||||||
|
switch ($id) {
|
||||||
|
case 'users':
|
||||||
|
try {
|
||||||
|
/**
|
||||||
|
* Create 'passwordHistory' attribute
|
||||||
|
*/
|
||||||
|
$this->createAttributeFromCollection($this->projectDB, $id, 'passwordHistory');
|
||||||
|
$this->projectDB->deleteCachedCollection($id);
|
||||||
|
} catch (\Throwable $th) {
|
||||||
|
Console::warning("'passwordHistory' from {$id}: {$th->getMessage()}");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
usleep(50000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix run on each document
|
||||||
|
*
|
||||||
|
* @param \Utopia\Database\Document $document
|
||||||
|
* @return \Utopia\Database\Document
|
||||||
|
*/
|
||||||
|
protected function fixDocument(Document $document)
|
||||||
|
{
|
||||||
|
switch ($document->getCollection()) {
|
||||||
|
case 'projects':
|
||||||
|
/**
|
||||||
|
* Bump version number.
|
||||||
|
*/
|
||||||
|
$document->setAttribute('version', '1.2.0');
|
||||||
|
break;
|
||||||
|
case 'projects':
|
||||||
|
/**
|
||||||
|
* Bump version number.
|
||||||
|
*/
|
||||||
|
$document->setAttribute('passwordHistory', []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set default passwordHistory
|
||||||
|
*/
|
||||||
|
$document->setAttribute('auths', array_merge($document->getAttribute('auths', []), [
|
||||||
|
'passwordHistory' => 0,
|
||||||
|
'passwordDictionary' => false,
|
||||||
|
]));
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $document;
|
||||||
|
}
|
||||||
|
}
|
|
@ -120,6 +120,18 @@ class Project extends Model
|
||||||
'default' => 10,
|
'default' => 10,
|
||||||
'example' => 10,
|
'example' => 10,
|
||||||
])
|
])
|
||||||
|
->addRule('authPasswordHistory', [
|
||||||
|
'type' => self::TYPE_INTEGER,
|
||||||
|
'description' => 'Max allowed passwords in the history list per user. Max passwords limit allowed in history is 20. Use 0 for disabling password history.',
|
||||||
|
'default' => 0,
|
||||||
|
'example' => 5,
|
||||||
|
])
|
||||||
|
->addRule('authPasswordDictionary', [
|
||||||
|
'type' => self::TYPE_BOOLEAN,
|
||||||
|
'description' => 'Whether or not to check user\'s password against most commonly used passwords.',
|
||||||
|
'default' => false,
|
||||||
|
'example' => true,
|
||||||
|
])
|
||||||
->addRule('providers', [
|
->addRule('providers', [
|
||||||
'type' => Response::MODEL_PROVIDER,
|
'type' => Response::MODEL_PROVIDER,
|
||||||
'description' => 'List of Providers.',
|
'description' => 'List of Providers.',
|
||||||
|
@ -240,6 +252,8 @@ class Project extends Model
|
||||||
$document->setAttribute('authLimit', $authValues['limit'] ?? 0);
|
$document->setAttribute('authLimit', $authValues['limit'] ?? 0);
|
||||||
$document->setAttribute('authDuration', $authValues['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG);
|
$document->setAttribute('authDuration', $authValues['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG);
|
||||||
$document->setAttribute('authSessionsLimit', $authValues['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT);
|
$document->setAttribute('authSessionsLimit', $authValues['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT);
|
||||||
|
$document->setAttribute('authPasswordHistory', $authValues['passwordHistory'] ?? 0);
|
||||||
|
$document->setAttribute('authPasswordDictionary', $authValues['passwordDictionary'] ?? false);
|
||||||
|
|
||||||
foreach ($auth as $index => $method) {
|
foreach ($auth as $index => $method) {
|
||||||
$key = $method['key'];
|
$key = $method['key'];
|
||||||
|
|
|
@ -992,6 +992,247 @@ class ProjectsConsoleClientTest extends Scope
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @depends testUpdateProjectAuthLimit
|
||||||
|
*/
|
||||||
|
public function testUpdateProjectAuthPasswordHistory($data): array
|
||||||
|
{
|
||||||
|
$id = $data['projectId'] ?? '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test for Failure
|
||||||
|
*/
|
||||||
|
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([
|
||||||
|
'content-type' => 'application/json',
|
||||||
|
'x-appwrite-project' => $this->getProject()['$id'],
|
||||||
|
], $this->getHeaders()), [
|
||||||
|
'limit' => 25,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertEquals(400, $response['headers']['status-code']);
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test for Success
|
||||||
|
*/
|
||||||
|
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([
|
||||||
|
'content-type' => 'application/json',
|
||||||
|
'x-appwrite-project' => $this->getProject()['$id'],
|
||||||
|
], $this->getHeaders()), [
|
||||||
|
'limit' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertEquals(200, $response['headers']['status-code']);
|
||||||
|
$this->assertEquals(1, $response['body']['authPasswordHistory']);
|
||||||
|
|
||||||
|
|
||||||
|
$email = uniqid() . 'user@localhost.test';
|
||||||
|
$password = 'password';
|
||||||
|
$name = 'User Name';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new user
|
||||||
|
*/
|
||||||
|
$response = $this->client->call(Client::METHOD_POST, '/account', array_merge([
|
||||||
|
'origin' => 'http://localhost',
|
||||||
|
'content-type' => 'application/json',
|
||||||
|
'x-appwrite-project' => $id,
|
||||||
|
]), [
|
||||||
|
'userId' => ID::unique(),
|
||||||
|
'email' => $email,
|
||||||
|
'password' => $password,
|
||||||
|
'name' => $name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertEquals(201, $response['headers']['status-code']);
|
||||||
|
|
||||||
|
$userId = $response['body']['$id'];
|
||||||
|
|
||||||
|
// create session
|
||||||
|
$session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [
|
||||||
|
'origin' => 'http://localhost',
|
||||||
|
'content-type' => 'application/json',
|
||||||
|
'x-appwrite-project' => $id,
|
||||||
|
], [
|
||||||
|
'email' => $email,
|
||||||
|
'password' => $password,
|
||||||
|
]);
|
||||||
|
$this->assertEquals(201, $session['headers']['status-code']);
|
||||||
|
$session = $this->client->parseCookie((string)$session['headers']['set-cookie'])['a_session_' . $id];
|
||||||
|
|
||||||
|
$response = $this->client->call(Client::METHOD_PATCH, '/account/password', array_merge([
|
||||||
|
'origin' => 'http://localhost',
|
||||||
|
'content-type' => 'application/json',
|
||||||
|
'x-appwrite-project' => $id,
|
||||||
|
'cookie' => 'a_session_' . $id . '=' . $session,
|
||||||
|
]), [
|
||||||
|
'oldPassword' => $password,
|
||||||
|
'password' => $password,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertEquals(409, $response['headers']['status-code']);
|
||||||
|
|
||||||
|
$headers = array_merge($this->getHeaders(), [
|
||||||
|
'x-appwrite-mode' => 'admin',
|
||||||
|
'content-type' => 'application/json',
|
||||||
|
'x-appwrite-project' => $id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->client->call(Client::METHOD_PATCH, '/users/' . $userId . '/password', $headers, [
|
||||||
|
'password' => $password,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertEquals(409, $response['headers']['status-code']);
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset
|
||||||
|
*/
|
||||||
|
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([
|
||||||
|
'content-type' => 'application/json',
|
||||||
|
'x-appwrite-project' => $this->getProject()['$id'],
|
||||||
|
], $this->getHeaders()), [
|
||||||
|
'limit' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertEquals(200, $response['headers']['status-code']);
|
||||||
|
$this->assertEquals(0, $response['body']['authPasswordHistory']);
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @depends testUpdateProjectAuthLimit
|
||||||
|
*/
|
||||||
|
public function testUpdateProjectAuthPasswordDictionary($data): array
|
||||||
|
{
|
||||||
|
$id = $data['projectId'] ?? '';
|
||||||
|
|
||||||
|
$password = 'password';
|
||||||
|
$name = 'User Name';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test for Success
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* create account
|
||||||
|
*/
|
||||||
|
$response = $this->client->call(Client::METHOD_POST, '/account', array_merge([
|
||||||
|
'origin' => 'http://localhost',
|
||||||
|
'content-type' => 'application/json',
|
||||||
|
'x-appwrite-project' => $id,
|
||||||
|
]), [
|
||||||
|
'userId' => ID::unique(),
|
||||||
|
'email' => uniqid() . 'user@localhost.test',
|
||||||
|
'password' => $password,
|
||||||
|
'name' => $name,
|
||||||
|
]);
|
||||||
|
$this->assertEquals(201, $response['headers']['status-code']);
|
||||||
|
$userId = $response['body']['$id'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create user
|
||||||
|
*/
|
||||||
|
$user = $this->client->call(Client::METHOD_POST, '/users', array_merge($this->getHeaders(), [
|
||||||
|
'content-type' => 'application/json',
|
||||||
|
'x-appwrite-project' => $id,
|
||||||
|
'x-appwrite-mode' => 'admin',
|
||||||
|
]), [
|
||||||
|
'userId' => ID::unique(),
|
||||||
|
'email' => uniqid() . 'user@localhost.test',
|
||||||
|
'password' => 'password',
|
||||||
|
'name' => 'Cristiano Ronaldo',
|
||||||
|
]);
|
||||||
|
$this->assertEquals(201, $response['headers']['status-code']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable Disctionary
|
||||||
|
*/
|
||||||
|
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-dictionary', array_merge([
|
||||||
|
'content-type' => 'application/json',
|
||||||
|
'x-appwrite-project' => $this->getProject()['$id'],
|
||||||
|
], $this->getHeaders()), [
|
||||||
|
'enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertEquals(200, $response['headers']['status-code']);
|
||||||
|
$this->assertEquals(true, $response['body']['authPasswordDictionary']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test for failure
|
||||||
|
*/
|
||||||
|
$response = $this->client->call(Client::METHOD_POST, '/account', array_merge([
|
||||||
|
'origin' => 'http://localhost',
|
||||||
|
'content-type' => 'application/json',
|
||||||
|
'x-appwrite-project' => $id,
|
||||||
|
]), [
|
||||||
|
'userId' => ID::unique(),
|
||||||
|
'email' => uniqid() . 'user@localhost.test',
|
||||||
|
'password' => $password,
|
||||||
|
'name' => $name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertEquals(400, $response['headers']['status-code']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create user
|
||||||
|
*/
|
||||||
|
$user = $this->client->call(Client::METHOD_POST, '/users', array_merge($this->getHeaders(), [
|
||||||
|
'content-type' => 'application/json',
|
||||||
|
'x-appwrite-project' => $id,
|
||||||
|
'x-appwrite-mode' => 'admin',
|
||||||
|
]), [
|
||||||
|
'userId' => ID::unique(),
|
||||||
|
'email' => uniqid() . 'user@localhost.test',
|
||||||
|
'password' => 'password',
|
||||||
|
'name' => 'Cristiano Ronaldo',
|
||||||
|
]);
|
||||||
|
$this->assertEquals(400, $response['headers']['status-code']);
|
||||||
|
|
||||||
|
$headers = array_merge($this->getHeaders(), [
|
||||||
|
'x-appwrite-mode' => 'admin',
|
||||||
|
'content-type' => 'application/json',
|
||||||
|
'x-appwrite-project' => $id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->client->call(Client::METHOD_PATCH, '/users/' . $userId . '/password', $headers, [
|
||||||
|
'password' => $password,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertEquals(400, $response['headers']['status-code']);
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset
|
||||||
|
*/
|
||||||
|
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([
|
||||||
|
'content-type' => 'application/json',
|
||||||
|
'x-appwrite-project' => $this->getProject()['$id'],
|
||||||
|
], $this->getHeaders()), [
|
||||||
|
'limit' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertEquals(200, $response['headers']['status-code']);
|
||||||
|
$this->assertEquals(0, $response['body']['authPasswordHistory']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset
|
||||||
|
*/
|
||||||
|
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-dictionary', array_merge([
|
||||||
|
'content-type' => 'application/json',
|
||||||
|
'x-appwrite-project' => $this->getProject()['$id'],
|
||||||
|
], $this->getHeaders()), [
|
||||||
|
'enabled' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertEquals(200, $response['headers']['status-code']);
|
||||||
|
$this->assertEquals(false, $response['body']['authPasswordDictionary']);
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public function testUpdateProjectServiceStatusAdmin(): array
|
public function testUpdateProjectServiceStatusAdmin(): array
|
||||||
{
|
{
|
||||||
$team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([
|
$team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([
|
||||||
|
|
28
tests/unit/Auth/Validator/PasswordDictionaryTest.php
Normal file
28
tests/unit/Auth/Validator/PasswordDictionaryTest.php
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Auth\Validator;
|
||||||
|
|
||||||
|
use Appwrite\Auth\Validator\PasswordDictionary;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Utopia\Database\Document;
|
||||||
|
|
||||||
|
class PasswordDictionaryTest extends TestCase
|
||||||
|
{
|
||||||
|
protected ?PasswordDictionary $object = null;
|
||||||
|
|
||||||
|
public function setUp(): void
|
||||||
|
{
|
||||||
|
$this->object = new PasswordDictionary(
|
||||||
|
['password' => true, '123456' => true],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValues(): void
|
||||||
|
{
|
||||||
|
$this->assertEquals($this->object->isValid('1'), false); // to check parent is being called
|
||||||
|
$this->assertEquals($this->object->isValid('123456'), false);
|
||||||
|
$this->assertEquals($this->object->isValid('password'), false);
|
||||||
|
$this->assertEquals($this->object->isValid('myPasswordIsRight'), true);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue