From e33f8026e6a01409032c9a10da2c3ed5e69cd79b Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Wed, 8 Apr 2026 17:00:32 +0400 Subject: [PATCH] Add project password policy settings --- app/controllers/api/account.php | 6 +- app/controllers/api/projects.php | 200 ++++++++++++++++ app/controllers/api/users.php | 4 +- .../update-auth-password-policy-lowercase.md | 1 + .../update-auth-password-policy-min-length.md | 1 + .../update-auth-password-policy-number.md | 1 + ...pdate-auth-password-policy-special-char.md | 1 + .../update-auth-password-policy-uppercase.md | 1 + .../Auth/Validator/PasswordDictionary.php | 8 +- .../Auth/Validator/PasswordPolicy.php | 89 +++++++ .../Modules/Projects/Http/Projects/Create.php | 7 + .../Utopia/Response/Model/Project.php | 36 +++ .../Projects/ProjectsConsoleClientTest.php | 223 ++++++++++++++++++ .../Auth/Validator/PasswordPolicyTest.php | 48 ++++ 14 files changed, 617 insertions(+), 9 deletions(-) create mode 100644 docs/references/projects/update-auth-password-policy-lowercase.md create mode 100644 docs/references/projects/update-auth-password-policy-min-length.md create mode 100644 docs/references/projects/update-auth-password-policy-number.md create mode 100644 docs/references/projects/update-auth-password-policy-special-char.md create mode 100644 docs/references/projects/update-auth-password-policy-uppercase.md create mode 100644 src/Appwrite/Auth/Validator/PasswordPolicy.php create mode 100644 tests/unit/Auth/Validator/PasswordPolicyTest.php diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 8eb49ea27b..7ab7c44e7e 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -394,7 +394,7 @@ Http::post('/v1/account') ->label('abuse-limit', 10) ->param('userId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. 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.', false, ['dbForProject']) ->param('email', '', new EmailValidator(), 'User email.') - ->param('password', '', fn ($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, $project->getAttribute('auths', [])['passwordDictionary'] ?? false), 'New user password. Must be between 8 and 256 chars.', false, ['project', 'passwordsDictionary']) + ->param('password', '', fn ($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, $project->getAttribute('auths', [])['passwordDictionary'] ?? false, false, $project->getAttribute('auths', [])['passwordPolicy'] ?? []), 'New user password. Must be between 8 and 256 chars.', false, ['project', 'passwordsDictionary']) ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) ->inject('request') ->inject('response') @@ -3378,7 +3378,7 @@ Http::patch('/v1/account/password') contentType: ContentType::JSON )) ->label('abuse-limit', 10) - ->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('password', '', fn ($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, $project->getAttribute('auths', [])['passwordDictionary'] ?? false, false, $project->getAttribute('auths', [])['passwordPolicy'] ?? []), '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') @@ -3988,7 +3988,7 @@ Http::put('/v1/account/recovery') ->label('abuse-key', 'url:{url},userId:{param-userId}') ->param('userId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'User ID.', false, ['dbForProject']) ->param('secret', '', new Text(256), 'Valid reset token.') - ->param('password', '', fn ($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, $project->getAttribute('auths', [])['passwordDictionary'] ?? false), 'New user password. Must be between 8 and 256 chars.', false, ['project', 'passwordsDictionary']) + ->param('password', '', fn ($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, $project->getAttribute('auths', [])['passwordDictionary'] ?? false, false, $project->getAttribute('auths', [])['passwordPolicy'] ?? []), 'New user password. Must be between 8 and 256 chars.', false, ['project', 'passwordsDictionary']) ->inject('response') ->inject('user') ->inject('dbForProject') diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index dac6ed456a..6b77acab0f 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -555,6 +555,206 @@ Http::patch('/v1/projects/:projectId/auth/password-history') $response->dynamic($project, Response::MODEL_PROJECT); }); +Http::patch('/v1/projects/:projectId/auth/password-policy/min-length') + ->desc('Update the minimum password length requirement.') + ->groups(['api', 'projects']) + ->label('scope', 'projects.write') + ->label('sdk', new Method( + namespace: 'projects', + group: 'auth', + name: 'updateAuthPasswordPolicyMinLength', + description: '/docs/references/projects/update-auth-password-policy-min-length.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_PROJECT, + ) + ] + )) + ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) + ->param('minLength', 8, new Range(8, 256), 'Set the minimum password length. Value must be between 8 and 256. Default is 8.') + ->inject('response') + ->inject('dbForPlatform') + ->action(function (string $projectId, int $minLength, Response $response, Database $dbForPlatform) { + + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $auths = $project->getAttribute('auths', []); + $auths['passwordPolicy'] = \array_merge($auths['passwordPolicy'] ?? [], [ + 'minLength' => $minLength, + ]); + + $dbForPlatform->updateDocument('projects', $project->getId(), $project + ->setAttribute('auths', $auths)); + + $response->dynamic($project, Response::MODEL_PROJECT); + }); + +Http::patch('/v1/projects/:projectId/auth/password-policy/uppercase') + ->desc('Update the uppercase password requirement.') + ->groups(['api', 'projects']) + ->label('scope', 'projects.write') + ->label('sdk', new Method( + namespace: 'projects', + group: 'auth', + name: 'updateAuthPasswordPolicyUppercase', + description: '/docs/references/projects/update-auth-password-policy-uppercase.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_PROJECT, + ) + ] + )) + ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) + ->param('enabled', false, new Boolean(false), 'Set whether or not passwords must include at least one uppercase letter. Default is false.') + ->inject('response') + ->inject('dbForPlatform') + ->action(function (string $projectId, bool $enabled, Response $response, Database $dbForPlatform) { + + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $auths = $project->getAttribute('auths', []); + $auths['passwordPolicy'] = \array_merge($auths['passwordPolicy'] ?? [], [ + 'requireUppercase' => $enabled, + ]); + + $dbForPlatform->updateDocument('projects', $project->getId(), $project + ->setAttribute('auths', $auths)); + + $response->dynamic($project, Response::MODEL_PROJECT); + }); + +Http::patch('/v1/projects/:projectId/auth/password-policy/lowercase') + ->desc('Update the lowercase password requirement.') + ->groups(['api', 'projects']) + ->label('scope', 'projects.write') + ->label('sdk', new Method( + namespace: 'projects', + group: 'auth', + name: 'updateAuthPasswordPolicyLowercase', + description: '/docs/references/projects/update-auth-password-policy-lowercase.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_PROJECT, + ) + ] + )) + ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) + ->param('enabled', false, new Boolean(false), 'Set whether or not passwords must include at least one lowercase letter. Default is false.') + ->inject('response') + ->inject('dbForPlatform') + ->action(function (string $projectId, bool $enabled, Response $response, Database $dbForPlatform) { + + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $auths = $project->getAttribute('auths', []); + $auths['passwordPolicy'] = \array_merge($auths['passwordPolicy'] ?? [], [ + 'requireLowercase' => $enabled, + ]); + + $dbForPlatform->updateDocument('projects', $project->getId(), $project + ->setAttribute('auths', $auths)); + + $response->dynamic($project, Response::MODEL_PROJECT); + }); + +Http::patch('/v1/projects/:projectId/auth/password-policy/number') + ->desc('Update the numeric password requirement.') + ->groups(['api', 'projects']) + ->label('scope', 'projects.write') + ->label('sdk', new Method( + namespace: 'projects', + group: 'auth', + name: 'updateAuthPasswordPolicyNumber', + description: '/docs/references/projects/update-auth-password-policy-number.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_PROJECT, + ) + ] + )) + ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) + ->param('enabled', false, new Boolean(false), 'Set whether or not passwords must include at least one number. Default is false.') + ->inject('response') + ->inject('dbForPlatform') + ->action(function (string $projectId, bool $enabled, Response $response, Database $dbForPlatform) { + + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $auths = $project->getAttribute('auths', []); + $auths['passwordPolicy'] = \array_merge($auths['passwordPolicy'] ?? [], [ + 'requireNumber' => $enabled, + ]); + + $dbForPlatform->updateDocument('projects', $project->getId(), $project + ->setAttribute('auths', $auths)); + + $response->dynamic($project, Response::MODEL_PROJECT); + }); + +Http::patch('/v1/projects/:projectId/auth/password-policy/special-char') + ->desc('Update the special character password requirement.') + ->groups(['api', 'projects']) + ->label('scope', 'projects.write') + ->label('sdk', new Method( + namespace: 'projects', + group: 'auth', + name: 'updateAuthPasswordPolicySpecialChar', + description: '/docs/references/projects/update-auth-password-policy-special-char.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_PROJECT, + ) + ] + )) + ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) + ->param('enabled', false, new Boolean(false), 'Set whether or not passwords must include at least one special character. Default is false.') + ->inject('response') + ->inject('dbForPlatform') + ->action(function (string $projectId, bool $enabled, Response $response, Database $dbForPlatform) { + + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $auths = $project->getAttribute('auths', []); + $auths['passwordPolicy'] = \array_merge($auths['passwordPolicy'] ?? [], [ + 'requireSpecialChar' => $enabled, + ]); + + $dbForPlatform->updateDocument('projects', $project->getId(), $project + ->setAttribute('auths', $auths)); + + $response->dynamic($project, Response::MODEL_PROJECT); + }); + Http::patch('/v1/projects/:projectId/auth/password-dictionary') ->desc('Update authentication password dictionary status. Use this endpoint to enable or disable the dicitonary check for user password') ->groups(['api', 'projects']) diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 51fc3d03c7..b7990b1eac 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -278,7 +278,7 @@ Http::post('/v1/users') ->param('userId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. 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.', false, ['dbForProject']) ->param('email', null, new Nullable(new EmailValidator()), 'User email.', true) ->param('phone', null, new Nullable(new Phone()), 'Phone number. Format this number with a leading \'+\' and a country code, e.g., +16175551212.', 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('password', '', fn ($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, $project->getAttribute('auths', [])['passwordDictionary'] ?? false, false, $project->getAttribute('auths', [])['passwordPolicy'] ?? []), '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') @@ -1395,7 +1395,7 @@ Http::patch('/v1/users/:userId/password') ] )) ->param('userId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'User ID.', false, ['dbForProject']) - ->param('password', '', fn ($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, enabled: $project->getAttribute('auths', [])['passwordDictionary'] ?? false, allowEmpty: true), 'New user password. Must be at least 8 chars.', false, ['project', 'passwordsDictionary']) + ->param('password', '', fn ($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, enabled: $project->getAttribute('auths', [])['passwordDictionary'] ?? false, allowEmpty: true, policy: $project->getAttribute('auths', [])['passwordPolicy'] ?? []), 'New user password. Must be at least 8 chars.', false, ['project', 'passwordsDictionary']) ->inject('response') ->inject('project') ->inject('dbForProject') diff --git a/docs/references/projects/update-auth-password-policy-lowercase.md b/docs/references/projects/update-auth-password-policy-lowercase.md new file mode 100644 index 0000000000..139930d20e --- /dev/null +++ b/docs/references/projects/update-auth-password-policy-lowercase.md @@ -0,0 +1 @@ +Update whether authentication passwords must include at least one lowercase letter. diff --git a/docs/references/projects/update-auth-password-policy-min-length.md b/docs/references/projects/update-auth-password-policy-min-length.md new file mode 100644 index 0000000000..9a01d51543 --- /dev/null +++ b/docs/references/projects/update-auth-password-policy-min-length.md @@ -0,0 +1 @@ +Update the minimum authentication password length requirement. diff --git a/docs/references/projects/update-auth-password-policy-number.md b/docs/references/projects/update-auth-password-policy-number.md new file mode 100644 index 0000000000..fe7866fb88 --- /dev/null +++ b/docs/references/projects/update-auth-password-policy-number.md @@ -0,0 +1 @@ +Update whether authentication passwords must include at least one number. diff --git a/docs/references/projects/update-auth-password-policy-special-char.md b/docs/references/projects/update-auth-password-policy-special-char.md new file mode 100644 index 0000000000..7c1b718252 --- /dev/null +++ b/docs/references/projects/update-auth-password-policy-special-char.md @@ -0,0 +1 @@ +Update whether authentication passwords must include at least one special character. diff --git a/docs/references/projects/update-auth-password-policy-uppercase.md b/docs/references/projects/update-auth-password-policy-uppercase.md new file mode 100644 index 0000000000..a64ab65d0a --- /dev/null +++ b/docs/references/projects/update-auth-password-policy-uppercase.md @@ -0,0 +1 @@ +Update whether authentication passwords must include at least one uppercase letter. diff --git a/src/Appwrite/Auth/Validator/PasswordDictionary.php b/src/Appwrite/Auth/Validator/PasswordDictionary.php index 99a6c81525..290ca5766b 100644 --- a/src/Appwrite/Auth/Validator/PasswordDictionary.php +++ b/src/Appwrite/Auth/Validator/PasswordDictionary.php @@ -7,14 +7,14 @@ namespace Appwrite\Auth\Validator; * * Validates user password string */ -class PasswordDictionary extends Password +class PasswordDictionary extends PasswordPolicy { protected array $dictionary; protected bool $enabled; - public function __construct(array $dictionary, bool $enabled = false, bool $allowEmpty = false) + public function __construct(array $dictionary, bool $enabled = false, bool $allowEmpty = false, array $policy = []) { - parent::__construct($allowEmpty); + parent::__construct($policy, $allowEmpty); $this->dictionary = $dictionary; $this->enabled = $enabled; } @@ -28,7 +28,7 @@ class PasswordDictionary extends Password */ public function getDescription(): string { - return 'Password must be between 8 and 265 characters long, and should not be one of the commonly used password.'; + return parent::getDescription() . ' It should not be one of the commonly used passwords.'; } /** diff --git a/src/Appwrite/Auth/Validator/PasswordPolicy.php b/src/Appwrite/Auth/Validator/PasswordPolicy.php new file mode 100644 index 0000000000..6fdb11d15a --- /dev/null +++ b/src/Appwrite/Auth/Validator/PasswordPolicy.php @@ -0,0 +1,89 @@ +minLength = $policy['minLength'] ?? 8; + $this->requireUppercase = $policy['requireUppercase'] ?? false; + $this->requireLowercase = $policy['requireLowercase'] ?? false; + $this->requireNumber = $policy['requireNumber'] ?? false; + $this->requireSpecialChar = $policy['requireSpecialChar'] ?? false; + } + + public function getDescription(): string + { + $requirements = [ + 'between ' . $this->minLength . ' and 256 characters long', + ]; + + if ($this->requireUppercase) { + $requirements[] = 'include an uppercase letter'; + } + + if ($this->requireLowercase) { + $requirements[] = 'include a lowercase letter'; + } + + if ($this->requireNumber) { + $requirements[] = 'include a number'; + } + + if ($this->requireSpecialChar) { + $requirements[] = 'include a special character'; + } + + return 'Password must be ' . \implode(', ', $requirements) . '.'; + } + + /** + * @param mixed $value + */ + public function isValid($value): bool + { + if (!parent::isValid($value)) { + return false; + } + + if ($this->allowEmpty && \strlen($value) === 0) { + return true; + } + + if (\strlen($value) < $this->minLength) { + return false; + } + + if ($this->requireUppercase && !\preg_match('/[A-Z]/', $value)) { + return false; + } + + if ($this->requireLowercase && !\preg_match('/[a-z]/', $value)) { + return false; + } + + if ($this->requireNumber && !\preg_match('/\d/', $value)) { + return false; + } + + if ($this->requireSpecialChar && !\preg_match("/[!\"#$%&'()*+,\-.\/:;<=>?@[\\\\\]^_`{|}~]/", $value)) { + return false; + } + + return true; + } +} diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php index 9070962e7d..d43fe3e071 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php @@ -110,6 +110,13 @@ class Create extends Action $auths = [ 'limit' => 0, 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT, + 'passwordPolicy' => [ + 'minLength' => 8, + 'requireUppercase' => false, + 'requireLowercase' => false, + 'requireNumber' => false, + 'requireSpecialChar' => false, + ], 'passwordHistory' => 0, 'passwordDictionary' => false, 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 1ef73aa769..4001d61fbf 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -120,6 +120,36 @@ class Project extends Model 'default' => 0, 'example' => 5, ]) + ->addRule('authPasswordPolicyMinLength', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Minimum password length required for user passwords.', + 'default' => 8, + 'example' => 12, + ]) + ->addRule('authPasswordPolicyRequireUppercase', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether or not passwords must include at least one uppercase letter.', + 'default' => false, + 'example' => true, + ]) + ->addRule('authPasswordPolicyRequireLowercase', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether or not passwords must include at least one lowercase letter.', + 'default' => false, + 'example' => true, + ]) + ->addRule('authPasswordPolicyRequireNumber', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether or not passwords must include at least one number.', + 'default' => false, + 'example' => true, + ]) + ->addRule('authPasswordPolicyRequireSpecialChar', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether or not passwords must include at least one special character.', + 'default' => false, + 'example' => true, + ]) ->addRule('authPasswordDictionary', [ 'type' => self::TYPE_BOOLEAN, 'description' => 'Whether or not to check user\'s password against most commonly used passwords.', @@ -426,12 +456,18 @@ class Project extends Model } $authValues = $document->getAttribute('auths', []); + $passwordPolicy = $authValues['passwordPolicy'] ?? []; $auth = Config::getParam('auth', []); $document->setAttribute('authLimit', $authValues['limit'] ?? 0); $document->setAttribute('authDuration', $authValues['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG); $document->setAttribute('authSessionsLimit', $authValues['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT); $document->setAttribute('authPasswordHistory', $authValues['passwordHistory'] ?? 0); + $document->setAttribute('authPasswordPolicyMinLength', $passwordPolicy['minLength'] ?? 8); + $document->setAttribute('authPasswordPolicyRequireUppercase', $passwordPolicy['requireUppercase'] ?? false); + $document->setAttribute('authPasswordPolicyRequireLowercase', $passwordPolicy['requireLowercase'] ?? false); + $document->setAttribute('authPasswordPolicyRequireNumber', $passwordPolicy['requireNumber'] ?? false); + $document->setAttribute('authPasswordPolicyRequireSpecialChar', $passwordPolicy['requireSpecialChar'] ?? false); $document->setAttribute('authPasswordDictionary', $authValues['passwordDictionary'] ?? false); $document->setAttribute('authPersonalDataCheck', $authValues['personalDataCheck'] ?? false); $document->setAttribute('authDisposableEmails', $authValues['disposableEmails'] ?? false); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index e0f94b64cc..8e0cf1f87e 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -2006,6 +2006,229 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(0, $response['body']['authPasswordHistory']); } + public function testUpdateProjectAuthPasswordPolicy(): void + { + $data = $this->setupProjectData(); + $id = $data['projectId']; + + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-policy/min-length', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'minLength' => 7, + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-policy/min-length', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'minLength' => 12, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-policy/uppercase', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'enabled' => true, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-policy/lowercase', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'enabled' => true, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-policy/number', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'enabled' => true, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-policy/special-char', 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(12, $response['body']['authPasswordPolicyMinLength']); + $this->assertEquals(true, $response['body']['authPasswordPolicyRequireUppercase']); + $this->assertEquals(true, $response['body']['authPasswordPolicyRequireLowercase']); + $this->assertEquals(true, $response['body']['authPasswordPolicyRequireNumber']); + $this->assertEquals(true, $response['body']['authPasswordPolicyRequireSpecialChar']); + + $weakPassword = 'password123!'; + $validPassword = 'Password123!'; + $email = uniqid() . 'user@localhost.test'; + + $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() . 'weak-account@localhost.test', + 'password' => $weakPassword, + 'name' => 'Weak Account', + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + $response = $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() . 'weak-user@localhost.test', + 'password' => $weakPassword, + 'name' => 'Weak User', + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + $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' => $validPassword, + 'name' => 'Password Policy User', + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $userId = $response['body']['$id']; + + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + ], [ + 'email' => $email, + 'password' => $validPassword, + ]); + + $this->assertEquals(201, $session['headers']['status-code']); + $session = $session['cookies']['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' => $validPassword, + 'password' => $weakPassword, + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, '/account/recovery', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + ]), [ + 'email' => $email, + 'url' => 'http://localhost/recovery', + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + $lastEmail = $this->getLastEmailByAddress($email, function ($email) { + $this->assertStringContainsString('Password Reset', $email['subject']); + }); + $tokens = $this->extractQueryParamsFromEmailLink($lastEmail['html']); + + $response = $this->client->call(Client::METHOD_PUT, '/account/recovery', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + ]), [ + 'userId' => $userId, + 'secret' => $tokens['secret'], + 'password' => $weakPassword, + ]); + + $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' => $weakPassword, + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-policy/min-length', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'minLength' => 8, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-policy/uppercase', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'enabled' => false, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-policy/lowercase', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'enabled' => false, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-policy/number', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'enabled' => false, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-policy/special-char', 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(8, $response['body']['authPasswordPolicyMinLength']); + $this->assertEquals(false, $response['body']['authPasswordPolicyRequireUppercase']); + $this->assertEquals(false, $response['body']['authPasswordPolicyRequireLowercase']); + $this->assertEquals(false, $response['body']['authPasswordPolicyRequireNumber']); + $this->assertEquals(false, $response['body']['authPasswordPolicyRequireSpecialChar']); + } + #[Group('smtpAndTemplates')] #[Group('projectsCRUD')] public function testUpdateMockNumbers(): void diff --git a/tests/unit/Auth/Validator/PasswordPolicyTest.php b/tests/unit/Auth/Validator/PasswordPolicyTest.php new file mode 100644 index 0000000000..a342455f61 --- /dev/null +++ b/tests/unit/Auth/Validator/PasswordPolicyTest.php @@ -0,0 +1,48 @@ +assertFalse($validator->isValid('1234567')); + $this->assertTrue($validator->isValid('password')); + } + + public function testConfiguredPolicy(): void + { + $validator = new PasswordPolicy([ + 'minLength' => 12, + 'requireUppercase' => true, + 'requireLowercase' => true, + 'requireNumber' => true, + 'requireSpecialChar' => true, + ]); + + $this->assertFalse($validator->isValid('Password1!')); + $this->assertFalse($validator->isValid('password123!')); + $this->assertFalse($validator->isValid('PASSWORD123!')); + $this->assertFalse($validator->isValid('PasswordOnly!')); + $this->assertFalse($validator->isValid('Password1234')); + $this->assertTrue($validator->isValid('Password123!')); + } + + public function testAllowEmpty(): void + { + $validator = new PasswordPolicy([ + 'minLength' => 12, + 'requireUppercase' => true, + 'requireLowercase' => true, + 'requireNumber' => true, + 'requireSpecialChar' => true, + ], true); + + $this->assertTrue($validator->isValid('')); + } +}