Skip to main content

Password Policy

This page documents the password policy and associated security mechanisms.

Overview

The password policy defines the rules for:

  • Minimum password complexity
  • Expiration and rotation
  • Lockout after failures
  • Password history

Complexity Rules

Minimum Requirements

CriterionRequirement
Minimum length8 characters
UppercaseAt least 1
LowercaseAt least 1
NumbersAt least 1
Special charactersAt least 1 (!@#$%^&*)

Forbidden Passwords

  • Same as username
  • In the list of common passwords
  • Same as the last 5 passwords

Configuration

Environment Variables

# Password policy
PASSWORD_MIN_LENGTH=8
PASSWORD_REQUIRE_UPPERCASE=true
PASSWORD_REQUIRE_LOWERCASE=true
PASSWORD_REQUIRE_NUMBER=true
PASSWORD_REQUIRE_SPECIAL=true
PASSWORD_HISTORY_COUNT=5
PASSWORD_EXPIRY_DAYS=90

# Account lockout
LOGIN_MAX_ATTEMPTS=5
LOGIN_LOCKOUT_MINUTES=15

Symfony Configuration

# config/packages/security.yaml
security:
password_hashers:
App\Entity\User:
algorithm: bcrypt
cost: 13

Validation

Symfony Validator

use Symfony\Component\Validator\Constraints as Assert;

class ChangePasswordRequest
{
#[Assert\NotBlank]
#[Assert\Length(min: 8)]
#[Assert\Regex(
pattern: '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]+$/',
message: 'Password must contain uppercase, lowercase, numbers and special characters'
)]
private string $newPassword;
}

Validation Service

class PasswordPolicyService
{
public function validate(string $password, User $user): array
{
$errors = [];

if (strlen($password) < 8) {
$errors[] = 'Minimum 8 characters required';
}

if (!preg_match('/[A-Z]/', $password)) {
$errors[] = 'At least one uppercase letter required';
}

if ($password === $user->getEmail()) {
$errors[] = 'Password cannot be the same as email';
}

if ($this->isInHistory($password, $user)) {
$errors[] = 'This password has been used recently';
}

return $errors;
}
}

Account Lockout

Mechanism

Attempt 1: Failure → Counter = 1
Attempt 2: Failure → Counter = 2
...
Attempt 5: Failure → ACCOUNT LOCKED

User Fields

FieldTypeDescription
failedLoginAttemptsintNumber of failed attempts
lockedUntildatetimeUnlock date
lastFailedLogindatetimeLast failed attempt

Implementation

class LoginService
{
public function handleFailedLogin(User $user): void
{
$attempts = $user->getFailedLoginAttempts() + 1;
$user->setFailedLoginAttempts($attempts);
$user->setLastFailedLogin(new \DateTime());

if ($attempts >= 5) {
$user->setLockedUntil(new \DateTime('+15 minutes'));
}

$this->em->flush();
}

public function handleSuccessfulLogin(User $user): void
{
$user->resetFailedAttempts();
$user->setLockedUntil(null);
$this->em->flush();
}

public function isLocked(User $user): bool
{
$lockedUntil = $user->getLockedUntil();
return $lockedUntil && $lockedUntil > new \DateTime();
}
}

Password Expiration

Configuration

# config/services.yaml
parameters:
password_expiry_days: 90

Verification

public function isPasswordExpired(User $user): bool
{
$lastChange = $user->getPasswordChangedAt();
if (!$lastChange) {
return true;
}

$expiryDate = (clone $lastChange)->modify('+90 days');
return $expiryDate < new \DateTime();
}

Force Change

// Listener that redirects to password change
if ($this->passwordService->isPasswordExpired($user)) {
return new RedirectResponse('/password/change');
}

Password History

Storage

#[ORM\Entity]
class PasswordHistory
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private UuidInterface $id;

#[ORM\ManyToOne(targetEntity: User::class)]
private User $user;

#[ORM\Column]
private string $passwordHash;

#[ORM\Column]
private \DateTimeInterface $createdAt;
}

Verification

public function isInHistory(string $newPassword, User $user): bool
{
$history = $this->historyRepo->findRecent($user, 5);

foreach ($history as $old) {
if (password_verify($newPassword, $old->getPasswordHash())) {
return true;
}
}

return false;
}

API Endpoints

Change Password

POST /api/auth/change-password
{
"currentPassword": "OldPass123!",
"newPassword": "NewPass456@",
"confirmPassword": "NewPass456@"
}

Reset Password (Admin)

POST /api/admin/users/{id}/reset-password
{
"newPassword": "TempPass789#"
}

Console Commands

Reset a Password

php bin/console app:reset-user-password user@example.com NewPassword123!

Check Expired Accounts

php bin/console app:check-password-expiry

User Messages

SituationMessage
Password too shortPassword must contain at least 8 characters
Missing uppercasePassword must contain at least one uppercase letter
Account lockedAccount locked. Try again in X minutes
Password expiredYour password has expired. Please change it
HistoryThis password has been used recently

Best Practices

:::tip Recommendations

  1. Strong hashing: Use bcrypt with cost >= 12
  2. No plain storage: Never store passwords in plain text
  3. Secure transport: HTTPS mandatory
  4. Logs without passwords: Never log passwords
  5. Rate limiting: Limit login attempts :::

Audit

Logged Events

EventLevelData
Successful loginINFOemail, IP, timestamp
Failed loginWARNINGemail, IP, attempt #
Account lockedWARNINGemail, IP
Password changedINFOemail, by_whom
Password resetWARNINGemail, by_whom