feat: verify email

This commit is contained in:
Leon Morival 2026-04-28 14:56:25 +02:00
parent 38ea85c03b
commit 6966919887
11 changed files with 331 additions and 6 deletions

View File

@ -59,6 +59,15 @@ deploy-to-portainer:
- apk add --no-cache curl jq gettext
script:
- |
export MAIL_MAILER="${MAIL_MAILER:-smtp}"
export MAIL_SCHEME="${MAIL_SCHEME:-null}"
export MAIL_FROM_NAME="${MAIL_FROM_NAME:-$APP_NAME}"
: "${MAIL_HOST:?Missing MAIL_HOST GitLab CI/CD variable}"
: "${MAIL_PORT:?Missing MAIL_PORT GitLab CI/CD variable}"
: "${MAIL_USERNAME:?Missing MAIL_USERNAME GitLab CI/CD variable}"
: "${MAIL_PASSWORD:?Missing MAIL_PASSWORD GitLab CI/CD variable}"
: "${MAIL_FROM_ADDRESS:?Missing MAIL_FROM_ADDRESS GitLab CI/CD variable}"
- envsubst < docker-compose.prod.yml > docker-compose.rendered.yml
- |

View File

@ -6,9 +6,11 @@ use App\Http\Requests\Auth\LoginRequest;
use App\Http\Requests\Auth\RegisterRequest;
use App\Models\User;
use Hash;
use Illuminate\Auth\Events\Verified;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\Response;
class AuthController extends Controller
@ -36,6 +38,8 @@ class AuthController extends Controller
$this->resolveDeviceName($data)
)->plainTextToken;
$user->sendEmailVerificationNotification();
return response()->json([
'token' => $token,
'token_type' => 'Bearer',
@ -79,6 +83,47 @@ class AuthController extends Controller
return response()->json($user);
}
public function verifyEmail(Request $request, string $id, string $hash): JsonResponse|View
{
$user = User::findOrFail($id);
if (! hash_equals($hash, sha1($user->getEmailForVerification()))) {
abort(Response::HTTP_FORBIDDEN);
}
if (! $user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
event(new Verified($user));
}
if ($request->expectsJson()) {
return response()->json([
'message' => __('auth.email_verified'),
]);
}
return view('auth.email-verified');
}
public function resendEmailVerificationNotification(Request $request): JsonResponse
{
/** @var \App\Models\User $user */
$user = $request->user();
if ($user->hasVerifiedEmail()) {
return response()->json([
'message' => __('auth.email_already_verified'),
]);
}
$user->sendEmailVerificationNotification();
return response()->json([
'message' => __('auth.email_verification_sent'),
]);
}
public function logout(Request $request): JsonResponse
{
$request->user()?->currentAccessToken()?->delete();

View File

@ -2,11 +2,12 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Enums\UserRole;
use App\Notifications\VerifyEmailNotification;
use Filament\Models\Contracts\FilamentUser;
use Filament\Models\Contracts\HasName;
use Filament\Panel;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -15,7 +16,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable implements FilamentUser, HasName
class User extends Authenticatable implements FilamentUser, HasName, MustVerifyEmail
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, HasUuids, Notifiable, SoftDeletes;
@ -96,6 +97,11 @@ class User extends Authenticatable implements FilamentUser, HasName
return $this->belongsTo(Level::class, 'level_id');
}
public function sendEmailVerificationNotification(): void
{
$this->notify(new VerifyEmailNotification);
}
public function updateLevel(): void
{
$level = Level::where('xpThreshold', '<=', $this->xp)

View File

@ -0,0 +1,48 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\URL;
class VerifyEmailNotification extends Notification
{
use Queueable;
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
/** @var MustVerifyEmail $notifiable */
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(Config::get('auth.verification.expire', 60)),
[
'id' => $notifiable->getKey(),
'hash' => sha1($notifiable->getEmailForVerification()),
],
);
return (new MailMessage)
->subject('Vérifie ton adresse email')
->view('mail.auth.verify-email', [
'notifiable' => $notifiable,
'verificationUrl' => $verificationUrl,
]);
}
}

View File

@ -20,6 +20,14 @@ services:
DB_DATABASE: "${DB_DATABASE}"
DB_USERNAME: "${DB_USERNAME}"
DB_PASSWORD: "${DB_PASSWORD}"
MAIL_MAILER: "${MAIL_MAILER}"
MAIL_SCHEME: "${MAIL_SCHEME}"
MAIL_HOST: "${MAIL_HOST}"
MAIL_PORT: "${MAIL_PORT}"
MAIL_USERNAME: "${MAIL_USERNAME}"
MAIL_PASSWORD: "${MAIL_PASSWORD}"
MAIL_FROM_ADDRESS: "${MAIL_FROM_ADDRESS}"
MAIL_FROM_NAME: "${MAIL_FROM_NAME}"
volumes:
- storage-data:/var/www/html/storage/app

View File

@ -17,5 +17,8 @@ return [
'password' => 'The provided password is incorrect.',
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
'logout_success' => 'You have been deconnecte',
'email_verified' => 'Email address verified.',
'email_already_verified' => 'Email address already verified.',
'email_verification_sent' => 'Verification link sent.',
];

View File

@ -17,5 +17,8 @@ return [
'password' => 'Email ou mot de passe incorrect',
'throttle' => 'Trop de tentatives veuillez reessayer dans :seconds seconds.',
'logout_success' => 'Vous êtes bien déconnecté',
'email_verified' => 'Adresse email vérifiée.',
'email_already_verified' => 'Adresse email déjà vérifiée.',
'email_verification_sent' => 'Lien de vérification envoyé.',
];

View File

@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Email vérifié</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: #f4f7fb;
color: #172033;
font-family: Arial, Helvetica, sans-serif;
}
main {
width: min(100% - 32px, 480px);
padding: 32px;
background: #ffffff;
border: 1px solid #e6ebf2;
border-radius: 12px;
text-align: center;
}
h1 {
margin: 0 0 12px;
color: #101828;
font-size: 28px;
line-height: 1.2;
}
p {
margin: 0;
color: #475467;
font-size: 16px;
line-height: 1.6;
}
</style>
</head>
<body>
<main>
<h1>Email vérifié</h1>
<p>Ton adresse email a bien été validée. Tu peux maintenant retourner sur l'application.</p>
</main>
</body>
</html>

View File

@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vérifie ton adresse email</title>
</head>
<body style="margin: 0; padding: 0; background: #f4f7fb; color: #172033; font-family: Arial, Helvetica, sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background: #f4f7fb; padding: 32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 560px; overflow: hidden; background: #ffffff; border: 1px solid #e6ebf2; border-radius: 12px;">
<tr>
<td style="padding: 32px 32px 16px;">
<p style="margin: 0 0 8px; color: #657086; font-size: 14px;">{{ config('app.name') }}</p>
<h1 style="margin: 0; color: #101828; font-size: 28px; line-height: 1.2;">Vérifie ton adresse email</h1>
</td>
</tr>
<tr>
<td style="padding: 0 32px 24px;">
<p style="margin: 0 0 16px; color: #344054; font-size: 16px; line-height: 1.6;">
Bonjour {{ $notifiable->username }},
</p>
<p style="margin: 0 0 24px; color: #344054; font-size: 16px; line-height: 1.6;">
Clique sur le bouton ci-dessous pour confirmer ton adresse email et activer ton compte.
</p>
<p style="margin: 0 0 28px;">
<a href="{{ $verificationUrl }}" style="display: inline-block; padding: 14px 22px; background: #16a34a; border-radius: 8px; color: #ffffff; font-size: 16px; font-weight: 700; text-decoration: none;">
Vérifier mon email
</a>
</p>
<p style="margin: 0 0 16px; color: #667085; font-size: 14px; line-height: 1.6;">
Si le bouton ne fonctionne pas, ouvre ce lien dans ton navigateur :
</p>
<p style="margin: 0; word-break: break-all; color: #475467; font-size: 13px; line-height: 1.5;">
<a href="{{ $verificationUrl }}" style="color: #1570ef;">{{ $verificationUrl }}</a>
</p>
</td>
</tr>
<tr>
<td style="padding: 20px 32px 32px; border-top: 1px solid #edf1f7;">
<p style="margin: 0; color: #98a2b3; font-size: 13px; line-height: 1.5;">
Si tu n'es pas à l'origine de cette demande, tu peux ignorer cet email.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -11,6 +11,9 @@ use Illuminate\Support\Facades\Route;
Route::prefix('auth')->group(function (): void {
Route::post('register', [AuthController::class, 'register']);
Route::post('login', [AuthController::class, 'login']);
Route::get('email/verify/{id}/{hash}', [AuthController::class, 'verifyEmail'])
->middleware(['signed', 'throttle:6,1'])
->name('verification.verify');
// Route::get('{provider}/redirect', [ProviderRedirectController::class, 'redirect']);
// Route::get('{provider}/callback', [ProviderCallbackController::class, 'callback']);
});
@ -18,12 +21,12 @@ Route::prefix('auth')->group(function (): void {
// List hunts
// Ensure is Admin
Route::middleware(['auth:sanctum', EnsureUserIsAdmin::class])->group(function () {
Route::middleware(['auth:sanctum', 'verified', EnsureUserIsAdmin::class])->group(function () {
Route::post('users/{user}/delete', [UsersController::class, 'softDeleteById'])->withTrashed();
});
// Ensure is Orga
Route::middleware(['auth:sanctum', EnsureUserIsOrga::class])->group(function () {
Route::middleware(['auth:sanctum', 'verified', EnsureUserIsOrga::class])->group(function () {
Route::post('hunts', [HuntsController::class, 'store']);
Route::post('hunts/{hunt}/steps', [HuntStepsController::class, 'store']);
Route::match(['put', 'patch'], 'hunts/{hunt}', [HuntsController::class, 'update']);
@ -32,6 +35,14 @@ Route::middleware(['auth:sanctum', EnsureUserIsOrga::class])->group(function ()
// Auth middleware
Route::middleware('auth:sanctum')->group(function (): void {
Route::post('auth/email/verification-notification', [AuthController::class, 'resendEmailVerificationNotification'])
->middleware('throttle:6,1')
->name('verification.send');
Route::get('auth/me', [AuthController::class, 'me']);
Route::post('auth/logout', [AuthController::class, 'logout']);
});
Route::middleware(['auth:sanctum', 'verified'])->group(function (): void {
Route::get('hunts', [HuntsController::class, 'index']);
Route::get('hunts/{hunt}', [HuntsController::class, 'show']);
// Auth
@ -45,6 +56,4 @@ Route::middleware('auth:sanctum')->group(function (): void {
Route::post('hunts/{hunt}/participate', [HuntsController::class, 'participate']);
Route::delete('hunts/{hunt}/participate', [HuntsController::class, 'unparticipate']);
Route::post('hunts/{hunt}/steps/{step}/validate', [HuntStepsController::class, 'validateStep']);
Route::get('auth/me', [AuthController::class, 'me']);
Route::post('auth/logout', [AuthController::class, 'logout']);
});

View File

@ -0,0 +1,94 @@
<?php
use App\Models\User;
use App\Notifications\VerifyEmailNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\URL;
use Laravel\Sanctum\Sanctum;
uses(RefreshDatabase::class);
it('sends a verification email after registration', function () {
Notification::fake();
$this->postJson('/api/auth/register', [
'username' => 'new-user',
'email' => 'new-user@example.com',
'password' => 'Password1!',
'password_confirmation' => 'Password1!',
'device_name' => 'iphone',
])->assertCreated();
$user = User::where('email', 'new-user@example.com')->firstOrFail();
expect($user->hasVerifiedEmail())->toBeFalse();
Notification::assertSentTo($user, VerifyEmailNotification::class, function (VerifyEmailNotification $notification) use ($user) {
$mail = $notification->toMail($user);
expect($mail->subject)->toBe('Vérifie ton adresse email')
->and($mail->render())->toContain('Vérifier mon email');
return true;
});
});
it('resends a verification email for an authenticated unverified user', function () {
Notification::fake();
$user = User::factory()->unverified()->create();
Sanctum::actingAs($user);
$this->postJson('/api/auth/email/verification-notification')
->assertOk()
->assertJsonPath('message', __('auth.email_verification_sent'));
Notification::assertSentTo($user, VerifyEmailNotification::class);
});
it('verifies the user email from a signed verification link', function () {
$user = User::factory()->unverified()->create();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
[
'id' => $user->getKey(),
'hash' => sha1($user->getEmailForVerification()),
],
);
$this->getJson($verificationUrl)
->assertOk()
->assertJsonPath('message', __('auth.email_verified'));
expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
});
it('shows a confirmation page when the verification link is opened in a browser', function () {
$user = User::factory()->unverified()->create();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
[
'id' => $user->getKey(),
'hash' => sha1($user->getEmailForVerification()),
],
);
$this->get($verificationUrl)
->assertOk()
->assertSeeText('Email vérifié')
->assertSeeText("Ton adresse email a bien été validée. Tu peux maintenant retourner sur l'application.", false);
});
it('blocks verified routes for authenticated unverified users', function () {
$user = User::factory()->unverified()->create();
Sanctum::actingAs($user);
$this->getJson('/api/hunts')->assertForbidden();
});