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
User
s and more in terms of something relevant to your business model;Author
s,Client
s, 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.
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’.
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.
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?).
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.
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.
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!
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.
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!