[Symfony Security] Implements OAuth login using Guard

Symfony Security

Whatever authentication is performed, it is essentially a process of searching user class through credentials. Whether it is a traditional form (username/password) login or an API token, credentials are usually stored in the request object (header, body, query, etc.), so they are extracted from the request object. The process of finding user credentials is called Authentication.

Symfony Security Security components are composed of four sub-components, which are independent of each other and can be installed selectively.

assembly describe
security-core Provide basic security functions such as user password encryption to authentication and authorization.
security-guard An Abstract authentication layer for creating complex authentication systems.
security-http Integrate security components with HTTP protocol to handle security requests and responses.
security-csrf Provide authentication and protection for cross-domain Request Forgery (CSRF).

Use Symfony Security It needs to be pre-installed. Since security-bundle has integrated all the security components, it only needs to be installed:

$ composer require symfony/security-bundle

Implementing Github login

To achieve Github login, you first need to create an application in Settings - > Developer settings - > OAuth Apps - > New OAuth App, and set the Authorization callback URL. After successful creation, you will be assigned a Client ID and Client Secret to copy this parameter to the project. Environment variable file:

# .env.local

GITHUB_CLIENT_ID=YOUR_CLIENT_ID
GITHUB_CLIENT_SECRET=YOUR_CLIENT_SECRET

Create a class for centrally managing Github API access services, which we use http-client Components act as HTTP clients:

// ./src/OAuth/Github.php

<?php

namespace App\OAuth;

use Symfony\Contracts\HttpClient\HttpClientInterface;

class Github
{
    const AUTHORIZE_URL = 'https://github.com/login/oauth/authorize';
    const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';
    const USER_URL = 'https://api.github.com/user';

    private $client;
    private $clientId;
    private $clientSecret;

    public function __construct(HttpClientInterface $client, string $clientId, string $clientSecret)
    {
        $this->client = $client;
        $this->clientId = $clientId;
        $this->clientSecret = $clientSecret;
    }

    public function getAuthorizeUrl(string $redirectUri, string $state = null): string
    {
        $query = [
            'client_id' => $this->clientId,
            'redirect_uri' => $redirectUri,
            'state' => $state,
        ];

        return self::AUTHORIZE_URL.'?'.http_build_query($query);
    }

    public function getAccessToken(string $code): array
    {
        $options = [
            'body' => [
                'client_id' => $this->clientId,
                'client_secret' => $this->clientSecret,
                'code' => $code,
            ],
            'headers' => [
                'Accept' => 'application/json',
            ],
        ];

        $response = $this->client->request('POST', self::ACCESS_TOKEN_URL, $options);

        $data = $response->toArray();
        if (isset($data['error'])) {
            throw new \RuntimeException(sprintf('%s (%s)', $data['error_description'], $data['error']));
        }

        return $data;
    }

    public function getUser(string $accessToken): array
    {
        $options = [
            'headers' => [
                'Authorization' => sprintf('token %s', $accessToken),
            ],
        ];

        $response = $this->client->request('GET', self::USER_URL, $options);

        $data = $response->toArray();
        if (isset($data['error'])) {
            throw new \RuntimeException(sprintf('%s (%s)', $data['error_description'], $data['error']));
        }

        return $data;
    }
}

Configure environment variables to services:

# ./config/services.yaml

services:
    # ...
    App\OAuth\Github:
        $clientId: '%env(GITHUB_CLIENT_ID)%'
        $clientSecret: '%env(GITHUB_CLIENT_SECRET)%'

To create user class, user object must inherit UserInterface.

// ./src/Entity/User.php

class User implements UserInterface
{
    // ...

    /**
     * @ORM\Column(type="string", length=255, unique=true)
     */
    private $username;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $nickname;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $avatar;

    /**
     * @ORM\Column(type="datetime", nullable=true)
     */
    private $updatedAt;

    /**
     * @ORM\Column(type="datetime_immutable")
     */
    private $createdAt;

    // ...
}

Create a controller and jump to Github for authentication:

// ./src/Controller/SecurityController.php

class SecurityController extends AbstractController
{
    /**
     * @Route("/login/oauth/github", name="login_oauth_github")
     *
     * @param Request $request
     * @param Github  $client
     *
     * @return Response
     */
    public function loginWithGithub(Request $request, Github $client)
    {
        // Random state string to prevent CSRF attacks
        $state = bin2hex(random_bytes(8));

        $session = $request->getSession();
        $session->set(GithubAuthenticator::STATE, $state);

        // Generate a callback address, which is the Authorization callback URL, to be filled in on Github
        $callback = $this->generateUrl('login_oauth_github_callback', [], 0);
        $redirect = $client->getAuthorizeUrl($callback, $state);

        return $this->redirect($redirect);
    }

    /**
     * @Route("/login/oauth/github/callback", name="login_oauth_github_callback")
     *
     * Just define the routing, and the routing doesn't do anything because it will be intercepted by Guard.
     */
    public function loginWithGithubCallback()
    {
        // nothing todo...
    }
}

Create a Guard interceptor to implement the authentication process:

// ./src/Security/GithubAuthenticator.php

class GithubAuthenticator extends AbstractGuardAuthenticator
{
    use TargetPathTrait;

    const STATE = '_github_oauth_state';

    private $entityManager;
    private $httpUtils;
    private $github;

    public function __construct(EntityManagerInterface $entityManager, HttpUtils $httpUtils, Github $github)
    {
        $this->entityManager = $entityManager;
        $this->httpUtils = $httpUtils;
        $this->github = $github;
    }

    /**
     * Each request enters this method, where irrelevant requests need to be filtered and can be skipped by returning false.
     */
    public function supports(Request $request)
    {
        // Filter requests, intercept only the callback address, Github in the callback address brings back the code
        return $this->httpUtils->checkRequestPath($request, 'login_oauth_github_callback')
            && $request->query->has('code');
    }

    /**
     * If matched to support, this method is called to retrieve credentials from the request and to getUser
     */
    public function getCredentials(Request $request)
    {
        // Verify state to prevent CSRF attacks
        $state = $request->query->get('state');
        if ($state !== $request->getSession()->get(self::STATE)) {
            throw new CustomUserMessageAuthenticationException('Bad authentication state.');
        }

        return $request->query->get('code');
    }

    /**
     * The credentials obtained from getCredentials are found and returned to the user, and authentication fails if NULL is returned or an exception is thrown.
     * If the User Interface is returned, check Credentials is entered.
     */
    public function getUser($credentials, UserProviderInterface $userProvider)
    {
        // credentials is the data returned by getCredentials
        $token = $this->github->getAccessToken($credentials);

        try {
            $user = $this->github->getUser($token['access_token']);
        } catch (\Throwable $th) {
            // ...
        }

        try {
            // If you find a user to return directly, go to the next step
            $entity = $userProvider->loadUserByUsername($user['login']);
        } catch (UsernameNotFoundException $e) {
            // If you log in for the first time, you need to store it in the database
            $entity = new User();
            $entity->setUsername($user['login']);
            $entity->setNickname($user['name']);
            $entity->setAvatar($user['avatar_url']);
            $entity->setCreatedAt(new \DateTimeImmutable());

            $this->entityManager->persist($entity);
            $this->entityManager->flush();
        }

        return $entity;
    }

    /**
     * OAuth Authentication does not require checking whether the credentials are correct or not
     */
    public function checkCredentials($credentials, UserInterface $user)
    {
        return true;
    }

    /**
     * This method will be invoked if any step of authentication fails
     */
    public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
    {
        throw new \RuntimeException($exception->getMessage());
    }

    /**
     * After authentication is successful, this method is called to jump to the previous page.
     */
    public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
    {
        // getTargetPath returns the page address where the user stayed before authentication, which is provided by TargetPathTrait.
        $targetPath = $this->getTargetPath($request->getSession(), $providerKey);

        if (!$targetPath) {
            $targetPath = $this->httpUtils->generateUri($request, 'app_index');
        }

        return $this->httpUtils->createRedirectResponse($request, $targetPath);
    }

    /**
     * If the access_control segment is configured in secruity.yaml, enter this method when user privileges are insufficient, otherwise it will not be executed.
     */
    public function start(Request $request, AuthenticationException $authException = null)
    {
        return $this->httpUtils->createRedirectResponse($request, 'app_login');
    }

    /**
     * Whether to enable "automatic login" function, OAuth authentication does not support this function
     */
    public function supportsRememberMe()
    {
        return false;
    }
}

Configure Guard Interceptor:

# ./config/packages/security.yarml

security:
    # ...

    # Define user loader
    providers:
        entity_provider:
            entity: { class: App\Entity\User, property: username }

    # Define firewall rules
    firewalls:
        # ...

        # Firewall area, you can define the name at will
        my_area:
            # Loader uses entity_provider
            provider: entity_provider
            # The interceptor uses Github Authenticator
            guard:
                authenticators:
                    - App\Security\GithubAuthenticator

Now, just put a link to the route login_oauth_github where you log in and use Github to log in:

<a href="{{ path('login_oauth_github') }}">Github Sign in</a>

Configuration parameter reference

Following is a description of common security configuration parameters.

# config/packages/security.yaml

security:
    # User password encryption mode, which determines how the user password will be encrypted
    encoders:
        # The appropriate encryption mode is determined by the system
        App\Entity\FooUser: auto
        # Encrypted by sodium, preferable range: plaintext, pbkdf2, bcrypt, argon2i, native, sodium
        App\Entity\BarUser: sodium

    # User loader, which determines where the user loads from
    providers:
        # Hardcoded user loader
        my_memory_provider:
            memory:
                users:
                    # A user called foo with the ROLE_READER role
                    foo: { password: foo_password, roles: ROLE_READER }
                    # Another user, called bar, has the ROLE_EDITOR role
                    bar: { password: bar_password, roles: ROLE_EDITOR }
        # Entity user loader (loaded from database)
        my_entity_provider:
            entity: { class: App\Entity\AcmeUser, property: username }
        # Custom user loader. UserProviderInterface interface must be implemented
        my_custom_provider:
            id: App\Security\MyCustomProvider

    # Access area, which can be configured with multiple access areas, such as / api and / api/user will be matched to / api first
    firewalls:
        # Requests starting from / api will match to zone_a, which is provided by my_entity_provider
        zone_a:
            pattern: ^/api
            provider: my_entity_provider
        # Starting with / admin, and host's request for admin.com will match to the area_b region, which is provided by my_custom_provider for users
        zone_b:
            pattern: ^/admin
            host: admin.com
            provider: my_custom_provider

    # Access control, can configure multiple access areas, multiple areas have a sequence
    access_control:
        # Requests starting from / api must contain ROLE_API or ROLE_USER roles
        - { path: ^/api, roles: [ROLE_API, ROLE_USER] }
        # Requests starting with / admin must contain ROLE_ADMIN roles
        - { path: ^/admin, roles: ROLE_ADMIN }

    # User Role Level
    role_hierarchy:
        # Users with ROLE_API roles will have both ROLE_READER and ROLE_EDITOR permissions
        ROLE_API: [ROLE_READER, ROLE_EDITOR]
        # Users with ROLE_ADMIN roles will also have ROLE_ADMIN_ARTICLE, ROLE_ADMIN_COMMENT privileges
        ROLE_ADMIN: [ROLE_ADMIN_ARTICLE, ROLE_ADMIN_COMMENT]
        

Tags: PHP github Session Database

Posted on Tue, 10 Sep 2019 03:31:35 -0700 by KiwiDave