update login scurity

This commit is contained in:
root
2026-05-08 00:17:42 -04:00
parent b01a4496e6
commit 76866ffe1e
17 changed files with 342 additions and 62 deletions
+6 -1
View File
@@ -24,6 +24,8 @@ class Filters extends BaseConfig
'honeypot' => Honeypot::class,
'invalidchars' => InvalidChars::class,
'secureheaders' => SecureHeaders::class,
'sanitizeinput' => \App\Filters\SanitizeInputFilter::class,
'apiratelimit' => \App\Filters\ApiRateLimitFilter::class,
'auth' => \App\Filters\AuthFilter::class, // Define the alias for your auth filter
'apiAuth' => \App\Filters\ApiAuthFilter::class, // JWT-based API authentication
'cleanupScheduler' => \App\Filters\CleanupScheduler::class,
@@ -42,6 +44,8 @@ class Filters extends BaseConfig
public array $globals = [
'before' => [
'timezone',
'sanitizeinput',
'invalidchars',
'csrf' => ['except' => [
// Webhooks / integrations
'api/paypal-webhook',
@@ -80,6 +84,7 @@ class Filters extends BaseConfig
]],
],
'after' => [
'secureheaders',
'toolbar',
'cleanupScheduler' => ['except' => ['cleanup/*']],
],
@@ -112,7 +117,7 @@ class Filters extends BaseConfig
* @var array<string, array<string, list<string>>>
*/
public array $filters = [
'apiratelimit' => ['before' => ['api/*', 'index.php/api/*']],
];
}
+49 -29
View File
@@ -5,7 +5,6 @@ namespace App\Controllers;
use App\Models\LoginActivityModel;
use App\Models\UserModel;
use App\Models\UserRoleModel;
use CodeIgniter\Controller;
use CodeIgniter\Events\Events;
use App\Models\IpAttemptModel;
use App\Models\PasswordResetModel;
@@ -19,7 +18,7 @@ require_once APPPATH . 'Helpers/pbkdf2_helper.php';
require_once APPPATH . 'Helpers/jwt_helper.php';
class AuthController extends Controller
class AuthController extends BaseController
{
protected $configModel;
protected $userModel;
@@ -83,9 +82,23 @@ class AuthController extends Controller
log_message('info', 'Processing login form submission.');
$redirectTo = $this->sanitizeRedirectTarget((string) ($this->request->getPost('redirect_to') ?? $this->request->getGet('redirect_to') ?? ''));
$validator = \Config\Services::validation();
$requestData = sanitize_request_value($this->request->getPost(['email', 'password']));
$validator->setRules([
'email' => 'required|valid_email|max_length[254]',
'password' => 'required|max_length[255]',
]);
if (! $validator->run($requestData)) {
return redirect()
->back()
->with('error', 'Please enter a valid email and password.')
->withInput();
}
// Step 1: Get email, password, and IP from the request
$email = $this->request->getPost('email');
$password = $this->request->getPost('password');
$email = strtolower((string) $requestData['email']);
$password = (string) $requestData['password'];
$ip = $this->request->getIPAddress();
log_message('info', 'Login attempt from IP: ' . $ip . ' for email: ' . $email);
@@ -139,26 +152,29 @@ class AuthController extends Controller
// JSON API: POST /api/login
public function apiLogin()
{
$requestData = $this->request->getJSON(true);
if (!$requestData) {
// fallback to form vars
$requestData = [
'email' => $this->request->getPost('email'),
'password' => $this->request->getPost('password'),
];
}
$requestData = sanitize_request_value($this->request->getJSON(true) ?: [
'email' => $this->request->getPost('email'),
'password' => $this->request->getPost('password'),
]);
$email = $requestData['email'] ?? '';
$password = $requestData['password'] ?? '';
$ip = $this->request->getIPAddress();
$validation = \Config\Services::validation();
$validation->setRules([
'email' => 'required|valid_email|max_length[254]',
'password' => 'required|max_length[255]',
]);
if (!$email || !$password) {
return $this->response->setStatusCode(400)->setJSON([
if (! $validation->run($requestData)) {
return $this->response->setStatusCode(422)->setJSON([
'status' => false,
'message' => 'Email and password are required.'
'message' => 'Validation failed.',
'errors' => $validation->getErrors(),
]);
}
$email = strtolower((string) ($requestData['email'] ?? ''));
$password = (string) ($requestData['password'] ?? '');
$ip = $this->request->getIPAddress();
if ($this->isIpBlocked($ip)) {
return $this->response->setStatusCode(429)->setJSON([
'status' => false,
@@ -220,7 +236,15 @@ class AuthController extends Controller
'exp' => $exp,
];
$secret = env('JWT_SECRET', 'change-me-in-env');
try {
$secret = require_env('JWT_SECRET');
} catch (\RuntimeException $e) {
return $this->response->setStatusCode(500)->setJSON([
'status' => false,
'message' => 'JWT configuration is missing.',
]);
}
$token = jwt_encode($payload, $secret, 'HS256');
return $this->response->setJSON([
@@ -240,18 +264,14 @@ class AuthController extends Controller
*/
public function apiRegister()
{
$requestData = $this->request->getJSON(true);
if (!$requestData) {
// fallback to form vars
$requestData = $this->request->getPost();
}
$requestData = sanitize_request_value($this->request->getJSON(true) ?: $this->request->getPost());
// Basic validation
$rules = [
'firstname' => 'required|min_length[2]|max_length[30]',
'lastname' => 'required|min_length[2]|max_length[30]',
'email' => 'required|valid_email|is_unique[users.email]',
'password' => 'required|min_length[8]',
'firstname' => "required|regex_match[/^[\\p{L}\\s'\\-]+$/u]|min_length[2]|max_length[30]",
'lastname' => "required|regex_match[/^[\\p{L}\\s'\\-]+$/u]|min_length[2]|max_length[30]",
'email' => 'required|valid_email|max_length[254]|is_unique[users.email]',
'password' => 'required|min_length[8]|max_length[255]',
'cellphone' => 'required|min_length[10]|max_length[20]',
];
@@ -332,7 +352,7 @@ class AuthController extends Controller
'exp' => $exp,
];
$secret = env('JWT_SECRET', 'change-me-in-env');
$secret = require_env('JWT_SECRET');
$token = jwt_encode($payload, $secret, 'HS256');
return $this->response->setStatusCode(201)->setJSON([
+17 -2
View File
@@ -36,7 +36,7 @@ abstract class BaseController extends Controller
*
* @var list<string>
*/
protected $helpers = [];
protected $helpers = ['security'];
/** @var ApiClient */
protected ApiClient $api;
@@ -73,6 +73,21 @@ abstract class BaseController extends Controller
// Assuming the user role is stored in the session
return session()->get('role');
}
protected function validated(array $rules, ?array $data = null): array
{
$validation = service('validation');
$payload = $data ?? ($this->request->getJSON(true) ?: $this->request->getPost());
$payload = sanitize_request_value($payload);
$validation->setRules($rules);
if (! $validation->run($payload)) {
throw new \InvalidArgumentException(json_encode($validation->getErrors(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: 'Validation failed');
}
return $payload;
}
}
?>
?>
+10 -3
View File
@@ -20,15 +20,22 @@ class ProofreadController extends ResourceController
}
// Accept form-urlencoded payload to play nicely with CSRF protection
$text = (string) ($this->request->getPost('text') ?? '');
if ($text === '' || mb_strlen($text) > 20000) {
$validation = service('validation');
$payload = sanitize_request_value($this->request->getPost(['text']));
$validation->setRules([
'text' => 'required|min_length[1]|max_length[20000]',
]);
if (! $validation->run($payload)) {
return $this->respond([
'ok' => false,
'error' => 'Invalid text (empty or too long).',
'error' => 'Invalid text payload.',
'csrfHash' => csrf_hash(),
], 422);
}
$text = (string) $payload['text'];
$client = \Config\Services::curlrequest(['timeout' => 10]);
try {
+9 -9
View File
@@ -24,10 +24,10 @@ class ContactController extends BaseController
// Define validation rules
$validation->setRules([
'name' => 'required|min_length[3]',
'email' => 'required|valid_email',
'subject' => 'required|min_length[3]',
'message' => 'required|min_length[10]'
'name' => "required|regex_match[/^[\\p{L}\\s'\\-]+$/u]|min_length[3]|max_length[100]",
'email' => 'required|valid_email|max_length[254]',
'subject' => 'required|min_length[3]|max_length[150]',
'message' => 'required|min_length[10]|max_length[5000]'
]);
if (!$validation->withRequest($this->request)->run()) {
@@ -37,10 +37,10 @@ class ContactController extends BaseController
}
// Process form data
$name = $this->request->getPost('name');
$email = strtolower($this->request->getPost('email'));
$subject = $this->request->getPost('subject');
$message = $this->request->getPost('message');
$name = esc((string) $this->request->getPost('name'));
$email = esc(strtolower((string) $this->request->getPost('email')));
$subject = esc((string) $this->request->getPost('subject'));
$message = nl2br(esc((string) $this->request->getPost('message')));
// Initialize the EmailController
$emailController = new \App\Controllers\View\EmailController();
@@ -63,4 +63,4 @@ class ContactController extends BaseController
return redirect()->to('/parent/contact');
}
}
}
+1 -1
View File
@@ -486,7 +486,7 @@ class EventController extends ResourceController
$chargesBuilder = $this->eventChargesModel
->select('event_charges.*,
users.firstname AS parent_firstname, users.lastname AS parent_lastname,
users.firstname AS parent_firstname, users.lastname AS parent_lastname, users.cellphone AS parent_cellphone,
students.firstname AS student_firstname, students.lastname AS student_lastname,
events.event_name, events.description AS event_description, events.amount AS event_amount')
->join('users', 'users.id = event_charges.parent_id', 'left')
+8 -1
View File
@@ -11,6 +11,8 @@ class ApiAuthFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
helper('security');
$authorization = $request->getHeaderLine('Authorization');
if (!$authorization || stripos($authorization, 'Bearer ') !== 0) {
return $this->unauthorized('Missing or invalid Authorization header');
@@ -21,7 +23,12 @@ class ApiAuthFilter implements FilterInterface
return $this->unauthorized('Bearer token is required');
}
$secret = env('JWT_SECRET', 'change-me-in-env');
try {
$secret = require_env('JWT_SECRET');
} catch (\RuntimeException $e) {
return $this->unauthorized('JWT configuration is missing');
}
$payload = jwt_decode($token, $secret);
if (!$payload || empty($payload['sub'])) {
+3 -1
View File
@@ -13,13 +13,15 @@ class ApiDocsAuthFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
helper('security');
$authHeader = $request->getHeaderLine('Authorization');
if ($authHeader && str_starts_with($authHeader, 'Bearer ')) {
$token = trim(substr($authHeader, 7));
try {
$key = getenv('JWT_SECRET') ?: 'your_default_secret';
$key = require_env('JWT_SECRET');
$decoded = JWT::decode($token, new Key($key, 'HS256'));
if (!isset($decoded->roles) || empty($decoded->roles->admin)) {
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
class ApiRateLimitFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
$throttler = service('throttler');
$response = Services::response();
$maxRequests = isset($arguments[0]) ? max(1, (int) $arguments[0]) : 60;
$windowSeconds = isset($arguments[1]) ? max(1, (int) $arguments[1]) : MINUTE;
$userId = session()->get('user_id');
$identifier = $userId ? 'user:' . $userId : 'ip:' . $request->getIPAddress();
$route = trim($request->getUri()->getPath(), '/');
$key = 'api-rate-' . sha1($request->getMethod() . '|' . $route . '|' . $identifier);
if (! $throttler->check($key, $maxRequests, $windowSeconds)) {
return $response
->setStatusCode(429)
->setHeader('Retry-After', (string) $windowSeconds)
->setJSON([
'status' => false,
'message' => 'Too many requests. Please try again later.',
]);
}
return null;
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
return $response;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
class SanitizeInputFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
helper('security');
$sanitizedGet = sanitize_request_value($request->getGet() ?? []);
$sanitizedPost = sanitize_request_value($request->getPost() ?? []);
$request->setGlobal('get', $sanitizedGet);
$request->setGlobal('post', $sanitizedPost);
$request->setGlobal('request', array_merge(is_array($sanitizedGet) ? $sanitizedGet : [], is_array($sanitizedPost) ? $sanitizedPost : []));
$request->setGlobal('cookie', sanitize_request_value($request->getCookie() ?? []));
$contentType = strtolower($request->getHeaderLine('Content-Type'));
$body = $request->getBody();
if (str_contains($contentType, 'application/json') && trim($body) !== '') {
$decoded = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($decoded)) {
return Services::response()
->setStatusCode(400)
->setJSON([
'status' => false,
'message' => 'Malformed JSON payload.',
]);
}
$request->setBody((string) json_encode(sanitize_request_value($decoded), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
return null;
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
return $response;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
if (! function_exists('sanitize_request_value')) {
/**
* Recursively normalize request values without applying output encoding.
*/
function sanitize_request_value($value)
{
if (is_array($value)) {
foreach ($value as $key => $item) {
$value[$key] = sanitize_request_value($item);
}
return $value;
}
if (! is_string($value)) {
return $value;
}
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value) ?? $value;
return trim($value);
}
}
if (! function_exists('require_env')) {
/**
* Fetch an environment variable and fail closed when it is missing.
*/
function require_env(string $key): string
{
$value = env($key);
if ($value === null || $value === '') {
throw new RuntimeException(sprintf('Missing required environment variable: %s', $key));
}
return (string) $value;
}
}
+2 -1
View File
@@ -16,8 +16,9 @@ class StaffTimeOffLinkService
public function __construct(?string $secret = null, ?int $ttlSeconds = null)
{
helper('jwt');
helper('security');
$this->secret = $secret ?: (string)env('JWT_SECRET', 'change-me-in-env');
$this->secret = $secret ?: require_env('JWT_SECRET');
$this->ttl = ($ttlSeconds !== null && $ttlSeconds > 0) ? $ttlSeconds : self::DEFAULT_TTL;
}
@@ -290,11 +290,15 @@
);
$externalParentPhone = $charge['external_parent_phone'] ?? '';
$externalParentEmail = $charge['external_parent_email'] ?? '';
$parentPhone = $charge['parent_cellphone'] ?? '';
$standardParentName = trim($charge['parent_firstname'] . ' ' . $charge['parent_lastname']);
$parentColumnName = $externalParentLabel ?: ($standardParentName ?: '—');
$isExternalParticipant = $externalName !== '';
$infoPhone = $isExternalParticipant ? $externalParentPhone : $parentPhone;
$infoEmail = $isExternalParticipant ? $externalParentEmail : '';
$externalInfoValue = trim(implode(' ', array_filter([
$externalParentPhone,
$externalParentEmail,
$infoPhone,
$infoEmail,
$externalNote,
])));
?>
@@ -306,13 +310,13 @@
<?php endif; ?>
</td>
<td data-sort-value="<?= esc(strtolower($externalInfoValue)) ?>">
<?php if ($externalParentPhone): ?>
<div class="text-muted small"><?= esc($externalParentPhone) ?></div>
<?php if ($infoPhone): ?>
<div class="text-muted small"><?= esc($infoPhone) ?></div>
<?php endif; ?>
<?php if ($externalParentEmail): ?>
<div class="text-muted small"><?= esc($externalParentEmail) ?></div>
<?php if ($infoEmail): ?>
<div class="text-muted small"><?= esc($infoEmail) ?></div>
<?php endif; ?>
<?php if (!$externalParentPhone && !$externalParentEmail): ?>
<?php if (!$infoPhone && !$infoEmail): ?>
<span class="text-muted small">—</span>
<?php endif; ?>
</td>
+4 -2
View File
@@ -18,7 +18,8 @@
<div class="form-group mb-3">
<label for="email" class="form-label"></label>
<input type="email" class="form-control item" id="email" name="email"
placeholder="Enter your email" maxlength="50" value="<?= old('email') ?>" required>
placeholder="Enter your email" maxlength="254" autocomplete="username"
inputmode="email" value="<?= old('email') ?>" required>
<div id="email-error" class="text-danger small mt-1"></div>
</div>
@@ -30,7 +31,8 @@
id="password"
name="password"
placeholder="Enter your password"
maxlength="30"
maxlength="255"
autocomplete="current-password"
required>
<span class="position-absolute top-50 end-0 translate-middle-y me-3"
onclick="togglePasswordVisibility('password', this)"
+8 -5
View File
@@ -1,12 +1,15 @@
<?php
helper('security');
// Database configuration
$host = 'localhost';
$dbname = 'u280815660_school';
$username = 'u280815660_melabidi';
$password = '>tNxlRzP/W8';
$host = env('database.default.hostname', 'localhost');
$dbname = require_env('database.default.database');
$username = require_env('database.default.username');
$password = require_env('database.default.password');
$port = (int) env('database.default.port', 3306);
// Create connection
$conn = @new mysqli($host, $username, $password, $dbname);
$conn = @new mysqli($host, $username, $password, $dbname, $port);
// Check connection
if ($conn->connect_error) {
@@ -0,0 +1,35 @@
<?php
namespace Tests\App\Filters;
use App\Filters\ApiRateLimitFilter;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\Response;
use CodeIgniter\HTTP\URI;
use CodeIgniter\HTTP\UserAgent;
use CodeIgniter\Test\CIUnitTestCase;
use Config\App;
use Config\Services;
class ApiRateLimitFilterTest extends CIUnitTestCase
{
protected function setUp(): void
{
parent::setUp();
Services::resetSingle('throttler');
Services::resetSingle('session');
}
public function testBlocksRequestWhenThresholdIsExceeded(): void
{
$request = new IncomingRequest(config(App::class), new URI('https://example.test/api/health'), 'php://input', new UserAgent());
$filter = new ApiRateLimitFilter();
$first = $filter->before($request, [1, 60]);
$second = $filter->before($request, [1, 60]);
$this->assertNull($first);
$this->assertInstanceOf(Response::class, $second);
$this->assertSame(429, $second->getStatusCode());
}
}
@@ -0,0 +1,48 @@
<?php
namespace Tests\App\Filters;
use App\Filters\SanitizeInputFilter;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\Response;
use CodeIgniter\HTTP\URI;
use CodeIgniter\HTTP\UserAgent;
use CodeIgniter\Test\CIUnitTestCase;
use Config\App;
use Config\Services;
class SanitizeInputFilterTest extends CIUnitTestCase
{
public function testSanitizesGetPostAndJsonPayloads(): void
{
$request = new IncomingRequest(config(App::class), new URI('https://example.test/api/login'), 'php://input', new UserAgent());
$request->setHeader('Content-Type', 'application/json');
$request->setGlobal('get', ['q' => " hello\x00 "]);
$request->setGlobal('post', ['email' => " user@example.com \n"]);
$request->setGlobal('request', ['email' => " user@example.com \n"]);
$request->setGlobal('cookie', ['token' => " abc\t"]);
$request->setBody("{\"email\":\" user@example.com \",\"name\":\" Bob\\u0000 \"}");
$filter = new SanitizeInputFilter();
$result = $filter->before($request);
$this->assertNull($result);
$this->assertSame('hello', $request->getGet('q'));
$this->assertSame('user@example.com', $request->getPost('email'));
$this->assertSame('abc', $request->getCookie('token'));
$this->assertSame(['email' => 'user@example.com', 'name' => 'Bob'], $request->getJSON(true));
}
public function testRejectsMalformedJsonPayload(): void
{
$request = new IncomingRequest(config(App::class), new URI('https://example.test/api/login'), 'php://input', new UserAgent());
$request->setHeader('Content-Type', 'application/json');
$request->setBody('{invalid');
$filter = new SanitizeInputFilter();
$result = $filter->before($request);
$this->assertInstanceOf(Response::class, $result);
$this->assertSame(400, $result->getStatusCode());
}
}