diff --git a/README.md b/README.md index 274bf90..ad4bae7 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,576 @@ -# AuthTemplate +# BSPDX AuthKit -A Laravel template package that includes auth with 2FA and passkeys. +[![Latest Version on Packagist](https://img.shields.io/packagist/v/bspdx/authkit.svg?style=flat-square)](https://packagist.org/packages/bspdx/authkit) +[![Total Downloads](https://img.shields.io/packagist/dt/bspdx/authkit.svg?style=flat-square)](https://packagist.org/packages/bspdx/authkit) +[![License](https://img.shields.io/packagist/l/bspdx/authkit.svg?style=flat-square)](https://packagist.org/packages/bspdx/authkit) -## Features +A comprehensive, production-ready authentication package for Laravel 12+ featuring: -This template is built on Laravel 12 and includes: +- 🔐 **Standard Authentication** - Powered by Laravel Fortify +- 👥 **Role-Based Access Control (RBAC)** - Using Spatie Laravel Permission +- 📱 **TOTP Two-Factor Authentication** - Google Authenticator, Authy, etc. +- 🔑 **Passkey Authentication** - Modern WebAuthn/FIDO2 login +- 🛡️ **Passkey as 2FA** - Use passkeys as a second factor +- 🎨 **Framework-Agnostic Blade Components** - Beautiful, customizable UI partials +- 🌐 **API Support** - Full Sanctum integration for API authentication +- 🏢 **Multi-Tenancy Ready** - Optional tenant scoping -- **Laravel Framework ^12.0** - The latest Laravel framework -- **Inertia.js ^2.0** - Modern monolith with server-side routing and client-side rendering -- **Laravel Fortify ^1.31** - Backend authentication scaffolding including 2FA -- **Laravel Sanctum ^4.0** - API token authentication -- **Spatie Laravel Passkeys** - WebAuthn/Passkey authentication support -- **Ziggy ^2.0** - Use Laravel named routes in JavaScript +## Table of Contents + +- [Requirements](#requirements) +- [Installation](#installation) +- [Configuration](#configuration) +- [Usage](#usage) + - [User Model Setup](#user-model-setup) + - [Blade Components](#blade-components) + - [Routes](#routes) + - [Middleware](#middleware) + - [API Usage](#api-usage) +- [HTTPS Setup](#https-setup) +- [Multi-Tenancy](#multi-tenancy) +- [Testing](#testing) +- [Credits](#credits) +- [License](#license) + +## Requirements + +- PHP 8.2+ +- Laravel 12.0+ +- MySQL 5.7+ / PostgreSQL 9.6+ / SQLite 3.8.8+ ## Installation +### Step 1: Install via Composer + +```bash +composer require bspdx/authkit +``` + +### Step 2: Publish Configuration & Assets + +```bash +# Publish configuration +php artisan vendor:publish --tag=authkit-config + +# Publish migrations +php artisan vendor:publish --tag=authkit-migrations + +# Publish Blade views (optional - only if you want to customize) +php artisan vendor:publish --tag=authkit-views + +# Publish example routes +php artisan vendor:publish --tag=authkit-routes + +# Publish database seeders +php artisan vendor:publish --tag=authkit-seeders +``` + +### Step 3: Run Migrations + +```bash +php artisan migrate +``` + +This will create tables for: +- Two-factor authentication columns in `users` table +- Roles and permissions (Spatie) +- Passkeys (Spatie) +- Personal access tokens (Sanctum) + +### Step 4: Seed Demo Data (Optional) + +```bash +php artisan db:seed --class=AuthKitSeeder +``` + +This creates: +- 4 default roles: `super-admin`, `admin`, `editor`, `user` +- Common permissions for each role +- 4 demo users (all with password: `password`) + - `superadmin@example.com` - Super Admin + - `admin@example.com` - Admin + - `editor@example.com` - Editor + - `user@example.com` - Regular User + +### Step 5: Configure Fortify + +In your `config/fortify.php`, ensure these features are enabled: + +```php +'features' => [ + Features::registration(), + Features::resetPasswords(), + Features::emailVerification(), + Features::updateProfileInformation(), + Features::updatePasswords(), + Features::twoFactorAuthentication([ + 'confirm' => true, + 'confirmPassword' => true, + ]), +], +``` + +## Configuration + +The package configuration is located at `config/authkit.php`. Key settings: + +### Enable/Disable Features + +```php +'features' => [ + 'registration' => true, + 'email_verification' => true, + 'two_factor' => true, + 'passkeys' => true, + 'passkey_2fa' => true, + 'api_tokens' => true, +], +``` + +### RBAC Settings + +```php +'rbac' => [ + 'multi_tenant' => false, + 'default_role' => 'user', + 'super_admin_role' => 'super-admin', +], +``` + +### Passkey Settings + +```php +'passkey' => [ + 'rp_name' => env('APP_NAME', 'Laravel'), + 'rp_id' => env('PASSKEY_RP_ID', 'localhost'), + 'user_verification' => 'preferred', + 'allow_multiple' => true, + 'required_for_roles' => [ + // 'admin', + ], +], +``` + +### Two-Factor Settings + +```php +'two_factor' => [ + 'qr_code_size' => 200, + 'recovery_codes_count' => 8, + 'required_for_roles' => [ + // 'admin', + ], +], +``` + +## Usage + +### User Model Setup + +Add the `HasAuthKit` trait to your `User` model: + +```php + +``` + +#### Register Form + +```blade + +``` + +#### Two-Factor Challenge + +```blade + +``` + +#### Passkey Registration + +```blade + +``` + +#### Passkey Login + +```blade + +``` + +### Routes + +AuthKit doesn't auto-register routes. Add them manually from the published examples: + +**Web Routes** (`routes/authkit-web.php`): +```php +// Include in your routes/web.php +require __DIR__.'/authkit-web.php'; +``` + +**API Routes** (`routes/authkit-api.php`): +```php +// Include in your routes/api.php +require __DIR__.'/authkit-api.php'; +``` + +### Middleware + +AuthKit provides three middleware aliases: + +#### Role Middleware + +```php +Route::middleware(['auth', 'role:admin'])->group(function () { + // Only users with 'admin' role can access +}); + +// Multiple roles (OR logic) +Route::middleware(['auth', 'role:admin,editor'])->group(function () { + // Users with 'admin' OR 'editor' role can access +}); +``` + +#### Permission Middleware + +```php +Route::middleware(['auth', 'permission:edit-posts'])->group(function () { + // Only users with 'edit-posts' permission +}); + +// Multiple permissions +Route::middleware(['auth', 'permission:edit-posts,publish-posts'])->group(function () { + // Users with either permission can access +}); +``` + +#### 2FA Enforcement Middleware + +```php +Route::middleware(['auth', '2fa'])->group(function () { + // Ensures users with required roles have 2FA enabled +}); +``` + +### Checking Permissions in Code + +```php +// Check role +if (auth()->user()->hasRole('admin')) { + // User is an admin +} + +// Check permission +if (auth()->user()->can('edit-posts')) { + // User can edit posts +} + +// Check multiple roles +if (auth()->user()->hasAnyRole(['admin', 'editor'])) { + // User has at least one of these roles +} + +// Super admin check +if (auth()->user()->isSuperAdmin()) { + // User is super admin (bypasses all permission checks) +} +``` + +### API Usage + +#### Authentication + +Use Sanctum for API authentication: + +```php +// Login endpoint (you need to create this) +Route::post('/login', function (Request $request) { + $credentials = $request->validate([ + 'email' => 'required|email', + 'password' => 'required', + ]); + + if (!Auth::attempt($credentials)) { + return response()->json(['message' => 'Invalid credentials'], 401); + } + + $user = $request->user(); + $token = $user->createToken('api-token')->plainTextToken; + + return response()->json([ + 'token' => $token, + 'user' => $user, + ]); +}); +``` + +#### API Endpoints + +All API routes are protected with `auth:sanctum` middleware. Example requests: + +**Get All Roles:** +```bash +curl -X GET http://localhost/api/roles \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Accept: application/json" +``` + +**Assign Role to User:** +```bash +curl -X POST http://localhost/api/users/1/roles \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"roles": ["admin"]}' +``` + +**Enable 2FA:** +```bash +curl -X POST http://localhost/api/user/two-factor-authentication \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +## HTTPS Setup + +**Passkeys require HTTPS!** See our detailed guide: [HTTPS Setup for Laravel Sail](docs/https-setup.md) + +Quick summary: + +1. Install `mkcert`: + ```bash + brew install mkcert # macOS + mkcert -install + ``` + +2. Generate certificates: + ```bash + mkdir -p docker/ssl && cd docker/ssl + mkcert localhost 127.0.0.1 ::1 + mv localhost+2.pem cert.pem + mv localhost+2-key.pem key.pem + ``` + +3. Update `.env`: + ```env + APP_URL=https://localhost + SESSION_SECURE_COOKIE=true + ``` + +4. Configure Nginx/Caddy to use the certificates + +See the full guide for detailed instructions. + +## Multi-Tenancy + +AuthKit is multi-tenancy ready. To enable: + +### Step 1: Enable in Configuration + +```php +// config/authkit.php +'multi_tenancy' => [ + 'enabled' => true, + 'tenant_column' => 'tenant_id', + 'auto_scope' => true, +], + +'rbac' => [ + 'multi_tenant' => true, +], +``` + +### Step 2: Add Tenant Column to Migrations + +Uncomment the tenant column in the published migration: + +```php +// database/migrations/2024_01_01_000001_add_authkit_fields_to_users_table.php +$table->unsignedBigInteger('tenant_id')->nullable()->after('id'); +$table->index('tenant_id'); +``` + +### Step 3: Run Migrations + ```bash -composer install -cp .env.example .env -php artisan key:generate php artisan migrate -npm install -npm run build ``` -## Development +### Step 4: Scope Queries + +Use Spatie's multitenancy package or implement your own scoping logic. + +## Testing + +Run the package tests: ```bash -npm run dev -php artisan serve +composer test ``` +Or with PHPUnit directly: + +```bash +./vendor/bin/phpunit +``` + +## Customization + +### Custom Blade Views + +Publish the views and modify as needed: + +```bash +php artisan vendor:publish --tag=authkit-views +``` + +Views will be in `resources/views/vendor/authkit/`. + +### Custom Styling + +All Blade components use CSS custom properties for easy theming: + +```css +:root { + --authkit-primary: #4f46e5; + --authkit-primary-hover: #4338ca; + --authkit-danger: #dc2626; + --authkit-text: #1f2937; + --authkit-border: #d1d5db; + --authkit-bg: #ffffff; + --authkit-radius: 0.5rem; +} +``` + +## Security + +If you discover any security issues, please email security@bspdx.com instead of using the issue tracker. + +## Credits + +- [BSPDX](https://github.com/TheBootstrapParadox) +- Built on top of: + - [Laravel Fortify](https://github.com/laravel/fortify) + - [Laravel Sanctum](https://github.com/laravel/sanctum) + - [Spatie Laravel Permission](https://github.com/spatie/laravel-permission) + - [Spatie Laravel Passkeys](https://github.com/spatie/laravel-passkeys) + ## License -This project is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). +The MIT License (MIT). Please see [License File](LICENSE) for more information. + +--- + +## Quick Start Example + +Here's a complete example to get you started quickly: + +### 1. Install Package + +```bash +composer require bspdx/authkit +php artisan vendor:publish --tag=authkit-config +php artisan vendor:publish --tag=authkit-migrations +php artisan migrate +php artisan db:seed --class=AuthKitSeeder +``` + +### 2. Update User Model + +```php + + + + + Login + + + + + + +``` + +### 4. Add Routes + +```php +// routes/web.php +Route::get('/login', function () { + return view('auth.login'); +})->name('login'); + +// Include AuthKit routes +require __DIR__.'/authkit-web.php'; +``` + +### 5. Test It Out + +```bash +# Start server (with HTTPS for passkeys) +./vendor/bin/sail up + +# Visit https://localhost/login +# Use demo credentials: admin@example.com / password +``` + +That's it! You now have a complete authentication system with 2FA, passkeys, and RBAC. + +## Support + +- **Documentation:** [Full documentation](https://github.com/TheBootstrapParadox/AuthKit/wiki) +- **Issues:** [GitHub Issues](https://github.com/TheBootstrapParadox/AuthKit/issues) +- **Discussions:** [GitHub Discussions](https://github.com/TheBootstrapParadox/AuthKit/discussions) diff --git a/composer.json b/composer.json index f868a12..5244233 100644 --- a/composer.json +++ b/composer.json @@ -1,19 +1,23 @@ { "$schema": "https://getcomposer.org/schema.json", - "name": "laravel/laravel", - "type": "project", - "description": "The skeleton application for the Laravel framework.", - "keywords": ["laravel", "framework"], + "name": "bspdx/authkit", + "type": "library", + "description": "Complete authentication package for Laravel with Fortify, Passkeys, TOTP 2FA, and RBAC", + "keywords": ["laravel", "authentication", "fortify", "passkeys", "webauthn", "2fa", "totp", "rbac", "permissions"], "license": "MIT", + "authors": [ + { + "name": "BSPDX", + "email": "support@bspdx.com" + } + ], "require": { "php": "^8.2", - "inertiajs/inertia-laravel": "^2.0", "laravel/fortify": "^1.31", "laravel/framework": "^12.0", "laravel/sanctum": "^4.0", - "laravel/tinker": "^2.10.1", "spatie/laravel-passkeys": "^1.5", - "tightenco/ziggy": "^2.0" + "spatie/laravel-permission": "^6.0" }, "require-dev": { "fakerphp/faker": "^1.23", @@ -26,6 +30,7 @@ }, "autoload": { "psr-4": { + "BSPDX\\AuthKit\\": "src/", "App\\": "app/", "Database\\Factories\\": "database/factories/", "Database\\Seeders\\": "database/seeders/" @@ -33,6 +38,7 @@ }, "autoload-dev": { "psr-4": { + "BSPDX\\AuthKit\\Tests\\": "tests/", "Tests\\": "tests/" } }, @@ -74,6 +80,9 @@ }, "extra": { "laravel": { + "providers": [ + "BSPDX\\AuthKit\\AuthKitServiceProvider" + ], "dont-discover": [] } }, diff --git a/composer.lock b/composer.lock index 4cd07ee..7bfa53c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e480f91b7d4d3f3f963d19778df7e089", + "content-hash": "57e5c62e966529b35b3481d48d02a617", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1204,76 +1204,6 @@ ], "time": "2025-08-22T14:27:06+00:00" }, - { - "name": "inertiajs/inertia-laravel", - "version": "v2.0.10", - "source": { - "type": "git", - "url": "https://github.com/inertiajs/inertia-laravel.git", - "reference": "07da425d58a3a0e3ace9c296e67bd897a6e47009" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/07da425d58a3a0e3ace9c296e67bd897a6e47009", - "reference": "07da425d58a3a0e3ace9c296e67bd897a6e47009", - "shasum": "" - }, - "require": { - "ext-json": "*", - "laravel/framework": "^10.0|^11.0|^12.0", - "php": "^8.1.0", - "symfony/console": "^6.2|^7.0" - }, - "require-dev": { - "guzzlehttp/guzzle": "^7.2", - "larastan/larastan": "^3.0", - "laravel/pint": "^1.16", - "mockery/mockery": "^1.3.3", - "orchestra/testbench": "^8.0|^9.2|^10.0", - "phpunit/phpunit": "^10.4|^11.5", - "roave/security-advisories": "dev-master" - }, - "suggest": { - "ext-pcntl": "Recommended when running the Inertia SSR server via the `inertia:start-ssr` artisan command." - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Inertia\\ServiceProvider" - ] - } - }, - "autoload": { - "files": [ - "./helpers.php" - ], - "psr-4": { - "Inertia\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jonathan Reinink", - "email": "jonathan@reinink.ca", - "homepage": "https://reinink.ca" - } - ], - "description": "The Laravel adapter for Inertia.js.", - "keywords": [ - "inertia", - "laravel" - ], - "support": { - "issues": "https://github.com/inertiajs/inertia-laravel/issues", - "source": "https://github.com/inertiajs/inertia-laravel/tree/v2.0.10" - }, - "time": "2025-09-28T21:21:36+00:00" - }, { "name": "laravel/fortify", "version": "v1.31.3", @@ -1742,72 +1672,6 @@ }, "time": "2025-10-09T13:42:30+00:00" }, - { - "name": "laravel/tinker", - "version": "v2.10.1", - "source": { - "type": "git", - "url": "https://github.com/laravel/tinker.git", - "reference": "22177cc71807d38f2810c6204d8f7183d88a57d3" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/22177cc71807d38f2810c6204d8f7183d88a57d3", - "reference": "22177cc71807d38f2810c6204d8f7183d88a57d3", - "shasum": "" - }, - "require": { - "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "php": "^7.2.5|^8.0", - "psy/psysh": "^0.11.1|^0.12.0", - "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0" - }, - "require-dev": { - "mockery/mockery": "~1.3.3|^1.4.2", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" - }, - "suggest": { - "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Laravel\\Tinker\\TinkerServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Laravel\\Tinker\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "Powerful REPL for the Laravel framework.", - "keywords": [ - "REPL", - "Tinker", - "laravel", - "psysh" - ], - "support": { - "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.10.1" - }, - "time": "2025-01-27T14:24:01+00:00" - }, { "name": "league/commonmark", "version": "2.7.1", @@ -2721,64 +2585,6 @@ }, "time": "2025-08-06T21:43:34+00:00" }, - { - "name": "nikic/php-parser", - "version": "v5.6.2", - "source": { - "type": "git", - "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "3a454ca033b9e06b63282ce19562e892747449bb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb", - "reference": "3a454ca033b9e06b63282ce19562e892747449bb", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "ext-json": "*", - "ext-tokenizer": "*", - "php": ">=7.4" - }, - "require-dev": { - "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^9.0" - }, - "bin": [ - "bin/php-parse" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.x-dev" - } - }, - "autoload": { - "psr-4": { - "PhpParser\\": "lib/PhpParser" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Nikita Popov" - } - ], - "description": "A PHP parser written in PHP", - "keywords": [ - "parser", - "php" - ], - "support": { - "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.2" - }, - "time": "2025-10-21T19:32:17+00:00" - }, { "name": "nunomaduro/termwind", "version": "v2.3.2", @@ -3696,85 +3502,6 @@ }, "time": "2021-10-29T13:26:27+00:00" }, - { - "name": "psy/psysh", - "version": "v0.12.14", - "source": { - "type": "git", - "url": "https://github.com/bobthecow/psysh.git", - "reference": "95c29b3756a23855a30566b745d218bee690bef2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/95c29b3756a23855a30566b745d218bee690bef2", - "reference": "95c29b3756a23855a30566b745d218bee690bef2", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-tokenizer": "*", - "nikic/php-parser": "^5.0 || ^4.0", - "php": "^8.0 || ^7.4", - "symfony/console": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", - "symfony/var-dumper": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" - }, - "conflict": { - "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.2", - "composer/class-map-generator": "^1.6" - }, - "suggest": { - "composer/class-map-generator": "Improved tab completion performance with better class discovery.", - "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", - "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." - }, - "bin": [ - "bin/psysh" - ], - "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": false, - "forward-command": false - }, - "branch-alias": { - "dev-main": "0.12.x-dev" - } - }, - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Psy\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Justin Hileman", - "email": "justin@justinhileman.info" - } - ], - "description": "An interactive shell for modern PHP.", - "homepage": "https://psysh.org", - "keywords": [ - "REPL", - "console", - "interactive", - "shell" - ], - "support": { - "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.14" - }, - "time": "2025-10-27T17:15:31+00:00" - }, { "name": "ralouphie/getallheaders", "version": "3.0.3", @@ -4112,6 +3839,89 @@ ], "time": "2025-10-28T15:20:09+00:00" }, + { + "name": "spatie/laravel-permission", + "version": "6.23.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-permission.git", + "reference": "9e41247bd512b1e6c229afbc1eb528f7565ae3bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/9e41247bd512b1e6c229afbc1eb528f7565ae3bb", + "reference": "9e41247bd512b1e6c229afbc1eb528f7565ae3bb", + "shasum": "" + }, + "require": { + "illuminate/auth": "^8.12|^9.0|^10.0|^11.0|^12.0", + "illuminate/container": "^8.12|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^8.12|^9.0|^10.0|^11.0|^12.0", + "illuminate/database": "^8.12|^9.0|^10.0|^11.0|^12.0", + "php": "^8.0" + }, + "require-dev": { + "laravel/passport": "^11.0|^12.0", + "laravel/pint": "^1.0", + "orchestra/testbench": "^6.23|^7.0|^8.0|^9.0|^10.0", + "phpunit/phpunit": "^9.4|^10.1|^11.5" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Permission\\PermissionServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "6.x-dev", + "dev-master": "6.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\Permission\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Permission handling for Laravel 8.0 and up", + "homepage": "https://github.com/spatie/laravel-permission", + "keywords": [ + "acl", + "laravel", + "permission", + "permissions", + "rbac", + "roles", + "security", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-permission/issues", + "source": "https://github.com/spatie/laravel-permission/tree/6.23.0" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-11-03T20:16:13+00:00" + }, { "name": "spomky-labs/cbor-php", "version": "3.2.2", @@ -7137,76 +6947,6 @@ ], "time": "2025-09-27T09:00:46+00:00" }, - { - "name": "tightenco/ziggy", - "version": "v2.6.0", - "source": { - "type": "git", - "url": "https://github.com/tighten/ziggy.git", - "reference": "cccc6035c109daab03a33926b3a8499bedbed01f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/tighten/ziggy/zipball/cccc6035c109daab03a33926b3a8499bedbed01f", - "reference": "cccc6035c109daab03a33926b3a8499bedbed01f", - "shasum": "" - }, - "require": { - "ext-json": "*", - "laravel/framework": ">=9.0", - "php": ">=8.1" - }, - "require-dev": { - "laravel/folio": "^1.1", - "orchestra/testbench": "^7.0 || ^8.0 || ^9.0 || ^10.0", - "pestphp/pest": "^2.26|^3.0", - "pestphp/pest-plugin-laravel": "^2.4|^3.0" - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Tighten\\Ziggy\\ZiggyServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Tighten\\Ziggy\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Daniel Coulbourne", - "email": "daniel@tighten.co" - }, - { - "name": "Jake Bathman", - "email": "jake@tighten.co" - }, - { - "name": "Jacob Baker-Kretzmar", - "email": "jacob@tighten.co" - } - ], - "description": "Use your Laravel named routes in JavaScript.", - "homepage": "https://github.com/tighten/ziggy", - "keywords": [ - "Ziggy", - "javascript", - "laravel", - "routes" - ], - "support": { - "issues": "https://github.com/tighten/ziggy/issues", - "source": "https://github.com/tighten/ziggy/tree/v2.6.0" - }, - "time": "2025-09-15T00:00:26+00:00" - }, { "name": "tijsverkoyen/css-to-inline-styles", "version": "v2.3.0", @@ -8184,6 +7924,64 @@ ], "time": "2025-08-01T08:46:24+00:00" }, + { + "name": "nikic/php-parser", + "version": "v5.6.2", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "3a454ca033b9e06b63282ce19562e892747449bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb", + "reference": "3a454ca033b9e06b63282ce19562e892747449bb", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.2" + }, + "time": "2025-10-21T19:32:17+00:00" + }, { "name": "nunomaduro/collision", "version": "v8.8.2", diff --git a/config/authkit.php b/config/authkit.php new file mode 100644 index 0000000..725e64d --- /dev/null +++ b/config/authkit.php @@ -0,0 +1,233 @@ + false, + + /* + |-------------------------------------------------------------------------- + | Authentication Features + |-------------------------------------------------------------------------- + | + | Toggle authentication features on/off. These control what functionality + | is available in your application. + | + */ + + 'features' => [ + // Enable user registration + 'registration' => true, + + // Enable email verification + 'email_verification' => true, + + // Enable password reset functionality + 'password_reset' => true, + + // Enable two-factor authentication (TOTP via Fortify) + 'two_factor' => true, + + // Enable passkey authentication + 'passkeys' => true, + + // Enable passkey as a second factor + 'passkey_2fa' => true, + + // Enable API token authentication (Sanctum) + 'api_tokens' => true, + + // Enable profile information updates + 'update_profile' => true, + + // Enable password updates + 'update_passwords' => true, + + // Enable account deletion + 'account_deletion' => false, + ], + + /* + |-------------------------------------------------------------------------- + | Role & Permission Settings + |-------------------------------------------------------------------------- + | + | Configuration for the RBAC system powered by Spatie Laravel Permission. + | + */ + + 'rbac' => [ + // Enable multi-tenancy support for roles/permissions + 'multi_tenant' => false, + + // Cache expiration time for roles and permissions (in seconds) + 'cache_expiration' => 60 * 60 * 24, // 24 hours + + // Default role assigned to new users (null = no default role) + 'default_role' => 'user', + + // Super admin role that bypasses all permission checks + 'super_admin_role' => 'super-admin', + + // Default permissions for API access + 'api_permissions' => [ + 'view-roles', + 'view-permissions', + 'assign-roles', + 'assign-permissions', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Passkey Settings + |-------------------------------------------------------------------------- + | + | Configuration for WebAuthn/Passkey authentication. + | + */ + + 'passkey' => [ + // Relying Party name (your application name) + 'rp_name' => env('APP_NAME', 'Laravel'), + + // Relying Party ID (your domain) + 'rp_id' => env('APP_URL') ? parse_url(env('APP_URL'), PHP_URL_HOST) : 'localhost', + + // Timeout for passkey operations (in milliseconds) + 'timeout' => 60000, + + // User verification requirement: 'required', 'preferred', or 'discouraged' + 'user_verification' => 'preferred', + + // Attestation conveyance: 'none', 'indirect', or 'direct' + 'attestation' => 'none', + + // Allow users to have multiple passkeys + 'allow_multiple' => true, + + // Require passkey for specific user roles + 'required_for_roles' => [ + // 'admin', + // 'super-admin', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Two-Factor Authentication Settings + |-------------------------------------------------------------------------- + | + | Configuration for TOTP-based 2FA (Google Authenticator, Authy, etc.) + | + */ + + 'two_factor' => [ + // QR code size (in pixels) + 'qr_code_size' => 200, + + // Number of recovery codes to generate + 'recovery_codes_count' => 8, + + // Window of time to accept TOTP codes (in periods, 1 period = 30 seconds) + 'window' => 1, + + // Require 2FA for specific user roles + 'required_for_roles' => [ + // 'admin', + // 'super-admin', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Redirect Paths + |-------------------------------------------------------------------------- + | + | Define where users should be redirected after various auth actions. + | + */ + + 'redirects' => [ + 'login' => '/dashboard', + 'logout' => '/', + 'register' => '/dashboard', + 'password_reset' => '/login', + 'email_verification' => '/dashboard', + 'two_factor_challenge' => '/dashboard', + ], + + /* + |-------------------------------------------------------------------------- + | Rate Limiting + |-------------------------------------------------------------------------- + | + | Configure rate limiting for authentication attempts. + | + */ + + 'rate_limiting' => [ + // Maximum login attempts before lockout + 'max_login_attempts' => 5, + + // Lockout duration in minutes + 'lockout_duration' => 1, + + // Maximum 2FA attempts + 'max_2fa_attempts' => 3, + + // Maximum passkey attempts + 'max_passkey_attempts' => 3, + ], + + /* + |-------------------------------------------------------------------------- + | Multi-Tenancy Settings (Optional) + |-------------------------------------------------------------------------- + | + | Enable these settings if you're using Spatie's Laravel Multitenancy + | or a similar multi-tenant architecture. + | + */ + + 'multi_tenancy' => [ + // Enable multi-tenant support + 'enabled' => false, + + // Tenant column name in database tables + 'tenant_column' => 'tenant_id', + + // Automatically scope queries by tenant + 'auto_scope' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Session Settings + |-------------------------------------------------------------------------- + | + | Additional session configuration for enhanced security. + | + */ + + 'session' => [ + // Regenerate session ID after login + 'regenerate_on_login' => true, + + // Remember me duration (in minutes) + 'remember_duration' => 60 * 24 * 30, // 30 days + + // Require password confirmation for sensitive operations (in minutes) + 'password_timeout' => 10800, // 3 hours + ], + +]; diff --git a/database/migrations/2024_01_01_000001_add_authkit_fields_to_users_table.php b/database/migrations/2024_01_01_000001_add_authkit_fields_to_users_table.php new file mode 100644 index 0000000..9a0221b --- /dev/null +++ b/database/migrations/2024_01_01_000001_add_authkit_fields_to_users_table.php @@ -0,0 +1,41 @@ +text('two_factor_secret')->nullable()->after('password'); + $table->text('two_factor_recovery_codes')->nullable()->after('two_factor_secret'); + $table->timestamp('two_factor_confirmed_at')->nullable()->after('two_factor_recovery_codes'); + + // Multi-tenancy support (optional, commented out by default) + // Uncomment if using multi-tenancy + // $table->unsignedBigInteger('tenant_id')->nullable()->after('id'); + // $table->index('tenant_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn([ + 'two_factor_secret', + 'two_factor_recovery_codes', + 'two_factor_confirmed_at', + // 'tenant_id', // Uncomment if you added it + ]); + }); + } +}; diff --git a/database/seeders/AuthKitSeeder.php b/database/seeders/AuthKitSeeder.php new file mode 100644 index 0000000..d3c4de0 --- /dev/null +++ b/database/seeders/AuthKitSeeder.php @@ -0,0 +1,159 @@ +forgetCachedPermissions(); + + // Create permissions + $permissions = [ + // User permissions + 'view-users', + 'create-users', + 'edit-users', + 'delete-users', + + // Role & Permission management + 'view-roles', + 'create-roles', + 'edit-roles', + 'delete-roles', + 'assign-roles', + + 'view-permissions', + 'create-permissions', + 'edit-permissions', + 'delete-permissions', + 'assign-permissions', + + // Content management examples + 'view-posts', + 'create-posts', + 'edit-posts', + 'delete-posts', + 'publish-posts', + + // Settings + 'view-settings', + 'edit-settings', + ]; + + foreach ($permissions as $permission) { + Permission::firstOrCreate(['name' => $permission]); + } + + // Create roles and assign permissions + + // Super Admin - has all permissions + $superAdmin = Role::firstOrCreate(['name' => 'super-admin']); + $superAdmin->givePermissionTo(Permission::all()); + + // Admin - most permissions except user/role management + $admin = Role::firstOrCreate(['name' => 'admin']); + $admin->givePermissionTo([ + 'view-users', + 'create-users', + 'edit-users', + 'view-roles', + 'view-permissions', + 'view-posts', + 'create-posts', + 'edit-posts', + 'delete-posts', + 'publish-posts', + 'view-settings', + 'edit-settings', + ]); + + // Editor - content management only + $editor = Role::firstOrCreate(['name' => 'editor']); + $editor->givePermissionTo([ + 'view-posts', + 'create-posts', + 'edit-posts', + 'publish-posts', + ]); + + // User - basic read permissions + $user = Role::firstOrCreate(['name' => 'user']); + $user->givePermissionTo([ + 'view-posts', + ]); + + // Create demo users + $this->createDemoUsers($superAdmin, $admin, $editor, $user); + + $this->command->info('AuthKit roles, permissions, and demo users created successfully!'); + } + + /** + * Create demo users for each role. + */ + protected function createDemoUsers($superAdmin, $admin, $editor, $user): void + { + // Super Admin user + $superAdminUser = User::firstOrCreate( + ['email' => 'superadmin@example.com'], + [ + 'name' => 'Super Admin', + 'password' => Hash::make('password'), + 'email_verified_at' => now(), + ] + ); + $superAdminUser->assignRole($superAdmin); + + // Admin user + $adminUser = User::firstOrCreate( + ['email' => 'admin@example.com'], + [ + 'name' => 'Admin User', + 'password' => Hash::make('password'), + 'email_verified_at' => now(), + ] + ); + $adminUser->assignRole($admin); + + // Editor user + $editorUser = User::firstOrCreate( + ['email' => 'editor@example.com'], + [ + 'name' => 'Editor User', + 'password' => Hash::make('password'), + 'email_verified_at' => now(), + ] + ); + $editorUser->assignRole($editor); + + // Regular user + $regularUser = User::firstOrCreate( + ['email' => 'user@example.com'], + [ + 'name' => 'Regular User', + 'password' => Hash::make('password'), + 'email_verified_at' => now(), + ] + ); + $regularUser->assignRole($user); + + $this->command->info(''); + $this->command->info('Demo users created:'); + $this->command->info('Super Admin: superadmin@example.com / password'); + $this->command->info('Admin: admin@example.com / password'); + $this->command->info('Editor: editor@example.com / password'); + $this->command->info('User: user@example.com / password'); + $this->command->info(''); + } +} diff --git a/docs/https-setup.md b/docs/https-setup.md new file mode 100644 index 0000000..a33fac0 --- /dev/null +++ b/docs/https-setup.md @@ -0,0 +1,278 @@ +# HTTPS Setup for Laravel Sail + +Passkeys (WebAuthn) **require HTTPS** to function properly. This guide will help you set up HTTPS for your local development environment using Laravel Sail. + +## Why HTTPS is Required + +WebAuthn/Passkey authentication is only available over HTTPS connections (except for `localhost` which is treated as a secure context). To properly test passkey functionality, you need to set up HTTPS locally. + +## Method 1: Using mkcert (Recommended) + +[mkcert](https://github.com/FiloSottile/mkcert) is a simple tool for making locally-trusted development certificates. + +### Step 1: Install mkcert + +**macOS:** +```bash +brew install mkcert +brew install nss # if you use Firefox +``` + +**Linux:** +```bash +# Ubuntu/Debian +sudo apt install libnss3-tools +wget -O mkcert https://github.com/FiloSottile/mkcert/releases/download/v1.4.4/mkcert-v1.4.4-linux-amd64 +chmod +x mkcert +sudo mv mkcert /usr/local/bin/ +``` + +**Windows:** +```powershell +choco install mkcert +``` + +### Step 2: Install Local CA + +```bash +mkcert -install +``` + +This installs a local Certificate Authority (CA) in your system's trust store. + +### Step 3: Generate Certificates + +Navigate to your Laravel project root and create an `ssl` directory: + +```bash +mkdir -p docker/ssl +cd docker/ssl +``` + +Generate certificates for your local domain: + +```bash +mkcert localhost 127.0.0.1 ::1 +``` + +This creates two files: +- `localhost+2.pem` (certificate) +- `localhost+2-key.pem` (private key) + +Rename them for clarity: + +```bash +mv localhost+2.pem cert.pem +mv localhost+2-key.pem key.pem +``` + +### Step 4: Update docker-compose.yml + +Modify your `docker-compose.yml` to use HTTPS: + +```yaml +services: + laravel.test: + build: + context: ./vendor/laravel/sail/runtimes/8.3 + dockerfile: Dockerfile + args: + WWWGROUP: '${WWWGROUP}' + image: sail-8.3/app + extra_hosts: + - 'host.docker.internal:host-gateway' + ports: + - '${APP_PORT:-80}:80' + - '${VITE_PORT:-5173}:${VITE_PORT:-5173}' + - '443:443' # Add HTTPS port + environment: + WWWUSER: '${WWWUSER}' + LARAVEL_SAIL: 1 + XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}' + XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}' + IGNITION_LOCAL_SITES_PATH: '${PWD}' + volumes: + - '.:/var/www/html' + - './docker/ssl:/etc/nginx/ssl' # Mount SSL certificates + networks: + - sail + depends_on: + - mysql + - redis +``` + +### Step 5: Update Nginx Configuration + +Create a new Nginx configuration file at `docker/nginx/default.conf`: + +```nginx +server { + listen 80; + listen [::]:80; + server_name localhost; + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name localhost; + root /var/www/html/public; + + ssl_certificate /etc/nginx/ssl/cert.pem; + ssl_certificate_key /etc/nginx/ssl/key.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + add_header X-Frame-Options "SAMEORIGIN"; + add_header X-Content-Type-Options "nosniff"; + + index index.php; + + charset utf-8; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location = /favicon.ico { access_log off; log_not_found off; } + location = /robots.txt { access_log off; log_not_found off; } + + error_page 404 /index.php; + + location ~ \.php$ { + fastcgi_pass unix:/var/run/php/php8.3-fpm.sock; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + include fastcgi_params; + } + + location ~ /\.(?!well-known).* { + deny all; + } +} +``` + +Mount this configuration in your `docker-compose.yml`: + +```yaml +volumes: + - '.:/var/www/html' + - './docker/ssl:/etc/nginx/ssl' + - './docker/nginx/default.conf:/etc/nginx/sites-available/default' +``` + +### Step 6: Update .env + +Update your `.env` file to use HTTPS: + +```env +APP_URL=https://localhost +ASSET_URL=https://localhost +SESSION_SECURE_COOKIE=true +``` + +### Step 7: Restart Sail + +```bash +./vendor/bin/sail down +./vendor/bin/sail up -d +``` + +Your application should now be accessible at `https://localhost` + +## Method 2: Using Caddy (Alternative) + +Laravel Sail can also use Caddy, which automatically handles HTTPS: + +### Step 1: Create a Caddyfile + +Create `docker/caddy/Caddyfile`: + +``` +localhost { + reverse_proxy laravel.test:80 + tls internal +} +``` + +### Step 2: Add Caddy to docker-compose.yml + +```yaml +services: + caddy: + image: caddy:latest + ports: + - "443:443" + - "443:443/udp" + volumes: + - ./docker/caddy/Caddyfile:/etc/caddy/Caddyfile + - caddy_data:/data + - caddy_config:/config + networks: + - sail + depends_on: + - laravel.test + +volumes: + caddy_data: + caddy_config: +``` + +### Step 3: Trust Caddy's Root Certificate + +```bash +# macOS +./vendor/bin/sail exec caddy caddy trust + +# Linux +sudo ./vendor/bin/sail exec caddy caddy trust +``` + +## Verifying HTTPS Setup + +1. Visit `https://localhost` in your browser +2. Check that the connection is secure (padlock icon) +3. Test passkey registration to ensure WebAuthn is working + +## Troubleshooting + +### Certificate Not Trusted + +- **Browser:** Clear browser cache and restart +- **System:** Re-run `mkcert -install` +- **Firefox:** Firefox uses its own certificate store. Install `libnss3-tools` and re-run `mkcert -install` + +### Port Already in Use + +```bash +# Check what's using port 443 +sudo lsof -i :443 + +# Kill the process or change the port in docker-compose.yml +``` + +### WebAuthn Still Not Working + +- Ensure `APP_URL` is set to `https://localhost` +- Check browser console for errors +- Verify that `window.isSecureContext` returns `true` in browser console + +## Production Considerations + +For production deployments: + +1. Use a real SSL certificate (Let's Encrypt, CloudFlare, etc.) +2. Set up proper DNS records +3. Configure `APP_URL` to your production domain +4. Update `authkit.php` passkey configuration: + ```php + 'rp_id' => env('PASSKEY_RP_ID', 'yourdomain.com'), + ``` + +## Additional Resources + +- [mkcert Documentation](https://github.com/FiloSottile/mkcert) +- [Laravel Sail Documentation](https://laravel.com/docs/sail) +- [WebAuthn Guide](https://webauthn.guide/) +- [Caddy Documentation](https://caddyserver.com/docs/) diff --git a/resources/views/components/login-form.blade.php b/resources/views/components/login-form.blade.php new file mode 100644 index 0000000..3aee7ee --- /dev/null +++ b/resources/views/components/login-form.blade.php @@ -0,0 +1,212 @@ +
+ + +
+ @csrf + + @if ($errors->any()) +
+ @foreach ($errors->all() as $error) +

{{ $error }}

+ @endforeach +
+ @endif + +
+ + + @error('email') + {{ $message }} + @enderror +
+ +
+ + + @error('password') + {{ $message }} + @enderror +
+ + @if ($showRememberMe) +
+ +
+ @endif + + + + @if ($showPasskeyOption && config('authkit.features.passkeys')) +
or
+ + @endif + + +
+
+ +@if ($showPasskeyOption && config('authkit.features.passkeys')) + +@endif diff --git a/resources/views/components/passkey-login.blade.php b/resources/views/components/passkey-login.blade.php new file mode 100644 index 0000000..125db4c --- /dev/null +++ b/resources/views/components/passkey-login.blade.php @@ -0,0 +1,106 @@ +
+
+

+ Sign in with Passkey +

+ + + +
+ + +
+
+ + diff --git a/resources/views/components/passkey-register.blade.php b/resources/views/components/passkey-register.blade.php new file mode 100644 index 0000000..1973d1a --- /dev/null +++ b/resources/views/components/passkey-register.blade.php @@ -0,0 +1,100 @@ +
+
+
+ + +

+ Give this passkey a recognizable name. +

+
+ + + +
+
+
+ + diff --git a/resources/views/components/register-form.blade.php b/resources/views/components/register-form.blade.php new file mode 100644 index 0000000..3960359 --- /dev/null +++ b/resources/views/components/register-form.blade.php @@ -0,0 +1,87 @@ +
+
+ @csrf + + @if ($errors->any()) +
+ @foreach ($errors->all() as $error) +

{{ $error }}

+ @endforeach +
+ @endif + + @if (in_array('name', $requiredFields)) +
+ + + @error('name') + {{ $message }} + @enderror +
+ @endif + + @if (in_array('email', $requiredFields)) +
+ + + @error('email') + {{ $message }} + @enderror +
+ @endif + + @if (in_array('password', $requiredFields)) +
+ + + @error('password') + {{ $message }} + @enderror +
+ @endif + + @if (in_array('password_confirmation', $requiredFields)) +
+ + +
+ @endif + + + + @if ($showLoginLink) + + @endif +
+
diff --git a/resources/views/components/two-factor-challenge.blade.php b/resources/views/components/two-factor-challenge.blade.php new file mode 100644 index 0000000..926c26d --- /dev/null +++ b/resources/views/components/two-factor-challenge.blade.php @@ -0,0 +1,93 @@ +
+
+ @csrf + +
+
+ + +

+ Enter the code from your authenticator app. +

+ @error('code') + {{ $message }} + @enderror +
+
+ + @if ($showRecoveryCodeOption) + + @endif + + + + @if ($showRecoveryCodeOption) +
+ +
+ @endif +
+
+ +@if ($showRecoveryCodeOption) + +@endif diff --git a/routes/api.example.php b/routes/api.example.php new file mode 100644 index 0000000..1d7d42d --- /dev/null +++ b/routes/api.example.php @@ -0,0 +1,100 @@ +prefix('api')->group(function () { + // View roles and permissions + Route::get('/roles', [RolePermissionController::class, 'roles']) + ->middleware('permission:view-roles') + ->name('api.roles.index'); + + Route::get('/permissions', [RolePermissionController::class, 'permissions']) + ->middleware('permission:view-permissions') + ->name('api.permissions.index'); + + // Create roles and permissions + Route::post('/roles', [RolePermissionController::class, 'createRole']) + ->middleware('permission:create-roles') + ->name('api.roles.store'); + + Route::post('/permissions', [RolePermissionController::class, 'createPermission']) + ->middleware('permission:create-permissions') + ->name('api.permissions.store'); + + // Delete roles and permissions + Route::delete('/roles/{role}', [RolePermissionController::class, 'deleteRole']) + ->middleware('permission:delete-roles') + ->name('api.roles.destroy'); + + Route::delete('/permissions/{permission}', [RolePermissionController::class, 'deletePermission']) + ->middleware('permission:delete-permissions') + ->name('api.permissions.destroy'); + + // Assign roles and permissions to users + Route::post('/users/{user}/roles', [RolePermissionController::class, 'assignRoles']) + ->middleware('permission:assign-roles') + ->name('api.users.roles.assign'); + + Route::post('/users/{user}/permissions', [RolePermissionController::class, 'assignPermissions']) + ->middleware('permission:assign-permissions') + ->name('api.users.permissions.assign'); + + // Get user roles and permissions + Route::get('/users/{user}/roles-permissions', [RolePermissionController::class, 'userRolesPermissions']) + ->middleware('permission:view-users') + ->name('api.users.roles-permissions'); + + // Assign permissions to roles + Route::post('/roles/{role}/permissions', [RolePermissionController::class, 'assignPermissionsToRole']) + ->middleware('permission:assign-permissions') + ->name('api.roles.permissions.assign'); +}); + +// Two-Factor Authentication API +Route::middleware(['auth:sanctum'])->prefix('api/user')->group(function () { + Route::post('/two-factor-authentication', [TwoFactorAuthController::class, 'store']) + ->name('api.two-factor.store'); + + Route::post('/confirmed-two-factor-authentication', [TwoFactorAuthController::class, 'confirm']) + ->name('api.two-factor.confirm'); + + Route::delete('/two-factor-authentication', [TwoFactorAuthController::class, 'destroy']) + ->name('api.two-factor.destroy'); + + Route::get('/two-factor-recovery-codes', [TwoFactorAuthController::class, 'recoveryCodes']) + ->name('api.two-factor.recovery-codes'); + + Route::post('/two-factor-recovery-codes', [TwoFactorAuthController::class, 'regenerateRecoveryCodes']) + ->name('api.two-factor.recovery-codes.regenerate'); +}); + +// Passkey API +Route::middleware(['auth:sanctum'])->prefix('api/user')->group(function () { + Route::get('/passkeys', [PasskeyAuthController::class, 'index']) + ->name('api.passkeys.index'); + + Route::post('/passkeys/options', [PasskeyAuthController::class, 'registerOptions']) + ->name('api.passkeys.register.options'); + + Route::post('/passkeys', [PasskeyAuthController::class, 'store']) + ->name('api.passkeys.store'); + + Route::delete('/passkeys/{passkey}', [PasskeyAuthController::class, 'destroy']) + ->name('api.passkeys.destroy'); +}); diff --git a/routes/web.example.php b/routes/web.example.php new file mode 100644 index 0000000..44fe892 --- /dev/null +++ b/routes/web.example.php @@ -0,0 +1,94 @@ +group(function () { + // Enable 2FA + Route::get('/user/two-factor-authentication', [TwoFactorAuthController::class, 'create']) + ->name('two-factor.enable'); + + Route::post('/user/two-factor-authentication', [TwoFactorAuthController::class, 'store']) + ->name('two-factor.store'); + + // Confirm 2FA + Route::post('/user/confirmed-two-factor-authentication', [TwoFactorAuthController::class, 'confirm']) + ->name('two-factor.confirm'); + + // Disable 2FA + Route::delete('/user/two-factor-authentication', [TwoFactorAuthController::class, 'destroy']) + ->name('two-factor.destroy'); + + // Recovery codes + Route::get('/user/two-factor-recovery-codes', [TwoFactorAuthController::class, 'recoveryCodes']) + ->name('two-factor.recovery-codes'); + + Route::post('/user/two-factor-recovery-codes', [TwoFactorAuthController::class, 'regenerateRecoveryCodes']) + ->name('two-factor.recovery-codes.regenerate'); +}); + +// Passkey Routes +Route::middleware(['web'])->group(function () { + // Passkey login (guest) + Route::get('/passkey/login', [PasskeyAuthController::class, 'loginView']) + ->name('passkeys.login') + ->middleware('guest'); + + Route::post('/passkey/login/options', [PasskeyAuthController::class, 'loginOptions']) + ->name('passkeys.login.options') + ->middleware('guest'); + + Route::post('/passkey/authenticate', [PasskeyAuthController::class, 'authenticate']) + ->name('passkeys.authenticate') + ->middleware('guest'); + + // Passkey management (authenticated) + Route::middleware(['auth'])->group(function () { + Route::get('/user/passkeys', [PasskeyAuthController::class, 'registerView']) + ->name('passkeys.register.view'); + + Route::post('/user/passkeys/options', [PasskeyAuthController::class, 'registerOptions']) + ->name('passkeys.register.options'); + + Route::post('/user/passkeys', [PasskeyAuthController::class, 'store']) + ->name('passkeys.register'); + + Route::delete('/user/passkeys/{passkey}', [PasskeyAuthController::class, 'destroy']) + ->name('passkeys.destroy'); + }); +}); + +// Example protected routes using AuthKit middleware +Route::middleware(['web', 'auth', '2fa'])->group(function () { + // Routes that require 2FA to be enabled (if required by role) + Route::get('/dashboard', function () { + return view('dashboard'); + })->name('dashboard'); +}); + +// Example RBAC protected routes +Route::middleware(['web', 'auth', 'role:admin'])->group(function () { + Route::get('/admin', function () { + return 'Admin Dashboard'; + }); +}); + +Route::middleware(['web', 'auth', 'permission:edit-posts'])->group(function () { + Route::get('/posts/edit', function () { + return 'Edit Posts'; + }); +}); diff --git a/src/AuthKitServiceProvider.php b/src/AuthKitServiceProvider.php new file mode 100644 index 0000000..4be0a4b --- /dev/null +++ b/src/AuthKitServiceProvider.php @@ -0,0 +1,82 @@ +mergeConfigFrom( + __DIR__.'/../config/authkit.php', + 'authkit' + ); + } + + /** + * Bootstrap any application services. + */ + public function boot(): void + { + // Load package routes if enabled + if (config('authkit.load_routes', false)) { + $this->loadRoutesFrom(__DIR__.'/../routes/web.php'); + $this->loadRoutesFrom(__DIR__.'/../routes/api.php'); + } + + // Load migrations + $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); + + // Load views + $this->loadViewsFrom(__DIR__.'/../resources/views', 'authkit'); + + // Publish configuration + $this->publishes([ + __DIR__.'/../config/authkit.php' => config_path('authkit.php'), + ], 'authkit-config'); + + // Publish migrations + $this->publishes([ + __DIR__.'/../database/migrations' => database_path('migrations'), + ], 'authkit-migrations'); + + // Publish views + $this->publishes([ + __DIR__.'/../resources/views' => resource_path('views/vendor/authkit'), + ], 'authkit-views'); + + // Publish seeders + $this->publishes([ + __DIR__.'/../database/seeders' => database_path('seeders'), + ], 'authkit-seeders'); + + // Publish example routes + $this->publishes([ + __DIR__.'/../routes/web.example.php' => base_path('routes/authkit-web.php'), + __DIR__.'/../routes/api.example.php' => base_path('routes/authkit-api.php'), + ], 'authkit-routes'); + + // Register middleware aliases + $router = $this->app['router']; + $router->aliasMiddleware('role', EnsureHasRole::class); + $router->aliasMiddleware('permission', EnsureHasPermission::class); + $router->aliasMiddleware('2fa', EnsureTwoFactorEnabled::class); + + // Register Blade components + $this->loadViewComponentsAs('authkit', [ + \BSPDX\AuthKit\View\Components\LoginForm::class, + \BSPDX\AuthKit\View\Components\RegisterForm::class, + \BSPDX\AuthKit\View\Components\TwoFactorChallenge::class, + \BSPDX\AuthKit\View\Components\PasskeyRegister::class, + \BSPDX\AuthKit\View\Components\PasskeyLogin::class, + ]); + } +} diff --git a/src/Http/Controllers/PasskeyAuthController.php b/src/Http/Controllers/PasskeyAuthController.php new file mode 100644 index 0000000..38aeeb3 --- /dev/null +++ b/src/Http/Controllers/PasskeyAuthController.php @@ -0,0 +1,153 @@ + $request->user(), + 'passkeys' => $request->user()->passkeys, + ]); + } + + /** + * Generate passkey registration options. + */ + public function registerOptions(Request $request): JsonResponse + { + $options = Passkey::registerOptions($request->user()); + + return response()->json($options); + } + + /** + * Store a new passkey for the authenticated user. + */ + public function store(Request $request): RedirectResponse|JsonResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'credential' => ['required', 'array'], + ]); + + try { + Passkey::register($request->user(), $validated['credential'], $validated['name']); + + if ($request->wantsJson()) { + return response()->json([ + 'message' => 'Passkey registered successfully.', + ]); + } + + return redirect()->back()->with('status', 'passkey-registered'); + } catch (\Exception $e) { + if ($request->wantsJson()) { + return response()->json([ + 'message' => 'Failed to register passkey: ' . $e->getMessage(), + ], 422); + } + + return redirect()->back()->withErrors([ + 'passkey' => 'Failed to register passkey. Please try again.', + ]); + } + } + + /** + * Delete a passkey. + */ + public function destroy(Request $request, string $passkeyId): RedirectResponse|JsonResponse + { + $passkey = $request->user()->passkeys()->findOrFail($passkeyId); + $passkey->delete(); + + if ($request->wantsJson()) { + return response()->json([ + 'message' => 'Passkey deleted successfully.', + ]); + } + + return redirect()->back()->with('status', 'passkey-deleted'); + } + + /** + * Display the passkey login view. + */ + public function loginView(): View + { + return view('authkit::passkeys.login'); + } + + /** + * Generate passkey authentication options. + */ + public function loginOptions(Request $request): JsonResponse + { + $options = Passkey::authenticationOptions(); + + return response()->json($options); + } + + /** + * Authenticate a user using a passkey. + */ + public function authenticate(Request $request): RedirectResponse|JsonResponse + { + $validated = $request->validate([ + 'credential' => ['required', 'array'], + ]); + + try { + $user = Passkey::authenticate($validated['credential']); + + auth()->login($user, remember: true); + + if ($request->wantsJson()) { + return response()->json([ + 'message' => 'Authentication successful.', + 'redirect' => config('authkit.redirects.login', '/dashboard'), + ]); + } + + return redirect()->intended(config('authkit.redirects.login', '/dashboard')); + } catch (\Exception $e) { + if ($request->wantsJson()) { + return response()->json([ + 'message' => 'Authentication failed: ' . $e->getMessage(), + ], 401); + } + + return redirect()->back()->withErrors([ + 'passkey' => 'Authentication failed. Please try again.', + ]); + } + } + + /** + * Get all passkeys for the authenticated user. + */ + public function index(Request $request): JsonResponse + { + $passkeys = $request->user()->passkeys()->get()->map(function ($passkey) { + return [ + 'id' => $passkey->id, + 'name' => $passkey->name, + 'created_at' => $passkey->created_at->toDateTimeString(), + 'last_used_at' => $passkey->last_used_at?->toDateTimeString(), + ]; + }); + + return response()->json(['passkeys' => $passkeys]); + } +} diff --git a/src/Http/Controllers/RolePermissionController.php b/src/Http/Controllers/RolePermissionController.php new file mode 100644 index 0000000..98ef74a --- /dev/null +++ b/src/Http/Controllers/RolePermissionController.php @@ -0,0 +1,214 @@ +get()->map(function ($role) { + return [ + 'id' => $role->id, + 'name' => $role->name, + 'guard_name' => $role->guard_name, + 'permissions' => $role->permissions->pluck('name'), + 'users_count' => $role->users()->count(), + 'created_at' => $role->created_at->toDateTimeString(), + ]; + }); + + return response()->json(['roles' => $roles]); + } + + /** + * Get all permissions. + */ + public function permissions(): JsonResponse + { + $permissions = Permission::with('roles')->get()->map(function ($permission) { + return [ + 'id' => $permission->id, + 'name' => $permission->name, + 'guard_name' => $permission->guard_name, + 'roles' => $permission->roles->pluck('name'), + 'created_at' => $permission->created_at->toDateTimeString(), + ]; + }); + + return response()->json(['permissions' => $permissions]); + } + + /** + * Create a new role. + */ + public function createRole(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'unique:roles,name'], + 'guard_name' => ['nullable', 'string'], + 'permissions' => ['nullable', 'array'], + 'permissions.*' => ['string', 'exists:permissions,name'], + ]); + + $role = Role::create([ + 'name' => $validated['name'], + 'guard_name' => $validated['guard_name'] ?? 'web', + ]); + + if (isset($validated['permissions'])) { + $role->syncPermissions($validated['permissions']); + } + + return response()->json([ + 'message' => 'Role created successfully.', + 'role' => $role->load('permissions'), + ], 201); + } + + /** + * Create a new permission. + */ + public function createPermission(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'unique:permissions,name'], + 'guard_name' => ['nullable', 'string'], + ]); + + $permission = Permission::create([ + 'name' => $validated['name'], + 'guard_name' => $validated['guard_name'] ?? 'web', + ]); + + return response()->json([ + 'message' => 'Permission created successfully.', + 'permission' => $permission, + ], 201); + } + + /** + * Assign roles to a user. + */ + public function assignRoles(Request $request, User $user): JsonResponse + { + $validated = $request->validate([ + 'roles' => ['required', 'array'], + 'roles.*' => ['string', 'exists:roles,name'], + ]); + + $user->syncRoles($validated['roles']); + + return response()->json([ + 'message' => 'Roles assigned successfully.', + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'roles' => $user->roles->pluck('name'), + ], + ]); + } + + /** + * Assign permissions to a user. + */ + public function assignPermissions(Request $request, User $user): JsonResponse + { + $validated = $request->validate([ + 'permissions' => ['required', 'array'], + 'permissions.*' => ['string', 'exists:permissions,name'], + ]); + + $user->syncPermissions($validated['permissions']); + + return response()->json([ + 'message' => 'Permissions assigned successfully.', + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'permissions' => $user->getAllPermissions()->pluck('name'), + ], + ]); + } + + /** + * Assign permissions to a role. + */ + public function assignPermissionsToRole(Request $request, Role $role): JsonResponse + { + $validated = $request->validate([ + 'permissions' => ['required', 'array'], + 'permissions.*' => ['string', 'exists:permissions,name'], + ]); + + $role->syncPermissions($validated['permissions']); + + return response()->json([ + 'message' => 'Permissions assigned to role successfully.', + 'role' => $role->load('permissions'), + ]); + } + + /** + * Get user's roles and permissions. + */ + public function userRolesPermissions(User $user): JsonResponse + { + return response()->json([ + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'roles' => $user->roles->map(fn($role) => [ + 'id' => $role->id, + 'name' => $role->name, + ]), + 'permissions' => $user->getAllPermissions()->map(fn($permission) => [ + 'id' => $permission->id, + 'name' => $permission->name, + ]), + 'direct_permissions' => $user->permissions->pluck('name'), + ], + ]); + } + + /** + * Remove a role. + */ + public function deleteRole(Role $role): JsonResponse + { + $superAdminRole = config('authkit.rbac.super_admin_role', 'super-admin'); + + if ($role->name === $superAdminRole) { + return response()->json([ + 'message' => 'Cannot delete the super admin role.', + ], 403); + } + + $role->delete(); + + return response()->json([ + 'message' => 'Role deleted successfully.', + ]); + } + + /** + * Remove a permission. + */ + public function deletePermission(Permission $permission): JsonResponse + { + $permission->delete(); + + return response()->json([ + 'message' => 'Permission deleted successfully.', + ]); + } +} diff --git a/src/Http/Controllers/TwoFactorAuthController.php b/src/Http/Controllers/TwoFactorAuthController.php new file mode 100644 index 0000000..e9145d9 --- /dev/null +++ b/src/Http/Controllers/TwoFactorAuthController.php @@ -0,0 +1,154 @@ + $request->user(), + ]); + } + + /** + * Enable two-factor authentication for the user. + */ + public function store(Request $request): RedirectResponse|JsonResponse + { + $user = $request->user(); + + // Generate 2FA secret + $user->forceFill([ + 'two_factor_secret' => encrypt(app('pragmarx.google2fa')->generateSecretKey()), + 'two_factor_recovery_codes' => encrypt(json_encode($this->generateRecoveryCodes())), + ])->save(); + + if ($request->wantsJson()) { + return response()->json([ + 'message' => 'Two-factor authentication has been enabled.', + 'qr_code' => $user->twoFactorQrCodeSvg(), + 'recovery_codes' => $this->getRecoveryCodes($user), + ]); + } + + return redirect()->back()->with([ + 'status' => 'two-factor-enabled', + 'recovery_codes' => $this->getRecoveryCodes($user), + ]); + } + + /** + * Confirm two-factor authentication for the user. + */ + public function confirm(Request $request): RedirectResponse|JsonResponse + { + $request->validate([ + 'code' => ['required', 'string'], + ]); + + $user = $request->user(); + + if (!app('pragmarx.google2fa')->verifyKey( + decrypt($user->two_factor_secret), + $request->code + )) { + if ($request->wantsJson()) { + return response()->json(['message' => 'The provided code was invalid.'], 422); + } + + return redirect()->back()->withErrors([ + 'code' => 'The provided code was invalid.', + ]); + } + + $user->forceFill([ + 'two_factor_confirmed_at' => now(), + ])->save(); + + if ($request->wantsJson()) { + return response()->json(['message' => 'Two-factor authentication has been confirmed.']); + } + + return redirect()->back()->with('status', 'two-factor-confirmed'); + } + + /** + * Disable two-factor authentication for the user. + */ + public function destroy(Request $request): RedirectResponse|JsonResponse + { + $request->user()->forceFill([ + 'two_factor_secret' => null, + 'two_factor_recovery_codes' => null, + 'two_factor_confirmed_at' => null, + ])->save(); + + if ($request->wantsJson()) { + return response()->json(['message' => 'Two-factor authentication has been disabled.']); + } + + return redirect()->back()->with('status', 'two-factor-disabled'); + } + + /** + * Display the user's recovery codes. + */ + public function recoveryCodes(Request $request): JsonResponse + { + return response()->json([ + 'recovery_codes' => $this->getRecoveryCodes($request->user()), + ]); + } + + /** + * Regenerate the user's recovery codes. + */ + public function regenerateRecoveryCodes(Request $request): RedirectResponse|JsonResponse + { + $request->user()->forceFill([ + 'two_factor_recovery_codes' => encrypt(json_encode($this->generateRecoveryCodes())), + ])->save(); + + $codes = $this->getRecoveryCodes($request->user()); + + if ($request->wantsJson()) { + return response()->json(['recovery_codes' => $codes]); + } + + return redirect()->back()->with([ + 'status' => 'recovery-codes-regenerated', + 'recovery_codes' => $codes, + ]); + } + + /** + * Generate new recovery codes. + */ + protected function generateRecoveryCodes(): array + { + $count = config('authkit.two_factor.recovery_codes_count', 8); + + return Collection::times($count, function () { + return Str::random(10) . '-' . Str::random(10); + })->toArray(); + } + + /** + * Get the user's recovery codes. + */ + protected function getRecoveryCodes($user): array + { + return json_decode(decrypt($user->two_factor_recovery_codes), true); + } +} diff --git a/src/Http/Middleware/EnsureHasPermission.php b/src/Http/Middleware/EnsureHasPermission.php new file mode 100644 index 0000000..acfd962 --- /dev/null +++ b/src/Http/Middleware/EnsureHasPermission.php @@ -0,0 +1,34 @@ +user()) { + return redirect()->route('login'); + } + + // Super admins bypass all permission checks + if (method_exists($request->user(), 'isSuperAdmin') && $request->user()->isSuperAdmin()) { + return $next($request); + } + + if (!$request->user()->hasAnyPermission($permissions)) { + abort(403, 'You do not have the required permission to access this resource.'); + } + + return $next($request); + } +} diff --git a/src/Http/Middleware/EnsureHasRole.php b/src/Http/Middleware/EnsureHasRole.php new file mode 100644 index 0000000..96ffcbe --- /dev/null +++ b/src/Http/Middleware/EnsureHasRole.php @@ -0,0 +1,34 @@ +user()) { + return redirect()->route('login'); + } + + // Super admins bypass all role checks + if (method_exists($request->user(), 'isSuperAdmin') && $request->user()->isSuperAdmin()) { + return $next($request); + } + + if (!$request->user()->hasAnyRole($roles)) { + abort(403, 'You do not have the required role to access this resource.'); + } + + return $next($request); + } +} diff --git a/src/Http/Middleware/EnsureTwoFactorEnabled.php b/src/Http/Middleware/EnsureTwoFactorEnabled.php new file mode 100644 index 0000000..1e292e4 --- /dev/null +++ b/src/Http/Middleware/EnsureTwoFactorEnabled.php @@ -0,0 +1,33 @@ +user()) { + return redirect()->route('login'); + } + + // Check if 2FA is required for the user's role + if (method_exists($request->user(), 'requires2FA') && $request->user()->requires2FA()) { + // Check if 2FA is enabled + if (!method_exists($request->user(), 'hasTwoFactorEnabled') || !$request->user()->hasTwoFactorEnabled()) { + return redirect()->route('two-factor.enable') + ->with('warning', 'Two-factor authentication is required for your account. Please enable it to continue.'); + } + } + + return $next($request); + } +} diff --git a/src/Traits/HasAuthKit.php b/src/Traits/HasAuthKit.php new file mode 100644 index 0000000..d745f8e --- /dev/null +++ b/src/Traits/HasAuthKit.php @@ -0,0 +1,91 @@ +two_factor_secret) && + !is_null($this->two_factor_confirmed_at); + } + + /** + * Determine if the user has registered any passkeys. + */ + public function hasPasskeysRegistered(): bool + { + return $this->passkeys()->exists(); + } + + /** + * Determine if 2FA is required for this user based on their roles. + */ + public function requires2FA(): bool + { + $requiredRoles = config('authkit.two_factor.required_for_roles', []); + + if (empty($requiredRoles)) { + return false; + } + + return $this->hasAnyRole($requiredRoles); + } + + /** + * Determine if passkeys are required for this user based on their roles. + */ + public function requiresPasskey(): bool + { + $requiredRoles = config('authkit.passkey.required_for_roles', []); + + if (empty($requiredRoles)) { + return false; + } + + return $this->hasAnyRole($requiredRoles); + } + + /** + * Get the user's authentication methods. + */ + public function getAuthenticationMethods(): array + { + return [ + 'password' => true, + 'two_factor' => $this->hasTwoFactorEnabled(), + 'passkey' => $this->hasPasskeysRegistered(), + ]; + } + + /** + * Determine if the user is a super admin. + */ + public function isSuperAdmin(): bool + { + $superAdminRole = config('authkit.rbac.super_admin_role', 'super-admin'); + + return $this->hasRole($superAdminRole); + } + + /** + * Check if user can bypass permission checks (super admin). + */ + public function canBypassPermissions(): bool + { + return $this->isSuperAdmin(); + } +} diff --git a/src/View/Components/LoginForm.php b/src/View/Components/LoginForm.php new file mode 100644 index 0000000..e6082dc --- /dev/null +++ b/src/View/Components/LoginForm.php @@ -0,0 +1,24 @@ +action = $action ?? route('login'); + } + + public function render(): View + { + return view('authkit::components.login-form'); + } +} diff --git a/src/View/Components/PasskeyLogin.php b/src/View/Components/PasskeyLogin.php new file mode 100644 index 0000000..f8e7ef1 --- /dev/null +++ b/src/View/Components/PasskeyLogin.php @@ -0,0 +1,22 @@ +loginOptionsUrl = $loginOptionsUrl ?? route('passkeys.login.options'); + $this->authenticateUrl = $authenticateUrl ?? route('passkeys.authenticate'); + } + + public function render(): View + { + return view('authkit::components.passkey-login'); + } +} diff --git a/src/View/Components/PasskeyRegister.php b/src/View/Components/PasskeyRegister.php new file mode 100644 index 0000000..860e347 --- /dev/null +++ b/src/View/Components/PasskeyRegister.php @@ -0,0 +1,22 @@ +registerOptionsUrl = $registerOptionsUrl ?? route('passkeys.register.options'); + $this->registerUrl = $registerUrl ?? route('passkeys.register'); + } + + public function render(): View + { + return view('authkit::components.passkey-register'); + } +} diff --git a/src/View/Components/RegisterForm.php b/src/View/Components/RegisterForm.php new file mode 100644 index 0000000..d8bd82a --- /dev/null +++ b/src/View/Components/RegisterForm.php @@ -0,0 +1,22 @@ +action = $action ?? route('register'); + } + + public function render(): View + { + return view('authkit::components.register-form'); + } +} diff --git a/src/View/Components/TwoFactorChallenge.php b/src/View/Components/TwoFactorChallenge.php new file mode 100644 index 0000000..77a24a4 --- /dev/null +++ b/src/View/Components/TwoFactorChallenge.php @@ -0,0 +1,21 @@ +action = $action ?? route('two-factor.login'); + } + + public function render(): View + { + return view('authkit::components.two-factor-challenge'); + } +} diff --git a/tests/Feature/AuthKitTest.php b/tests/Feature/AuthKitTest.php new file mode 100644 index 0000000..5c1bc3f --- /dev/null +++ b/tests/Feature/AuthKitTest.php @@ -0,0 +1,167 @@ +forgetCachedPermissions(); + } + + /** @test */ + public function user_can_be_assigned_a_role() + { + $user = User::factory()->create(); + $role = Role::create(['name' => 'admin']); + + $user->assignRole('admin'); + + $this->assertTrue($user->hasRole('admin')); + } + + /** @test */ + public function user_can_be_assigned_a_permission() + { + $user = User::factory()->create(); + $permission = Permission::create(['name' => 'edit-posts']); + + $user->givePermissionTo('edit-posts'); + + $this->assertTrue($user->can('edit-posts')); + } + + /** @test */ + public function role_can_have_permissions() + { + $role = Role::create(['name' => 'editor']); + $permission = Permission::create(['name' => 'publish-posts']); + + $role->givePermissionTo($permission); + + $this->assertTrue($role->hasPermissionTo($permission)); + } + + /** @test */ + public function user_inherits_permissions_from_role() + { + $user = User::factory()->create(); + $role = Role::create(['name' => 'editor']); + $permission = Permission::create(['name' => 'publish-posts']); + + $role->givePermissionTo($permission); + $user->assignRole($role); + + $this->assertTrue($user->can('publish-posts')); + } + + /** @test */ + public function super_admin_can_be_identified() + { + $user = User::factory()->create(); + $superAdminRole = Role::create(['name' => 'super-admin']); + + $user->assignRole($superAdminRole); + + $this->assertTrue($user->isSuperAdmin()); + } + + /** @test */ + public function user_can_check_two_factor_status() + { + $user = User::factory()->create(); + + $this->assertFalse($user->hasTwoFactorEnabled()); + + // Simulate enabling 2FA + $user->forceFill([ + 'two_factor_secret' => encrypt('test-secret'), + 'two_factor_confirmed_at' => now(), + ])->save(); + + $this->assertTrue($user->hasTwoFactorEnabled()); + } + + /** @test */ + public function user_can_check_if_two_factor_is_required_for_role() + { + config(['authkit.two_factor.required_for_roles' => ['admin']]); + + $user = User::factory()->create(); + $adminRole = Role::create(['name' => 'admin']); + + $this->assertFalse($user->requires2FA()); + + $user->assignRole($adminRole); + + $this->assertTrue($user->requires2FA()); + } + + /** @test */ + public function user_can_get_authentication_methods() + { + $user = User::factory()->create(); + + $methods = $user->getAuthenticationMethods(); + + $this->assertArrayHasKey('password', $methods); + $this->assertArrayHasKey('two_factor', $methods); + $this->assertArrayHasKey('passkey', $methods); + $this->assertTrue($methods['password']); + $this->assertFalse($methods['two_factor']); + $this->assertFalse($methods['passkey']); + } + + /** @test */ + public function middleware_blocks_users_without_required_role() + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get('/test-admin-route') + ->assertStatus(403); + } + + /** @test */ + public function middleware_allows_users_with_required_role() + { + $user = User::factory()->create(); + $adminRole = Role::create(['name' => 'admin']); + $user->assignRole($adminRole); + + // This would need a test route set up + // For now, we just verify the role assignment worked + $this->assertTrue($user->hasRole('admin')); + } + + /** @test */ + public function middleware_blocks_users_without_required_permission() + { + $user = User::factory()->create(); + $permission = Permission::create(['name' => 'edit-posts']); + + $this->assertFalse($user->can('edit-posts')); + } + + /** @test */ + public function super_admin_bypasses_permission_checks() + { + $user = User::factory()->create(); + $superAdminRole = Role::create(['name' => 'super-admin']); + $user->assignRole($superAdminRole); + + // Super admin should bypass permission checks + $this->assertTrue($user->canBypassPermissions()); + } +} diff --git a/tests/Unit/HasAuthKitTraitTest.php b/tests/Unit/HasAuthKitTraitTest.php new file mode 100644 index 0000000..a34c549 --- /dev/null +++ b/tests/Unit/HasAuthKitTraitTest.php @@ -0,0 +1,138 @@ +forgetCachedPermissions(); + } + + /** @test */ + public function it_can_check_if_two_factor_is_enabled() + { + $user = User::factory()->create(); + + $this->assertFalse($user->hasTwoFactorEnabled()); + + $user->forceFill([ + 'two_factor_secret' => encrypt('test-secret'), + 'two_factor_confirmed_at' => now(), + ])->save(); + + $this->assertTrue($user->hasTwoFactorEnabled()); + } + + /** @test */ + public function it_returns_false_when_two_factor_secret_is_null() + { + $user = User::factory()->create([ + 'two_factor_secret' => null, + 'two_factor_confirmed_at' => null, + ]); + + $this->assertFalse($user->hasTwoFactorEnabled()); + } + + /** @test */ + public function it_can_check_if_passkeys_are_registered() + { + $user = User::factory()->create(); + + $this->assertFalse($user->hasPasskeysRegistered()); + } + + /** @test */ + public function it_can_check_if_two_factor_is_required() + { + config(['authkit.two_factor.required_for_roles' => ['admin']]); + + $user = User::factory()->create(); + $adminRole = Role::create(['name' => 'admin']); + + $this->assertFalse($user->requires2FA()); + + $user->assignRole($adminRole); + + $this->assertTrue($user->requires2FA()); + } + + /** @test */ + public function it_returns_false_when_no_roles_require_two_factor() + { + config(['authkit.two_factor.required_for_roles' => []]); + + $user = User::factory()->create(); + $adminRole = Role::create(['name' => 'admin']); + $user->assignRole($adminRole); + + $this->assertFalse($user->requires2FA()); + } + + /** @test */ + public function it_can_check_if_passkey_is_required() + { + config(['authkit.passkey.required_for_roles' => ['admin']]); + + $user = User::factory()->create(); + $adminRole = Role::create(['name' => 'admin']); + + $this->assertFalse($user->requiresPasskey()); + + $user->assignRole($adminRole); + + $this->assertTrue($user->requiresPasskey()); + } + + /** @test */ + public function it_can_get_authentication_methods() + { + $user = User::factory()->create(); + + $methods = $user->getAuthenticationMethods(); + + $this->assertIsArray($methods); + $this->assertArrayHasKey('password', $methods); + $this->assertArrayHasKey('two_factor', $methods); + $this->assertArrayHasKey('passkey', $methods); + } + + /** @test */ + public function it_can_identify_super_admin() + { + config(['authkit.rbac.super_admin_role' => 'super-admin']); + + $user = User::factory()->create(); + $superAdminRole = Role::create(['name' => 'super-admin']); + + $this->assertFalse($user->isSuperAdmin()); + + $user->assignRole($superAdminRole); + + $this->assertTrue($user->isSuperAdmin()); + } + + /** @test */ + public function it_can_check_if_user_can_bypass_permissions() + { + $user = User::factory()->create(); + $superAdminRole = Role::create(['name' => 'super-admin']); + + $this->assertFalse($user->canBypassPermissions()); + + $user->assignRole($superAdminRole); + + $this->assertTrue($user->canBypassPermissions()); + } +}