fix(auth) : use dedicated plainPassword field for password hashing

- Add non-persisted plainPassword field to User entity (write-only via API)
- Remove direct write access to password field
- Update UserPasswordHasherProcessor to hash from plainPassword
- Update frontend DTO and UserDrawer component

Ticket: T-009

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-17 15:23:29 +01:00
parent 2ac815d074
commit ed58a402b0
4 changed files with 28 additions and 8 deletions

View File

@@ -1,5 +1,5 @@
<template> <template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un utilisateur' : 'Ajouter un utilisateur'"> <AppDrawer v-model="isOpen" :title="isEditing ? $t('users.editUser') : $t('users.addUser')">
<form class="flex flex-col gap-2" @submit.prevent="handleSubmit"> <form class="flex flex-col gap-2" @submit.prevent="handleSubmit">
<MalioInputText <MalioInputText
v-model="form.username" v-model="form.username"
@@ -90,6 +90,8 @@ import { useProjectService } from '~/services/projects'
import type { Client } from '~/services/dto/client' import type { Client } from '~/services/dto/client'
import type { Project } from '~/services/dto/project' import type { Project } from '~/services/dto/project'
const { t } = useI18n()
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
item: UserData | null item: UserData | null
@@ -114,7 +116,7 @@ const clients = ref<Client[]>([])
const allProjects = ref<Project[]>([]) const allProjects = ref<Project[]>([])
const clientOptions = computed(() => [ const clientOptions = computed(() => [
{ label: 'Aucun client', value: null as number | null }, { label: t('common.noClient'), value: null as number | null },
...clients.value.map((c) => ({ label: c.name, value: c.id as number | null })), ...clients.value.map((c) => ({ label: c.name, value: c.id as number | null })),
]) ])
@@ -190,7 +192,7 @@ async function handleSubmit() {
allowedProjects: form.allowedProjectIds.map((id) => `/api/projects/${id}`), allowedProjects: form.allowedProjectIds.map((id) => `/api/projects/${id}`),
} }
if (form.password) { if (form.password) {
payload.password = form.password payload.plainPassword = form.password
} }
if (isEditing.value && props.item) { if (isEditing.value && props.item) {

View File

@@ -12,7 +12,7 @@ export type UserData = {
export type UserWrite = { export type UserWrite = {
username: string username: string
password?: string plainPassword?: string
roles: string[] roles: string[]
client?: string | null client?: string | null
allowedProjects?: string[] allowedProjects?: string[]

View File

@@ -61,9 +61,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
private array $roles = []; private array $roles = [];
#[ORM\Column] #[ORM\Column]
#[Groups(['user:write'])]
private ?string $password = null; private ?string $password = null;
#[Groups(['user:write'])]
private ?string $plainPassword = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)] #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private ?DateTimeImmutable $createdAt = null; private ?DateTimeImmutable $createdAt = null;
@@ -224,5 +226,20 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return '/api/users/'.$this->id.'/avatar'; return '/api/users/'.$this->id.'/avatar';
} }
public function eraseCredentials(): void {} public function getPlainPassword(): ?string
{
return $this->plainPassword;
}
public function setPlainPassword(?string $plainPassword): static
{
$this->plainPassword = $plainPassword;
return $this;
}
public function eraseCredentials(): void
{
$this->plainPassword = null;
}
} }

View File

@@ -29,10 +29,11 @@ final readonly class UserPasswordHasherProcessor implements ProcessorInterface
*/ */
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{ {
$plainPassword = $data->getPassword(); $plainPassword = $data->getPlainPassword();
if (null !== $plainPassword && !str_starts_with($plainPassword, '$')) { if (null !== $plainPassword && '' !== $plainPassword) {
$data->setPassword($this->passwordHasher->hashPassword($data, $plainPassword)); $data->setPassword($this->passwordHasher->hashPassword($data, $plainPassword));
$data->setPlainPassword(null);
} }
return $this->persistProcessor->process($data, $operation, $uriVariables, $context); return $this->persistProcessor->process($data, $operation, $uriVariables, $context);