Ясные печеньки

от автора

Привет, Хабр! В свете информации, посвященной безопасности аккаунтов крупных порталов, появившейся в последнее время, я решила немного пересмотреть cookie авторизацию в своих проектах. В первую очередь был допрошен с пристрастием гугл на тему готовых решений. Ничего толкового не нашлось, хотя может статься и так, что я не умею пользоваться поиском. После этого я решила посмотреть, что же вообще пишут про то, как правильно жевать печенье. Моему удивлению не было границ, когда в основной массе оказались статьи из разряда «вредные советы», и то что я читала более 5-и лет назад.
Эта статья — попытка исправить сложившуюся ситуацию.

Для многих нижеизложенное покажется очевидным, но, думаю, найдется и не мало тех, для кого эта информация окажется полезной. В ходе изысканий и размышлений на тему: «как поступать простым смертным, не имеющим субдоменов», был придуман велосипед подход, на авторство которого я не претендую, ибо, как сказано выше, существует отличная от нуля вероятность того, что я не умею пользоваться поиском. В примерах будет использоваться PHP, так как это самый популярный среди меня язык, но и у людей с более тонкой душевной организацией не должно возникнуть проблем с пониманием происходящего. Итак, приступим.

Печенье мы будем хранить как в лучших домах Филадельфии: в красивой жестяной коробочке. То есть при авторизации устанавливается сookie для единственного каталога, отличного от document root. В дальнейшем эта cookie используется только в том случае, когда сессия не установлена, или данные (IP-адрес жертпользователя) не валидны. На странице проверки cookie не должно быть никакого динамического содержимого.

Теперь давайте рассмотрим алгоритм подробнее.

Для начала нам понадобится SQL таблица со следующей структурой:

CREATE TABLE `user_auth_cookies` (  `key` char(32) NOT NULL,  `user_id` int(10) unsigned NOT NULL,  `logged_in` datetime NOT NULL,  PRIMARY KEY (`key`),  KEY `user_id` (`user_id`),  KEY `logged_in` (`logged_in`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 

кое-какие предустановки, и небольшой класс User:

$db = new mysqli('localhost', 'test', '', 'test'); if ($db->connect_errno) die('Не удалось подключиться к MySQL: ('.$db->connect_errno.') '.$db->connect_error); // думаю тут все понятно  define('DOMAIN', ($_SERVER['HTTP_HOST'] !== 'localhost' ? $_SERVER['HTTP_HOST'] : false)); // Доменное имя сайта. Если localhost, то false (в противном случае куки не выставятся как надо) define('AUTO_AUTH_URL', '/auth/auto'); // ссылка на страницу автоматической авторизации define('AUTH_URL', '/auth'); // ссылка на форму авторизации define('AUTH_COOKIE_DURATION', 20); // временной интервал в днях, на который следует устанавливать куки define('USE_HTTPS', false); // использовать HTTPS для передачи кук (для счастливых обладателей подписанных сертификатов)  class User {  	private $_id;  	public $isGuest = true; 	public $name = 'Гость';  	public function __construct() { 		GLOBAL $db;  		if (!isset($_SESSION['user']) || $_SESSION['user']['ip'] !== $_SERVER['REMOTE_ADDR']) 			return;  		$query = 'SELECT * FROM users WHERE `id` = '.(int)$_SESSION['user']['id']; 		if (($res = $db->query($query)) !== false && $res->num_rows) {  			$user = $res->fetch_assoc();  			$this->_id = $user['id']; 			$this->name = $user['name']; 			$this->isGuest = false;  			if (isset($_SESSION['last_request'])) { 				$_POST = $_SESSION['last_request']['data']; 				unset($_SESSION['last_request']); 			}  		} else { 			unset($_SESSION['user']); 		} 	}  	public function getId() { 		return $this->_id; 	} } 

Как видно, используются две сессионные переменные, одна из них (user) непосредственно для авторизации, а вторая (last_request) для сохранения URI, с которого потребовался запрос авторизации, и параметров POST, чтобы не потерялись данные отправляемые формы, если таковые имелись.

Для авторизации используется класс Auth, содержащий 4 статических метода.

class Auth {  	public static function loginRequired() { 		$_SESSION['last_request'] = array( 			'url' => $_SERVER['REQUEST_URI'], 			'data' => $_POST 		);  		header('Location: '.AUTO_AUTH_URL); 		die('Перенаправление...'); 	}  	public static function login($login, $password, $remember = false) { 		GLOBAL $db;  		$query = "SELECT * FROM users WHERE `login` = '".$db->real_escape_string($login)."';"; 		if (($res = $db->query($query)) === false || !$res->num_rows) 			return false;  		$user = $res->fetch_assoc();  		// это для примера, проверка может быть абсолютно любой 		if ($user['password'] !== md5($login.md5($password))) 			return false;  		if ($remember) { 			do { 				$key = md5(mcrypt_create_iv(30)); 				$query = "SELECT COUNT(*) AS `cnt` FROM user_auth_cookies WHERE `key` = '".$key."';";  				$count = 0; 				if (($res = $db->query($query)) !== false && $res->num_rows) { 					$row = $res->fetch_assoc(); 					$count = (int)$row['cnt']; 				} else 					die('Ошибка запроса к БД.'); 			} while ($count > 0);  			$db->query("INSERT INTO user_auth_cookies VALUES ('".$key."', ".$user['id'].", NOW());");  			setcookie('key', $key, strtotime('+'.AUTH_COOKIE_DURATION.' days'), AUTO_AUTH_URL, DOMAIN, USE_HTTPS, true); 		}  		$_SESSION['user'] = array( 			'id' => $user['id'], 			'ip' => $_SERVER['REMOTE_ADDR'], 		);  		return true; 	}  	public static function loginByCookie() { 		GLOBAL $db;  		$location = AUTH_URL;  		if (isset($_COOKIE['key'])) {  			$query = "SELECT user_id FROM user_auth_cookies WHERE `key` = '".$db->real_escape_string($_COOKIE['key'])."';";  			if (($res = $db->query($query)) !== false && $res->num_rows) {  				$row = $res->fetch_assoc();  				$_SESSION['user'] = array( 					'id' => $row['user_id'], 					'ip' => $_SERVER['REMOTE_ADDR'] 				);  				$location = '/'; 				if (isset($_SESSION['last_request'])) 					$location = $_SESSION['last_request']['url']; 			} 		}  		header('X-Frame-Options: DENY'); // защита от встраивания в FRAME/IFRAME 		header('Location: '.$location); 		die('Перенаправление...'); 	}  	public static function logout() {  		if (!isset($_SESSION['user'])) 			return;  		if (mb_strlen($_SESSION['user']['key']) === 32) 			$db->query("DELETE FROM user_auth_cookies WHERE `key` = '".$db->real_escape_string($_SESSION['user']['key'])."';");  		setcookie('key', '', 0, AUTO_AUTH_URL, DOMAIN, USE_HTTPS, true);  		unset($_SESSION['user']);  		header('Location: /'); 		die('Перенаправление...'); 	} } 

Auth::loginRequired() вызывается, если пользователь не авторизован и переходит на страницу, требующую авторизацию. Метод сохраняет текущий URI и параметры POST запроса в сессионную переменную (сохранение POST данных нужно, если пользователь писал длинный гневный пост, и у него в этот момент сменился IP), и перенаправляет на страницу автоматической авторизации по cookie.
В контексте класса User:

…… $user = new User(); if ($user->isGuest) 	Auth::loginRequired(); …… 

Auth::login($login, $password, $remember = false) вызывается, если получена форма авторизации. Параметр $login внезапно содержит полученный логин, $password не менее внезапно — пароль, $remember – флаг отвечающий за установку куки.
Пример использования:

…… if (isset($_POST['login']) && Auth::login($_POST['login'], $_POST['password'], !!$_POST['remember_me'])) {  	$location = '/'; 	if (isset($_SESSION['last_request'])) 		$location = $_SESSION['last_request']['url'];  	header('Location: '.$location); 	die('Перенаправление...'); } …… 

Auth::loginByCookie() вызывается на странице автоматической авторизации. Напомню, что во избежание неприятных ситуаций на этой странице не должно быть никакого динамического вывода, и не надо ничего подгружать из других директорий, тем более доменов. И вообще на директорию скрипта не плохо бы установить RewriteRule, перенаправляющее абсолютно все запросы на этот скрипт. Допустим так:
.htaccess

<ifModule mod_rewrite.c>  RewriteEngine On  RewriteCond %{REQUEST_FILENAME} !-U  RewriteRule ^.*$ index.php [L,QSA] </ifModule>  

Auth::logout() вызывается для «разлогинивания» пользователя. Очищает куку и сессионную перемнную, удаляет за ненадобностью ключ из базы, и перенаправляет на главную.

Остался последний штрих. Необходимо периодический (по cron) очищать таблицу от устаревших ключей.

…… $db->query("DELETE FROM user_auth_cookies WHERE `logged_in` < DATE_SUB(NOW(), INTERVAL ".AUTH_COOKIE_DURATION." DAYS);"); …… 

Также можно добавить на сайт кнопку типа: «Разлогинить меня на всех устройствах», при нажатии на которую удаляются все ключи авторизации из таблицы user_auth_cookies по user_id. Это нужно если пользователь, например, забыл нажать на «Выход» на чужом компьютере, или у него украли мобильное устройство, с которого он посещал ваш сайт.

…… if (isset($_POST['signout_all']) { 	$db->query("DELETE FROM user_auth_cookies WHERE `user_id` = ".$user->getId()); 	Auth::logout(); } …… 

На любителя можно добавить проверку user agent, или еще чего-то подобного, но, на мой взгляд, от этого не будет никакого толку потому, что, если куку решат угнать, то делать это будут вместе с user agent, и прочими подобными атрибутами.

На этом все, желаю вашему печенью оставаться всегда свежим и хрустящим.

ссылка на оригинал статьи http://habrahabr.ru/post/210416/


Комментарии

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *