TL;DR


No time? No problem... We provide expert level Laravel development and consulting. We also help customers scaling their projects. If you think we could fit together, drop us a message or give us a call. We are here to help.

Contact


+

Laravel Authentication with AWS Cognito


Introduction

Laravel already comes with a powerful authentication feature which we all know and love right? You simply execute the following command, and have authentication working:


php artisan make:auth

That's super easy and will not require any actual programming. For most projects, this will be enough. You can register, login, have your password reset and you have a remember me feature. Actually, that's enough for most use cases, but let's start by defining a use case where it is not enough: What if we want to give a user, registered in Project A, access to Project B or the other way round? Well, sadly Laravel doesn't come with a built-in Single Sign-On feature (check Laravel Socialite for authentication with Facebook, Twitter, LinkedIn, Google, GitHub and Bitbucket), but its actually pretty easy to implement one ourselves.

During this post, we will dive into the authentication functionality and how it works behind the scene. We will also implement a Single Sign On, which will solve our use case.

AWS Cognito

Cognito is a powerful Authentication handler provided by AWS. We will use it in the background to store all of our user credentials and identifications.

To set up a Cognito user pool, log into your management console and navigate to Cognito. Oh, great news by the way. Cognito is 100% free for up to 50.000 monthly active users.

Once you are on the Cognito page, click Create a user pool. It is actually pretty easy to set up, so we won't walk through the whole process here.

IMPORTANT: Don't forget to activate the checkbox to Enable sign-in API for server-based Authentication.
The Auth Flow is called: ADMIN_NO_SRP_AUTH

So now that we have set up a valid Cognito Pool we can go into our Laravel project.

At this point, we cannot communicate with AWS. We could implement their API by ourselves using Guzzle or Zttp as a Guzzle wrapper, but this would be too much work for our task.

Luckily AWS provides a PHP SDK which we can require using composer:


composer require aws/aws-sdk-php

Once installed the SDK will give us a Client which we can use for the API communication: CognitoIdentityProviderClient.

By implementing the authentication functionality we will build a CognitoClient which we can then easily use in our controller.

Laravel Authentication Setup

Okay, so as already mentioned in the introduction, it is pretty easy to get authentication up and running in Laravel. Let's dive into it.

What does the make:auth command actually do? First of all, it creates the needed directories were it will store the authentication files which we can edit later.

They are located in the following directories:

  • resources/views/auth
  • resources/views/layouts

All files you get from those two directories are copied from the make:auth command.

Additionally, we get a HomeController plus some AuthControllers.

To activate the new routes, Laravel automatically appends an Auth::routes() call to your routes/web.php file. Now we got all the scaffolding done and our authentication is up and running.

With just one call from the command line, we created views, a controller, and routes. That's just awesome. Let's have a deeper look at the registration process.

Registration

As we all know, the registration process starts in the RegistrationController we have previously created. Normally, after the user enters valid credentials into the form, we will create the new User object and store it into the database. Ok, you might ask yourself, how am I able to push the user into our Cognito Pool?

The answer is easy. We need to add a little programming logic of our own.

First, we create a class called CognitoClient under app\Cognito. Once it's developed, it will look like this:


<?php
namespace App\Cognito;

use Aws\CognitoIdentityProvider\CognitoIdentityProviderClient;
use Aws\CognitoIdentityProvider\Exception\CognitoIdentityProviderException;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;

class CognitoClient
{
    const NEW_PASSWORD_CHALLENGE = 'NEW_PASSWORD_REQUIRED';
    const FORCE_PASSWORD_STATUS  = 'FORCE_CHANGE_PASSWORD';
    const RESET_REQUIRED         = 'PasswordResetRequiredException';
    const USER_NOT_FOUND         = 'UserNotFoundException';
    const USERNAME_EXISTS        = 'UsernameExistsException';
    const INVALID_PASSWORD       = 'InvalidPasswordException';
    const CODE_MISMATCH          = 'CodeMismatchException';
    const EXPIRED_CODE           = 'ExpiredCodeException';

    /**
     * @var CognitoIdentityProviderClient
     */
    protected $client;

    /**
     * @var string
     */
    protected $clientId;

    /**
     * @var string
     */
    protected $clientSecret;

    /**
     * @var string
     */
    protected $poolId;

    /**
     * CognitoClient constructor.
     * @param CognitoIdentityProviderClient $client
     * @param string $clientId
     * @param string $clientSecret
     * @param string $poolId
     */
    public function __construct(
        CognitoIdentityProviderClient $client,
        $clientId,
        $clientSecret,
        $poolId
    ) {
        $this->client       = $client;
        $this->clientId     = $clientId;
        $this->clientSecret = $clientSecret;
        $this->poolId       = $poolId;
    }

    /**
     * Checks if credentials of a user are valid
     *
     * @see http://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_AdminInitiateAuth.html
     * @param string $email
     * @param string $password
     * @return \Aws\Result|bool
     */
    public function authenticate($email, $password)
    {
        try
        {
            $response = $this->client->adminInitiateAuth([
                'AuthFlow'       => 'ADMIN_NO_SRP_AUTH',
                'AuthParameters' => [
                    'USERNAME'     => $email,
                    'PASSWORD'     => $password,
                    'SECRET_HASH'  => $this->cognitoSecretHash($email)
                ],
                'ClientId'   => $this->clientId,
                'UserPoolId' => $this->poolId
            ]);
        }
        catch (CognitoIdentityProviderException $exception)
        {
            if ($exception->getAwsErrorCode() === self::RESET_REQUIRED ||
                $exception->getAwsErrorCode() === self::USER_NOT_FOUND) {
                return false;
            }

            throw $exception;
        }

        return $response;
    }

    /**
     * Registers a user in the given user pool
     *
     * @param $email
     * @param $password
     * @param array $attributes
     * @return bool
     */
    public function register($email, $password, array $attributes = [])
    {
        $attributes['email'] = $email;

        try
        {
            $response = $this->client->signUp([
                'ClientId' => $this->clientId,
                'Password' => $password,
                'SecretHash' => $this->cognitoSecretHash($email),
                'UserAttributes' => $this->formatAttributes($attributes),
                'Username' => $email
            ]);
        }
        catch (CognitoIdentityProviderException $e) {
            if ($e->getAwsErrorCode() === self::USERNAME_EXISTS) {
                return false;
            }

            throw $e;
        }

        $this->setUserAttributes($email, ['email_verified' => 'true']);

        return (bool) $response['UserConfirmed'];
    }

    /**
     * Send a password reset code to a user.
     * @see http://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_ForgotPassword.html
     *
     * @param  string $username
     * @return string
     */
    public function sendResetLink($username)
    {
        try {
            $result = $this->client->forgotPassword([
                'ClientId' => $this->clientId,
                'SecretHash' => $this->cognitoSecretHash($username),
                'Username' => $username,
            ]);
        } catch (CognitoIdentityProviderException $e) {
            if ($e->getAwsErrorCode() === self::USER_NOT_FOUND) {
                return Password::INVALID_USER;
            }

            throw $e;
        }

        return Password::RESET_LINK_SENT;
    }

    # HELPER FUNCTIONS

    /**
     * Set a users attributes.
     * http://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_AdminUpdateUserAttributes.html
     *
     * @param string $username
     * @param array  $attributes
     * @return bool
     */
    public function setUserAttributes($username, array $attributes)
    {
        $this->client->AdminUpdateUserAttributes([
            'Username' => $username,
            'UserPoolId' => $this->poolId,
            'UserAttributes' => $this->formatAttributes($attributes),
        ]);

        return true;
    }


    /**
     * Creates the Cognito secret hash
     * @param string $username
     * @return string
     */
    protected function cognitoSecretHash($username)
    {
        return $this->hash($username . $this->clientId);
    }

    /**
     * Creates a HMAC from a string
     *
     * @param string $message
     * @return string
     */
    protected function hash($message)
    {
        $hash = hash_hmac(
            'sha256',
            $message,
            $this->clientSecret,
            true
        );

        return base64_encode($hash);
    }

    /**
     * Get user details.
     * http://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_GetUser.html
     *
     * @param  string $username
     * @return mixed
     */
    public function getUser($username)
    {
        try {
            $user = $this->client->AdminGetUser([
                'Username' => $username,
                'UserPoolId' => $this->poolId,
            ]);
        } catch (CognitoIdentityProviderException $e) {
            return false;
        }

        return $user;
    }

    /**
     * Format attributes in Name/Value array
     *
     * @param  array $attributes
     * @return array
     */
    protected function formatAttributes(array $attributes)
    {
        $userAttributes = [];

        foreach ($attributes as $key => $value) {
            $userAttributes[] = [
                'Name' => $key,
                'Value' => $value,
            ];
        }

        return $userAttributes;
    }
}

Now we have a client, which can actually talk to Cognito. since we have some dependencies in this, we want to turn it into a singleton service, so what we are going to do, is create a CognitoAuthServiceProvider.


<?php
namespace App\Providers;

use App\Auth\CognitoGuard;
use App\Cognito\CognitoClient;
use Illuminate\Support\ServiceProvider;
use Illuminate\Foundation\Application;
use Aws\CognitoIdentityProvider\CognitoIdentityProviderClient;

class CognitoAuthServiceProvider extends ServiceProvider
{
    public function boot()
    {
        $this->app->singleton(CognitoClient::class, function (Application $app) {
            $config = [
                'credentials' => config('cognito.credentials'),
                'region'      => config('cognito.region'),
                'version'     => config('cognito.version')
            ];

            return new CognitoClient(
                new CognitoIdentityProviderClient($config),
                config('cognito.app_client_id'),
                config('cognito.app_client_secret'),
                config('cognito.user_pool_id')
            );
        });
  
    }
}

And finally, add this ServiceProvider to your config\app.php file to activate it. Now that we have our Cognito Client available in the application, we want to use it in the RegisterController. So let's copy the register function from the trait, and slightly adjust it to fit our needs.


public function register(Request $request)
    {
        $this->validator($request->all())->validate();

        $attributes = [];

        $userFields = ['name', 'email'];

        foreach($userFields as $userField) {

            if ($request->$userField === null) {
                throw new \Exception("The configured user field $userField is not provided in the request.");
            }

            $attributes[$userField] = $request->$userField;
        }

        app()->make(CognitoClient::class)->register($request->email, $request->password, $attributes);

        event(new Registered($user = $this->create($request->all())));

        return $this->registered($request, $user)
            ?: redirect($this->redirectPath());
    }

We first validate the user post data, go through the user fields we want to store in Cognito, and extract them from our request. Then create a new CognitoClient Singleton and call its register with the values.

When you register now in your Laravel App you should have the first user in your Cognito pool. Congratulations!

Let's move on to the Login logic.

Login

To be able to let the user login via Cognito, we need to adjust the way Laravel authenticates the user. You will be able to adjust it in the config\auth.php file. Currently, it looks like this:


 'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],

        'api' => [
            'driver' => 'token',
            'provider' => 'users',
        ],
    ],

Laravel works with so-called Guards. One guard has the responsibility to authenticate the user by its given credentials and perform the login process, so the user can access the application.

What we want to do now is create a new AuthenticationGuard, so we can use our own Cognito driver.

This can be achieved quite easily.

Let's create a folder called Auth and place it underneath the app directory and create a class called CognitoGuard. This class should extend the SessionGuard from Laravel, and implement the StatefulGuard interface.

In our case it will look like this:


<?php
namespace App\Auth;

use App\Cognito\CognitoClient;
use App\Exceptions\InvalidUserModelException;
use App\Exceptions\NoLocalUserException;
use Aws\Result;
use Illuminate\Auth\SessionGuard;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Session\Session;
use Illuminate\Database\Eloquent\Model;
use Symfony\Component\HttpFoundation\Request;

class CognitoGuard extends SessionGuard implements StatefulGuard
{
    /**
     * @var CognitoClient
     */
    protected $client;

    /**
     * CognitoGuard constructor.
     * @param string $name
     * @param CognitoClient $client
     * @param UserProvider $provider
     * @param Session $session
     * @param null|Request $request

     */
    public function __construct(
        string $name,
        CognitoClient $client,
        UserProvider $provider,
        Session $session,
        ?Request $request = null
    ) {
        $this->client = $client;
        parent::__construct($name, $provider, $session, $request);
    }

    /**
     * @param mixed $user
     * @param array $credentials
     * @return bool
     * @throws InvalidUserModelException
     */
    protected function hasValidCredentials($user, $credentials)
    {
        /** @var Result $response */
        $result = $this->client->authenticate($credentials['email'], $credentials['password']);

        if ($result && $user instanceof Authenticatable) {
            return true;
        }

        return false;
    }

    /**
     * Attempt to authenticate a user using the given credentials.
     *
     * @param  array  $credentials
     * @param  bool   $remember
     * @throws
     * @return bool
     */
    public function attempt(array $credentials = [], $remember = false)
    {
        $this->fireAttemptEvent($credentials, $remember);

        $this->lastAttempted = $user = $this->provider->retrieveByCredentials($credentials);

        if ($this->hasValidCredentials($user, $credentials)) {
            $this->login($user, $remember);
            return true;
        }

        $this->fireFailedEvent($user, $credentials);

        return false;
    }
}

This looks pretty straightforward and should do the job. What we are doing here is, we are taking the credentials from the login and using the provider to retrieve a user by its credentials. You now see the combination. The guard uses a provider. That's what we saw in the guard config array some lines above.

And this is what a provider definition looks like:


'providers' => [
   'users' => [
       'driver' => 'eloquent',
       'model' => App\User::class
   ],
],

The Provider has a driver, which is currently set to eloquent and a model definition, which tells Laravel about the model class it needs to use.

Now we need to make our guard visible to the application and as you might have guessed, we can do so by adapting our boot method from our CognitoAuthServiceProvider.


<?php
namespace App\Providers;

use App\Auth\CognitoGuard;
use App\Cognito\CognitoClient;
use Illuminate\Support\ServiceProvider;
use Illuminate\Foundation\Application;
use Aws\CognitoIdentityProvider\CognitoIdentityProviderClient;

class CognitoAuthServiceProvider extends ServiceProvider
{
    public function boot()
    {
       // ...
        $this->app['auth']->extend('cognito', function (Application $app, $name, array $config) {
            $guard = new CognitoGuard(
                $name,
                $client = $app->make(CognitoClient::class),
                $app['auth']->createUserProvider($config['provider']),
                $app['session.store'],
                $app['request']
            );

            $guard->setCookieJar($this->app['cookie']);
            $guard->setDispatcher($this->app['events']);
            $guard->setRequest($this->app->refresh('request', $guard, 'setRequest'));

            return $guard;
        });
    }
}

Here we are going to extend the Authentication internals from Laravel and create a new driver which we can use in our application. The first parameter is the name we have to set as a driver.

Now set the new driver so your auth config looks like this:


<?php

return [

    /*
    |--------------------------------------------------------------------------
    | Authentication Defaults
    |--------------------------------------------------------------------------
    |
    | This option controls the default authentication "guard" and password
    | reset options for your application. You may change these defaults
    | as required, but they're a perfect start for most applications.
    |
    */

    'defaults' => [
        'guard' => 'web',
        'passwords' => 'users',
    ],

    /*
    |--------------------------------------------------------------------------
    | Authentication Guards
    |--------------------------------------------------------------------------
    |
    | Next, you may define every authentication guard for your application.
    | Of course, a great default configuration has been defined for you
    | here which uses session storage and the Eloquent user provider.
    |
    | All authentication drivers have a user provider. This defines how the
    | users are actually retrieved out of your database or other storage
    | mechanisms used by this application to persist your user's data.
    |
    | Supported: "session", "token"
    |
    */

    'guards' => [
        'web' => [
            'driver' => 'cognito', <--- This is the important line
            'provider' => 'users',
        ],
        'api' => [
            'driver' => 'token',
            'provider' => 'users',
        ],
    ],
    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\User::class
        ]
    ],
];


The last step is to adjust the LoginController. For our task, we need to overwrite the basic login functionality as we did in the registration process.

Go to your LoginController and add those to methods:


public function login(Request $request)
{
        $this->validateLogin($request);

        if ($this->hasTooManyLoginAttempts($request)) {
            $this->fireLockoutEvent($request);

            return $this->sendLockoutResponse($request);
        }

        try
        {
            if ($this->attemptLogin($request)) {
                return $this->sendLoginResponse($request);
            }
        }
        catch(CognitoIdentityProviderException $c) {
            return $this->sendFailedCognitoResponse($c);
        }
        catch (\Exception $e) {
            return $this->sendFailedLoginResponse($request);
        }

        return $this->sendFailedLoginResponse($request);
    }

    private function sendFailedCognitoResponse(CognitoIdentityProviderException $exception)
    {
        throw ValidationException::withMessages([
            $this->username() => $exception->getAwsErrorMessage(),
        ]);
    }

Now when you use your credentials, you can log in your user with the new Cognito driver. That's cool!

You can see that it takes quite a bit of time to implement this! Remember our original use case? We wanted to create a single sign-on, so we have to repeat this at least one more time. Now, take a step back and imagine you have 5 projects you want to connect with a single sign-on. This is not maintainable!

So, we decided to create a package which solves all of this work for you, and cut it down to just 5 minutes of work to get it up and running!

Laravel Package to easily manage authentication with AWS Cognito

Let's throw all the work away we have done so far and start from scratch, and I'll show you how easy it is to implement a single sign-on.

To get started you will need to require our package using composer:


composer require black-bits/laravel-cognito-auth

Once installed you can go on and publish the config and the view:


php artisan vendor:publish --provider="BlackBits\LaravelCognitoAuth\CognitoAuthServiceProvider"

Now move on to your config\auth.php file and activate the new cognito driver like we did before:


'guards' => [
    'web' => [
        'driver' => 'cognito', // This line is important 
        'provider' => 'users',
    ],
    'api' => [
        'driver' => 'token',
        'provider' => 'users',
    ],
],

Add the following fields to your .env file:


AWS_COGNITO_KEY=
AWS_COGNITO_SECRET=
AWS_COGNITO_REGION=
AWS_COGNITO_CLIENT_ID=
AWS_COGNITO_CLIENT_SECRET=
AWS_COGNITO_USER_POOL_ID=

Now you walk through the AuthControllers and swap out the Laravel specific traits with our traits. Don't be afraid to do something wrong, they are just called like the ones in Laravel.


BlackBits\LaravelCognitoAuth\Auth\AuthenticatesUsers
BlackBits\LaravelCognitoAuth\Auth\RegistersUsers
BlackBits\LaravelCognitoAuth\Auth\ResetsPasswords
BlackBits\LaravelCognitoAuth\Auth\SendsPasswordResetEmails

And that's it. You have implemented our package and enabled AWS Cognito authentication. Last but not least we want to take care of the Single Sign-On.

Single Sign On

With our package and AWS Cognito, we provide you a simple way to use Single Sign On's. To enable it just go to the cognito.php file in the config directory and set USE_SSO=true. But how does it work?

When you have SSO enabled in your config, and a user tries to log into your application, the cognito client will check if he exists in your Cognito pool. If the user exists he will be created automatically in your database and is logged in simultaneously.

That's what we need the fields sso_user_model and sso_user_fields for. In sso_user_model you define the class of your user model. In most cases, this will simply be App\User.

With sso_user_fields you can define the fields which should be stored in Cognito. Pay attention here! If you define a field which you do not send with the Register Request, it will throw an InvalidUserFieldException and you won't be able to register.

So now you have registered your user with its attributes in the Cognito pool and your local database, and you want to attach a second app which uses the same pool. Well, that's actually pretty easy. You set up your project like you are used to and install our laravel-cognito-auth package. On both sites set USE_SSO=true. Also be sure you entered exactly the same pool id. Now when a user is registered in your other app but not in your second and wants to login he gets created. And that's all you need to do.

Conclusion

Today you've learned how simple it is to spread a single user database over multiple projects.

In the future, we will work on this package to make it even better and more customizable. For the moment you can only log in with an email address, but in the future, we want to make it customizable to be any field of choice.

Let us know what you think.

https://github.com/black-bits/laravel-cognito-auth

Expert Level Laravel Web Development & Consulting Agency

We love Laravel, and so should you. Let us show you why.

Find out about Laravel

About Us

About Us


Founded in 2014, Black Bits helps customers to reach their goals and to expand their market positions. We work with a wide range of companies, from startups that have a vision and first funding to get to market faster, to big industry leaders that want to profit from modern technologies. If you want to start on your project without building an internal dev-team first, or if you need extra expertise or resources, Black Bits - the Laravel Web Development Agency is here to help.

Laravel

Laravel is one of the most popular PHP Frameworks. Perfect for Sites and API's of all sizes. Clean, fast and reliable.

Lumen

Lumen is the perfect solution for building Laravel based micro-services and blazing fast APIs.

Vue.js

Live-Updating Dashboards, Components and Reactivity. Your project needs a modern user-interface? Vue.js is the right choice.

React

Live-Updating Dashboards, Components and Reactivity. Your project needs a modern user-interface? React is the right choice.

Bootstrap

Bootstrap is the second most-starred project on GitHub and the leading framework for designing modern front-ends.

TailwindCSS

Tailwind is a utility-first CSS framework for rapidly building custom user interfaces.

Amazon Web Services

Amazon Web Services (AWS) leads the worldwide cloud computing market. With a wide range of services, AWS is a perfect option for fast development.

Digital Ocean

Leave complex pricing structures behind. Always know what you'll pay per month. Service customers around the world from eight different locations.

Contact Us

Contact Us


You have the vision, we have the development expertise. We would love to hear about your project.

Send us a message or give us a call and see
how Black Bits can help you!

Grants Pass, Oregon, U.S.A.

phone: (+1) 541 592 9181

[email protected]