Add project password policy settings

This commit is contained in:
Torsten Dittmann
2026-04-08 17:00:32 +04:00
parent fec573d23b
commit e33f8026e6
14 changed files with 617 additions and 9 deletions
+3 -3
View File
@@ -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')
+200
View File
@@ -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'])
+2 -2
View File
@@ -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')
@@ -0,0 +1 @@
Update whether authentication passwords must include at least one lowercase letter.
@@ -0,0 +1 @@
Update the minimum authentication password length requirement.
@@ -0,0 +1 @@
Update whether authentication passwords must include at least one number.
@@ -0,0 +1 @@
Update whether authentication passwords must include at least one special character.
@@ -0,0 +1 @@
Update whether authentication passwords must include at least one uppercase letter.
@@ -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.';
}
/**
@@ -0,0 +1,89 @@
<?php
namespace Appwrite\Auth\Validator;
/**
* PasswordPolicy.
*
* Validates password complexity rules.
*/
class PasswordPolicy extends Password
{
protected int $minLength;
protected bool $requireUppercase;
protected bool $requireLowercase;
protected bool $requireNumber;
protected bool $requireSpecialChar;
public function __construct(array $policy = [], bool $allowEmpty = false)
{
parent::__construct($allowEmpty);
$this->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;
}
}
@@ -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,
@@ -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);
@@ -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
@@ -0,0 +1,48 @@
<?php
namespace Tests\Unit\Auth\Validator;
use Appwrite\Auth\Validator\PasswordPolicy;
use PHPUnit\Framework\TestCase;
class PasswordPolicyTest extends TestCase
{
public function testDefaultPolicy(): void
{
$validator = new PasswordPolicy();
$this->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(''));
}
}