feat: mandatory auto-generated passwords for share links + admin password copy/regeneration + password gate rate limiting

This commit is contained in:
Pontoporeia
2026-05-12 13:50:13 +02:00
parent 8bb0b3a1f2
commit 9152b120e8
15 changed files with 294 additions and 68 deletions

View File

@@ -41,18 +41,29 @@ class ShareLink
// ── CRUD ──────────────────────────────────────────────────────────────────
/**
* Generate a cryptographically secure random password.
* Returns a 16-byte hex string (32 characters).
*/
public static function generatePassword(): string
{
return bin2hex(random_bytes(16));
}
/**
* Create a new share link.
*
* Password is always auto-generated — no custom passwords allowed.
*
* @param int $createdBy Admin user ID
* @param string|null $password Plain-text password (will be hashed), null = no password
* @param string|null $expiresAt ISO-8601 expiration date, null = never expires
* @return array|null The created link row
* @return array|null The created link row with _plain_password attached
*/
public function create(int $createdBy, ?string $password = null, ?string $expiresAt = null, ?string $objetRestriction = null, ?string $name = null): ?array
public function create(int $createdBy, ?string $expiresAt = null, ?string $objetRestriction = null, ?string $name = null): ?array
{
$slug = self::generateSlug();
$passwordHash = $password !== null ? password_hash($password, PASSWORD_BCRYPT) : null;
$plainPassword = self::generatePassword();
$passwordHash = password_hash($plainPassword, PASSWORD_BCRYPT);
$validObjet = ['tfe', 'thèse', 'frart'];
if ($objetRestriction !== null && $objetRestriction !== '') {
$parts = array_intersect(explode(',', $objetRestriction), $validObjet);
@@ -62,12 +73,17 @@ class ShareLink
}
$stmt = $this->db->getConnection()->prepare(
'INSERT INTO share_links (slug, name, objet_restriction, password_hash, is_active, created_by, expires_at)
VALUES (?, ?, ?, ?, 1, ?, ?)'
'INSERT INTO share_links (slug, name, objet_restriction, password_hash, encrypted_password, is_active, created_by, expires_at)
VALUES (?, ?, ?, ?, ?, 1, ?, ?)'
);
$stmt->execute([$slug, $name, $objetRestriction, $passwordHash, $createdBy, $expiresAt]);
$stmt->execute([$slug, $name, $objetRestriction, $passwordHash, Crypto::encrypt($plainPassword), $createdBy, $expiresAt]);
return $this->findBySlug($slug);
$link = $this->findBySlug($slug);
if ($link) {
// Attach plain-text password temporarily so the caller can display it
$link['_plain_password'] = $plainPassword;
}
return $link;
}
/**
@@ -100,15 +116,44 @@ class ShareLink
return $row ?: null;
}
/**
* Decrypt and return the plain-text password for a link.
* Returns empty string if not set or decryption fails.
*/
public function getDecryptedPassword(int $id): string
{
$stmt = $this->db->getConnection()->prepare(
'SELECT encrypted_password FROM share_links WHERE id = ?'
);
$stmt->execute([$id]);
$enc = $stmt->fetchColumn();
return $enc ? Crypto::decrypt($enc) : '';
}
/**
* List active (non-archived) share links, ordered by creation date descending.
* Decorates each row with its decrypted password.
*/
public function listActive(): array
{
$stmt = $this->db->getConnection()->query(
'SELECT * FROM share_links WHERE is_archived = 0 ORDER BY created_at DESC'
);
return $stmt->fetchAll();
$rows = $stmt->fetchAll();
return array_map(fn($row) => $this->decorateWithPassword($row), $rows);
}
/**
* Decorate a link row with its decrypted password.
*/
private function decorateWithPassword(array $row): array
{
if (!empty($row['encrypted_password'])) {
$row['_decrypted_password'] = Crypto::decrypt($row['encrypted_password']);
} else {
$row['_decrypted_password'] = '';
}
return $row;
}
/**
@@ -186,9 +231,9 @@ class ShareLink
}
/**
* Update a share link (name, password, expiration).
* Update a share link (name, expiration).
*/
public function update(int $id, ?string $name = null, ?string $password = null, ?string $expiresAt = null): void
public function update(int $id, ?string $name = null, ?string $expiresAt = null): void
{
$pdo = $this->db->getConnection();
$fields = [];
@@ -198,10 +243,6 @@ class ShareLink
$fields[] = 'name = ?';
$params[] = $name;
}
if ($password !== null) {
$fields[] = 'password_hash = ?';
$params[] = $password !== '' ? password_hash($password, PASSWORD_BCRYPT) : null;
}
if ($expiresAt !== null) {
$expiresAtVal = $expiresAt !== '' ? date('Y-m-d H:i:s', strtotime($expiresAt)) : null;
$fields[] = 'expires_at = ?';