Cookies not being set

I have the following login script which works fine. I want to add a cookie so that the visitor doesn’t have to log in subsequently so I added lines 4-10 and set a couple of cookies later on. Unfortunately that part is not working and on checking the cookies are not being set. I have dumped the cookie array both before and after calling the setcookie function and the array is not changed.

Nothing has been output to the browser before I call setcookie, and there are no errors in my PHP or Apache error logs.

Where be I goin’ wrong?

<?php
session_start();

// Check if the user is already logged in with a cookie (line 4)
if (!empty($_COOKIE['loggedin']) && !empty($_COOKIE['ulevel'])) {
  $_SESSION['username'] = $_COOKIE['loggedin'];
  $_SESSION['ulevel'] = $_COOKIE['ulevel'];
  header('Location: home.php');
  exit;
}

if (!empty($_POST)) {
  // require user to log in
  $db = new PDO('sqlite:users.sqlite');
  $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT);
  
  $login_ok = false;
  $_POST = array_map('trim', $_POST);
  $query = 'SELECT username, password, level FROM users WHERE username = :username';
  $stmt = $db->prepare($query);
  $stmt->bindParam('username', $_POST['username'], PDO::PARAM_STR);
  $stmt->execute();
  $row = $stmt->fetch();

  if ($row) {
    if (password_verify($_POST['password'], $row['password'])) {
      $_SESSION['username'] = $_POST['username'];
      $_SESSION['ulevel'] = $row['level'];
      // set cookie for 24 hours
      #var_dump($_COOKIE);
      setcookie('loggedin', $_POST['username'], 86400);
      setcookie('ulevel', $row['level'], 86400);
      #var_dump($_COOKIE);die;
      header('Location: home.php');
      exit;
    }
  }
}
session_destroy();
?>
<!DOCTYPE html>
<html lang="en-GB">
<head>
<title>Log in to Admin Area</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="css/login.css">
</head>
<body>
<div class="login">
  <h1>Admin Area</h1>
  <h2>please log in</h2>
  <?php
  if (isset($login_ok) && !$login_ok)
    echo '<p class="error">Login failed.</p>', "\n";
  ?>
  <form method="post">
    <div class="form-row">
      <input type="text" class="field" id="username" name="username" required autofocus>
      <label for="username">Username</label>
    </div>
    <div class="form-row">
      <input type="password" class="field" id="password" name="password" required>
      <label for="password">Password</label>
      <div class="eye">👁<input type="checkbox" id="passwdShow"></div>
    </div>
    <div>
      <input type="submit" name="submit" value="Login">
    </div>
  </form>
</div>
<script src="js/passwdShow.js"></script>
</body>
</html>

I don’t know if this will help, but this is how I hand my login (It’s just a basic login script for my personal website)->

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // Check if the submitted CSRF token matches the one stored in the session
    if (hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
        // Sanitize the username and password input
        $username = strip_tags($_POST['username']);
        $password = $_POST['password'];

        // Verify the user's credentials
        if ($loginRepository->verify_credentials($username, $password)) {
            // Generate a secure login token
            $token = bin2hex(random_bytes(32));
            // Store the login token in the database
            $loginRepository->store_token_in_database($_SESSION['user_id'], $token);

            // Set a secure cookie with the login token
            setcookie('login_token', $token, [
                'expires' => strtotime('+6 months'),
                'path' => '/',
                'domain' => DOMAIN,
                'secure' => true,
                'httponly' => true,
                'samesite' => 'Lax'
            ]);

            // Store the login token in the session
            $_SESSION['login_token'] = $token;

            // Redirect the user to the dashboard
            header('Location: ../dashboard.php');
            exit;
        } else {
            // Display an error message for invalid username or password
            $error = 'Invalid username or password';
            error_log("Login error: " . $error);
        }
    } else {
        // Display an error message
        $error = 'Invalid CSRF token';
        error_log("Login error: " . $error);
        $error = 'An error occurred. Please try again.';
    }
}
1 Like

Ah well, I found part of the problem. My 3rd parameter should have been time() + 86400 :blush: but now I’m getting ERR_TOO_MANY_REDIRECTS

Edit: and I’ve found that too, not in the above script but in home.php

The problem lies here:

It redirects when you have those cookies, which remains true once you’re logged in. So you need to add a condition to that line to verify that currently no user is logged in (eg check that $_SESSION['username'] is empty)

Do note though that this construction is pretty insecure. If I know a username and some userlevel I can just set those cookies myself and your site will accept them on face value.

An approach that is normally taken is:

  • when somebody logs in, generate a random string of about 40 characters or so
  • store that string, along with the userid in the database
  • send the string in a cookie to the browser
  • when nobody is logged in, check for the cookie
  • of the cookie is there, fetch the user that was linked to it (in the database, as written in step 2)
  • use the fetched details to populate the session

This also has the advantage that people don’t stay “stuck” in their user role, as it is retrieved every time. This prevents people keeping to many privileges even though they were revoked.

Make sure the string is cryptographically secure, e. g. use bin2hex(random_bytes(20)).

2 Likes

Ah, cool - thanks @rpkamp I’ll try to wrap my braincell around that!

1 Like

Sure thing. Feel free to ask further questions if things are unclear.

1 Like

Having reread that several times I think I’m clear how it works! :slight_smile:

For the random string, I guess I want something like the following?

function getRandomString($n) {
  $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
  $randomString = '';
  for ($i = 0; $i < $n; $i++) {
    $index = rand(0, strlen($characters) - 1);
    $randomString .= $characters[$index];
  }
  return $randomString;
}
$token = bin2hex(getRandomString(40));

Nah. rand is pretty poor. You want bin2hex(random_bytes(20)). random_bytes is an actual function :slightly_smiling_face:

If you want to know why that’s important, see https://en.m.wikipedia.org/wiki/Cryptographically_secure_pseudorandom_number_generator

TL;DR most random number generators (such as rand) aren’t truly random and can therefore be compromised easier. You want something that’s truly unpredictable. Most systems nowadays have a system that generates numbers based on network traffic, key strokes, that sort of thing. Which is totally unpredictable. Or you could connect it to a device that reacts to wind or solar flares. Stuff like that.

I’ve once read of an office that has a wall of lava lamps in their hallway with a camera on them that will be used to generate random numbers in case the systems actually can’t produce them (which essentially never happens).

1 Like

Yes, its called CloudFlare. and they do use it :stuck_out_tongue:

1 Like

I hope this is an improvement. I’ve not tested it yet, but it seems to follow all your steps…

<?php
session_start();

$db = new PDO('sqlite:users.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT);
$login_ok = false;

// Is user already logged on?
if (isset($_COOKIE['login_token'])) {
  $sql = 'SELECT username, level FROM users WHERE token = ?';
  $stmt = $db->prepare($sql);
  $stmt->execute([$_COOKIE['login_token']]);
  $user = $stmt->fetch();
  if ($user) {
    $_SESSION['username'] = $user['username'];
    $_SESSION['ulevel'] = $user['level'];
    header('Location: home.php');
    exit;
  }
}

// User has filled in the login form
if (!empty($_POST)) {
  $_POST = array_map('trim', $_POST);
  $sql = 'SELECT username, password, level FROM users WHERE username = :username';
  $stmt = $db->prepare($sql);
  $stmt->execute([$_POST['username']]);
  $row = $stmt->fetch();
  if ($row) {
    if (password_verify($_POST['password'], $row['password'])) {
      $token = bin2hex(random_bytes(20));
      setcookie('login_token', $token, 60 * 60 * 24 * 30 + time());
      $_SESSION['username'] = $_POST['username'];
      $_SESSION['ulevel'] = $row['level'];
      $sql = 'UPDATE user SET token = ? WHERE username = ?';
      $stmt = $db->prepare($sql);
      $stmt->execute([$token, $_POST['username']]);
      header('Location: home.php');
      exit;
    }
  }
}

// If all else fails, fill in the login form...
?>
1 Like

Yes. That certainly looks good :+1:

The only thing is that by storing the token in the user table any user can only be remembered on one device, as logging in to a new device will overwrite the token from the previous device.

If you want to overcome this you’d need a separate table with just the token and user ID and then join it to the user table when querying.

Additionally, I would add some expiry time to the token. Can be far in the future, like a month or maybe even a year. But allowing someone to stay logged in forever seems like a bad idea.

2 Likes

Ah, as this is just for my own use, I’m not worried about using different devices but I shall bear that in mind.

I wondered about the last point, but the cookie expires after a month and without the cookie the token is useless anyway.

Unless someone stole it of course :slight_smile:

1 Like

How would you go about removing the token then, a Cron job?

Either that, or add a timestamp to it and verify it’s still valid when you retrieve it. If it isn’t, delete it and stop processing it any further.

That may leave some stray tokens in there that are never deleted. Up to you to decide how much effort you’d want to put in that.

1 Like