Ajax авторизация и регистрация на PHP

148 комментариев

Напишем систему авторизации и регистрации пользователей на PHP. Для работы скриптов потребуется интерпретатор PHP версии 5.3 и сервер MySQL 5.

Создание базы данных

Создадим новую базу данных с названием testdb, выполнив следующий запрос от привилегированного пользователя.

-- Создание базы данных testdb с кодировкой utf8
CREATE DATABASE testdb CHARACTER SET utf8 COLLATE utf8_general_ci;

Для работы с базой, добавим отдельного пользователя testdb и предоставим ему необходимые права.

-- Создание пользователя testdb с паролем testdb
CREATE USER testdb IDENTIFIED by 'testdb';
-- Выделение прав на все таблицы базы testdb
GRANT ALL PRIVILEGES ON testdb@localhost TO testdb WITH GRANT OPTION;

Структура таблицы users

Для хранения пользователей создадим в базе данных таблицу users, выполнив следующий запрос.

-- Создание таблицы для хранения пользователей
CREATE TABLE IF NOT EXISTS `users` (
    `id` MEDIUMINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
    `username` VARCHAR(255) NOT NULL UNIQUE,
    `password` VARCHAR(255) NOT NULL,
    `salt` VARCHAR(100) NOT NULL
) ENGINE=INNODB;

Поля таблицы:

  • id — уникальный идентификатор пользователя
  • username — логин
  • password — шифрованный пароль
  • salt — соль для шифрования пароля

Структура файлов и директорий

Создадим директорию «php-auth» для нашего проекта. Добавим в нее следующие файлы и папки:

  • login.php — страница авторизации пользователя
  • register.php — страница регистрации
  • ajax.php — Файл для обработки Ajax-запросов
  • js — клиентские скрипты
    • ajax-form.js — скрипт для работы с Ajax-формами
  • css — стили CSS
    • style.css — основной файл стилей
    • reset.css — файл сброса кастомных стилей браузеров
  • vendor — сторонние third-party библиотеки
    • bootstrap — Twitter Bootstrap 2.3.2
  • classes — основной функционал модуля
    • AjaxRequest.class.php — обертка для работы с Ajax-запросами
    • Auth.class.php — класс для работы с пользователями

Полный архив с исходниками (обновленная версия).

Регистрация пользователей

При регистрации, пользователю нужно ввести логин, пароль и подтверждения пароля. После отправки формы будем делать проверку на существование введенного логина. Если логин уже существует — уведомляем об этом посетителя.

Форма регистрации на PHP

Вся работа с базой данных будет происходить через расширение PDO для PHP. Оно включено в стандартную библиотеку PHP, начиная с версии 5.1.

Основная логика приложения находится в файле classes/Auth.class.php. Разберем подробнее его содержимое.

<?php

namespace Auth;

class User
{
    private $id;
    private $username;
    private $db;
    private $user_id;

    private $db_host = "localhost";
    private $db_name = "testdb";
    private $db_user = "testdb";
    private $db_pass = "testdb";

    private $is_authorized = false;

    public function __construct($username = null, $password = null)
    {
        $this->username = $username;
        $this->connectDb($this->db_name, $this->db_user, $this->db_pass, $this->db_host);
    }

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

    public static function isAuthorized()
    {
        if (!empty($_SESSION["user_id"])) {
            return (bool) $_SESSION["user_id"];
        }
        return false;
    }

    public function passwordHash($password, $salt = null, $iterations = 10)
    {
        $salt || $salt = uniqid();
        $hash = md5(md5($password . md5(sha1($salt))));

        for ($i = 0; $i < $iterations; ++$i) {
            $hash = md5(md5(sha1($hash)));
        }

        return array('hash' => $hash, 'salt' => $salt);
    }

    public function getSalt($username) {
        $query = "select salt from users where username = :username limit 1";
        $sth = $this->db->prepare($query);
        $sth->execute(
            array(
                ":username" => $username
            )
        );
        $row = $sth->fetch();
        if (!$row) {
            return false;
        }
        return $row["salt"];
    }

    public function authorize($username, $password, $remember=false)
    {
        $query = "select id, username from users where
            username = :username and password = :password limit 1";
        $sth = $this->db->prepare($query);
        $salt = $this->getSalt($username);

        if (!$salt) {
            return false;
        }

        $hashes = $this->passwordHash($password, $salt);
        $sth->execute(
            array(
                ":username" => $username,
                ":password" => $hashes['hash'],
            )
        );
        $this->user = $sth->fetch();

        if (!$this->user) {
            $this->is_authorized = false;
        } else {
            $this->is_authorized = true;
            $this->user_id = $this->user['id'];
            $this->saveSession($remember);
        }

        return $this->is_authorized;
    }

    public function logout()
    {
        if (!empty($_SESSION["user_id"])) {
            unset($_SESSION["user_id"]);
        }
    }

    public function saveSession($remember = false, $http_only = true, $days = 7)
    {
        $_SESSION["user_id"] = $this->user_id;

        if ($remember) {
            // Save session id in cookies
            $sid = session_id();

            $expire = time() + $days * 24 * 3600;
            $domain = ""; // default domain
            $secure = false;
            $path = "/";

            $cookie = setcookie("sid", $sid, $expire, $path, $domain, $secure, $http_only);
        }
    }

    public function create($username, $password) {
        $user_exists = $this->getSalt($username);

        if ($user_exists) {
            throw new \Exception("User exists: " . $username, 1);
        }

        $query = "insert into users (username, password, salt)
            values (:username, :password, :salt)";
        $hashes = $this->passwordHash($password);
        $sth = $this->db->prepare($query);

        try {
            $this->db->beginTransaction();
            $result = $sth->execute(
                array(
                    ':username' => $username,
                    ':password' => $hashes['hash'],
                    ':salt' => $hashes['salt'],
                )
            );
            $this->db->commit();
        } catch (\PDOException $e) {
            $this->db->rollback();
            echo "Database error: " . $e->getMessage();
            die();
        }

        if (!$result) {
            $info = $sth->errorInfo();
            printf("Database error %d %s", $info[1], $info[2]);
            die();
        } 

        return $result;
    }

    public function connectdb($db_name, $db_user, $db_pass, $db_host = "localhost")
    {
        try {
            $this->db = new \pdo("mysql:host=$db_host;dbname=$db_name", $db_user, $db_pass);
        } catch (\pdoexception $e) {
            echo "database error: " . $e->getmessage();
            die();
        }
        $this->db->query('set names utf8');

        return $this;
    }
}

Алгоритм регистрации

Для создания нового пользователя используется метод User::create(), который принимает логин и пароль в качестве аргументов.

Первым делом, проверяем существование пользователя. Для этого используем метод User::getSalt(), который выбирает из базы «соль» пользователя по его логину. Соль нужна для усложнения подбора паролей пользователей в случае утечки базы.

Если пользователь существует — выбрасываем исключение. Иначе, генерируем новую соль и хешируем ей пароль. После этого выполняем запрос на добавление данных в базу. Если при выполнении запроса происходит ошибка, печатаем соответствуюее сообщение и завершаем работу скрипта. Такая ситуация может произойти при отключении сервера MySQL или его внутренней ошибки.

Если пользователь был усшешно создан, функция User::create() возвращает его уникальный идентификатор. Это обычное числовое поле, которое автоматически увеличивается при добавлении записей в таблицу.

Алгоритм аутентификации

Для того, чтобы проверить правильность ввода логина и пароля, используется метод User::authorize(). Первым делом, мы проверяем существование юзера, пытаясь выбрать его соль из базы. Если пользователь не найден, сразу возвращаем false. Иначе, хешируем принятый пароль этой солью через функцию User::passwordHash(). Затем, делаем выборку из базы по логину и хешу пароля.

Если результат запроса оказался непустым, то логин и пароль верные. Сохраняем пользотельские данные в объекте класса User. Записываем id пользователя в сессию через метод User::saveSession(). Если в качестве первого аргумента — $remember, передать ей true, то идентификатор сессии сохранится в куках. Это позволит не вводить пароль каждый раз при перезапуске браузера.

Работа с формами через Ajax

Для обработки Ajax-запросов создадим класс AjaxRequest. Сохраним его в файле classes/AjaxRequest.class.php.

<?php

/*
 * Обертка для работы с Ajax-запросами
*/

class AjaxRequest
{
    public $actions = array();

    public $data;
    public $code;
    public $message;
    public $status;

    public function __construct($request)
    {
        $this->request = $request;
        $this->action = $this->getRequestParam("act");

        if (!empty($this->actions[$this->action])) {
            $this->callback = $this->actions[$this->action];
            call_user_func(array($this, $this->callback));
        } else {
            header("HTTP/1.1 400 Bad Request");
            $this->setFieldError("main", "Некорректный запрос");
        }

        $this->response = $this->renderToString();
    }



    public function getRequestParam($name)
    {
        if (array_key_exists($name, $this->request)) {
            return trim($this->request[$name]);
        }
        return null;
    }


    public function setResponse($key, $value)
    {
        $this->data[$key] = $value;
    }


    public function setFieldError($name, $message = "")
    {
        $this->status = "err";
        $this->code = $name;
        $this->message = $message;
    }


    public function renderToString()
    {
        $this->json = array(
            "status" => $this->status,
            "code" => $this->code,
            "message" => $this->message,
            "data" => $this->data,
        );
        return json_encode($this->json, ENT_NOQUOTES);
    }


    public function showResponse()
    {
        header("Content-Type: application/json; charset=UTF-8");
        echo $this->response;
    }
}

Этот класс облегчит нам обработку данных, отправленных пользователем из формы. В качестве конструктора, он принимает массив с данными запроса ($_GET или $_POST).

В запросе обязательно должно быть поле act, которое определяет текущее действие. Например, при регистрации значением $_POST["act"] будет «register», а при авторизации — «login».

Метод getRequestParam() нужен для получения параметра из запроса. Он делает дополнительную проверку на существование ключа массива и возвращает null, если запрос не содержит нужных данных.

Функция setResponse() используется для формирования ответа. Метод setFieldError() нужен для передачи сообщения об ошибке в поле формы.

Для того, чтобы вернуть ответ пользователю, мы используем метод showResponse. Он генерирует строку в JSON-формате, задает нужные HTTP-заголовки и возвращает данные клиенту.

В файле ajax.php происходит непосредственная обработка запросов через класс AjaxRequest.

Содержимое ajax.php:

<?php

include './classes/Auth.class.php';
include './classes/AjaxRequest.class.php';

if (!empty($_COOKIE['sid'])) {
    // check session id in cookies
    session_id($_COOKIE['sid']);
}
session_start();

class AuthorizationAjaxRequest extends AjaxRequest
{
    public $actions = array(
        "login" => "login",
        "logout" => "logout",
        "register" => "register",
    );

    public function login()
    {
        if ($_SERVER["REQUEST_METHOD"] !== "POST") {
            // Method Not Allowed
            http_response_code(405);
            header("Allow: POST");
            $this->setFieldError("main", "Method Not Allowed");
            return;
        }
        setcookie("sid", "");

        $username = $this->getRequestParam("username");
        $password = $this->getRequestParam("password");
        $remember = !!$this->getRequestParam("remember-me");

        if (empty($username)) {
            $this->setFieldError("username", "Enter the username");
            return;
        }

        if (empty($password)) {
            $this->setFieldError("password", "Enter the password");
            return;
        }

        $user = new Auth\User();
        $auth_result = $user->authorize($username, $password, $remember);

        if (!$auth_result) {
            $this->setFieldError("password", "Invalid username or password");
            return;
        }

        $this->status = "ok";
        $this->setResponse("redirect", ".");
        $this->message = sprintf("Hello, %s! Access granted.", $username);
    }

    public function logout()
    {
        if ($_SERVER["REQUEST_METHOD"] !== "POST") {
            // Method Not Allowed
            http_response_code(405);
            header("Allow: POST");
            $this->setFieldError("main", "Method Not Allowed");
            return;
        }

        setcookie("sid", "");

        $user = new Auth\User();
        $user->logout();

        $this->setResponse("redirect", ".");
        $this->status = "ok";
    }

    public function register()
    {
        if ($_SERVER["REQUEST_METHOD"] !== "POST") {
            // Method Not Allowed
            http_response_code(405);
            header("Allow: POST");
            $this->setFieldError("main", "Method Not Allowed");
            return;
        }

        setcookie("sid", "");

        $username = $this->getRequestParam("username");
        $password1 = $this->getRequestParam("password1");
        $password2 = $this->getRequestParam("password2");

        if (empty($username)) {
            $this->setFieldError("username", "Enter the username");
            return;
        }

        if (empty($password1)) {
            $this->setFieldError("password1", "Enter the password");
            return;
        }

        if (empty($password2)) {
            $this->setFieldError("password2", "Confirm the password");
            return;
        }

        if ($password1 !== $password2) {
            $this->setFieldError("password2", "Confirm password is not match");
            return;
        }

        $user = new Auth\User();

        try {
            $new_user_id = $user->create($username, $password1);
        } catch (\Exception $e) {
            $this->setFieldError("username", $e->getMessage());
            return;
        }
        $user->authorize($username, $password1);

        $this->message = sprintf("Hello, %s! Thank you for registration.", $username);
        $this->setResponse("redirect", "/");
        $this->status = "ok";
    }
}

$ajaxRequest = new AuthorizationAjaxRequest($_REQUEST);
$ajaxRequest->showResponse();

Формат JSON-ответа

На клиентской стороне, мы должны иметь возможность показать результат операции в понятном для человека виде. Для этого мы возвращаем JSON ответ в таком формате:

{
    "status": "статус операции", // err или ok
    "code": "имя поля с ошибкой",
    "message": "сообщение об ошибке",
    "data": "другие произвольные данные"
}

Создадим файл js/ajax-form.js. Он будет перехватывать событие отправки всех форм с классом ajax и отправлять асинхронный запрос серверу. Исходный код ajax-form.js.

Для работы скрипта нужен jQuery версии 2.0.3 (лежит в архиве с исходниками).

Обработка ответов происходит в методах объекта script.ajaxForm.callbacks. Пример обработчика ответа для авторизации на сайте:

login: function ($form, data) {
    if (data.status === 'ok') {
        // если авторизация успешна, делаем редирект на
        // нужную страницу
        if (data.data && data.data.redirect) {
            window.location.href = data.data.redirect;
        }
    }
}

Эти коллбеки вызываются только, если валидация ответа прошла успешно. Метод script.ajaxForm.validate проверяет наличие в ответе имени поля с ошибкой. Если такое поле существует, подствечивает его и отображает текст самой ошибки.

Пример ошибки в&nbsp;форме регистрации

Если нашли ошибку в коде или тексте статьи — обязательно напишите о ней в комментариях.

UPDATE: Исправлены ошибки, связанные с хешированием пароля, нормально заработала функция «Запомнить меня». Спасибо пользователю santas156 за найденные баги.

Комментарии к статье: 148

Подождите, загружаются комментарии...

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

Если у вас есть вопросы по содержанию статьи, рекомендуем вам обратиться за помощью на наш форум.