6
\$\begingroup\$

What I try to achieve

I need to create a administration-panel for a website. Therefore, I need a possibility to protect the content of the panel via a password. The functionality doesn't have to be very advanced. I neither need a password-reset functionality nor the possibility to add more than one user. I also don't need a "keep me logged in"-functionality.

It is also a pretty small site, so I don't think the security standards need to be as high as with big companies.

I can't use cookies for this project. It is for a completely "cookie-free" website.

I've already asked a question about this on stackoverflow, where user Ro Achterberg suggested in a comment that I could add layers for improving the security.

I therefore decided that I add 2 factor authentication via email.

The code

index.php

<?php

//Documentation: https://www.php.net/manual/de/function.password-hash.php
$username = '$2y$10$bWW0KD6P7WUaTU99PpcjtON1xKSBhCCxxiiyoaMuY0aVehZSfgVI6'; //result of password_hash("admin", PASSWORD_BCRYPT);
$password = '$2y$10$NxtrHFdZGZMG7y2G6l2o6eZpksOQfQvrQrCBTj7knEmL8VynQlcz2'; //result of password_hash("1234", PASSWORD_BCRYPT);


//Loading login form
$content = "<h1>Login</h1>
            <form method='POST' class='input'>
                <label>
                    <input type='text' name='username' placeholder='Username' required/>
                </label>
            
                <label>
                    <input type='password' name='password' placeholder='Password' required/>
                </label>
            
                <input type='submit' name='login' value='Login'/>
            </form>";

//Check if form was submitted
if(isset($_POST['login'])) {
    //Documentation: https://www.php.net/manual/de/function.password-verify.php
    $verifyUsername = password_verify($_POST['username'], $username);
    $verifyPassword = password_verify($_POST['password'], $password);

    //Check if user entered correct username and password
    if($verifyUsername && $verifyPassword) {

        //Generate and send OTP
        include "randomCode.php";
        $code = randomCode();
        mail("contact@example.com", "One time code", "Your one time code: " . $code);
        $time = openssl_encrypt(time(),"AES-128-ECB", "****************");
        $code = password_hash($code, PASSWORD_BCRYPT) . $time;

        //Load 2fa screen
        $content = "<h1>2-factor-authentication</h1>
            <form method='POST' class='input'>
                <label>
                    <input type='text' name='2fa' placeholder='one time code' class='validInput' required/>
                </label>
                <input  id='code' name='code' type='text' value='$code' readonly/>
                <input type='submit' name='2faLogin' value='Login'/>
            </form>";
    }

    //Reload login form
    else {
        $content = "<h1>Login</h1>
                    <p>Wrong username or password!</p>
                    <form method='POST' class='input'>
                        <label>
                            <input type='text' name='username' placeholder='Username' class='validInput' required/>
                        </label>
                    
                        <label>
                            <input type='password' name='password' placeholder='Password' class='validInput' required/>
                        </label>
                    
                        <input type='submit' name='login' value='Login'/>
                    </form>";
    }
}

//Check if one time code was submitted
if(isset($_POST['2faLogin'])) {

    $time = openssl_decrypt(substr($_POST['code'], 60), "AES-128-ECB", "****************");

    //Did user take too long to enter code (5 minutes)?
    if(!$time || time() - intval($time) > 300) {
        include "randomCode.php";
        $code = randomCode();
        mail("contact@example.com", "One time code", "Your one time code: " . $code);
        $time = openssl_encrypt(time(),"AES-128-ECB", "****************");
        $code = password_hash($code, PASSWORD_BCRYPT) . $time;

        $content = "<h1>2-factor-authentication</h1>
                    <p>Code invalid! We've sent you a new mail.</p>
                    <form method='POST' class='input'>
                        <label>
                            <input type='text' name='2fa' placeholder='one time code' class='validInput' required/>
                        </label>
                        <input  id='code' name='code' type='text' value='$code' readonly/>
                        <input type='submit' name='2faLogin' value='Login'/>
                    </form>";
    }

    //Did user was too fast (10 seconds)?
    else if(time() - intval($time) < 10) {
        $code = $_POST['code'];
        $content = "<h1>2-factor-authentication</h1>
                    <p>That was a bit too fast.</p>
                    <form method='POST' class='input'>
                        <label>
                            <input type='text' name='2fa' placeholder='one time code' class='validInput' required/>
                        </label>
                        <input  id='code' name='code' type='text' value='$code' readonly/>
                        <input type='submit' name='2faLogin' value='Login'/>
                    </form>";
    }

    //Time is ok
    else {
        $verify = password_verify($_POST['2fa'], substr($_POST['code'], 0, 60));

        //Verify OTP failed
        if(!$verify) {
            $code = $_POST['code'];
            $content = "<h1>2-factor-authentication</h1>
                    <p>Entered wrong code!</p>
                    <form method='POST' class='input'>
                        <label>
                            <input type='text' name='2fa' placeholder='one time code' class='validInput' required/>
                        </label>
                        <input  id='code' name='code' type='text' value='$code' readonly/>
                        <input type='submit' name='2faLogin' value='Login'/>
                    </form>";
        }
        //Verified OTP successfully
        else {
            $content = "<h1>Welcome!</h1><p>Content here...</p>";
        }
    }

}
?>

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

    <head>
        <title>John Doe</title>
        <meta http-equiv='Content-Type' content='text/html;charset=utf-8'/>
    </head>

    <body id='body'>
        <?php echo $content; ?>
    </body>

</html>

randomCode.php

<?php

function randomCode() {
    $characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
    $random = "";
    for($i = 0; $i < 6; $i++) {
        $random .= $characters[rand(0, strlen($characters) - 1)];
    }
    return $random;
}

Question

All suggestions are welcome. I am especially interested in the security of this code and how to further improve it.

\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

I'm aware that the OP appears to no longer be a part of the site. However, I thought that this was an interesting topic that may have relevance to future visitors as well


General

HTML & PHP

The first thing I suggest is that you move all of your HTML code blocks to the top of your document. That way they're out of the way and don't interfere with your ability to read the code easily. Additionally it means you don't have repeated code for, for example, the login form. Example:

$htmlForms = [
    "login" => "<form>....</form>",
    "2fa"   => "<form>....</form>",
};

// Use as...

echo $htmlForms["login"];

// Or...

$content = $htmlForms["login"];

To output PHP (echo) into HTML you can use shorthand to make it cleaner:

<?php echo $content; ?>

// Becomes...

<?=$content?>

Storing Credentials

Currently you have stored username, password, and key in the same file as your code. The problem with that is that in the event of a server misconfiguration all of your details are exposed to the viewer.

Better to store those credentials in another configuration file outside of the web root. Preferably store them in a database - that way as and when you come to need more people to have logon ability it's as simple as adding them to the database.

Notices

Your password_verify lines will generate notices in the event someone tries to access the page without the form data. You can get around this by checking the value first or using the NULL coalescing operator (??). Example:

$verifyUsername = password_verify($_POST['username'] ?? null, $username);

Cookies

I'm not sure what the logic is RE not using cookies. If it's GDPR related then you should note that just because you aren't using cookies which has been a buzzword for GDPR and websites that doesn't mean you're in the clear...

The guidelines are there for any personal data that is collected/stored/processed and swapping a cookie (which is just data being passed back and forth between client and server) for a variable in the page that is doing the same thing doesn't change your responsibilities as far as gaining consent etc.

Perhaps there's a technical reason not to use them in this case; but if it's just to comply with consent to process guidelines then you're making complications for no reason. Better to just ask consent and use a cookie; you can simply only have cookies on that part of your site - it doesn't have to affect the normal.

2 factor authentication: general

I can't really see a reason for 2 factor authentication. Yes, usually, it makes it more secure but if this system is only for you (presumably the case because the username/password is hardcoded) then just pick good credentials.

N.B. the implementation of 2 factor authentication shown here is inherently insecure (see below).

Time limits

A ten second time limit seems redundant in most cases. There's nothing stopping me waiting those ten seconds and/or producing another hundred requests in those 10 seconds... If you want to use time/rate as a factor then you need to store the time of the last login attempt server side (again, potentially in a config file or database).

It also impacts the user experience. In a modern society we all have our phones on us and whenever I need to receive a text for two factor authentication my phone is read and waiting to type the code in long before 10 seconds had passed. Which means your user has to artificially delay their entering of the second code.

Five minutes on the other hand, sometimes might not be long enough for the email to come through; meaning you could be stuck in a loop of requesting a One Time Code and not being able to use it.

Code Generation

It is normal for a One Time Code to be about 6 digits long. However, in your scenario that will not do. Usually the code would be stored server side but in your case you are sending the code back and forth between the client and the server which means that any potential attacker can crack the encryption in a very short amount of time:

The total possible combinations are 36^6 = 2176782336; guesses per second varies depending on hardware but with modern GPU leveraging software it's a lot and it's the reason minimum password lengths are required by almost every website going.

This website is linked from another StackExchange answer which has various tables suggesting that a 5 x 36 code can be cracked by a fast PC in less than 1 minute.

Additional things to bear in mind:

  • That website defines a fast PC as dual processors; which is a bit out of date compared to modern hardware!
  • With GPU leveraging (which almost anyone can set up) that number would likely drop even lower
    • We must assume that they would be using a powerful computer (gpu matrix) because, by this stage, they already cracked your username/password
  • These times represent total time to cycle all possibilities. So unless the attacker starts at 000000 and your code is ZZZZZZ it'll be far quicker
  • You also need to bear in mind that there's a 1 in 36 chance that the first number will be a 0; which effectively drops you back down to a 5 character code (in terms of time to crack; assuming a linear brute force)
    • 60466176 possible guesses...
    • 5038848 if you're code starts 02

With your code generation you also use rand which comes with a caution from the PHP manual stating that is not crypto secure. This means that it shouldn't really be used in your scenario: random_int($min, $max) is the way to go instead. Better yet just create some suitably safe length string with random_bytes and convert to a suitable base* (such as your character set base36).

The user doesn't have to do anything with this code so what's the harm in it being [insert absurdly large number here] characters?

Code Handling

After creating the code not only do you output that code back to the client, but you also show it to the user in an input! One look at it from anyone familiar with PHP and they will know exactly what it is (it starts $2y$10; they'll even know where to split the string to start cracking) without even having to check the HTML source code...

Again, this should be stored server side.

But even more importantly handing the code back to the user means an attacker doesn't even need to crack it. Take the following:

$code = bin2hex(random_bytes(10));                                // 32385751bad0c265d2db
$hash = password_hash($code, PASSWORD_DEFAULT);                   // $2y$10$qhEGDWr8hNwaWuRQCznX..bM6zpYZliTmbuO8RuIUo4udqtZvP8p6
$time = openssl_encrypt(time(),"AES-128-ECB", "reallyStrongKey"); // G2MyOIN0ni7TY2esVkfPUg==

$code = $hash . $time; // $2y$10$qhEGDWr8hNwaWuRQCznX..bM6zpYZliTmbuO8RuIUo4udqtZvP8p6G2MyOIN0ni7TY2esVkfPUg==

Because an attacker recognised $2y$10 they know that the password hash section of that code is the first 60 characters. So they can generate their own and replace the value:

echo password_hash("123", PASSWORD_DEFAULT); // $2y$10$G0Q6M65Wxg2DiwvRnsSJUOsoHIDOHYa5JqP8BrYHjKlk2XkoleMJK

// Copy/Paste with

G2MyOIN0ni7TY2esVkfPUg==

Now they can log on.

Further more because of the structure of your code an attacker can create their own HTML form with fields:

username
password
2fa
code
login
2faLogin

and log on straight away. Or rather they could if they knew the key to your encryption - which potentially they may do (as mentioned earlier) through a misconfiguration etc.

To that end you should be clear that login and 2faLogin are either... or... and not both. Easily done with if... elseif....

Structure of attack

Firstly, the attacker needs to get hold of the username/password. This will happen in one of two ways:

  1. A brute force attack
    • Password likely with a dictionary (or hybrid) attack
    • Username probably with guess work
  2. A server misconfiguration leads to the hashes being displayed (and the AES key)
    • Now the attacker can brute force using their set up --- way faster than over HTTP

How long would it really take when...

  • Usernames tend to:
    • Clearly identify people
    • Be re-used between services
    • Be an email address (or the first part of it at last!)
  • Passwords tend to be:
    • Of low quality
    • Easily guessable (names/dates/etc.; hundreds of personalised guesses can be generated with software)
    • In a password list like RockYou

Now that the attacker has your username and password they only have to get through your one time code. Which will happen in a few ways:

  1. As explained earlier, they can use their own
  2. High chance that your email password and logon password are the same/similar
    • They'll check your emails
  3. Brute force the hashed code in a few minutes and enter it in

The only thing holding up an attacker with your two factor authentication is that they don't know what the second part of the code is (unless a server error meant the key was shown and they can decode it) and they can't generate it themselves. But, an attacker will experiment and submitting that same code with their custom one time code would be their first attempt.

Penetration testing scales

Now, I've obviously seen your source code so can work out a plan to beat it with that knowledge; an attacker (hopefully) is on the other end of the spectrum and wouldn't have that knowledge.

However, the reality is that in a real attack once the attacker is past the username and password the rest of it is really child's play and adds no benefit to your security.


Code

Implementing the above suggestions you would end up with two files:

config.json

{
    "username":"$2y$10$bWW0KD6P7WUaTU99PpcjtON1xKSBhCCxxiiyoaMuY0aVehZSfgVI6",
    "password":"$2y$10$NxtrHFdZGZMG7y2G6l2o6eZpksOQfQvrQrCBTj7knEmL8VynQlcz2",
    "lastLogon":"2021-02-07 12:00:00"
}

index.php

Note, this doesn't implement continued authentication. Only up to the point the OPs original code did.

<?php

$credentials = json_decode(file_get_contents("../../path/to/config.json"));

$html = [
    "logon" => "
        <h1>Login</h1>
        <form method='POST' class='input'>
            <label>
                <input type='text' name='username' placeholder='Username' required/>
            </label>

            <label>
                <input type='password' name='password' placeholder='Password' required/>
            </label>

            <input type='submit' name='login' value='Login'/>
        </form>",
    "wrongCredentials" => "...",
    "nothignSubmitted" => "...",
    "tooSoon"          => "...",
];

$username  = $_POST["username"] ?? null;
$password  = $_POST["password"] ?? null;
$lastLogon = $credentials["lastLogon"];
$timeLimit = date("Y-m-d H:i:s", strtotime("-30 seconds"));

if($password && $username)
    
    $credentials["lastLogon"] = date("Y-m-d H:i:s");
    file_put_contents("../../path/to/config.json", json_encode($credentials));

    if (
        password_verify($username, $credentials["username"]) &&
        password_verify($password, $credentials["password"])
    ) {
        if ($lastLogon > $timeLimit) {
            $content = $html["tooSoon"];
        } else {
            // Logged on, everyone is happy...

        }
    } else {
        $content = $html["wrongCredentials"];
    }
} else {
    $content = $html["logon"]
}


?>

<!DOCTYPE html>
<html lang='en'>
    <head>
        <title>John Doe</title>
        <meta http-equiv='Content-Type' content='text/html;charset=utf-8'/>
    </head>
    <body id='body'>
        <?=$content?>
    </body>
</html>

Further points

This isn't in the code, so isn't part of the main review, however....

The code above shows no way of a follow up request being verified. Using SESSION and/or cookies you can just set a server side variable...

$_SESSION["loggedon"] = true;

...and check that on subsequent requests. With this code there's no real way to do that; you would have to send some form of token but - as nothing is stored server side - that could just as easily be faked/cracked by an attacker. Rendering the whole log on process (even if it was 100% secure) completely moot.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy