Posted by Neal Brooks on Dec 18, 2018

Managing authentication in your Symfony project with AWS Cognito

One of our front-end engineers, Sebastian, has been working on a few side projects recently, one of which included setting up user pools in AWS Cognito to handle his user management. As he was showing me around the things he’d been doing, it got me thinking “how easy would it be to defer Symfony’s authentication to Cognito as well?”.

Why Cognito?

If you’re not familiar with Cognito, it can be summarised as follows: it provides a central mechanism for on-boarding and managing your users, and authenticating them across your web applications.

To simplify this, think of Cognito as being a simplified version of your application’s current Users table, but held outside of your database.

The beauty is that you can access this Users ‘table’ directly from your PHP back-end, your Python Lambda functions, or your React front-end. You don’t even need to build your own custom authentication API endpoints.

Cognito also has built-in support for multi-factor authentication, password reset, email & SMS confirmation, social logins (Facebook, Twitter, etc), and much more. That means you can offer your own users many more options for signing up to your service.

Why should I move my users to Cognito?

There’s no hard and fast reason why you need to move your users out of your application database. In fact, in many cases I’d advise you to keep them where they are. However, moving your users out of your database can bring a number of benefits:

  • Your database / PHP model structure may improve once you stop thinking in the realm of Users and more in terms of something relevant to your business model; Authors, Clients, etc
  • You can move your authentication out of your application’s data concerns
  • Let Amazon worry about data security, password hashing, etc, instead of doing it yourself

Additionally, if you found this page by searching online for PHP/Symfony AWS Cognito, you’re probably already well aware of what Cognito is and why you’d want to do it.

A slight deviation

A lot of the Cognito usage examples suggest that its primary goal is to allow users to log in directly from client-side applications. Server-side (eg PHP, NodeJS, etc) authentication is provided, but is far less well documented.

Personally, I often find AWS’ documentation hard to navigate and without seeing other community examples achieving the same thing in other languages I think I would have struggled to make this successful. Thank you, development community, you’re amazing.

Getting started

The first thing we’ll do is create a new Symfony 4 project and get it running locally:

 composer create-project symfony/website-skeleton cognito-login
 cd cognito-login

Next, let’s create a user authentication system by invoking the new maker bundle. We’re going to tell maker that we don’t want to store our security User object in our local database, and that our application is not going to be checking passwords itself (remember, we’re going to defer all of those headaches to AWS):

php bin/console make:user

The name of the security user class (e.g. User) [User]:
> User

Do you want to store user data in the database 
(via Doctrine)? (yes/no) [yes]:
> no

Enter a property name that will be the 
unique "display" name for the user 
(e.g. email, username, uuid) [email]:
> email

Will this app need to hash/check user passwords? 
Choose No if passwords are not needed or will 
be checked/hashed by some other system 
(e.g. a single sign-on server).

Does this app need to hash/check user passwords? (yes/no) [yes]:
> no

created: src/Security/User.php
updated: src/Security/User.php
created: src/Security/UserProvider.php
updated: config/packages/security.yaml

Success!

Now let’s use maker again to generate a login form:

php bin/console make:auth

What style of authentication do you want? [Empty authenticator]:
[0] Empty authenticator
[1] Login form authenticator
> 1

The class name of the authenticator to create 
(e.g. AppCustomAuthenticator):
> CognitoAuthenticator

Choose a name for the controller class 
(e.g. SecurityController) [SecurityController]:
> SecurityController

Enter the User class that you want to authenticate 
(e.g. App\Entity\User) [App\Security\User]:
> App\Security\User

created: src/Security/CognitoAuthenticator.php
updated: config/packages/security.yaml
created: src/Controller/SecurityController.php
created: templates/security/login.html.twig

Success!

If you open config/packages/security.yaml you’ll notice that the maker bundle has already configured the user provider and firewall to use the classes we’re creating:

security:
    providers:
        app_user_provider:
            id: App\Security\UserProvider
    firewalls:
        # ...
        main:
            anonymous: true
            guard:
                authenticators:
                    - App\Security\CognitoAuthenticator

While we’re here, let’s add the logout firewall configuration to security.yaml:

security:
    # ...
    firewalls:
        # ...
        main:
            # ...
            logout:
                path:   app_logout

And add the /logout path to config/routes.yaml:

app_logout:
    path: /logout

Next we need to install the AWS PHP SDK into our project:

composer require aws/aws-sdk-php

Now the only thing left to do is start the local web server so we can test as we go:

composer require symfony/web-server-bundle --dev
php bin/console server:run

Once the web server is running you’ll be able to access the Symfony development environment. Notice the ‘n/a’ next to the user icon in the debug bar, indicating that we’re currently unauthenticated.

Hello, World

Creating the user pool

We want to create a ‘user pool’ within Cognito which will hold all of our users and their passwords. You can do all of this through the AWS CLI, but when doing things for the first time I prefer to be able to click around and visualise what I’m doing, so I’m going to use the AWS console in my web browser.

The first time you visit the Cognito page, you will be asked if you want to manage your ‘user pools’ or ‘identity pools’. Identities relate to people who are allowed to use your AWS services directly, which is not what we want. Our aim is to create a pool of users who are allowed to use our application instead. So we need to go to ‘user pool’ management.

Now create a new user pool. Give it a name, and select the option to ‘review defaults’.

Create a new user pool

AWS will now select a bunch of default options and present you with the summary. There are a couple of customisation steps I want to make here, so first I’ll click the ‘Attributes’ tab.

Create a new user pool

In my app I want my users to sign in using their email address. I’m not bothered by usernames at the moment, so I’m choosing Email address or phone number for the sign-in method. Maybe, if my domain model requires it at a later date, I’ll create a Profile entity which I can store in my application database, and add username as a property to that (see how we’re already decoupling User from Profile objects?).

Select sign-in method for users

Next I want to add an ‘App Client’. The important things here are to check ‘Enable sign-in for server-based authentication ADMIN_NO_SRP_AUTH’, and to un-check ‘Generate client secret’. At the time of writing (Dec 2018) the PHP SDK is incompatible with client secrets in Cognito.

Create app client

Now we can return to the Review tab and create the pool. Make a note of the Pool Id in the overview page, and the Client ID in the client settings as we’ll need those later to configure the SDK.

If your app has different functions for admin users and ‘normal’ users, go to the ‘Users and Groups’ tab, and add a group named ADMIN (leave the other settings as they are). We can leverage this later in our firewall settings.

Create admin group

Now go to the Users tab and create a couple of test users then add one of them to the admin group you’ve just created.

Wiring it all together

With our user pool created and our PHP site ready to start logging people in, how do we make Symfony look to Cognito for authentication?

First of all we’ll create an AWS Cognito adapter class which we can use to hold our AWS configuration settings and proxy the requests from our application to the SDK.

Create src/Bridge/AwsCognitoClient.php with the following content:

<?php

namespace App\Bridge;

use Aws\CognitoIdentityProvider\CognitoIdentityProviderClient;

class AwsCognitoClient
{
    private $client;

    private $poolId;

    private $clientId;

    public function __construct(
        string $poolId,
        string $clientId,
        string $region = 'eu-west-2',
        string $version = 'latest'
    ) {
        $this->client = CognitoIdentityProviderClient::factory([
            'region' => $region,
            'version' => $version
        ]);
        $this->poolId = $poolId;
        $this->clientId = $clientId;
    }
}

If you’ve used the AWS PHP SDK before you’ll know that the easiest way to configure it is using environment variables, which it will automatically detect. Add your account’s AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY into your .env file. While we’re at it, let’s also add the COGNITO_POOL_ID and COGNITO_CLIENT_ID values we made a note of in the AWS web interface:

# ...

###> aws/aws-sdk-php ###
AWS_ACCESS_KEY_ID=AKIAJSKM...
AWS_SECRET_ACCESS_KEY=VRJ106jEn7D2Q7...
COGNITO_POOL_ID=eu-west-2_...
COGNITO_CLIENT_ID=7po1...
###< aws/aws-sdk-php ###

And now we should configure the service in config/services.yaml:

services:
    # ...

    App\Bridge\AwsCognitoClient:
        arguments:
            $poolId: '%env(COGNITO_POOL_ID)%'
            $clientId: '%env(COGNITO_CLIENT_ID)%'

If we go to /login and try to log in using one of the users we just created, the first thing that happens is we get an exception TODO: fill in loadUserByUsername().

Let’s open src/Security/UserProvider.php and fix that.

First we need to inject the bridge service we just created:

class UserProvider implements UserProviderInterface
{
    /**
     * @var AWSCognitoClient
     */
    private $cognitoClient;

    public function __construct(AWSCognitoClient $cognitoClient)
    {
        $this->cognitoClient = $cognitoClient;
    }
    
// ...

And then we want to use the client to look for users in our user pool, searching by email address:

// ...
    public function loadUserByUsername($username)
    {
        $result = $this->cognitoClient->findByUsername($username);

        if (count($result['Users']) === 0) {
            throw new UsernameNotFoundException();
        }

        $user = new User();
        $user->setEmail($username);

        return $user;
    }

Now we should implement the findByUsername() method on our adapter class src/Bridge/AwsCognitoClient.php:

// ...
use Aws\Result;

class AwsCognitoClient
{
// ...
    public function findByUsername(string $username): Result
    {
        return $this->client->listUsers([
            'UserPoolId' => $this->poolId,
            'Filter'     => "email=\"" . $username . "\""
        ]);
    }

Now, if we re-submit the form, we get the exception TODO: check the credentials inside .../Security/CognitoAuthenticator.php. Let’s update src/Security/CognitoAuthenticator.php to use our adapter for checking passwords:

use App\Bridge\AwsCognitoClient;
use Aws\CognitoIdentityProvider\Exception\CognitoIdentityProviderException;

// ...
    private $cognitoClient;

    public function __construct(
        RouterInterface $router,
        CsrfTokenManagerInterface $csrfTokenManager,
        AwsCognitoClient $cognitoClient
    ) {
        $this->router = $router;
        $this->csrfTokenManager = $csrfTokenManager;
        $this->cognitoClient = $cognitoClient;
    }
    
    // ...
    
    public function checkCredentials($credentials, UserInterface $user)
    {
        try {
            $this->cognitoClient->checkCredentials(
                $credentials['email'],
                $credentials['password']
            );
        } catch (CognitoIdentityProviderException $exception) {
            return false;
        }

        return true;
    }

And let’s add the checkCredentials() method to the adapter class (note, the front-end SDK examples use initiateAuth to do the credential check, but when using a server-side implementation with an SDK pre-configured with an access token and secret, you should use adminInitiateAuth instead):

// ...

    public function checkCredentials($username, $password): Result
    {
        return $this->client->adminInitiateAuth([
            'UserPoolId'     => $this->poolId,
            'ClientId'       => $this->clientId,
            'AuthFlow'       => 'ADMIN_NO_SRP_AUTH', // this matches the 'server-based sign-in' checkbox setting from earlier
            'AuthParameters' => [
                'USERNAME' => $username,
                'PASSWORD' => $password
            ]
        ]);
    }

When we re-submit the form this time, we get the exception TODO: provide a valid redirect inside Security/CognitoAuthenticator.php

For the purpose of this demo, I’m just going to redirect back to the login form:

// ...
    public function onAuthenticationSuccess(
        Request $request,
        TokenInterface $token,
        $providerKey
    ) {
        if ($targetPath = $this->getTargetPath($request->getSession(),
            $providerKey)
        ) {
            return new RedirectResponse($targetPath);
        }
    
        return new RedirectResponse($this->router->generate('app_login'));
    }

And the final thing to address is TODO: fill in refreshUser() inside Security/UserProvider.php. We already have the method to fetch the user by username inside the same class, so let’s just re-use that:

// ...

    public function refreshUser(UserInterface $user)
    {
        if (!$user instanceof User) {
            throw new UnsupportedUserException(
                sprintf(
                    'Invalid user class "%s".',
                    get_class($user)
                )
            );
        }

        return $this->loadUserByUsername($user->getEmail());
    }

This time when I refresh the form I am redirected back to the same login form, but now the debug bar shows me being logged in with my email address. I can disable users from the AWS Cognito admin panel (try it, they won’t be able to log in!), and my own application code / entities has absolutely no knowledge of the authentication mechanism. In fact we haven’t configured a single database connection or Doctrine entity in this example app!

Authenticated using the Cognito PHP SDK

Adding support for roles

Remember earlier when we added an ADMIN group in the Cognito interface? Let’s wire that up as well, so we can manage our administrators via AWS as well.

There’s a separate SDK method for fetching a user’s role, adminListGroupsForUser(), so we just need to leverage that in our adapter class:

    // ...

    public function getRolesForUsername(string $username): Result
    {
        return $this->client->adminListGroupsForUser([
            'UserPoolId' => $this->poolId,
            'Username'   => $username
        ]);
    }

And to use it in our UserProvider class to convert the groups to roles, we should update loadUserByUsername:

    // ...

    public function loadUserByUsername($username)
    {
        $result = $this->cognitoClient->findByUsername($username);

        if (count($result['Users']) === 0) {
            throw new UsernameNotFoundException();
        }

        $user = new User();
        $user->setEmail($username);

        $groups = $this->cognitoClient->getRolesForUsername(
            $result['Users'][0]['Username']
        );

        if (count($groups['Groups']) > 0) {
            $user->setRoles(
                array_map(
                    function ($item) {
                        return 'ROLE_' . $item['GroupName'];
                    },
                    $groups['Groups']
                )
            );
        }

        return $user;
    }

Now in the profiler you can see that the user I added to my ADMIN group earlier has been given the ROLE_ADMIN role, but the other user only has the default ROLE_USER role attached.

User in the ADMIN group has the ADMIN role User not in the ADMIN group has the basic role

So, what was the point of all this?

While we haven’t gone so far as implementing more advanced functionality like resetting passwords, deleting users, etc, what we have done here is produce a proof-of-concept that we can easily use the AWS SDK to secure our Symfony app.

With just a little more work we could create a fully-featured user management system which is totally decoupled from our domain concerns. Additionally, the front-end team can allow the same users to log in to the site’s React application without us needing to add an authentication endpoint to the Symfony application, and the mobile app can log in users from the same pool, all without adding any extra traffic to our PHP back-end or application database!

Jobs at MyBuilder and Instapro

We need experienced software engineers who love their craft and want to share their hard-earned knowledge.

View vacancies
comments powered by Disqus