המחלקה ADMIN (system/collections/ADMIN.php) היא המחלקה הסטטית שמחליטה מי המנהל המחובר ומה מותר לו. היא מנהלת סשנים בטבלה CRM_adminPanel_sessions, בודקת סיסמאות מול CRM_adminPanel_admins, מגבילה ניסיונות כושלים, ומחשבת הרשאות לפי קבוצת המנהל. העמוד הזה מתעד כל מתודה ציבורית עם החתימה המדויקת מהקוד; ההסבר הרחב על זרימת ההתחברות נמצא ב-התחברות, סשנים ו-WIZZO ID ועל מודל ההרשאות ב-הרשאות וקבוצות.
system/ בעמוד הזה הוא תיקיית הליבה הפרוסה (api/core בריפו), ו-{admin} הוא CONFIG::$admin_url.
שמות העוגיות בדפדפן מקבלים את הקידומת CRM_ (COOKIES מוסיפה אותה בכל קריאה), כך שעוגיית הסשן היא בפועל CRM_admin_session. בקוד תמיד כותבים את השם בלי הקידומת: COOKIES::get("admin_session").
מאפיינים וקבועים#
| שם | ערך | הערות |
|---|---|---|
ADMIN::$login_use_ip | false | רלוונטי רק לעוגיית admin_token הישנה: כש-false לא נבדקת כתובת ה-IP שנשמרה בעוגייה |
ADMIN::WIZZO_ID_DEVELOPER_PUBLIC_KEY | קבוע מחרוזת | המפתח הציבורי (RSA) שמול חתימת טוקן המפתח מאומתת. החלפתו היא שחרור ליבה |
| סשן (פנימי, private) | admin_session, 168 שעות | הטוקן הוא bin2hex(random_bytes(32)) (64 תווי hex), נשמר בטבלה ולא בעוגייה בלבד |
סשנים#
הסשן של מנהל הוא שורה ב-CRM_adminPanel_sessions (עמודות: id, admin_id, token, ip, user_agent, device_label, created_at, last_active_at, expires_at, is_impersonated, impersonated_by). העוגייה admin_session (httpOnly, מאובטחת, 7 ימים) מחזיקה רק את הטוקן. לצידה נקבעת עוגיית admin_logged עם הערך 1, שה-JavaScript יכול לקרוא, ושאינה סודית: היא רק דגל שאומר "יש סשן".
ADMIN::ensure_sessions_table()#
public static function ensure_sessions_table()
יוצרת את CRM_adminPanel_sessions אם היא לא קיימת, ומוסיפה לטבלת המנהלים את העמודות auth_provider (ברירת מחדל local) ו-external_id אם חסרות. רצה פעם אחת לכל בקשה (משתנה static), וכל מתודת סשן אחרת קוראת לה בעצמה. כל חריגה נבלעת בשקט. אין ערך החזרה.
ADMIN::get_id()#
public static function get_id()
מחזירה את ה-id של המנהל המחובר כמספר שלם, או false אם אין סשן תקף. התוצאה נשמרת לכל הבקשה (גם התוצאה השלילית).
- מחפשת את הטוקן מהעוגייה
admin_sessionבטבלה, עםexpires_at > NOW(). - אם נמצא סשן: מרעננת
last_active_atו-expires_atומחדשת את העוגייה, אבל לכל היותר פעם ב-5 דקות. - אם אין סשן חדש אבל יש עוגיית
admin_tokenישנה (מקודדת ב-MISC::encode): מאמתת אותה, מצמידה אליה סשן קיים של אותו מנהל או יוצרת חדש (admin_log), ומוחקת את העוגייה הישנה.
$adminId = ADMIN::get_id();
if (!$adminId) { /* אין מנהל מחובר */ }
ADMIN::is_admin()#
public static function is_admin()
מחזירה true אם יש סשן תקף וגם שורת המנהל עדיין קיימת ב-CRM_adminPanel_admins. מנהל שנמחק מהטבלה מפסיק להיות מנהל גם אם הסשן שלו עוד חי. זה הכלי הנכון לשער של קוד שמיועד למנהלים בלבד.
if (!ADMIN::is_admin()) { header("HTTP/1.1 403 Forbidden"); exit; }
ADMIN::admin_log($adminID)#
public static function admin_log($adminID)
יוצרת סשן חדש למנהל: מחוללת טוקן, כותבת שורה (כולל ip, user_agent ותווית מכשיר כמו Chrome on Windows), קובעת את עוגיית admin_logged ואת עוגיית admin_session, ומחזירה את תוצאת setcookie. המתודה לא בודקת סיסמה: היא נקראת רק אחרי שהאימות הצליח (ראו check_details). הרשאה לקרוא לה היא הרשאה להפוך לכל מנהל.
ADMIN::admin_logout()#
public static function admin_logout()
מוחקת את שורת הסשן הנוכחי מהטבלה ואת העוגיות admin_session, admin_logged ו-admin_token, ומאפסת את המטמון של הבקשה. אין ערך החזרה.
ADMIN::get_current_session_id()#
public static function get_current_session_id()
מחזירה את id של שורת הסשן הנוכחית (int), או 0. חשוב: הערך מלא רק אחרי שנקראה get_id() בבקשה הזו.
ADMIN::get_active_sessions($adminID)#
public static function get_active_sessions($adminID)
מחזירה מערך של הסשנים הפעילים של מנהל (expires_at > NOW()), מהאחרון שהיה פעיל. העמודות: id, ip, device_label, created_at, last_active_at, is_impersonated, impersonated_by. הטוקן עצמו לא מוחזר. משמשת את מסך הסשנים של מנהל בפאנל Admins.
ADMIN::revoke_session($sessionID, $adminID)#
public static function revoke_session($sessionID, $adminID)
מוחקת סשן אחד. התנאי כולל גם admin_id, כך שאי אפשר למחוק סשן של מנהל אחר. אין ערך החזרה.
ADMIN::logout_all($adminID)#
public static function logout_all($adminID)
מוחקת את כל הסשנים של המנהל, כולל הנוכחי.
ADMIN::invalidate_sessions_on_password_change($adminID)#
public static function invalidate_sessions_on_password_change($adminID)
מוחקת את כל הסשנים של המנהל חוץ מהנוכחי. אם אין סשן נוכחי (למשל איפוס סיסמה מחוץ לסשן) היא מתנהגת כמו logout_all. קראו לה מיד אחרי שינוי סיסמה.
ADMIN::cleanup_expired_sessions()#
public static function cleanup_expired_sessions()
מוחקת סשנים שפג תוקפם ושורות ב-CRM_adminPanel_admins_bad_attempts ישנות מ-24 שעות. נקראת מהקונטרולר admin_session_cleanup. אין ערך החזרה.
התחברות ובדיקת פרטים#
ADMIN::check_details($username, $password, $phone = false, $record_bad_attempt = true)#
public static function check_details ($username, $password, $phone = false, $record_bad_attempt = true)
| פרמטר | משמעות |
|---|---|
$username | שם משתמש; עובר strtolower(trim()) ומושווה לעמודה uname |
$password | סיסמה; עוברת trim() ומאומתת עם Bcrypt::verify מול password |
$phone | קוד OTP. אם הועבר ערך, הוא מאומת מול phone_last_code (גם הוא Bcrypt) בנוסף לסיסמה |
$record_bad_attempt | האם לרשום ניסיון כושל ב-CRM_adminPanel_admins_bad_attempts ולבדוק מגבלת IP |
ערכי החזרה, ושלושתם שונים:
| ערך | מתי |
|---|---|
true | הסיסמה (והקוד, אם נדרש) נכונים וגם ה-IP מותר. מחדשת את עוגיית הסשן אם קיימת |
false | משתמש לא קיים, סיסמה שגויה, IP לא ברשימת allow_ip של המנהל |
"to_many_ettempts" (מחרוזת, כך באיות בקוד) | 5 ניסיונות כושלים או יותר לאותו משתמש ב-10 הדקות האחרונות, או 15 ניסיונות מאותו IP (רק כש-$record_bad_attempt פעיל) |
פרטים נוספים מהקוד:
allow_ipשמור כרשימה בפורמטbgl_implodeשל כתובות מקודדות ב-MISC::encode; הבדיקה משווה אתMISC::encode($_SERVER['REMOTE_ADDR'])לרשימה.- מגבלת הניסיונות נבדקת רק למשתמש שקיים. שם משתמש לא קיים לא נרשם ולא נחסם.
- ניסיון כושל נרשם עם
uname,ipו-password, כשהעמודה האחרונה מכילה את ה-hash של הסיסמה האמיתית של המנהל אחריMISC::encode. אל תחשפו את הטבלה הזו.
השוו תמיד עם === true, כפי שעושים הקונטרולרים (controllers/admin_login.php). המחרוזת "to_many_ettempts" היא truthy, ולכן if (ADMIN::check_details(...)) מזהה חסימה בגלל ניסיונות כושלים כהצלחה.
// כך נראית ההתחברות בקונטרולר הכניסה, בקיצור
$ret = ADMIN::check_details($_POST["username"], $_POST["password"]);
if ($ret === true)
{
$user = DB::get_val("adminPanel_admins", ["uname" => $_POST["username"]]);
ADMIN::admin_log($user["id"]);
}
elseif ($ret === "to_many_ettempts")
{
// להציג "נסו שוב בעוד כמה דקות"
}
ADMIN::login($username, $password)#
public static function login ($username, $password)
קיצור: קוראת ל-check_details ואז ל-admin_log עבור המשתמש שנמצא. מחזירה את תוצאת setcookie בהצלחה או false. אף קונטרולר בליבה לא משתמש בה (הם קוראים ל-check_details ו-admin_log בנפרד, כי יש להם שלב OTP).
login() בודקת את תוצאת check_details בתנאי רפוי (if (self::check_details(...))). כשהחשבון חסום בגלל ניסיונות כושלים, check_details מחזירה את המחרוזת "to_many_ettempts", והתנאי עובר: login() יוצרת סשן עבור המשתמש בלי שהסיסמה אומתה. כמו כן היא מחפשת את המשתמש לפי $username כפי שהועבר, בלי strtolower(trim()). אל תשתמשו בה בקוד חדש; השתמשו ב-check_details(...) === true ואז ב-admin_log.קבוצות והרשאות#
ADMIN::GetAdminGroup()#
public static function GetAdminGroup()
מחזירה את group_id של המנהל המחובר, או false בלי סשן. התוצאה נשמרת ב-MISC::$GLOBALS["AdminGroupID"] לכל הבקשה.
ADMIN::has_perms($perm = "", $curr_page = false, $curr_action = false)#
public static function has_perms($perm = "", $curr_page = false, $curr_action = false)
| פרמטר | משמעות |
|---|---|
$perm | הרשאה ספציפית ("edit", "delete", "export", "publish", או כל מפתח מ-extra_perms של הפאנל). מחרוזת ריקה בודקת גישה בלבד |
$curr_page | panel_name של הפאנל. ברירת מחדל false = $_GET["module"] |
$curr_action | שם הפעולה. ברירת מחדל false = $_GET["pname"] |
מחזירה bool. הרשאות הקבוצה נקראות מ-CRM_adminPanel_admins_groups.perms ונשמרות ב-MISC::$GLOBALS["admin_perms"]. סדר ההחלטות:
| מצב | תוצאה |
|---|---|
| אין מנהל מחובר או אין קבוצה | false |
perms של הקבוצה הוא "-1" | true לכל דבר |
הפאנל לא קיים ב-adminPanel_panels | true אם לקבוצה יש גישה לפאנל wizzo_market; אחרת נבדקת רשימת market_panels (בלי $perm מספיק שהפאנל ברשימה; עם $perm נבדק !empty($mp[$perm])); אחרת false |
| הפאנל לא ברשימת הקבוצה | false |
הפעולה ריקה או index | בלי $perm: true. עם $perm: $found_page[$perm] === true |
פעולה שאין לה שורה ב-adminPanel_panels_options | true (גם אם $perm הועבר) |
| פעולה עם שורה | חייבת להופיע ב-subs של הפאנל; עם $perm נבדק !empty($sub[$perm]) |
צורת ה-JSON של perms (כפי ש-Admins/save_group שומר אותה):
[{"id": 12, "checked": true, "edit": true, "delete": false, "export": true, "publish": true,
"subs": [{"id": 40, "checked": true, "edit": true, "delete": false}],
"market_panels": [{"panel_name": "services_poosh_stats", "send": true}]}]
id הוא adminPanel_panels.id, ו-subs[].id הוא adminPanel_panels_options.id.
// בתוך פאנל: להציג כפתור מחיקה רק למי שמורשה
if (ADMIN::has_perms("delete")) { /* ... */ }
// בדיקה מפורשת של פאנל אחר
$canEditNews = ADMIN::has_perms("edit", "news", "index");
פעולה (pname) שאין לה שורה ב-adminPanel_panels_options מקבלת true בלי קשר להרשאות, כי הקוד מניח שאלה דפי insert/edit. אם יש לכם פעולה רגישה, ודאו שנרשמה ל-panel_options, או בדקו has_perms("edit") בתוכה במפורש.
ADMIN::is_developer()#
static function is_developer()
מחזירה true אם המנהל המחובר הוא מפתח של Wizzo, לפי העמודה developer_token שנשמרה בשורת המנהל ביום ההתחברות דרך WIZZO ID. התשובה מחושבת פעם אחת לכל בקשה (תפריט הניהול שואל עשרות פעמים). הלוגיקה:
- אין סשן מנהל:
false(לא נשמר במטמון, כי הבקשה עוד עלולה להתחבר). - אין טוקן:
false. - טוקן עם חתימה תקפה (
decode_developer_token) ושדהtimeשעוד לא עבר:true, בלי בקשת רשת. - טוקן שפג או שאינו חתום במפתח הנוכחי: פנייה אחת ל-WIZZO ID (
wizzo_auth::renew_developer_token) שמחליפה את הטוקן או מוחקת אותו. טוקן שאפילו אינו נפרס כטוקן נמחק מיד.
פאנל עם perm_developer = 1 מוצג רק כש-is_developer() מחזירה true.
ADMIN::decode_developer_token($encoded)#
public static function decode_developer_token($encoded)
מקבלת את הטוקן השמור (MISC::encode של JSON עם user_id, time, sig, pk_sig) ומחזירה את האובייקט המפוענח אם החתימה pk_sig (RSA-SHA256 על המחרוזת dev|user_id|time) מאומתת מול ADMIN::WIZZO_ID_DEVELOPER_PUBLIC_KEY עם openssl_verify. בכל כשל, כולל חסר user_id, חסר pk_sig, חתימה של מפתח אחר, או חוסר בהרחבת openssl (נכשלת סגורה ורושמת ל-error_log), היא מחזירה false. האימות מקומי, ללא קריאת HTTP.
מפתח ההצפנה של MISC::encode נמצא בכל ליבה, ולכן כל אחד יכול לפענח טוקן. החתימה היא מה שמונע זיוף. בדקו מפתח באמצעות decode_developer_token או is_developer, ולא באמצעות MISC::decode בלבד.
רב-לשוניות באדמין#
ADMIN::init_multilang()#
public static function init_multilang()
טוענת את כל הפלטפורמות (CRM_platforms, לפי ord) ל-MISC::$GLOBALS["langs"], מכבדת את $_GET["lang"] (שומרת בעוגיית admin_lang, מקודדת), ובוחרת את שפת הניהול: MISC::$GLOBALS["admin_lang_data"] ו-MISC::$GLOBALS["admin_lang_id"]. ברירת המחדל היא הפלטפורמה הראשונה. בסוף היא מעדכנת את last_login של המנהל ל-NOW(). נקראת מ-PAGE בטעינת עמוד ניהול (PAGE.php); אין סיבה לקרוא לה ידנית. אין ערך החזרה.
last_login מתעדכן בכל טעינת עמוד ניהול, לא רק בהתחברות, ולכן הוא מתאר "פעילות אחרונה".התחברות כמנהל אחר#
ADMIN::create_login_as_token($targetAdminId, $requesterId)#
public static function create_login_as_token($targetAdminId, $requesterId)
מחזירה מחרוזת MISC::encode של {target_id, requester_id, time, rand}. הכפתור "התחבר כמנהל" ב-Admins יוצר אותה, ורק כש-ADMIN::is_developer() ועבור מנהל שאינו המחובר.
ADMIN::validate_login_as_token($token)#
public static function validate_login_as_token($token)
מפענחת את הטוקן ומחזירה את המערך (target_id, requester_id, time, rand) אם הוא תקין ולא עברו מעל 60 שניות מיצירתו; אחרת false.
ADMIN::admin_log_impersonate($targetAdminId, $requesterId = null)#
public static function admin_log_impersonate($targetAdminId, $requesterId = null)
יוצרת סשן עבור $targetAdminId עם is_impersonated = 1, impersonated_by = $requesterId ותווית מכשיר שמסתיימת ב-(impersonated). מחזירה false אם המנהל לא קיים; אחרת את תוצאת setcookie. הקונטרולר admin_login::login_as קורא לה ואז מעביר לכתובת הניהול.
login_as בודק רק שלשורת המבקש יש developer_token לא ריק, ולא קורא ל-is_developer(). הטוקן עצמו הוא MISC::encode ללא חתימה. אל תרחיבו את השימוש בו ואל תחשפו כתובת login_as?token=... ביומנים.הגנת CSRF#
תבנית double-submit: עוגייה אקראית admin_csrf (httpOnly, שעה אחת) שהלקוח חייב להחזיר גם כפרמטר בבקשה.
ADMIN::generate_csrf_cookie()#
public static function generate_csrf_cookie()
מחזירה את הערך של admin_csrf אם קיים, ואחרת מחוללת טוקן חדש (64 תווי hex), קובעת אותו בעוגייה (שעה) ומחזירה אותו. את הערך שמים בשדה נסתר של הטופס, או מוסרים ללקוח כדי שישלח אותו בכותרת X-WZ-CSRF (כך עושה file_manager).
ADMIN::verify_csrf_token($posted_token)#
public static function verify_csrf_token($posted_token)
מחזירה true רק אם גם העוגייה וגם הערך שנשלח אינם ריקים והם שווים (hash_equals).
// בעמוד
$csrf = ADMIN::generate_csrf_cookie();
// בטיפול בבקשה (כפי שעושה file_manager לפעולות כתיבה)
$token = $_POST["csrf"] ?? $_SERVER["HTTP_X_WZ_CSRF"] ?? "";
if (!ADMIN::verify_csrf_token($token)) { /* לדחות */ }
controllers/admin_login.php בודק CSRF רק כש-$_POST["csrf_token"] לא ריק (!empty(...) && !verify(...)), ולכן בקשה בלי השדה עוברת. בפאנלים חדשים כתבו if (!ADMIN::verify_csrf_token($token)) die(...) ללא התנאי הראשון. העוגייה חיה שעה, ולכן טאב עריכה פתוח ארוך עלול לקבל כישלון תקין: טפלו בו בשליחה חוזרת עם טוקן חדש.עזרים#
ADMIN::icon_html($icon)#
public static function icon_html($icon)
מנקה אייקון של פאנל לפני הצגה בתפריט. שדה icon מכיל HTML גולמי, ואייקונים שיוצאים מכלי עיצוב מגיעים עם <svg width="100%" height="auto">. הערך auto אינו חוקי כמאפיין SVG, והדפדפן רושם שגיאה בקונסולה. הפונקציה מוחקת מכל תגית <svg ...> את המאפיינים width ו-height שערכם auto, ומחזירה את שאר ה-HTML כפי שהוא (מחרוזת). קלט שאינו SVG (תג <i> של FontAwesome, אימוג'י, מחרוזת ריקה) מוחזר ללא שינוי.
echo ADMIN::icon_html('<svg width="100%" height="auto" viewBox="0 0 24 24"></svg>');
// <svg width="100%" viewBox="0 0 24 24"></svg>