Stidges' Blog

Writing an Allowed Username Validator in Laravel

Posted on in Development

When building a project that requires top-level public user profiles, you will have to find a way to prevent the usernames from clashing with public URLs or files. If you don't do this, it's only a matter of time before someone signs up with the username 'logout' and make everyone who clicks on the profile link logout of their account. Here's a solution that I came up with that allows you to build it once and forget about it for the rest of the project lifetime.

Setting up the validation rule

Because this is a more complex validation rule, let's use a class to build it in. We start by creating an 'empty' validator class like so:

<?php

// File: app/Validation/AllowedUsernameValidator.php

namespace App\Validation;

class AllowedUsernameValidator
{
    public function validate($attribute, $username)
    {
        // Normalize the username before checking it.
        $username = trim(strtolower($username));

        return true;
    }
}

Before we can use our validation rule we have to register it with Laravel's Validation factory class. Let's register it in our AppServiceProvider:

<?php

// File: app/Providers/AppServiceProvider.php

namespace App\Providers;

use App\Validation\AllowedUsernameValidator;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Validator::extend(
            'allowed_username', 
            'App\Validation\AllowedUsernameValidator@validate'
        );
    }

    //...
}

Great, now anywhere where we have to validate whether some username is allowed we can use the allowed_username rule! Let's start implementing!

Disallowing usernames that match a route

First up, let's prevent users from picking a username that matches any of our application defined routes. We can easily do this by grabbing all the defined routes and checking their URIs against the username. We start by injecting the Laravel Router instance into our validator:

<?php

namespace App\Validation;

use Illuminate\Routing\Router;

class AllowedUsernameValidator
{
    private $router;

    public function __construct(Router $router)
    {
        $this->router = $router;
    }

    // ...
}

Now we can use the Router instance to retrieve our application routes and check them against the username given by the user:

<?php

public function validate($attribute, $username)
{       
    foreach ($this->router->getRoutes() as $route) {
        if (strtolower($route->uri) === $username) {
            return false;
        }
    }

    return true;
}

Note, the getRoutes() method will actually return an instance of the Illuminate\Routing\RouteCollection class, but because this class implements the IteratorAggregate interface we can use it directly in the foreach loop. In the background, PHP will call the getIterator() method on it to get an array of the application routes.

Now, whenever a user picks a username that matches any of our routes, this validation will fail!

Disallowing usernames that match public files and folders

Next, let's make sure that a username does not match any files and folders in the public directory. Note that we are only checking the first level of the directory because allowing slashes in a username is a bad idea :). First we inject an instance of Laravel's Filesystem class into our constructor:

<?php

namespace App\Validation;

use Illuminate\Filesystem\Filesystem;
use Illuminate\Routing\Router;

class AllowedUsernameValidator
{
    private $router;
    private $files;

    public function __construct(Router $router, Filesystem $files)
    {
        $this->router = $router;
        $this->files = $files;
    }

    // ...
}

We can now use this class to grab a list of files and folders in the public directory and check them against the username given by the user:

<?php

public function validate($attribute, $username)
{       
    // ...

    foreach ($this->files->glob(public_path().'/*') as $path) {
        if (strtolower(basename($path)) === $username) {
            return false;
        }
    }

    return true;
}

The glob() method will give the full paths to all files and folder in the public directory. Note the asterisk after the public_path() call, without this, glob would only return the full path to the public directory!

Now anytime a user enters a username that matches any of our public files or folders, the validation rule will fail!

Disallowing usernames that match reserved names

A lot of times, it is desirable to be able to disallow any 'reserved' usernames, e.g. usernames like admin, moderator. We can easily do this by adding a configuration key to our config/auth.php file:

<?php

// File: config/auth.php

return [

    'reserved_usernames' => [
        'admin', 
        'moderator',
    ],

    // ...

];

To be able to grab these configuration values, let's inject Laravel's Config class into our constructor:

<?php

namespace App\Validation;

use Illuminate\Config\Repository;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Routing\Router;

class AllowedUsernameValidator
{
    private $router;
    private $files;
    private $config;

    public function __construct(Router $router, Filesystem $files, Repository $config)
    {
        $this->router = $router;
        $this->files = $files;
        $this->config = $config;
    }

    // ...
}

And now, we can grab these configuration values and check the given username against it:

<?php

public function validate($attribute, $username)
{
    // ...
    if (in_array($username, $this->config->get('auth.reserved_usernames')) {
        return false;
    }

    return true;
}

Whenever you have any usernames that you would like to disallow manually, you can add it to the auth.reserved_usernames configuration.

Wrap up

We now have a fully functional username validator! However, our validate method has become a little long now. We can clean this up by extracting the different parts of the validation into dedicated methods. This keeps our validate method focussed and easier to read. While moving things around, let's also change the order of the validation from 'cheapest' to 'expensive'. Checking usernames against configuration values is the cheapest, after that comes the route checking, and the most expensive one is the filesystem check. This is what the final version of the validator class looks like:

<?php

namespace App\Validation;

use Illuminate\Filesystem\Filesystem;
use Illuminate\Routing\Router;
use Illuminate\Config\Repository;

class AllowedUsernameValidator
{
    private $router;

    private $files;

    private $config;

    public function __construct(Router $router, Filesystem $files, Repository $config)
    {
        $this->config = $config;
        $this->router = $router;
        $this->files = $files;
    }

    public function validate($attribute, $username)
    {
        $username = trim(strtolower($username));

        if ($this->isReservedUsername($username)) {
            return false;
        }

        if ($this->matchesRoute($username)) {
            return false;
        }

        if ($this->matchesPublicFileOrDirectory($username)) {
            return false;
        }

        return true;
    }

    private function isReservedUsername($username)
    {
        return in_array($username, $this->config->get('auth.reserved_usernames'));
    }

    private function matchesRoute($username)
    {
        foreach ($this->router->getRoutes() as $route) {
            if (strtolower($route->uri) === $username) {
                return true;
            }
        }

        return false;
    }

    private function matchesPublicFileOrDirectory($username)
    {
        foreach ($this->files->glob(public_path().'/*') as $path) {
            if (strtolower(basename($path)) === $username) {
                return true;
            }
        }

        return false;
    }
}

I have created a gist of this final version, which also includes comments and a full unit test: https://gist.github.com/stidges/d74a0eca585d83d73bc781a54a2f3253.

If you have any questions, please don't hesitate to add a comment below!