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 && ( - + <> + + {type === "sign-up" && ( + + )} + )} {/* {type === "sign-in" && (
diff --git a/ui/components/auth/oss/password-validator.tsx b/ui/components/auth/oss/password-validator.tsx new file mode 100644 index 0000000000..e3ef2a2b1e --- /dev/null +++ b/ui/components/auth/oss/password-validator.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { AlertCircle, CheckCircle } from "lucide-react"; + +import { + PASSWORD_REQUIREMENTS, + passwordRequirementCheckers, +} from "@/types/authFormSchema"; + +interface PasswordRequirementsMessageProps { + password: string; + className?: string; +} + +const REQUIREMENTS = [ + { + key: "minLength", + checker: passwordRequirementCheckers.minLength, + label: `At least ${PASSWORD_REQUIREMENTS.minLength} characters`, + }, + { + key: "specialChars", + checker: passwordRequirementCheckers.specialChars, + label: "Special characters", + }, + { + key: "uppercase", + checker: passwordRequirementCheckers.uppercase, + label: "Uppercase letters", + }, + { + key: "lowercase", + checker: passwordRequirementCheckers.lowercase, + label: "Lowercase letters", + }, + { + key: "numbers", + checker: passwordRequirementCheckers.numbers, + label: "Numbers", + }, +]; + +export const PasswordRequirementsMessage = ({ + password, + className = "", +}: PasswordRequirementsMessageProps) => { + const hasPasswordInput = password.length > 0; + if (!hasPasswordInput) { + return null; + } + const results = REQUIREMENTS.map((req) => ({ + ...req, + isMet: req.checker(password), + })); + const metCount = results.filter((r) => r.isMet).length; + const allRequirementsMet = metCount === REQUIREMENTS.length; + + return ( +
+
+ {allRequirementsMet ? ( +
+
+ ) : ( +
+
+
+
    + {results.map((req) => ( +
  • +
    + + + {req.isMet ? "Requirement met" : "Requirement not met"} + +
  • + ))} +
+
+ )} +
+
+ ); +}; diff --git a/ui/lib/utils.ts b/ui/lib/utils.ts index 365058cebd..6939d456cc 100644 --- a/ui/lib/utils.ts +++ b/ui/lib/utils.ts @@ -4,3 +4,5 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export const SPECIAL_CHARACTERS = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"; diff --git a/ui/types/authFormSchema.ts b/ui/types/authFormSchema.ts index 1e1d26aca0..b4600e6cbf 100644 --- a/ui/types/authFormSchema.ts +++ b/ui/types/authFormSchema.ts @@ -1,7 +1,69 @@ import { z } from "zod"; +import { SPECIAL_CHARACTERS } from "@/lib/utils"; + export type AuthSocialProvider = "google" | "github"; +export const PASSWORD_REQUIREMENTS = { + minLength: 12, + specialChars: SPECIAL_CHARACTERS, + requireUppercase: true, + requireLowercase: true, + requireNumbers: true, +} as const; + +export const passwordRequirementCheckers = { + minLength: (password: string) => + password.length >= PASSWORD_REQUIREMENTS.minLength, + specialChars: (password: string) => + PASSWORD_REQUIREMENTS.specialChars + .split("") + .some((char) => password.includes(char)), + uppercase: (password: string) => /[A-Z]/.test(password), + lowercase: (password: string) => /[a-z]/.test(password), + numbers: (password: string) => /[0-9]/.test(password), +}; + +export const validatePassword = () => { + const { + minLength, + specialChars, + requireUppercase, + requireLowercase, + requireNumbers, + } = PASSWORD_REQUIREMENTS; + + return z + .string() + .min(minLength, { + message: `Password must contain at least ${minLength} characters.`, + }) + .refine(passwordRequirementCheckers.specialChars, { + message: `Password must contain at least one special character from: ${specialChars}`, + }) + .refine( + (password) => + !requireUppercase || passwordRequirementCheckers.uppercase(password), + { + message: "Password must contain at least one uppercase letter.", + }, + ) + .refine( + (password) => + !requireLowercase || passwordRequirementCheckers.lowercase(password), + { + message: "Password must contain at least one lowercase letter.", + }, + ) + .refine( + (password) => + !requireNumbers || passwordRequirementCheckers.numbers(password), + { + message: "Password must contain at least one number.", + }, + ); +}; + export const authFormSchema = (type: string) => z .object({ @@ -20,8 +82,8 @@ export const authFormSchema = (type: string) => confirmPassword: type === "sign-in" ? z.string().optional() - : z.string().min(12, { - message: "It must contain at least 12 characters.", + : z.string().min(1, { + message: "Please confirm your password.", }), invitationToken: type === "sign-in" ? z.string().optional() : z.string().optional(), @@ -35,12 +97,7 @@ export const authFormSchema = (type: string) => // Fields for Sign In and Sign Up email: z.string().email(), - password: - type === "sign-in" - ? z.string() - : z.string().min(12, { - message: "It must contain at least 12 characters.", - }), + password: type === "sign-in" ? z.string() : validatePassword(), isSamlMode: z.boolean().optional(), }) .refine(