monkeyscloud / monkeyslegion-auth
Comprehensive authentication and authorization package with JWT, RBAC, 2FA, OAuth, and API keys
Installs: 220
Dependents: 1
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/monkeyscloud/monkeyslegion-auth
Requires
- php: ^8.4
- firebase/php-jwt: ^6.10
- monkeyscloud/monkeyslegion-cli: ^1.0
- monkeyscloud/monkeyslegion-database: ^1.0
- psr/event-dispatcher: ^1.0
- psr/http-message: ^2.0
- psr/http-server-middleware: ^1.0
- psr/simple-cache: ^3.0
Requires (Dev)
- phpstan/phpstan: ^1.10 || ^2.0
- phpunit/phpunit: ^10.0 || ^11.0
- squizlabs/php_codesniffer: ^3.7
Suggests
- ext-redis: Required for Redis-based rate limiting and token storage
- nyholm/psr7: PSR-7 implementation for middleware responses
This package is auto-updated.
Last update: 2025-12-09 00:36:04 UTC
README
A comprehensive, production-ready PHP authentication and authorization package for modern applications.
โจ Features
| Feature | Description |
|---|---|
| JWT Authentication | Stateless auth with access/refresh token pairs and automatic rotation |
| RBAC | Role-based access control with permission inheritance and wildcards |
| 2FA/TOTP | Two-factor authentication compatible with Google Authenticator, Authy, 1Password |
| OAuth2 | Social login with Google, GitHub (easily extensible for more providers) |
| API Keys | Scoped API keys for machine-to-machine authentication |
| Rate Limiting | Brute force protection with Redis, cache, or in-memory backends |
| Token Revocation | Blacklist tokens instantly with Redis or database storage |
| Policy-Based Auth | Laravel-style policies for fine-grained authorization |
| Event System | PSR-14 compatible events for audit logging and integrations |
| Custom Exceptions | Rich exception hierarchy with context for better error handling |
๐ Requirements
- PHP 8.4 or higher
- firebase/php-jwt ^6.10
- PSR-7 HTTP Message implementation (e.g.,
nyholm/psr7) - PSR-15 HTTP Server Middleware support
- Optional: Redis extension for production rate limiting/token storage
๐ฆ Installation
composer require monkeyscloud/monkeyslegion-auth
๐ Quick Start
1. Basic Authentication Setup
<?php use MonkeysLegion\Auth\Service\AuthService; use MonkeysLegion\Auth\Service\JwtService; use MonkeysLegion\Auth\Service\PasswordHasher; // Initialize services $jwt = new JwtService( secret: $_ENV['JWT_SECRET'], // Min 32 characters accessTtl: 1800, // 30 minutes refreshTtl: 604800, // 7 days issuer: 'your-app', // Optional ); $auth = new AuthService( users: $userProvider, // Your UserProviderInterface implementation hasher: new PasswordHasher(), jwt: $jwt, tokenStorage: $redisTokenStorage, // Optional: for token blacklisting rateLimiter: $rateLimiter, // Optional: for brute force protection );
2. User Login
try { $result = $auth->login($email, $password, $request->ip()); if ($result->requires2FA) { // Store challenge token in session, show 2FA form return response()->json([ 'requires_2fa' => true, 'challenge' => $result->challengeToken, ]); } // Success! Return tokens to client return response()->json([ 'access_token' => $result->tokens->accessToken, 'refresh_token' => $result->tokens->refreshToken, 'expires_at' => $result->tokens->accessExpiresAt, ]); } catch (InvalidCredentialsException $e) { return response()->json(['error' => 'Invalid credentials'], 401); } catch (AccountLockedException $e) { return response()->json([ 'error' => 'Account locked', 'retry_after' => $e->getLockedUntil() - time(), ], 423); }
3. Token Refresh
try { $tokens = $auth->refresh($refreshToken); return response()->json([ 'access_token' => $tokens->accessToken, 'refresh_token' => $tokens->refreshToken, // Rotated! 'expires_at' => $tokens->accessExpiresAt, ]); } catch (TokenRevokedException $e) { return response()->json(['error' => 'Session expired'], 401); }
4. Logout
// Single device $auth->logout($accessToken); // All devices (invalidates all tokens) $auth->logout($accessToken, allDevices: true);
๐ค User Entity Setup
Implement the required interfaces using the provided traits:
<?php use MonkeysLegion\Auth\Contract\AuthenticatableInterface; use MonkeysLegion\Auth\Contract\HasRolesInterface; use MonkeysLegion\Auth\Contract\HasPermissionsInterface; use MonkeysLegion\Auth\Trait\AuthenticatableTrait; use MonkeysLegion\Auth\Trait\HasRolesTrait; use MonkeysLegion\Auth\Trait\HasPermissionsTrait; class User implements AuthenticatableInterface, HasRolesInterface, HasPermissionsInterface { use AuthenticatableTrait; use HasRolesTrait; use HasPermissionsTrait; public function __construct( public readonly int $id, public string $email, public string $passwordHash, public int $tokenVersion = 1, public bool $emailVerified = false, public ?string $twoFactorSecret = null, public array $roles = [], public array $permissions = [], ) {} // Required by AuthenticatableInterface public function getAuthIdentifier(): int|string { return $this->id; } public function getAuthPassword(): string { return $this->passwordHash; } public function getTokenVersion(): int { return $this->tokenVersion; } }
๐ก๏ธ Middleware
Authentication Middleware
Validates JWT tokens and attaches user to request:
use MonkeysLegion\Auth\Middleware\AuthenticationMiddleware; $middleware = new AuthenticationMiddleware( auth: $authService, users: $userProvider, publicPaths: [ '/auth/*', // Wildcard matching '/public/*', '/health', // Exact match '/api/*/public', // Glob patterns ], ); // In your middleware stack $app->pipe($middleware);
Authorization Middleware
Enforces #[RequiresRole], #[RequiresPermission], and #[Can] attributes:
use MonkeysLegion\Auth\Middleware\AuthorizationMiddleware; $middleware = new AuthorizationMiddleware( authorization: $authorizationService, permissions: $permissionChecker, publicPaths: ['/auth/*'], );
Rate Limit Middleware
use MonkeysLegion\Auth\Middleware\RateLimitMiddleware; $middleware = new RateLimitMiddleware( limiter: $rateLimiter, defaultMaxAttempts: 60, defaultDecaySeconds: 60, );
๐ท๏ธ PHP Attributes
Secure your controllers with declarative attributes:
<?php use MonkeysLegion\Auth\Attribute\Authenticated; use MonkeysLegion\Auth\Attribute\RequiresRole; use MonkeysLegion\Auth\Attribute\RequiresPermission; use MonkeysLegion\Auth\Attribute\Can; #[Authenticated] // All methods require authentication class PostController { // Anyone authenticated can list public function index(): Response { return $this->posts->paginate(); } #[RequiresPermission('posts.create')] public function create(Request $request): Response { // Only users with posts.create permission } #[Can('update', Post::class)] // Policy-based public function update(Post $post, Request $request): Response { // Checked against PostPolicy::update() } #[RequiresRole('admin', 'moderator')] // Any of these roles public function delete(Post $post): Response { // Only admins or moderators } }
๐ RBAC (Role-Based Access Control)
Define Roles
use MonkeysLegion\Auth\RBAC\RoleRegistry; use MonkeysLegion\Auth\RBAC\PermissionChecker; $roles = new RoleRegistry(); $roles->registerFromConfig([ 'super-admin' => [ 'permissions' => ['*'], // Full access 'description' => 'Complete system control', ], 'admin' => [ 'permissions' => ['users.*', 'posts.*', 'settings.view'], 'description' => 'Administrative access', ], 'editor' => [ 'permissions' => ['posts.*', 'media.*'], 'inherits' => ['viewer'], // Inheritance! ], 'author' => [ 'permissions' => ['posts.create', 'posts.edit-own', 'posts.delete-own'], 'inherits' => ['viewer'], ], 'viewer' => [ 'permissions' => ['posts.view', 'media.view'], ], ]); $checker = new PermissionChecker($roles);
Check Permissions
// Single permission if ($checker->can($user, 'posts.create')) { // Allowed } // Wildcard matching: 'posts.*' grants 'posts.anything' if ($checker->can($user, 'posts.publish')) { // Allowed for users with 'posts.*' } // Check role if ($checker->hasRole($user, 'admin')) { // User is admin } // Any of multiple roles if ($checker->hasAnyRole($user, ['admin', 'editor'])) { // User has at least one } // All permissions required if ($checker->hasAllPermissions($user, ['posts.edit', 'posts.publish'])) { // User has both }
๐ Two-Factor Authentication (2FA)
Setup 2FA for User
use MonkeysLegion\Auth\TwoFactor\TotpProvider; use MonkeysLegion\Auth\Service\TwoFactorService; $totp = new TotpProvider(); $twoFactor = new TwoFactorService($totp, issuer: 'YourApp'); // Step 1: Generate setup data $setup = $twoFactor->generateSetup($user->email); return response()->json([ 'secret' => $setup['secret'], // For manual entry 'qr_code' => $setup['qr_code'], // Base64 QR image 'provisioning_uri' => $setup['uri'], // otpauth:// URI 'recovery_codes' => $setup['recovery'], // Save these! ]);
Enable 2FA (Verify First Code)
// Step 2: User scans QR and enters code try { $twoFactor->enable( secret: $setup['secret'], code: $request->input('code'), userId: $user->id, ); return response()->json(['message' => '2FA enabled']); } catch (TwoFactorInvalidException $e) { return response()->json(['error' => 'Invalid code'], 400); }
Login with 2FA
// After password verification, if 2FA required: $result = $auth->login($email, $password); if ($result->requires2FA) { // Store challenge token, show 2FA form $_SESSION['2fa_challenge'] = $result->challengeToken; return view('auth.2fa'); } // Later, verify 2FA code: $result = $auth->verify2FA( challengeToken: $_SESSION['2fa_challenge'], code: $request->input('code'), ); // Success! $result->tokens contains JWT tokens
Recovery Codes
// Use recovery code instead of TOTP $valid = $twoFactor->verifyRecoveryCode($user->id, $recoveryCode); if ($valid) { // Code is consumed (one-time use) // Proceed with login } // Regenerate recovery codes $newCodes = $twoFactor->regenerateRecoveryCodes($user->id);
๐ OAuth2 / Social Login
Setup Providers
use MonkeysLegion\Auth\OAuth\OAuthService; use MonkeysLegion\Auth\OAuth\GoogleProvider; use MonkeysLegion\Auth\OAuth\GitHubProvider; $oauth = new OAuthService(); $oauth->register(new GoogleProvider( clientId: $_ENV['GOOGLE_CLIENT_ID'], clientSecret: $_ENV['GOOGLE_CLIENT_SECRET'], redirectUri: 'https://yourapp.com/auth/google/callback', )); $oauth->register(new GitHubProvider( clientId: $_ENV['GITHUB_CLIENT_ID'], clientSecret: $_ENV['GITHUB_CLIENT_SECRET'], redirectUri: 'https://yourapp.com/auth/github/callback', ));
Redirect to Provider
// Generate state for CSRF protection $state = $oauth->generateState(); $_SESSION['oauth_state'] = $state; // Get authorization URL $url = $oauth->getAuthorizationUrl('google', $state, [ 'additional_scope', // Optional extra scopes ]); return redirect($url);
Handle Callback
// Verify state if ($request->get('state') !== $_SESSION['oauth_state']) { throw new InvalidStateException(); } // Exchange code for user info $oauthUser = $oauth->handleCallback('google', $request->get('code')); // $oauthUser contains: // - providerId: string (provider's user ID) // - email: string // - name: ?string // - avatar: ?string // Find or create user $user = $userRepository->findByEmail($oauthUser->email) ?? $userRepository->createFromOAuth($oauthUser); // Issue tokens $tokens = $auth->issueTokenPair($user);
Add Custom Provider
use MonkeysLegion\Auth\OAuth\AbstractOAuthProvider; class MicrosoftProvider extends AbstractOAuthProvider { public function getName(): string { return 'microsoft'; } protected function getAuthorizationEndpoint(): string { return 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize'; } protected function getTokenEndpoint(): string { return 'https://login.microsoftonline.com/common/oauth2/v2.0/token'; } protected function getUserInfoEndpoint(): string { return 'https://graph.microsoft.com/v1.0/me'; } protected function getDefaultScopes(): array { return ['openid', 'email', 'profile']; } protected function parseUserInfo(array $data): array { return [ 'id' => $data['id'], 'email' => $data['mail'] ?? $data['userPrincipalName'], 'name' => $data['displayName'], 'avatar' => null, ]; } }
๐ API Keys
For machine-to-machine authentication:
Create API Key
use MonkeysLegion\Auth\ApiKey\ApiKeyService; $apiKeys = new ApiKeyService($apiKeyRepository); $result = $apiKeys->create( userId: $user->id, name: 'Production Server', scopes: ['read:users', 'write:posts'], // Or ['*'] for full access expiresAt: new DateTime('+1 year'), // Optional ); // โ ๏ธ Show key ONCE - it cannot be retrieved later! return response()->json([ 'key' => $result['key'], // ml_abc123def456_secretpart789 'id' => $result['id'], 'name' => $result['name'], ]);
Validate API Key
// In middleware or controller $apiKey = $request->getHeaderLine('X-API-Key'); $keyData = $apiKeys->validate($apiKey); if (!$keyData) { throw new InvalidApiKeyException(); } // Check scopes if (!$apiKeys->hasScope($keyData, 'write:posts')) { throw new ForbiddenException('Insufficient scope'); } // Use $keyData['user_id'] for attribution
Manage Keys
// List user's keys $keys = $apiKeys->listForUser($user->id); // Revoke a key $apiKeys->revoke($keyId, $user->id); // Key format: ml_{keyId}_{secret} // Only keyId is stored; secret is hashed
โฑ๏ธ Rate Limiting
Available Backends
use MonkeysLegion\Auth\RateLimit\RedisRateLimiter; use MonkeysLegion\Auth\RateLimit\CacheRateLimiter; use MonkeysLegion\Auth\RateLimit\InMemoryRateLimiter; // Redis (recommended for production) $limiter = new RedisRateLimiter($redis); // PSR-16 Cache $limiter = new CacheRateLimiter($cache); // In-memory (for testing/single-server) $limiter = new InMemoryRateLimiter();
Manual Rate Limiting
$key = 'login:' . $request->ip(); if (!$limiter->attempt($key, maxAttempts: 5, decaySeconds: 900)) { $retryAfter = $limiter->availableIn($key); throw new RateLimitException( message: 'Too many login attempts', retryAfter: $retryAfter, ); } // On successful login, clear the limit $limiter->clear($key);
Per-Route Rate Limits
Configure different limits per endpoint:
$middleware = new RateLimitMiddleware( limiter: $limiter, defaultMaxAttempts: 60, defaultDecaySeconds: 60, limits: [ 'POST /auth/login' => ['max' => 5, 'decay' => 900], 'POST /auth/register' => ['max' => 3, 'decay' => 3600], 'POST /auth/forgot-password' => ['max' => 3, 'decay' => 3600], 'POST /api/*' => ['max' => 100, 'decay' => 60], ], );
๐ Policies
Fine-grained authorization for model actions:
Define a Policy
use MonkeysLegion\Auth\Policy\AbstractPolicy; class PostPolicy extends AbstractPolicy { /** * Runs before all checks. Return true/false to override, null to continue. */ public function before(?object $user, string $ability, ?object $model = null): ?bool { // Admins can do anything if ($user?->hasRole('admin')) { return true; } return null; // Continue to specific check } public function view(?object $user, Post $post): bool { // Anyone can view published posts if ($post->isPublished()) { return true; } // Only author can view drafts return $user?->id === $post->authorId; } public function create(?object $user): bool { // Any authenticated user return $user !== null; } public function update(?object $user, Post $post): bool { return $user?->id === $post->authorId; } public function delete(?object $user, Post $post): bool { return $user?->id === $post->authorId; } public function publish(?object $user, Post $post): bool { return $user?->id === $post->authorId && $user->hasPermission('posts.publish'); } }
Register and Use
use MonkeysLegion\Auth\Policy\Gate; $gate = new Gate(); $gate->policy(Post::class, PostPolicy::class); // Check authorization if ($gate->allows($user, 'update', $post)) { // Allowed } // Or throw on denied $gate->authorize($user, 'delete', $post); // Throws UnauthorizedException // Define inline abilities $gate->define('access-admin', fn(?object $user) => $user?->hasRole('admin')); if ($gate->allows($user, 'access-admin')) { // Show admin panel }
๐ก Events
All events extend AuthEvent and are dispatched via PSR-14:
| Event | When Fired | Key Properties |
|---|---|---|
UserRegistered |
New user created | user, ipAddress |
LoginSucceeded |
Successful login | user, ipAddress, userAgent |
LoginFailed |
Failed login | identifier, reason, ipAddress |
Logout |
User logged out | userId, allDevices |
TokenRefreshed |
Token refreshed | userId, ipAddress |
PasswordChanged |
Password updated | userId |
PasswordResetRequested |
Reset requested | userId, email |
TwoFactorEnabled |
2FA turned on | userId |
TwoFactorDisabled |
2FA turned off | userId |
Listen to Events
// Using PSR-14 dispatcher $dispatcher->listen(LoginFailed::class, function (LoginFailed $event) { Log::warning('Failed login attempt', [ 'email' => $event->identifier, 'ip' => $event->ipAddress, 'reason' => $event->reason, 'time' => $event->occurredAt->format('c'), ]); // Alert on suspicious activity if ($this->isSuspicious($event)) { $this->alertSecurityTeam($event); } }); $dispatcher->listen(LoginSucceeded::class, function (LoginSucceeded $event) { // Update last login timestamp $this->users->updateLastLogin($event->user->id, $event->occurredAt); // Send notification for new device if ($this->isNewDevice($event)) { $this->notifyUser($event->user, 'New device login detected'); } });
โ Exception Hierarchy
All exceptions provide rich context for error handling:
AuthException (401)
โโโ InvalidCredentialsException (401)
โโโ TokenExpiredException (401)
โโโ TokenInvalidException (401)
โโโ TokenRevokedException (401)
โโโ TwoFactorInvalidException (401)
โโโ InvalidApiKeyException (401)
โโโ UnauthorizedException (403)
โโโ ForbiddenException (403)
โโโ EmailNotVerifiedException (403)
โโโ TwoFactorRequiredException (428)
โโโ AccountLockedException (423)
โโโ RateLimitException (429)
โโโ UserAlreadyExistsException (409)
โโโ PolicyNotFoundException (500)
Error Handling
try { $result = $auth->login($email, $password); } catch (AuthException $e) { return response()->json( $e->toArray(), // Structured error response $e->getCode(), ); } // toArray() returns: // [ // 'error' => true, // 'type' => 'InvalidCredentialsException', // 'message' => 'Invalid credentials', // 'code' => 401, // 'context' => [...], // ]
๐๏ธ Database Schema
Required Tables
-- Users (extend as needed) CREATE TABLE users ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, token_version INT UNSIGNED DEFAULT 1, email_verified_at TIMESTAMP NULL, two_factor_secret VARCHAR(255) NULL, two_factor_recovery_codes JSON NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); -- Roles CREATE TABLE roles ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL UNIQUE, description VARCHAR(255) NULL, permissions JSON NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- User Roles (many-to-many) CREATE TABLE user_roles ( user_id BIGINT UNSIGNED NOT NULL, role_id BIGINT UNSIGNED NOT NULL, PRIMARY KEY (user_id, role_id), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE ); -- API Keys CREATE TABLE api_keys ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id BIGINT UNSIGNED NOT NULL, name VARCHAR(255) NOT NULL, key_id VARCHAR(32) NOT NULL UNIQUE, key_hash VARCHAR(255) NOT NULL, scopes JSON NOT NULL, last_used_at TIMESTAMP NULL, expires_at TIMESTAMP NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_key_id (key_id) ); -- OAuth Accounts CREATE TABLE oauth_accounts ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id BIGINT UNSIGNED NOT NULL, provider VARCHAR(50) NOT NULL, provider_user_id VARCHAR(255) NOT NULL, access_token TEXT NULL, refresh_token TEXT NULL, expires_at TIMESTAMP NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, UNIQUE INDEX idx_provider_user (provider, provider_user_id) ); -- Token Blacklist (if not using Redis) CREATE TABLE token_blacklist ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, token_id VARCHAR(64) NOT NULL UNIQUE, expires_at TIMESTAMP NOT NULL, INDEX idx_expires (expires_at) ); -- Password Resets CREATE TABLE password_resets ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id BIGINT UNSIGNED NOT NULL, token_hash VARCHAR(255) NOT NULL, expires_at TIMESTAMP NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_expires (expires_at) );
๐งช Testing
# Install dependencies composer install # Run all tests composer test # Run specific test suites composer test:unit composer test:integration # Generate coverage report composer test:coverage # Static analysis composer phpstan # Code style check composer cs composer cs-fix # Auto-fix
Test Fixtures
The package includes test doubles for easy testing:
use MonkeysLegion\Auth\Tests\Fixtures\FakeUser; use MonkeysLegion\Auth\Tests\Fixtures\FakeUserProvider; use MonkeysLegion\Auth\Tests\Fixtures\FakeTokenStorage; use MonkeysLegion\Auth\Tests\Fixtures\FakeRequest; // In your tests $users = new FakeUserProvider(); $users->addUser(new FakeUser( id: 1, email: 'test@example.com', roles: ['admin'], )); $auth = new AuthService( users: $users, hasher: new PasswordHasher(), jwt: new JwtService('test-secret-32-characters-long'), tokenStorage: new FakeTokenStorage(), );
๐ Security Best Practices
- Use strong JWT secrets โ Minimum 256 bits (32+ characters) of cryptographic randomness
- Keep access tokens short-lived โ 15-30 minutes recommended
- Always rotate refresh tokens โ Blacklist old tokens on refresh
- Enable rate limiting โ Especially on authentication endpoints
- Require 2FA for privileged accounts โ Admins, financial access, etc.
- Validate token versions โ Increment on password change/security events
- Store only hashed secrets โ API keys, recovery codes, etc.
- Use HTTPS exclusively โ Never transmit tokens over HTTP
- Implement proper CORS โ Restrict token usage to your domains
- Monitor authentication events โ Log and alert on suspicious activity
๐ License
MIT License โ see LICENSE for details.
๐ค Contributing
Contributions are welcome! Please read our contributing guidelines and submit pull requests to the main branch.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Write tests for your changes
- Ensure all tests pass (
composer check) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
Built with โค๏ธ by MonkeysLegion