DEV Community

Fabien
Fabien

Posted on

Modern OAuth2 Discord Authentification with Symfony

Are you making an app that's related to gaming ? Are you requiring users to be authenticated ? But most importantly, are you a gamer (although that's not a requirement) ?

This tutorial has been created for you !


The OAuth2 protocol

If you are already familiar with the OAuth2 protocol, you can already skip to the next part.

I'm not going to make any revolutionary speech about what is the OAuth2 protocol and how great it is, so at first, I'm going to quote the official oauth2.net website for this one :

OAuth 2.0 is the industry-standard protocol for authorization. OAuth 2.0 focuses on client developer simplicity while providing specific authorization flows for web applications, desktop applications, mobile phones, and living room devices.

In simple words, it means the users, as clients, only have to click buttons to connect into your app using external services such as Google, Facebook, Discord, etc.

Other than simplifying authentication, it also makes registration way easier, since there's none !

With this protocol, the main resource, the key, is the access token. The goal here is to obtain this access token using your secret API key, and use it in order to retrieve resources from the provider (such as user data).

Here I've unleashed my skills and made a drawing on Paint so you get the idea :

OAuth2 drawing

Note that authentication and resource servers can be the same.

Registering your app

The very first step but not the least, going onto Discord's developer portal in order to register your app. Once you've done that, you will obtain your API keys that will make you able to send requests to the Discord API.

⚠️ Before you continue, make sure you have a Discord account which is still available.

Find the button

At first, click the "New application" button. You can find it on the top right hand corner of the portal, next to your profile :

Create application

Enter the name

Once you've done that, a popup will appear so you can give a name to the app. For the purpose of the tutorial, the app will be called "OAuthTutorial" :

Application name popup

Obviously, don't forget to read the terms of service 😉

You now have access to the dashboard of the app.

Create the Symfony application

In this part, we are going to run throughout basic Symfony project setup. If you are already familiar with Symfony, you can already go to the next part.

Set the project up

Open a terminal, and the following command :

symfony new --webapp DiscordOauthTutorial
Enter fullscreen mode Exit fullscreen mode

If you havn't installed Symfony CLI yet, although I highly recommend you to do so, run the following boring command :

composer create-project symfony/skeleton:"6.3.*" DiscordOauthTutorial
cd DiscordOauthTutorial
composer require webapp
Enter fullscreen mode Exit fullscreen mode

Run the webserver

Once you've opened the project in your favorite IDE which must obviously be PHP Storm I mean what else, run the following command :

symfony server:start
Enter fullscreen mode Exit fullscreen mode

You now have launched the local web server, which makes the app available at https://127.0.0.1:8000/ by default. If you click that link, you will be greeted by the Symfony welcome page 🥳.

If you encounter problems or wish to learn more about local web servers, click here.

Database things

This part is for people who would like to authenticate the user using Symfony's authentication system. This requires creating a database with a User entity. If you don't want to do so (which is possible), skip to the next part.

At first, let's modify the database information in our .env file to match our database's settings :

DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
Enter fullscreen mode Exit fullscreen mode

Let's assume we are working on MariaDB.

Then run :

php bin/console doctrine:database:create
Enter fullscreen mode Exit fullscreen mode

Create the entity

Now that our database is alive, run the following command :

php bin/console make:entity
Enter fullscreen mode Exit fullscreen mode

For the purpose of the tutorial, we are going to call the entity User (how normy).

The main goal here is to add a (e.g.) discordId property, which will be a string. This will hold the user's Discord ID so you will be able to retrieve or create it while authenticating.

Our entity should look like this :

#[ORM\Entity(repositoryClass: UserRepository::class)]
class User
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $discordId = null;

    // getters and setters
}

Enter fullscreen mode Exit fullscreen mode

Migrating to the database

Let's now use the magic of Doctrine to push the entity into our database. In order to do so, Doctrine uses migrations. These are lines of SQL that describe what's changed in our database since last migration.

Obviously we don't have to generate migrations by ourselves, just run :

php bin/console make:migration
Enter fullscreen mode Exit fullscreen mode

This will generate a file into the migrations folder of our project. The migration name is "Version" followed by the datetime.

Check it, push it.

php bin/console doctrine:migrations:migrate
Enter fullscreen mode Exit fullscreen mode

Let's do it

Fortunately, this planet has produced great people that make great things. I don't know if the people from KNP University are great, but from what I know, is that they make great tools to help us implementing complex things. So let's use one of these tools, the KNP OAuth2 Client bundle.

Install the bundle

To install the bundle, run :

composer require knpuniversity/oauth2-client-bundle
Enter fullscreen mode Exit fullscreen mode

After Composer has downloaded the bundle, it will ask you if you wish to execute the recipes for this bundle, which you should. The recipes will generate the configuration file for the bundle, which can be found at config/packages/knpu_oauth2_client.yaml.

If, for some reasons, that does not happen, create the file and paste this in it :

knpu_oauth2_client:
    clients:
        # configure your clients as described here: https://github.com/knpuniversity/oauth2-client-bundle#configuration
Enter fullscreen mode Exit fullscreen mode

Install the OAuth Discord client

Since the KNP package provides a flexible tool for OAuth authentication, it requires you to install clients for it to work. Another great person on Earth has made it ! So, in order to install it, run :

composer require wohali/oauth2-discord-new
Enter fullscreen mode Exit fullscreen mode

More information about the client here

Configure the client

Since we're authenticating our users using OAuth2, we need API keys. Spot on, you've just created a Discord app ! Let's gather what we need !

At first, go to the dashboard of our app, and get the application ID :

Discord developer dashboard

Go to our .env.local file, and paste the application ID into a brand new variable that is going to be called DISCORD_APP_ID :

DISCORD_APP_ID=app_id
Enter fullscreen mode Exit fullscreen mode

For the secret key, it's always more complicated...

Click on the "OAuth2" section of the sidebar on the left side of the screen, and click the "General" link :

Discord developer portal OAuth2 section

On this page, you should find this :

Secret key button

Click this button, get the secret key, hold it tightly, and paste it into another variable that will be called DISCORD_SECRET_KEY :

DISCORD_SECRET_KEY=app_secret
Enter fullscreen mode Exit fullscreen mode

Note that the app's secret key is important and private, and must not leak. If you have problems configuring the project, do not send screenshots on which people can see it.

Now that our environment variables are set, go back to the YAML configuration file, and fill it like so :

knpu_oauth2_client:
    clients:
        discord:
            type: discord
            client_id: '%env(DISCORD_APP_ID)%'
            client_secret: '%env(DISCORD_SECRET_KEY)%'
            redirect_route: auth_discord_login
Enter fullscreen mode Exit fullscreen mode

As you can see, we have a redirect_route parameter that sets a route name that does not exist yet. This route is where the magic will happen for the user to authenticate.

Create the login route

Let's create a Controller named DiscordController. I suggest you not to use the maker command since it also generates a template, which we don't want for this one.

Once the Controller is created, create the route like so :

namespace App\Controller;

use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;

#[AsController]
#[Route("/auth/discord", name: "auth_discord_")]
final class DiscordController
{
    #[Route("/login", name: "login")]
    public function login(Request $request, ClientRegistry $clientRegistry)
    {
    }
}
Enter fullscreen mode Exit fullscreen mode

Here the method is empty, since the login process will be handled by the custom authenticator. However, you can add logic in order to check authentication in it if you wish to.

For the purpose of making it clean, we are separating the Discord authentication into a /auth/discord/ prefix.

  • The /auth part to make clear that we are in an authentication process.
  • The /discord part to separate the Discord auth from any authentication you could add.

Now that our app knows the way, Discord must also. So let's go back to the developer portal, onto the OAuth2 > General page.

In this page, you will be able to set the redirect URL. As a reminder, in the OAuth2 process, it's the URL that receives the code generated after the user said "Yes" for authentication on the vendor's page (the user must say "Yes" for you to access its data).

So let's add this URL :

Redirect URL configuration on Discord developer portal

Create the authenticator

In order to manage authentication, Symfony uses Authenticators. These basically are services that defines how to retrieve the User and what to do once the job is done.

Let's now create the big boy :

<?php

namespace App\Security;

use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use KnpU\OAuth2ClientBundle\Security\Authenticator\OAuth2Authenticator;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
use Wohali\OAuth2\Client\Provider\DiscordResourceOwner;

final class DiscordAuthenticator extends OAuth2Authenticator implements AuthenticationEntryPointInterface
{
    public function __construct(
        private readonly ClientRegistry $clientRegistry,
        private readonly EntityManagerInterface $em,
        private readonly RouterInterface $router,
        private readonly UserRepository $userRepository
    ) {}

    public function start(Request $request, AuthenticationException $authException = null): RedirectResponse
    {
        return new RedirectResponse($this->router->generate("auth_discord_start"), Response::HTTP_TEMPORARY_REDIRECT);
    }

    public function supports(Request $request): ?bool
    {
        return $request->attributes->get("_route") === "auth_discord_login";
    }

    public function authenticate(Request $request): SelfValidatingPassport
    {
        $client = $this->clientRegistry->getClient("discord");
        $accessToken = $this->fetchAccessToken($client);

        return new SelfValidatingPassport(
            new UserBadge($accessToken->getToken(), function () use ($accessToken, $client) {
                /** @var DiscordResourceOwner $discordUser */
                $discordUser = $client->fetchUserFromToken($accessToken);

                $user = $this->userRepository->findOneBy(["discordId" => $discordUser->getId()]);

                if (null === $user) {
                    $user = new User();
                    $user->setDiscordId($discordUser->getId());

                    $this->em->persist($user);
                }

                $this->em->flush();

                return $user;
            })
        );
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        // redirect to user to your post authentication page (e.g. dashboard, home)
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        // do something
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's make a quick run through what's going on in here :

  • The start method defines the entrypoint of the authentication. The goal for us here is to redirect the user to Discord's portal (in other words, start the OAuth2 process). As you can see, it references a route we haven't created yet.

  • The supports method defined whether the current Request must be supported by our authenticator. This should happen only if the current route is the login route we've created before.

  • The authenticate method is the heart of the authenticator. It must return a Passport that will contain the user to authenticate.

  • The onAuthenticationSuccess defines our post-authentication process. We mostly want to redirect users to their homepage here.

  • The onAuthenticationFailure allows you to interact with authentication failures if you need to.

Now that our authenticator is all set up, let's make Symfony aware that it is in charge of authentication. Let's now open the config/packages/security.yaml file, and make it look like this :

security:
    providers:
        users:
            entity:
                class: 'App\Entity\User'
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            lazy: true
            provider: users
            custom_authenticators:
                - App\Security\DiscordAuthenticator
            logout:
                path: app_logout
                target: app_index
Enter fullscreen mode Exit fullscreen mode

Symfony is now aware that you are authenticating users using the User entity through the App\Security\DiscordAuthenticator authenticator.

BUT

We are not done yet 😄

Make the User entity authentication-friendly

Following Symfony's way to build authentication, we now have to make our User entity implement the UserInterface. It should now look like this :

#[ORM\Entity(repositoryClass: UserRepository::class)]
class User implements UserInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $discordId = null;

    // getters and setters

    public function getRoles(): array
    {
        // TODO: Implement getRoles() method.
    }

    public function eraseCredentials()
    {
        // TODO: Implement eraseCredentials() method.
    }

    public function getUserIdentifier(): string
    {
        // TODO: Implement getUserIdentifier() method.
    }
}
Enter fullscreen mode Exit fullscreen mode

This tutorial does not aim to dive into Symfony authentication. If you need more information about the User, click here.

Create the entrypoint

Going back to the authenticator, don't forget the entrypoint ! In order to set it up, let's go back to our DiscordController, and add the auth_discord_start route like so :

#[Route("/start", name: "start")]
public function start(ClientRegistry $clientRegistry): RedirectResponse
{
    return $clientRegistry->getClient("discord")->redirect(["identify"]);
}
Enter fullscreen mode Exit fullscreen mode

In this method, we are getting the client called discord from the registry. The name discord does not come out of nowhere. Indeed, this is the one we have set earlier in the YAML configuration of the KNP bundle. Don't forget to change the name if you did in the YAML.

After getting the right client, we call the redirect method in order to redirect the user to the Discord authentication portal. Note that we set the list of scopes to [identify]. It will allow the client to make requests to the /users/@me URL and gather users' information.

Now that this is all set up, you can now add a button to the UI that redirects to this URL. Once the user is on this URL, it gets redirected to the Discord authentication portal :

Discord OAuth2 portal

Well yes this is French that's not the point.

After the user clicks the "Autoriser" button, it should be redirected to the /auth/discord/login URL, with a little bonus in the form of a code query parameter. This code will be used in order to retrieve the access token, which is done automatically here.

While testing on our dev environement, after authenticating, we should be able to witness the authentication success using the Symfony Web debug toolbar :

Symfony web debug toolbar showing successfull authentication

If that's the case, good job, our full OAuth2 authentication process is done and successful !

Conclude with logging out

Hey ! Hold on a second ! Haven't we forgot something ? No worries, disconnecting users with Symfony is very easy. Indeed, you have already started configuring the way you want to log the user out !

In the config/packages/security.yaml file, we added that :

logout:
    path: app_logout
    target: app_index
Enter fullscreen mode Exit fullscreen mode

This part defines that our logout route name is app_logout, and that the user will be redirected to app_index once logged out.

All we need to do now is to declare the logout route :

namespace App\Controller;

use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Annotation\Route;

#[AsController]
final class SecurityController
{
    #[Route('/logout', name: 'app_logout')]
    public function logout()
    {
        throw new \Exception('Logging out');
    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Thank you for those who followed this tutorial all along. I hope that helped you, and that everything is clear. I could have gone deeper at some points, but you know, the tutorial must stay readable !

More importantly, I hope you got the point of OAuth2 ! In this tutorial, we are using Discord. Thankfully, they have a clean OAuth2 system that can help you get introduced to the protocol. Once you have understood the protocol, it is only a matter of implementation !

If you need to, go and check out the Github repository for this tutorial.

Thanks a lot again for reading my very first article. If you need more explaination on anything, let's meet in the comment section !

Happy coding, and don't forget that PHP is not dead !

Top comments (1)

Collapse
 
moondeer profile image
I.M.

Thanks for a good level of details and completeness!