207 lines
4.7 KiB
PHP
207 lines
4.7 KiB
PHP
<?php
|
|
|
|
namespace Web;
|
|
|
|
require_once __DIR__ . '/Auth.php';
|
|
|
|
/**
|
|
* AuthMiddleware provides authentication checks for routes
|
|
*/
|
|
class AuthMiddleware
|
|
{
|
|
private Auth $auth;
|
|
|
|
public function __construct(Auth $auth)
|
|
{
|
|
$this->auth = $auth;
|
|
}
|
|
|
|
/**
|
|
* Require authentication
|
|
*/
|
|
public function requireAuth(): callable
|
|
{
|
|
return function(Context $context, callable $next) {
|
|
if ($this->auth->guest()) {
|
|
// Check if request expects JSON
|
|
if ($context->request->expectsJson()) {
|
|
$context->json(['error' => 'Unauthenticated'], 401);
|
|
return;
|
|
}
|
|
|
|
// Redirect to login page
|
|
$context->redirect('/login');
|
|
return;
|
|
}
|
|
|
|
// Add user to context
|
|
$context->set('user', $this->auth->user());
|
|
$next();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Require guest (not authenticated)
|
|
*/
|
|
public function requireGuest(): callable
|
|
{
|
|
return function(Context $context, callable $next) {
|
|
if ($this->auth->check()) {
|
|
// Check if request expects JSON
|
|
if ($context->request->expectsJson()) {
|
|
$context->json(['error' => 'Already authenticated'], 403);
|
|
return;
|
|
}
|
|
|
|
// Redirect to home or dashboard
|
|
$context->redirect('/');
|
|
return;
|
|
}
|
|
|
|
$next();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Optional authentication (sets user if authenticated)
|
|
*/
|
|
public function optional(): callable
|
|
{
|
|
return function(Context $context, callable $next) {
|
|
if ($this->auth->check()) $context->set('user', $this->auth->user());
|
|
$next();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if user has specific role
|
|
*/
|
|
public function requireRole(string|array $roles): callable
|
|
{
|
|
$roles = is_array($roles) ? $roles : [$roles];
|
|
|
|
return function(Context $context, callable $next) use ($roles) {
|
|
if ($this->auth->guest()) {
|
|
if ($context->request->expectsJson()) {
|
|
$context->json(['error' => 'Unauthenticated'], 401);
|
|
return;
|
|
}
|
|
$context->redirect('/login');
|
|
return;
|
|
}
|
|
|
|
$user = $this->auth->user();
|
|
$userRole = $user->role;
|
|
|
|
if (!in_array($userRole, $roles)) {
|
|
if ($context->request->expectsJson()) {
|
|
$context->json(['error' => 'Insufficient permissions'], 403);
|
|
return;
|
|
}
|
|
$context->error(403, 'Forbidden');
|
|
return;
|
|
}
|
|
|
|
$context->set('user', $user);
|
|
$next();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Rate limiting per user
|
|
*/
|
|
public function rateLimit(int $maxAttempts = 60, int $decayMinutes = 1): callable
|
|
{
|
|
return function(Context $context, callable $next) use ($maxAttempts, $decayMinutes) {
|
|
if ($this->auth->guest()) {
|
|
$identifier = $context->request->ip();
|
|
} else {
|
|
$identifier = 'user:' . $this->auth->id();
|
|
}
|
|
|
|
$key = 'rate_limit:' . $identifier . ':' . $context->request->path;
|
|
$attempts = $context->session->get($key, 0);
|
|
$resetTime = $context->session->get($key . ':reset', 0);
|
|
|
|
// Reset counter if decay time has passed
|
|
if (time() > $resetTime) {
|
|
$attempts = 0;
|
|
$context->session->set($key . ':reset', time() + ($decayMinutes * 60));
|
|
}
|
|
|
|
if ($attempts >= $maxAttempts) {
|
|
$retryAfter = $resetTime - time();
|
|
$context->response->header('X-RateLimit-Limit', (string)$maxAttempts);
|
|
$context->response->header('X-RateLimit-Remaining', '0');
|
|
$context->response->header('Retry-After', (string)$retryAfter);
|
|
|
|
if ($context->request->expectsJson()) {
|
|
$context->json([
|
|
'error' => 'Too many requests',
|
|
'retry_after' => $retryAfter
|
|
], 429);
|
|
return;
|
|
}
|
|
|
|
$context->error(429, 'Too Many Requests');
|
|
return;
|
|
}
|
|
|
|
// Increment attempts
|
|
$context->session->set($key, $attempts + 1);
|
|
|
|
// Add rate limit headers
|
|
$context->response->header('X-RateLimit-Limit', (string)$maxAttempts);
|
|
$context->response->header('X-RateLimit-Remaining', (string)($maxAttempts - $attempts - 1));
|
|
|
|
$next();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* CSRF protection
|
|
*/
|
|
public function verifyCsrf(): callable
|
|
{
|
|
return function(Context $context, callable $next) {
|
|
// Skip CSRF for safe methods
|
|
if (in_array($context->request->method, ['GET', 'HEAD', 'OPTIONS'])) {
|
|
$next();
|
|
return;
|
|
}
|
|
|
|
$token = $context->request->input('_token')
|
|
?? $context->request->header('X-CSRF-TOKEN')
|
|
?? $context->request->header('X-XSRF-TOKEN');
|
|
|
|
if (!$context->session->validateCsrf($token)) {
|
|
if ($context->request->expectsJson()) {
|
|
$context->json(['error' => 'CSRF token mismatch'], 419);
|
|
return;
|
|
}
|
|
$context->error(419, 'CSRF token mismatch');
|
|
return;
|
|
}
|
|
|
|
$next();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Remember user from cookie
|
|
*/
|
|
public function remember(): callable
|
|
{
|
|
return function(Context $context, callable $next) {
|
|
// Auth class already handles remember cookies in constructor
|
|
// This middleware can be used to refresh the remember token if needed
|
|
|
|
if ($this->auth->check()) {
|
|
$context->set('user', $this->auth->user());
|
|
}
|
|
|
|
$next();
|
|
};
|
|
}
|
|
}
|