Logomark

LARAVEL

Un framework qui rend heureux
Voir cette catégorie
Vers le bas
Voir cette série
Shop V2 : oubli du mot de passe
Dimanche 2 août 2026 17:50

La mise en place d'un système de réinitialisation de mot de passe est un élément important à prendre en considération lors de la conception d'un site web ou d'une application en ligne. Les utilisateurs peuvent oublier leurs mots de passe pour diverses raisons (absence prolongée, informations sensibles oubliées, etc.). En offrant une fonctionnalité de réinitialisation de mot de passe, vous améliorez l'expérience utilisateur en facilitant leur accès au site ou à l'application.

Le code complet à la fin de cet article peut être téléchargé ici.

On va commencer par créer un composant Blade pour les messages en session :

php artisan make:component session-status --view

@props(['status'])

@if ($status)
    <div {{ $attributes->merge(['class' => 'font-medium text-sm text-green-600']) }}>
        {{ $status }}
    </div>
@endif

Pour l'oubli du mot de passe, on crée un composant Livewire : 

php artisan make:livewire pages::auth.forgot-password

Avec ce code :

<?php

use Illuminate\Support\Facades\Password;
use Livewire\Attributes\{Validate, Title};
use Livewire\Component;

new
#[Title('Mot de passe oublié')]
class extends Component {

    #[Validate('required|email')]
    public string $email = '';

    public function sendPasswordResetLink(): void
    {
        $this->validate();

        // 1. Protection contre le spam (Rate Limiting)
        // On limite à 3 demandes toutes les 10 minutes par e-mail + IP
        $throttleKey = Str::lower($this->email).'|'.request()->ip();
        if (RateLimiter::tooManyAttempts($throttleKey, 3)) {
            $seconds = RateLimiter::availableIn($throttleKey);
            $this->addError('email', __('Veuillez patienter : trop de demandes. Réessayez dans ' . ceil($seconds / 60) . ' min.'));
            return;
        }

        $status = Password::sendResetLink(
            $this->only('email')
        );

        if ($status !== Password::RESET_LINK_SENT) {
            RateLimiter::hit($throttleKey, 600); // On marque une tentative

            // 2. Sécurité : On ne révèle pas si l'utilisateur existe ou non
            // On affiche un message générique même en cas d'erreur
            session()->flash('status', __('Un lien de réinitialisation vous a été envoyé si votre adresse e-mail est enregistrée.'));

            return;
        }

        RateLimiter::clear($throttleKey);
        $this->reset('email');
        session()->flash('status', __($status));
    }
}; ?>

<div class="flex items-center justify-center min-h-[calc(100vh-16rem)] py-12 px-4">
    <div class="w-full max-w-md">
        <x-card
            title="{{ __('Mot de passe oublié') }}"
            subtitle="{{ __('Saisissez votre e-mail pour recevoir un lien de réinitialisation') }}"
            class="shadow-xl bg-base-100"
            separator
            progress-indicator
        >
            {{-- Message de confirmation d'envoi du mail --}}
            @if (session('status'))
                <x-alert icon="o-check-circle" class="alert-success text-xs mb-4">
                    {{ session('status') }}
                </x-alert>
            @endif

            <x-form wire:submit="sendPasswordResetLink" class="space-y-4">
                <x-input
                    label="{{ __('Adresse e-mail') }}"
                    wire:model="email"
                    icon="o-envelope"
                    type="email"
                    required
                />

                <p class="text-xs text-base-content/60 pt-1">
                    <span class="text-error">*</span> @lang('Champ obligatoire')
                </p>

                <x-slot:actions>
                    <div class="flex flex-col sm:flex-row items-center justify-between gap-3 w-full pt-2">
                        <x-button
                            label="{{ __('Retour') }}"
                            type="button"
                            icon="o-arrow-left"
                            class="btn-ghost btn-sm w-full sm:w-auto order-2 sm:order-1"
                            link="{{ route('login') }}"
                        />

                        <x-button
                            label="{{ __('Envoyer le lien') }}"
                            type="submit"
                            icon-right="o-paper-airplane"
                            class="btn-primary btn-sm w-full sm:w-auto order-1 sm:order-2"
                            spinner="sendPasswordResetLink"
                        />
                    </div>
                </x-slot:actions>
            </x-form>
        </x-card>
    </div>
</div>

Et on ajoute la route, toujours dans le même groupe :

Route::middleware('guest')->group(function () {
    ...
    Route::livewire('/forgot-password', 'pages::auth.forgot-password');
});

Voilà le formulaire généré :

Si vous utilisez le formulaire, vous allez tomber sur une erreur à la soumission :

Route [password.reset] not defined.

Et effectivement, on n'a pas encore codé cette partie, alors faisons-le :

php artisan make:livewire pages::auth.reset-password

Avec ce code :

<?php

use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Support\Facades\{Hash, Password, Session};
use Illuminate\Support\Str;
use Livewire\Attributes\{Locked, Title};
use Livewire\Component;
use Illuminate\Validation\Rules\Password as PasswordRule;

new
#[Title('Réinitialisation du mot de passe')]
class extends Component {
    #[Locked]
    public string $token = '';

    public string $email                 = '';
    public string $password              = '';
    public string $password_confirmation = '';
    public ?string $generatedPassword    = null;

    public function mount(string $token): void
    {
        $this->token = $token;
        $this->email = request()->input('email', '');
    }

    /**
     * Génère un mot de passe aléatoire et le remplit dans les champs de mot de passe.
     */
    public function generatePassword(): void
    {
        $this->generatedPassword = Str::password(14);
        $this->password = $this->generatedPassword;
        $this->password_confirmation = $this->generatedPassword;
    }

    public function resetPassword(): void
    {
        $this->validate([
            'token'    => ['required'],
            'email'    => ['required', 'string', 'email'],
            'password' => [
                'required',
                'confirmed',
                PasswordRule::min(8)
                    ->mixedCase()   // Majuscules & Minuscules
                    ->numbers()     // Chiffres
                    ->symbols()     // Caractères spéciaux
                    ->uncompromised(), // Vérifie via "Have I Been Pwned" si le mot de passe a fuité sur le web
            ],
        ]);

        $status = Password::reset(
            $this->only('email', 'password', 'password_confirmation', 'token'),
            function ($user) {
                $user->forceFill([
                    'password'       => Hash::make($this->password),
                    'remember_token' => Str::random(60),
                ])->save();

                event(new PasswordReset($user));
            }
        );

        if (Password::PASSWORD_RESET != $status) {
            $this->addError('email', __($status));
            return;
        }

        Session::flash('status', __($status));

        $this->redirectRoute('login', navigate: true);
    }
}; ?>

<div class="flex items-center justify-center min-h-[calc(100vh-16rem)] py-12 px-4">
    <div class="w-full max-w-md">
        <x-card
            title="{{ __('Réinitialiser le mot de passe') }}"
            subtitle="{{ __('Saisissez votre e-mail et choisissez votre nouveau mot de passe') }}"
            class="shadow-xl bg-base-100"
            separator
            progress-indicator
        >
            @if (session('status'))
                <x-alert icon="o-check-circle" class="alert-success text-xs mb-4">
                    {{ session('status') }}
                </x-alert>
            @endif

            <x-form wire:submit="resetPassword" class="space-y-4">
                <x-input
                    label="{{ __('Adresse e-mail') }}"
                    wire:model="email"
                    icon="o-envelope"
                    type="email"
                    required
                    readonly
                />

                <div class="space-y-3">
                    <x-password
                        label="{{ __('Nouveau mot de passe') }}"
                        wire:model="password"
                        password-icon="o-key"
                        hint="{{ __('8 caractères min., majuscule, minuscule, chiffre et symbole.') }}"
                        clearable
                        required
                    />

                    <x-password
                        label="{{ __('Confirmer le mot de passe') }}"
                        wire:model="password_confirmation"
                        password-icon="o-key"
                        clearable
                        required
                    />

                    <div class="flex justify-end">
                        <x-button
                            type="button"
                            label="{{ __('Générer un mot de passe sécurisé') }}"
                            wire:click="generatePassword"
                            icon="o-wrench"
                            class="btn-ghost btn-xs text-primary"
                        />
                    </div>

                    {{-- Affichage du mot de passe généré --}}
                    @if($generatedPassword)
                        <div
                            x-data="{ copied: false }"
                            class="alert alert-warning shadow-sm mt-3 flex items-center justify-between p-3 rounded-xl"
                        >
                            <div class="flex items-center gap-3">
                                <x-icon name="o-key" class="w-5 h-5 shrink-0" />
                                <div class="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
                                    <span class="font-semibold text-xs text-base-content whitespace-nowrap">
                                        {{ __('Mot de passe généré :') }}
                                    </span>
                                    <code class="font-mono bg-base-100 px-2.5 py-1 rounded-md text-sm font-bold text-base-content select-all tracking-wider border border-base-300">
                                        {{ $generatedPassword }}
                                    </code>
                                </div>
                            </div>

                            <button
                                type="button"
                                class="btn btn-xs gap-1 transition-all"
                                :class="copied ? 'btn-success text-white' : 'btn-ghost bg-base-100/60 hover:bg-base-100 text-base-content'"
                                @click="
                                    navigator.clipboard.writeText('{{ $generatedPassword }}');
                                    copied = true;
                                    setTimeout(() => copied = false, 2000);
                                "
                            >
                                <template x-if="!copied">
                                    <span class="flex items-center gap-1">
                                        <x-icon name="o-document-duplicate" class="w-3.5 h-3.5" />
                                        <span>{{ __('Copier') }}</span>
                                    </span>
                                </template>

                                <template x-if="copied">
                                    <span class="flex items-center gap-1">
                                        <x-icon name="o-check" class="w-3.5 h-3.5" />
                                        <span>{{ __('Copié !') }}</span>
                                    </span>
                                </template>
                            </button>
                        </div>
                    @endif
                </div>

                <p class="text-xs text-base-content/60 pt-1">
                    <span class="text-error">*</span> @lang('Champs obligatoires')
                </p>

                <x-slot:actions>
                    <div class="flex flex-col sm:flex-row items-center justify-between gap-3 w-full pt-2">
                        <x-button
                            label="{{ __('Annuler') }}"
                            type="button"
                            icon="o-arrow-left"
                            class="btn-ghost btn-sm w-full sm:w-auto order-2 sm:order-1"
                            link="{{ route('login') }}"
                        />

                        <x-button
                            label="{{ __('Réinitialiser le mot de passe') }}"
                            type="submit"
                            icon-right="o-check"
                            class="btn-primary btn-sm w-full sm:w-auto order-1 sm:order-2"
                            spinner="resetPassword"
                        />
                    </div>
                </x-slot:actions>
            </x-form>
        </x-card>
    </div>
</div>

On ajoute la route :

Route::middleware('guest')->group(function () {
    ...
    Route::livewire('/reset-password/{token}', 'pages::auth.reset-password')->name('password.reset');
});

Maintenant l'envoi du lien fonctionne et on a un message qui apparaît :

Voici l'aspect de l'email :

En utilisant le lien, on aboutit sur ce formulaire :

Tant qu'à faire, j'ai rendu l'email non modifiable. Après réinitialisation, on se retrouve sur la page de connexion.

Conclusion

Dans ce chapitre, on a créé la logistique pour le renouvellement du mot de passe.



Par bestmomo

Aucun commentaire

Article précédent : Shop V2 : l'authentification