mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
feat(security): password strength (#8225)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -4,3 +4,5 @@ import { twMerge } from "tailwind-merge";
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export const SPECIAL_CHARACTERS = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user