Simple PHP Authentication

Many beginners still get stuck when writing simple authentication in PHP. On the Toaster, with enviable regularity, there are questions about how to compare the saved password with the password received from the login form. There will be a short article-tutorial on this topic.

Disclaimer: This article is for complete beginners. Experienced developers will not find anything new here, but they can point out possible shortcomings =).

To write an authentication system, we will use the MySQL / MariaDB, PHP database, PDO, functions for working with passwordsto build an interface, take bootstrap.

Let’s create a base first. Let it be called php-auth-demo. Create a user table in the new database users:

CREATE TABLE `users` (
    `id` int unsigned NOT NULL AUTO_INCREMENT,
    `username` varchar(255) COLLATE utf8mb4_general_ci NOT NULL,
    `password` varchar(255) COLLATE utf8mb4_general_ci NOT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `username` (`username`)
)
ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_general_ci;

Let’s create a config with data to connect to the database.

Keep in mind that in a real project, configs should not be accessible from the browser, and they should not be included in the version control system, in order to avoid compromising credentials.

config.php

<?php

return [
    'db_name' => 'php-auth-demo',
    'db_host' => '127.0.0.1',
    'db_user' => 'mysql',
    'db_pass' => 'mysql',
];

And we will make a “boot” file, which we will connect at the beginning of all other files.

In real projects, autoloading of necessary files is usually used. But this point is beyond the scope of the article, and in the demo we will get by with a simple connection.

In the “boot” file, we will initialize the session and declare some helper functions.

boot.php

<?php

// Инициализируем сессию
session_start();

// Простой способ сделать глобально доступным подключение в БД
function pdo(): PDO
{
    static $pdo;

    if (!$pdo) {
        $config = include __DIR__.'/config.php';
        // Подключение к БД
        $dsn = 'mysql:dbname=".$config["db_name'].';host=".$config["db_host'];
        $pdo = new PDO($dsn, $config['db_user'], $config['db_pass']);
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    }

    return $pdo;
}

Function pdo() will give us access to the PDO object anywhere in our code.

Next, we need a registration form. Let’s put it right in the file index.php.

<form method="post" action="do_register.php">
  <div class="mb-3">
    <label for="username" class="form-label">Username</label>
    <input type="text" class="form-control" id="username" name="username" required>
  </div>
  <div class="mb-3">
    <label for="password" class="form-label">Password</label>
    <input type="password" class="form-control" id="password" name="password" required>
  </div>
  <button type="submit" class="btn btn-primary">Register</button>
</form>

Everything is simple here: two fields, a button and a form that sends a request for a file do_register.php POST method. The user registration process will be described in the file do_register.php.

<?php

require_once __DIR__.'/boot.php';

// Проверим, не занято ли имя пользователя
$stmt = pdo()->prepare("SELECT * FROM `users` WHERE `username` = :username");
$stmt->execute(['username' => $_POST['username']]);
if ($stmt->rowCount() > 0) {
    flash('Это имя пользователя уже занято.');
    header('Location: /'); // Возврат на форму регистрации
    die; // Остановка выполнения скрипта
}

// Добавим пользователя в базу
$stmt = pdo()->prepare("INSERT INTO `users` (`username`, `password`) VALUES (:username, :password)");
$stmt->execute([
    'username' => $_POST['username'],
    'password' => password_hash($_POST['password'], PASSWORD_DEFAULT),
]);

header('Location: login.php');

At the very beginning, we will connect our “bootloader”.

Then we check if the username is already taken. To do this, we will make a selection from the table, specifying the username received from the form in the condition. Please note that for queries here and below we will use prepared querieswhich will protect us from SQL injection. To do this, we specify special placeholders in the text of the SQL query, and during execution we associate unreliable data with them (unreliable data should be considered everything that comes from outside – $_GET, $_POST, $_REQUEST, $_COOCKIE). After executing the query, we simply check the number of returned rows. If there are more than zero, then the username is already taken. In this case, we will display a message and return the user to the registration form.

I wrote “greater than zero”, but in fact, due to the fact that the field username unique in the table rowCount() can return only two possible values: 0 and 1.

In the code above, we have used the function flash(). This function is intended for “one-time” messages. If you call it with a string parameter, then it will save this string in the session, and if you call it without parameters, it will display the saved message from the session and then delete it in the session. Let’s add this function to the file boot.php.

function flash(?string $message = null)
{
    if ($message) {
        $_SESSION['flash'] = $message;
    } else {
        if ($_SESSION['flash']) { ?>
          <div class="alert alert-danger mb-3">
              <?=$_SESSION['flash']?>
          </div>
        <?php }
        unset($_SESSION['flash']);
    }
}

We will also call it on the registration form to display possible messages.

<h1 class="mb-5">Registration</h1>

<?php flash(); ?>

<form method="post" action="do_register.php">
    <!-- ... -->
</form>

At this stage, the simplest functionality for registering a new user is ready.

If we look at the registration code above, we will see that in case of successful registration, we redirect the user to the login page. It’s time to write it.

login.php

<h1 class="mb-5">Login</h1>

<?php flash() ?>

<form method="post" action="do_login.php">
    <div class="mb-3">
        <label for="username" class="form-label">Username</label>
        <input type="text" class="form-control" id="username" name="username" required>
    </div>
    <div class="mb-3">
        <label for="password" class="form-label">Password</label>
        <input type="password" class="form-control" id="password" name="password" required>
    </div>
    <div class="d-flex justify-content-between">
        <button type="submit" class="btn btn-primary">Login</button>
        <a class="btn btn-outline-primary" href="https://habr.com/ru/post/665602/index.php">Register</a>
    </div>
</form>

In view of the simplicity of the example, it practically repeats the registration form. It will be more interesting to look at the login process itself in the file do_login.php.

do_login.php

<?php

require_once __DIR__.'/boot.php';

// проверяем наличие пользователя с указанным юзернеймом
$stmt = pdo()->prepare("SELECT * FROM `users` WHERE `username` = :username");
$stmt->execute(['username' => $_POST['username']]);
if (!$stmt->rowCount()) {
    flash('Пользователь с такими данными не зарегистрирован');
    header('Location: login.php');
    die;
}
$user = $stmt->fetch(PDO::FETCH_ASSOC);

// проверяем пароль
if (password_verify($_POST['password'], $user['password'])) {
    // Проверяем, не нужно ли использовать более новый алгоритм
    // или другую алгоритмическую стоимость
    // Например, если вы поменяете опции хеширования
    if (password_needs_rehash($user['password'], PASSWORD_DEFAULT)) {
        $newHash = password_hash($_POST['password'], PASSWORD_DEFAULT);
        $stmt = pdo()->prepare('UPDATE `users` SET `password` = :password WHERE `username` = :username');
        $stmt->execute([
            'username' => $_POST['username'],
            'password' => $newHash,
        ]);
    }
    $_SESSION['user_id'] = $user['id'];
    header('Location: /');
    die;
}

flash('Пароль неверен');
header('Location: login.php');

There is an important point here. We don’t query user from table by pair username/passwordbut we only use username. The fact is that even if you hash the password that came from the login form and try to compare the new hash with the one stored in the database, you will not get anything. Password_hash() uses an automatically generated salt for passwords and hashes will be always get different. Here is the result of the function password_hashcalled multiple times for the password “123”:

$2y$10$loqucup11.3DL1fgDWanoettFpFJuFFd0fY6BZyiP698ZqvA4tmuy
$2y$10$.LF3OzmQRtJvuZZWeWF.2u80x3ls6OEAU5J9gLHDtcYrFzJkRRPvq
$2y$10$iGj/nOCavShd2vbMZTC4GOMYCqDj2YSc8qWoeqjVbD1xaKU2CgAfi

That is why it is necessary to use the function password_verify to check the password. In addition, this function uses a special verification algorithm and is safe for timing attacks.

It will also be good to check the password for the need to update the hash, in case you change the hashing algorithm or its options in the project.

In case of a successful login, we will store the user ID in the session and send it back to the main page.

To check the fact that the user is logged in, you will need to check the presence of this identifier in the session. For convenience, we will write a helper function and place it in the same file boot.php.

function check_auth(): bool
{
    return !!($_SESSION['user_id'] ?? false);
}

Now we can add checks and change the output on the main page if the user is authenticated.

<?php
require_once __DIR__.'/boot.php';

$user = null;

if (check_auth()) {
    // Получим данные пользователя по сохранённому идентификатору
    $stmt = pdo()->prepare("SELECT * FROM `users` WHERE `id` = :id");
    $stmt->execute(['id' => $_SESSION['user_id']]);
    $user = $stmt->fetch(PDO::FETCH_ASSOC);
}
?>
<?php if ($user) { ?>

    <h1>Welcome back, <?=$user['username']?>!</h1>

    <form class="mt-5" method="post" action="do_logout.php">
        <button type="submit" class="btn btn-primary">Logout</button>
    </form>

<?php } else { ?>

    <h1 class="mb-5">Registration</h1>

    <?php flash(); ?>

    <form method="post" action="do_register.php">
        <!-- ... -->
    </form>

<?php } ?>

And also close access to the login form if the user is already logged in:

login.php

<?php

require_once __DIR__.'/boot.php';

if (check_auth()) {
    header('Location: /');
    die;
}
?>
<!-- Далее форма логина -->

It remains to add the ability to “exit”. You can see the exit form in the code above. The exit procedure itself is the simplest, and consists in clearing the session.

<?php

require_once __DIR__.'/boot.php';

$_SESSION['user_id'] = null;
header('Location: /');

Conclusion

Total:

  • We use PDO/MySQLi and prepared queries to work with the database.

  • We store only the hash of the password in the database.

  • To hash a password, we use a special function password_hash.

  • To check the password, we do not compare hashes, but use a special function password_verify.


The full code of the example is available on github: link to Github.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *