feat(security): implement WASA audit mitigations, mobile anti-enumeration & device authorization, and user management fixes

This commit is contained in:
DevSoftTech Server 2026-10-15 17:47:51 +05:30
parent a3c544a53c
commit de4f65807d
26 changed files with 1642 additions and 301 deletions

View File

@ -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/]',
],
];

View File

@ -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,

View File

@ -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');

View File

@ -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;
/**
* --------------------------------------------------------------------------

View File

@ -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 ?? '')),
],
]);
}

View File

@ -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',
],
]);
}

View File

@ -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),
]);
}

View File

@ -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()) {

View File

@ -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);

View File

@ -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.';

View File

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Enterprise Application Security Headers Filter (WASA & OWASP Compliant)
*/
class SecureHeadersFilter implements FilterInterface
{
/**
* Recommended security headers matching OWASP Secure Headers Project
* and WASA (Web Application Security Assessment) requirements.
*
* @var array<string, string>
*/
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;
}
}

View File

@ -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

View File

@ -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();
}

View File

@ -26,18 +26,28 @@
<div class="row g-3">
<div class="col-md-4">
<label class="form-label fw-semibold" for="username">Login ID / Username <span class="text-danger">*</span></label>
<input class="form-control" id="username" name="username" type="text" maxlength="30" placeholder="e.g. dr.sharma or rajesh.k" value="<?= esc($formData['username'] ?? old('username')) ?>" required>
<div class="form-text">3-30 characters, letters, numbers, and dot.</div>
<input class="form-control" id="username" name="username" type="text" maxlength="30" placeholder="e.g. nur-637 or dr.sharma" value="<?= esc($formData['username'] ?? old('username')) ?>" required>
<div class="form-text">3-30 characters, letters, numbers, dot (.), hyphen (-), underscore (_).</div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold" for="email">Email Address <span class="text-danger">*</span></label>
<input class="form-control" id="email" name="email" type="email" placeholder="staff@hospital.com" value="<?= esc($formData['email'] ?? old('email')) ?>" required>
<div class="form-text">Used for notifications and password recovery.</div>
<input class="form-control" id="email" name="email" type="email" placeholder="staff@hospital.com (or leave blank for auto)" value="<?= esc($formData['email'] ?? old('email')) ?>">
<div class="form-text">Used for notifications. Leave blank for auto-generated internal account.</div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold" for="password">Password <span class="text-danger">*</span></label>
<input class="form-control" id="password" name="password" type="password" minlength="8" placeholder="Minimum 8 characters" required>
<div class="form-text">At least 8 characters.</div>
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label fw-semibold mb-0" for="password">Password <span class="text-danger">*</span></label>
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" id="btn_quick_pwd" style="font-size: 11px;">
<i class="bi bi-magic me-1"></i>Set Staff@123
</button>
</div>
<div class="input-group">
<input class="form-control" id="password" name="password" type="password" minlength="8" placeholder="Minimum 8 characters" required>
<button class="btn btn-outline-secondary" type="button" id="btn_toggle_pwd" title="Toggle Show/Hide Password">
<i class="bi bi-eye" id="pwd_eye_icon"></i>
</button>
</div>
<div class="form-text">At least 8 characters. Minimum standard security.</div>
</div>
<?php
@ -171,6 +181,28 @@
$('#role').off('change.roleSelect').on('change.roleSelect', handleRoleChange);
handleRoleChange();
// Quick password and toggle visibility
$('#btn_quick_pwd').off('click').on('click', function() {
$('#password').val('Staff@123').trigger('input');
if (typeof notify === 'function') {
notify('info', 'Password Set', 'Default password set to Staff@123');
}
});
$('#btn_toggle_pwd').off('click').on('click', function() {
var pwdInput = document.getElementById('password');
var icon = document.getElementById('pwd_eye_icon');
if (pwdInput && icon) {
if (pwdInput.type === 'password') {
pwdInput.type = 'text';
icon.className = 'bi bi-eye-slash';
} else {
pwdInput.type = 'password';
icon.className = 'bi bi-eye';
}
}
});
// Auto-populate when selecting a Doctor
$('#doctor_id').off('change.docSelect').on('change.docSelect', function() {
var docId = parseInt($(this).val() || '0', 10);
@ -183,16 +215,22 @@
var fname = (doc.p_fname || '').trim();
var lname = (doc.p_lname || '').trim();
var fullName = (title + ' ' + fname + ' ' + lname).trim();
var suggestedUser = 'dr.' + (fname ? fname.toLowerCase().replace(/[^a-z0-9._-]/g, '') : 'doc');
if (!$('#person_name').val()) $('#person_name').val(fullName);
if (!$('#email').val() && doc.email1) $('#email').val(doc.email1);
if (!$('#phone_no').val() && doc.mphone1) $('#phone_no').val(doc.mphone1);
if (!$('#designation').val()) $('#designation').val('Doctor / Consultant');
if (!$('#department').val()) $('#department').val('Clinical / OPD');
if (!$('#username').val() && fname) {
var suggestedUser = 'dr.' + fname.toLowerCase();
$('#username').val(suggestedUser);
}
if (!$('#email').val()) {
if (doc.email1) {
$('#email').val(doc.email1);
} else {
$('#email').val(suggestedUser + '@hospital.local');
}
}
});
// Auto-populate when selecting a Nurse
@ -203,14 +241,22 @@
var nurse = nursesData.find(function(n) { return parseInt(n.id, 10) === nurseId; });
if (!nurse) return;
var uName = (nurse.nurse_code ? nurse.nurse_code.toLowerCase().replace(/[^a-z0-9._-]/g, '') : '');
if (!$('#person_name').val() && nurse.name) $('#person_name').val(nurse.name);
if (!$('#email').val() && nurse.email) $('#email').val(nurse.email);
if (!$('#phone_no').val() && nurse.contact_no) $('#phone_no').val(nurse.contact_no);
if (!$('#employee_code').val() && nurse.nurse_code) $('#employee_code').val(nurse.nurse_code);
if (!$('#designation').val()) $('#designation').val(nurse.designation || 'Staff Nurse');
if (!$('#department').val()) $('#department').val(nurse.department || 'Nursing');
if (!$('#username').val() && nurse.nurse_code) {
$('#username').val(nurse.nurse_code.toLowerCase());
if (!$('#username').val() && uName) {
$('#username').val(uName);
}
if (!$('#email').val()) {
if (nurse.email) {
$('#email').val(nurse.email);
} else if (uName) {
$('#email').val(uName + '@hospital.local');
}
}
});
@ -220,10 +266,44 @@
$('#nurse_id').trigger('change.nurseSelect');
<?php endif; ?>
// 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.');
}
});
});
})();

View File

@ -24,6 +24,7 @@
<th>Name</th>
<th>Phone Number</th>
<th>Email</th>
<th>Device Code</th>
<th>User Account</th>
<th>Action</th>
</tr>
@ -35,6 +36,18 @@
<td><?= esc(($row->p_title ?? '') . ' ' . ($row->p_fname ?? '')) ?></td>
<td><?= esc($row->mphone1 ?? '') ?></td>
<td><?= esc($row->email1 ?? '') ?></td>
<td>
<div class="d-flex align-items-center gap-1">
<span class="badge bg-warning-subtle text-dark border border-warning font-monospace fs-6 px-2 py-1">
<i class="bi bi-shield-lock me-1 text-warning-emphasis"></i><?= esc($row->device_code ?? '-') ?>
</span>
<?php if (! empty($row->device_code)) : ?>
<button type="button" class="btn btn-outline-secondary btn-sm p-0 px-1 btn-copy-code" data-code="<?= esc($row->device_code) ?>" title="Copy Device Code">
<i class="bi bi-clipboard" style="font-size: 11px;"></i>
</button>
<?php endif; ?>
</div>
</td>
<td>
<?php if (! empty($row->linked_username)) : ?>
<span class="badge bg-success-subtle text-success border border-success-subtle" title="Linked User Account">
@ -47,8 +60,8 @@
<?php endif; ?>
</td>
<td>
<button type="button" class="btn btn-outline-dark btn-sm me-1 btn-qr-doctor" data-id="<?= (int)$row->id ?>" data-name="<?= esc(($row->p_title ?? '') . ' ' . ($row->p_fname ?? '')) ?>">
<i class="bi bi-qr-code"></i> QR
<button type="button" class="btn btn-outline-dark btn-sm me-1 btn-qr-doctor" data-id="<?= (int)$row->id ?>" data-name="<?= esc(($row->p_title ?? '') . ' ' . ($row->p_fname ?? '')) ?>" data-device="<?= esc($row->device_code ?? '') ?>">
<i class="bi bi-qr-code"></i> App Access
</button>
<button onclick="load_form_div('<?= base_url('setting/admin/doctor/' . $row->id) ?>','maindiv','<?= esc(($row->p_title ?? '') . ' ' . ($row->p_fname ?? '')) ?>');" type="button" class="btn btn-success btn-sm">Edit</button>
</td>
@ -56,7 +69,7 @@
<?php endforeach ?>
<?php else : ?>
<tr>
<td colspan="5" class="text-center text-muted">No doctors found.</td>
<td colspan="6" class="text-center text-muted">No doctors found.</td>
</tr>
<?php endif ?>
</tbody>
@ -65,21 +78,42 @@
</div>
</div>
<!-- Modal for Doctor App QR Code -->
<!-- Modal for Doctor App Pairing & QR Code -->
<div class="modal fade" id="doctorQrModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content text-center shadow-lg border-0">
<div class="modal-header bg-primary text-white py-2">
<h5 class="modal-title fs-6 mb-0" id="docQrModalTitle"><i class="bi bi-qr-code-scan me-1"></i> Doctor App QR Code</h5>
<h5 class="modal-title fs-6 mb-0" id="docQrModalTitle"><i class="bi bi-phone me-1"></i> DoctorCare Mobile App Access</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body py-3">
<h6 class="fw-bold mb-1 text-dark" id="qr_doc_name">Doctor Name</h6>
<span class="badge bg-primary mb-3">Doctor PWA Access</span>
<div class="p-2 border rounded d-inline-block bg-white shadow-sm mb-3">
<img id="qr_doc_code_img" src="" alt="Doctor App QR Code" style="width: 180px; height: 180px;" />
<div class="mb-3">
<span class="badge bg-success-subtle text-success border border-success-subtle"><i class="bi bi-shield-check me-1"></i>Device Auth Secured</span>
</div>
<p class="small text-muted mb-0" style="font-size: 11px;">Scan with mobile phone to launch <strong>DoctorCare PWA</strong> for this doctor profile.</p>
<div class="p-2 border rounded d-inline-block bg-white shadow-sm mb-3">
<img id="qr_doc_code_img" src="" alt="Doctor App QR Code" style="width: 170px; height: 170px;" />
</div>
<div class="card bg-light border-0 text-start mx-auto p-2 mb-2" style="max-width: 340px;">
<div class="d-flex justify-content-between align-items-center mb-1">
<small class="text-muted">Doctor ID / Code:</small>
<span class="fw-bold font-monospace text-primary" id="qr_card_doc_id">-</span>
</div>
<div class="d-flex justify-content-between align-items-center mb-1">
<small class="text-muted">Device Security Code:</small>
<span class="badge bg-warning text-dark font-monospace fs-6 px-2 py-0" id="qr_card_doc_device">-</span>
</div>
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted">App Security PIN:</small>
<span class="small text-secondary fst-italic">Configured in Master</span>
</div>
</div>
<p class="small text-muted mb-0" style="font-size: 11px;">
<i class="bi bi-info-circle me-1"></i> Scan to open the app on mobile, then enter the <strong>Doctor ID</strong>, <strong>Device Security Code</strong>, and <strong>PIN</strong> to pair this phone.
</p>
</div>
<div class="modal-footer bg-light py-2 justify-content-center">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
@ -97,10 +131,15 @@
if (qrBtn) {
var docId = qrBtn.getAttribute('data-id') || '0';
var name = qrBtn.getAttribute('data-name') || '';
var appUrl = doctorAppBaseUrl + '?doctor_id=' + encodeURIComponent(docId);
var deviceCode = qrBtn.getAttribute('data-device') || '';
// Clean app URL - no query parameters leaking doctor identity
var appUrl = doctorAppBaseUrl;
var qrApiUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=' + encodeURIComponent(appUrl);
document.getElementById('qr_doc_name').textContent = name;
document.getElementById('qr_card_doc_id').textContent = 'DOC-' + docId;
document.getElementById('qr_card_doc_device').textContent = deviceCode || 'N/A';
document.getElementById('qr_doc_code_img').src = qrApiUrl;
var modalEl = document.getElementById('doctorQrModal');
@ -115,6 +154,18 @@
}
}
}
var copyBtn = e.target.closest('.btn-copy-code');
if (copyBtn) {
var code = copyBtn.getAttribute('data-code') || '';
if (code) {
navigator.clipboard.writeText(code).then(function() {
alert('Device Security Code copied: ' + code);
}).catch(function() {
prompt('Copy Device Security Code:', code);
});
}
}
});
})();
</script>

View File

@ -224,6 +224,28 @@ if (!empty($data) && !empty($data[0]) && !empty($data[0]->dob)) {
<?php endif; ?>
</div>
<?php endif; ?>
<div class="row g-3 mt-1">
<div class="col-md-6">
<div class="p-2 bg-warning-subtle border border-warning rounded">
<label class="form-label fw-bold small text-dark mb-1"><i class="bi bi-key me-1"></i> Doctor Mobile App Security PIN</label>
<input type="password" maxlength="6" class="form-control form-control-sm" id="doctor_app_pin" name="app_pin" placeholder="Set or update 4-6 digit PIN">
<small class="text-muted" style="font-size: 10px;">Used by doctor to login/unlock DoctorCare PWA App</small>
</div>
</div>
<div class="col-md-6">
<div class="p-2 bg-light border rounded">
<label class="form-label fw-bold small text-dark mb-1"><i class="bi bi-shield-lock me-1"></i> Device Security Code</label>
<div class="input-group input-group-sm">
<input type="text" maxlength="10" class="form-control form-control-sm font-monospace fw-bold" id="doctor_device_code" name="device_code" value="<?= esc($data[0]->device_code ?? '') ?>" placeholder="6-digit security code">
<button type="button" class="btn btn-outline-secondary btn-sm" id="btn_regen_doc_code" title="Generate New Code">
<i class="bi bi-arrow-repeat"></i> New
</button>
</div>
<small class="text-muted" style="font-size: 10px;">Provided by HMS Admin for doctor's first-time mobile pairing</small>
</div>
</div>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary">Save</button>
</div>
@ -500,5 +522,22 @@ if (!empty($data) && !empty($data[0]) && !empty($data[0]->dob)) {
updateCsrf(data);
}, 'json');
}
$('#btn_regen_doc_code').on('click', function() {
var docId = parseInt($('#doc_id').val(), 10) || 0;
if (docId > 0) {
$.post('<?= base_url('setting/admin/doctor/regenerate-device-code') ?>', { id: docId }, function(resp) {
if (resp && resp.ok && resp.device_code) {
$('#doctor_device_code').val(resp.device_code);
} else {
$('#doctor_device_code').val(Math.floor(100000 + Math.random() * 900000).toString());
}
}, 'json').fail(function() {
$('#doctor_device_code').val(Math.floor(100000 + Math.random() * 900000).toString());
});
} else {
$('#doctor_device_code').val(Math.floor(100000 + Math.random() * 900000).toString());
}
});
</script>
<?php endif ?>

View File

@ -41,6 +41,7 @@
<th style="width: 120px;">Designation</th>
<th>Qualification</th>
<th>Contact / Mobile</th>
<th style="width: 130px;">Device Code</th>
<th style="width: 120px;">User Account</th>
<th style="width: 90px;">Status</th>
<th style="width: 110px;" class="text-end pe-3">Actions</th>
@ -76,6 +77,18 @@
<td><span class="badge bg-secondary-subtle text-dark border"><?= esc($nurse['designation'] ?: 'Staff Nurse') ?></span></td>
<td><?= esc($nurse['qualification'] ?: '-') ?></td>
<td><?= esc($nurse['contact_no'] ?: '-') ?></td>
<td>
<div class="d-flex align-items-center gap-1">
<span class="badge bg-warning-subtle text-dark border border-warning font-monospace fs-6 px-2 py-1" id="device_code_badge_<?= (int)$nurse['id'] ?>">
<i class="bi bi-shield-lock me-1 text-warning-emphasis"></i><?= esc($nurse['device_code'] ?? '-') ?>
</span>
<?php if (!empty($nurse['device_code'])): ?>
<button type="button" class="btn btn-outline-secondary btn-sm p-0 px-1 btn-copy-code" data-code="<?= esc($nurse['device_code']) ?>" title="Copy Device Code">
<i class="bi bi-clipboard" style="font-size: 11px;"></i>
</button>
<?php endif; ?>
</div>
</td>
<td>
<?php if (!empty($nurse['linked_username'])): ?>
<span class="badge bg-success-subtle text-success border border-success-subtle" title="Linked User Account">
@ -95,8 +108,8 @@
<?php endif; ?>
</td>
<td class="text-end pe-3">
<button type="button" class="btn btn-outline-dark btn-sm py-0 px-2 me-1 btn-qr-nurse" data-code="<?= esc($nurse['nurse_code']) ?>" data-name="<?= esc($nurse['name']) ?>">
<i class="bi bi-qr-code"></i> QR
<button type="button" class="btn btn-outline-dark btn-sm py-0 px-2 me-1 btn-qr-nurse" data-code="<?= esc($nurse['nurse_code']) ?>" data-name="<?= esc($nurse['name']) ?>" data-device="<?= esc($nurse['device_code'] ?? '') ?>">
<i class="bi bi-qr-code"></i> App Access
</button>
<button type="button" class="btn btn-outline-primary btn-sm py-0 px-2 btn-edit-nurse" data-id="<?= (int)$nurse['id'] ?>">
<i class="bi bi-pencil-square"></i> Edit
@ -201,12 +214,24 @@
<input type="email" class="form-control form-control-sm" id="nurse_email" name="email" placeholder="Email address">
</div>
<!-- App Security PIN Section -->
<!-- App Security PIN & Device Code Section -->
<div class="col-md-6 mt-2">
<div class="p-2 bg-warning-subtle border border-warning rounded">
<label class="form-label fw-bold small text-dark mb-1"><i class="bi bi-key me-1"></i> Mobile App Security PIN</label>
<input type="password" maxlength="6" class="form-control form-control-sm" id="nurse_app_pin" name="app_pin" placeholder="Set or update 4-6 digit PIN">
<small class="text-muted" style="font-size: 10px;">Used by nurse to login to NursingCare PWA App</small>
<small class="text-muted" style="font-size: 10px;">Used by nurse to login/unlock NursingCare PWA App</small>
</div>
</div>
<div class="col-md-6 mt-2">
<div class="p-2 bg-light border rounded">
<label class="form-label fw-bold small text-dark mb-1"><i class="bi bi-shield-lock me-1"></i> Device Security Code</label>
<div class="input-group input-group-sm">
<input type="text" maxlength="10" class="form-control form-control-sm font-monospace fw-bold" id="nurse_device_code" name="device_code" placeholder="6-digit security code">
<button type="button" class="btn btn-outline-secondary btn-sm" id="btn_regen_nurse_code" title="Generate New Code">
<i class="bi bi-arrow-repeat"></i> New
</button>
</div>
<small class="text-muted" style="font-size: 10px;">Provided by HMS Admin for first-time mobile device pairing</small>
</div>
</div>
</div>
@ -222,21 +247,43 @@
</div>
</div>
<!-- Modal for Nurse App QR Code -->
<!-- Modal for Nurse App Pairing & QR Code -->
<div class="modal fade" id="nurseQrModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content text-center">
<div class="modal-header bg-light py-2">
<h5 class="modal-title text-primary fs-6 mb-0" id="qrModalTitle"><i class="bi bi-qr-code-scan me-1"></i> Nurse App QR Code</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content text-center shadow-lg border-0">
<div class="modal-header bg-primary text-white py-2">
<h5 class="modal-title fs-6 mb-0" id="qrModalTitle"><i class="bi bi-phone me-1"></i> NursingCare Mobile App Access</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body py-3">
<h6 class="fw-bold mb-0 text-dark" id="qr_nurse_name">Staff Nurse</h6>
<span class="badge bg-secondary mb-3" id="qr_nurse_code">NUR-001</span>
<div class="p-2 border rounded d-inline-block bg-white shadow-sm mb-3">
<img id="qr_code_img" src="" alt="Nurse App QR Code" style="width: 180px; height: 180px;" />
<h6 class="fw-bold mb-1 text-dark" id="qr_nurse_name">Staff Nurse</h6>
<div class="mb-3">
<span class="badge bg-secondary me-1" id="qr_nurse_code">NUR-001</span>
<span class="badge bg-success-subtle text-success border border-success-subtle"><i class="bi bi-shield-check me-1"></i>Device Auth Secured</span>
</div>
<p class="small text-muted mb-0" style="font-size: 11px;">Scan with mobile phone to open <strong>NursingCare PWA</strong> for this nurse profile.</p>
<div class="p-2 border rounded d-inline-block bg-white shadow-sm mb-3">
<img id="qr_code_img" src="" alt="Nurse App QR Code" style="width: 170px; height: 170px;" />
</div>
<div class="card bg-light border-0 text-start mx-auto p-2 mb-2" style="max-width: 340px;">
<div class="d-flex justify-content-between align-items-center mb-1">
<small class="text-muted">Staff / User ID:</small>
<span class="fw-bold font-monospace text-primary" id="qr_card_nurse_code">-</span>
</div>
<div class="d-flex justify-content-between align-items-center mb-1">
<small class="text-muted">Device Security Code:</small>
<span class="badge bg-warning text-dark font-monospace fs-6 px-2 py-0" id="qr_card_device_code">-</span>
</div>
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted">App Security PIN:</small>
<span class="small text-secondary fst-italic">Configured in Master</span>
</div>
</div>
<p class="small text-muted mb-0" style="font-size: 11px;">
<i class="bi bi-info-circle me-1"></i> Scan to open the app on mobile, then enter the <strong>Staff ID</strong>, <strong>Device Security Code</strong>, and <strong>PIN</strong> to pair this phone.
</p>
</div>
<div class="modal-footer bg-light py-2 justify-content-center">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
@ -249,6 +296,7 @@
(function() {
var saveUrl = '<?= base_url('setting/admin/nurse/save') ?>';
var deleteUrlBase = '<?= base_url('setting/admin/nurse/delete') ?>';
var regenUrl = '<?= base_url('setting/admin/nurse/regenerate-device-code') ?>';
var appBaseUrl = '<?= base_url('app/nursing') ?>';
function resetNurseForm() {
@ -263,6 +311,7 @@
$('#nurse_contact_no').val('');
$('#nurse_email').val('');
$('#nurse_app_pin').val('');
$('#nurse_device_code').val(Math.floor(100000 + Math.random() * 900000).toString());
$('#nurse_department').val('Nursing');
$('#nurse_is_active').val('1');
$('#nurse_form_status').addClass('d-none').text('');
@ -272,11 +321,16 @@
$(document).on('click', '.btn-qr-nurse', function() {
var code = $(this).data('code') || '';
var name = $(this).data('name') || '';
var appUrl = appBaseUrl + '?nurse_code=' + encodeURIComponent(code);
var deviceCode = $(this).data('device') || '';
// Clean URL - No nurse credentials leaked in the public QR code
var appUrl = appBaseUrl;
var qrApiUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=' + encodeURIComponent(appUrl);
$('#qr_nurse_name').text(name);
$('#qr_nurse_code').text(code);
$('#qr_card_nurse_code').text(code);
$('#qr_card_device_code').text(deviceCode || 'N/A');
$('#qr_code_img').attr('src', qrApiUrl);
var modalEl = document.getElementById('nurseQrModal');
@ -287,6 +341,33 @@
}
});
$(document).on('click', '.btn-copy-code', function() {
var code = $(this).data('code') || '';
if (!code) return;
navigator.clipboard.writeText(code).then(function() {
alert('Device Security Code copied: ' + code);
}).catch(function() {
prompt('Copy Device Security Code:', code);
});
});
$('#btn_regen_nurse_code').on('click', function() {
var nurseId = parseInt($('#nurse_id').val(), 10) || 0;
if (nurseId > 0) {
$.post(regenUrl + '/' + nurseId, {}, function(resp) {
if (resp && resp.ok && resp.device_code) {
$('#nurse_device_code').val(resp.device_code);
} else {
$('#nurse_device_code').val(Math.floor(100000 + Math.random() * 900000).toString());
}
}, 'json').fail(function() {
$('#nurse_device_code').val(Math.floor(100000 + Math.random() * 900000).toString());
});
} else {
$('#nurse_device_code').val(Math.floor(100000 + Math.random() * 900000).toString());
}
});
$('#btn_add_nurse').on('click', function() {
resetNurseForm();
});
@ -308,6 +389,7 @@
$('#nurse_qualification').val(data.qualification || '');
$('#nurse_contact_no').val(data.contact_no || '');
$('#nurse_email').val(data.email || '');
$('#nurse_device_code').val(data.device_code || Math.floor(100000 + Math.random() * 900000).toString());
$('#nurse_department').val(data.department || 'Nursing');
$('#nurse_is_active').val(data.is_active !== undefined ? data.is_active : '1');
$('#nurse_form_status').addClass('d-none').text('');

View File

@ -14,6 +14,10 @@
<link href="assets/img/favicon.png" rel="alternate icon" type="image/png">
<link href="assets/img/apple-touch-icon.png" rel="apple-touch-icon">
<meta name="csrf-token-name" content="<?= csrf_token() ?>">
<meta name="csrf-token" content="<?= csrf_hash() ?>">
<meta name="csrf-header" content="<?= config('Security')->headerName ?>">
<link href="https://fonts.gstatic.com" rel="preconnect">
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,600,600i,700,700i|Nunito:300,300i,400,400i,600,600i,700,700i|Poppins:300,300i,400,400i,500,500i,600,600i,700,700i" rel="stylesheet">
@ -896,6 +900,7 @@
<script src="assets/vendor/tinymce/tinymce.min.js"></script>
<script src="assets/vendor/php-email-form/validate.js"></script>
<script src="assets/js/csrf-guard.js"></script>
<script src="assets/js/main.js"></script>
</body>

View File

@ -1,4 +1,6 @@
<?php
$csrfName = csrf_token();
$csrfHash = csrf_hash();
$discharge = $discharge_info[0] ?? null;
$ipd = $ipd_info ?? null;
$canEditDischarge = (bool) ($can_edit_discharge ?? false);
@ -168,8 +170,13 @@ $canManageIpdStatus = auth()->user()?->can('billing.ipd.status.manage') ?? false
}
var ipdId = <?= $ipdId ?>;
var csrfName = '<?= esc($csrfName) ?>';
var csrfHash = '<?= esc($csrfHash) ?>';
function refreshDischargeTab(response) {
if (response && response.csrf_hash) {
csrfHash = response.csrf_hash;
}
if (response && Number(response.update || 0) === 1 && response.html) {
$('#tab_discharge_content').html(response.html);
if (typeof window.notify === 'function') {
@ -185,10 +192,17 @@ $canManageIpdStatus = auth()->user()?->can('billing.ipd.status.manage') ?? false
}
function post(url, payload) {
payload = payload || {};
if (typeof payload === 'object' && !Array.isArray(payload)) {
payload[csrfName] = csrfHash;
}
return $.ajax({
url: url,
type: 'POST',
dataType: 'json',
headers: {
'X-CSRF-TOKEN': csrfHash
},
data: payload
});
}

View File

@ -298,6 +298,59 @@ if ($ageLabel === '') {
var baseUrl = '<?php echo rtrim(base_url(), '/'); ?>/';
var invoiceId = '<?php echo htmlspecialchars((string) ($invoice_id ?? $invoice->inv_id ?? $invoice->id ?? '0')); ?>';
var labType = '<?php echo htmlspecialchars((string) ($lab_type ?? '5')); ?>';
var csrfTokenName = '<?php echo esc(csrf_token()); ?>';
var csrfHash = '<?php echo esc(csrf_hash()); ?>';
// Auto-inject CSRF token into all fetch and jQuery requests within this view
(function() {
if (window.jQuery) {
window.jQuery.ajaxPrefilter(function(options, originalOptions, jqXHR) {
var method = (options.type || 'GET').toUpperCase();
if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') {
jqXHR.setRequestHeader('X-CSRF-TOKEN', csrfHash);
}
});
}
var originalFetch = window.fetch;
window.fetch = function(url, options) {
options = options || {};
var method = (options.method || 'GET').toUpperCase();
if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') {
options.headers = options.headers || {};
if (options.headers instanceof Headers) {
if (!options.headers.has('X-CSRF-TOKEN')) {
options.headers.set('X-CSRF-TOKEN', csrfHash);
}
} else if (typeof options.headers === 'object') {
if (!options.headers['X-CSRF-TOKEN']) {
options.headers['X-CSRF-TOKEN'] = csrfHash;
}
}
if (options.body instanceof FormData) {
if (!options.body.has(csrfTokenName)) {
options.body.append(csrfTokenName, csrfHash);
}
} else if (typeof options.body === 'string' && options.body.indexOf('=') !== -1) {
try {
var params = new URLSearchParams(options.body);
if (!params.has(csrfTokenName)) {
params.append(csrfTokenName, csrfHash);
options.body = params.toString();
}
} catch(e) {}
}
}
return originalFetch.call(this, url, options).then(function(response) {
var newHeader = response.headers.get('X-CSRF-TOKEN') || response.headers.get('x-csrf-token');
if (newHeader) {
csrfHash = newHeader;
}
return response;
});
};
})();
console.log('Page loaded - Invoice ID:', invoiceId, 'Lab Type:', labType);
@ -313,6 +366,7 @@ function updateLabTiming() {
const data = new FormData();
data.append('invoice_id', invoiceId);
data.append('lab_type', labType);
data.append(csrfTokenName, csrfHash);
if (collectedTime) data.append('collected_time', collectedTime);
if (reportedTime) data.append('reported_time', reportedTime);
@ -359,6 +413,7 @@ function saveCollectionTime() {
const data = new FormData();
data.append('invoice_id', invoiceId);
data.append(csrfTokenName, csrfHash);
fetch(baseUrl + 'diagnosis/sample-collection/' + testId + '/' + labType, {
method: 'POST',
@ -1200,6 +1255,7 @@ function onChangeUpdate(cb, itemId) {
function removeTest(reqId) {
if (confirm('Are you sure you want to remove this test?')) {
const formData = new FormData();
formData.append(csrfTokenName, csrfHash);
fetch(baseUrl + 'diagnosis/remove-test/' + reqId, {
method: 'POST',

View File

@ -327,25 +327,17 @@
</div>
</div>
<!-- Step 2: Staff User Selection -->
<div class="mb-3">
<label class="form-label fw-bold small text-secondary mb-1" id="hdr_apps_staff_label">2. SELECT NURSING STAFF PROFILE</label>
<select class="form-select form-select-sm" id="hdr_apps_nurse_select">
<option value="">-- General / All Staff Access --</option>
</select>
</div>
<!-- Step 3: QR Code & Access Link -->
<!-- Step 2: App Launch QR Code & Secure Intranet Link -->
<div class="p-3 bg-light rounded border text-center">
<div class="fw-bold small text-dark mb-1" id="hdr_apps_target_name">Nursing Care App</div>
<span class="badge bg-secondary mb-2" id="hdr_apps_target_code">/app/nursing</span>
<div class="my-2">
<img id="hdr_apps_qr_img" src="" alt="App QR Code" style="width: 160px; height: 160px; border-radius: 8px;" class="border p-1 bg-white shadow-sm" />
<img id="hdr_apps_qr_img" src="" alt="App QR Code" style="width: 170px; height: 170px; border-radius: 8px;" class="border p-1 bg-white shadow-sm" />
</div>
<div class="alert alert-info py-2 px-3 small mb-2 text-start" style="font-size: 11px;">
<i class="bi bi-shield-lock-fill me-1 text-primary"></i> <strong>Secure Staff Authentication:</strong> Staff will be prompted to enter their <strong>Staff ID / Username</strong>, <strong>PIN / Password</strong>, and one-time <strong>Device Security Code</strong> (set in Staff Master).
</div>
<p class="small text-muted mb-2" style="font-size: 11px;">
<i class="bi bi-qr-code-scan me-1"></i> Scan with Phone Camera to launch app on Mobile, or click link below to open on Computer.
</p>
<div class="input-group input-group-sm mb-2">
<input type="text" class="form-control form-control-sm text-center" id="hdr_apps_url_input" readonly />
@ -373,11 +365,7 @@
}
function updateHdrAppDetails() {
var sel = document.getElementById('hdr_apps_nurse_select');
var code = sel ? sel.value : '';
var appBaseUrl = getAppBaseUrl();
var paramKey = (selectedApp === 'doctor') ? 'doctor_id=' : 'nurse_code=';
var appUrl = (selectedApp === 'pharmacy') ? appBaseUrl : (appBaseUrl + (code ? '?' + paramKey + encodeURIComponent(code) : ''));
var appUrl = getAppBaseUrl();
var qrUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=' + encodeURIComponent(appUrl);
var qrImg = document.getElementById('hdr_apps_qr_img');
@ -391,15 +379,10 @@
if (launchLink) launchLink.href = appUrl;
var title = (selectedApp === 'doctor') ? 'DoctorCare EMR App' : ((selectedApp === 'pharmacy') ? 'Medical Store & Pharmacy POS' : 'Nursing Care App');
var path = (selectedApp === 'pharmacy') ? '/MedicalStore' : ('/app/' + selectedApp);
if (code && sel && sel.options[sel.selectedIndex]) {
var text = sel.options[sel.selectedIndex].text;
if (targetName) targetName.textContent = title + ': ' + text;
if (targetCode) targetCode.textContent = code;
} else {
if (targetName) targetName.textContent = title;
if (targetCode) targetCode.textContent = (selectedApp === 'pharmacy') ? '/MedicalStore' : ('/app/' + selectedApp);
}
if (targetName) targetName.textContent = title;
if (targetCode) targetCode.textContent = path;
}
function showAppsModal() {
@ -416,53 +399,8 @@
}
}
function loadAppStaffList() {
var sel = document.getElementById('hdr_apps_nurse_select');
var label = document.getElementById('hdr_apps_staff_label');
if (!sel) return;
if (selectedApp === 'pharmacy') {
if (label) label.textContent = '2. PHARMACY COUNTER / STORE ACCESS';
sel.innerHTML = '<option value="">-- All Pharmacy Counters (Auto-detect) --</option>';
updateHdrAppDetails();
return;
} else if (selectedApp === 'doctor') {
if (label) label.textContent = '2. SELECT DOCTOR PROFILE';
fetch('<?= base_url('api/v1/doctor/list') ?>')
.then(function(r) { return r.json(); })
.then(function(resp) {
sel.innerHTML = '<option value="">-- General / All Doctor Access --</option>';
if (resp && resp.status === 1 && Array.isArray(resp.data)) {
resp.data.forEach(function(d) {
var opt = document.createElement('option');
opt.value = d.id;
opt.textContent = d.name;
sel.appendChild(opt);
});
}
updateHdrAppDetails();
});
} else {
if (label) label.textContent = '2. SELECT NURSING STAFF PROFILE';
fetch('<?= base_url('api/v1/nursing/nurses') ?>')
.then(function(r) { return r.json(); })
.then(function(resp) {
sel.innerHTML = '<option value="">-- General / All Staff Access --</option>';
if (resp && resp.status === 1 && Array.isArray(resp.data)) {
resp.data.forEach(function(n) {
var opt = document.createElement('option');
opt.value = n.nurse_code;
opt.textContent = '[' + n.nurse_code + '] ' + n.name;
sel.appendChild(opt);
});
}
updateHdrAppDetails();
});
}
}
function openAppsModal() {
loadAppStaffList();
updateHdrAppDetails();
showAppsModal();
}
@ -485,7 +423,7 @@
});
appCard.classList.remove('border-secondary', 'bg-light', 'text-dark');
appCard.classList.add('border-primary', 'bg-primary-subtle', 'text-primary');
loadAppStaffList();
updateHdrAppDetails();
}
return;
}
@ -505,11 +443,5 @@
}
}
});
document.addEventListener('change', function(e) {
if (e.target && e.target.id === 'hdr_apps_nurse_select') {
updateHdrAppDetails();
}
});
})();
</script>

View File

@ -14,6 +14,10 @@
<link href="<?= base_url('assets/img/favicon.png') ?>" rel="alternate icon" type="image/png">
<link href="<?= base_url('assets/img/apple-touch-icon.png') ?>" rel="apple-touch-icon">
<meta name="csrf-token-name" content="<?= csrf_token() ?>">
<meta name="csrf-token" content="<?= csrf_hash() ?>">
<meta name="csrf-header" content="<?= config('Security')->headerName ?>">
<!-- Google Fonts removed: offline app uses system fonts -->
<link href="<?= base_url('assets/vendor/bootstrap/css/bootstrap.min.css') ?>" rel="stylesheet">
@ -149,6 +153,7 @@
<a href="#" class="back-to-top d-flex align-items-center justify-content-center"><i class="bi bi-arrow-up-short"></i></a>
<script src="<?= base_url('assets/vendor/jquery/jquery-4.0.0.min.js') ?>"></script>
<script src="<?= base_url('assets/js/csrf-guard.js') ?>"></script>
<script src="<?= base_url('assets/vendor/inputmask/inputmask.min.js') ?>"></script>
<script src="<?= base_url('assets/vendor/inputmask/jquery.inputmask.min.js') ?>"></script>
<script src="<?= base_url('assets/vendor/jquery-ui/jquery-ui.min.js') ?>"></script>

View File

@ -28,8 +28,10 @@ echo "=== Deploying to server ==="
# with "syntax error, unexpected ')'" parse errors from an incompatible PHP CLI.
REMOTE_CMDS="cd $PROJECT && git config core.fileMode false && git pull origin main && PHP_BIN=\$(command -v php83 || command -v php) && \$PHP_BIN spark migrate --namespace App && PHP_BIN=\$PHP_BIN bash scripts/install_hms_cron.sh $PROJECT && echo DEPLOY_OK"
if command -v plink &>/dev/null; then
if command -v plink &>/dev/null && [ -n "$HMS_SSH_PASS" ]; then
plink -ssh -batch -pw "$HMS_SSH_PASS" $SERVER "$REMOTE_CMDS"
elif ssh -q -o BatchMode=yes -o ConnectTimeout=3 hms-live exit &>/dev/null; then
ssh -o StrictHostKeyChecking=no hms-live "$REMOTE_CMDS"
else
ssh -o StrictHostKeyChecking=no $SERVER "$REMOTE_CMDS"
fi

View File

@ -425,11 +425,23 @@
const [installPrompt, setInstallPrompt] = useState(null);
// Auth state
// Auth & Device Security State
const PAIRED_DOC_KEY = 'hms_doctor_paired_profile';
const [authenticatedDoctor, setAuthenticatedDoctor] = useState(null);
const [authDocId, setAuthDocId] = useState(0);
const [pairedDoctor, setPairedDoctor] = useState(() => {
try {
const item = localStorage.getItem(PAIRED_DOC_KEY);
return item ? JSON.parse(item) : null;
} catch (e) {
return null;
}
});
const [docIdInput, setDocIdInput] = useState('');
const [deviceCodeInput, setDeviceCodeInput] = useState('');
const [pinInput, setPinInput] = useState('');
const [pinError, setPinError] = useState('');
const [showPairingForm, setShowPairingForm] = useState(false);
const [isVerifying, setIsVerifying] = useState(false);
const [isSettingNewPin, setIsSettingNewPin] = useState(false);
const [newPin, setNewPin] = useState('');
const [confirmPin, setConfirmPin] = useState('');
@ -464,24 +476,6 @@
const DRUG_CHIPS = ['Paracetamol 500mg', 'Amoxicillin 500mg', 'Pantoprazole 40mg', 'Cetirizine 10mg', 'Ibuprofen 400mg', 'Azithromycin 500mg', 'Multivitamin'];
const LAB_CHIPS = ['CBC', 'Serum Creatinine', 'LFT', 'KFT', 'Chest X-Ray PA', 'ECG', 'Urine RE', 'HbA1c', 'USG Abdomen'];
const fetchDoctors = async () => {
try {
const res = await fetch('/api/v1/doctor/list');
const json = await res.json();
if (json.status === 1 && json.data) {
setDoctors(json.data);
const params = new URLSearchParams(window.location.search);
const docIdParam = params.get('doctor_id');
if (docIdParam) {
setAuthDocId(docIdParam);
}
}
} catch (err) {
console.error('Failed to load doctors', err);
}
};
const fetchIpdPatients = async (docId) => {
try {
setLoading(true);
@ -528,7 +522,6 @@
}
} catch (e) {}
}
fetchDoctors();
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
@ -543,20 +536,32 @@
}, []);
const handleVerifyPin = async () => {
if (!authDocId || authDocId === '0') {
setPinError('Please select your Doctor Profile.');
const isPairing = !pairedDoctor || showPairingForm;
const targetDocId = isPairing ? docIdInput.trim() : (pairedDoctor.doc_id || '');
if (!targetDocId) {
setPinError('Please enter your Doctor ID or Username.');
return;
}
if (isPairing && (!deviceCodeInput || deviceCodeInput.trim().length < 4)) {
setPinError('Please enter the 6-digit Device Security Code from Doctor Master.');
return;
}
if (!pinInput || pinInput.length < 4) {
setPinError('Enter your 4 to 6 digit Security PIN.');
setPinError('Enter your 4 to 6 digit Security PIN or Password.');
return;
}
setPinError('');
setIsVerifying(true);
try {
const body = new URLSearchParams();
body.append('doctor_id', authDocId);
body.append('doctor_id', targetDocId);
body.append('pin', pinInput);
if (isPairing) {
body.append('device_code', deviceCodeInput.trim());
} else if (pairedDoctor && pairedDoctor.device_token) {
body.append('device_token', pairedDoctor.device_token);
}
const res = await fetch('/api/v1/doctor/auth/verify-pin', {
method: 'POST',
@ -573,16 +578,32 @@
expires_at: Date.now() + THIRTY_DAYS_MS
};
localStorage.setItem(DOC_DEVICE_AUTH_KEY, JSON.stringify(deviceAuthObj));
const pairedObj = {
id: json.doctor.id,
doc_id: json.doctor.id ? ('DOC-' + json.doctor.id) : targetDocId,
name: json.doctor.name,
device_token: json.device_token || ''
};
localStorage.setItem(PAIRED_DOC_KEY, JSON.stringify(pairedObj));
setPairedDoctor(pairedObj);
setShowPairingForm(false);
setPinInput('');
setDeviceCodeInput('');
fetchIpdPatients(json.doctor.id);
fetchOpdAppointments(json.doctor.id, opdDateMode);
} else if (json.status === 2) {
setIsSettingNewPin(true);
} else {
setPinError(json.message || 'Invalid Security PIN');
if (json.need_device_code) {
setShowPairingForm(true);
}
setPinError(json.message || 'Invalid Security PIN or Device Code');
}
} catch (err) {
setPinError('Connection error verifying PIN.');
setPinError('Connection error verifying credentials.');
} finally {
setIsVerifying(false);
}
};
@ -597,8 +618,9 @@
}
try {
const targetDocId = pairedDoctor ? pairedDoctor.id : (authenticatedDoctor ? authenticatedDoctor.id : 0);
const body = new URLSearchParams();
body.append('doctor_id', authDocId || (authenticatedDoctor ? authenticatedDoctor.id : 0));
body.append('doctor_id', targetDocId);
body.append('new_pin', newPin);
const res = await fetch('/api/v1/doctor/auth/set-pin', {
@ -630,6 +652,20 @@
setActiveOpd(null);
};
const handleUnpairDevice = () => {
if (confirm('Unpair this device? You will need the Doctor ID and Device Security Code to pair it again.')) {
setAuthenticatedDoctor(null);
setPairedDoctor(null);
localStorage.removeItem(DOC_DEVICE_AUTH_KEY);
localStorage.removeItem(PAIRED_DOC_KEY);
setShowPairingForm(true);
setPinInput('');
setDocIdInput('');
setDeviceCodeInput('');
setPinError('');
}
};
const openIpdWorkspace = async (patient) => {
setActivePatient(patient);
clearCapturedImage();
@ -895,9 +931,11 @@
};
// -------------------------------------------------------------
// RENDER: LOCK SCREEN (PIN AUTHENTICATION)
// RENDER: LOCK SCREEN (DEVICE REGISTRATION & PIN AUTHENTICATION)
// -------------------------------------------------------------
if (!authenticatedDoctor) {
const isQuickUnlock = Boolean(pairedDoctor && !showPairingForm);
return (
<div className="lock-screen-overlay">
<MobileAppsModal show={showAppsModal} onClose={() => setShowAppsModal(false)} currentApp="doctor" />
@ -914,49 +952,169 @@
</button>
<img src="/assets/img/logo.png" alt="Hospital Logo" className="d-block mx-auto mb-2" style={{maxHeight: '40px', filter: 'brightness(0) invert(1)'}} onError={(e) => { e.target.style.display = 'none'; }} />
<h5 className="fw-bold mb-1">Doctor EMR Mobile Portal</h5>
<small className="opacity-75">30-Day Device Authentication Security</small>
<div className="d-flex align-items-center justify-content-center gap-1 opacity-75 small">
<i className="bi bi-shield-check text-success"></i>
<span>Hospital Intranet &amp; Device Protected</span>
</div>
</div>
<div className="p-4">
{pinError && <div className="alert alert-danger py-2 px-3 small mb-3">{pinError}</div>}
{!isSettingNewPin ? (
<div>
<div className="mb-3">
<label className="form-label fw-bold small">Select Doctor Profile</label>
<select
className="form-select form-select-lg fs-6"
value={authDocId}
onChange={(e) => setAuthDocId(e.target.value)}
isQuickUnlock ? (
/* -------------------------------------------------------------
PAIRED DEVICE QUICK UNLOCK
------------------------------------------------------------- */
<div>
<div className="text-center mb-4">
<div className="avatar-circle mx-auto mb-2" style={{width: '54px', height: '54px', fontSize: '18px'}}>
{getInitials(pairedDoctor.name || pairedDoctor.doc_id)}
</div>
<h6 className="fw-bold text-dark mb-0">{pairedDoctor.name || 'Doctor'}</h6>
<span className="badge bg-secondary-subtle text-dark border font-monospace mt-1">
{pairedDoctor.doc_id}
</span>
<div className="text-muted small mt-1" style={{fontSize: '11px'}}>
<i className="bi bi-phone-fill me-1 text-primary"></i> Trusted Hospital Device
</div>
</div>
<div className="mb-4">
<label className="form-label fw-bold small text-muted">Enter PIN or Password to Unlock</label>
<input
type="password"
maxLength="20"
className="form-control text-center fs-3 letter-spacing-2"
placeholder="••••"
value={pinInput}
autoFocus
onChange={(e) => setPinInput(e.target.value)}
onKeyUp={(e) => e.key === 'Enter' && handleVerifyPin()}
/>
<small className="text-muted text-center d-block mt-1" style={{fontSize: '11px'}}>
30-day verified session active
</small>
</div>
<button
className="btn btn-primary btn-lg w-100 py-3 fw-bold shadow-sm mb-2"
onClick={handleVerifyPin}
disabled={isVerifying}
>
<option value="0">-- Choose Doctor Profile --</option>
{doctors.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
</div>
{isVerifying ? (
<span><span className="spinner-border spinner-border-sm me-2"></span>Verifying...</span>
) : (
<span><i className="bi bi-unlock-fill me-2"></i> Unlock Doctor Portal</span>
)}
</button>
<div className="mb-4">
<label className="form-label fw-bold small">Enter App Security PIN</label>
<input
type="password"
maxLength="6"
className="form-control text-center fs-3 letter-spacing-2"
placeholder="••••"
value={pinInput}
onChange={(e) => setPinInput(e.target.value)}
onKeyUp={(e) => e.key === 'Enter' && handleVerifyPin()}
/>
<small className="text-muted text-center d-block mt-1" style={{fontSize: '11px'}}>
Unlocks device access for 30 days
</small>
<div className="text-center mt-3 pt-2 border-top">
<button
type="button"
className="btn btn-link btn-sm text-secondary text-decoration-none p-0"
onClick={() => { setShowPairingForm(true); setPinError(''); }}
>
<i className="bi bi-arrow-repeat me-1"></i> Switch Doctor / Re-Pair Device
</button>
</div>
</div>
) : (
/* -------------------------------------------------------------
FIRST-TIME DEVICE REGISTRATION / PAIRING FORM
------------------------------------------------------------- */
<div>
<div className="alert alert-primary py-2 px-3 small d-flex align-items-center gap-2 mb-3">
<i className="bi bi-shield-lock-fill fs-5 text-primary"></i>
<div>
<div className="fw-bold">First-Time Device Pairing</div>
<div style={{fontSize: '11px'}}>Enter Doctor ID and Device Security Code from Doctor Master.</div>
</div>
</div>
<button className="btn btn-primary btn-lg w-100 py-3 fw-bold shadow-sm" onClick={handleVerifyPin}>
<i className="bi bi-unlock-fill me-2"></i> Unlock Doctor Portal
</button>
</div>
<div className="mb-3">
<label className="form-label fw-bold small text-dark mb-1">
Doctor ID or Username <span className="text-danger">*</span>
</label>
<div className="input-group">
<span className="input-group-text bg-light"><i className="bi bi-person-badge"></i></span>
<input
type="text"
className="form-control"
placeholder="e.g. DOC-123 or doctor username"
value={docIdInput}
onChange={(e) => setDocIdInput(e.target.value)}
/>
</div>
</div>
<div className="mb-3">
<label className="form-label fw-bold small text-dark mb-1">
Device Security Code <span className="text-danger">*</span>
</label>
<div className="input-group">
<span className="input-group-text bg-light"><i className="bi bi-key-fill text-warning"></i></span>
<input
type="text"
maxLength="10"
className="form-control font-monospace fw-bold"
placeholder="6-digit code from Admin"
value={deviceCodeInput}
onChange={(e) => setDeviceCodeInput(e.target.value)}
/>
</div>
<small className="text-muted" style={{fontSize: '11px'}}>
Displayed on HMS Admin &gt; Doctors
</small>
</div>
<div className="mb-4">
<label className="form-label fw-bold small text-dark mb-1">
App PIN or Password <span className="text-danger">*</span>
</label>
<div className="input-group">
<span className="input-group-text bg-light"><i className="bi bi-lock-fill"></i></span>
<input
type="password"
maxLength="20"
className="form-control"
placeholder="••••"
value={pinInput}
onChange={(e) => setPinInput(e.target.value)}
onKeyUp={(e) => e.key === 'Enter' && handleVerifyPin()}
/>
</div>
</div>
<button
className="btn btn-primary btn-lg w-100 py-3 fw-bold shadow-sm"
onClick={handleVerifyPin}
disabled={isVerifying}
>
{isVerifying ? (
<span><span className="spinner-border spinner-border-sm me-2"></span>Pairing Device...</span>
) : (
<span><i className="bi bi-shield-check me-2"></i> Register Device &amp; Unlock</span>
)}
</button>
{pairedDoctor && (
<div className="text-center mt-3 pt-2 border-top">
<button
type="button"
className="btn btn-link btn-sm text-secondary text-decoration-none p-0"
onClick={() => { setShowPairingForm(false); setPinError(''); }}
>
<i className="bi bi-arrow-left me-1"></i> Back to Quick Unlock
</button>
</div>
)}
</div>
)
) : (
/* -------------------------------------------------------------
INITIAL PIN CREATION (IF DOCTOR DOES NOT HAVE A PIN YET)
------------------------------------------------------------- */
<div>
<div className="alert alert-info py-2 small mb-3">
Initial PIN Setup for Doctor Profile. Set a 4-6 digit Security PIN.

View File

@ -513,11 +513,23 @@
const [wellnessHistoryList, setWellnessHistoryList] = useState([]);
const [loadingHistory, setLoadingHistory] = useState(false);
// Auth State
// Auth & Device Security State
const PAIRED_NURSE_KEY = 'hms_nursing_paired_profile';
const [authenticatedNurse, setAuthenticatedNurse] = useState(null);
const [authNurseId, setAuthNurseId] = useState(0);
const [pairedNurse, setPairedNurse] = useState(() => {
try {
const item = localStorage.getItem(PAIRED_NURSE_KEY);
return item ? JSON.parse(item) : null;
} catch (e) {
return null;
}
});
const [staffIdInput, setStaffIdInput] = useState('');
const [deviceCodeInput, setDeviceCodeInput] = useState('');
const [pinInput, setPinInput] = useState('');
const [pinError, setPinError] = useState('');
const [showPairingForm, setShowPairingForm] = useState(false);
const [isVerifying, setIsVerifying] = useState(false);
const [isSettingNewPin, setIsSettingNewPin] = useState(false);
const [newPin, setNewPin] = useState('');
const [confirmPin, setConfirmPin] = useState('');
@ -585,27 +597,6 @@
}
};
const fetchNurses = async () => {
try {
const res = await fetch('/api/v1/nursing/nurses');
const json = await res.json();
if (json.status === 1 && json.data) {
setNurses(json.data);
const params = new URLSearchParams(window.location.search);
const codeParam = params.get('nurse_code');
if (codeParam) {
const matched = json.data.find(n => n.nurse_code === codeParam);
if (matched) {
setAuthNurseId(matched.id);
}
}
}
} catch (err) {
console.error('Failed to load nurses', err);
}
};
useEffect(() => {
const savedAuth = localStorage.getItem(DEVICE_AUTH_KEY);
if (savedAuth) {
@ -619,26 +610,37 @@
} catch (e) {}
}
fetchBeds();
fetchNurses();
fetchDoctors();
fetchOpdAppointments('today', '0', 'all');
}, []);
const handleVerifyPin = async () => {
if (!authNurseId || authNurseId === '0') {
setPinError('Please select your Nursing Staff Profile.');
const isPairing = !pairedNurse || showPairingForm;
const targetStaffId = isPairing ? staffIdInput.trim() : (pairedNurse.staff_id || '');
if (!targetStaffId) {
setPinError('Please enter your Staff ID or Username.');
return;
}
if (isPairing && (!deviceCodeInput || deviceCodeInput.trim().length < 4)) {
setPinError('Please enter the 6-digit Device Security Code from Nursing Staff Master.');
return;
}
if (!pinInput || pinInput.length < 4) {
setPinError('Enter your 4 to 6 digit Security PIN.');
setPinError('Enter your 4 to 6 digit Security PIN or Password.');
return;
}
setPinError('');
setIsVerifying(true);
try {
const body = new URLSearchParams();
body.append('nurse_id', authNurseId);
body.append('staff_id', targetStaffId);
body.append('pin', pinInput);
if (isPairing) {
body.append('device_code', deviceCodeInput.trim());
} else if (pairedNurse && pairedNurse.device_token) {
body.append('device_token', pairedNurse.device_token);
}
const res = await fetch('/api/v1/nursing/auth/verify-pin', {
method: 'POST',
@ -654,16 +656,32 @@
expires_at: Date.now() + THIRTY_DAYS_MS
};
localStorage.setItem(DEVICE_AUTH_KEY, JSON.stringify(deviceAuthObj));
const pairedObj = {
id: json.nurse.id,
staff_id: json.nurse.nurse_code || targetStaffId,
name: json.nurse.name,
device_token: json.device_token || ''
};
localStorage.setItem(PAIRED_NURSE_KEY, JSON.stringify(pairedObj));
setPairedNurse(pairedObj);
setShowPairingForm(false);
setPinInput('');
setDeviceCodeInput('');
fetchBeds();
fetchOpdAppointments(opdDateMode, selectedDoctorId, vitalsFilter);
} else if (json.status === 2) {
setIsSettingNewPin(true);
} else {
setPinError(json.message || 'Incorrect Security PIN');
if (json.need_device_code) {
setShowPairingForm(true);
}
setPinError(json.message || 'Incorrect Security PIN or Device Code.');
}
} catch (err) {
setPinError('Connection error verifying PIN.');
} finally {
setIsVerifying(false);
}
};
@ -678,8 +696,10 @@
}
try {
const targetNurseId = pairedNurse ? pairedNurse.id : (authenticatedNurse ? authenticatedNurse.id : 0);
const body = new URLSearchParams();
body.append('nurse_id', authNurseId || (authenticatedNurse ? authenticatedNurse.id : 0));
body.append('nurse_id', targetNurseId);
body.append('staff_id', staffIdInput.trim() || (pairedNurse ? pairedNurse.staff_id : ''));
body.append('new_pin', newPin);
const res = await fetch('/api/v1/nursing/auth/set-pin', {
@ -710,6 +730,20 @@
setActiveBed(null);
};
const handleUnpairDevice = () => {
if (confirm('Unpair this device? You will need the Staff ID and Device Security Code to pair it again.')) {
setAuthenticatedNurse(null);
setPairedNurse(null);
localStorage.removeItem(DEVICE_AUTH_KEY);
localStorage.removeItem(PAIRED_NURSE_KEY);
setShowPairingForm(true);
setPinInput('');
setStaffIdInput('');
setDeviceCodeInput('');
setPinError('');
}
};
const openWorkspace = async (bed) => {
if (!bed.ipd_id || bed.status !== 'occupied') return;
setActiveBed(bed);
@ -1266,9 +1300,11 @@
});
// -------------------------------------------------------------
// LOCK SCREEN
// LOCK SCREEN (DEVICE REGISTRATION & PIN AUTHENTICATION)
// -------------------------------------------------------------
if (!authenticatedNurse) {
const isQuickUnlock = Boolean(pairedNurse && !showPairingForm);
return (
<div className="lock-screen-overlay">
<MobileAppsModal show={showAppsModal} onClose={() => setShowAppsModal(false)} currentApp="nursing" />
@ -1285,49 +1321,169 @@
</button>
<img src="/assets/img/logo.png" alt="Hospital Logo" className="d-block mx-auto mb-2" style={{maxHeight: '40px', filter: 'brightness(0) invert(1)'}} onError={(e) => { e.target.style.display = 'none'; }} />
<h5 className="fw-bold mb-1">Nursing Care Mobile App</h5>
<small className="opacity-75">30-Day Device Security Authentication</small>
<div className="d-flex align-items-center justify-content-center gap-1 opacity-75 small">
<i className="bi bi-shield-check text-success"></i>
<span>Hospital Intranet &amp; Device Protected</span>
</div>
</div>
<div className="p-4">
{pinError && <div className="alert alert-danger py-2 px-3 small mb-3">{pinError}</div>}
{!isSettingNewPin ? (
<div>
<div className="mb-3">
<label className="form-label fw-bold small">Select Nursing Staff Profile</label>
<select
className="form-select form-select-lg fs-6"
value={authNurseId}
onChange={(e) => setAuthNurseId(e.target.value)}
isQuickUnlock ? (
/* -------------------------------------------------------------
PAIRED DEVICE QUICK UNLOCK
------------------------------------------------------------- */
<div>
<div className="text-center mb-4">
<div className="avatar-circle mx-auto mb-2" style={{width: '54px', height: '54px', fontSize: '18px'}}>
{getInitials(pairedNurse.name || pairedNurse.staff_id)}
</div>
<h6 className="fw-bold text-dark mb-0">{pairedNurse.name || 'Staff Nurse'}</h6>
<span className="badge bg-secondary-subtle text-dark border font-monospace mt-1">
{pairedNurse.staff_id}
</span>
<div className="text-muted small mt-1" style={{fontSize: '11px'}}>
<i className="bi bi-phone-fill me-1 text-primary"></i> Trusted Hospital Device
</div>
</div>
<div className="mb-4">
<label className="form-label fw-bold small text-muted">Enter PIN or Password to Unlock</label>
<input
type="password"
maxLength="20"
className="form-control text-center fs-3 letter-spacing-2"
placeholder="••••"
value={pinInput}
autoFocus
onChange={(e) => setPinInput(e.target.value)}
onKeyUp={(e) => e.key === 'Enter' && handleVerifyPin()}
/>
<small className="text-muted text-center d-block mt-1" style={{fontSize: '11px'}}>
30-day verified session active
</small>
</div>
<button
className="btn btn-success btn-lg w-100 py-3 fw-bold shadow-sm mb-2"
onClick={handleVerifyPin}
disabled={isVerifying}
>
<option value="0">-- Choose Nurse Profile --</option>
{nurses.map(n => (
<option key={n.id} value={n.id}>[{n.nurse_code}] {n.name}</option>
))}
</select>
</div>
{isVerifying ? (
<span><span className="spinner-border spinner-border-sm me-2"></span>Verifying...</span>
) : (
<span><i className="bi bi-unlock-fill me-2"></i> Unlock Nursing App</span>
)}
</button>
<div className="mb-4">
<label className="form-label fw-bold small">Enter App Security PIN</label>
<input
type="password"
maxLength="6"
className="form-control text-center fs-3"
placeholder="••••"
value={pinInput}
onChange={(e) => setPinInput(e.target.value)}
onKeyUp={(e) => e.key === 'Enter' && handleVerifyPin()}
/>
<small className="text-muted text-center d-block mt-1" style={{fontSize: '11px'}}>
Unlocks device access for 30 days
</small>
<div className="text-center mt-3 pt-2 border-top">
<button
type="button"
className="btn btn-link btn-sm text-secondary text-decoration-none p-0"
onClick={() => { setShowPairingForm(true); setPinError(''); }}
>
<i className="bi bi-arrow-repeat me-1"></i> Switch Account / Re-Pair Device
</button>
</div>
</div>
) : (
/* -------------------------------------------------------------
FIRST-TIME DEVICE REGISTRATION / PAIRING FORM
------------------------------------------------------------- */
<div>
<div className="alert alert-primary py-2 px-3 small d-flex align-items-center gap-2 mb-3">
<i className="bi bi-shield-lock-fill fs-5 text-primary"></i>
<div>
<div className="fw-bold">First-Time Device Pairing</div>
<div style={{fontSize: '11px'}}>Enter Staff ID and Device Security Code from Nursing Staff Master.</div>
</div>
</div>
<button className="btn btn-success btn-lg w-100 py-3 fw-bold shadow-sm" onClick={handleVerifyPin}>
<i className="bi bi-unlock-fill me-2"></i> Unlock Nursing App
</button>
</div>
<div className="mb-3">
<label className="form-label fw-bold small text-dark mb-1">
Staff ID or Username <span className="text-danger">*</span>
</label>
<div className="input-group">
<span className="input-group-text bg-light"><i className="bi bi-person-badge"></i></span>
<input
type="text"
className="form-control"
placeholder="e.g. NUR-637 or username"
value={staffIdInput}
onChange={(e) => setStaffIdInput(e.target.value)}
/>
</div>
</div>
<div className="mb-3">
<label className="form-label fw-bold small text-dark mb-1">
Device Security Code <span className="text-danger">*</span>
</label>
<div className="input-group">
<span className="input-group-text bg-light"><i className="bi bi-key-fill text-warning"></i></span>
<input
type="text"
maxLength="10"
className="form-control font-monospace fw-bold"
placeholder="6-digit code from Admin"
value={deviceCodeInput}
onChange={(e) => setDeviceCodeInput(e.target.value)}
/>
</div>
<small className="text-muted" style={{fontSize: '11px'}}>
Displayed on HMS Admin &gt; Nursing Staff Master
</small>
</div>
<div className="mb-4">
<label className="form-label fw-bold small text-dark mb-1">
App PIN or Password <span className="text-danger">*</span>
</label>
<div className="input-group">
<span className="input-group-text bg-light"><i className="bi bi-lock-fill"></i></span>
<input
type="password"
maxLength="20"
className="form-control"
placeholder="••••"
value={pinInput}
onChange={(e) => setPinInput(e.target.value)}
onKeyUp={(e) => e.key === 'Enter' && handleVerifyPin()}
/>
</div>
</div>
<button
className="btn btn-primary btn-lg w-100 py-3 fw-bold shadow-sm"
onClick={handleVerifyPin}
disabled={isVerifying}
>
{isVerifying ? (
<span><span className="spinner-border spinner-border-sm me-2"></span>Pairing Device...</span>
) : (
<span><i className="bi bi-shield-check me-2"></i> Register Device &amp; Unlock</span>
)}
</button>
{pairedNurse && (
<div className="text-center mt-3 pt-2 border-top">
<button
type="button"
className="btn btn-link btn-sm text-secondary text-decoration-none p-0"
onClick={() => { setShowPairingForm(false); setPinError(''); }}
>
<i className="bi bi-arrow-left me-1"></i> Back to Quick Unlock
</button>
</div>
)}
</div>
)
) : (
/* -------------------------------------------------------------
INITIAL PIN CREATION (IF NURSE DOES NOT HAVE A PIN YET)
------------------------------------------------------------- */
<div>
<div className="alert alert-info py-2 small mb-3">
Initial PIN Setup for Nurse Profile. Set a 4-6 digit Security PIN.

View File

@ -0,0 +1,336 @@
/**
* CSRF Guard - Universal Cross-Site Request Forgery Protection Handler
*
* Automatically protects ALL HTTP requests across the application:
* 1. jQuery AJAX ($.ajax, $.post, $.get with payload)
* 2. Fetch API (window.fetch)
* 3. XMLHttpRequest (raw XHR, axios, third-party libraries)
* 4. HTML Forms (native submit & programmatic form.submit())
*/
(function(window, document) {
'use strict';
var DEFAULT_TOKEN_NAME = 'csrf_test_name';
var DEFAULT_HEADER_NAME = 'X-CSRF-TOKEN';
var currentToken = '';
var tokenName = DEFAULT_TOKEN_NAME;
var headerName = DEFAULT_HEADER_NAME;
function getCookie(name) {
if (!document.cookie) return null;
var parts = document.cookie.split(';');
for (var i = 0; i < parts.length; i++) {
var c = parts[i].trim();
if (c.indexOf(name + '=') === 0) {
return decodeURIComponent(c.substring(name.length + 1));
}
}
return null;
}
function readMetaTags() {
var metaToken = document.querySelector('meta[name="csrf-token"]') ||
document.querySelector('meta[name="X-CSRF-TOKEN"]') ||
document.querySelector('meta[name="csrf-hash"]');
if (metaToken && metaToken.getAttribute('content')) {
currentToken = metaToken.getAttribute('content');
}
var metaName = document.querySelector('meta[name="csrf-token-name"]');
if (metaName && metaName.getAttribute('content')) {
tokenName = metaName.getAttribute('content');
}
var metaHeader = document.querySelector('meta[name="csrf-header"]');
if (metaHeader && metaHeader.getAttribute('content')) {
headerName = metaHeader.getAttribute('content');
}
if (!currentToken) {
var cookieToken = getCookie('csrf_cookie_name') || getCookie('csrf_test_name');
if (cookieToken) {
currentToken = cookieToken;
}
}
}
readMetaTags();
function updateToken(newToken) {
if (!newToken || typeof newToken !== 'string' || newToken === currentToken) {
return;
}
currentToken = newToken;
// Update meta tag
var metaToken = document.querySelector('meta[name="csrf-token"]') ||
document.querySelector('meta[name="X-CSRF-TOKEN"]') ||
document.querySelector('meta[name="csrf-hash"]');
if (metaToken) {
metaToken.setAttribute('content', newToken);
}
// Update all existing hidden inputs across DOM
var inputs = document.querySelectorAll('input[name="' + tokenName + '"]');
for (var i = 0; i < inputs.length; i++) {
inputs[i].value = newToken;
}
// Update global jQuery ajaxSetup if available
if (window.jQuery && window.jQuery.ajaxSetup) {
var headers = {};
headers[headerName] = newToken;
window.jQuery.ajaxSetup({ headers: headers });
}
}
var CsrfGuard = {
getToken: function() {
if (!currentToken) readMetaTags();
return currentToken;
},
getTokenName: function() {
return tokenName;
},
getHeaderName: function() {
return headerName;
},
updateToken: updateToken,
init: function(config) {
if (config) {
if (config.token) currentToken = config.token;
if (config.tokenName) tokenName = config.tokenName;
if (config.headerName) headerName = config.headerName;
}
readMetaTags();
this.setupJQuery();
},
setupJQuery: function() {
if (!window.jQuery) return;
var $ = window.jQuery;
if ($._csrfGuardInstalled) return;
$._csrfGuardInstalled = true;
var defaultHeaders = {};
if (currentToken) {
defaultHeaders[headerName] = currentToken;
}
$.ajaxSetup({
headers: defaultHeaders
});
$.ajaxPrefilter(function(options, originalOptions, jqXHR) {
var method = (options.type || options.method || 'GET').toUpperCase();
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
return;
}
var token = CsrfGuard.getToken();
if (!token) return;
// Set header on every non-GET request
jqXHR.setRequestHeader(headerName, token);
// Inject into payload data as well
if (options.data === undefined || options.data === null) {
options.data = encodeURIComponent(tokenName) + '=' + encodeURIComponent(token);
} else if (typeof options.data === 'string') {
if (options.data.indexOf(tokenName + '=') === -1) {
options.data += (options.data.length > 0 ? '&' : '') +
encodeURIComponent(tokenName) + '=' + encodeURIComponent(token);
}
} else if (window.FormData && options.data instanceof window.FormData) {
if (!options.data.has(tokenName)) {
options.data.append(tokenName, token);
}
} else if (typeof options.data === 'object') {
options.data[tokenName] = token;
}
});
$(document).ajaxComplete(function(event, xhr, settings) {
var respToken = xhr.getResponseHeader(headerName) ||
xhr.getResponseHeader('X-CSRF-TOKEN') ||
xhr.getResponseHeader('x-csrf-token');
if (respToken) {
updateToken(respToken);
}
try {
if (xhr.responseJSON && xhr.responseJSON.csrf_hash) {
updateToken(xhr.responseJSON.csrf_hash);
}
} catch (e) {}
});
}
};
window.CSRF_GUARD = CsrfGuard;
window.getCsrfToken = CsrfGuard.getToken;
window.getCsrfTokenName = CsrfGuard.getTokenName;
window.getCsrfHeader = CsrfGuard.getHeaderName;
window.updateCsrfToken = CsrfGuard.updateToken;
// 1. Hook HTML Form Submissions
document.addEventListener('submit', function(e) {
var form = e.target;
if (!form || form.tagName !== 'FORM') return;
var method = (form.getAttribute('method') || 'GET').toUpperCase();
if (method !== 'POST') return;
var token = CsrfGuard.getToken();
if (!token) return;
var input = form.querySelector('input[name="' + tokenName + '"]');
if (!input) {
input = document.createElement('input');
input.type = 'hidden';
input.name = tokenName;
input.value = token;
form.appendChild(input);
} else {
input.value = token;
}
}, true);
// Hook programmatic form.submit()
if (window.HTMLFormElement && window.HTMLFormElement.prototype) {
var originalSubmit = window.HTMLFormElement.prototype.submit;
window.HTMLFormElement.prototype.submit = function() {
var method = (this.getAttribute('method') || 'GET').toUpperCase();
if (method === 'POST') {
var token = CsrfGuard.getToken();
if (token) {
var input = this.querySelector('input[name="' + tokenName + '"]');
if (!input) {
input = document.createElement('input');
input.type = 'hidden';
input.name = tokenName;
input.value = token;
this.appendChild(input);
} else {
input.value = token;
}
}
}
return originalSubmit.apply(this, arguments);
};
}
// 2. Hook Fetch API
if (window.fetch) {
var originalFetch = window.fetch;
window.fetch = function(resource, init) {
init = init || {};
var method = (init.method || 'GET').toUpperCase();
if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') {
var token = CsrfGuard.getToken();
if (token) {
// Inject Header
if (!init.headers) {
init.headers = {};
}
if (window.Headers && init.headers instanceof Headers) {
if (!init.headers.has(headerName)) {
init.headers.set(headerName, token);
}
} else if (Array.isArray(init.headers)) {
init.headers.push([headerName, token]);
} else {
init.headers[headerName] = token;
}
// Inject Body (FormData or URLSearchParams)
if (window.FormData && init.body instanceof FormData) {
if (!init.body.has(tokenName)) {
init.body.append(tokenName, token);
}
} else if (typeof init.body === 'string') {
var ct = '';
if (init.headers) {
if (window.Headers && init.headers instanceof Headers) {
ct = init.headers.get('Content-Type') || '';
} else {
ct = init.headers['Content-Type'] || init.headers['content-type'] || '';
}
}
if (ct.indexOf('application/x-www-form-urlencoded') !== -1 && init.body.indexOf(tokenName + '=') === -1) {
init.body += (init.body.length ? '&' : '') +
encodeURIComponent(tokenName) + '=' + encodeURIComponent(token);
}
}
}
}
return originalFetch.apply(this, [resource, init]).then(function(response) {
var newToken = response.headers.get(headerName) ||
response.headers.get('X-CSRF-TOKEN') ||
response.headers.get('x-csrf-token');
if (newToken) {
updateToken(newToken);
}
return response;
});
};
}
// 3. Hook Raw XMLHttpRequest
if (window.XMLHttpRequest) {
var originalOpen = XMLHttpRequest.prototype.open;
var originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url) {
this._csrfMethod = (method || 'GET').toUpperCase();
return originalOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function(body) {
if (this._csrfMethod && this._csrfMethod !== 'GET' && this._csrfMethod !== 'HEAD' && this._csrfMethod !== 'OPTIONS') {
var token = CsrfGuard.getToken();
if (token) {
try {
this.setRequestHeader(headerName, token);
} catch (e) {}
}
}
var self = this;
var onReadyHandler = function() {
if (self.readyState === 4) {
try {
var newToken = self.getResponseHeader(headerName) ||
self.getResponseHeader('X-CSRF-TOKEN') ||
self.getResponseHeader('x-csrf-token');
if (newToken) {
updateToken(newToken);
}
} catch (e) {}
}
};
this.addEventListener('readystatechange', onReadyHandler, false);
return originalSend.apply(this, arguments);
};
}
// Try setting up jQuery now, or poll briefly until jQuery is loaded
CsrfGuard.setupJQuery();
if (!window.jQuery) {
var attempts = 0;
var checkJq = setInterval(function() {
attempts++;
if (window.jQuery) {
CsrfGuard.setupJQuery();
clearInterval(checkJq);
} else if (attempts > 50) {
clearInterval(checkJq);
}
}, 100);
}
})(window, document);