diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md
index e8c48e8dcc..8d330103c6 100644
--- a/api/CHANGELOG.md
+++ b/api/CHANGELOG.md
@@ -9,6 +9,11 @@ All notable changes to the **Prowler API** are documented in this file.
- SSO with SAML support [(#8175)](https://github.com/prowler-cloud/prowler/pull/8175)
- `/processors` endpoints to post-process findings. Currently, only the Mutelist processor is supported to allow to mute findings.
+### Security
+
+- Enhanced password validation to enforce 12+ character passwords with special characters, uppercase, lowercase, and numbers [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX)
+
+
---
## [v1.9.1] (Prowler v5.8.1)
diff --git a/api/README.md b/api/README.md
index a1241372ca..ba8896ff35 100644
--- a/api/README.md
+++ b/api/README.md
@@ -257,7 +257,7 @@ cd src/backend
python manage.py loaddata api/fixtures/0_dev_users.json --database admin
```
-> The default credentials are `dev@prowler.com:thisisapassword123` or `dev2@prowler.com:thisisapassword123`
+> The default credentials are `dev@prowler.com:Thisisapassword123@` or `dev2@prowler.com:Thisisapassword123@`
## Run tests
diff --git a/api/src/backend/api/fixtures/dev/0_dev_users.json b/api/src/backend/api/fixtures/dev/0_dev_users.json
index 87ce06239f..61fb0bd883 100644
--- a/api/src/backend/api/fixtures/dev/0_dev_users.json
+++ b/api/src/backend/api/fixtures/dev/0_dev_users.json
@@ -3,7 +3,7 @@
"model": "api.user",
"pk": "8b38e2eb-6689-4f1e-a4ba-95b275130200",
"fields": {
- "password": "pbkdf2_sha256$720000$vA62S78kog2c2ytycVQdke$Fp35GVLLMyy5fUq3krSL9I02A+ocQ+RVa4S22LIAO5s=",
+ "password": "pbkdf2_sha256$870000$Z63pGJ7nre48hfcGbk5S0O$rQpKczAmijs96xa+gPVJifpT3Fetb8DOusl5Eq6gxac=",
"last_login": null,
"name": "Devie Prowlerson",
"email": "dev@prowler.com",
@@ -16,7 +16,7 @@
"model": "api.user",
"pk": "b6493a3a-c997-489b-8b99-278bf74de9f6",
"fields": {
- "password": "pbkdf2_sha256$720000$vA62S78kog2c2ytycVQdke$Fp35GVLLMyy5fUq3krSL9I02A+ocQ+RVa4S22LIAO5s=",
+ "password": "pbkdf2_sha256$870000$Z63pGJ7nre48hfcGbk5S0O$rQpKczAmijs96xa+gPVJifpT3Fetb8DOusl5Eq6gxac=",
"last_login": null,
"name": "Devietoo Prowlerson",
"email": "dev2@prowler.com",
diff --git a/api/src/backend/api/tests/integration/test_authentication.py b/api/src/backend/api/tests/integration/test_authentication.py
index f41e8207c4..24d8e16f76 100644
--- a/api/src/backend/api/tests/integration/test_authentication.py
+++ b/api/src/backend/api/tests/integration/test_authentication.py
@@ -11,7 +11,7 @@ def test_basic_authentication():
client = APIClient()
test_user = "test_email@prowler.com"
- test_password = "test_password"
+ test_password = "Test_password@1"
# Check that a 401 is returned when no basic authentication is provided
no_auth_response = client.get(reverse("provider-list"))
@@ -108,7 +108,7 @@ def test_user_me_when_inviting_users(create_test_user, tenants_fixture, roles_fi
user1_email = "user1@testing.com"
user2_email = "user2@testing.com"
- password = "thisisapassword123"
+ password = "Thisisapassword123@"
user1_response = client.post(
reverse("user-list"),
@@ -187,7 +187,7 @@ class TestTokenSwitchTenant:
client = APIClient()
test_user = "test_email@prowler.com"
- test_password = "test_password"
+ test_password = "Test_password1@"
# Check that we can create a new user without any kind of authentication
user_creation_response = client.post(
diff --git a/api/src/backend/api/tests/integration/test_providers.py b/api/src/backend/api/tests/integration/test_providers.py
index 0f17c2d839..9c91ad2c07 100644
--- a/api/src/backend/api/tests/integration/test_providers.py
+++ b/api/src/backend/api/tests/integration/test_providers.py
@@ -17,7 +17,7 @@ def test_delete_provider_without_executing_task(
client = APIClient()
test_user = "test_email@prowler.com"
- test_password = "test_password"
+ test_password = "Test_password1@"
prowler_task = tasks_fixture[0]
task_mock = Mock()
diff --git a/api/src/backend/api/tests/test_rbac.py b/api/src/backend/api/tests/test_rbac.py
index 7365bae3d4..19ea256723 100644
--- a/api/src/backend/api/tests/test_rbac.py
+++ b/api/src/backend/api/tests/test_rbac.py
@@ -60,7 +60,7 @@ class TestUserViewSet:
def test_create_user_with_all_permissions(self, authenticated_client_rbac):
valid_user_payload = {
"name": "test",
- "password": "newpassword123",
+ "password": "Newpassword123@",
"email": "new_user@test.com",
}
response = authenticated_client_rbac.post(
@@ -74,7 +74,7 @@ class TestUserViewSet:
):
valid_user_payload = {
"name": "test",
- "password": "newpassword123",
+ "password": "Newpassword123@",
"email": "new_user@test.com",
}
response = authenticated_client_no_permissions_rbac.post(
@@ -321,7 +321,7 @@ class TestProviderViewSet:
@pytest.mark.django_db
class TestLimitedVisibility:
TEST_EMAIL = "rbac@rbac.com"
- TEST_PASSWORD = "thisisapassword123"
+ TEST_PASSWORD = "Thisisapassword123@"
@pytest.fixture
def limited_admin_user(
diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py
index c73a4c5425..dbfcabf207 100644
--- a/api/src/backend/api/tests/test_views.py
+++ b/api/src/backend/api/tests/test_views.py
@@ -93,7 +93,7 @@ class TestUserViewSet:
def test_users_create(self, client):
valid_user_payload = {
"name": "test",
- "password": "newpassword123",
+ "password": "NewPassword123!",
"email": "NeWuSeR@example.com",
}
response = client.post(
@@ -134,6 +134,10 @@ class TestUserViewSet:
"password1", # Common password and too similar to a common password
"dev12345", # Similar to username
("querty12" * 9) + "a", # Too long, 73 characters
+ "NewPassword123", # No special character
+ "newpassword123@", # No uppercase letter
+ "NEWPASSWORD123", # No lowercase letter
+ "NewPassword@", # No number
],
)
def test_users_create_invalid_passwords(self, authenticated_client, password):
@@ -164,7 +168,7 @@ class TestUserViewSet:
# First user created; no errors should occur
user_payload = {
"name": "test_email_validator",
- "password": "newpassword123",
+ "password": "Newpassword123@",
"email": "nonexistentemail@prowler.com",
}
response = authenticated_client.post(
@@ -174,7 +178,7 @@ class TestUserViewSet:
user_payload = {
"name": "test_email_validator",
- "password": "newpassword123",
+ "password": "Newpassword123@",
"email": email,
}
response = authenticated_client.post(
@@ -267,6 +271,10 @@ class TestUserViewSet:
# Fails UserAttributeSimilarityValidator (too similar to email)
"dev12345",
"test@prowler.com",
+ "NewPassword123", # No special character
+ "newpassword123@", # No uppercase letter
+ "NEWPASSWORD123", # No lowercase letter
+ "NewPassword@", # No number
],
)
def test_users_partial_update_invalid_password(
@@ -3950,7 +3958,7 @@ class TestInvitationViewSet:
data = {
"name": "test",
- "password": "newpassword123",
+ "password": "Newpassword123@",
"email": invitation.email,
}
assert invitation.state == Invitation.State.PENDING.value
@@ -4042,7 +4050,7 @@ class TestInvitationViewSet:
data = {
"name": "test",
- "password": "newpassword123",
+ "password": "Newpassword123@",
"email": new_email,
}
diff --git a/api/src/backend/api/validators.py b/api/src/backend/api/validators.py
index 135a0fd6c0..543406f202 100644
--- a/api/src/backend/api/validators.py
+++ b/api/src/backend/api/validators.py
@@ -1,3 +1,5 @@
+import string
+
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
@@ -20,3 +22,89 @@ class MaximumLengthValidator:
return _(
f"Your password must contain no more than {self.max_length} characters."
)
+
+
+class SpecialCharactersValidator:
+ def __init__(self, special_characters=None, min_special_characters=1):
+ # Use string.punctuation if no custom characters provided
+ self.special_characters = special_characters or string.punctuation
+ self.min_special_characters = min_special_characters
+
+ def validate(self, password, user=None):
+ if (
+ sum(1 for char in password if char in self.special_characters)
+ < self.min_special_characters
+ ):
+ raise ValidationError(
+ _("This password must contain at least one special character."),
+ code="password_no_special_characters",
+ params={
+ "special_characters": self.special_characters,
+ "min_special_characters": self.min_special_characters,
+ },
+ )
+
+ def get_help_text(self):
+ return _(
+ f"Your password must contain at least one special character from: {self.special_characters}"
+ )
+
+
+class UppercaseValidator:
+ def __init__(self, min_uppercase=1):
+ self.min_uppercase = min_uppercase
+
+ def validate(self, password, user=None):
+ if sum(1 for char in password if char.isupper()) < self.min_uppercase:
+ raise ValidationError(
+ _(
+ "This password must contain at least %(min_uppercase)d uppercase letter."
+ ),
+ code="password_no_uppercase_letters",
+ params={"min_uppercase": self.min_uppercase},
+ )
+
+ def get_help_text(self):
+ return _(
+ f"Your password must contain at least {self.min_uppercase} uppercase letter."
+ )
+
+
+class LowercaseValidator:
+ def __init__(self, min_lowercase=1):
+ self.min_lowercase = min_lowercase
+
+ def validate(self, password, user=None):
+ if sum(1 for char in password if char.islower()) < self.min_lowercase:
+ raise ValidationError(
+ _(
+ "This password must contain at least %(min_lowercase)d lowercase letter."
+ ),
+ code="password_no_lowercase_letters",
+ params={"min_lowercase": self.min_lowercase},
+ )
+
+ def get_help_text(self):
+ return _(
+ f"Your password must contain at least {self.min_lowercase} lowercase letter."
+ )
+
+
+class NumericValidator:
+ def __init__(self, min_numeric=1):
+ self.min_numeric = min_numeric
+
+ def validate(self, password, user=None):
+ if sum(1 for char in password if char.isdigit()) < self.min_numeric:
+ raise ValidationError(
+ _(
+ "This password must contain at least %(min_numeric)d numeric character."
+ ),
+ code="password_no_numeric_characters",
+ params={"min_numeric": self.min_numeric},
+ )
+
+ def get_help_text(self):
+ return _(
+ f"Your password must contain at least {self.min_numeric} numeric character."
+ )
diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py
index 78ad0f6991..514dcfc6ff 100644
--- a/api/src/backend/config/django/base.py
+++ b/api/src/backend/config/django/base.py
@@ -159,6 +159,30 @@ AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
+ {
+ "NAME": "api.validators.SpecialCharactersValidator",
+ "OPTIONS": {
+ "min_special_characters": 1,
+ },
+ },
+ {
+ "NAME": "api.validators.UppercaseValidator",
+ "OPTIONS": {
+ "min_uppercase": 1,
+ },
+ },
+ {
+ "NAME": "api.validators.LowercaseValidator",
+ "OPTIONS": {
+ "min_lowercase": 1,
+ },
+ },
+ {
+ "NAME": "api.validators.NumericValidator",
+ "OPTIONS": {
+ "min_numeric": 1,
+ },
+ },
]
SIMPLE_JWT = {
diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md
index 934e594dce..f9e40fb654 100644
--- a/ui/CHANGELOG.md
+++ b/ui/CHANGELOG.md
@@ -9,6 +9,10 @@ All notable changes to the **Prowler UI** are documented in this file.
- Mutelist configuration form [(#8190)](https://github.com/prowler-cloud/prowler/pull/8190)
- SAML login integration [(#8203)](https://github.com/prowler-cloud/prowler/pull/8203)
+### Security
+
+- Enhanced password validation to enforce 12+ character passwords with special characters, uppercase, lowercase, and numbers [(#XXXX)](https://github.com/prowler-cloud/prowler/pull/XXXX)
+
### 🔄 Changed
- Upgrade to Next.js 14.2.30 and lock TypeScript to 5.5.4 for ESLint compatibility [(#8189)](https://github.com/prowler-cloud/prowler/pull/8189)
diff --git a/ui/components/auth/oss/auth-form.tsx b/ui/components/auth/oss/auth-form.tsx
index a9f0c3b345..211d238341 100644
--- a/ui/components/auth/oss/auth-form.tsx
+++ b/ui/components/auth/oss/auth-form.tsx
@@ -9,6 +9,7 @@ import { z } from "zod";
import { authenticate, createNewUser } from "@/actions/auth";
import { initiateSamlAuth } from "@/actions/integrations/saml";
+import { PasswordRequirementsMessage } from "@/components/auth/oss/password-validator";
import { NotificationIcon, ProwlerExtended } from "@/components/icons";
import { ThemeSwitch } from "@/components/ThemeSwitch";
import { useToast } from "@/components/ui";
@@ -219,15 +220,22 @@ export const AuthForm = ({
showFormMessage={type !== "sign-in"}
/>
{!isSamlMode && (
-
+ Password meets all requirements +
++ Password must include: +
+