Fix VS Code extension storage paths for new directory structure Fix jqhtml compiled files missing from bundle Fix bundle babel transformation and add rsxrealpath() function 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
171 lines
5.6 KiB
PHP
Executable File
171 lines
5.6 KiB
PHP
Executable File
<?php
|
|
|
|
namespace App\Http\Middleware;
|
|
|
|
use Closure;
|
|
use Illuminate\Http\Request;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
class Gatekeeper
|
|
{
|
|
/**
|
|
* Handle an incoming request.
|
|
*
|
|
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
|
*/
|
|
public function handle(Request $request, Closure $next): Response
|
|
{
|
|
// Check if gatekeeper is enabled
|
|
if (!config('rsx.gatekeeper.enabled')) {
|
|
return $next($request);
|
|
}
|
|
|
|
// Always allow CLI requests
|
|
if (php_sapi_name() === 'cli') {
|
|
return $next($request);
|
|
}
|
|
|
|
// Check if request is whitelisted (localhost without reverse proxy headers)
|
|
if ($this->is_whitelisted($request)) {
|
|
return $next($request);
|
|
}
|
|
|
|
// Check if user has valid authentication cookie
|
|
$cookie_name = config('rsx.gatekeeper.cookie_name', 'gatekeeper_auth');
|
|
$password = config('rsx.gatekeeper.password');
|
|
|
|
if (!$password) {
|
|
throw new \Exception('Gatekeeper enabled but no password configured. Set GATEKEEPER_PASSWORD in .env');
|
|
}
|
|
|
|
$cookie_value = $request->cookie($cookie_name);
|
|
$expected_hash = hash('sha256', $password);
|
|
|
|
// If authenticated, renew cookie and continue
|
|
if ($cookie_value === $expected_hash) {
|
|
$lifetime_hours = config('rsx.gatekeeper.cookie_lifetime_hours', 12);
|
|
$cookie = cookie($cookie_name, $expected_hash, 60 * $lifetime_hours);
|
|
|
|
$response = $next($request);
|
|
|
|
// Only add cookie to regular responses, not binary file responses
|
|
if (method_exists($response, 'withCookie')) {
|
|
return $response->withCookie($cookie);
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
|
|
// Handle login POST request
|
|
if ($request->isMethod('POST') && $request->path() === '_gatekeeper/login') {
|
|
return $this->handle_login($request);
|
|
}
|
|
|
|
// Show login page
|
|
return $this->show_login_page($request);
|
|
}
|
|
|
|
/**
|
|
* Check if the request is whitelisted (localhost without reverse proxy headers)
|
|
*/
|
|
private function is_whitelisted(Request $request): bool
|
|
{
|
|
// Get the client IP
|
|
$ip = $request->ip();
|
|
|
|
// List of localhost IPs
|
|
$localhost_ips = [
|
|
'127.0.0.1',
|
|
'localhost',
|
|
'::1',
|
|
'0.0.0.0',
|
|
];
|
|
|
|
// Check if IP matches localhost patterns
|
|
$is_localhost = in_array($ip, $localhost_ips) ||
|
|
str_starts_with($ip, '127.') ||
|
|
$ip === '::1';
|
|
|
|
if (!$is_localhost) {
|
|
return false;
|
|
}
|
|
|
|
// Check for reverse proxy headers - if present, this is NOT a true localhost request
|
|
$proxy_headers = [
|
|
'HTTP_X_FORWARDED_FOR',
|
|
'HTTP_X_FORWARDED_HOST',
|
|
'HTTP_X_FORWARDED_PORT',
|
|
'HTTP_X_FORWARDED_PROTO',
|
|
'HTTP_X_FORWARDED_SERVER',
|
|
'HTTP_X_REAL_IP',
|
|
'HTTP_X_ORIGINAL_URL',
|
|
'HTTP_FORWARDED',
|
|
'HTTP_CLIENT_IP',
|
|
'HTTP_CF_CONNECTING_IP', // Cloudflare
|
|
'HTTP_TRUE_CLIENT_IP', // Cloudflare Enterprise
|
|
'HTTP_X_CLUSTER_CLIENT_IP',
|
|
'HTTP_X_FORWARDED',
|
|
'HTTP_FORWARDED_FOR',
|
|
'HTTP_VIA',
|
|
];
|
|
|
|
foreach ($proxy_headers as $header) {
|
|
if (!empty($_SERVER[$header])) {
|
|
// Reverse proxy header detected - force authentication
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Also check Laravel's request headers
|
|
if ($request->headers->has('X-Forwarded-For') ||
|
|
$request->headers->has('X-Forwarded-Host') ||
|
|
$request->headers->has('X-Forwarded-Proto') ||
|
|
$request->headers->has('X-Real-IP') ||
|
|
$request->headers->has('Forwarded')) {
|
|
return false;
|
|
}
|
|
|
|
// True localhost request without proxy headers
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Handle login POST request
|
|
*/
|
|
private function handle_login(Request $request): Response
|
|
{
|
|
$password = config('rsx.gatekeeper.password');
|
|
$submitted = $request->input('password');
|
|
|
|
if ($submitted === $password) {
|
|
// Authentication successful
|
|
$cookie_name = config('rsx.gatekeeper.cookie_name', 'gatekeeper_auth');
|
|
$lifetime_hours = config('rsx.gatekeeper.cookie_lifetime_hours', 12);
|
|
$cookie_value = hash('sha256', $password);
|
|
$cookie = cookie($cookie_name, $cookie_value, 60 * $lifetime_hours);
|
|
|
|
// Redirect to originally requested URL or home
|
|
$redirect = $request->input('redirect', '/');
|
|
return redirect($redirect)->withCookie($cookie);
|
|
}
|
|
|
|
// Authentication failed - show login page with error
|
|
return $this->show_login_page($request, 'Invalid password. Please try again.');
|
|
}
|
|
|
|
/**
|
|
* Show the gatekeeper login page
|
|
*/
|
|
private function show_login_page(Request $request, string $error = null): Response
|
|
{
|
|
$data = [
|
|
'title' => config('rsx.gatekeeper.title', 'Development Preview'),
|
|
'subtitle' => config('rsx.gatekeeper.subtitle', 'This is a restricted development preview site. Please enter the access password to continue.'),
|
|
'logo' => config('rsx.gatekeeper.logo'),
|
|
'error' => $error,
|
|
'redirect' => $request->fullUrl(),
|
|
];
|
|
|
|
return response()->view('gatekeeper.login', $data, 403);
|
|
}
|
|
} |