Basic walkthrough for PHP-enabled email based 2fa

Disclaimer: This does not set up https, use certbot or set up certificates manually. Furthermore, I’m uncertain if the practices shown here are considered “best practices” so take everything with a grain of salt. What isn’t done here for simplicity’s sake:

And maybe more because I’m a naive undergrad.

A note on SQL Injections

Though PHP prepared statements aren’t used, I found the htmlspecialcharacters() saves the database from injections because the ' and " characters are translated to &#039 and &quot respectively. I tried escaping them by prefixxing with a backslash but I was still unable to perform an injection.

Download Source Code Here (tar.gz)

Prerequisites

Install php-fpm, compose, nginx, and mariadb (or mysql)

This guide was written using Arch Linux as the host.

$ pacman -S php-fpm compose nginx mariadb

Nginx Setup

$ systemctl start nginx

Navigate to http://localhost:80 to verify nginx is working

$ systemctl stop nginx
$ cd /etc/nginx

By default the directories sites-enabled and sites-available do not exist on Arch.

They aren’t necessary, but I’m already familiar with the associated workflow to deploy websites using them.

If they do not exist:

Create sites-enabled and sites-available directories in /etc/nginx/:

$ mkdir /etc/nginx/sites-enabled
$ mkdir /etc/nginx/sites-available

In /etc/nginx/nginx.conf:

  1. Comment out (or delete) the server{...} block contained in http{...}

  2. Append include sites-enabled/* to end of http{...} block


Create file /etc/nginx/sites-available/2fademo described by Nginx PHP setup:

server {
    root /usr/share/nginx/html;

    location / {
        index index.html index.htm index.php;
    }

    location ~ \.php$ {
        # 404
        try_files $fastcgi_script_name =404;

        # default fastcgi_params
        include fastcgi_params;

        # fastcgi settings
        fastcgi_pass            unix:/run/php-fpm/php-fpm.sock;
        fastcgi_index           index.php;
        fastcgi_buffers         8 16k;
        fastcgi_buffer_size     32k;

        # fastcgi params
        fastcgi_param DOCUMENT_ROOT $realpath_root;
        fastcgi_param SCRIPT_FILENAME   $realpath_root$fastcgi_script_name;
        #fastcgi_param PHP_ADMIN_VALUE  "open_basedir=$base/:/usr/lib/php/:/tmp/";
    }
}

Change root directory on line 2 from /usr/share/nginx/html to /srv/html/2fademo

Create symbolic link mapping /etc/nginx/sites-available/2fademo to /etc/nginx/sites-enabled/2fademo:

$ ln -sf /etc/nginx/sites-available/2fademo /etc/nginx/sites-enabled/2fademo

Add following lines before location / { ... and after root /... lines:

listen       80;
server_name  localhost;

Save file.

Open and uncomment the following lines in /etc/php/php.ini:

extension=pdo_mysql
extension=mysqli

Test Nginx setup

$ mkdir -P /srv/http/2fademo
$ echo "<?php phpinfo(); ?>" >> /srv/http/2fademo/index.php

start php-fpm and nginx services

$ systemctl start nginx php-fpm

Verify php-fpm is working by opening http://localhost:80 in a web browser.

MariaDB/SQL setup

This bit assumes you’re using Arch Linux’s drop in replacement for mysql: mariadb. Because it is drop-in, the process should be the same for mysql, just replace ocurrences of mariadb with mysql


If mariadb:

I do not know if mysql has an equivelant to the following:

$ mariadb-install-db --user=mysql --basedir=/usr --datadir=/var/lib/mysql`

start mariadb service

$ systemctl start mariadb

Login:

$ mariadb -u root -p

Create database:

> CREATE DATABASE 2fademo

Create user:

Set user password here:

The permissions granted to this 2fademo user are not ideal in production environments with real user information at risk. For this local demo, it is ok.

> CREATE USER '2fademo'@'localhost' IDENTIFIED BY 'password';
> GRANT ALL PRIVILEGES ON 2fademo.* TO '2fademo'@'localhost'
> quit

Log back in as new user:

$ mariadb -u 2fademo -p 2fademo
Password: ...

Create table:

> CREATE TABLE students ( 
    uid int NOT NULL AUTO_INCREMENT, 
    email VARCHAR(128), 
    username VARCHAR(128), 
    password VARCHAR(128), 
    realname VARCHAR(128), 
    major VARCHAR(128), 
    token bigint, 
    gentime VARCHAR(128), 
    PRIMARY KEY (uid) 
);

All following work is in /srv/http/2fademo

$ cd /srv/http/2fademo/

PHPMailer Setup

$ compose init 
$ compose required phpmailer/phpmailer  

Should result in a vendor directory and composer.json file.

You will need a SMTP server available to use for sending email. I opted to use Gmail’s platform for simplicity’s sake. Follow the instructions here to generate an app specific password.

If you choose to use Gmail, you will need one of these. It will be used later.

Site Setup

The remainder of this post discusses the code deployed on this site.

Code

Homepage

Give users username and password form entry, also introduces the use case of creating a new account. This file resets the stored cookies to save effort during demonstration. I admit, using get in the form is not a good practice, it should be replaced with post.

Create file index.html:

<!DOCTYPE html>
<html lang="en">

<head>
  <title>
    2fademo
  </title>
</head>

<body>
  <?php 
    session_unset();
    session_destroy(); 
  ?>
  <p>
  <form action="handler-signin.php" method="get">
    Username: <input type="text" name="username"><br>
    Password: <input type="password" name="password"><br>
    <button type="submit">Submit</button><br>
  </form>

  <a href="signup.html">Signup</a>
  </p>
</body>

</html>

Sign Up Form

Create file signup.html:

Give users a form for signing up for a new account. This implementation does not validate email addresses for correctness. This is intentional to keep explanations concise.

<!DOCTYPE html>
<html lang="en">

<head>
  <title>
    2fademo - signup
  </title>
</head>

<body>
  <p>
  <form action="signuphandler.php" method="get">
    E-mail: <input type="text" name="email"><br>
    Username: <input type="text" name="username"><br>
    Password <input type="password" name="password"><br>
    Real Name: <input type="text" name="name"><br>
    Major: <input type="text" name="major"><br>
    <button type="submit">Submit</button>
  </form>
  </p>
</body>

</html>

Sign-up Handling

The follwing code handles adding new users to the students database. It verifies neither the given email nor username is already in use by another user as those need to be unique between every user in order for the two factor authentication to work.

Create file signuphandler.php:

<?php
if ($_SERVER["REQUEST_METHOD"] == "GET") {

$name = htmlspecialchars($_GET["name"]);
$email = htmlspecialchars($_GET["email"]);
$password = htmlspecialchars($_GET["password"]);
$username = htmlspecialchars($_GET["username"]);
$major = htmlspecialchars($_GET["major"]);

$servername = "localhost";
$database = "2fademo";
$dbusername = "2fademo";
$dbpassword = "password";
  
$conn = new mysqli($servername, $dbusername, $dbpassword, $database);
  
$sql = "SELECT * FROM students WHERE email='$email'";
$result = $conn->query($sql);
if ($result->num_rows > 0){
  echo "Email already in use";
  exit();
}
 
$sql = "SELECT * FROM students WHERE username='$username'";
$result = $conn->query($sql);
 if ($result->num_rows > 0){
  echo "Username already in use";
  exit();
        
} else {
    $sql = "INSERT INTO students (email, username, password, realname, major) 
    VALUES ('$email', '$username', '$password', '$name', '$major')";
    if ($conn->query($sql) === TRUE){
    
    header('Location: /');
  } else {
    echo "Error: " . $sql . "<br>" . $conn->error;
  }
}
 
$conn->close();
}
?>

Sign In Backend

This file uses PHPMailer to send the 2FA authentication token to the user provided their username is in the database, and the given password matches the password saved in the database.

If using Gmail, all you need to do is replace lines 8, 9, and 62 with your email address or previously generated app-specific password.

If not using Gmail, you’ll have to figure out the correct values for the various PHPMailer properties.

Create file handler-signin.php:

<?php
//Load Composer's autoloader
require 'vendor/autoload.php';
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use PHPMailer\PHPMailer\Exception;

define('GUSER', '##### YOUR EMAIL ###### ');
define('GPWD', '##### YOUR APP PASSWORD ######');

function smtpmailer($to, $from, $from_name, $subject, $body) { 
    global $error;
    $mail = new PHPMailer();  // create a new object
    $mail->IsSMTP(); // enable SMTP
    $mail->SMTPDebug = 0;  // debugging: 1 = errors and messages, 2 = messages only
    $mail->SMTPAuth = true;  // authentication enabled
    $mail->SMTPSecure = 'ssl'; // secure transfer enabled REQUIRED for GMail
    $mail->Host = 'smtp.gmail.com';
    $mail->Port = 465; 
    $mail->Username = GUSER;  
    $mail->Password = GPWD;           
    $mail->SetFrom($from, $from_name);
    $mail->Subject = $subject;
    $mail->Body = $body;
    $mail->AddAddress($to);
    if(!$mail->Send()) {
        $error = 'Mail error: '.$mail->ErrorInfo; 
        return false;
    } else {
        $error = 'Message sent!';
        return true;
    }
}

if ($_SERVER["REQUEST_METHOD"] == "GET") {

$username = htmlspecialchars($_GET["username"]);
$password = htmlspecialchars($_GET["password"]);

$servername = "localhost";
$database = "2fademo";
$dbusername = "2fademo";
$dbpassword = "password";

$conn = new mysqli($servername, $dbusername, $dbpassword, $database);
    
$sql = "SELECT * FROM students where username='$username'";
$result = $conn -> query($sql);

if ($result->num_rows==0){
    echo "User does not exist";
}else{
    $row=$result->fetch_assoc();
    if($row["password"] == $password){
      $token=mt_rand(1000000000, 9999999999);
      $date=gmdate("c");
      $uid = $row["uid"];
      $sql = "UPDATE students SET token='$token' where uid=$uid";
      $tokenstatus=$conn->query($sql);
      $sql = "UPDATE students SET gentime='$date' where uid=$uid";
      $email = $row["email"];
      smtpmailer("$email", "#### YOUR EMAIL ####", "## NAME ##", "2fa token", "$token");
      $datestatus=$conn->query($sql);
      session_start();
      $_SESSION['uid'] = $uid;
      header('Location: 2fa.php');
      exit;
    } else {
      echo "Incorrect password";
    }
}

$conn->close();
}
?>

2fa Verification

After signing in with an account, users will be prompted to enter a 10 digit key. This key was sent to the associated email in the database. If the key is wrong or 10 minutes have passed since the correct key was generated, the user should be aware that the website is dissapointed in them. In the condition the key is correct and less than 10 minutes have passed, let the user know the website is proud of them.

Create file 2fa.php:

<?php
if ($_SERVER["REQUEST_METHOD"] == "POST"){
$token = htmlspecialchars($_POST["token"]);

$servername = "localhost";
$database = "2fademo";
$dbusername = "2fademo";
$dbpassword = "password";

$conn = new mysqli($servername, $dbusername, $dbpassword, $database);

session_start();
$uid=$_SESSION['uid'];
$sql = "SELECT token, gentime FROM students WHERE uid=$uid";
$result = $conn -> query($sql);
$row=$result->fetch_assoc();
$gendate=$row["gentime"];
$gendateobj=date_create("$gendate");
$current=gmdate("c");
$currentdate = date_create($current);
$interval=date_diff($currentdate, $gendateobj);
$minutes=$interval->i;
if($row["token"] == $token){
  if( $minutes > 10 ){
    echo "<h1>too slow. token expired.</h1>";
    exit;
  }
  echo "<h1>you did it!</h1>";
  $conn->close();
  exit;
} else {
  echo "<h1>epic fail</h1>";
  $conn->close();
  exit;
}
}
?>


<!DOCTYPE html>
<html>
    <head>
        <title>2fademo</title>
    </head>
    <body>
        <form action="" method="POST">
            Token: <input type="text" name="token"><br>
            <button type="submit">Submit</button>
        </form>
    </body>
</html>