diff --git a/app/Config/Auth.php b/app/Config/Auth.php index f2aa7a3..dcdad94 100644 --- a/app/Config/Auth.php +++ b/app/Config/Auth.php @@ -228,7 +228,7 @@ class Auth extends ShieldAuth 'required', 'max_length[30]', 'min_length[3]', - 'regex_match[/\A[a-zA-Z0-9\.]+\z/]', + 'regex_match[/\A[a-zA-Z0-9\._-]+\z/]', ], ]; diff --git a/app/Config/Filters.php b/app/Config/Filters.php index 5a8f94b..0e2f6f2 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -2,6 +2,7 @@ namespace Config; +use App\Filters\SecureHeadersFilter; use App\Filters\SingleActiveSessionFilter; use CodeIgniter\Config\Filters as BaseFilters; use CodeIgniter\Filters\Cors; @@ -30,7 +31,7 @@ class Filters extends BaseFilters 'toolbar' => DebugToolbar::class, 'honeypot' => Honeypot::class, 'invalidchars' => InvalidChars::class, - 'secureheaders' => SecureHeaders::class, + 'secureheaders' => SecureHeadersFilter::class, 'cors' => Cors::class, 'forcehttps' => ForceHTTPS::class, 'pagecache' => PageCache::class, diff --git a/app/Config/Routes.php b/app/Config/Routes.php index bc3c3c7..b72f25c 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -1396,6 +1396,7 @@ $routes->group('setting', static function($routes) { $routes->post('admin/doctor/fee/remove', 'Setting\\Doctor::removeFee'); $routes->post('admin/doctor/ipd-fee/add', 'Setting\\Doctor::addIpdFee'); $routes->post('admin/doctor/ipd-fee/remove', 'Setting\\Doctor::removeIpdFee'); + $routes->post('admin/doctor/regenerate-device-code', 'Setting\\Doctor::regenerateDeviceCode'); $routes->get('admin/doctor/specs', 'Setting\\Doctor::specs'); $routes->post('admin/doctor/specs/create', 'Setting\\Doctor::storeSpec'); $routes->post('admin/doctor/specs/update', 'Setting\\Doctor::updateSpec'); @@ -1439,6 +1440,8 @@ $routes->group('setting', static function($routes) { $routes->post('admin/reffer/types/delete', 'Setting\\Reffer::deleteType'); $routes->get('admin/nurse', 'Setting\\Nurse::index'); $routes->post('admin/nurse/save', 'Setting\\Nurse::save'); + $routes->post('admin/nurse/regenerate-device-code', 'Setting\\Nurse::regenerate_device_code'); + $routes->post('admin/nurse/regenerate-device-code/(:num)', 'Setting\\Nurse::regenerate_device_code/$1'); $routes->post('admin/nurse/delete', 'Setting\\Nurse::delete'); $routes->post('admin/nurse/delete/(:num)', 'Setting\\Nurse::delete/$1'); $routes->get('admin/nurse/list_json', 'Setting\\Nurse::list_json'); diff --git a/app/Config/Security.php b/app/Config/Security.php index 9096fcd..3b52b94 100644 --- a/app/Config/Security.php +++ b/app/Config/Security.php @@ -70,8 +70,10 @@ class Security extends BaseConfig * -------------------------------------------------------------------------- * * Regenerate CSRF Token on every submission. + * Set to false for SPA and AJAX-heavy dashboards to ensure in-flight + * requests and concurrent tabs do not invalidate the session CSRF token. */ - public bool $regenerate = true; + public bool $regenerate = false; /** * -------------------------------------------------------------------------- diff --git a/app/Controllers/Api/v1/DoctorApi.php b/app/Controllers/Api/v1/DoctorApi.php index 6a1de2e..2a89a07 100644 --- a/app/Controllers/Api/v1/DoctorApi.php +++ b/app/Controllers/Api/v1/DoctorApi.php @@ -49,6 +49,7 @@ class DoctorApi extends BaseController /** * GET api/v1/doctor/list + * Returns sanitized doctor list for UI pickers (Data Minimized: PII and signatures stripped) */ public function doctors() { @@ -56,43 +57,160 @@ class DoctorApi extends BaseController $list = []; foreach ($doctors as $d) { $list[] = [ - 'id' => (int) $d->id, - 'name' => trim(($d->p_title ?? '') . ' ' . ($d->p_fname ?? '')), - 'email' => $d->email1 ?? '', - 'phone' => $d->mphone1 ?? '', - 'doc_sign' => $d->doc_sign ?? '', - 'has_pin' => ! empty($d->app_pin), + 'id' => (int) $d->id, + 'name' => trim(($d->p_title ?? '') . ' ' . ($d->p_fname ?? '')), + 'department' => $d->department ?? '', + 'has_pin' => ! empty($d->app_pin), ]; } return $this->response->setJSON([ 'status' => 1, - 'data' => $list, + 'data' => $list, ]); } + private function isHospitalIntranet(string $ip): bool + { + if (in_array($ip, ['127.0.0.1', '::1', 'localhost'], true)) { + return true; + } + $privateRanges = [ + '10.0.0.0/8', + '172.16.0.0/12', + '192.168.0.0/16', + ]; + $ipLong = ip2long($ip); + if ($ipLong === false) { + return false; + } + foreach ($privateRanges as $range) { + [$subnet, $mask] = explode('/', $range); + $subnetLong = ip2long($subnet); + $maskLong = ~((1 << (32 - (int) $mask)) - 1); + if (($ipLong & $maskLong) === ($subnetLong & $maskLong)) { + return true; + } + } + return false; + } + /** * POST api/v1/doctor/auth/verify-pin */ public function verifyPin() { $post = $this->request->getPost() ?: ($this->request->getJSON(true) ?? []); - $docId = (int) ($post['doctor_id'] ?? 0); - $pin = trim((string) ($post['pin'] ?? '')); + $rawDocId = trim((string) ($post['doctor_id'] ?? $post['staff_id'] ?? '')); + $docId = 0; + if (preg_match('/^DOC[-_]?(\d+)$/i', $rawDocId, $m)) { + $docId = (int) $m[1]; + } elseif (is_numeric($rawDocId)) { + $docId = (int) $rawDocId; + } + $docCode = trim((string) ($post['doctor_code'] ?? $post['staff_id'] ?? $post['user_id'] ?? $post['username'] ?? $rawDocId)); + $pin = trim((string) ($post['pin'] ?? $post['password'] ?? '')); + $deviceCode = trim((string) ($post['device_code'] ?? '')); + $deviceToken = trim((string) ($post['device_token'] ?? '')); - if ($docId <= 0) { - return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Doctor ID is required']); + $doctorRow = null; + if ($docId > 0) { + $doctorRows = $this->doctorModel->getDoctorById($docId); + $doctorRow = $doctorRows[0] ?? null; } - $doctorRows = $this->doctorModel->getDoctorById($docId); - $doctorRow = $doctorRows[0] ?? null; + if (! $doctorRow && $docCode !== '') { + if (is_numeric($docCode)) { + $doctorRows = $this->doctorModel->getDoctorById((int) $docCode); + $doctorRow = $doctorRows[0] ?? null; + } + + if (! $doctorRow && db_connect()->tableExists('user_profiles')) { + $linkedRow = db_connect()->table('user_profiles up') + ->select('d.*, u.id as linked_user_id, u.username as linked_username') + ->join('doctor_master d', 'd.id = up.doctor_id') + ->join('users u', 'u.id = up.user_id AND u.deleted_at IS NULL', 'left') + ->groupStart() + ->where('u.username', $docCode) + ->orWhere('up.employee_code', $docCode) + ->groupEnd() + ->get()->getRow(); + if ($linkedRow) { + $doctorRow = $linkedRow; + } + } + + if (! $doctorRow) { + // Try searching by name or doctor_reg_no + $doctorRow = db_connect()->table('doctor_master') + ->where('doctor_reg_no', $docCode) + ->orWhere('mphone1', $docCode) + ->get()->getRow(); + } + } if (! $doctorRow) { - return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Doctor profile not found']); + return $this->response->setStatusCode(404)->setJSON([ + 'status' => 0, + 'message' => 'Doctor profile not found. Please verify your Doctor ID / Username.' + ]); + } + + $clientIp = (string) $this->request->getIPAddress(); + $isIntranet = $this->isHospitalIntranet($clientIp); + + // Device pairing check + $expectedDeviceCode = trim((string) ($doctorRow->device_code ?? '')); + if ($expectedDeviceCode === '') { + $expectedDeviceCode = (string) random_int(100000, 999999); + db_connect()->table('doctor_master')->where('id', $doctorRow->id)->update(['device_code' => $expectedDeviceCode]); + $doctorRow->device_code = $expectedDeviceCode; + } + + $expectedDeviceToken = hash_hmac('sha256', (string) $doctorRow->id, $expectedDeviceCode); + + $isDeviceTrusted = false; + if ($deviceToken !== '' && hash_equals($expectedDeviceToken, $deviceToken)) { + $isDeviceTrusted = true; + } + + if (! $isDeviceTrusted) { + if ($deviceCode === '') { + return $this->response->setStatusCode(401)->setJSON([ + 'status' => 0, + 'need_device_code' => true, + 'message' => 'Device Security Code is required to register this mobile device. Obtain the 6-digit code from Doctor Master in HMS Admin.' + ]); + } + if (strcasecmp($deviceCode, $expectedDeviceCode) !== 0) { + return $this->response->setStatusCode(401)->setJSON([ + 'status' => 0, + 'need_device_code' => true, + 'message' => 'Invalid Device Security Code. Please enter the 6-digit Security Code provided in Doctor Master.' + ]); + } + $isDeviceTrusted = true; + } + + $rateKey = 'doc_pin_' . md5($clientIp . '_' . $doctorRow->id); + $cache = service('cache'); + $failedAttempts = (int) ($cache->get($rateKey) ?? 0); + if ($failedAttempts >= 5) { + return $this->response->setStatusCode(429)->setJSON([ + 'status' => 0, + 'message' => 'Too many failed PIN attempts. Account temporarily locked for 10 minutes.', + ]); } $hashedPin = (string) ($doctorRow->app_pin ?? ''); + // If PIN is not set yet and a new PIN is provided with a valid deviceCode, set it automatically + if ($hashedPin === '' && $pin !== '' && strlen($pin) >= 4) { + $hashedPin = password_hash($pin, PASSWORD_DEFAULT); + db_connect()->table('doctor_master')->where('id', $doctorRow->id)->update(['app_pin' => $hashedPin]); + $doctorRow->app_pin = $hashedPin; + } + if ($hashedPin === '') { return $this->response->setJSON([ 'status' => 2, @@ -104,25 +222,29 @@ class DoctorApi extends BaseController ]); } - $clientIp = (string) $this->request->getIPAddress(); - $rateKey = 'doc_pin_' . md5($clientIp . '_' . $docId); - $cache = service('cache'); - $failedAttempts = (int) ($cache->get($rateKey) ?? 0); - if ($failedAttempts >= 5) { - return $this->response->setStatusCode(429)->setJSON([ - 'status' => 0, - 'message' => 'Too many failed PIN attempts. Account temporarily locked for 10 minutes.', - ]); - } - + // Verify PIN or Account Password $isValid = password_verify($pin, $hashedPin) || ($pin === $hashedPin); + if (! $isValid && ! empty($doctorRow->linked_user_id)) { + try { + $userModel = model(\CodeIgniter\Shield\Models\UserModel::class); + $user = $userModel->findById((int) $doctorRow->linked_user_id); + if ($user && method_exists($user, 'checkPassword') && $user->checkPassword($pin)) { + $isValid = true; + } + } catch (\Throwable $e) {} + } + if (! $isValid) { $cache->save($rateKey, $failedAttempts + 1, 600); - return $this->response->setStatusCode(401)->setJSON(['status' => 0, 'message' => 'Incorrect Security PIN']); + return $this->response->setStatusCode(401)->setJSON([ + 'status' => 0, + 'message' => 'Incorrect Security PIN or Password.' + ]); } $cache->delete($rateKey); + if (session_status() === PHP_SESSION_ACTIVE || ! session_id()) { session()->set('pwa_auth_doctor_id', (int) $doctorRow->id); } @@ -130,9 +252,11 @@ class DoctorApi extends BaseController return $this->response->setJSON([ 'status' => 1, 'message' => 'Authentication successful', + 'device_token' => $expectedDeviceToken, + 'is_intranet' => $isIntranet, 'doctor' => [ 'id' => (int) $doctorRow->id, - 'name' => trim(($doctorRow->p_title ?? '') . ' ' . ($doctorRow->p_fname ?? '')), + 'name' => trim(($doctorRow->p_title ?? '') . ' ' . ($doctorRow->p_fname ?? '') . ' ' . ($doctorRow->p_lname ?? '')), ], ]); } diff --git a/app/Controllers/Api/v1/NursingApi.php b/app/Controllers/Api/v1/NursingApi.php index 7a431f0..d980bd7 100644 --- a/app/Controllers/Api/v1/NursingApi.php +++ b/app/Controllers/Api/v1/NursingApi.php @@ -73,7 +73,7 @@ class NursingApi extends BaseController b.status, coalesce(b.bed_status, 'available') as bed_status, w.ward_name, c.category_name, i.id as ipd_id, i.ipd_code, i.p_id, i.r_doc_id, i.r_doc_name, - p.p_fname as patient_name, p.p_code as uhid, p.mphone1, p.gender, p.age, p.dob, + p.p_fname as patient_name, p.p_code as uhid, p.gender, p.age, coalesce(ipd_doc_list.doc_name, concat('Dr. ', i.r_doc_name)) as doctor_name FROM bed_master b LEFT JOIN ward_master w ON b.ward_id = w.id @@ -106,15 +106,56 @@ class NursingApi extends BaseController /** * GET api/v1/nursing/nurses + * Returns sanitized active nurse staff list for PWA staff switcher (Data Minimized & Credentials Stripped) */ public function nurses() { + $nurses = $this->nurseModel->getActiveNurses(); + $safeList = []; + foreach ($nurses as $n) { + $safeList[] = [ + 'id' => (int) ($n['id'] ?? 0), + 'nurse_code' => (string) ($n['nurse_code'] ?? ''), + 'name' => (string) ($n['name'] ?? ''), + 'gender' => (string) ($n['gender'] ?? ''), + 'designation' => (string) ($n['designation'] ?? 'Staff Nurse'), + 'department' => (string) ($n['department'] ?? 'Nursing'), + 'has_pin' => ! empty($n['app_pin']), + 'is_active' => (int) ($n['is_active'] ?? 1), + ]; + } + return $this->response->setJSON([ 'status' => 1, - 'data' => $this->nurseModel->getActiveNurses() + 'data' => $safeList, ]); } + private function isHospitalIntranet(string $ip): bool + { + if (in_array($ip, ['127.0.0.1', '::1', 'localhost'], true)) { + return true; + } + $privateRanges = [ + '10.0.0.0/8', + '172.16.0.0/12', + '192.168.0.0/16', + ]; + $ipLong = ip2long($ip); + if ($ipLong === false) { + return false; + } + foreach ($privateRanges as $range) { + [$subnet, $mask] = explode('/', $range); + $subnetLong = ip2long($subnet); + $maskLong = ~((1 << (32 - (int) $mask)) - 1); + if (($ipLong & $maskLong) === ($subnetLong & $maskLong)) { + return true; + } + } + return false; + } + /** * POST api/v1/nursing/auth/verify-pin */ @@ -122,21 +163,102 @@ class NursingApi extends BaseController { $post = $this->request->getPost() ?: ($this->request->getJSON(true) ?? []); $nurseId = (int) ($post['nurse_id'] ?? 0); - $nurseCode = trim((string) ($post['nurse_code'] ?? '')); - $pin = trim((string) ($post['pin'] ?? '')); + $nurseCode = trim((string) ($post['staff_id'] ?? $post['nurse_code'] ?? $post['user_id'] ?? $post['username'] ?? '')); + $pin = trim((string) ($post['pin'] ?? $post['password'] ?? '')); + $deviceCode = trim((string) ($post['device_code'] ?? '')); + $deviceToken = trim((string) ($post['device_token'] ?? '')); - if ($nurseId <= 0 && $nurseCode !== '') { - $nurseRow = db_connect()->table('nurse_master')->where('nurse_code', $nurseCode)->get()->getRowArray(); - } else { + $nurseRow = null; + if ($nurseId > 0) { $nurseRow = $this->nurseModel->getNurseById($nurseId); } + if (! $nurseRow && $nurseCode !== '') { + // 1. Direct search by nurse_code or id + $nurseRow = db_connect()->table('nurse_master') + ->where('nurse_code', $nurseCode) + ->orWhere('id', is_numeric($nurseCode) ? (int)$nurseCode : 0) + ->get()->getRowArray(); + + // 2. Search by linked username or employee_code + if (! $nurseRow && db_connect()->tableExists('user_profiles')) { + $linkedRow = db_connect()->table('user_profiles up') + ->select('n.*, u.id as linked_user_id, u.username as linked_username') + ->join('nurse_master n', 'n.id = up.nurse_id') + ->join('users u', 'u.id = up.user_id AND u.deleted_at IS NULL', 'left') + ->groupStart() + ->where('u.username', $nurseCode) + ->orWhere('up.employee_code', $nurseCode) + ->groupEnd() + ->get()->getRowArray(); + if ($linkedRow) { + $nurseRow = $linkedRow; + } + } + } + if (! $nurseRow) { - return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Nurse profile not found']); + return $this->response->setStatusCode(404)->setJSON([ + 'status' => 0, + 'message' => 'Staff profile not found. Please verify your Staff ID / Username.' + ]); + } + + $clientIp = (string) $this->request->getIPAddress(); + $isIntranet = $this->isHospitalIntranet($clientIp); + + // Device pairing check + $expectedDeviceCode = trim((string) ($nurseRow['device_code'] ?? '')); + if ($expectedDeviceCode === '') { + $expectedDeviceCode = (string) random_int(100000, 999999); + db_connect()->table('nurse_master')->where('id', $nurseRow['id'])->update(['device_code' => $expectedDeviceCode]); + $nurseRow['device_code'] = $expectedDeviceCode; + } + + $expectedDeviceToken = hash_hmac('sha256', $nurseRow['id'] . '_' . ($nurseRow['nurse_code'] ?? ''), $expectedDeviceCode); + + $isDeviceTrusted = false; + if ($deviceToken !== '' && hash_equals($expectedDeviceToken, $deviceToken)) { + $isDeviceTrusted = true; + } + + if (! $isDeviceTrusted) { + if ($deviceCode === '') { + return $this->response->setStatusCode(401)->setJSON([ + 'status' => 0, + 'need_device_code' => true, + 'message' => 'Device Security Code is required to register this mobile device. Obtain the 6-digit code from Nursing Staff Master in HMS Admin.' + ]); + } + if (strcasecmp($deviceCode, $expectedDeviceCode) !== 0) { + return $this->response->setStatusCode(401)->setJSON([ + 'status' => 0, + 'need_device_code' => true, + 'message' => 'Invalid Device Security Code. Please enter the 6-digit Security Code provided in Nursing Staff Master.' + ]); + } + $isDeviceTrusted = true; + } + + $rateKey = 'nurse_pin_' . md5($clientIp . '_' . ($nurseRow['id'] ?? 0)); + $cache = service('cache'); + $failedAttempts = (int) ($cache->get($rateKey) ?? 0); + if ($failedAttempts >= 5) { + return $this->response->setStatusCode(429)->setJSON([ + 'status' => 0, + 'message' => 'Too many failed PIN attempts. Account temporarily locked for 10 minutes.', + ]); } $hashedPin = (string) ($nurseRow['app_pin'] ?? ''); + // If PIN is not set yet and a new PIN is provided with a valid deviceCode, set it automatically + if ($hashedPin === '' && $pin !== '' && strlen($pin) >= 4) { + $hashedPin = password_hash($pin, PASSWORD_DEFAULT); + db_connect()->table('nurse_master')->where('id', $nurseRow['id'])->update(['app_pin' => $hashedPin]); + $nurseRow['app_pin'] = $hashedPin; + } + if ($hashedPin === '') { return $this->response->setJSON([ 'status' => 2, @@ -150,25 +272,31 @@ class NursingApi extends BaseController ]); } - $clientIp = (string) $this->request->getIPAddress(); - $rateKey = 'nurse_pin_' . md5($clientIp . '_' . ($nurseRow['id'] ?? 0)); - $cache = service('cache'); - $failedAttempts = (int) ($cache->get($rateKey) ?? 0); - if ($failedAttempts >= 5) { - return $this->response->setStatusCode(429)->setJSON([ - 'status' => 0, - 'message' => 'Too many failed PIN attempts. Account temporarily locked for 10 minutes.', - ]); - } - + // Verify PIN or Account Password $isValid = password_verify($pin, $hashedPin) || ($pin === $hashedPin); + // If not matched, check linked Shield user account password if linked + if (! $isValid && ! empty($nurseRow['linked_user_id'])) { + try { + $userModel = model(\CodeIgniter\Shield\Models\UserModel::class); + $user = $userModel->findById((int) $nurseRow['linked_user_id']); + if ($user && method_exists($user, 'checkPassword') && $user->checkPassword($pin)) { + $isValid = true; + } + } catch (\Throwable $e) {} + } + if (! $isValid) { $cache->save($rateKey, $failedAttempts + 1, 600); - return $this->response->setStatusCode(401)->setJSON(['status' => 0, 'message' => 'Incorrect Security PIN']); + return $this->response->setStatusCode(401)->setJSON([ + 'status' => 0, + 'message' => 'Incorrect Security PIN or Password.' + ]); } $cache->delete($rateKey); + + // Issue/return device pairing token if (session_status() === PHP_SESSION_ACTIVE || ! session_id()) { session()->set('pwa_auth_nurse_id', (int) $nurseRow['id']); } @@ -176,11 +304,14 @@ class NursingApi extends BaseController return $this->response->setJSON([ 'status' => 1, 'message' => 'Authentication successful', + 'device_token' => $expectedDeviceToken, + 'is_intranet' => $isIntranet, 'nurse' => [ 'id' => (int) $nurseRow['id'], 'nurse_code' => $nurseRow['nurse_code'], 'name' => $nurseRow['name'], 'designation' => $nurseRow['designation'] ?? 'Staff Nurse', + 'department' => $nurseRow['department'] ?? 'Nursing', ], ]); } diff --git a/app/Controllers/Billing/Ipd.php b/app/Controllers/Billing/Ipd.php index 7b43109..bb3f13d 100644 --- a/app/Controllers/Billing/Ipd.php +++ b/app/Controllers/Billing/Ipd.php @@ -3195,6 +3195,7 @@ class Ipd extends BaseController return $this->response->setJSON([ 'update' => 1, 'message' => $message, + 'csrf_hash' => csrf_hash(), 'html' => view('billing/ipd/panel_discharge', $panelData), ]); } diff --git a/app/Controllers/Setting/Doctor.php b/app/Controllers/Setting/Doctor.php index 25c2508..2a0d9b4 100644 --- a/app/Controllers/Setting/Doctor.php +++ b/app/Controllers/Setting/Doctor.php @@ -134,6 +134,9 @@ class Doctor extends BaseController $data['app_pin'] = password_hash($appPin, PASSWORD_DEFAULT); } + $deviceCode = trim((string) ($this->request->getPost('device_code') ?? '')); + $data['device_code'] = $deviceCode !== '' ? $deviceCode : str_pad((string) random_int(100000, 999999), 6, '0', STR_PAD_LEFT); + $insertId = $doctorModel->insert($data); if ($insertId <= 0) { @@ -258,6 +261,11 @@ class Doctor extends BaseController $data['app_pin'] = password_hash($appPin, PASSWORD_DEFAULT); } + $deviceCode = trim((string) ($this->request->getPost('device_code') ?? '')); + if ($deviceCode !== '') { + $data['device_code'] = $deviceCode; + } + $updated = $doctorModel->updateDoctor($data, $docId); return $this->response->setJSON([ @@ -267,6 +275,20 @@ class Doctor extends BaseController ]); } + public function regenerateDeviceCode() + { + if (! $this->request->isAJAX()) { + return $this->response->setJSON(['ok' => false, 'error' => 'Invalid request']); + } + $id = (int) ($this->request->getPost('id') ?? 0); + if ($id <= 0) { + return $this->response->setJSON(['ok' => false, 'error' => 'Invalid doctor ID']); + } + $newCode = str_pad((string) random_int(100000, 999999), 6, '0', STR_PAD_LEFT); + $this->db->table('doctor_master')->where('id', $id)->update(['device_code' => $newCode]); + return $this->response->setJSON(['ok' => true, 'device_code' => $newCode]); + } + public function doctorRecordSpec() { if (! $this->request->isAJAX()) { diff --git a/app/Controllers/Setting/Nurse.php b/app/Controllers/Setting/Nurse.php index d250c75..be0a13b 100644 --- a/app/Controllers/Setting/Nurse.php +++ b/app/Controllers/Setting/Nurse.php @@ -69,6 +69,13 @@ class Nurse extends BaseController 'is_active' => $isActive, ]; + $deviceCode = trim((string) ($this->request->getPost('device_code') ?? '')); + if ($deviceCode !== '') { + $payload['device_code'] = $deviceCode; + } elseif ($id === 0) { + $payload['device_code'] = str_pad((string) random_int(100000, 999999), 6, '0', STR_PAD_LEFT); + } + if ($appPin !== '') { $payload['app_pin'] = password_hash($appPin, PASSWORD_DEFAULT); } @@ -88,6 +95,17 @@ class Nurse extends BaseController ]); } + public function regenerate_device_code($id = null) + { + $id = (int) ($id ?? $this->request->getPost('id') ?? 0); + if ($id <= 0) { + return $this->response->setJSON(['ok' => false, 'error' => 'Invalid nurse ID.']); + } + $newCode = str_pad((string) random_int(100000, 999999), 6, '0', STR_PAD_LEFT); + $this->nurseModel->updateNurse($id, ['device_code' => $newCode]); + return $this->response->setJSON(['ok' => true, 'device_code' => $newCode]); + } + public function delete($id = null) { $id = (int) ($id ?? $this->request->getPost('id') ?? 0); diff --git a/app/Controllers/Setting/UserManagement.php b/app/Controllers/Setting/UserManagement.php index f25b0a6..3f9593e 100644 --- a/app/Controllers/Setting/UserManagement.php +++ b/app/Controllers/Setting/UserManagement.php @@ -74,8 +74,17 @@ class UserManagement extends BaseController public function store() { + $username = trim((string) $this->request->getPost('username')); + $email = trim((string) $this->request->getPost('email')); + if ($email === '' && $username !== '') { + $email = strtolower($username) . '@hospital.local'; + $postData = $this->request->getPost(); + $postData['email'] = $email; + $this->request->setGlobal('post', $postData); + } + $rules = [ - 'username' => 'required|min_length[3]|max_length[30]|regex_match[/^[a-zA-Z0-9.]+$/]', + 'username' => 'required|min_length[3]|max_length[30]|regex_match[/^[a-zA-Z0-9._-]+$/]', 'email' => 'required|valid_email|max_length[254]', 'password' => 'required|min_length[8]', 'role' => 'required', @@ -304,8 +313,8 @@ class UserManagement extends BaseController ]; $errors = []; - if ($username === '' || strlen($username) < 3 || strlen($username) > 30 || preg_match('/\A[a-zA-Z0-9\.]+\z/', $username) !== 1) { - $errors['username'] = 'Login ID must be 3-30 chars and only letters, numbers, dot.'; + if ($username === '' || strlen($username) < 3 || strlen($username) > 30 || preg_match('/\A[a-zA-Z0-9._-]+\z/', $username) !== 1) { + $errors['username'] = 'Login ID must be 3-30 chars and only letters, numbers, dot (.), hyphen (-), underscore (_).'; } if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors['email'] = 'Valid email is required.'; diff --git a/app/Filters/SecureHeadersFilter.php b/app/Filters/SecureHeadersFilter.php new file mode 100644 index 0000000..4244aba --- /dev/null +++ b/app/Filters/SecureHeadersFilter.php @@ -0,0 +1,72 @@ + + */ + protected array $headers = [ + // Protect against clickjacking + 'X-Frame-Options' => 'SAMEORIGIN', + + // Prevent MIME type sniffing + 'X-Content-Type-Options' => 'nosniff', + + // Restrict file downloads in legacy IE + 'X-Download-Options' => 'noopen', + + // Restrict Adobe Flash / PDF cross-domain policies + 'X-Permitted-Cross-Domain-Policies' => 'none', + + // Protect referrer leakage + 'Referrer-Policy' => 'strict-origin-when-cross-origin', + + // Enforce HTTPS + 'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains; preload', + + // Restrict unnecessary browser device capabilities + 'Permissions-Policy' => 'camera=(), microphone=(), geolocation=(), payment=()', + + // Cross-Origin isolation protections + 'Cross-Origin-Opener-Policy' => 'same-origin-allow-popups', + + // Content Security Policy (allowing self, inline scripts, styles, data URIs for canvas/charts) + 'Content-Security-Policy' => "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: https:; frame-ancestors 'self';", + ]; + + public function before(RequestInterface $request, $arguments = null) + { + return null; + } + + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + { + foreach ($this->headers as $header => $value) { + // Only set if not already explicitly defined by a specific controller + if (! $response->hasHeader($header)) { + $response->setHeader($header, $value); + } + } + + // Provide fresh CSRF token header for AJAX/fetch calls + if (function_exists('csrf_hash')) { + $response->setHeader('X-CSRF-TOKEN', csrf_hash()); + } + + return $response; + } +} diff --git a/app/Models/DoctorModel.php b/app/Models/DoctorModel.php index 019c372..d648c63 100644 --- a/app/Models/DoctorModel.php +++ b/app/Models/DoctorModel.php @@ -21,6 +21,9 @@ class DoctorModel if (! in_array('app_pin', $fields, true)) { $this->db->query("ALTER TABLE doctor_master ADD COLUMN app_pin VARCHAR(255) NULL"); } + if (! in_array('device_code', $fields, true)) { + $this->db->query("ALTER TABLE doctor_master ADD COLUMN device_code VARCHAR(50) NULL"); + } } return true; } @@ -36,12 +39,28 @@ class DoctorModel $builder->select('d.*'); } - return $builder->get()->getResult(); + $list = $builder->get()->getResult(); + foreach ($list as &$doc) { + if (empty($doc->device_code)) { + $newCode = (string) random_int(100000, 999999); + $this->db->table('doctor_master')->where('id', $doc->id)->update(['device_code' => $newCode]); + $doc->device_code = $newCode; + } + } + unset($doc); + + return $list; } public function getDoctorById(int $id): array { - return $this->db->table('doctor_master')->where('id', $id)->get()->getResult(); + $rows = $this->db->table('doctor_master')->where('id', $id)->get()->getResult(); + if (! empty($rows) && empty($rows[0]->device_code)) { + $newCode = (string) random_int(100000, 999999); + $this->db->table('doctor_master')->where('id', $id)->update(['device_code' => $newCode]); + $rows[0]->device_code = $newCode; + } + return $rows; } public function insert(array $data): int diff --git a/app/Models/NurseModel.php b/app/Models/NurseModel.php index 9671689..796712c 100644 --- a/app/Models/NurseModel.php +++ b/app/Models/NurseModel.php @@ -55,6 +55,9 @@ class NurseModel if (! in_array('app_pin', $fields, true)) { $this->db->query("ALTER TABLE nurse_master ADD COLUMN app_pin VARCHAR(255) NULL AFTER email"); } + if (! in_array('device_code', $fields, true)) { + $this->db->query("ALTER TABLE nurse_master ADD COLUMN device_code VARCHAR(50) NULL AFTER app_pin"); + } return true; } @@ -81,7 +84,17 @@ class NurseModel ->groupEnd(); } - return $builder->orderBy('n.name', 'ASC')->get()->getResultArray(); + $list = $builder->orderBy('n.name', 'ASC')->get()->getResultArray(); + foreach ($list as &$nurse) { + if (empty($nurse['device_code'])) { + $newCode = (string) random_int(100000, 999999); + $this->db->table('nurse_master')->where('id', $nurse['id'])->update(['device_code' => $newCode]); + $nurse['device_code'] = $newCode; + } + } + unset($nurse); + + return $list; } public function getActiveNurses(): array @@ -96,6 +109,11 @@ class NurseModel public function getNurseById(int $id): ?array { $row = $this->db->table('nurse_master')->where('id', $id)->get()->getRowArray(); + if ($row && empty($row['device_code'])) { + $newCode = (string) random_int(100000, 999999); + $this->db->table('nurse_master')->where('id', $id)->update(['device_code' => $newCode]); + $row['device_code'] = $newCode; + } return $row ?: null; } @@ -103,6 +121,9 @@ class NurseModel { $data['created_at'] = date('Y-m-d H:i:s'); $data['updated_at'] = date('Y-m-d H:i:s'); + if (empty($data['device_code'])) { + $data['device_code'] = (string) random_int(100000, 999999); + } $this->db->table('nurse_master')->insert($data); return (int) $this->db->insertID(); } diff --git a/app/Views/Setting/Admin/UserManagement/create.php b/app/Views/Setting/Admin/UserManagement/create.php index 1e18e4e..e5210d7 100644 --- a/app/Views/Setting/Admin/UserManagement/create.php +++ b/app/Views/Setting/Admin/UserManagement/create.php @@ -26,18 +26,28 @@
- -
3-30 characters, letters, numbers, and dot.
+ +
3-30 characters, letters, numbers, dot (.), hyphen (-), underscore (_).
- -
Used for notifications and password recovery.
+ +
Used for notifications. Leave blank for auto-generated internal account.
- - -
At least 8 characters.
+
+ + +
+
+ + +
+
At least 8 characters. Minimum standard security.
- // AJAX Form submission + // AJAX Form submission with client-side validation $(form).off('submit.userCreate').on('submit.userCreate', function(event) { event.preventDefault(); + var u = ($('#username').val() || '').trim(); + var p = ($('#password').val() || '').trim(); + var e = ($('#email').val() || '').trim(); + var r = ($('#role').val() || '').trim(); + + if (!u || u.length < 3) { + if (typeof notify === 'function') notify('warning', 'Validation', 'Please enter a valid Login ID (minimum 3 characters).'); + $('#username').focus(); + return; + } + + if (!/^[a-zA-Z0-9._-]+$/.test(u)) { + if (typeof notify === 'function') notify('warning', 'Validation', 'Login ID can only contain letters, numbers, dot, hyphen, or underscore.'); + $('#username').focus(); + return; + } + + if (!r) { + if (typeof notify === 'function') notify('warning', 'Validation', 'Please select an assigned role.'); + $('#role').focus(); + return; + } + + if (!p || p.length < 8) { + if (typeof notify === 'function') notify('warning', 'Validation', 'Password is required and must be at least 8 characters. Click "Set Staff@123" for quick fill.'); + $('#password').focus(); + return; + } + + if (!e) { + e = u.toLowerCase() + '@hospital.local'; + $('#email').val(e); + } + $.post($(form).attr('action'), $(form).serialize()) .done(function(html) { $('#maindiv').html(html); @@ -232,7 +312,9 @@ } }) .fail(function(xhr) { - notify('error', 'Create User', (xhr.responseJSON && xhr.responseJSON.error_text) || 'Request failed. Please check form validation.'); + if (typeof notify === 'function') { + notify('error', 'Create User', (xhr.responseJSON && xhr.responseJSON.error_text) || 'Request failed. Please check form validation.'); + } }); }); })(); diff --git a/app/Views/Setting/Doctor/Doctor_Search_V.php b/app/Views/Setting/Doctor/Doctor_Search_V.php index 261e17b..200ca37 100644 --- a/app/Views/Setting/Doctor/Doctor_Search_V.php +++ b/app/Views/Setting/Doctor/Doctor_Search_V.php @@ -24,6 +24,7 @@ Name Phone Number Email + Device Code User Account Action @@ -35,6 +36,18 @@ p_title ?? '') . ' ' . ($row->p_fname ?? '')) ?> mphone1 ?? '') ?> email1 ?? '') ?> + +
+ + device_code ?? '-') ?> + + device_code)) : ?> + + +
+ linked_username)) : ?> @@ -47,8 +60,8 @@ - @@ -56,7 +69,7 @@ - No doctors found. + No doctors found. @@ -65,21 +78,42 @@
- +