Rate Limiting

Not much time to work at the start of the day. After taking the dogs out, I have about 15 minutes before I need to leave the house. Let’s see what we can pull off in a short period of time.

I want to to tackle rate limiting to thwart brute force attacks. With only one account, we can put on a blanket restriction on the website.

Account Lockout Prompt: Microsoft Image Creator

For enhanced security, I’ve implemented account lockouts after failed login attempts to thwart brute-force attacks. Logins now feature progress spinners for smoother transitions, and automatic logout upon expired tokens. I’ve also blurred 2FA details for increased privacy, and added a button to streamline error reporting.

… and I’m back. Getting back home was challenging as their was heavy rain and branches falling around me. The power was out for about an hour when I arrived back home. I took a bit of a rest and watched a few videos. I’ve been so immersed in code lately that I needed a little bit of a break even after the power came back. Power outages always result in odd things happening. My universal power supply (UPS) shuts off and doesn’t turn back on after the outage is over, so I’m still on battery power without realizing it. My USB desk lamp acts a bit strange in that I can only power it on for a few milliseconds unless I remove the power cord supplying the docking station and plug it back in. And then the dryer likes to play a tune every 10 minutes or so…

So we were setting up rate limiting. As a result, I’m storing the number of failed login attempts and locking the account for five minutes after three failures. Once the user logs in, the counter is reset. The process starts over again for 2FA. If someone got ahold of the login credentials, but doesn’t know the 2FA secret, they could brute force their way in if they could automate all possible combinations (1,000,000) of a six digit code in 30 seconds. A million is a lot, but a 30 second window looks at three different OTP codes (prior, current, next). Thus statistically reducing the average number of attempts needed down to 333,333.

Username/password locks are working. After 3 attempts, my account was locked. I setup a shorter window to only lock the account for a minute

The question is – do we let them have three more attempts once the account is no longer locked, or do we lock it immediately if they fail on the first try? I decided that as I lock the account, I’ll reset the failed attempts back to zero so they can have three more attempts.

For 2FA, we have a one-time password that changes every 30 seconds. I increased the allowed attempts to five and only lock the account for 30 seconds, long enough for the OTP to change. – technically that isn’t true. Although the OTP for the period changes, I’m still checking the next and prior OTP providing a 90 second window. Still, I believe 15 attempts over a 90 second window may be restrictive enough to prevent an attack of this nature. I’m wondering if I’m focusing too much on this type of attack as I don’t know what methods attackers often use. My guess is that they find any possible loophole and exploit it, regardless if its easy or not, so long as they don’t risk exposing themselves or the activity.

Both the login form and 2FA form share similar logic in locking accounts. I’ve separated the logic

Rate limiting PHP logic
<?php
namespace error_log\rate_limiting;

require_once 'Secrets.php';
require_once 'Show.php';
require_once 'HTTP_STATUS.php';

$key = "RATE_LIMITING";

function guard_locked_accounts()
{
    global $key;
    $rate_limiting = \Secrets::revealArray($key);
    if ($rate_limiting === false) {
        return;
    }

    if ($rate_limiting['unlock_at'] > time()) {
        \Show::error('Account locked', \HTTP_STATUS_LOCKED);
        exit;
    }
}
function successful_attempt()
{
    global $key;
    $rate_limiting = \Secrets::revealArray($key);
    if ($rate_limiting === false) {
        $rate_limiting = [
            'unlock_at' => 0,
            'failed_attempts' => 0,
        ];
    }
    $rate_limiting['failed_attempts'] = 0;
    \Secrets::keepArray($key, $rate_limiting);
}
function failed_attempt(string $message = "Invalid Credentials", int $max_chances, int $lockout_seconds)
{
    global $key;
    $rate_limiting = \Secrets::revealArray($key);
    if ($rate_limiting === false) {
        $rate_limiting = [
            'unlock_at' => 0,
            'failed_attempts' => 0,
        ];
    }
    $failed_attempts = $rate_limiting['failed_attempts'];
    $failed_attempts++;
    if ($failed_attempts >= $max_chances) {
        $rate_limiting['unlock_at'] = time() + $lockout_seconds;
        // allow the same number of attempts after unlocked
        $rate_limiting['failed_attempts'] = 0;
        \Show::error('Account locked', \HTTP_STATUS_LOCKED);
    } else {
        $rate_limiting['failed_attempts'] = $failed_attempts;
        \Show::error($message);
    }
    \Secrets::keepArray($key, $rate_limiting);
    exit;
}

Next we have a problem where the website seems broken once our authorization token becomes invalid. I experience this primarily when logging into both the local and production environments since they call the same endpoints. let’s go ahead and handle errors in the client.

I needed to wire this up to every API call so that if any of them returned an “Unauthorized” error, then I would need to call the authentication service to log the user out and then redirect them to the login page with a returnUrl. That’s a lot of API calls. Fortunately, I centralized all of my API calls into one class yesterday. I can just write the hook in a central location.

Redirect to login form on unauthorized error
redirectLogin() {
  this.auth.logout();
  const { pathname, search, hash } = window.location;
  const extras = {
    queryParams: {
      returnUrl: `${pathname}${search}${hash}`
    }
  };
  this.router.navigate(['login'], extras);
}
errorHandler<T>() {
  return catchError<T, Observable<never>>((response: HttpErrorResponse) => {
    try {
      const error = response.error.error;
      if (error === "Unauthorized") {
        this.redirectLogin();
      }
      return throwError(() => new Error(error));
    } catch (e) {
      return throwError(() => new Error("Unexpected API response"));
    }
  })
}

Works like a charm!

I made a video demonstrating many of the features of the error log application.

Error Log Demo

During the first video attempt, I noticed that quoted terms were not returning results. Somehow during the API consolidation of get/post requests, I removed the call to sqlLike(). Fixed!

I also noticed that the server was sometimes taking awhile to return a response. Upwards of 30 seconds. Odd – but I had no visual feedback other than monitoring network requests showing as pending. I added some progress spinners for various api calls and disabled the related buttons, fields, and pagination controls for the log list search, error lookup, log dates, and log details.

After posting the video of my error log, I realized someone could muck around and lock my account simply by attempting to log into my account. Changing my username wasn’t enough. The logic simply locked down authentication regardless if the username was correct. I can’t believe I overlooked that part. I went ahead and locked the account only if the username matched.

During the making of the video, I realized that the credentials form stated that the account was foo. That needs to change to reflect the actual account the user logged in with, or removed completely.

I created a new endpoint to let me know the name of the current account logged in, and if 2FA is enabled. Any time the username is saved with a new value, I generated a new provision URL to reflect the new account name. I also setup the QR code and secret to be blurred until clicked, and added a button to copy the secret to the clipboard. I had a bit of fun with transitions to slowly adjust the blur and opacity of a message overlaying the blurred content.

I tried to format the secret to be easier to type by hand when looking at it. With 26 characters, it’s hard to figure out how to split it up evenly. It’s only divisible by 2 and 13. Characters are best grouped between three to five characters. I changed the formatting to be easier to tell between numbers and letters for one, zero, “O” and “L”.

As an added measure, I added a button to transfer log files using the original view and transfer endpoints that started this whole application. The button is only enabled when a log file was found in the error logs folder.

I think we are done. Almost. There is one small issue remaining. It’s not noticeable in the local development environment, but going to a route that doesn’t exist on the file system results in a 404 error. My Angular application has routes for /login and /tfa. The fix is very simple. Route all 404 errors to the home page in the .htaccess file.

ErrorDocument 404 /

Another bug popped up. If I go to /tfa without logging in first, it’s telling me that my token expired when I try to log in – but it’s not redirecting me to login again. The fix is to log the user out completely and redirect them back to the login page while preserving the returnUrl query string parameter.

Wrap Up

We are done. I may setup a build action to deploy the project – but for the most part, everything is running as it should be.

  • Rate limiting added to lock accounts after a few failed logins.
  • Rate limiting added to 2FA with shorter lockout periods and more attempts.
  • All API requests wired up to redirect back to the login form if the authentication token expires.
  • Progress spinners added to remaining areas of the app to indicate we are waiting for the server to respond.
  • QR Code and Secret are blurred until user is ready to scan/view them. Button added to copy secret to clipboard.
  • Button added to transfer error logs from file into database
  • User settings changed to display current username and if 2FA is enabled.
  • Redirect 404 errors to root folder to show single page application that handles routes internally.
  • A video was made demonstrating the log viewer

What’s left

Honestly, not much. There are a few minor items. As this is an internal application, I can proceed without them.

  • Responsive design
  • Trailing ellipses missing on messages
  • Setup a build action

There are a few potential features to add/remove. The issue is that these aren’t really necessary. I have other tools that I need to move forward implementing.

  • Delete errors
  • Add notes to errors
  • Associate a Unicode Emoji to applications/scope to override default 2×2 hash matrix
  • Remove hash matrix images from messages/paths
  • Allow Unicode Emoji for error types to be editable from database rather than hard-coded.

Discover more from Lewis Moten

Subscribe now to keep reading and get access to the full archive.

Continue reading