משתמשי האתר: LOGIN וטבלאות users

מערכת הכניסה למבקרים ולחברי אתר, נפרדת ממנהלי הפאנל. LOGIN עם טבלת טוקנים ומגבלת מכשירים, טבלאות CRM_users ו-CRM_users_logins, הצפנת סיסמאות, דוגמה מלאה להרשמה וכניסה, והמלכודות.

⏱ 9 דק' קריאה 1661 מילים ערוך דף זה ב-GitHub

באתר עם חברים (חנות, קהילה, אזור אישי) צריך לזהות מבקר מחובר. הליבה מספקת לכך את המחלקה LOGIN: כניסה לפי שם משתמש וסיסמה, טוקן בעוגייה שמאומת מול טבלה, והגבלה של מספר מכשירים מחוברים. היא נפרדת לגמרי ממנהלי הפאנל (ראו התחברות וסשנים). בעמוד הזה: ההגדרות, הטבלאות, דוגמה מלאה להרשמה, כניסה ויציאה, וההגבלות שחשוב להכיר.

system/ בעמוד הזה הוא תיקיית הליבה הפרוסה (api/core בריפו), ו-{admin} הוא CONFIG::$admin_url. המחלקה נמצאת ב-system/collections/LOGIN.php.

הליבה לא משתמשת ב-LOGIN

אף קוד בליבה לא קורא ל-LOGIN: זו ספרייה לקוד האתר. הליבה לא מגיעה עם דפי הרשמה, שחזור סיסמה או ניהול משתמשים, ואת אלה כותבים באתר. עוגיות וסשן כלליים מתוארים בעוגיות וסשן.

שתי שיטות, ואחת מומלצת#

LOGINCOOKIES::user_* (ישן)
הטוקןMISC::encode(json) מאומת מול CRM_users_loginsMISC::encode([...]) בלבד, ללא טבלה
שם עוגייהCRM_login_<table>CRM_user_<table>
מגבלת מכשיריםכן ($max_devices, ברירת מחדל 3)לא
יציאה בצד שרתכן (מוחק את הטוקן מהטבלה)רק מוחק את העוגייה
גיבוב סיסמהmd5, bcrypt או wpmd5 בלבד

השיטה הישנה פועלת על כל טבלה בשם $tableName ותומכת רק ב-md5, ולכן אתרים חדשים צריכים להשתמש ב-LOGIN. פירוט השיטה הישנה בעוגיות וסשן.

הגדרה#

המשתנים הסטטיים נקבעים ב-application/includes/init.php:

// application/includes/init.php
LOGIN::$table         = "users";    // ברירת מחדל: users (הטבלה CRM_users)
LOGIN::$encryption    = "bcrypt";   // md5 (ברירת מחדל) | bcrypt | wp
LOGIN::$max_devices   = 3;          // 0 = בלי מגבלה
LOGIN::$login_use_ip  = false;      // true = הטוקן תקף רק מכתובת ה-IP שהונפק לה
ברירת המחדל היא md5
LOGIN::$encryption ברירת המחדל היא md5, גיבוב מהיר בלי salt שנשבר בקלות. הגדירו bcrypt לפני הרשמת המשתמש הראשון, כי מעבר מאוחר מחייב לאפס סיסמאות או לגבב מחדש בכל כניסה.

אם $encryption = "wp", הקוד משתמש במחלקה PasswordHash (פורמט הסיסמאות של WordPress) שהליבה לא כוללת. השתמשו בזה רק באתר שמטמיע אותה בעצמו, למשל בעת העברה מ-WordPress.

הטבלאות#

הטבלאות CRM_users ו-CRM_users_logins לא נוצרות אוטומטית על ידי עדכון הליבה; יוצרים אותן ידנית. הסכמה המינימלית שהקוד דורש:

CREATE TABLE `CRM_users` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `username` VARCHAR(255) NOT NULL,
  `password` VARCHAR(255) NOT NULL,
  `active` TINYINT(1) NOT NULL DEFAULT 1,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uq_user` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE `CRM_users_logins` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `uid` INT NOT NULL,
  `token` TEXT NOT NULL,
  `ip` VARCHAR(45) NOT NULL DEFAULT '',
  PRIMARY KEY (`id`),
  KEY `idx_uid` (`uid`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

מותר להוסיף עמודות (אימייל, שם, תאריך הרשמה), והליבה תתעלם מהן. username חייב להיות ייחודי: הכניסה מחפשת שורה לפי שם המשתמש ולוקחת את הראשונה. הטבלה נקראת דרך ה-builder כ-users (בלי CRM_).

אם הטבלה שלכם נקראת אחרת, הגדירו LOGIN::$table; ה-builder יפנה אליה, אבל טבלת הטוקנים CRM_users_logins קבועה בקוד ואינה ניתנת לשינוי.

המתודות#

מתודהפרמטריםהחזרה
LOGIN::login_by_details($username, $password, $sessTime = 24*7*30*12)שם משתמש וסיסמה בטקסט גלוי. $sessTime בשעותtrue כשהעוגייה נשלחה, false כשהכניסה נכשלה
LOGIN::login_by_id($id, $sessTime = 24*7*30*12)מזהה משתמש. דורש שורה עם active != "0"כמו למעלה
LOGIN::is_logged()bool
LOGIN::get_id()מזהה משתמש (int), או false כשלא מחובר
LOGIN::get_token()ערך העוגייה, או false
LOGIN::logout()אין (מוחק עוגייה ושורת טוקן)

איך כניסה עובדת#

  1. login_by_details מנקה את שם המשתמש (strtolower, trim) ואת הסיסמה (trim), מחפשת שורה לפי username, ומשווה לפי LOGIN::$encryption. שורה לא קיימת או סיסמה שגויה מחזירות false ללא הבחנה.
  2. login_by_id בודקת שהמשתמש פעיל, בונה טוקן MISC::encode(json_encode(["uid", "ip", "tbl", "time"])), מוסיפה שורה ל-CRM_users_logins (uid, token, ip) ושולחת עוגייה CRM_login_<table> דרך COOKIES::set. העוגייה נשלחת עם secure בלבד ללא httponly, כלומר היא עובדת רק ב-HTTPS וקוד JavaScript יכול לקרוא אותה.
  3. is_logged מפענחת את הטוקן, ואם $max_devices > 0 בודקת שהוא נמצא בין $max_devices הטוקנים האחרונים של אותו משתמש. כניסה רביעית (במגבלה 3) מבטלת בפועל את הוותיקה ביותר.
שמות משתמש באותיות קטנות

הכניסה ממירה את שם המשתמש לאותיות קטנות ומסירה רווחים בקצוות, וגם את הסיסמה היא מנקה מרווחים בקצוות. בהרשמה שמרו את שם המשתמש באותיות קטנות ואת הסיסמה אחרי trim, אחרת המשתמש לא יוכל להיכנס. כמו כן שם משתמש שמכיל גרש (') לא יתאים, כי הערך עובר DB::escape ידני וגם escape של ה-builder; מומלץ להגביל שם משתמש לאותיות, ספרות וסימנים פשוטים, או להשתמש באימייל בלבד.

דוגמה מלאה#

קונטרולר account עם הרשמה, כניסה ויציאה. על כתיבת קונטרולר והרישום שלו ב-CRM_modules ראו קונטרולרים.

<?php
// application/controllers/account.php
class account extends wz_controller
{
    // GET /account : עמוד אזור אישי, רק למחוברים
    function index()
    {
        if (!LOGIN::is_logged()) return "REQUIRE_LOGIN";

        $user = DB::get_val("users", LOGIN::get_id());
        return $this->view("account/home", ["user" => $user]);
    }

    // POST /account/register
    function register()
    {
        $_GET["pmode"] = "empg";
        header("Content-Type: application/json; charset=utf-8");

        $email = strtolower(trim($_POST["email"] ?? ""));
        $pass  = trim($_POST["password"] ?? "");

        if (!MISC::is_valid_email($email) || strlen($pass) < 8) {
            return json_encode(["ok" => false, "error" => "invalid"]);
        }
        if (DB::get_val("users", ["username" => $email])) {
            return json_encode(["ok" => false, "error" => "exists"]);
        }

        // dt_encode מחזירה גיבוב bcrypt, והיא נטענת עם הספרייה bcrypt
        $this->load->library("bcrypt");

        $u = DB::update("users");
        $u->set("username", $email);
        $u->set("password", dt_encode($pass));
        $u->set("active", 1);
        if (!$u->insert()) return json_encode(["ok" => false, "error" => "db"]);

        // כניסה מיידית אחרי ההרשמה, 30 יום (720 שעות)
        LOGIN::login_by_id($u->new_id, 24 * 30);

        return json_encode(["ok" => true]);
    }

    // POST /account/login
    function login()
    {
        $_GET["pmode"] = "empg";
        header("Content-Type: application/json; charset=utf-8");

        $ok = LOGIN::login_by_details($_POST["email"] ?? "", $_POST["password"] ?? "", 24 * 30);
        return json_encode(["ok" => (bool)$ok]);
    }

    // POST /account/logout
    function logout()
    {
        $_GET["pmode"] = "empg";

        // במקום LOGIN::logout(): הטוקן עובר escape לפני שהוא נכנס ל-SQL
        $t = LOGIN::get_token();
        if ($t) DB::sql("DELETE FROM CRM_users_logins WHERE token = '" . DB::escape($t) . "'");
        COOKIES::kill("login_" . LOGIN::$table);

        return "";
    }
}

הרישום ב-CRM_modules:

INSERT INTO CRM_modules (modulesDir, moduleName, active, ord) VALUES ('', 'account', 1, 0);

ולאחר מכן ניקוי מטמון המודולים: cache_engine::remove("modules"); (ראו קונטרולרים).

כל מתודה ציבורית היא כתובת, ולכן מומלץ לבדוק $_SERVER["REQUEST_METHOD"] === "POST" בפעולות שמשנות מצב, ולצרף להן הגנת CSRF (ראו CSRF, XSS ו-SQL injection). LOGIN לא מגינה על הבקשות עצמן.

שימוש ב-
$u->new_id אחרי insert() המזהה החדש נמצא ב-$u->new_id; הדוגמה משתמשת בו כדי להיכנס מיד אחרי ההרשמה. DB::insert לא קיימת (הבילדרים).

בדיקת מחובר בתבנית ובקוד#

LOGIN::is_logged() יכולה להיקרא מכל מקום, וכל קריאה מפענחת את העוגייה ופונה פעם אחת ל-DB כש-$max_devices > 0. אם אתם קוראים לה כמה פעמים בבקשה, שמרו את התוצאה במשתנה.

דף שמוגש ממטמון עמוד לא יכול להכיל תוכן אישי: הדף נשמר פעם אחת ומוגש לכולם. עמודים שתלויים במחובר לא מסמנים PAGE::$cache_this_page, או שהתוכן האישי נטען בקריאת AJAX נפרדת.

מגבלות ומלכודות#

  • אין הגבלת ניסיונות. LOGIN לא סופרת כניסות כושלות ולא חוסמת. הוסיפו מגבלה משלכם (לפי IP ולפי שם משתמש) על account/login, ותגובה אחידה לכל כישלון.
  • הטוקן לא פג בשרת. שורת הטוקן ב-CRM_users_logins נמחקת רק ביציאה, והשדה time בטוקן לא נבדק. הפקיעה היא פקיעת העוגייה בדפדפן בלבד (ברירת מחדל: 24*7*30*12 שעות, כשבע שנים). הטבלה גדלה עם כל כניסה, ורק $max_devices הטוקנים האחרונים של כל משתמש בתוקף. מומלץ לנקות שורות ישנות במשימת cron, בלי למחוק את הטוקנים האחרונים של המשתמשים.
  • שינוי סיסמה לא מנתק מכשירים. כדי לנתק את כל המכשירים של משתמש, מחקו את שורות CRM_users_logins שלו (למשל בעת שינוי סיסמה או חסימה). חסימה דרך active = 0 חוסמת כניסות חדשות בלבד: is_logged לא בודקת את active, ולכן טוקן קיים ממשיך לעבוד עד שמוחקים אותו.
  • logout() משרשרת את ערך העוגייה ל-SQL ללא escape. קוד הליבה מוחק את השורה עם DB::sql("DELETE ... WHERE token = '" . $token . "'"), והערך מגיע מעוגייה שהלקוח שולט בה. העוגייה היא קלט של הלקוח, ולכן בקוד האתר מומלץ לא לקרוא ל-LOGIN::logout(), ובמקומה: $t = LOGIN::get_token(); if ($t) DB::sql("DELETE FROM CRM_users_logins WHERE token = '" . DB::escape($t) . "'"); COOKIES::kill("login_" . LOGIN::$table);.
  • הטוקן מוצפן, לא חתום. הוא נוצר עם MISC::encode (ראו הצפנה). אם מפתחות ההצפנה נשארו בברירת המחדל, מי שמכיר אותם יכול לייצר עוגייה. עם $max_devices > 0 זה נעצר כי הטוקן חייב להימצא גם בטבלה; עם 0 ההגנה היחידה היא סודיות המפתחות. הגדירו מפתחות ייחודיים (הקשחה) ואל תכבו את מגבלת המכשירים.
  • $login_use_ip = true נועל את הטוקן לכתובת ה-IP שהונפק לה. משתמשי נייד ורשתות שמחליפות כתובת ינותקו, ולכן ברירת המחדל היא false.

ראו גם#

מצאתם טעות או חוסר? תקנו את הדף או פתחו Issue בריפו. התיעוד נכתב מתוך הקוד של ליבה 5.0.115.