Merge branch 'dev/tests-borked'
This commit is contained in:
commit
a22db4ee0f
34 changed files with 2136 additions and 128 deletions
19
.env.testing
Executable file
19
.env.testing
Executable 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
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,6 +5,7 @@
|
||||||
.env.production
|
.env.production
|
||||||
.phpactor.json
|
.phpactor.json
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
|
/.composer
|
||||||
/.fleet
|
/.fleet
|
||||||
/.idea
|
/.idea
|
||||||
/.nova
|
/.nova
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@ RUN apt-get update && apt-get install -y \
|
||||||
# Clear cache
|
# Clear cache
|
||||||
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
|
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Fix nginx permissions for rootless
|
||||||
|
RUN mkdir -p /var/lib/nginx/body /var/lib/nginx/proxy /var/lib/nginx/fastcgi /var/log/nginx /var/run \
|
||||||
|
&& chmod -R 777 /var/lib/nginx /var/log/nginx /var/run
|
||||||
|
|
||||||
# Install PHP extensions
|
# Install PHP extensions
|
||||||
RUN docker-php-ext-install pdo_mysql pdo_pgsql mbstring exif pcntl bcmath gd zip
|
RUN docker-php-ext-install pdo_mysql pdo_pgsql mbstring exif pcntl bcmath gd zip
|
||||||
|
|
||||||
|
|
@ -42,7 +46,7 @@ RUN chown -R www-data:www-data /var/www/html \
|
||||||
&& chmod -R 755 /var/www/html/bootstrap/cache
|
&& chmod -R 755 /var/www/html/bootstrap/cache
|
||||||
|
|
||||||
# Expose port 80
|
# Expose port 80
|
||||||
EXPOSE 80
|
EXPOSE 8080
|
||||||
|
|
||||||
# Start supervisor
|
# Start supervisor
|
||||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
|
|
|
||||||
105
README.md
105
README.md
|
|
@ -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).
|
|
||||||
|
|
|
||||||
75
app/Auth/HashEmailUserProvider.php
Executable file
75
app/Auth/HashEmailUserProvider.php
Executable 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
|
||||||
|
}
|
||||||
|
}
|
||||||
129
app/Http/Controllers/Auth/MagicLinkController.php
Executable file
129
app/Http/Controllers/Auth/MagicLinkController.php
Executable 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
54
app/Mail/MagicLoginLink.php
Executable 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
138
app/Models/MagicLoginToken.php
Executable 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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
117
app/Services/MagicLinkAuthService.php
Executable file
117
app/Services/MagicLinkAuthService.php
Executable 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
38
compose.yml
38
compose.yml
|
|
@ -5,24 +5,18 @@ services:
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: laravel-app
|
container_name: laravel-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
userns_mode: "keep-id"
|
||||||
working_dir: /var/www/html
|
working_dir: /var/www/html
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/var/www/html:Z
|
- ./:/var/www/html:Z
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:8080"
|
||||||
networks:
|
networks:
|
||||||
- laravel
|
- laravel
|
||||||
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 +29,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 +44,31 @@ 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
|
||||||
|
|
||||||
|
phpmyadmin:
|
||||||
|
image: docker.io/phpmyadmin/phpmyadmin:latest
|
||||||
|
container_name: laravel-phpmyadmin
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
PMA_HOST: db
|
||||||
|
PMA_USER: root
|
||||||
|
PMA_PASSWORD: root
|
||||||
|
ports:
|
||||||
|
- "8081:80"
|
||||||
|
networks:
|
||||||
|
- laravel
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
laravel:
|
laravel:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/tinker": "^2.10.1"
|
"laravel/tinker": "^2.10.1",
|
||||||
|
"predis/predis": "^3.4"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
|
|
||||||
65
composer.lock
generated
65
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "c514d8f7b9fc5970bdd94287905ef584",
|
"content-hash": "d6bd2a26512dd52b870be9ab0b7d9c72",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
|
|
@ -2603,6 +2603,69 @@
|
||||||
],
|
],
|
||||||
"time": "2025-12-27T19:41:33+00:00"
|
"time": "2025-12-27T19:41:33+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "predis/predis",
|
||||||
|
"version": "v3.4.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/predis/predis.git",
|
||||||
|
"reference": "1183f5732e6b10efd33f64984a96726eaecb59aa"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/predis/predis/zipball/1183f5732e6b10efd33f64984a96726eaecb59aa",
|
||||||
|
"reference": "1183f5732e6b10efd33f64984a96726eaecb59aa",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.2 || ^8.0",
|
||||||
|
"psr/http-message": "^1.0|^2.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.3",
|
||||||
|
"phpstan/phpstan": "^1.9",
|
||||||
|
"phpunit/phpcov": "^6.0 || ^8.0",
|
||||||
|
"phpunit/phpunit": "^8.0 || ~9.4.4"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-relay": "Faster connection with in-memory caching (>=0.6.2)"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Predis\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Till Krüss",
|
||||||
|
"homepage": "https://till.im",
|
||||||
|
"role": "Maintainer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A flexible and feature-complete Redis/Valkey client for PHP.",
|
||||||
|
"homepage": "http://github.com/predis/predis",
|
||||||
|
"keywords": [
|
||||||
|
"nosql",
|
||||||
|
"predis",
|
||||||
|
"redis"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/predis/predis/issues",
|
||||||
|
"source": "https://github.com/predis/predis/tree/v3.4.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/sponsors/tillkruss",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-02-11T17:30:28+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "psr/clock",
|
"name": "psr/clock",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
39
database/migrations/2026_02_22_000002_create_magic_login_tokens_table.php
Executable file
39
database/migrations/2026_02_22_000002_create_magic_login_tokens_table.php
Executable 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
28
database/migrations/2026_02_22_000003_drop_password_reset_tokens_table.php
Executable file
28
database/migrations/2026_02_22_000003_drop_password_reset_tokens_table.php
Executable 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
36
database/migrations/2026_02_22_000004_remove_password_from_users_table.php
Executable file
36
database/migrations/2026_02_22_000004_remove_password_from_users_table.php
Executable 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
4
docker/mysql/init.sql
Normal 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;
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 8080;
|
||||||
listen [::]:80;
|
listen [::]:8080;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
root /var/www/html/public;
|
root /var/www/html/public;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
[supervisord]
|
[supervisord]
|
||||||
nodaemon=true
|
nodaemon=true
|
||||||
user=root
|
logfile=/dev/stdout
|
||||||
logfile=/var/log/supervisor/supervisord.log
|
logfile_maxbytes=0
|
||||||
pidfile=/var/run/supervisord.pid
|
pidfile=/tmp/supervisord.pid
|
||||||
|
|
||||||
[program:php-fpm]
|
[program:php-fpm]
|
||||||
command=/usr/local/sbin/php-fpm -F
|
command=/usr/local/sbin/php-fpm -F
|
||||||
|
|
@ -21,3 +21,12 @@ stdout_logfile=/dev/stdout
|
||||||
stdout_logfile_maxbytes=0
|
stdout_logfile_maxbytes=0
|
||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
stderr_logfile_maxbytes=0
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
[program:queue-worker]
|
||||||
|
command=php /var/www/html/artisan queue:work --sleep=3 --tries=3 --max-time=3600
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
77
resources/views/auth/login.blade.php
Executable file
77
resources/views/auth/login.blade.php
Executable 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('magic-link.send') }}">
|
||||||
|
@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>
|
||||||
101
resources/views/auth/verify-code.blade.php
Executable file
101
resources/views/auth/verify-code.blade.php
Executable 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>
|
||||||
98
resources/views/dashboard.blade.php
Executable file
98
resources/views/dashboard.blade.php
Executable 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>
|
||||||
36
resources/views/emails/magic-login-text.blade.php
Executable file
36
resources/views/emails/magic-login-text.blade.php
Executable 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.
|
||||||
108
resources/views/emails/magic-login.blade.php
Executable file
108
resources/views/emails/magic-login.blade.php
Executable 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;">
|
||||||
|
© {{ date('Y') }} {{ config('app.name') }}. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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('/magic-link', [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');
|
||||||
|
});
|
||||||
|
|
|
||||||
21
tests/CreatesApplication.php
Normal file
21
tests/CreatesApplication.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
350
tests/Feature/Auth/MagicLinkAuthTest.php
Executable file
350
tests/Feature/Auth/MagicLinkAuthTest.php
Executable file
|
|
@ -0,0 +1,350 @@
|
||||||
|
<?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\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_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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,5 +6,5 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||||
|
|
||||||
abstract class TestCase extends BaseTestCase
|
abstract class TestCase extends BaseTestCase
|
||||||
{
|
{
|
||||||
//
|
use CreatesApplication;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
324
tests/Unit/MagicLoginTokenTest.php
Executable file
324
tests/Unit/MagicLoginTokenTest.php
Executable file
|
|
@ -0,0 +1,324 @@
|
||||||
|
<?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()->startOfSecond()->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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
159
tests/Unit/UserHashEmailTest.php
Executable file
159
tests/Unit/UserHashEmailTest.php
Executable 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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue