test borked

This commit is contained in:
Dan Baker 2026-02-22 17:49:23 +00:00
parent 49b528a66b
commit 2418edccfd
29 changed files with 2036 additions and 121 deletions

19
.env.testing Executable file
View file

@ -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

105
README.md
View file

@ -28,6 +28,7 @@ This will build and start all containers in detached mode.
### 2. Access the application ### 2. Access the application
Open your browser and navigate to: Open your browser and navigate to:
``` ```
http://localhost:8080 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 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 ## Troubleshooting
### Containers won't start ### 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 route:clear
podman-compose exec app php artisan view: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).

View file

@ -0,0 +1,75 @@
<?php
namespace App\Auth;
use App\Models\User;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider;
class HashEmailUserProvider implements UserProvider
{
/**
* Retrieve a user by their unique identifier.
*/
public function retrieveById($identifier): ?Authenticatable
{
return User::find($identifier);
}
/**
* Retrieve a user by their unique identifier and "remember me" token.
*/
public function retrieveByToken($identifier, $token): ?Authenticatable
{
$user = $this->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
}
}

View file

@ -0,0 +1,129 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Mail\MagicLoginLink;
use App\Services\MagicLinkAuthService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;
use Illuminate\Validation\ValidationException;
class MagicLinkController extends Controller
{
public function __construct(
protected MagicLinkAuthService $authService
) {}
/**
* Show the login form.
*/
public function showLoginForm()
{
return view('auth.login');
}
/**
* Send a magic link to the user's email.
*/
public function sendLink(Request $request)
{
$request->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('/');
}
}

54
app/Mail/MagicLoginLink.php Executable file
View file

@ -0,0 +1,54 @@
<?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;
class MagicLoginLink extends Mailable
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct(
public string $loginUrl,
public string $code,
public int $expiresInMinutes = 15
) {}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'Your Login Link',
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
view: 'emails.magic-login',
text: 'emails.magic-login-text',
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

138
app/Models/MagicLoginToken.php Executable file
View file

@ -0,0 +1,138 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
class MagicLoginToken extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
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<string, string>
*/
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');
});
}
}

View file

@ -4,6 +4,7 @@ namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail; // use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
@ -19,8 +20,7 @@ class User extends Authenticatable
*/ */
protected $fillable = [ protected $fillable = [
'name', 'name',
'email', 'email_hash',
'password',
]; ];
/** /**
@ -29,7 +29,7 @@ class User extends Authenticatable
* @var list<string> * @var list<string>
*/ */
protected $hidden = [ protected $hidden = [
'password', 'email_hash',
'remember_token', 'remember_token',
]; ];
@ -42,7 +42,32 @@ class User extends Authenticatable
{ {
return [ return [
'email_verified_at' => 'datetime', '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');
}
} }

View file

@ -2,6 +2,8 @@
namespace App\Providers; namespace App\Providers;
use App\Auth\HashEmailUserProvider;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@ -19,6 +21,8 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
// Auth::provider('hash_email', function ($app, array $config) {
return new HashEmailUserProvider();
});
} }
} }

View file

@ -0,0 +1,117 @@
<?php
namespace App\Services;
use App\Models\MagicLoginToken;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
class MagicLinkAuthService
{
/**
* Send a magic link to the given email address.
*
* @throws ValidationException
*/
public function sendMagicLink(string $email, ?string $ip, ?string $ua): MagicLoginToken
{
$key = 'magic-link:' . hash('sha256', strtolower(trim($email)));
if (RateLimiter::tooManyAttempts($key, 5)) {
$seconds = RateLimiter::availableIn($key);
throw ValidationException::withMessages([
'email' => [
"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();
}
}

View file

@ -15,14 +15,7 @@ services:
depends_on: depends_on:
- db - db
- redis - redis
environment: - mailpit
- DB_HOST=db
- DB_PORT=3306
- DB_DATABASE=laravel
- DB_USERNAME=laravel
- DB_PASSWORD=secret
- REDIS_HOST=redis
- REDIS_PORT=6379
db: db:
image: docker.io/library/mysql:8.0 image: docker.io/library/mysql:8.0
@ -35,6 +28,7 @@ services:
MYSQL_PASSWORD: secret MYSQL_PASSWORD: secret
volumes: volumes:
- dbdata:/var/lib/mysql:Z - dbdata:/var/lib/mysql:Z
- ./docker/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql:Z
ports: ports:
- "3306:3306" - "3306:3306"
networks: networks:
@ -49,6 +43,16 @@ services:
networks: networks:
- laravel - 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: networks:
laravel: laravel:
driver: bridge driver: bridge

View file

@ -61,7 +61,7 @@ return [
'providers' => [ 'providers' => [
'users' => [ 'users' => [
'driver' => 'eloquent', 'driver' => 'hash_email',
'model' => env('AUTH_MODEL', App\Models\User::class), 'model' => env('AUTH_MODEL', App\Models\User::class),
], ],

View file

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
// Add email_hash column after id if it doesn't exist
if (!Schema::hasColumn('users', 'email_hash')) {
$table->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();
});
}
};

View file

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('magic_login_tokens', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::dropIfExists('password_reset_tokens');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
}
};

View file

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
// Drop unique constraint first for SQLite compatibility
$table->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');
});
}
};

4
docker/mysql/init.sql Normal file
View file

@ -0,0 +1,4 @@
-- Create test database
CREATE DATABASE IF NOT EXISTS `laravel_test`;
GRANT ALL PRIVILEGES ON `laravel_test`.* TO 'laravel'@'%';
FLUSH PRIVILEGES;

View file

@ -23,8 +23,6 @@
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/> <env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/> <env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/> <env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/> <env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/> <env name="SESSION_DRIVER" value="array"/>

View file

@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Login - Scan.fyi</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gradient-to-br from-blue-50 to-indigo-100 min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-md">
<div class="bg-white rounded-lg shadow-xl p-8">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">Scan.fyi</h1>
<p class="mt-2 text-sm text-gray-600">Passwordless Authentication</p>
</div>
@if (session('status'))
<div class="mb-6 p-4 rounded-lg bg-green-50 border border-green-200">
<p class="text-sm text-green-800">{{ session('status') }}</p>
</div>
@endif
@if (session('error'))
<div class="mb-6 p-4 rounded-lg bg-red-50 border border-red-200">
<p class="text-sm text-red-800">{{ session('error') }}</p>
</div>
@endif
<form method="POST" action="{{ route('login') }}">
@csrf
<div class="mb-6">
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
Email Address
</label>
<input
type="email"
id="email"
name="email"
value="{{ old('email') }}"
required
autofocus
class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors @error('email') border-red-500 @enderror"
placeholder="you@example.com"
>
@error('email')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<button
type="submit"
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-3 px-4 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
Send Login Link
</button>
</form>
<div class="mt-6 text-center">
<a
href="{{ route('verify-code') }}"
class="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
>
Already have a code? Enter it here
</a>
</div>
</div>
<div class="mt-6 text-center">
<p class="text-xs text-gray-600">
You will receive a secure login link via email
</p>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Verify Code - Scan.fyi</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gradient-to-br from-blue-50 to-indigo-100 min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-md">
<div class="bg-white rounded-lg shadow-xl p-8">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">Scan.fyi</h1>
<p class="mt-2 text-sm text-gray-600">Enter Your Verification Code</p>
</div>
@if (session('status'))
<div class="mb-6 p-4 rounded-lg bg-green-50 border border-green-200">
<p class="text-sm text-green-800">{{ session('status') }}</p>
</div>
@endif
@if (session('error'))
<div class="mb-6 p-4 rounded-lg bg-red-50 border border-red-200">
<p class="text-sm text-red-800">{{ session('error') }}</p>
</div>
@endif
<form method="POST" action="{{ route('verify-code') }}">
@csrf
<div class="mb-6">
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
Email Address
</label>
<input
type="email"
id="email"
name="email"
value="{{ old('email') }}"
required
autofocus
class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors @error('email') border-red-500 @enderror"
placeholder="you@example.com"
>
@error('email')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div class="mb-6">
<label for="code" class="block text-sm font-medium text-gray-700 mb-2">
Verification Code
</label>
<input
type="text"
id="code"
name="code"
value="{{ old('code') }}"
required
pattern="[0-9]{6}"
maxlength="6"
inputmode="numeric"
class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors text-center text-2xl font-mono tracking-widest @error('code') border-red-500 @enderror"
placeholder="000000"
>
@error('code')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
@enderror
<p class="mt-2 text-xs text-gray-500 text-center">
Enter the 6-digit code from your email
</p>
</div>
<button
type="submit"
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-3 px-4 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
Verify Code
</button>
</form>
<div class="mt-6 text-center">
<a
href="{{ route('login') }}"
class="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
>
Back to login
</a>
</div>
</div>
<div class="mt-6 text-center">
<p class="text-xs text-gray-600">
Codes expire after 15 minutes
</p>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Dashboard - Scan.fyi</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50 min-h-screen">
<nav class="bg-white shadow-sm border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<div class="flex items-center">
<h1 class="text-xl font-bold text-gray-900">Scan.fyi</h1>
</div>
<div class="flex items-center space-x-4">
<span class="text-sm text-gray-600">{{ auth()->user()->email }}</span>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button
type="submit"
class="bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium py-2 px-4 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2"
>
Logout
</button>
</form>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="bg-white rounded-lg shadow-sm p-8">
<div class="text-center">
<div class="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4">
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<h2 class="text-3xl font-bold text-gray-900 mb-2">
Welcome, {{ auth()->user()->name ?? 'User' }}!
</h2>
<p class="text-lg text-gray-600 mb-6">
You are successfully logged in using passwordless authentication
</p>
<div class="inline-block bg-indigo-50 border border-indigo-200 rounded-lg px-6 py-4">
<p class="text-sm text-indigo-800">
<span class="font-semibold">Security:</span> Your session is secured with a 30-day remember token
</p>
</div>
</div>
<div class="mt-12 grid gap-6 md:grid-cols-3">
<div class="bg-gray-50 rounded-lg p-6 border border-gray-200">
<div class="text-gray-400 mb-3">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg>
</div>
<h3 class="font-semibold text-gray-900 mb-1">Email-Based Login</h3>
<p class="text-sm text-gray-600">No passwords to remember or manage</p>
</div>
<div class="bg-gray-50 rounded-lg p-6 border border-gray-200">
<div class="text-gray-400 mb-3">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
<h3 class="font-semibold text-gray-900 mb-1">Secure Authentication</h3>
<p class="text-sm text-gray-600">Time-limited codes and magic links</p>
</div>
<div class="bg-gray-50 rounded-lg p-6 border border-gray-200">
<div class="text-gray-400 mb-3">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
</div>
<h3 class="font-semibold text-gray-900 mb-1">Fast Access</h3>
<p class="text-sm text-gray-600">Quick login with verification codes</p>
</div>
</div>
</div>
</main>
<footer class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<p class="text-center text-sm text-gray-500">
Powered by Scan.fyi Passwordless Authentication
</p>
</footer>
</body>
</html>

View file

@ -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.

View file

@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Your Login Link</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f3f4f6; line-height: 1.6;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f3f4f6;">
<tr>
<td style="padding: 40px 20px;">
<table role="presentation" style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05); border-collapse: collapse; overflow: hidden;">
<!-- Header -->
<tr>
<td style="padding: 48px 40px 32px; text-align: center; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700; letter-spacing: -0.5px;">
{{ config('app.name') }}
</h1>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 40px;">
<h2 style="margin: 0 0 16px; color: #111827; font-size: 24px; font-weight: 600; text-align: center;">
Your Login Link
</h2>
<p style="margin: 0 0 32px; color: #6b7280; font-size: 16px; text-align: center;">
Click the button below to securely log in to your account.
</p>
<!-- Login Button -->
<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 0 0 32px;">
<tr>
<td style="text-align: center;">
<a href="{{ $loginUrl }}" style="display: inline-block; padding: 16px 48px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; text-decoration: none; border-radius: 8px; font-size: 18px; font-weight: 600; box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); transition: transform 0.2s;">
Log In to {{ config('app.name') }}
</a>
</td>
</tr>
</table>
<!-- Divider -->
<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 0 0 32px;">
<tr>
<td style="width: 45%; border-bottom: 1px solid #e5e7eb;"></td>
<td style="width: 10%; text-align: center; color: #9ca3af; font-size: 14px; font-weight: 500; padding: 0 16px;">
OR
</td>
<td style="width: 45%; border-bottom: 1px solid #e5e7eb;"></td>
</tr>
</table>
<!-- Code Display -->
<div style="margin: 0 0 32px; text-align: center;">
<p style="margin: 0 0 16px; color: #6b7280; font-size: 16px;">
Enter this code on the login page:
</p>
<div style="display: inline-block; padding: 24px 48px; background-color: #f9fafb; border: 2px solid #e5e7eb; border-radius: 12px;">
<span style="font-family: 'Courier New', Courier, monospace; font-size: 42px; font-weight: 700; letter-spacing: 8px; color: #111827;">
{{ $code }}
</span>
</div>
</div>
<!-- Expiry Notice -->
<div style="margin: 0 0 32px; padding: 16px; background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 6px;">
<p style="margin: 0; color: #92400e; font-size: 14px; line-height: 1.5;">
<strong style="font-weight: 600;">Important:</strong> This link and code will expire in {{ $expiresInMinutes }} minutes for your security.
</p>
</div>
<!-- Security Notice -->
<div style="margin: 0; padding: 16px; background-color: #f3f4f6; border-radius: 6px;">
<p style="margin: 0; color: #6b7280; font-size: 14px; line-height: 1.5; text-align: center;">
If you didn't request this login link, you can safely ignore this email. Your account remains secure.
</p>
</div>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 32px 40px; background-color: #f9fafb; border-top: 1px solid #e5e7eb;">
<p style="margin: 0; color: #9ca3af; font-size: 12px; text-align: center; line-height: 1.5;">
This is an automated message from {{ config('app.name') }}.<br>
Please do not reply to this email.
</p>
</td>
</tr>
</table>
<!-- Bottom Spacer -->
<table role="presentation" style="max-width: 600px; margin: 24px auto 0; border-collapse: collapse;">
<tr>
<td style="text-align: center;">
<p style="margin: 0; color: #9ca3af; font-size: 12px;">
&copy; {{ date('Y') }} {{ config('app.name') }}. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View file

@ -1,8 +1,18 @@
<?php <?php
use App\Services\MagicLinkAuthService;
use Illuminate\Foundation\Inspiring; use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () { Artisan::command('inspire', function () {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
})->purpose('Display an 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();

View file

@ -1,7 +1,25 @@
<?php <?php
use App\Http\Controllers\Auth\MagicLinkController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/', function () { Route::get('/', function () {
return view('welcome'); return view('welcome');
}); });
// Guest routes (unauthenticated users)
Route::middleware('guest')->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');
});

View file

@ -0,0 +1,21 @@
<?php
namespace Tests;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Foundation\Application;
trait CreatesApplication
{
/**
* Creates the application.
*/
public function createApplication(): Application
{
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
return $app;
}
}

View file

@ -0,0 +1,372 @@
<?php
namespace Tests\Feature\Auth;
use App\Mail\MagicLoginLink;
use App\Models\MagicLoginToken;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
class MagicLinkAuthTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
RateLimiter::clear('magic-link:' . hash('sha256', strtolower('test@example.com')));
RateLimiter::clear('magic-code:' . User::hashEmail('test@example.com'));
}
public function test_login_screen_renders(): void
{
$response = $this->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');
}
}

View file

@ -2,9 +2,11 @@
namespace Tests; namespace Tests;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase abstract class TestCase extends BaseTestCase
{ {
// use CreatesApplication;
use DatabaseTransactions;
} }

View file

@ -0,0 +1,303 @@
<?php
namespace Tests\Unit;
use App\Models\MagicLoginToken;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class MagicLoginTokenTest extends TestCase
{
use RefreshDatabase;
public function test_word_token_generation_creates_exactly_4_words(): void
{
$token = MagicLoginToken::generateWordToken();
$words = explode('-', $token);
$this->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);
}
}

159
tests/Unit/UserHashEmailTest.php Executable file
View file

@ -0,0 +1,159 @@
<?php
namespace Tests\Unit;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class UserHashEmailTest extends TestCase
{
use RefreshDatabase;
public function test_hash_uses_hmac_sha256_with_app_key(): void
{
$email = 'test@example.com';
$hash = User::hashEmail($email);
$expectedHash = hash_hmac('sha256', strtolower(trim($email)), config('app.key'));
$this->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'
);
}
}