PHP návod - tvoríme si bezpečnejšie prihlasovanie v OOP

Publikované 08.2.2014 15:53   |  Návody, PHP, OOP, Security 6

Každý z nás chce mať aplikáciu čo najviac zabezpečenú voči útočníkom. K tomu vieme, že prihlasovanie do administrácie je ako veľká brána do sejfu, ktorá sa otvorí len vtedy, ak k nej pristupuje tá správna osoba. V dnešnom návode si ukážeme, ako naše prihlasovanie čo najlepšie zabezpečiť voči technikám ako Session Fixation, Session Hijacking a CSRF.


Prv si musíme ujasniť nejaké pojmy, aby sme vôbec vedeli na čo sa brániť:

Session Fixation

Ide o prípad, keď útočník si vytvorí SESSION ID, napr. pomocou URL adresy a postrčí to inému používateľovi, ktorý ak sa prihlási, tak všetko prebehlo pod SESSION ID útočníka. Takto bude aj útočník autorizovaný bez toho, aby musel vedieť prihlasovacie údaje. Riešením je ždy pri zmene hodnoty u SESSION vygenerovať nové ID a stare zmazať. To je možné pomocou funkcie session_regenerate_id() a parametra true. Takto už sa nebude môcť útočník autorizovať pod SESSION ID infikovaného používateľa.

Session Hijacking

O Session Hijacking sa jedná vtedy, ak útočník sa snaží získať SESSION ID používateľa. V tomto sa to nedá na 100% zabrániť, ale ochrany, ktoré môžeme podniknúť sú dosť silné na to, aby to zabranilo vo väčšine prípadov. Riešením je správne nastavenie servera, ale to tu rozoberať nebudeme a nastavenie PHP skriptu. V PHP skripte je aspoň postačujúca kontrola IP adresy a User-agenta. Budeme to robiť tak, že si do SESSIONS uložíme zašifrovanú IP adresu a User-agenta, a ak sa počas skriptu nezhodujú, celý SESSIONS zničíme.

Cross-site request forgery

Alebo v skratne CSRF, ide o útok, síce tieto útoky sa skôr zamieravajú už pre prihlásených používateľov (ale ochrana v prihlasovacom paneli tiež nie je nepotrebná), kde útočník pozná štruktúru systému a vďala tejto znalosti autorizovaného používateľa priláka na škodlivú webstránku, ktorá tento CSRF útok vykoná. Táto stránka sa pokúsi dostať na server pomocou používateľa, kde vykoná vopred pripravené akcie, ktoré sú v danej administrácii dostupné. Môže tak upraviť určité dáta pomocou POST požiadaviek alebo GET. Riešením je generovanie CSRF tokenov, ktorý overí, kto pred tým formulárom skutočne je.

To by sme mali troška z teórie, poďme sa pustiť do aplikácie. Ako už v názve tohto článku je, budeme to tvoriť v prostredí OOP. Ak by sme mali našu "súborovú hierarchiu" graficky znázorniť, vyzerala by nejak takto:

Hierarchia súborov

(kliknite pre zväčšenie)

Takže teraz by sme si mohli tú hierarchiu vysvetliť, lebo to vyzerá na prvý pohľad ako náhodné čiary ťahané do náhodnych "položiek". No, v prvom rade chceme mať index.php, ktorý bude pre nás výsledkom v tom, že sa nám zobrazí až vtedy, ak sme prihlásení. Preto je tam taký čiarkovaný vzťah medzi login.php a logout.php, keďže index sa odkazuje aj na login aj na logout. Ak nie sme prihlásení, hodí nás to na login.php a ak sme a chceme sa odhlásiť, pôjdeme na logout.php. Následne tu môžeme vidieť autoloader.php, na ktorý sa odkazujú všetky súbory, ktoré sme teraz spomenuli. Tento autoloader sa stará o to, že nám vloží presne také triedy, ktoré v danom súbore potrebujeme. Máme tu vzťah, kde autoloader vkláda classes/authenticate.php a ten vkláda napr. classes/tools/mysql.class.php. Na ujasnenie, toto znamená, že autoloader vkláda všetky tieto triedy, ale bolo by tu veľa šípiek, čo by mohlo byť neprehliadné, takže to funguje metódou A->B, B->C => A->C.

Prv si ale vytvoríme tabuľky do databázy, aby sme mohli s niečim pracovať. Začnime s tabuľkou uzivatelia, kde si budeme ukládať informácie o danom používateľovi, aby sme ich v budúcnosti mohli autorizovať:

CREATE TABLE IF NOT EXISTS `uzivatelia` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(255) NOT NULL DEFAULT '',
  `password` varchar(255) NOT NULL DEFAULT '',
  `salt` varchar(30) NOT NULL DEFAULT '',
  PRIMARY KEY (`id`),
  UNIQUE KEY `username` (`username`)
) ENGINE=MyISAM  DEFAULT CHARSET=utf8;

Potom, ešte si pripravíme 1 špeciálnu a ňou je bad_logins, kde si budeme zaznamenávať údaje o neúspešných pokusoch o prihlásenie, aby sme včas vedeli daného návštevníka zablokovať pri N neuspešných pokusoch:

CREATE TABLE IF NOT EXISTS `bad_logins` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `ip` varchar(15) NOT NULL DEFAULT '',
  `time` bigint(20) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=MyISAM  DEFAULT CHARSET=utf8;

A teraz sa môžeme pustiť na dané triedy pekne postupne. Pôjdeme od konca, aby sme si deklarovali prv triedy, ktoré budeme používať.

class MySQL (classes/tools/mysql.class.php)

Stará sa o prácu s databázou, túto triedu máme všeobecne už definovanú na tejto stránke. Jedinú vec, ktorú si pridáme je to, že nad class MySQL vložíme tento riadok:

namespace Tools;

Áno, už je možné vidieť, že budeme využívať metódy namespace..use. (príklad a vysvetlenie o čo ide, nájdete v IV. časti tutoriálu).

class MultiLoginProtection (classes/tools/multiloginprotection.class.php)

V tejto výnimočnej triede bude prebiehať ochrana proti tomu, aby používateľ nemal nekonečno pokusov pri okamžitom nesprávnom prihlasovaní. Ak prekročí určitý počet nesprávnych pokusov, musí počkať 60 sekúnd a môže to skúsiť znova. Zaznamenávanie prebieha pomocou DB, kde si zapíše IP adresu a čas tohto pokusu. Následne v konštruktore sa pýta, či posledný čas je väčší ako 60 sekúnd a ak je, vymaže všetky nesprávne pokusy na danej IP adrese.

Začnime tým, že si určíme namespace a taktiež budeme vedieť, že chceme využiť triedu MySQL, aby sme sa mohli spojiť s tabuľkou bad_logins:

namespace Tools;

use Tools\MySQL; // pripravíme si triedu MySQL

class MultiLoginProtection
{

}

Môžeme sa pustiť do vyplňovania triedy MultiLoginProtection. Najprv si je treba nastaviť vlastnosť $mysql, aby sme si uložili našu inštanciu MySQL spojenia a v konštruktore si toto spojenie vytvoríme. Keďže nám viac ako 1 inštanciu tejto triedy fakt nebude potreba, uplatníme tu vzor Singleton, teda vyvtvoríme si ešte statickú vlastnosť $instance a metódu getInstance(). V konštruktore tiež aj vyvoláme privátnu metódu check(), ktorú si ochvíľu popíšeme:

  private $mysql;
  static private $instance = null; // na túto triedu nám stačí 1 inštancia
  
  // statická metóda na vrátenie inštancie
  static public function getInstance()
  {
    if(null === self::$instance)
      self::$instance = new MultiLoginProtection();
    return self::$instance;
  }
  
  public function __construct()
  {
    $this->mysql = new MySQL('localhost', 'username', 'pass', 'db'); // vytvoríme si vlastné spojenie
    
    $this->check(); // rovno už v konštruktore spravíme kontrolu na nesprávne pokusy, poprípade ich vymaže, ak časový rozdiel je väčší ako 60 sekúnd
  }

A môžeme sa pustiť do tejto záhadnej metódy check(). Ide o takú metódu, ktorá sa už na začiatku opýta, či má používateľ už nejaké nesprávne prihlásenia podľa IP adresy, porovná to s databázou a ak áno, zistí časový rozdiel súčasného času a času posledného záznamu. Ak je väčší ako 60, vymaže všetky nesprávne pokusy na danej IP adrese a používateľ môže znova skúšať sa pripojiť:

  // metóda na zistenie, či daný použávateľ má už nejaké resty
  private function check()
  {
    // nájdeme si v DB, či sa naša IP nachádza
    $query = $this->mysql->query("SELECT * FROM `bad_logins` WHERE `ip` = '" . $this->mysql->safe($_SERVER['REMOTE_ADDR']) . "' ORDER BY id DESC LIMIT 1");
    if($query && $query->num_rows > 0) // ak áno...
    {
      $row = $this->mysql->fetchSingle($query);
      if( time()-$row->time > 60 ) // overíme, či časový rozdiel súčasného času a posledného času nesprávneho pokusu je väčší ako 60
        $this->mysql->query("DELETE FROM `bad_logins` WHERE `ip` = '" . $this->mysql->safe($_SERVER['REMOTE_ADDR']) . "'"); // následne všetky záznamy môžeme vymazať
    }
  }

Už nám len zostáva vytvoriť metódy, ktoré sa budú starať o pridane ďalšieho neplatného pokusu o prihlásenie do databázy a na získanie celkového počtu nesprávnych prihlásení na danej IP adrese. Nazvyme si ich addBadLogin() a getBadLogins():

  // metóda na pridanie nesprávneho pokusu do databázy
  public function addBadLogin()
  {
    $query = $this->mysql->query("INSERT INTO `bad_logins` (`ip`, `time`) VALUES ('" . $this->mysql->safe($_SERVER['REMOTE_ADDR']) . "', '" . time() . "') ");
  }
  
  // metóda na vrátenie počtu nesprávnych pokusov o prihlasenie
  public function getBadLogins()
  {
    $query = $this->mysql->query("SELECT * FROM `bad_logins` WHERE `ip` = '" . $this->mysql->safe($_SERVER['REMOTE_ADDR']) . "'");
    return $query->num_rows;
  }

A ako čerešnička na torte, pridáme metódu close(), ktorý ukončí spojenie s MySQL databázou, aby sme nemali zbytočne otvorené spojenie:

  // metóda, ktorá ukončí spojenie s MySQL serverom.
  public function close()
  {
    $this->mysql = null;
  }

Výsledný kód celej triedy pokope je:

<?php

namespace Tools;

use Tools\MySQL; // pripravíme si triedu MySQL

class MultiLoginProtection
{
  private $mysql;
  static private $instance = null; // na túto triedu nám stačí 1 inštancia
  
  // statická metóda na vrátenie inštancie
  static public function getInstance()
  {
    if(null === self::$instance)
      self::$instance = new MultiLoginProtection();
    return self::$instance;
  }
  
  public function __construct()
  {
    $this->mysql = new MySQL('localhost', 'username', 'pass', 'db'); // vytvoríme si vlastné spojenie
    
    $this->check(); // rovno už v konštruktore spravíme kontrolu na nesprávne pokusy, poprípade ich vymaže, ak časový rozdiel je väčší ako 60 sekúnd
  }
  
  // metóda na zistenie, či daný použávateľ má už nejaké resty
  private function check()
  {
    // nájdeme si v DB, či sa naša IP nachádza
    $query = $this->mysql->query("SELECT * FROM `bad_logins` WHERE `ip` = '" . $this->mysql->safe($_SERVER['REMOTE_ADDR']) . "' ORDER BY id DESC LIMIT 1");
    if($query && $query->num_rows > 0) // ak áno...
    {
      $row = $this->mysql->fetchSingle($query);
      if( time()-$row->time > 60 ) // overíme, či časový rozdiel súčasného času a posledného času nesprávneho pokusu je väčší ako 60
        $this->mysql->query("DELETE FROM `bad_logins` WHERE `ip` = '" . $this->mysql->safe($_SERVER['REMOTE_ADDR']) . "'"); // následne všetky záznamy môžeme vymazať
    }
  }
  
  // metóda na pridanie nesprávneho pokusu do databázy
  public function addBadLogin()
  {
    $query = $this->mysql->query("INSERT INTO `bad_logins` (`ip`, `time`) VALUES ('" . $this->mysql->safe($_SERVER['REMOTE_ADDR']) . "', '" . time() . "') ");
  }
  
  // metóda na vrátenie počtu nesprávnych pokusov o prihlasenie
  public function getBadLogins()
  {
    $query = $this->mysql->query("SELECT * FROM `bad_logins` WHERE `ip` = '" . $this->mysql->safe($_SERVER['REMOTE_ADDR']) . "'");
    return $query->num_rows;
  }
  
  // metóda, ktorý ukončí spojenie s MySQL serverom.
  public function close()
  {
    $this->mysql = null;
  }
}

class SessionProtection (classes/tools/sessionprotection.class.php)

Táto trieda sa stará o celkový priebeh a prácu so sessions. Zahrňuje tu ochranu proti Session Fixation a taktiež základnú ochranu proti Session Hijacking. Keďže na túto triedu nám N inštancií netreba, vytvoríme si ju podľa vzoru Singleton. Teda, túto triedu budeme získavať pomocou metódy getInstance(), ktorá nám vráti už vytvorenú inštanciu tejto triedy. Takže začiatok je následovný:

namespace Tools;

class SessionProtection
{
  static private $instance = null; // na túto triedu nám stačí len 1 inštancia
  
  // statická metóda na vrátenie inštancie
  static public function getInstance()
  {
    if(null === self::$instance)
      self::$instance = new SessionProtection();
    return self::$instance;
  }
}

To je zatiaľ snád všetkým jasné. Teraz je nám potrebné vytvoriť si konštruktor, ktorý, čo je samozrejmosťou, inicializuje, že budeme pracovať so sessions pomocou funkcie session_start(). Ďalej si to aj pridáme základnú ochranu proti Session Hijacking a to tak, že si uložíme do session zašifrovaného User-agenta a IP adresu. Následne to budeme počas celého behu aplikácie, pokiaľ nám session nevyprší porovnávať, či sa súčasné zašifrované hodnoty rovnajú s tými, čo sú uložené v sessions. Ak nie, sessions okamžité zničíme (tým sa zruší aj autorizácia používateľa a bude sa musieť znova prihlásiť). Kód by bol následovný:

  public function __construct()
  {
    session_start(); // v konštruktore môžeme nastaviť session_start()
    
    // zistíme, či sú sessions na user_agent a remote_ip už nastavené
    if(isSet($this->user_agent) && isSet($this->remote_ip) && $this->user_agent && $this->remote_ip)
    {
      // ak áno, overíme, či sa zhodujú s hodnotami používateľa
      if(sha1($_SERVER['HTTP_USER_AGENT']) != $this->user_agent || sha1($_SERVER['REMOTE_ADDR']) != $this->remote_ip)
        $this->sessionDestroy(); // ak nie, sessions zničíme
    }
    else // ak neboli ešte nastevené, tak ich nastavíme
    {
      $this->user_agent = sha1($_SERVER['HTTP_USER_AGENT']);
      $this->remote_ip = sha1($_SERVER['REMOTE_ADDR']);
    }    
  }

Ako si už môžeme aj všimnúť, pokúšame sa vyvolať vlastnosti, ktoré nemáme nikde deklarované. Za bežných okolností by nám to vôbec nefungovalo, ale v našom tutoriále sme si ukázali ako na to. Využijeme preťažovanie vlastností. Bude nám treba deklarovať dve metódy __get() a __set():

  // metóda, ktorá sa vyvolá, ak sa snažíme vyvolať vlastnosť, ktorá neexistuje
  public function __get($var)
  {
    if(isSet($_SESSION[$var]) && $_SESSION[$var])
      return $_SESSION[$var]; // ak session s týmto názvom existuje, vrátime jeho hodnotu
    else
      return null; // inak vrátime null
  }
  
  // metóda, ktorá sa vyvolá, ak sa snažíme nastaviť hodnotu vlastnosti, ktorá neexistuje
  public function __set($var, $val)
  {
    $_SESSION[$var] = $val; // nastavíme session s názvom $var na hodnotu $val
    
    session_regenerate_id(true); // po každej novej zmene, vygenerujeme nové SESSION ID a STARÉ VYMAŽEME
  }

Funkcia session_regenerate_id(true), čo aj je ochrana proti Session Fixation, nám hovorí, že pri zmene alebo nastavení nového session sa okamžite staré SESSION ID vymaže a vytvorí sa nové. Takto útočník už nebude môcť naďalej fungovať pod SESSION ID, ktoré používateľovi podstrkol, takže nakoniec ostane s prázdnymi rukami.

Teraz nám zostáva si vytvoriť metódu na zrušenie všetkých sessions. Nazvime si ju sessionDestroy(). Táto metóda sa taktiež aj zaujíma o sessions, ktoré sú uložené v cookies a preto využijeme funkciu session_get_cookie_params() na získanie parametrov. Následne môžeme vymazať tieto sessions aj z cookies:

  // metóda na zničenie sessions
  public function sessionDestroy()
  {
    $params = session_get_cookie_params(); // získame si ešte všetky cookie parametre, ktoré sa týkajú sessions
    // následne ich zrušíme
    setcookie(session_name(), '', time() - 42000,
        $params['path'], $params['domain'],
        $params['secure'], $params['httponly']
    );
    session_destroy(); // a na koniec zničíme aj sessions
  }

A aby sme zabránili k duplikácii objektov, vytvoríme si na záver súkromnú metódu __clone(), ktorú nepôjde odnikiaľ vyvolať:

  // pokúsime sa zabrániť k duplikácii triedy s tým, že metódu __clone() dáme ako privátnu
  private function __clone()
  {
  }

Výsledný kód tejto triedy je takýto:

<?php

namespace Tools;

class SessionProtection
{
  static private $instance = null; // na túto triedu nám stačí len 1 inštancia
  
  // statická metóda na vrátenie inštancie
  static public function getInstance()
  {
    if(null === self::$instance)
      self::$instance = new SessionProtection();
    return self::$instance;
  }
  
  public function __construct()
  {
    session_start(); // v konštruktore môžeme nastaviť session_start()
    
    // zistíme, či sú sessions na user_agent a remote_ip už nastavené
    if(isSet($this->user_agent) && isSet($this->remote_ip) && $this->user_agent && $this->remote_ip)
    {
      // ak áno, overíme, či sa zhodujú s hodnotami používateľa
      if(sha1($_SERVER['HTTP_USER_AGENT']) != $this->user_agent || sha1($_SERVER['REMOTE_ADDR']) != $this->remote_ip)
        $this->sessionDestroy(); // ak nie, sessions zničíme
    }
    else // ak neboli ešte nastevené, tak ich nastavíme
    {
      $this->user_agent = sha1($_SERVER['HTTP_USER_AGENT']);
      $this->remote_ip = sha1($_SERVER['REMOTE_ADDR']);
    }    
  }
  
  // metóda, ktorá sa vyvolá, ak sa snažíme vyvolať vlastnosť, ktorá neexistuje
  public function __get($var)
  {
    if(isSet($_SESSION[$var]) && $_SESSION[$var])
      return $_SESSION[$var]; // ak session s týmto názvom existuje, vrátime jeho hodnotu
    else
      return null; // inak vrátime null
  }
  
  // metóda, ktorá sa vyvolá, ak sa snažíme nastaviť hodnotu vlastnosti, ktorá neexistuje
  public function __set($var, $val)
  {
    $_SESSION[$var] = $val; // nastavíme session s názvom $var na hodnotu $val
    
    session_regenerate_id(true); // po každej novej zmene, vygenerujeme nové SESSION ID a STARÉ VYMAŽEME
  }
  
  // metóda na zničenie sessions
  public function sessionDestroy()
  {
    $params = session_get_cookie_params(); // získame si ešte všetky cookie parametre, ktoré sa týkajú sessions
    // následne ich zrušíme
    setcookie(session_name(), '', time() - 42000,
        $params['path'], $params['domain'],
        $params['secure'], $params['httponly']
    );
    session_destroy(); // a na koniec zničíme aj sessions
  }
  
  // pokúsime sa zabrániť k duplikácii triedy s tým, že metódu __clone() dáme ako privátnu
  private function __clone()
  {
  }
}

class CSRFProtection (classes/tools/csrfprotection.class.php)

Síce v prihlasovaní sa CSRF veľmi nepoužíva, keďže sa zameriava už na používateľov, ktorí už sú prihlásení. Ale nič nám nebráni tomu, si to už dať aj pred prihlasovací formulár. Taktiež ako aj trieda SessionProtection, aj na túto triedu nám viac ako 1 inštancia nie je potrebná. Preto aj tu uplatníme vzor Singleton. Túto triedu, troška v poupravenom stave mám z portálu StackOverflow, presnejšie z tejto odpovede. Stručný popis tejto triedy je to, že metóda createApiKey() vytvorí CSRF token a metóda checkApiKey() ho overí aj s časovým rozdielom, ktorý nastavíme ako argument pri vonkakšom vyvolávaní tejto metódy. Metódy encrypt() a decrypt() sa starajú o zašifrovanie a dešifrovanie tohto tokenu.

Kód v celej podobe:

<?php

namespace Tools;

class CSRFProtection
{
  // využitie vzoru Singleton
  static private $instance = null;
  
  static public function getInstance()
  {
    if(null === self::$instance)
      self::$instance = new CSRFProtection();
    return self::$instance;
  }
  
  // metóda na vytvorenie tokenu
  public function createApiKey()
  {
    return base64_encode(base64_encode($this->encrypt(time().'X'.$_SERVER['REMOTE_ADDR'])));
  }
  
  // metóda na overenie tokenu
  public function checkApiKey($key,$timeout=5)
  {
    if(empty($key))
      return false;

    $keys = explode('X',$this->decrypt(base64_decode(base64_decode($key))));

    if (isSet($key) && isSet($keys[0]) && $keys[0] >= (time()-$timeout) &&
    isSet($keys[1]) && $keys[1] == $_SERVER['REMOTE_ADDR']){
        return true;
    } else {
        return false;
    }
  }
  
  // metóda na zašifrovanie tokenu
  public function encrypt($value)
  {
    $iv_size = mcrypt_get_iv_size(MCRYPT_RIJNDAEL_256, MCRYPT_MODE_ECB);
    $iv = mcrypt_create_iv($iv_size, MCRYPT_RAND);
    return mcrypt_encrypt(MCRYPT_RIJNDAEL_256, 'SECURE_KEY', $value, MCRYPT_MODE_ECB, $iv);
  }

  // metóda na dešifrovanie tokenu
  public function decrypt($value)
  {
    $iv_size = mcrypt_get_iv_size(MCRYPT_RIJNDAEL_256, MCRYPT_MODE_ECB);
    $iv = mcrypt_create_iv($iv_size, MCRYPT_RAND);
    return trim(mcrypt_decrypt(MCRYPT_RIJNDAEL_256, 'SECURE_KEY', $value, MCRYPT_MODE_ECB, $iv));
  }
  
  private function __construct()
  {   
  }

  private function __clone()
  {
  }
}

Viac v tomto súbore nie je čo popisovať

class Authenticate (classes/authenticate.class.php)

No a môžme sa pustiť na triedu, ktorá sa už stará o logiku autorizovania používateľa. Vkládame mu prihl. údaje a token, kde táto trieda toto všetko overí a vráti buď FALSE -> nepovolený prístup, alebo nastaví sessions a presmeruje používateľa na index. Tiež tu zahrňuje samozrejme odhlásenie používateľa.

No pekne poporiadku. Zrejme budeme potrebovať MySQL spojenie a taktiež nám bude potreba využitie CSRF ochrany na to, aby sme mohli overiť token a zároveň aj session ochrany, keďže budeme pri úspešnom prihlasovaní určité sessions nastavovať. Spíšeme si, čo potrebujeme pomocou use:

// vložíme triedy, ktoré budeme potrebovať
use Tools\MySQL;
use Tools\CSRFProtection;
use Tools\SessionProtection;

Môžeme sa venovať našej triede Authenticate. Prv si, ako už je zvykom, vytvoríme konštruktor, ktorý načíta všetky potrebné inštancie, poprípade vytvorí nové:

class Authenticate
{
  private $mysql;
  private $csrf;
  private $session;
  
  public function __construct()
  {
    $this->mysql = new MySQL('localhost', 'username', 'pass', 'db');  // vytvoríme si súkromné spojenie s touto triedou
    $this->csrf = CSRFPRotection::getInstance();        // načítame si inštanciu triedy CSRFProtection
    $this->session = SessionProtection::getInstance();  // to isté u triedy SessionProtection
  }
}

Pusťme sa do tej najdôležitejšej metódy, a ňou je auth(). Tá vráti buď false (nepovolený prístup), alebo nastaví určité sessions a užívateľa presmeruje. Ako parametre očakáva $username, $password a $token. Zo začiatku overíme token, následne zistíme, či vôbec daný používateľ existuje a ak áno, vytvoríme si skutočné heslo s tým, že získame salt používateľa a skombinujeme to s heslom, ktoré nám zadal. Potom už len to heslo zašifrujeme a porovnáme so šifrou, ktorá je uložená v databáze. Ak sa našiel záznam (užívateľ sa prihlásil správne), nastavíme sessions a presmerujeme ho:

  public function auth($username, $password, $token)
  {
    // ošetríme si premenné
    $username = $this->mysql->safe($username);
    $password = $this->mysql->safe($password);
    $token = $this->mysql->safe($token);
    
    if(!$this->csrf->checkApiKey($token, 60))  // skontrolujeme, či daný token je ešte platný
      return false; 
    
    if(!$this->findUser($username)) // netreba porovnávať heslo ani nič navyše, ak daný pooužívateľ vôbec neexistuje
      return false;
      
    $salt = $this->getSalt($username);  // získame si "salt" používateľa na ochranu hesla
    
    $real_password = sha1($password . '{' . $salt . '}');  // spracujeme heslo do takej podoby, ktoré sa ukladadá do databázy
    // nájdeme používateľa v DB
    $query = $this->mysql->query("SELECT * FROM `uzivatelia` WHERE `username` = '" . $this->mysql->safe($username) . "' AND `password` = '" . $this->mysql->safe($real_password) . "'");
    if($query && $query->num_rows > 0)
    {  
      $row = $this->mysql->fetchSingle($query);
      $this->session->user_id = $row->id; // vytvoríme si session na ID používateľa
      $this->session->user_hash = sha1($row->username . '{' . $row->salt . '}' . $row->password); // a session na hash, kde zaheshujeme username, salt a password
      header('Location: index.php'); // nakoniec presmerujeme
      exit(); // a okamzžite skript ukončíme
      
    } 
    else
      return false; // používateľovi sa heslo nezhoduje, vrátime false
  }

Určite sme si povšimli, že tam máme ešte nedeklarované metódy findUser() a getSalt(). Oboje ako parameter vyžadujú používateľské meno $username, podľa ktorého vedia daný záznam najsť:

  // metóda na nájdenie používateľa podľa mena
  public function findUser($username)
  {
    $query = $this->mysql->query("SELECT `id` FROM `uzivatelia` WHERE `username` = '" . $this->mysql->safe($username) . "'"); 
    return $query->num_rows < 1 ? false : true;
  }
  
  // metóda na nájdenie salt-u podľa mena
  public function getSalt($username)
  {
    $query = $this->mysql->query("SELECT `salt` FROM `uzivatelia` WHERE `username` = '" . $this->mysql->safe($username) . "'");
    return $this->mysql->fetchSingle($query)->salt;
  }

To by možno na začiatok v tejto metóde stačilo, ale pomyslíme dopredu. Určite budeme chcieť zistiť na hlavných stránkach, či je vôbec daný používateľ, ktorý si prezerá stránku autorizovaný (podľa toho mu vygenerujeme buď index.php alebo sa presmeruje na login.php). Vytvoríme si teda metódu isAuthenticated(), ktorá vráti buď true (je autorizovaný) alebo false (nie je):

  // metóda, kde overíme, či je daný používateľ prihlásený
  public function isAuthenticated()
  {
    $user_id = $this->mysql->safe($this->session->user_id); // získame ID používateľa, ak nie je, vráti sa nám null.
    if(null === $user_id) // session user_id je null, neexistuje => užívateľ nie je prihlásený
      return false;
    
    $query = $this->mysql->query("SELECT * FROM `uzivatelia` WHERE `id` = '" . $user_id . "'");
    if($query && $query->num_rows > 0)
    {
      $row = $this->mysql->fetchSingle($query);
      // overíme, či daný hash v session sa zhoduje so súčasnými hodnotami, ak nie, vrátime false - zrejme niekto, kto sa pokúša nabúrať do systému pod falošnými údajmi
      return $this->session->user_hash == sha1($row->username . '{' . $row->salt . '}' . $row->password) ? true : false;
    }
  }

Ešte nám určite nesmie chýbať metóda logout(), aby sa vôbec daný používateľ mohol odhlásiť bezpečne:

  // metóda na odhlásenie
  public function logout()
  {
    if($this->isAuthenticated())
      $this->session->sessionDestroy(); // odhlasíme ak vôbec je autorizovaný
    
    $this->mysql = null; // zrušíme spojenie s DB, aby nám neostalo otvorené
    header('Location: login.php'); // presmerujeme
    exit(); // a ukončíme
  }

A na záver, si deklarujeme deštruktor, ktorý pri spustení vyvolá deštruktor inštancie triedy MySQL a zruší tak spojenie s MySQL serverom:

  public function __destruct()
  {
    $this->mysql = null;
  }

Konečný kód tejto veľkej triedy:

<?php

// vložíme triedy, ktoré budeme potrebovať
use Tools\MySQL;
use Tools\CSRFProtection;
use Tools\SessionProtection;

class Authenticate
{
  private $mysql;
  private $csrf;
  private $session;
  
  public function __construct()
  {
    $this->mysql = new MySQL('localhost', 'username', 'pass', 'db');  // vytvoríme si súkromné spojenie s touto triedou
    $this->csrf = CSRFPRotection::getInstance();        // načítame si inštanciu triedy CSRFProtection
    $this->session = SessionProtection::getInstance();  // to isté u triedy SessionProtection
  }
  
  public function auth($username, $password, $token)
  {
    // ošetríme si premenné
    $username = $this->mysql->safe($username);
    $password = $this->mysql->safe($password);
    $token = $this->mysql->safe($token);
    
    if(!$this->csrf->checkApiKey($token, 60))  // skontrolujeme, či daný token je ešte platný
      return false; 
    
    if(!$this->findUser($username)) // netreba porovnávať heslo ani nič navyše, ak daný pooužívateľ vôbec neexistuje
      return false;
      
    $salt = $this->getSalt($username);  // získame si "salt" používateľa na ochranu hesla
    
    $real_password = sha1($password . '{' . $salt . '}');  // spracujeme heslo do takej podoby, ktoré sa ukladadá do databázy
    // nájdeme používateľa v DB
    $query = $this->mysql->query("SELECT * FROM `uzivatelia` WHERE `username` = '" . $this->mysql->safe($username) . "' AND `password` = '" . $this->mysql->safe($real_password) . "'");
    if($query && $query->num_rows > 0)
    {  
      $row = $this->mysql->fetchSingle($query);
      $this->session->user_id = $row->id; // vytvoríme si session na ID používateľa
      $this->session->user_hash = sha1($row->username . '{' . $row->salt . '}' . $row->password); // a session na hash, kde zaheshujeme username, salt a password
      header('Location: index.php'); // nakoniec presmerujeme
      exit(); // a okamzžite skript ukončíme
      
    } 
    else
      return false; // používateľovi sa heslo nezhoduje, vrátime false
  }
  
  // metóda na nájdenie používateľa podľa mena
  public function findUser($username)
  {
    $query = $this->mysql->query("SELECT `id` FROM `uzivatelia` WHERE `username` = '" . $this->mysql->safe($username) . "'"); 
    return $query->num_rows < 1 ? false : true;
  }
  
  // metóda na nájdenie salt-u podľa mena
  public function getSalt($username)
  {
    $query = $this->mysql->query("SELECT `salt` FROM `uzivatelia` WHERE `username` = '" . $this->mysql->safe($username) . "'");
    return $this->mysql->fetchSingle($query)->salt;
  }
  
  // metóda, kde overíme, či je daný používateľ prihlásený
  public function isAuthenticated()
  {
    $user_id = $this->mysql->safe($this->session->user_id); // získame ID používateľa, ak nie je, vráti sa nám null.
    if(null === $user_id) // session user_id je null, neexistuje => užívateľ nie je prihlásený
      return false;
    
    $query = $this->mysql->query("SELECT * FROM `uzivatelia` WHERE `id` = '" . $user_id . "'");
    if($query && $query->num_rows > 0)
    {
      $row = $this->mysql->fetchSingle($query);
      // overíme, či daný hash v session sa zhoduje so súčasnými hodnotami, ak nie, vrátime false - zrejme niekto, kto sa pokúša nabúrať do systému pod falošnými údajmi
      return $this->session->user_hash == sha1($row->username . '{' . $row->salt . '}' . $row->password) ? true : false;
    }
  }
  
  // metóda na odhlásenie
  public function logout()
  {
    if($this->isAuthenticated())
      $this->session->sessionDestroy(); // odhlasíme ak vôbec je autorizovaný
    
    $this->mysql = null; // zrušíme spojenie s DB, aby nám neostalo otvorené
    header('Location: login.php'); // presmerujeme
    exit(); // a ukončíme
  }
  
  public function __destruct()
  {
    $this->mysql = null;
  }
}

Autoloader.php

Aby sme mohli pokračovať so zvyšnými stránkami, je potrebné nám vytvoriť autoloader, ktorý sa ako veľká sekretárka stará o to, kde sa každá trieda nachádza, ak sa rozhodneme vyvolať práve triedu napr. Tools\SessionProtection. Pozrime si na začiatok kód, ako to vôbec funguje:

<?php

// vytvoríme si vlastný autoloader
function __autoload($nazov_triedy)
{
    $nazov_triedy = str_replace('\\', '/', $nazov_triedy); // premeníme znaky \ na /
    require_once('classes/' . strtolower($nazov_triedy) . '.class.php'); // všetko dáme na malé písmená
}

Takže vo funckii __autoload() sme na začiatku premenili všetky znaky \ na /, čo nám umožňuje prechádzať po priečinkoch a následne sme všetko dali na malé písmená. Takže trieda Tools\SessionProtection potrebuje byť v zložke tools, ktorá sa nachádza v zložke classes v súbore pod názvom sessionprotection.class.php.

Login.php

Aku prvú stránku si zvolíme login.php, keďže ako prvý návštevník uvidí práve túto prihlasovaciu stránku (za predpokladu, že ju máme dobre naprogramovanú). Už na začiatok, je nám potrebné vložiť autoloader.php, aby sme mohli ďalej s týmito triedami pracovať:

require_once('autoloader.php');

Potom si určíme, s ktorými triedami chceme pracovať. Určite to bude Authenticate, MultiLoginProtection a CSRFProtection na generovanie tokenu. SessionProtection nám v tomto prípade netreba, nič iné v logine s ním robiť nepotrebujeme a spúšťa sa aj v triede Authenticate, ktorú vyvolávať budeme. Následne si aj vytvoríme premenné, kde tieto inštancie si uložíme:

// pripravíme si triedy
use Authenticate;
use Tools\MultiLoginProtection;
use Tools\CSRFProtection;

// uložíme si ich inštancie
$multiLogin = MultiLoginProtection::getInstance();
$token = CSRFProtection::getInstance();
$auth = new Authenticate();

Teraz môžeme zistiť, či je náš návštevník vôbec prihlásený. Ak áno, bude presmerovaný. Na login-e nemá čo hľadať:

if($auth->isAuthenticated()) { // je už prihlásený?
  header('Location: index.php'); // ak áno, presmerujeme
  exit();
}

Potom pokračujeme ďalej, ak nie je. Chceme vedieť, či ak boli odoslané POST požiadavky (berieme do úvahy, že bol odoslaný formulár), tak zistíme, či náhodou už nemá 5 a viac neúspešných pokusov:

// zistíme, či boli odoslané nejaké POST požiadavky
if(isSet($_POST) && $_POST)
{
  if($multiLogin->getBadLogins() > 5) { // ak už bolo 5 a viac neúspešných pokusov, ďalšiemu prihlasovaniu už zabránime
    $message = 'Prihlasovali ste sa veľakrát neúspešne. Prosím počkajte 60 sekúnd.';
  }

Ak nemá, priamo, surovo a drzo sa spýtame, či používateľ zadal nesprávne údaje alebo token je nesprávny, resp. už vypršal:

  else
  {
    // zistíme, či nie sú zadané prihl. údaje správne a zároveň aj token (keby boli správne, budeme presmerovaní)
    if(!$auth->auth($_POST['username'], $_POST['password'], $_POST['token']) ) {
      $message = 'Prístup odmietnutý';  // ak nie sú, uložíme si chybovú správu 
      $multiLogin->addBadLogin(); // a uložíme si zlé prihlásenie
    }
  }    
}

Potom si do premennej $tokenApi uložíme nový vygenerovaný token a nastavíme mu dĺžku životnosti 60 sekúnd (viac mu hádam na prihlasovanie netreba):

$tokenApi = $token->createApiKey(); // vygenerujeme si token

Potom už len v triede MultiLoginProtection vyvoláme metódu close() na uzatvorenie MySQL spojenia a objekt Authenticate môžeme zničiť, keďže tiež sa po zničení zruší MySQL spojenie.

$multiLogin->close(); // zrušíme spojenie s MySQL serverom v triede MultiLoginProtection
$auth = null; // a taktiež to isté aj v triede Authenticate
?>

Potom už len zostáva vygenerovať HTML kód:

<html>
  <head>
  <title>Prosím, prihláste sa</title>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  </head>
  <body>
    <fieldset>
      <legend>Prihláste sa</legend>
      <p>
        <?php if($message): ?>
          <div style="display: block; border: 1px solid red; padding: 10px; width: 500px; margin: 10px; color: red;">
            <?= $message; ?>
          </div>
        <?php endif; ?>
        <form action="" method="post">
        <table>
          <tr>
            <td><label for="login-username">Používateľské meno:</label></td>
            <td><input type="text" name="username" id="login-username"></td>
          </tr>
          <tr>
            <td><label for="login-password">Používateľské heslo:</label></td>
            <td><input type="password" name="password" id="login-password"></td>
          </tr>
          <tr>
            <td>&nbsp;</td>
            <td>
            <input type="hidden" name="token" value="<?= $tokenApi; ?>">
            <input type="submit" value="Prihlásiť"></td>
          </tr>
        </table>
        <p>Platnosť tohto formulára je 60 sekúnd</p>
        </form>
      </p>
    </fieldset>
  </body>
</html>

Výsledný kód tohto súboru:

<?php

require_once('autoloader.php');

// pripravíme si triedy
use Authenticate;
use Tools\MultiLoginProtection;
use Tools\CSRFProtection;

// uložíme si ich inštancie
$multiLogin = MultiLoginProtection::getInstance();
$token = CSRFProtection::getInstance();
$auth = new Authenticate();

if($auth->isAuthenticated()) { // je už prihlásený?
  header('Location: index.php'); // ak áno, presmerujeme
  exit();
}

// zistíme, či boli odoslané nejaké POST požiadavky
if(isSet($_POST) && $_POST)
{
  if($multiLogin->getBadLogins() > 5) { // ak už bolo 5 a viac neúspešných pokusov, ďalšiemu prihlasovaniu už zabránime
    $message = 'Prihlasovali ste sa veľakrát neúspešne. Prosím počkajte 60 sekúnd.';
  }
  else
  {
    // zistíme, či nie sú zadané prihl. údaje správne a zároveň aj token (keby boli správne, budeme presmerovaní)
    if(!$auth->auth($_POST['username'], $_POST['password'], $_POST['token']) ) {
      $message = 'Prístup odmietnutý';  // ak nie sú, uložíme si chybovú správu 
      $multiLogin->addBadLogin(); // a uložíme si zlé prihlásenie
    }
  }    
}

$tokenApi = $token->createApiKey(); // vygenerujeme si token

$multiLogin->close(); // zrušíme spojenie s MySQL serverom v triede MultiLoginProtection
$auth = null; // a taktiež to isté aj v triede Authenticate
?>
<html>
  <head>
  <title>Prosím, prihláste sa</title>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  </head>
  <body>
    <fieldset>
      <legend>Prihláste sa</legend>
      <p>
        <?php if($message): ?>
          <div style="display: block; border: 1px solid red; padding: 10px; width: 500px; margin: 10px; color: red;">
            <?= $message; ?>
          </div>
        <?php endif; ?>
        <form action="" method="post">
        <table>
          <tr>
            <td><label for="login-username">Používateľské meno:</label></td>
            <td><input type="text" name="username" id="login-username"></td>
          </tr>
          <tr>
            <td><label for="login-password">Používateľské heslo:</label></td>
            <td><input type="password" name="password" id="login-password"></td>
          </tr>
          <tr>
            <td>&nbsp;</td>
            <td>
            <input type="hidden" name="token" value="<?= $tokenApi; ?>">
            <input type="submit" value="Prihlásiť"></td>
          </tr>
        </table>
        <p>Platnosť tohto formulára je 60 sekúnd</p>
        </form>
      </p>
    </fieldset>
  </body>
</html>

Index.php

V tomto súbore sa nám výstup zobrazí práve vtedy, ak sme prihlásený. V opačnom prípade nás to presmeruje na login.php, ak ste postupovali správne. Na začiatku si taktiež vložíme autoloader a momentálne nám bude stačiť vložiť len 1 triedu a ňou je Authenticate. S ňou overíme, či daný používateľ je vôbec prihlásený:

<?php

require_once('autoloader.php');

use Authenticate; // pripravíme si triedu Authenticate

$auth = new Authenticate(); // vytvoríme si inštanciu

// ak nie je prihlásený, nemá to čo hľadať, takže ho presmerujeme na login.php
if(!$auth->isAuthenticated()) {
  $auth = null; // tu už objekt vopred zničíme, keďže sa k tomu druhému zničeniu ani nedostaneme
  header('Location: login.php');
  exit();
}

$auth = null; // objekt znčíme a tým uzavrieme MySQL spojenie 
?>

Museli sme tu písať na 2x $auth = null; z dôvodu, že ak nie je prihlásený, presmerujeme ho na login.php a skript ukončíme pomocou príkazu exit(), teda nie je možnosť, aby sa dostal k tomu druhému $auth = null;.

Potom už opäť ostáva vypísať nejaký output, že sme sa úspešne prihlásili:

<html>
  <head>
  <title>Admin panel</title>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  </head>
  <body>
    <h1>Boli ste úspešne prihlásený!</h1>
    <h2><a href="./logout.php">Odhlásiť sa</a></h2>
  </body>
</html>

Celý kód:

<?php

require_once('autoloader.php');

use Authenticate; // pripravíme si triedu Authenticate

$auth = new Authenticate(); // vytvoríme si inštanciu

// ak nie je prihlásený, nemá to čo hľadať, takže ho presmerujeme na login.php
if(!$auth->isAuthenticated()) {
  $auth = null; // tu už objekt vopred zničíme, keďže sa k tomu druhému zničeniu ani nedostaneme
  header('Location: login.php');
  exit();
}

$auth = null; // objekt znčíme a tým uzavrieme MySQL spojenie 
?>
<html>
  <head>
  <title>Admin panel</title>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  </head>
  <body>
    <h1>Boli ste úspešne prihlásený!</h1>
    <h2><a href="./logout.php">Odhlásiť sa</a></h2>
  </body>
</html>

Logout.php

A ako na záver si už pre oddychovku vytvoríme logout.php. Tu taktiež len použime triedu Authenticate a metódu logout(). Tá už za nás to MySQL spojenie zruší, preto nemusíme ani tento objekt vymazávať:

<?php

require_once('autoloader.php');

use Authenticate; // pripravíme si objekt Authenticate

$auth = new Authenticate();  // uložíme inštanciu

$auth->logout(); // a odhlásime sa

 

A to je všetko. Výsledné demo tohto dlhého až psychicky zničujúceho návodu je tu.

Prihlasovacie údaje:

Username: admin
Password: tajneheslo

Ako bonus by ste si mohli prirobiť to, že ak vyprší platnosť tokenu, bude o tom iná chybová správa ako len Nepovolený prístup. Nezabudnite v prípade akýchkoľvek otázok, návrhov a pod. napísať komentár.

Komentáre

Václav píše:
19.3.2014 10:59:52[odpovedať]

Jen by to asi chtělo nějaký mechanismus, který by umožnil ponechat v SESSION (resp. předat do nové SESSION) údaje, které nejsou svázané s přihlašováním. Přeci jen, session_regenerate_id(TRUE) vytvoří úplně prázdnou novou SESSION - podobně jako kdybych obsah SESSION vymazal pomocí $_SESSION = array(), tedy až na to, že ID SESSION bude jiné.

Eduard Karpiel píše:
19.3.2014 17:14:54[odpovedať]

@Vaclav použitím funkcie session_regenerate_id() len vygeneruje nové session ID. Po danej zmene sa bežné hodnoty nevymažú.

Peter píše:
08.2.2016 09:53:50[odpovedať]

Dobry den ako provtne naplit tabulku uzivatelia ? dakujem peto

Peter píše:
08.2.2016 10:09:42[odpovedať]

@Peter dakujem, uz to mam ;-)

Eduard Karpiel píše:
06.5.2016 21:21:42[odpovedať]

@Peter ejha v článku som to nespomenul, ale je potrebné to urobiť ručne. Avšak treba si dávať pozor v akom formáte je heslo šifrované. Na sha1 zašifrovanie môžete využiť aj online nástroj: sha1.karpiel.sk . Ospravedlňujem sa za neskorú odpoveď (už som ani neveril, že to niekto vôbec číta :-D).

Vlado píše:
18.7.2017 16:11:11[odpovedať]

Prosím o pomoc: hláška Warning: mcrypt_encrypt(): Key of size 10 not supported by this algorithm. Only keys of sizes 16, 24 or 32 supported in ... on line 44 >> return mcrypt_encrypt(MCRYPT_RIJNDAEL_256, 'SECURE_KEY', $value, MCRYPT_MODE_ECB, $iv);

Odoslať komentár

Posledné Referencie

Deffender BG & FUN private server Eduard Karpiel - portfólio VideoVizitky.SK Apartmány Maladinovo Lymfoštúdia Eva Czech Imperial Server

O Mne

Mám 21 rokov. Študujem Informatiku v odbore programovanie a taktiež sa venujem tvorbe nových webstránok. Pracujem s jazykmi

  • PHP (OOP) & MySQL
  • HTML & CSS (len drobné zmeny)
  • Symfony2 Framework (začiatočník, ale učím sa rýchlo - viď tento web)

Kontakt

V prípade záujmu alebo nejakého dotazu ma neváhajte kontaktovať na nižšie uvedených možnostiach. Poprípade môžete využiť kontaktný formulár

ICQ: 357-248-017
Skype: lkopo__ (upřednostňujte)
E-mail: