Laravel

Un framework qui rend heureux

Voir cette catégorie
Vers le bas
Voir cette série
Shop : l'authentification
Lundi 13 janvier 2025 16:31

Dans cette partie du développement de notre boutique, nous allons intégrer les formulaires d'authentification. Bien que Laravel propose des kits d'authentification prêts à l'emploi, je préfère adopter une approche sur mesure. Cette décision nous permet de créer un système d'authentification parfaitement adapté à nos besoins spécifiques, tout en offrant une flexibilité maximale.

Vous pouvez trouver le code dans ce dépôt Github.

Créer un compte

Une règle de validation pour les mots de passe

La première des choses à prévoir est évidemment un formulaire pour s'enregistrer sur le site. On va sécuriser convenablement les mots de passe proposés en imposant une majuscule, une minuscule, un nombre et un caractère particulier, ce qui est une approche classique. Il n'existe pas de règle toute prête dans Laravel pour vérifier ça. Alors, on crée une règle personnalisée : 

php artisan make:rule StrongPassword

Avec ce code :

public function validate(string $attribute, mixed $value, Closure $fail): void
{
    if (!preg_match('/[A-Z]/', $value) || 
        !preg_match('/[a-z]/', $value) || 
        !preg_match('/[0-9]/', $value) || 
        !preg_match('/[^A-Za-z0-9]/', $value)) {
        $fail(trans('The :attribute must contain at least one uppercase letter, one lowercase letter, one number, and one special character.'));
    }
}

On ajoute la traduction du message :

"The :attribute must contain at least one uppercase letter, one lowercase letter, one number, and one special character.": "Le :attribute doit contenir au moins une lettre majuscule, une lettre minuscule, un chiffre et un caractère special."

La génération d'un mot de passe sécurisé

Pour le confort de l'utilisateur, on va proposer la génération d'un mot de passe sécurisé. Comme cette fonctionnalité sera utilisée dans plusieurs classes on crée un trait :

On en profite pour placer également les propriétés communes dans ce trait dont voilà le code :

<?php

namespace App\Traits;

trait ManageProfile
{
	public string $firstname = '';
	public string $name = '';
	public string $email = '';
	public string $password = '';
	public string $password_confirmation = '';
	public bool   $newsletter = false;

    public function generatePassword(int $length = 16): void
    {
        $characters = array_merge(
            range('A', 'Z'), 
            range('a', 'z'),
            range('0', '9'), 
            str_split('!@#$%^&*()_+-=[]{}|;:,.<>?')
        );

        $password = '';
        $max = count($characters) - 1;

        $password .= chr(random_int(65, 90)); 
        $password .= chr(random_int(97, 122));
        $password .= chr(random_int(48, 57));
        $password .= $characters[array_rand(array_slice($characters, 62))];

        while (strlen($password) < $length) {
            $password .= $characters[random_int(0, $max)];
        }

        $this->password = str_shuffle($password);
        $this->password_confirmation = $this->password;
    }
}

Un email de confirmation

Il faut aussi prévoir l'envoi d'un email à l'utilisateur pour confirmer que l'inscription s'est bien déroulée.

La classe

On crée la classe qui va gérer l'envoi de l'email :

php artisan make:mail UserRegistered

Avec ce code :

<?php

namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Mail\Mailables\Address;
use App\Models\Shop;

class UserRegistered extends Mailable
{
    use Queueable, SerializesModels;

    Public Shop $shop;

    public function __construct()
    {
        $this->shop = Shop::firstOrFail();
    }

    public function envelope(): Envelope
    {
        return new Envelope(
            from: new Address($this->shop->email, $this->shop->name),
            subject: trans('You have been registered'),
        );
    }

    public function content(): Content
    {
        return new Content(
            view: 'mails.registered',
        );
    }

    public function attachments(): array
    {
        return [];
    }
}

On transmet les données de la boutique pour les renseignements essentiels à donner au nouvel inscrit.

On a une traduction à prévoir :

"You have been registered": "Vous avez bien été enregistré"

La vue

On doit créer la vue associée à cet email :

Comme le code est assez volumineux, je vous propose d'aller chercher le fichier dans le dépôt. Pour créer ce fichier, je me suis appuyé sur l'excellent site Beefree. Dans la version gratuite, on peut créer dix emails, et faire six exportations chaque mois. On peut ainsi créer le design et, après exportation, apporter les modifications nécessaires pour adapter l'email à nos données.

On a encore besoin de traductions pour cet email :

"Thank you for creating an account": "Merci d'avoir créé un compte",
"Your access code is": "Votre code d'accès est",
"Safety tips": "Conseils de sécurité",
"Your account information must remain confidential.": "Vos informations de compte doivent rester confidentielles.",
"Never give them to anyone.": "Ne les communiquez à personne.",
"Change your password regularly.": "Changez votre mot de passe régulièrement.",
"If you believe that someone is using your account illegally, please notify us immediately.": "Si vous pensez qu'une personne utilise votre compte indûment, veuillez nous notifier immédiatement.",
"You can now order on": "Vous pouvez maintenant commander sur",
"Questions?": "Des questions ?",
"our shop": "notre boutique",
"You can": "Vous pouvez",
"send us a message": "nous envoyer un message",
"Call us": "Appelez-nous",
"Hello": "Bonjour",

D'autre part, pour vos essais, vous aurez besoin d'une plateforme pour recevoir les emails. J'aime bien utiliser Mailtrap. Avec un compte gratuit, on a droit à 1000 emails par mois, ce qui est assez généreux. Les fonctionnalités sont nombreuses et faciles à utiliser. pour que ça fonctionne, vous devez entrer les bons paramètres dans le fichier .env.

MAIL_MAILER=smtp
MAIL_HOST=sandbox.smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=445ccd3e72a7b2
MAIL_PASSWORD=******************

Vous trouvez votre identifiant et votre mot de passe dans votre boîte de réception sur le site. Ainsi, tous les emails qui partent de votre projet arrivent dans cette boîte de réception.

Le composant pour la création d'un compte

On crée le composant Volt pour la création d'un compte :

php artisan make:volt auth/register --class

Et voilà le code :

<?php

use App\Models\User;
use App\Traits\ManageProfile;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use App\Notifications\NewUser;
use App\Rules\StrongPassword;
use App\Mail\UserRegistered;
use Illuminate\Support\Facades\Mail;

new #[Title('Register')] 
class extends Component {
	use ManageProfile;

	public ?string $gender = null;

	public function register()
	{
		if ($this->gender) {
			abort(403);
		}

		$data = $this->validate([
			'firstname'  => 'required|string|max:255',
			'name'       => 'required|string|max:255',
			'newsletter' => 'nullable',
			'email'      => 'required|email|unique:users',
			'password'   => ['required','string','min:8','confirmed', new StrongPassword,],
			'password_confirmation' => 'required',
		]);

		$data['password'] = Hash::make($data['password']);
		$user = User::create($data);
		auth()->login($user);
		request()->session()->regenerate();

		Mail::to(auth()->user())->send(new UserRegistered());

		session()->flash('registered', __('Your account has been successfully created. An email has been sent to you with all the details.'));

		return redirect('/');
	}
	
}; ?>

<div>
    <x-card 
		class="flex justify-center items-center mt-6" 
		title="{{ __('Register') }}" 
		shadow 
		separator
        progress-indicator
	>
        <x-form wire:submit="register" x-data="{ rgpd: false }" class="w-full sm:min-w-[50vw]">
			<x-input 
				label="{{ __('Your firstName') }}" 
				wire:model="firstname" icon="o-user" 
				required 
			/>
            <x-input 
				label="{{ __('Your name') }}" 
				wire:model="name" 
				icon="o-user" 
				required 
			/>
            <x-input 
				label="{{ __('Your e-mail') }}" 
				wire:model="email" 
				icon="o-envelope" 
				required 
			/>
            <x-input 
				label="{{ __('Your password') }}" 
				wire:model="password" 
				icon="o-key"
				hint="{{ __('Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one number and one special character') }}"
				required
			/>
            <x-input 
				label="{{ __('Confirm Password') }}" 
				wire:model="password_confirmation" 
				icon="o-key" 
				required
			/>
			<x-button 
				label="{{ __('Generate a secure password') }}" 
				wire:click="generatePassword()" 
				icon="m-wrench"
				class="btn-outline btn-sm" 
				required
			/>
			<hr>
			<x-checkbox 
				label="{{ __('I would like to receive your newsletter') }}" 
				wire:model="newsletter" 
			/>
			<x-checkbox 
				label="{!! __('I accept the terms and conditions of the privacy policy') !!}" 
				x-model="rgpd" 
			/>        
            <div style="display: none;">
                <x-input 
					wire:model="gender" 
					type="text" 
					inline 
				/>
            </div>
			<p class="text-xs text-gray-500"><span class="text-red-600">*</span> @lang('Required information')</p>
            <x-slot:actions>
                <x-button 
					label="{{ __('Already registered?') }}" 
					class="btn-ghost" 
					link="/login" 
				/>
                <x-button 
					x-show="rgpd" 
					label="{{ __('Register') }}" 
					type="submit" 
					icon="o-paper-airplane" 
					class="btn-primary"
                    spinner="login" 
				/>
            </x-slot:actions>
        </x-form>

    </x-card>
</div>

Avec quelques traductions :

"Your account has been successfully created. An email has been sent to you with all the details.": "Votre compte a bien été crée. Un email vous a ete envoyé avec toutes les informations.",
"Your e-mail": "Votre courriel",
"Your password": "Votre mot de passe",
"Your firstName": "Votre prénom",
"Your name": "Votre nom",
"Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one number and one special character": "Le mot de passe doit contenir au moins 8 caractères et au moins une lettre majuscule, une lettre minuscule, un chiffre et un caractère special.",
"Confirm Password": "Confirmez le mot de passe",
"Generate a secure password": "Créer un mot de passe securisé",
"I would like to receive your newsletter": "Je souhaite recevoir votre newsletter",
"I accept the terms and conditions of the privacy policy": "J'accepte les conditions de la politique de confidentialité",
"Already registered?": "Vous avez déjà un compte ?",
"Required information": "Information requise",

On ajoute la route :

use Illuminate\Support\Facades\Route;

...

Route::middleware('guest')->group(function () {
	Volt::route('/register', 'auth.register');
});

Et maintenant avec l'url .../register, on arrive sur le formulaire :

Un message pour rassurer

Le nouvel inscrit va recevoir un email de confirmation, mais il est judicieux lors du renvoi sur la page d'accueil, même s'il se retrouve déjà connecté dans la boutique, de lui afficher un message. Dans le composant d'inscription, on a prévu un message en session :

session()->flash('registered', __('Your account has been successfully created. An email has been sent to you with all the details.'));

Il faut l'utiliser dans notre composant index de l'accueil :

<div class="container mx-auto">
    @if (session('registered'))
        <x-alert 
            title="{!! session('registered') !!}" 
            icon="s-rocket-launch" 
            class="mb-4 alert-info" 
            dismissible 
        />
    @endif

Le pot de miel

Si vous êtes observateur, vous avez remarqué la présence insolite d'une propriété gender qui semble inutile. C'est un moyen appelé "pot de miel" pour tromper les robots.

Les robots automatisés (spambots) remplissent souvent tous les champs de formulaire qu'ils trouvent, sans se soucier de leur visibilité. En ajoutant un champ de saisie masqué (le pot de miel), vous pouvez détecter si un robot a rempli le formulaire. Si le champ gender contient une valeur lors de la soumission du formulaire, cela signifie probablement que le formulaire a été rempli par un robot, car un utilisateur humain ne verrait pas ce champ et ne le remplirait pas.

En utilisant cette technique, vous pouvez ajouter une couche de sécurité supplémentaire à vos formulaires. Si le champ gender est rempli, vous pouvez rejeter la soumission du formulaire et empêcher les spambots de soumettre des données indésirables. On le fait dans le composant avec ce code :

public function register()
{
    if ($this->gender) {
        abort(403);
    }

Fonctionnement

Il ne nous reste plus qu'à vérifier que tout fonctionne correctement. Les validations :

L'enregistrement effectif dans la base :

Le message sur la page d'accueil :

Et la cohérence de l'email reçu :

La connexion

À présent qu'on peut s'inscrire dans la boutique, on doit prévoir la possibilité de se connecter. On crée un nouveau composant :

php artisan make:volt auth/login --class

Entrez ce code :

<?php

use Livewire\Attributes\{Validate, Title};
use Livewire\Volt\Component;
use Illuminate\Support\Str;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;

new
#[Title('Login')]
class extends Component {

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

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

	#[Validate('boolean')]
	public bool $remember = false;

	public function login()
	{
        $this->validate();
        $this->authenticate();
        Session::regenerate();

        $this->redirectIntended(default: url('/'), navigate: true);
	}

    public function authenticate(): void
    {
        $this->ensureIsNotRateLimited();

        if (! Auth::attempt($this->only(['email', 'password']), $this->remember)) {
            RateLimiter::hit($this->throttleKey());

            throw ValidationException::withMessages([
                'email' => __('auth.failed'),
            ]);
        }

        RateLimiter::clear($this->throttleKey());
    }

    protected function ensureIsNotRateLimited(): void
    {
        if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
            return;
        }

        event(new Lockout(request()));

        $seconds = RateLimiter::availableIn($this->throttleKey());

        throw ValidationException::withMessages([
            'email' => trans('auth.throttle', [
                'seconds' => $seconds,
                'minutes' => ceil($seconds / 60),
            ]),
        ]);
    }

    protected function throttleKey(): string
    {
        return Str::transliterate(Str::lower($this->email).'|'.request()->ip());
    }

}; ?>

<div>
    <x-card 
        class="flex justify-center items-center mt-6" 
        title="{{ __('Log in') }}" 
        shadow 
        separator 
        progress-indicator
    >
        <x-form wire:submit="login" >
            <x-input 
                label="{{ __('Your e-mail') }}" 
                wire:model="email" 
                icon="o-envelope" 
                type="email" 
                required 
            />
            <x-input 
                label="{{ __('Your password') }}" 
                wire:model="password" 
                type="password" 
                icon="o-key" 
                required 
            />
            <x-checkbox 
                label="{{ __('Remain identified for a few days') }}" 
                wire:model="remember" 
            />
            <p class="text-xs text-gray-500"><span class="text-red-600">*</span> @lang('Required information')</p>
            <x-slot:actions>
                <div class="flex flex-col space-y-2 flex-end sm:flex-row sm:space-y-0 sm:space-x-2">
                    <x-button 
                        label="{{ __('Login') }}" 
                        type="submit" 
                        icon="o-paper-airplane" 
                        class="ml-2 btn-primary sm:order-1" 
                    />
                    <div class="flex flex-col space-y-2 flex-end sm:flex-row sm:space-y-0 sm:space-x-2">
                        <x-button 
                            label="{{ __('Forgot your password?') }}" 
                            class="btn-ghost" 
                            link="/forgot-password" 
                        />
                        <x-button 
                            label="{{ __('Create my account') }}" 
                            class="btn-ghost" 
                            link="/register" 
                        />
                    </div>
                </div>
            </x-slot:actions>
        </x-form>
    </x-card>
</div>

On ajoute les traductions :

"Log in": "Identifiez-vous",
"Remain identified for a few days": "Rester identifié  quelques jours",
"Forgot your password?": "Mot de passe oublié ?",
"Remember me": "Se rappeler de moi",
"Create my account": "Créer mon compte",

Et la route :

Route::middleware('guest')->group(function () {
	...
	Volt::route('/login', 'auth.login')->name('login');
});

On obtient ce joli formulaire :

Vérifiez la validation :

Et évidemment aussi que la connexion et la déconnexion fonctionnent bien.

Conclusion

Dans ce chapitre, on a mis en place les fondamentaux de l'authentification avec l'inscription à la boutique et la connexion. Dans le prochain chapitre, on s'occupera de l'oubli du mot de passe.



Par bestmomo

Aucun commentaire

Article précédent : Shop : la page d'accueil
Article suivant : Shop : oubli du mot de passe