C, PHP, VB, .NET

Дневникът на Филип Петров


* Автоматична автентикация с cookie чрез token

Публикувано на 20 март 2015 в раздел ОСУП.

В предишните примери, в които показвахме автентикация с cookie, ние записвахме името и паролата в това cookie. Основен проблем на първоначалното решение беше, че ги записвахме като най-обикновен текст. В този случай хакер може да открадне бисквитката и моментално да разбере името и паролата на човека. Първоначалното решение на проблема, което предложихме, беше да се криптират със симетричен алгоритъм за криптиране (напр. aes256-cbc) с много дълъг (хардкоднат в приложението ни, или пък защо не – записан в базата от данни и различен за всеки клиент) ключ. В тази статия ще покажем една изцяло различна концепция – няма да записваме име и парола в бисквитката, а вместо това ще записваме уникален token в базата от данни, с който ще автентикираме потребителите. За основа на кода ще използваме този осъвременен вариант на нашата автентикационна система вместо оригиналния пример (въпреки, че ще забележите, че освен key straightening и по-надеждни хеш алгоритми, като цяло кодът е еквивалентен на оригиналния). Оригиналните статии за cookies (1, 23) бяха писани преди да покажем техниките за съхранение на пароли в база от данни. Именно затова в тази статия ще обвържем двете техники в един общ автентикиращ стрипт.Трябва още от самото начало да отбележим, че замяната на името и паролата с token не разрешава проблема с „открадване на login cookie“. Дали хакерът е откраднал login cookie с криптирани имена и пароли, или е откраднал login cookie с token, това за него няма значение – ще го използва и ще влезне в системата. Решението, което предложихме в статията за aes256-cbc криптиране в PHP показахме как можем да добавим някои уникални за потребителя характеристики, като например неговия IP адрес, като част от ключа и така да направим малко по-трудно преизползването на това cookie на друга машина. Това решение въобще не е устойчиво, защото при смяна на IP клиента ще губи своето cookie.

Каква е разликата между съхраняването на криптирани имена и пароли в cookie и съхранявахме login token в бисквитката? Представете си, че засечем, че наши потребители са станали потенциална жертва на кражба на cookie (например намерили сме уязвимост на нашия сайт). Ако съхраняваме имена и пароли в cookie, вече не е тривиално да поправим проблема – ние трябва да накараме всички потребители да си сменят паролите. Докато ако ние вместо имена и пароли съхраняваме произволен token за всеки потребител, ние имаме голямо предимство, защото, при засичане на изтичане на информация, ние просто можем да изтрием всички такива tokens от базата, с което ще направим невалидни всички откраднати login cookies. Освен това тези cookies сами по себе си не съдържат информация, която може да бъде използвана в offline атака, така че да бъде извлечена допълнителна информация от базата (т.е. да бъдат евентуално декриптирани имена и пароли, както беше в оригиналния дизайн).

Има и още един важен момент – не трябва да добавяме token директно в базата от данни. Този token реално е еквивалент на паролата на потребителя (втора парола)! Затова ние трябва да го хешираме по същия начин, по който се отнасяхме с паролите – трябва да се използва salt, pepper и key stretching.

Ето нашата обновена login система, която добавя функционалност „запомни ме“ (автоматична автентикация) чрез token в cookie:

База от данни – промяната тук е, че добавяме подклас на таблица users, в който ще записваме въпросните tokens.

DROP DATABASE upr2;
CREATE DATABASE upr2;
USE upr2;

CREATE TABLE users(
 id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
 user VARCHAR(64) NOT NULL UNIQUE,
 pass CHAR(96) NOT NULL,
 salt CHAR(96) NOT NULL
);

GRANT SELECT ON upr2.users
TO loginform@localhost
IDENTIFIED BY 'fghjudighfduishgiufdsw';

CREATE TABLE users_cookie_tokens(
	uid INT UNSIGNED NOT NULL,
        FOREIGN KEY(uid) REFERENCES users(id),
	token CHAR(96) NOT NULL,
	PRIMARY KEY (uid, token)
);
GRANT SELECT, INSERT, UPDATE ON upr2.users_cookie_tokens
TO loginform@localhost;

passgen.php – този скрипт е без промяна – добавяме го тук, за ваше удобство

<?php
DEFINE("PEPPER", 'jfrikl^#@&*wsa^$#&*gjr__--eiu--AAa');
DEFINE("KEYSTRECHITERATIONS", 100000);
	
$user = "ivan";
$pass = "123456";

$salt = base64_encode(openssl_random_pseudo_bytes(72));
for($i=0; $i<KEYSTRECHITERATIONS; $i++){
	$pass = hash("sha384", $salt.$pass.PEPPER);
}
echo "INSERT INTO users(user, pass, salt)
VALUES('".$user."', 
'".$pass."', 
'".$salt."')";
?>

index.php – в главната страница вече добавяме пренасочване към login.php, в случай че засечем наличие на cookie – условието в самото начало на скрипта:

<?php
if( isset($_COOKIE['user']) &&
    isset($_COOKIE['token'])){
	header("Location: login.php");
	exit;
}
session_start();
?>
<html><head><title>Login page</title></head>
<body>
<form action="login.php" method="POST">
USER: <input type="text" name="user" /><br />
PASS: <input type="password" name="pass" /><br />
Remember? <input type="checkbox" name="remember" value="1" /><br />
<input type="submit" name="loginbutton" value="Login!" /><br />
</form><br />
<?php
if(isset($_SESSION['error'])){
	echo $_SESSION['error'];
	session_unset();
	session_destroy();
}
?>
</body></html>

login.php – промените в скрипта за автентикация спрамо предишния му вариант сме ги отбелязали с коментари

<?php
DEFINE("DBHOST", "localhost");
DEFINE("DBUSER", "loginform");
DEFINE("DBPASS", "fghjudighfduishgiufdsw");
DEFINE("DBNAME", "upr2");
DEFINE("PEPPER", 'jfrikl^#@&*wsa^$#&*gjr__--eiu--AAa');
DEFINE("HASHALGO", "sha384");
DEFINE("KEYSTRECHITERATIONS", 100000);

function POSTVarsExists(){
   return (isset($_POST['user']) 
	   && isset($_POST['pass']) 
	   && isset($_POST['loginbutton']));
}

function userAndPassAreValidFormat($user, $pass){
   return (is_string($user) && is_string($pass)
           && strlen($pass)>=6 
	   && strlen($pass)<=64
	   && preg_match("/^[a-zA-Z0-9_-]{4,64}$/", $user));
}

function backToIndex($error){
   switch($error){
	case 0: break;
	case 1:	session_start();
		session_unset();
		$_SESSION['error'] 
	          = "Invalid username OR password";
		break;
	case 2:	session_start();
		session_unset();
		$_SESSION['error'] 
	          = "Technical issues. Come back later";
		break;
   }
   header("Location: index.php");
}

function invalidateLoginCookie(){
   setcookie("user", "", time()-3600);
   setcookie("token", "", time()-3600);
}

// Функция за автоматичен вход с cookie
function setAutoLoginCookie($link, $id, $user, $salt){
   // Генерираме произволен token и го записваме в cookie
   $token = base64_encode(openssl_random_pseudo_bytes(72));
   setcookie("user", $user, time()+3600);
   setcookie("token", $token, time()+3600);
   // Хешираме token, за да го запишем в базата
   $htoken = $token;
   for($i=0; $i<KEYSTRECHITERATIONS; $i++){
      $htoken = hash("sha384", $salt.$htoken.PEPPER);
   }
   $sql = "INSERT INTO users_cookie_tokens(uid, token) VALUES 
	   (".(int)$id.", '".$htoken."')";
   $result = @mysqli_query($link, $sql);
}

// Функция за автоматичен вход с cookie
function autoLoginWithCookie($link, $user, $token){
   // Salt ни е нужен, за да хешираме получения token
   $sql = "SELECT salt FROM users
    WHERE user = '".@mysqli_real_escape_string($link, $user)."'";
   $result = @mysqli_query($link, $sql);
   $row = @mysqli_fetch_assoc($result);
   $salt = $row['salt'];
   if(!$salt){
      invalidateLoginCookie();
      backToIndex(1);
      exit;	
   }
   // Хешираме token
   $htoken = $token;
   for($i=0; $i<KEYSTRECHITERATIONS; $i++){
      $htoken = hash("sha384", $salt.$htoken.PEPPER);
   }
   // Проверяваме за наличие на такъв потребител в базата
   $sql = "SELECT uid FROM users_cookie_tokens
	   WHERE token = '".$htoken."'
		   AND
		 uid = (
		   SELECT id FROM users
		   WHERE user = 
           '".@mysqli_real_escape_string($link, $user)."')";
					
   $result = @mysqli_query($link, $sql);
   $row = @mysqli_fetch_assoc($result);
   if(!isset($row['uid'])){
	invalidateLoginCookie();
	backToIndex(1);
	exit;
   }
	
   $id = $row['uid'];
   // Ще сменим token на потребителя с нов
   $token = base64_encode(openssl_random_pseudo_bytes(72));
   setcookie("user", $user, time()+3600);
   setcookie("token", $token, time()+3600);
   $newtoken = $token;
   for($i=0; $i<KEYSTRECHITERATIONS; $i++){
      $newtoken = hash("sha384", $salt.$newtoken.PEPPER);
   }	
   $sql = "UPDATE users_cookie_tokens
	   SET token = '".$newtoken."'
	   WHERE uid = ".$id."
		   AND
		 token = '".$htoken."'";
   $result = @mysqli_query($link, $sql);
   // Потребителят е валиден
   session_start();
   session_unset();
   session_regenerate_id(true);
   $_SESSION['userid'] = $id;
   header("Location: securearea.php");
   exit;
}

$link = @mysqli_connect(DBHOST,DBUSER,DBPASS,DBNAME);
if(!$link){
   backToIndex(2);
   exit;
}

// Добавяме проверка за cookie
if(!POSTVarsExists()){
   if(isset($_COOKIE['user']) && isset($_COOKIE['token'])){
     autoLoginWithCookie($link, $_COOKIE['user'], $_COOKIE['token']);
   }
   else{
     backToIndex(0);
     exit;
    }
}
else{
   $user = $_POST['user'];
   $pass = $_POST['pass'];
}

if(!userAndPassAreValidFormat(
	$user, $pass
  )){
	invalidateLoginCookie();
	backToIndex(1);
	exit;		
}

$user = @mysqli_real_escape_string($link, $user);

$sql = "SELECT id, pass, salt 
        FROM users 
	WHERE '".$user."' = user
	LIMIT 1";
$result = @mysqli_query($link, $sql);
if(!$result){
	backToIndex(2);
	@mysqli_close($link);
	exit;		
}

$row = @mysqli_fetch_assoc($result);
if(!isset($row['id'])){
	invalidateLoginCookie();
	backToIndex(1);
	@mysqli_close($link);
	exit;		
}

for($i=0; $i<KEYSTRECHITERATIONS; $i++){
	$pass = hash(HASHALGO, $row['salt'].$pass.PEPPER);
}

if($pass !== $row['pass']){
	invalidateLoginCookie();
	backToIndex(1);
	@mysqli_close($link);
	exit;	
}

// Записваме cookie ако клиента желае
if(isset($_POST['remember'])){
   setAutoLoginCookie($link, $row['id'], $user, $row['salt']);
}

session_start();
session_unset();
session_regenerate_id(true);
$_SESSION['userid'] = $row['id'];
header("Location: securearea.php");
exit;
?>

securearea.php – В този скрипт няма никаква промяна.

<?php
session_start();
if(!isset($_SESSION['userid'])){
	header("Location: index.php");
	exit;
}
?>
<html><head><title>Secure area</title></head>
<body>
<?= "Hello ".$_SESSION['userid'] ?>
</body></html>

С това автентикацията с cookie чрез token вместо с име и парола е осъществена. Прилагайки такова решение обаче води до опасност – с времето постепенно да задръстим тази таблица с отдавна неизползвани tokens. Затова е препоръчително да добавите още една колона – expiry_time – с която да укажете до кога максимално е валиден въпросния token. След това лесно ще може да си направите EVENT в базата от данни, с които периодично да изтривате tokens, които вече са изтекли. Реализирайте тази функционалност като задача за упражнение.

Остава обаче отворен въпроса „как да предотвратим кражба на cookie“. Забележете, че при успешен вход с cookie, ние винаги генерираме нов token и презаписваме това cookie. Това все още няма особена полза от гледна точка на сигурността – просто знаем, че ако двама човека имат едно и също cookie, първия който влезне ще направи cookie на втория невалидно. Така ако хакерът открадне бисквитката на клиента и влезе, на този етап просто клиента ще получи login страница при следващото си влизане – той едва ли би се досетил, че това е потенциално хакване на неговия акаунт. Затова даваме няколко допълнителни идеи, които бипа подобрили сигурността:

  1. Да покажем на клиента кога е било последното му влизане в системата – вярно е, че много хора може да не обърнат внимание, но като цяло това е препоръчителна практика. Тази мярка не е защита, а е само с уведомителен характер;
  2. Да уведомим клиента, че неговия акаунт потенциално е бил хакнат – ако клиента подаде login cookie, което е невалидно, можем да направим презумпция, че то е било откраднато и хакера вече е влязъл с откраднатото (съответно е генерирал ново, а старото вече е невалидно). Акаунтите НЕ трябва да се блокират при такива аларми, защото това ще доведе до потенциален DoS – хакерът ще прави заявки с невалидни cookies и постепенно ще блокира всички акаунти. Не трябва и да изтривате всички tokens за дадения акаунт, защото това ще позволи на хакера да унищожава login cookies на нашите потребители (няма да е DoS, но ще е неудобство за потребителите). Затова подобна защита ще е само и единствено с уведомителен характер – клиента ще реши сам какво действие да предприеме от този момент нататък. Колкото до предложеното по-нагоре „разчистване на изтекли tokens – самите cookies също си имат време на валидност. Просто се погрижете, че времената между базата и cookies са синхронизирани и няма да има проблеми (би било напълно очаквано и нормално да е така);
  3. Добавяне на сериен идентификатор – в сегашната имплементация ако хакера открадне login cookie на потребител и влезе в акаунта, старото login cookie, което потребителя ще представи, няма да бъде различимо от „невалидно“ и „бивше валидно“. Ако ние бихме могли да ги различим, това ще ни помогне да предприемем политика за блокиране на акаунт при засичане на опит за вход с подменена бисквитка. Идеята е следната – освен сменящият се при всеки вход „token“, добавяме още един перманентен token – сериен идентификатор (series identifier), който се записва в базата от данни и в бисквитката при първото влизане в системата. От там-насетне серийният идентификатор не се променя. Проверката за автентикация с cookie ще включва потребителя да подаде едновременно потребителско име, текущ token и сериен идентификатор (комбинацията от трите трябва да е налична като ред в таблицата). Или на този етап може да се каже, че просто добавихме двоен ключ за вход – един, който се подменя при всяко влизане, и един, който никога не се подменя. Така обаче ако хакера открадне login cookie на потребителя и влезне в неговия акаунт, при потребителя ще остане cookie с невалиден token, но валиден сериен идентификатор. Когато потребителят се опита да влезне с такова cookie, ще приемем това за сигнал за хакване и можем да блокираме акаунта.

Имайте предвид, че и 2, и 3 са слаба защита. Тези тактики са само привидно работещи. Действие на хакера, което ще я обезсмисли, е просто да изтрие login cookie на клиента (след като го е откраднал). Разбира се той трябва да може да направи това (трябва освен права за четене, да има и права за изтриване на cookie файлчето).

При всички положения обмислете и следното:

  • Давайте лимитиран достъп при автоматично автентикиране ако в системата има места, при които сигурността е от първостепенно значение (съхранена кредитна карта, смяна на парола, административен панел с допълнителни функции, т.н.). Ако потребителят влезе с автоматичен достъп вместо с парола, можете да му позволите само основните функционалности, а ако желае да използва допълнителни, които са важни от гледна точка на сигурността, попитайте го допълнително за парола.

Задача. Реализирайте 1., 2. и 3. от списъка по-горе.

 



Добави коментар

Адресът на електронната поща няма да се публикува


*