Search code examples
phpauthenticationoauth-2.0single-sign-onsymfony6

Authenticate local client from SSO remote Host


I may get some of my terminology wrong here but days of trial and error, googling, symfony docs, searching here and reading a variety of tutorials my head maybe a little fried.

What I am using:

  • PHP 8.1.0
  • Symfony lts (6)
  • Evelabs OAuth2 Provider for EveOnline
  • MySQL 5.7
  • Doctrine

Amongst other composer packages

What I am trying to do is redirect a user that wants to login to my site through an SSO (EveOnline) After being returned to my site, store relevant data and authenticate the user to access member only areas on my site.

In the future I will be making ESI (EveSwaggerInterface) Requests on behalf of the user.

At present I have the SSO working with OAuth2, I'm building the URL, redirecting to Eve, user logs in and is sent back to me, I then catch the response, populate/update the database or catch the error, finally redirect member area, all in my controller.


use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
use Evelabs\OAuth2\Client\Provider\EveOnline;
use Doctrine\Persistence\ManagerRegistry;
use App\Entity\User;

class SecurityController extends AbstractController
{
    #[Route('/login', name: 'page_login')]
    public function login_page()
    {
        return $this->render('login.html.twig');
    }

    #[Route('/redirect', name: 'app_login')]
    public function login(ManagerRegistry $doctrine)
    {
        session_start();

        $provider = new EveOnline([
            'clientId'          => $this->getParameter('eve.client_id'),
            'clientSecret'      => $this->getSecret,
            'redirectUri'       => $this->getParameter('eve.redirect_uri'),
        ]);
        if (!isset($_GET['code'])) {

            $options = [
                'scope' => ['publicData'] // array or string
            ];

            // If we don't have an authorization code then get one
            $authUrl = $provider->getAuthorizationUrl($options);
            $_SESSION['oauth2state'] = $provider->getState();
            unset($_SESSION['token']);
            header('Location: '.$authUrl);
            exit;

        // Check given state against previously stored one to mitigate CSRF attack
        } elseif (empty($_GET['state']) || ($_GET['state'] !== $_SESSION['oauth2state'])) {

            unset($_SESSION['oauth2state']);
            exit('Invalid state');

        } else{ // In this example we use php native $_SESSION as data store
            if(!isset($_SESSION['token']))
            {
                $_SESSION['token'] = $provider->getAccessToken('authorization_code', [
                    'code' => $_GET['code']
                ]);

            }elseif($_SESSION['token']->hasExpired()) {
                // This is how you refresh your access token once you have it
                $new_token = $provider->getAccessToken('refresh_token', [
                    'refresh_token' => $_SESSION['token']->getRefreshToken()
                ]);
                // Purge old access token and store new access token to your data store.
                $_SESSION['token'] = $new_token;
            }try{
                // Try to get an access token using the authorization code grant.
                $accessToken = $provider->getResourceOwner($_SESSION['token']);
                //Store eve user data
                $userdata = $accessToken->toArray();
                //Init Entity Manager
                $entityManager = $doctrine->getManager();
                //Get Repo
                $userobject = $doctrine->getRepository(User::class)->findBy(['userID' => $userdata['CharacterID']]);

                //If user exist, update database
                if ($accessToken && $userobject != null){
                    $id = $userobject[0]->getId();
                    $userobject[0]->setId($id);
                    $userobject[0]->setUserName($userdata['CharacterName']);
                    $userobject[0]->setUserID($userdata['CharacterID']);
                    $userobject[0]->setToken($_SESSION['token']->getToken());
                    $userobject[0]->setTokenRefresh($_SESSION['token']->getRefreshToken());
                    $userobject[0]->setExpiry($_SESSION['token']->getExpires());

                    $entityManager->flush();
                    //If user exists update tokens
                }elseif($accessToken != null){
                    $userobject = new User();
                    $userobject->setUserName($userdata['CharacterName']);
                    $userobject->setUserID($userdata['CharacterID']);
                    $userobject->setRoles(['ROLE_USER']);
                    $userobject->setToken($_SESSION['token']->getToken());
                    $userobject->setTokenRefresh($_SESSION['token']->getRefreshToken());
                    $userobject->setExpiry($_SESSION['token']->getExpires());

                    $entityManager->persist($userobject);
                    $entityManager->flush();
                }else{
                    print_r('You messed it all, go slap undo');
                    //TODO: Update this error with a real error
                }

            }catch (\League\OAuth2\Client\Provider\Exception\IdentityProviderException $e) {

                // Failed to get the access token or user details.
                exit($e->getMessage());
            }
        }
        return $this->redirectToRoute('page_success');
    }


    #[Route('/success', name: 'page_success')]
    public function success()
    {
        return $this->render('callback.html.twig');
    }

}

My issue is that I cant work out where or how within this to test if a user is logged in or set flags authenticating the user locally.

I have surmised that if there is a successfully return from the SSO with a payload the user is authenticated by the remote host, When I have managed to set authenticated flags I can move to make my auth process more secure by testing the refresh token that should confirm that its not a false payload && || cookies

my profiler shows the user is not authenticated and also that there is no session active at all even when calling session_start(); though i can dump($_SESSION['token']); with success

any help to get past this roadblock would be appreciated

Config\Security.yaml

    enable_authenticator_manager: true
    # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
    password_hashers:
        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
    # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
    providers:
        # used to reload user from session & other features (e.g. switch_user)
        app_user_provider:
            entity:
                class: App\Entity\User
                property: userID
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            lazy: true
            provider: app_user_provider

            # activate different ways to authenticate
            # https://symfony.com/doc/current/security.html#the-firewall

            # https://symfony.com/doc/current/security/impersonating_user.html
            # switch_user: true

    # Easy way to control access for large sections of your site
    # Note: Only the *first* access control that matches will be used
    access_control:
        # - { path: ^/admin, roles: ROLE_ADMIN }
        # - { path: ^/profile, roles: ROLE_USER }
#        - {path: ^/success, roles: ROLE_USER }

when@test:
    security:
        password_hashers:
            # By default, password hashers are resource intensive and take time. This is
            # important to generate secure password hashes. In tests however, secure hashes
            # are not important, waste resources and increase test times. The following
            # reduces the work factor to the lowest possible values.
            Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
                algorithm: auto
                cost: 4 # Lowest possible value for bcrypt
                time_cost: 3 # Lowest possible value for argon
                memory_cost: 10 # Lowest possible value for argon

Entity\User - Just in case


namespace App\Entity;

use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;

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

    #[ORM\Column(length: 180, unique: true)]
    private ?string $userID = null;

    #[ORM\Column]
    private array $roles = [];

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

    #[ORM\Column(length: 1000, nullable: true)]
    private ?string $token = null;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $tokenRefresh = null;

    #[ORM\Column(nullable: true)]
    private ?int $expiry = null;

    #[ORM\Column(length: 1000, nullable: true)]
    private ?string $scopes = null;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function setId(?int $id): self
    {
        $this->id = $id;

        return $this;
    }

    public function getUserID(): ?string
    {
        return $this->userID;
    }

    public function setUserID(string $userID): self
    {
        $this->userID = $userID;

        return $this;
    }

    /**
     * A visual identifier that represents this user.
     *
     * @see UserInterface
     */
    public function getUserIdentifier(): string
    {
        return (string) $this->userID;
    }

    /**
     * @deprecated since Symfony 5.3, use getUserIdentifier instead
     */
    public function getUsername(): string
    {
        return (string) $this->userID;
    }

    /**
     * @see UserInterface
     */
    public function getRoles(): array
    {
        $roles = $this->roles;
        // guarantee every user at least has ROLE_USER
        $roles[] = 'ROLE_USER';

        return array_unique($roles);
    }

    public function setRoles(array $roles): self
    {
        $this->roles = $roles;

        return $this;
    }

    /**
     * This method can be removed in Symfony 6.0 - is not needed for apps that do not check user passwords.
     *
     * @see PasswordAuthenticatedUserInterface
     */
    public function getPassword(): ?string
    {
        return null;
    }

    /**
     * This method can be removed in Symfony 6.0 - is not needed for apps that do not check user passwords.
     *
     * @see UserInterface
     */
    public function getSalt(): ?string
    {
        return null;
    }

    /**
     * @see UserInterface
     */
    public function eraseCredentials()
    {
        // If you store any temporary, sensitive data on the user, clear it here
        // $this->plainPassword = null;
    }

    public function setUserName(string $userName): self
    {
        $this->userName = $userName;

        return $this;
    }

    public function getToken(): ?string
    {
        return $this->token;
    }

    public function setToken(?string $token): self
    {
        $this->token = $token;

        return $this;
    }

    public function getTokenRefresh(): ?string
    {
        return $this->tokenRefresh;
    }

    public function setTokenRefresh(?string $tokenRefresh): self
    {
        $this->tokenRefresh = $tokenRefresh;

        return $this;
    }

    public function getExpiry(): ?int
    {
        return $this->expiry;
    }

    public function setExpiry(?int $expiry): self
    {
        $this->expiry = $expiry;

        return $this;
    }

    public function getScopes(): ?string
    {
        return $this->scopes;
    }

    public function setScopes(?string $scopes): self
    {
        $this->scopes = $scopes;

        return $this;
    }
}

I have had some experience with symfony firewalls back in v2 but i used a login form handled by my site for my site, but now I'm using 6.1 everything is different, as well as using a whole different type of provider, I cant call the default authenticator as I'm not using an email-password form.

do i have to build a custom authenticator to handle it? if so how. im not amazingly code smart so a layman explanation would be appreciated.


Solution

  • I had a revelation while working as a token expired.

    It seems,

    the Oauth2 provider checks the token expiry under the hood, or behind the scenes and passes a HTTP status (500) by default when a token expires and is not refreshed.

    the symfony 6 firewall is completely bypassed and handled in code elsewhere.

    any page I use session_start() becomes a "secure" page. if the session data has expired the Oauth provider throws an unauthorized Exception.

    this satisfies my authentication issues as any time I need a page to be considered members only I need only start a session, if session is invalid redirect user to login

    please correct me if I'm wrong.