1
0
Fork 0
mirror of synced 2024-09-28 23:41:23 +12:00

feat: personal data filters to enhance password protection

This commit is contained in:
Christy Jacob 2023-04-14 00:20:03 +04:00
parent 8a5a3db082
commit 0fb34433b9
10 changed files with 1863 additions and 1527 deletions

View file

@ -160,6 +160,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.',
'code' => 400,
],
Exception::USER_SESSION_NOT_FOUND => [
'name' => Exception::USER_SESSION_NOT_FOUND,
'description' => 'The current user session could not be found.',

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';
@ -98,6 +99,13 @@ App::post('/v1/account')
}
}
if ($project->getAttribute('auths', [])['disallowPersonalData'] ?? 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 {
@ -1582,13 +1590,20 @@ App::patch('/v1/account/password')
$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);
}
if ($project->getAttribute('auths', [])['disallowPersonalData'] ?? 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
->setAttribute('password', $newPassword)
->setAttribute('passwordHistory', $history)

View file

@ -80,7 +80,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, 'disallowPersonalData' => false];
foreach ($auth as $index => $method) {
$auths[$method['key'] ?? ''] = true;
}
@ -638,7 +638,7 @@ App::patch('/v1/projects/:projectId/auth/password-dictionary')
});
App::patch('/v1/projects/:projectId/auth/disallow-personal-data')
->desc('Enable or disable checking the user password against their 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])
@ -648,7 +648,7 @@ App::patch('/v1/projects/:projectId/auth/disallow-personal-data')
->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 Default is false.')
->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) {

View file

@ -36,6 +36,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
@ -52,6 +53,13 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e
? ID::unique()
: ID::custom($userId);
if ($project->getAttribute('auths', [])['disallowPersonalData'] ?? 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,
@ -806,6 +814,13 @@ App::patch('/v1/users/:userId/password')
throw new Exception(Exception::USER_NOT_FOUND);
}
if ($project->getAttribute('auths', [])['disallowPersonalData'] ?? 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;
@ -814,7 +829,7 @@ App::patch('/v1/users/:userId/password')
$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;

24
composer.lock generated
View file

@ -2112,16 +2112,16 @@
},
{
"name": "utopia-php/database",
"version": "0.35.0",
"version": "0.35.1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/database.git",
"reference": "f162c142fd61753c4b413b15c3c4041f3cd00bb2"
"reference": "b5ac84e0c77145bd0a7f38718ad915729c64fa93"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/database/zipball/f162c142fd61753c4b413b15c3c4041f3cd00bb2",
"reference": "f162c142fd61753c4b413b15c3c4041f3cd00bb2",
"url": "https://api.github.com/repos/utopia-php/database/zipball/b5ac84e0c77145bd0a7f38718ad915729c64fa93",
"reference": "b5ac84e0c77145bd0a7f38718ad915729c64fa93",
"shasum": ""
},
"require": {
@ -2164,9 +2164,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/database/issues",
"source": "https://github.com/utopia-php/database/tree/0.35.0"
"source": "https://github.com/utopia-php/database/tree/0.35.1"
},
"time": "2023-04-11T04:02:22+00:00"
"time": "2023-04-13T04:30:08+00:00"
},
{
"name": "utopia-php/domains",
@ -3037,16 +3037,16 @@
"packages-dev": [
{
"name": "appwrite/sdk-generator",
"version": "0.32.1",
"version": "0.32.2",
"source": {
"type": "git",
"url": "https://github.com/appwrite/sdk-generator.git",
"reference": "ba1d7afd57e3baef06c04ce6abc26f79310146df"
"reference": "cdec289bcf38c99d0074414d2438e9967d0c9699"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/ba1d7afd57e3baef06c04ce6abc26f79310146df",
"reference": "ba1d7afd57e3baef06c04ce6abc26f79310146df",
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/cdec289bcf38c99d0074414d2438e9967d0c9699",
"reference": "cdec289bcf38c99d0074414d2438e9967d0c9699",
"shasum": ""
},
"require": {
@ -3082,9 +3082,9 @@
"description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms",
"support": {
"issues": "https://github.com/appwrite/sdk-generator/issues",
"source": "https://github.com/appwrite/sdk-generator/tree/0.32.1"
"source": "https://github.com/appwrite/sdk-generator/tree/0.32.2"
},
"time": "2023-04-12T04:43:07+00:00"
"time": "2023-04-12T21:06:57+00:00"
},
{
"name": "doctrine/deprecations",

View file

@ -0,0 +1,100 @@
<?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 (!$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

@ -66,6 +66,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';

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,7 @@
namespace Tests\E2E\Services\Projects;
use Appwrite\Auth\Auth;
use Appwrite\Extend\Exception;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\ProjectConsole;
use Tests\E2E\Scopes\SideClient;
@ -1071,7 +1072,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',
@ -1083,7 +1084,7 @@ class ProjectsConsoleClientTest extends Scope
'password' => $password,
]);
$this->assertEquals(409, $response['headers']['status-code']);
$this->assertEquals(400, $response['headers']['status-code']);
/**
@ -1232,6 +1233,115 @@ 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/disallow-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']['authDisallowPersonalData']);
/**
* 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']);
$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/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']);
}
public function testUpdateProjectServiceStatusAdmin(): array
{

View file

@ -0,0 +1,85 @@
<?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);
}
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);
}
public function testSuccess(): void
{
$this->object = new PersonalData('userId', 'email@example.com', 'name', '+129492323', false);
$this->assertEquals($this->object->isValid('foo'), true);
$this->assertEquals($this->object->isValid('bar'), true);
}
}