diff --git a/.env.testing b/.env.testing new file mode 100755 index 0000000..d8ea204 --- /dev/null +++ b/.env.testing @@ -0,0 +1,19 @@ +APP_NAME=Laravel +APP_ENV=testing +APP_KEY=base64:jR19NpQ/jzcye05DaypprlW5DohdERYVk0Ot/SXa4F4= +APP_DEBUG=true +APP_URL=http://localhost + +DB_CONNECTION=mysql +DB_HOST=db +DB_PORT=3306 +DB_DATABASE=laravel_test +DB_USERNAME=laravel +DB_PASSWORD=secret + +CACHE_STORE=array +QUEUE_CONNECTION=sync +SESSION_DRIVER=array +MAIL_MAILER=array + +BCRYPT_ROUNDS=4 diff --git a/README.md b/README.md index 0c9daf8..009d3d8 100755 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ This will build and start all containers in detached mode. ### 2. Access the application Open your browser and navigate to: + ``` http://localhost:8080 ``` @@ -125,59 +126,6 @@ podman-compose exec app chown -R www-data:www-data /var/www/html/storage podman-compose exec app chmod -R 755 /var/www/html/storage ``` -## Configuration - -### Environment Variables - -The application is configured via the `.env` file. Key Docker-specific settings: - -``` -DB_CONNECTION=mysql -DB_HOST=db -DB_PORT=3306 -DB_DATABASE=laravel -DB_USERNAME=laravel -DB_PASSWORD=secret - -REDIS_HOST=redis -REDIS_PORT=6379 -``` - -### Database Credentials - -- **Database**: laravel -- **Username**: laravel -- **Password**: secret -- **Root Password**: root - -### Ports - -- **Application**: 8080 (maps to container port 80) -- **MySQL**: 3306 -- **Redis**: 6379 - -## Project Structure - -``` -. -├── app/ # Application code -├── bootstrap/ # Bootstrap files -├── config/ # Configuration files -├── database/ # Migrations, seeders, factories -├── docker/ # Docker configuration -│ ├── nginx/ # Nginx configuration -│ └── supervisor/ # Supervisor configuration -├── public/ # Public assets -├── resources/ # Views, assets -├── routes/ # Route definitions -├── storage/ # Storage files -├── tests/ # Tests -├── vendor/ # Composer dependencies -├── compose.yml # Podman Compose configuration -├── Dockerfile # Container image definition -└── .env # Environment configuration -``` - ## Troubleshooting ### Containers won't start @@ -216,54 +164,3 @@ podman-compose exec app php artisan config:clear podman-compose exec app php artisan route:clear podman-compose exec app php artisan view:clear ``` - -## Development Workflow - -1. Make code changes in your local files -2. Changes are reflected immediately via volume mounting -3. For composer or config changes, you may need to restart containers: - ```bash - podman-compose restart app - ``` - -## Stopping the Application - -```bash -# Stop containers (preserves data) -podman-compose down - -# Stop and remove volumes (deletes database data) -podman-compose down -v -``` - -## Additional Information - -- Laravel Version: 12.52.0 -- PHP Version: 8.3 -- MySQL Version: 8.0 -- Nginx: Latest stable -- Redis: Alpine latest - -For more information about Laravel, visit [https://laravel.com/docs](https://laravel.com/docs) - ---- - -## About Laravel - -Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: - -- [Simple, fast routing engine](https://laravel.com/docs/routing). -- [Powerful dependency injection container](https://laravel.com/docs/container). -- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. -- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). -- Database agnostic [schema migrations](https://laravel.com/docs/migrations). -- [Robust background job processing](https://laravel.com/docs/queues). -- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). - -## Security Vulnerabilities - -If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. - -## License - -The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/app/Auth/HashEmailUserProvider.php b/app/Auth/HashEmailUserProvider.php new file mode 100755 index 0000000..fbd08cb --- /dev/null +++ b/app/Auth/HashEmailUserProvider.php @@ -0,0 +1,75 @@ +retrieveById($identifier); + + if (!$user) { + return null; + } + + $rememberToken = $user->getRememberToken(); + + return $rememberToken && hash_equals($rememberToken, $token) ? $user : null; + } + + /** + * Update the "remember me" token for the given user in storage. + */ + public function updateRememberToken(Authenticatable $user, $token): void + { + $user->setRememberToken($token); + $user->save(); + } + + /** + * Retrieve a user by the given credentials. + */ + public function retrieveByCredentials(array $credentials): ?Authenticatable + { + if (empty($credentials['email'])) { + return null; + } + + return User::findByEmail($credentials['email']); + } + + /** + * Validate a user against the given credentials. + * + * For passwordless authentication, this always returns true. + */ + public function validateCredentials(Authenticatable $user, array $credentials): bool + { + return true; + } + + /** + * Rehash the user's password if required and update the model. + * + * This is a no-op for passwordless authentication. + */ + public function rehashPasswordIfRequired(Authenticatable $user, array $credentials, bool $force = false): void + { + // No-op: passwordless authentication doesn't use passwords + } +} diff --git a/app/Http/Controllers/Auth/MagicLinkController.php b/app/Http/Controllers/Auth/MagicLinkController.php new file mode 100755 index 0000000..1caba8c --- /dev/null +++ b/app/Http/Controllers/Auth/MagicLinkController.php @@ -0,0 +1,129 @@ +validate([ + 'email' => 'required|email', + ]); + + $email = $request->email; + $ip = $request->ip(); + $userAgent = $request->userAgent(); + + try { + $token = $this->authService->sendMagicLink($email, $ip, $userAgent); + + // Generate signed URL valid for 15 minutes + $loginUrl = URL::temporarySignedRoute( + 'magic-link.verify', + now()->addMinutes(15), + ['token' => $token->plain_token] + ); + + // Queue the magic link email + Mail::to($email)->queue(new MagicLoginLink($loginUrl, $token->plain_code, 15)); + + return back()->with('status', 'Check your email for a login link and code!'); + } catch (ValidationException $e) { + throw $e; + } + } + + /** + * Show the code verification form. + */ + public function showCodeForm(Request $request) + { + return view('auth.verify-code'); + } + + /** + * Verify the magic link token. + */ + public function verifyLink(Request $request) + { + // Validate the signed URL + if (!$request->hasValidSignature()) { + return redirect()->route('login')->with('error', 'Invalid or expired magic link.'); + } + + $token = $request->token; + + if ($this->authService->verifyMagicLink($token)) { + $request->session()->regenerate(); + + return redirect()->route('dashboard'); + } + + return redirect()->route('login')->with('error', 'Invalid or expired magic link.'); + } + + /** + * Verify the magic code. + */ + public function verifyCode(Request $request) + { + $request->validate([ + 'email' => 'required|email', + 'code' => 'required|digits:6', + ]); + + $email = $request->email; + $code = $request->code; + + try { + if ($this->authService->verifyCode($email, $code)) { + $request->session()->regenerate(); + + return redirect()->route('dashboard'); + } + + return back()->withErrors([ + 'code' => 'Invalid or expired code.', + ]); + } catch (ValidationException $e) { + throw $e; + } + } + + /** + * Log the user out. + */ + public function logout(Request $request) + { + Auth::logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect('/'); + } +} diff --git a/app/Mail/MagicLoginLink.php b/app/Mail/MagicLoginLink.php new file mode 100755 index 0000000..18d252f --- /dev/null +++ b/app/Mail/MagicLoginLink.php @@ -0,0 +1,54 @@ + + */ + public function attachments(): array + { + return []; + } +} diff --git a/app/Models/MagicLoginToken.php b/app/Models/MagicLoginToken.php new file mode 100755 index 0000000..d7af1a2 --- /dev/null +++ b/app/Models/MagicLoginToken.php @@ -0,0 +1,138 @@ + + */ + protected $fillable = [ + 'email_hash', + 'token_hash', + 'plain_token', + 'code_hash', + 'plain_code', + 'expires_at', + 'ip_address', + 'user_agent', + 'used_at', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'expires_at' => 'datetime', + 'used_at' => 'datetime', + ]; + } + + /** + * Generate a new magic login token for the given email. + */ + public static function generate(string $email, ?string $ip = null, ?string $ua = null): self + { + $emailHash = User::hashEmail($email); + $wordToken = self::generateWordToken(); + $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); + + return self::create([ + 'email_hash' => $emailHash, + 'token_hash' => Hash::make($wordToken), + 'plain_token' => $wordToken, + 'code_hash' => Hash::make($code), + 'plain_code' => $code, + 'expires_at' => now()->addMinutes(15), + 'ip_address' => $ip, + 'user_agent' => $ua, + ]); + } + + /** + * Generate a unique 4-word token from the word list. + */ + public static function generateWordToken(): string + { + $words = explode("\n", trim(Storage::get('words.txt'))); + + do { + $selectedWords = []; + for ($i = 0; $i < 4; $i++) { + $selectedWords[] = $words[array_rand($words)]; + } + $token = implode('-', $selectedWords); + + // Check for uniqueness in database + $exists = self::where('plain_token', $token)->exists(); + } while ($exists); + + return $token; + } + + /** + * Check if the token is valid (not expired and not used). + */ + public function isValid(): bool + { + return $this->expires_at->isFuture() && $this->used_at === null; + } + + /** + * Mark the token as used. + */ + public function markAsUsed(): void + { + $this->update(['used_at' => now()]); + } + + /** + * Verify the provided token matches the plain token. + */ + public function verifyToken(string $token): bool + { + return $this->plain_token === $token; + } + + /** + * Verify the provided code matches the hashed code. + */ + public function verifyCode(string $code): bool + { + return Hash::check($code, $this->code_hash); + } + + /** + * Scope a query to only include valid tokens. + */ + public function scopeValid(Builder $query): void + { + $query->where('expires_at', '>', now()) + ->whereNull('used_at'); + } + + /** + * Scope a query to only include expired or used tokens. + */ + public function scopeExpiredOrUsed(Builder $query): void + { + $query->where(function (Builder $q) { + $q->where('expires_at', '<=', now()) + ->orWhereNotNull('used_at'); + }); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 749c7b7..369b1aa 100755 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -19,8 +20,7 @@ class User extends Authenticatable */ protected $fillable = [ 'name', - 'email', - 'password', + 'email_hash', ]; /** @@ -29,7 +29,7 @@ class User extends Authenticatable * @var list */ protected $hidden = [ - 'password', + 'email_hash', 'remember_token', ]; @@ -42,7 +42,32 @@ class User extends Authenticatable { return [ 'email_verified_at' => 'datetime', - 'password' => 'hashed', ]; } + + /** + * Hash an email address using HMAC-SHA256. + */ + public static function hashEmail(string $email): string + { + return hash_hmac('sha256', strtolower(trim($email)), config('app.key')); + } + + /** + * Find a user by their email address. + */ + public static function findByEmail(string $email): ?self + { + $emailHash = self::hashEmail($email); + + return self::where('email_hash', $emailHash)->first(); + } + + /** + * Get the magic login tokens for the user. + */ + public function magicLoginTokens(): HasMany + { + return $this->hasMany(MagicLoginToken::class, 'email_hash', 'email_hash'); + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..10e78c1 100755 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Auth\HashEmailUserProvider; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -19,6 +21,8 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - // + Auth::provider('hash_email', function ($app, array $config) { + return new HashEmailUserProvider(); + }); } } diff --git a/app/Services/MagicLinkAuthService.php b/app/Services/MagicLinkAuthService.php new file mode 100755 index 0000000..123842a --- /dev/null +++ b/app/Services/MagicLinkAuthService.php @@ -0,0 +1,117 @@ + [ + "Too many magic link requests. Please try again in {$seconds} seconds.", + ], + ]); + } + + RateLimiter::hit($key, 3600); + + $user = User::findByEmail($email); + + if (!$user) { + $user = User::create([ + 'email_hash' => User::hashEmail($email), + ]); + } + + return MagicLoginToken::generate($email, $ip, $ua); + } + + /** + * Verify a magic link token and log the user in. + */ + public function verifyMagicLink(string $token): bool + { + $magicToken = MagicLoginToken::valid() + ->where('plain_token', $token) + ->first(); + + if (!$magicToken) { + return false; + } + + $user = User::where('email_hash', $magicToken->email_hash)->first(); + + if (!$user) { + return false; + } + + $magicToken->markAsUsed(); + + Auth::login($user, true); + + return true; + } + + /** + * Verify a magic code and log the user in. + */ + public function verifyCode(string $email, string $code): bool + { + $emailHash = User::hashEmail($email); + $key = 'magic-code:' . $emailHash; + + if (RateLimiter::tooManyAttempts($key, 5)) { + return false; + } + + $magicToken = MagicLoginToken::valid() + ->where('email_hash', $emailHash) + ->latest() + ->first(); + + if (!$magicToken || !$magicToken->verifyCode($code)) { + RateLimiter::hit($key, 300); + return false; + } + + $user = User::where('email_hash', $emailHash)->first(); + + if (!$user) { + return false; + } + + $magicToken->markAsUsed(); + + RateLimiter::clear($key); + + Auth::login($user, true); + + return true; + } + + /** + * Clean up expired and used tokens older than 7 days. + */ + public function cleanupExpiredTokens(): int + { + return MagicLoginToken::expiredOrUsed() + ->where('created_at', '<', now()->subDays(7)) + ->delete(); + } +} diff --git a/compose.yml b/compose.yml index c8f1d8d..41ed6b5 100755 --- a/compose.yml +++ b/compose.yml @@ -15,14 +15,7 @@ services: depends_on: - db - redis - environment: - - DB_HOST=db - - DB_PORT=3306 - - DB_DATABASE=laravel - - DB_USERNAME=laravel - - DB_PASSWORD=secret - - REDIS_HOST=redis - - REDIS_PORT=6379 + - mailpit db: image: docker.io/library/mysql:8.0 @@ -35,6 +28,7 @@ services: MYSQL_PASSWORD: secret volumes: - dbdata:/var/lib/mysql:Z + - ./docker/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql:Z ports: - "3306:3306" networks: @@ -49,6 +43,16 @@ services: networks: - laravel + mailpit: + image: docker.io/axllent/mailpit:latest + container_name: laravel-mailpit + restart: unless-stopped + ports: + - "8025:8025" # Web UI + - "1025:1025" # SMTP server + networks: + - laravel + networks: laravel: driver: bridge diff --git a/config/auth.php b/config/auth.php index 7d1eb0d..b9f7119 100755 --- a/config/auth.php +++ b/config/auth.php @@ -61,7 +61,7 @@ return [ 'providers' => [ 'users' => [ - 'driver' => 'eloquent', + 'driver' => 'hash_email', 'model' => env('AUTH_MODEL', App\Models\User::class), ], diff --git a/database/migrations/2026_02_22_000001_modify_users_table_for_passwordless_auth.php b/database/migrations/2026_02_22_000001_modify_users_table_for_passwordless_auth.php new file mode 100755 index 0000000..0e1e524 --- /dev/null +++ b/database/migrations/2026_02_22_000001_modify_users_table_for_passwordless_auth.php @@ -0,0 +1,43 @@ +string('email_hash', 64)->unique()->after('id'); + } + + // Make email, password, and name nullable (temporary during transition) + $table->string('email')->nullable()->change(); + $table->string('password')->nullable()->change(); + $table->string('name')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + // Remove email_hash column + $table->dropUnique(['email_hash']); + $table->dropColumn('email_hash'); + + // Restore email, password, and name to NOT NULL + $table->string('email')->nullable(false)->change(); + $table->string('password')->nullable(false)->change(); + $table->string('name')->nullable(false)->change(); + }); + } +}; diff --git a/database/migrations/2026_02_22_000002_create_magic_login_tokens_table.php b/database/migrations/2026_02_22_000002_create_magic_login_tokens_table.php new file mode 100755 index 0000000..4ff43e8 --- /dev/null +++ b/database/migrations/2026_02_22_000002_create_magic_login_tokens_table.php @@ -0,0 +1,39 @@ +id(); + $table->string('email_hash', 64)->index(); + $table->string('token_hash'); + $table->string('plain_token'); + $table->string('code_hash'); + $table->string('plain_code', 6); + $table->timestamp('expires_at')->index(); + $table->ipAddress('ip_address')->nullable(); + $table->text('user_agent')->nullable(); + $table->timestamp('used_at')->nullable(); + $table->timestamps(); + + // Composite index for efficient querying of valid tokens + $table->index(['expires_at', 'used_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('magic_login_tokens'); + } +}; diff --git a/database/migrations/2026_02_22_000003_drop_password_reset_tokens_table.php b/database/migrations/2026_02_22_000003_drop_password_reset_tokens_table.php new file mode 100755 index 0000000..a089c3a --- /dev/null +++ b/database/migrations/2026_02_22_000003_drop_password_reset_tokens_table.php @@ -0,0 +1,28 @@ +string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + } +}; diff --git a/database/migrations/2026_02_22_000004_remove_password_from_users_table.php b/database/migrations/2026_02_22_000004_remove_password_from_users_table.php new file mode 100755 index 0000000..32db676 --- /dev/null +++ b/database/migrations/2026_02_22_000004_remove_password_from_users_table.php @@ -0,0 +1,36 @@ +dropUnique(['email']); + }); + + Schema::table('users', function (Blueprint $table) { + // Now drop the password and email columns - we only use email_hash now + $table->dropColumn(['password', 'email']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + // Restore password and email columns + $table->string('email')->unique()->after('email_hash'); + $table->string('password')->after('email'); + }); + } +}; diff --git a/docker/mysql/init.sql b/docker/mysql/init.sql new file mode 100644 index 0000000..672dc9b --- /dev/null +++ b/docker/mysql/init.sql @@ -0,0 +1,4 @@ +-- Create test database +CREATE DATABASE IF NOT EXISTS `laravel_test`; +GRANT ALL PRIVILEGES ON `laravel_test`.* TO 'laravel'@'%'; +FLUSH PRIVILEGES; diff --git a/phpunit.xml b/phpunit.xml index d703241..04fd4c8 100755 --- a/phpunit.xml +++ b/phpunit.xml @@ -23,8 +23,6 @@ - - diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php new file mode 100755 index 0000000..de23350 --- /dev/null +++ b/resources/views/auth/login.blade.php @@ -0,0 +1,77 @@ + + + + + + + Login - Scan.fyi + + + +
+
+
+

Scan.fyi

+

Passwordless Authentication

+
+ + @if (session('status')) +
+

{{ session('status') }}

+
+ @endif + + @if (session('error')) +
+

{{ session('error') }}

+
+ @endif + +
+ @csrf + +
+ + + @error('email') +

{{ $message }}

+ @enderror +
+ + +
+ + +
+ +
+

+ You will receive a secure login link via email +

+
+
+ + diff --git a/resources/views/auth/verify-code.blade.php b/resources/views/auth/verify-code.blade.php new file mode 100755 index 0000000..50c827a --- /dev/null +++ b/resources/views/auth/verify-code.blade.php @@ -0,0 +1,101 @@ + + + + + + + Verify Code - Scan.fyi + + + +
+
+
+

Scan.fyi

+

Enter Your Verification Code

+
+ + @if (session('status')) +
+

{{ session('status') }}

+
+ @endif + + @if (session('error')) +
+

{{ session('error') }}

+
+ @endif + +
+ @csrf + +
+ + + @error('email') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('code') +

{{ $message }}

+ @enderror +

+ Enter the 6-digit code from your email +

+
+ + +
+ + +
+ +
+

+ Codes expire after 15 minutes +

+
+
+ + diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php new file mode 100755 index 0000000..e135e17 --- /dev/null +++ b/resources/views/dashboard.blade.php @@ -0,0 +1,98 @@ + + + + + + + Dashboard - Scan.fyi + + + + + +
+
+
+
+ + + +
+ +

+ Welcome, {{ auth()->user()->name ?? 'User' }}! +

+ +

+ You are successfully logged in using passwordless authentication +

+ +
+

+ Security: Your session is secured with a 30-day remember token +

+
+
+ +
+
+
+ + + +
+

Email-Based Login

+

No passwords to remember or manage

+
+ +
+
+ + + +
+

Secure Authentication

+

Time-limited codes and magic links

+
+ +
+
+ + + +
+

Fast Access

+

Quick login with verification codes

+
+
+
+
+ +
+

+ Powered by Scan.fyi Passwordless Authentication +

+
+ + diff --git a/resources/views/emails/magic-login-text.blade.php b/resources/views/emails/magic-login-text.blade.php new file mode 100755 index 0000000..289e036 --- /dev/null +++ b/resources/views/emails/magic-login-text.blade.php @@ -0,0 +1,36 @@ +{{ config('app.name') }} +=============================================== + +YOUR LOGIN LINK +=============================================== + +Hello, + +You requested a login link for your {{ config('app.name') }} account. + +CLICK THIS LINK TO LOG IN: +{{ $loginUrl }} + +----------------------------------------------- + +OR + +----------------------------------------------- + +ENTER THIS CODE ON THE LOGIN PAGE: + +{{ $code }} + +----------------------------------------------- + +IMPORTANT INFORMATION: +- This link and code will expire in {{ $expiresInMinutes }} minutes for your security +- If you didn't request this login link, you can safely ignore this email +- Your account remains secure + +----------------------------------------------- + +This is an automated message from {{ config('app.name') }}. +Please do not reply to this email. + +© {{ date('Y') }} {{ config('app.name') }}. All rights reserved. diff --git a/resources/views/emails/magic-login.blade.php b/resources/views/emails/magic-login.blade.php new file mode 100755 index 0000000..99af026 --- /dev/null +++ b/resources/views/emails/magic-login.blade.php @@ -0,0 +1,108 @@ + + + + + + Your Login Link + + + + + + +
+ + + + + + + + + + + + + + + +
+

+ {{ config('app.name') }} +

+
+

+ Your Login Link +

+ +

+ Click the button below to securely log in to your account. +

+ + + + + + +
+ + Log In to {{ config('app.name') }} + +
+ + + + + + + + +
+ OR +
+ + +
+

+ Enter this code on the login page: +

+
+ + {{ $code }} + +
+
+ + +
+

+ Important: This link and code will expire in {{ $expiresInMinutes }} minutes for your security. +

+
+ + +
+

+ If you didn't request this login link, you can safely ignore this email. Your account remains secure. +

+
+
+

+ This is an automated message from {{ config('app.name') }}.
+ Please do not reply to this email. +

+
+ + + + + + +
+

+ © {{ date('Y') }} {{ config('app.name') }}. All rights reserved. +

+
+
+ + diff --git a/routes/console.php b/routes/console.php index 3c9adf1..2cc32f8 100755 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,18 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +// Scheduled task to clean up expired magic login tokens +Schedule::call(function () { + $service = app(MagicLinkAuthService::class); + $deleted = $service->cleanupExpiredTokens(); + Log::info("Cleaned up {$deleted} expired magic login tokens"); +})->daily(); diff --git a/routes/web.php b/routes/web.php index 86a06c5..b33be3e 100755 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,25 @@ group(function () { + Route::get('/login', [MagicLinkController::class, 'showLoginForm'])->name('login'); + Route::post('/login', [MagicLinkController::class, 'sendLink'])->name('magic-link.send'); + Route::get('/verify-code', [MagicLinkController::class, 'showCodeForm'])->name('verify-code'); + Route::post('/verify-code', [MagicLinkController::class, 'verifyCode'])->name('magic-link.verify-code'); + Route::get('/auth/magic-link', [MagicLinkController::class, 'verifyLink'])->name('magic-link.verify'); +}); + +// Authenticated routes +Route::middleware('auth')->group(function () { + Route::post('/logout', [MagicLinkController::class, 'logout'])->name('logout'); + Route::get('/dashboard', function () { + return view('dashboard'); + })->name('dashboard'); +}); diff --git a/tests/CreatesApplication.php b/tests/CreatesApplication.php new file mode 100644 index 0000000..cc68301 --- /dev/null +++ b/tests/CreatesApplication.php @@ -0,0 +1,21 @@ +make(Kernel::class)->bootstrap(); + + return $app; + } +} diff --git a/tests/Feature/Auth/MagicLinkAuthTest.php b/tests/Feature/Auth/MagicLinkAuthTest.php new file mode 100755 index 0000000..2900f9c --- /dev/null +++ b/tests/Feature/Auth/MagicLinkAuthTest.php @@ -0,0 +1,372 @@ +get('/login'); + + $response->assertStatus(200); + $response->assertViewIs('auth.login'); + $response->assertSee('email'); + } + + public function test_magic_link_request_sends_email(): void + { + Mail::fake(); + + $response = $this->post('/magic-link', [ + 'email' => 'test@example.com', + ]); + + $response->assertRedirect(); + $response->assertSessionHas('status', 'Check your email for a login link and code!'); + + Mail::assertQueued(MagicLoginLink::class, function ($mail) { + return $mail->hasTo('test@example.com'); + }); + + Mail::assertQueued(MagicLoginLink::class, 1); + + $this->assertDatabaseHas('magic_login_tokens', [ + 'email_hash' => User::hashEmail('test@example.com'), + ]); + } + + public function test_user_created_on_first_login_request(): void + { + Mail::fake(); + + $this->assertDatabaseMissing('users', [ + 'email_hash' => User::hashEmail('newuser@example.com'), + ]); + + $this->post('/magic-link', [ + 'email' => 'newuser@example.com', + ]); + + $this->assertDatabaseHas('users', [ + 'email_hash' => User::hashEmail('newuser@example.com'), + ]); + } + + public function test_valid_4_word_token_logs_user_in(): void + { + $user = User::create([ + 'email_hash' => User::hashEmail('test@example.com'), + ]); + + $token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); + + $signedUrl = URL::temporarySignedRoute( + 'magic-link.verify', + now()->addMinutes(15), + ['token' => $token->plain_token] + ); + + $this->assertGuest(); + + $response = $this->get($signedUrl); + + $response->assertRedirect(route('dashboard')); + $this->assertAuthenticated(); + $this->assertEquals($user->id, Auth::id()); + + $token->refresh(); + $this->assertNotNull($token->used_at); + } + + public function test_valid_6_digit_code_logs_user_in(): void + { + $user = User::create([ + 'email_hash' => User::hashEmail('test@example.com'), + ]); + + $token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); + $code = $token->plain_code; + + $this->assertGuest(); + + $response = $this->post('/verify-code', [ + 'email' => 'test@example.com', + 'code' => $code, + ]); + + $response->assertRedirect(route('dashboard')); + $this->assertAuthenticated(); + $this->assertEquals($user->id, Auth::id()); + + $token->refresh(); + $this->assertNotNull($token->used_at); + } + + public function test_invalid_code_rejected_with_error(): void + { + User::create([ + 'email_hash' => User::hashEmail('test@example.com'), + ]); + + MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); + + $response = $this->post('/verify-code', [ + 'email' => 'test@example.com', + 'code' => '999999', + ]); + + $response->assertRedirect(); + $response->assertSessionHasErrors('code'); + $this->assertGuest(); + } + + public function test_expired_token_rejected(): void + { + $user = User::create([ + 'email_hash' => User::hashEmail('test@example.com'), + ]); + + $token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); + + $token->update(['expires_at' => now()->subMinutes(1)]); + + $signedUrl = URL::temporarySignedRoute( + 'magic-link.verify', + now()->addMinutes(15), + ['token' => $token->plain_token] + ); + + $this->assertGuest(); + + $response = $this->get($signedUrl); + + $response->assertRedirect(route('login')); + $response->assertSessionHas('error', 'Invalid or expired magic link.'); + $this->assertGuest(); + } + + public function test_4_word_token_format_validation(): void + { + $token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); + + $plainToken = $token->plain_token; + + $this->assertMatchesRegularExpression( + '/^[a-z]+-[a-z]+-[a-z]+-[a-z]+$/', + $plainToken, + 'Token should be 4 words separated by hyphens' + ); + + $words = explode('-', $plainToken); + $this->assertCount(4, $words, 'Token should contain exactly 4 words'); + + foreach ($words as $word) { + $this->assertNotEmpty($word, 'Each word should not be empty'); + $this->assertMatchesRegularExpression('/^[a-z]+$/', $word, 'Each word should contain only lowercase letters'); + } + } + + public function test_remember_token_always_set(): void + { + $user = User::create([ + 'email_hash' => User::hashEmail('test@example.com'), + ]); + + $token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); + + $signedUrl = URL::temporarySignedRoute( + 'magic-link.verify', + now()->addMinutes(15), + ['token' => $token->plain_token] + ); + + $this->get($signedUrl); + + $user->refresh(); + $this->assertNotNull($user->remember_token, 'Remember token should be set for 30-day sessions'); + } + + public function test_logout_works_and_invalidates_session(): void + { + $user = User::create([ + 'email_hash' => User::hashEmail('test@example.com'), + ]); + + Auth::login($user); + $this->assertAuthenticated(); + + $response = $this->post('/logout'); + + $response->assertRedirect('/'); + $this->assertGuest(); + } + + public function test_rate_limiting_on_email_requests(): void + { + Mail::fake(); + + $email = 'ratelimit@example.com'; + + for ($i = 0; $i < 5; $i++) { + $response = $this->post('/magic-link', ['email' => $email]); + $response->assertRedirect(); + } + + $response = $this->post('/magic-link', ['email' => $email]); + + $response->assertRedirect(); + $response->assertSessionHasErrors('email'); + + $errors = session('errors'); + $this->assertStringContainsString( + 'Too many magic link requests', + $errors->first('email') + ); + + RateLimiter::clear('magic-link:' . hash('sha256', strtolower($email))); + } + + public function test_rate_limiting_on_code_attempts(): void + { + $user = User::create([ + 'email_hash' => User::hashEmail('test@example.com'), + ]); + + MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); + + for ($i = 0; $i < 5; $i++) { + $this->post('/verify-code', [ + 'email' => 'test@example.com', + 'code' => '000000', + ]); + } + + $token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); + $validCode = $token->plain_code; + + $response = $this->post('/verify-code', [ + 'email' => 'test@example.com', + 'code' => $validCode, + ]); + + $this->assertGuest(); + + RateLimiter::clear('magic-code:' . User::hashEmail('test@example.com')); + } + + public function test_verify_code_form_renders(): void + { + $response = $this->get('/verify-code'); + + $response->assertStatus(200); + $response->assertViewIs('auth.verify-code'); + } + + public function test_invalid_signature_rejected(): void + { + $user = User::create([ + 'email_hash' => User::hashEmail('test@example.com'), + ]); + + $token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); + + $invalidUrl = route('magic-link.verify', ['token' => $token->plain_token]); + + $response = $this->get($invalidUrl); + + $response->assertRedirect(route('login')); + $response->assertSessionHas('error', 'Invalid or expired magic link.'); + $this->assertGuest(); + } + + public function test_used_token_cannot_be_reused(): void + { + $user = User::create([ + 'email_hash' => User::hashEmail('test@example.com'), + ]); + + $token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); + + $signedUrl = URL::temporarySignedRoute( + 'magic-link.verify', + now()->addMinutes(15), + ['token' => $token->plain_token] + ); + + $this->get($signedUrl); + $this->assertAuthenticated(); + + Auth::logout(); + + $response = $this->get($signedUrl); + + $response->assertRedirect(route('login')); + $response->assertSessionHas('error', 'Invalid or expired magic link.'); + $this->assertGuest(); + } + + public function test_email_validation_required(): void + { + $response = $this->post('/magic-link', [ + 'email' => '', + ]); + + $response->assertSessionHasErrors('email'); + } + + public function test_email_validation_must_be_valid(): void + { + $response = $this->post('/magic-link', [ + 'email' => 'not-an-email', + ]); + + $response->assertSessionHasErrors('email'); + } + + public function test_code_validation_required(): void + { + $response = $this->post('/verify-code', [ + 'email' => 'test@example.com', + 'code' => '', + ]); + + $response->assertSessionHasErrors('code'); + } + + public function test_code_validation_must_be_6_digits(): void + { + $response = $this->post('/verify-code', [ + 'email' => 'test@example.com', + 'code' => '12345', + ]); + + $response->assertSessionHasErrors('code'); + + $response = $this->post('/verify-code', [ + 'email' => 'test@example.com', + 'code' => 'abcdef', + ]); + + $response->assertSessionHasErrors('code'); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index fe1ffc2..c58ad54 100755 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,9 +2,11 @@ namespace Tests; +use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase { - // + use CreatesApplication; + use DatabaseTransactions; } diff --git a/tests/Unit/MagicLoginTokenTest.php b/tests/Unit/MagicLoginTokenTest.php new file mode 100755 index 0000000..6c8372e --- /dev/null +++ b/tests/Unit/MagicLoginTokenTest.php @@ -0,0 +1,303 @@ +assertCount(4, $words, 'Token should contain exactly 4 words'); + } + + public function test_word_token_is_hyphen_separated(): void + { + $token = MagicLoginToken::generateWordToken(); + + $this->assertMatchesRegularExpression( + '/^[a-z]+-[a-z]+-[a-z]+-[a-z]+$/', + $token, + 'Token should match pattern: word-word-word-word' + ); + } + + public function test_words_are_from_word_list_file(): void + { + $wordList = explode("\n", trim(Storage::get('words.txt'))); + $wordList = array_map('trim', $wordList); + $wordList = array_filter($wordList); + + $token = MagicLoginToken::generateWordToken(); + $words = explode('-', $token); + + foreach ($words as $word) { + $this->assertContains( + $word, + $wordList, + "Word '{$word}' should be from the word list file" + ); + } + } + + public function test_token_expiry_validation_works(): void + { + $token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); + + $this->assertTrue($token->isValid(), 'Newly created token should be valid'); + + $token->update(['expires_at' => now()->subMinutes(1)]); + $token->refresh(); + + $this->assertFalse($token->isValid(), 'Expired token should not be valid'); + } + + public function test_marking_token_as_used_works(): void + { + $token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); + + $this->assertNull($token->used_at, 'New token should not be marked as used'); + $this->assertTrue($token->isValid(), 'New token should be valid'); + + $token->markAsUsed(); + $token->refresh(); + + $this->assertNotNull($token->used_at, 'Token should be marked as used'); + $this->assertFalse($token->isValid(), 'Used token should not be valid'); + } + + public function test_code_verification_works(): void + { + $token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); + $code = $token->plain_code; + + $this->assertTrue( + $token->verifyCode($code), + 'Correct code should verify successfully' + ); + + $this->assertFalse( + $token->verifyCode('000000'), + 'Incorrect code should not verify' + ); + + $this->assertFalse( + $token->verifyCode('999999'), + 'Wrong code should not verify' + ); + } + + public function test_code_verification_uses_bcrypt(): void + { + $token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); + + $this->assertNotEquals( + $token->plain_code, + $token->code_hash, + 'Code hash should not be plain text' + ); + + $this->assertTrue( + Hash::check($token->plain_code, $token->code_hash), + 'Code hash should use bcrypt' + ); + } + + public function test_token_verification_works(): void + { + $token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); + $plainToken = $token->plain_token; + + $this->assertTrue( + $token->verifyToken($plainToken), + 'Correct token should verify successfully' + ); + + $this->assertFalse( + $token->verifyToken('wrong-token-here-bad'), + 'Incorrect token should not verify' + ); + } + + public function test_token_verification_uses_plain_text_comparison(): void + { + $token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); + + $this->assertEquals( + $token->plain_token, + $token->plain_token, + 'Plain token should be stored as-is for comparison' + ); + + $this->assertTrue( + $token->verifyToken($token->plain_token), + 'Token verification should use plain text comparison' + ); + } + + public function test_scope_valid_returns_only_valid_tokens(): void + { + $validToken = MagicLoginToken::generate('valid@example.com', '127.0.0.1', 'TestAgent'); + + $expiredToken = MagicLoginToken::generate('expired@example.com', '127.0.0.1', 'TestAgent'); + $expiredToken->update(['expires_at' => now()->subMinutes(1)]); + + $usedToken = MagicLoginToken::generate('used@example.com', '127.0.0.1', 'TestAgent'); + $usedToken->markAsUsed(); + + $validTokens = MagicLoginToken::valid()->get(); + + $this->assertCount(1, $validTokens, 'Only one token should be valid'); + $this->assertEquals($validToken->id, $validTokens->first()->id); + } + + public function test_scope_expired_or_used_returns_correct_tokens(): void + { + $validToken = MagicLoginToken::generate('valid@example.com', '127.0.0.1', 'TestAgent'); + + $expiredToken = MagicLoginToken::generate('expired@example.com', '127.0.0.1', 'TestAgent'); + $expiredToken->update(['expires_at' => now()->subMinutes(1)]); + + $usedToken = MagicLoginToken::generate('used@example.com', '127.0.0.1', 'TestAgent'); + $usedToken->markAsUsed(); + + $expiredOrUsedTokens = MagicLoginToken::expiredOrUsed()->get(); + + $this->assertCount(2, $expiredOrUsedTokens, 'Two tokens should be expired or used'); + + $ids = $expiredOrUsedTokens->pluck('id')->toArray(); + $this->assertContains($expiredToken->id, $ids); + $this->assertContains($usedToken->id, $ids); + $this->assertNotContains($validToken->id, $ids); + } + + public function test_generated_code_is_6_digits(): void + { + $token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); + + $this->assertMatchesRegularExpression( + '/^\d{6}$/', + $token->plain_code, + 'Code should be exactly 6 digits' + ); + } + + public function test_generated_code_includes_leading_zeros(): void + { + for ($i = 0; $i < 20; $i++) { + $token = MagicLoginToken::generate('test' . $i . '@example.com', '127.0.0.1', 'TestAgent'); + + $this->assertEquals( + 6, + strlen($token->plain_code), + 'Code should always be 6 characters long, including leading zeros' + ); + } + } + + public function test_token_stores_ip_address_and_user_agent(): void + { + $ip = '192.168.1.1'; + $userAgent = 'Mozilla/5.0 Test Browser'; + + $token = MagicLoginToken::generate('test@example.com', $ip, $userAgent); + + $this->assertEquals($ip, $token->ip_address); + $this->assertEquals($userAgent, $token->user_agent); + } + + public function test_token_expiry_is_15_minutes(): void + { + $beforeCreation = now()->addMinutes(15); + + $token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); + + $afterCreation = now()->addMinutes(15); + + $this->assertTrue( + $token->expires_at->between($beforeCreation, $afterCreation), + 'Token should expire in 15 minutes' + ); + } + + public function test_token_hash_uses_bcrypt(): void + { + $token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); + + $this->assertNotEquals( + $token->plain_token, + $token->token_hash, + 'Token hash should not be plain text' + ); + + $this->assertTrue( + Hash::check($token->plain_token, $token->token_hash), + 'Token hash should use bcrypt' + ); + } + + public function test_email_hash_is_stored_correctly(): void + { + $email = 'test@example.com'; + $expectedHash = User::hashEmail($email); + + $token = MagicLoginToken::generate($email, '127.0.0.1', 'TestAgent'); + + $this->assertEquals($expectedHash, $token->email_hash); + } + + public function test_multiple_tokens_can_exist_for_same_email(): void + { + $email = 'test@example.com'; + + $token1 = MagicLoginToken::generate($email, '127.0.0.1', 'TestAgent'); + $token2 = MagicLoginToken::generate($email, '127.0.0.1', 'TestAgent'); + + $this->assertNotEquals($token1->id, $token2->id); + $this->assertNotEquals($token1->plain_token, $token2->plain_token); + $this->assertNotEquals($token1->plain_code, $token2->plain_code); + $this->assertEquals($token1->email_hash, $token2->email_hash); + } + + public function test_word_tokens_are_unique(): void + { + $generatedTokens = []; + + for ($i = 0; $i < 10; $i++) { + $token = MagicLoginToken::generate('test' . $i . '@example.com', '127.0.0.1', 'TestAgent'); + $generatedTokens[] = $token->plain_token; + } + + $uniqueTokens = array_unique($generatedTokens); + + $this->assertCount( + 10, + $uniqueTokens, + 'All generated tokens should be unique' + ); + } + + public function test_token_casts_dates_correctly(): void + { + $token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); + + $this->assertInstanceOf(\Illuminate\Support\Carbon::class, $token->expires_at); + $this->assertNull($token->used_at); + + $token->markAsUsed(); + $token->refresh(); + + $this->assertInstanceOf(\Illuminate\Support\Carbon::class, $token->used_at); + } +} diff --git a/tests/Unit/UserHashEmailTest.php b/tests/Unit/UserHashEmailTest.php new file mode 100755 index 0000000..928b3fc --- /dev/null +++ b/tests/Unit/UserHashEmailTest.php @@ -0,0 +1,159 @@ +assertEquals($expectedHash, $hash); + + $plainSha256Hash = hash('sha256', strtolower(trim($email))); + $this->assertNotEquals($plainSha256Hash, $hash, 'HMAC-SHA256 should be different from plain SHA-256'); + } + + public function test_hash_is_deterministic(): void + { + $email = 'test@example.com'; + + $hash1 = User::hashEmail($email); + $hash2 = User::hashEmail($email); + + $this->assertEquals($hash1, $hash2, 'Same email should produce the same hash'); + } + + public function test_hash_is_case_insensitive(): void + { + $email1 = 'test@example.com'; + $email2 = 'TEST@EXAMPLE.COM'; + $email3 = 'TeSt@ExAmPlE.cOm'; + + $hash1 = User::hashEmail($email1); + $hash2 = User::hashEmail($email2); + $hash3 = User::hashEmail($email3); + + $this->assertEquals($hash1, $hash2, 'Uppercase email should produce the same hash'); + $this->assertEquals($hash1, $hash3, 'Mixed case email should produce the same hash'); + } + + public function test_hash_trims_whitespace(): void + { + $email1 = 'test@example.com'; + $email2 = ' test@example.com '; + $email3 = "\ttest@example.com\n"; + + $hash1 = User::hashEmail($email1); + $hash2 = User::hashEmail($email2); + $hash3 = User::hashEmail($email3); + + $this->assertEquals($hash1, $hash2, 'Email with leading/trailing spaces should produce the same hash'); + $this->assertEquals($hash1, $hash3, 'Email with tabs/newlines should produce the same hash'); + } + + public function test_different_emails_produce_different_hashes(): void + { + $email1 = 'user1@example.com'; + $email2 = 'user2@example.com'; + $email3 = 'admin@example.com'; + + $hash1 = User::hashEmail($email1); + $hash2 = User::hashEmail($email2); + $hash3 = User::hashEmail($email3); + + $this->assertNotEquals($hash1, $hash2, 'Different emails should produce different hashes'); + $this->assertNotEquals($hash1, $hash3, 'Different emails should produce different hashes'); + $this->assertNotEquals($hash2, $hash3, 'Different emails should produce different hashes'); + } + + public function test_find_by_email_method_works_correctly(): void + { + $email = 'findme@example.com'; + $emailHash = User::hashEmail($email); + + $this->assertNull(User::findByEmail($email), 'User should not exist yet'); + + $user = User::create([ + 'email_hash' => $emailHash, + ]); + + $foundUser = User::findByEmail($email); + + $this->assertNotNull($foundUser, 'User should be found'); + $this->assertEquals($user->id, $foundUser->id, 'Found user should match created user'); + $this->assertEquals($emailHash, $foundUser->email_hash, 'Email hash should match'); + } + + public function test_find_by_email_is_case_insensitive(): void + { + $email = 'case@example.com'; + $emailHash = User::hashEmail($email); + + $user = User::create([ + 'email_hash' => $emailHash, + ]); + + $foundUser1 = User::findByEmail('case@example.com'); + $foundUser2 = User::findByEmail('CASE@EXAMPLE.COM'); + $foundUser3 = User::findByEmail('CaSe@ExAmPlE.cOm'); + + $this->assertNotNull($foundUser1); + $this->assertNotNull($foundUser2); + $this->assertNotNull($foundUser3); + + $this->assertEquals($user->id, $foundUser1->id); + $this->assertEquals($user->id, $foundUser2->id); + $this->assertEquals($user->id, $foundUser3->id); + } + + public function test_find_by_email_trims_whitespace(): void + { + $email = 'trim@example.com'; + $emailHash = User::hashEmail($email); + + $user = User::create([ + 'email_hash' => $emailHash, + ]); + + $foundUser = User::findByEmail(' trim@example.com '); + + $this->assertNotNull($foundUser); + $this->assertEquals($user->id, $foundUser->id); + } + + public function test_hash_length_is_consistent(): void + { + $emails = [ + 'a@b.c', + 'test@example.com', + 'very.long.email.address@subdomain.example.com', + ]; + + $hashes = array_map(fn($email) => User::hashEmail($email), $emails); + + foreach ($hashes as $hash) { + $this->assertEquals(64, strlen($hash), 'SHA-256 hash should always be 64 characters'); + } + } + + public function test_hash_contains_only_hexadecimal_characters(): void + { + $email = 'test@example.com'; + $hash = User::hashEmail($email); + + $this->assertMatchesRegularExpression( + '/^[a-f0-9]{64}$/', + $hash, + 'Hash should contain only lowercase hexadecimal characters' + ); + } +}