1
0
Fork 0
mirror of synced 2024-09-28 15:31:43 +12:00

Merge pull request #5371 from appwrite/disallow-personal-data

Disallow personal data
This commit is contained in:
Christy Jacob 2023-08-08 23:08:10 +04:00 committed by GitHub
commit c1167f7dc7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 426 additions and 25 deletions

2
.gitmodules vendored
View file

@ -1,4 +1,4 @@
[submodule "app/console"]
path = app/console
url = https://github.com/appwrite/console
branch = 2.3.4
branch = disallow-personal-data

View file

@ -4,6 +4,7 @@
- Add error attribute to indexes and attributes [#4575](https://github.com/appwrite/appwrite/pull/4575)
- Add new index validation rules [#5710](https://github.com/appwrite/appwrite/pull/5710)
- Added support for disallowing passwords that contain personal data [#5371](https://github.com/appwrite/appwrite/pull/5371)
## Fixes

View file

@ -175,6 +175,16 @@ return [
'description' => 'Passwords do not match. Please check the password and confirm password.',
'code' => 400,
],
Exception::USER_PASSWORD_RECENTLY_USED => [
'name' => Exception::USER_PASSWORD_RECENTLY_USED,
'description' => 'The password you are trying to use is similar to your previous password. Please choose a stronger password.',
'code' => 400,
],
Exception::USER_PASSWORD_PERSONAL_DATA => [
'name' => Exception::USER_PASSWORD_PERSONAL_DATA,
'description' => 'The password you are trying to use contains references to your name, email, phone or userID. Please choose a different password and try again.',
'code' => 400,
],
Exception::USER_SESSION_NOT_FOUND => [
'name' => Exception::USER_SESSION_NOT_FOUND,
'description' => 'The current user session could not be found.',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -1 +1 @@
Subproject commit 9174d8f8cb584744dd7a53f69d324f490ee82ee3
Subproject commit 61ed63c5094bb675c1b2715cb2fa7e17389e1152

View file

@ -42,6 +42,7 @@ use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
use Appwrite\Auth\Validator\PasswordHistory;
use Appwrite\Auth\Validator\PasswordDictionary;
use Appwrite\Auth\Validator\PersonalData;
$oauthDefaultSuccess = '/auth/oauth2/success';
$oauthDefaultFailure = '/auth/oauth2/failure';
@ -193,6 +194,13 @@ App::post('/v1/account')
}
}
if ($project->getAttribute('auths', [])['personalDataCheck'] ?? false) {
$personalDataValidator = new PersonalData($userId, $email, $name, null);
if (!$personalDataValidator->isValid($password)) {
throw new Exception(Exception::USER_PASSWORD_PERSONAL_DATA);
}
}
$passwordHistory = $project->getAttribute('auths', [])['passwordHistory'] ?? 0;
$password = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS);
try {
@ -785,7 +793,7 @@ App::post('/v1/account/sessions/magic-url')
}
}
$userId = $userId == 'unique()' ? ID::unique() : $userId;
$userId = $userId === 'unique()' ? ID::unique() : $userId;
$user->setAttributes([
'$id' => $userId,
@ -1703,16 +1711,22 @@ App::patch('/v1/account/password')
$newPassword = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS);
$historyLimit = $project->getAttribute('auths', [])['passwordHistory'] ?? 0;
$history = [];
$history = $user->getAttribute('passwordHistory', []);
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);
throw new Exception(Exception::USER_PASSWORD_RECENTLY_USED);
}
$history[] = $newPassword;
array_slice($history, (count($history) - $historyLimit), $historyLimit);
$history = array_slice($history, (count($history) - $historyLimit), $historyLimit);
}
if ($project->getAttribute('auths', [])['personalDataCheck'] ?? false) {
$personalDataValidator = new PersonalData($user->getId(), $user->getAttribute('email'), $user->getAttribute('name'), $user->getAttribute('phone'));
if (!$personalDataValidator->isValid($password)) {
throw new Exception(Exception::USER_PASSWORD_PERSONAL_DATA);
}
}
$user
@ -2307,8 +2321,9 @@ App::put('/v1/account/recovery')
->inject('response')
->inject('user')
->inject('dbForProject')
->inject('project')
->inject('events')
->action(function (string $userId, string $secret, string $password, string $passwordAgain, Response $response, Document $user, Database $dbForProject, Event $events) {
->action(function (string $userId, string $secret, string $password, string $passwordAgain, Response $response, Document $user, Database $dbForProject, Document $project, Event $events) {
if ($password !== $passwordAgain) {
throw new Exception(Exception::USER_PASSWORD_MISMATCH);
}
@ -2328,8 +2343,23 @@ App::put('/v1/account/recovery')
Authorization::setRole(Role::user($profile->getId())->toString());
$newPassword = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS);
$historyLimit = $project->getAttribute('auths', [])['passwordHistory'] ?? 0;
$history = $profile->getAttribute('passwordHistory', []);
if ($historyLimit > 0) {
$validator = new PasswordHistory($history, $profile->getAttribute('hash'), $profile->getAttribute('hashOptions'));
if (!$validator->isValid($password)) {
throw new Exception(Exception::USER_PASSWORD_RECENTLY_USED);
}
$history[] = $newPassword;
$history = array_slice($history, (count($history) - $historyLimit), $historyLimit);
}
$profile = $dbForProject->updateDocument('users', $profile->getId(), $profile
->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('hashOptions', Auth::DEFAULT_ALGO_OPTIONS)

View file

@ -88,7 +88,7 @@ App::post('/v1/projects')
}
$auth = Config::getParam('auth', []);
$auths = ['limit' => 0, 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT, 'passwordHistory' => 0, 'passwordDictionary' => false, 'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG];
$auths = ['limit' => 0, 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT, 'passwordHistory' => 0, 'passwordDictionary' => false, 'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG, 'personalDataCheck' => false];
foreach ($auth as $index => $method) {
$auths[$method['key'] ?? ''] = true;
}
@ -615,7 +615,7 @@ App::patch('/v1/projects/:projectId/auth/password-history')
});
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')
->desc('Update authentication password dictionary 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])
@ -645,6 +645,37 @@ App::patch('/v1/projects/:projectId/auth/password-dictionary')
$response->dynamic($project, Response::MODEL_PROJECT);
});
App::patch('/v1/projects/:projectId/auth/personal-data')
->desc('Enable or disable checking user passwords for similarity with their personal data.')
->groups(['api', 'projects'])
->label('scope', 'projects.write')
->label('sdk.auth', [APP_AUTH_TYPE_ADMIN])
->label('sdk.namespace', 'projects')
->label('sdk.method', 'updatePersonalDataCheck')
->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 check a password for similarity with personal data. 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['personalDataCheck'] = $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

@ -37,6 +37,7 @@ use MaxMind\Db\Reader;
use Utopia\Validator\Integer;
use Appwrite\Auth\Validator\PasswordHistory;
use Appwrite\Auth\Validator\PasswordDictionary;
use Appwrite\Auth\Validator\PersonalData;
/** 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
@ -53,6 +54,13 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e
? ID::unique()
: ID::custom($userId);
if ($project->getAttribute('auths', [])['personalDataCheck'] ?? false) {
$personalDataValidator = new PersonalData($userId, $email, $name, $phone);
if (!$personalDataValidator->isValid($password)) {
throw new Exception(Exception::USER_PASSWORD_PERSONAL_DATA);
}
}
$password = (!empty($password)) ? ($hash === 'plaintext' ? Auth::passwordHash($password, $hash, $hashOptionsObject) : $password) : null;
$user = $dbForProject->createDocument('users', new Document([
'$id' => $userId,
@ -825,19 +833,25 @@ App::patch('/v1/users/:userId/password')
throw new Exception(Exception::USER_NOT_FOUND);
}
if ($project->getAttribute('auths', [])['personalDataCheck'] ?? false) {
$personalDataValidator = new PersonalData($userId, $user->getAttribute('email'), $user->getAttribute('name'), $user->getAttribute('phone'));
if (!$personalDataValidator->isValid($password)) {
throw new Exception(Exception::USER_PASSWORD_PERSONAL_DATA);
}
}
$newPassword = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS);
$historyLimit = $project->getAttribute('auths', [])['passwordHistory'] ?? 0;
$history = [];
$history = $user->getAttribute('passwordHistory', []);
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);
throw new Exception(Exception::USER_PASSWORD_RECENTLY_USED);
}
$history[] = $newPassword;
array_slice($history, (count($history) - $historyLimit), $historyLimit);
$history = array_slice($history, (count($history) - $historyLimit), $historyLimit);
}
$user

View file

@ -36,7 +36,6 @@ use Utopia\App;
use Utopia\Logger\Logger;
use Utopia\Cache\Adapter\Redis as RedisCache;
use Utopia\Cache\Cache;
use Utopia\CLI\Console;
use Utopia\Config\Config;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Database;
@ -817,7 +816,6 @@ foreach ($locales as $locale) {
if (!\file_exists($path)) {
$path = __DIR__ . '/config/locale/translations/' . \substr($code, 0, 2) . '.json'; // if `ar-ae` doesn't exist, look for `ar`
if (!\file_exists($path)) {
var_dump('Unable to find tralsnations for ' . $locale['code'] . ' so using en.json');
$path = __DIR__ . '/config/locale/translations/en.json'; // if none translation exists, use default from `en.json`
}
}

View file

@ -0,0 +1,102 @@
<?php
namespace Appwrite\Auth\Validator;
/**
* Validates user password string against their personal data
*/
class PersonalData extends Password
{
public function __construct(
protected ?string $userId = null,
protected ?string $email = null,
protected ?string $name = null,
protected ?string $phone = null,
protected bool $strict = false
) {
}
/**
* Get Description.
*
* Returns validator description
*
* @return string
*/
public function getDescription(): string
{
return 'Password must not include any personal data like your name, email, phone number, etc.';
}
/**
* Is valid.
*
* @param mixed $value
*
* @return bool
*/
public function isValid($password): bool
{
if (!parent::isValid($password)) {
return false;
}
if (!$this->strict) {
$password = strtolower($password);
$this->userId = strtolower($this->userId);
$this->email = strtolower($this->email);
$this->name = strtolower($this->name);
$this->phone = strtolower($this->phone);
}
if ($this->userId && strpos($password, $this->userId) !== false) {
return false;
}
if ($this->email && strpos($password, $this->email) !== false) {
return false;
}
if ($this->email && strpos($password, explode('@', $this->email)[0] ?? '') !== false) {
return false;
}
if ($this->name && strpos($password, $this->name) !== false) {
return false;
}
if ($this->phone && strpos($password, str_replace('+', '', $this->phone)) !== false) {
return false;
}
if ($this->phone && strpos($password, $this->phone) !== false) {
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

@ -69,6 +69,7 @@ class Exception extends \Exception
public const USER_SESSION_ALREADY_EXISTS = 'user_session_already_exists';
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_EMAIL_ALREADY_EXISTS = 'user_email_already_exists';
public const USER_PASSWORD_MISMATCH = 'user_password_mismatch';
public const USER_SESSION_NOT_FOUND = 'user_session_not_found';

View file

@ -132,6 +132,12 @@ class Project extends Model
'default' => false,
'example' => true,
])
->addRule('authPersonalDataCheck', [
'type' => self::TYPE_BOOLEAN,
'description' => 'Whether or not to check the user password for similarity with their personal data.',
'default' => false,
'example' => true,
])
->addRule('providers', [
'type' => Response::MODEL_PROVIDER,
'description' => 'List of Providers.',
@ -307,6 +313,7 @@ class Project extends Model
$document->setAttribute('authSessionsLimit', $authValues['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT);
$document->setAttribute('authPasswordHistory', $authValues['passwordHistory'] ?? 0);
$document->setAttribute('authPasswordDictionary', $authValues['passwordDictionary'] ?? false);
$document->setAttribute('authPersonalDataCheck', $authValues['personalDataCheck'] ?? false);
foreach ($auth as $index => $method) {
$key = $method['key'];

View file

@ -1336,7 +1336,7 @@ class ProjectsConsoleClientTest extends Scope
'password' => $password,
]);
$this->assertEquals(409, $response['headers']['status-code']);
$this->assertEquals(400, $response['headers']['status-code']);
$headers = array_merge($this->getHeaders(), [
'x-appwrite-mode' => 'admin',
@ -1348,7 +1348,7 @@ class ProjectsConsoleClientTest extends Scope
'password' => $password,
]);
$this->assertEquals(409, $response['headers']['status-code']);
$this->assertEquals(400, $response['headers']['status-code']);
/**
@ -1497,6 +1497,126 @@ class ProjectsConsoleClientTest extends Scope
return $data;
}
/**
* @depends testCreateProject
*/
public function testUpdateDisallowPersonalData($data): void
{
$id = $data['projectId'] ?? '';
/**
* Enable Disallowing of Personal Data
*/
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/personal-data', 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']['authPersonalDataCheck']);
/**
* Test for failure
*/
$email = uniqid() . 'user@localhost.test';
$password = 'password';
$name = 'username';
$userId = ID::unique();
$response = $this->client->call(Client::METHOD_POST, '/account', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $id,
]), [
'email' => $email,
'password' => $email,
'name' => $name,
'userId' => $userId
]);
$this->assertEquals(400, $response['headers']['status-code']);
$this->assertEquals(400, $response['body']['code']);
$this->assertEquals(Exception::USER_PASSWORD_PERSONAL_DATA, $response['body']['type']);
$response = $this->client->call(Client::METHOD_POST, '/account', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $id,
]), [
'email' => $email,
'password' => $name,
'name' => $name,
'userId' => $userId
]);
$phone = '+123456789';
$response = $this->client->call(Client::METHOD_POST, '/users', array_merge($this->getHeaders(), [
'content-type' => 'application/json',
'x-appwrite-project' => $id,
'x-appwrite-mode' => 'admin',
]), [
'email' => $email,
'password' => $phone,
'name' => $name,
'userId' => $userId,
'phone' => $phone
]);
$this->assertEquals(400, $response['headers']['status-code']);
$this->assertEquals(400, $response['body']['code']);
$this->assertEquals(Exception::USER_PASSWORD_PERSONAL_DATA, $response['body']['type']);
/** Test for success */
$email = uniqid() . 'user@localhost.test';
$password = 'password';
$name = 'username';
$userId = ID::unique();
$response = $this->client->call(Client::METHOD_POST, '/account', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $id,
]), [
'email' => $email,
'password' => $password,
'name' => $name,
'userId' => $userId
]);
$this->assertEquals(201, $response['headers']['status-code']);
$email = uniqid() . 'user@localhost.test';
$userId = ID::unique();
$response = $this->client->call(Client::METHOD_POST, '/users', array_merge($this->getHeaders(), [
'content-type' => 'application/json',
'x-appwrite-project' => $id,
'x-appwrite-mode' => 'admin',
]), [
'email' => $email,
'password' => $password,
'name' => $name,
'userId' => $userId,
'phone' => $phone
]);
$this->assertEquals(201, $response['headers']['status-code']);
/**
* Reset
*/
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/personal-data', 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']['authPersonalDataCheck']);
}
public function testUpdateProjectServiceStatusAdmin(): array
{

View file

@ -0,0 +1,81 @@
<?php
namespace Tests\Unit\Auth\Validator;
use Appwrite\Auth\Validator\PersonalData;
use PHPUnit\Framework\TestCase;
class PersonalDataTest extends TestCase
{
protected ?PersonalData $object = null;
public function testStrict(): void
{
$this->object = new PersonalData('userId', 'email@example.com', 'name', '+129492323', true);
$this->assertEquals($this->object->isValid('userId'), false);
$this->assertEquals($this->object->isValid('something.userId'), false);
$this->assertEquals($this->object->isValid('userId.something'), false);
$this->assertEquals($this->object->isValid('something.userId.something'), false);
$this->assertEquals($this->object->isValid('email@example.com'), false);
$this->assertEquals($this->object->isValid('something.email@example.com'), false);
$this->assertEquals($this->object->isValid('email@example.com.something'), false);
$this->assertEquals($this->object->isValid('something.email@example.com.something'), false);
$this->assertEquals($this->object->isValid('name'), false);
$this->assertEquals($this->object->isValid('something.name'), false);
$this->assertEquals($this->object->isValid('name.something'), false);
$this->assertEquals($this->object->isValid('something.name.something'), false);
$this->assertEquals($this->object->isValid('+129492323'), false);
$this->assertEquals($this->object->isValid('something.+129492323'), false);
$this->assertEquals($this->object->isValid('+129492323.something'), false);
$this->assertEquals($this->object->isValid('something.+129492323.something'), false);
$this->assertEquals($this->object->isValid('129492323'), false);
$this->assertEquals($this->object->isValid('something.129492323'), false);
$this->assertEquals($this->object->isValid('129492323.something'), false);
$this->assertEquals($this->object->isValid('something.129492323.something'), false);
$this->assertEquals($this->object->isValid('email'), false);
$this->assertEquals($this->object->isValid('something.email'), false);
$this->assertEquals($this->object->isValid('email.something'), false);
$this->assertEquals($this->object->isValid('something.email.something'), false);
/** Test for success */
$this->assertEquals($this->object->isValid('893pu5egerfsv3rgersvd'), true);
}
public function testNotStrict(): void
{
$this->object = new PersonalData('userId', 'email@example.com', 'name', '+129492323', false);
$this->assertEquals($this->object->isValid('userId'), false);
$this->assertEquals($this->object->isValid('USERID'), false);
$this->assertEquals($this->object->isValid('something.USERID'), false);
$this->assertEquals($this->object->isValid('USERID.something'), false);
$this->assertEquals($this->object->isValid('something.USERID.something'), false);
$this->assertEquals($this->object->isValid('email@example.com'), false);
$this->assertEquals($this->object->isValid('EMAIL@EXAMPLE.COM'), false);
$this->assertEquals($this->object->isValid('something.EMAIL@EXAMPLE.COM'), false);
$this->assertEquals($this->object->isValid('EMAIL@EXAMPLE.COM.something'), false);
$this->assertEquals($this->object->isValid('something.EMAIL@EXAMPLE.COM.something'), false);
$this->assertEquals($this->object->isValid('name'), false);
$this->assertEquals($this->object->isValid('NAME'), false);
$this->assertEquals($this->object->isValid('something.NAME'), false);
$this->assertEquals($this->object->isValid('NAME.something'), false);
$this->assertEquals($this->object->isValid('something.NAME.something'), false);
$this->assertEquals($this->object->isValid('+129492323'), false);
$this->assertEquals($this->object->isValid('129492323'), false);
$this->assertEquals($this->object->isValid('email'), false);
$this->assertEquals($this->object->isValid('EMAIL'), false);
$this->assertEquals($this->object->isValid('something.EMAIL'), false);
$this->assertEquals($this->object->isValid('EMAIL.something'), false);
$this->assertEquals($this->object->isValid('something.EMAIL.something'), false);
}
}