feat(security): password strength (#8225)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Pepe Fagoaga
2025-07-10 08:05:22 +02:00
committed by GitHub
parent 55c226029e
commit d63a383ec6
14 changed files with 349 additions and 32 deletions
+5
View File
@@ -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)
+1 -1
View File
@@ -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
@@ -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",
@@ -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(
@@ -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()
+3 -3
View File
@@ -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(
+13 -5
View File
@@ -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,
}
+88
View File
@@ -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."
)
+24
View File
@@ -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 = {
+4
View File
@@ -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)
+17 -9
View File
@@ -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 && (
<CustomInput
control={form.control}
name="password"
password
isInvalid={
!!form.formState.errors.password ||
!!form.formState.errors.email
}
/>
<>
<CustomInput
control={form.control}
name="password"
password
isInvalid={
!!form.formState.errors.password ||
!!form.formState.errors.email
}
/>
{type === "sign-up" && (
<PasswordRequirementsMessage
password={form.watch("password") || ""}
/>
)}
</>
)}
{/* {type === "sign-in" && (
<div className="flex items-center justify-between px-1 py-2">
@@ -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 (
<div className={className}>
<div
className={`rounded-xl border p-3 ${
allRequirementsMet
? "border-system-success bg-system-success/10"
: "border-red-200 bg-red-50"
}`}
role="region"
aria-label="Password requirements status"
>
{allRequirementsMet ? (
<div className="flex items-center gap-2">
<CheckCircle
className="h-4 w-4 flex-shrink-0 text-system-success"
aria-hidden="true"
/>
<p className="text-xs font-medium leading-tight text-system-success">
Password meets all requirements
</p>
</div>
) : (
<div className="space-y-1">
<div className="flex items-center gap-2">
<AlertCircle
className="h-4 w-4 flex-shrink-0 text-red-600"
aria-hidden="true"
/>
<p className="text-xs font-medium leading-tight text-red-700">
Password must include:
</p>
</div>
<ul className="ml-6 space-y-0.5" aria-label="Password requirements">
{results.map((req) => (
<li
key={req.key}
className="flex items-center gap-2 text-xs leading-tight"
>
<div className="flex items-center gap-2">
<div
className={`h-2 w-2 flex-shrink-0 rounded-full ${
req.isMet ? "bg-system-success" : "bg-red-400"
}`}
aria-hidden="true"
/>
<span
className={`${req.isMet ? "text-system-success" : "text-red-700"}`}
aria-label={`${req.label} ${req.isMet ? "satisfied" : "required"}`}
>
{req.label}
</span>
</div>
<span className="sr-only">
{req.isMet ? "Requirement met" : "Requirement not met"}
</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
);
};
+2
View File
@@ -4,3 +4,5 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const SPECIAL_CHARACTERS = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
+65 -8
View File
@@ -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(