1
0
Fork 0
mirror of synced 2024-07-07 23:46:11 +12:00

Merge pull request #4906 from appwrite/feat-password-dictionary

password dictionary
This commit is contained in:
Eldad A. Fux 2023-02-20 11:42:42 +02:00 committed by GitHub
commit cf0b039653
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 10310 additions and 23 deletions

View file

@ -1,6 +1,7 @@
## 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

File diff suppressed because it is too large Load diff

View file

@ -41,6 +41,7 @@ use Utopia\Validator\Assoc;
use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
use Appwrite\Auth\Validator\PasswordHistory;
use Appwrite\Auth\Validator\PasswordDictionary;
$oauthDefaultSuccess = '/auth/oauth2/success';
$oauthDefaultFailure = '/auth/oauth2/failure';
@ -65,7 +66,7 @@ App::post('/v1/account')
->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('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)
->inject('request')
->inject('response')
@ -73,7 +74,6 @@ App::post('/v1/account')
->inject('dbForProject')
->inject('events')
->action(function (string $userId, string $email, string $password, string $name, Request $request, Response $response, Document $project, Database $dbForProject, Event $events) {
$email = \strtolower($email);
if ('console' === $project->getId()) {
$whitelistEmails = $project->getAttribute('authWhitelistEmails');
@ -99,7 +99,6 @@ App::post('/v1/account')
}
$passwordHistory = $project->getAttribute('auths', [])['passwordHistory'] ?? 0;
$password = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS);
try {
$userId = $userId == 'unique()' ? ID::unique() : $userId;
@ -113,11 +112,11 @@ App::post('/v1/account')
'email' => $email,
'emailVerification' => false,
'status' => true,
'passwordHistory' => $passwordHistory > 0 ? [$password] : [],
'password' => $password,
'passwordHistory' => $passwordHistory > 0 ? [$password] : [],
'passwordUpdate' => DateTime::now(),
'hash' => Auth::DEFAULT_ALGO,
'hashOptions' => Auth::DEFAULT_ALGO_OPTIONS,
'passwordUpdate' => DateTime::now(),
'registration' => DateTime::now(),
'reset' => false,
'name' => $name,
@ -1534,7 +1533,7 @@ App::patch('/v1/account/password')
->label('sdk.response.code', Response::STATUS_CODE_OK)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->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)
->inject('response')
->inject('user')
@ -1549,7 +1548,6 @@ App::patch('/v1/account/password')
}
$newPassword = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS);
$historyLimit = $project->getAttribute('auths', [])['passwordHistory'] ?? 0;
$history = [];
if ($historyLimit > 0) {
@ -1564,11 +1562,11 @@ App::patch('/v1/account/password')
}
$user = $dbForProject->updateDocument('users', $user->getId(), $user
->setAttribute('passwordHistory', $history)
->setAttribute('password', $newPassword)
->setAttribute('passwordHistory', $history)
->setAttribute('passwordUpdate', DateTime::now()))
->setAttribute('hash', Auth::DEFAULT_ALGO)
->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS)
->setAttribute('passwordUpdate', DateTime::now()));
->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS);
$events->setParam('userId', $user->getId());
@ -2136,9 +2134,9 @@ App::put('/v1/account/recovery')
$profile = $dbForProject->updateDocument('users', $profile->getId(), $profile
->setAttribute('password', Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS))
->setAttribute('passwordUpdate', DateTime::now())
->setAttribute('hash', Auth::DEFAULT_ALGO)
->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS)
->setAttribute('passwordUpdate', DateTime::now())
->setAttribute('emailVerification', true));
$recoveryDocument = $dbForProject->getDocument('tokens', $recovery);

View file

@ -81,7 +81,7 @@ App::post('/v1/projects')
}
$auth = Config::getParam('auth', []);
$auths = ['limit' => 0, 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT, 'passwordHistory' => 0, '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) {
$auths[$method['key'] ?? ''] = true;
}
@ -577,12 +577,12 @@ App::patch('/v1/projects/:projectId/auth/:method')
});
App::patch('/v1/projects/:projectId/auth/password-history')
->desc('Update Project users limit')
->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', 'updateAuthLimit')
->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)
@ -607,6 +607,37 @@ App::patch('/v1/projects/:projectId/auth/password-history')
$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')
->desc('Update Project user sessions limit')
->groups(['api', 'projects'])

View file

@ -35,6 +35,7 @@ use Utopia\Validator\Boolean;
use MaxMind\Db\Reader;
use Utopia\Validator\Integer;
use Appwrite\Auth\Validator\PasswordHistory;
use Appwrite\Auth\Validator\PasswordDictionary;
/** 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, Document $project, Database $dbForProject, Event $events): Document
@ -64,11 +65,11 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e
'phone' => $phone,
'phoneVerification' => false,
'status' => true,
'passwordHistory' => is_null($password) && $passwordHistory === 0 ? [] : [$password],
'password' => $password,
'passwordHistory' => is_null($password) && $passwordHistory === 0 ? [] : [$password],
'passwordUpdate' => (!empty($password)) ? DateTime::now() : null,
'hash' => $hash === 'plaintext' ? Auth::DEFAULT_ALGO : $hash,
'hashOptions' => $hash === 'plaintext' ? Auth::DEFAULT_ALGO_OPTIONS : $hashOptionsObject + ['type' => $hash],
'passwordUpdate' => (!empty($password)) ? DateTime::now() : null,
'registration' => DateTime::now(),
'reset' => false,
'name' => $name,
@ -105,13 +106,14 @@ 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('email', null, new Email(), 'User email.', true)
->param('phone', null, new Phone(), 'Phone number. Format this number with a leading \'+\' and a country code, e.g., +16175551212.', true)
->param('password', 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)
->inject('response')
->inject('project')
->inject('dbForProject')
->inject('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, $project, $dbForProject, $events);
$response
@ -791,7 +793,7 @@ App::patch('/v1/users/:userId/password')
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_USER)
->param('userId', '', new UID(), 'User ID.')
->param('password', '', 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('project')
->inject('dbForProject')
@ -820,11 +822,11 @@ App::patch('/v1/users/:userId/password')
}
$user
->setAttribute('passwordHistory', $history)
->setAttribute('password', $newPassword)
->setAttribute('passwordHistory', $history)
->setAttribute('passwordUpdate', DateTime::now())
->setAttribute('hash', Auth::DEFAULT_ALGO)
->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS)
->setAttribute('passwordUpdate', DateTime::now());
->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS);
$user = $dbForProject->updateDocument('users', $user->getId(), $user);

View file

@ -607,6 +607,12 @@ $register->set('smtp', function () {
$register->set('geodb', function () {
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 () {
// This is usually for our workers or CLI commands scope
$dbHost = App::getEnv('_APP_DB_HOST', '');
@ -1027,6 +1033,11 @@ App::setResource('geodb', function ($register) {
return $register->get('geodb');
}, ['register']);
App::setResource('passwordsDictionary', function ($register) {
/** @var Utopia\Registry\Registry $register */
return $register->get('passwordsDictionary');
}, ['register']);
App::setResource('sms', function () {
$dsn = new DSN(App::getEnv('_APP_SMS_PROVIDER'));
$user = $dsn->getUser();

View 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;
}
}

View file

@ -82,7 +82,7 @@ class V18 extends Migration
*/
$document->setAttribute('version', '1.2.0');
break;
case 'users':
case 'projects':
/**
* Bump version number.
*/
@ -92,7 +92,8 @@ class V18 extends Migration
* Set default passwordHistory
*/
$document->setAttribute('auths', array_merge($document->getAttribute('auths', []), [
'passwordHistory' => 0
'passwordHistory' => 0,
'passwordDictionary' => false,
]));
break;

View file

@ -126,6 +126,12 @@ class Project extends Model
'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', [
'type' => Response::MODEL_PROVIDER,
'description' => 'List of Providers.',
@ -247,6 +253,7 @@ class Project extends Model
$document->setAttribute('authDuration', $authValues['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG);
$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) {
$key = $method['key'];

View file

@ -1101,6 +1101,137 @@ class ProjectsConsoleClientTest extends Scope
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
{

View 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);
}
}