Authentication with VueJs using Symfony and Api Platform – Part 2

Yesterday we saw how to secure our API with a Token and how to use the Token in VueJs. Today we will see how to authenticate a user in our application in a SPA context using VueJS.

Let’s proceed step by step.

1. Security with Symfony

A) Create User Entity

<?php

namespace App\Entity;

...

/**
 * @ApiResource(
 *     itemOperations={
 *          "get"={
 *             "path"="/users/{id}",
 *             "swagger_context"={
 *                 "tags"={"Account"}
 *             }
 *          }
 *     },
 *     collectionOperations={
 *         "post"={
 *             "path"="/users",
 *             "method"="POST",
 *             "swagger_context"={
 *                 "tags"={"Account"},
 *                 "summary"={"Create a new account"}
 *             }
 *         },
 *         "get"={
 *             "path"="/users",
 *             "method"="GET",
 *             "swagger_context"={
 *                 "tags"={"Account"}
 *             }
 *          }
 *     },
 * )
 *
 * @ORM\Entity(repositoryClass="App\Repository\UserAccountRepository")
 */
final class UserAccount implements UserInterface
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

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

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

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

    /**
     * @ORM\Column(type="json")
     */
    private $roles = [];

    /**
     * @var string The hashed password
     * @ORM\Column(type="string")
     */
    private $password;

    /**
     * @ORM\Column(type="string", unique=true, nullable=true)
     */
    private $apiToken;

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

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

    public function getEmail(): string
    {
        return (string)$this->email;
    }

    public function setEmail(string $email): self
    {
        $this->email = $email;

        return $this;
    }

    /**
     * @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;
    }

    /**
     * @see UserInterface
     */
    public function getPassword(): string
    {
        return (string)$this->password;
    }

    public function setPassword(string $password): self
    {
        $this->password = $password;

        return $this;
    }

    /**
     * @see UserInterface
     */
    public function getSalt()
    {
        // not needed when using the "bcrypt" algorithm in security.yaml
    }

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

    public function getApiToken(): ?string
    {
        return $this->apiToken;
    }

    public function setApiToken($apiToken): void
    {
        $this->apiToken = $apiToken;
    }

    public function getFirstName()
    {
        return $this->firstName;
    }

    public function setFirstName($firstName): void
    {
        $this->firstName = $firstName;
    }

    public function getLastName()
    {
        return $this->lastName;
    }

    public function setLastName($lastName): void
    {
        $this->lastName = $lastName;
    }
}

B) Create Authenticator

<?php

namespace App\Security;

...

class JsonAuthenticator extends AbstractFormLoginAuthenticator
{
    use TargetPathTrait;

    private $entityManager;
    private $router;
    private $csrfTokenManager;
    private $passwordEncoder;

    public function __construct(
        EntityManagerInterface $entityManager,
        RouterInterface $router,
        CsrfTokenManagerInterface $csrfTokenManager,
        UserPasswordEncoderInterface $passwordEncoder
    ) {
        $this->entityManager = $entityManager;
        $this->router = $router;
        $this->csrfTokenManager = $csrfTokenManager;
        $this->passwordEncoder = $passwordEncoder;
    }

    public function supports(Request $request)
    {
        return 'app_account' === $request->attributes->get('_route')
            && $request->isMethod('POST');
    }

    public function getCredentials(Request $request)
    {
        if ($request->getContentType() !== 'json') {
            throw new BadRequestHttpException();
        }

        $data = json_decode($request->getContent(), true);

        $credentials = [
            'email' => $data['email'],
            'password' => $data['password'],
            'csrf_token' => $data['_csrf_token'],
        ];

        $request->getSession()->set(
            Security::LAST_USERNAME,
            $credentials['email']
        );

        return $credentials;
    }

    public function getUser($credentials, UserProviderInterface $userProvider)
    {
        $token = new CsrfToken('authenticate', $credentials['csrf_token']);
        if (!$this->csrfTokenManager->isTokenValid($token)) {
            throw new InvalidCsrfTokenException();
        }

        $user = $this->entityManager->getRepository(UserAccount::class)->findOneBy(['email' => $credentials['email']]);

        if (!$user) {
            // fail authentication with a custom error
            throw new CustomUserMessageAuthenticationException('Email could not be found.');
        }

        return $user;
    }

    public function checkCredentials($credentials, UserInterface $user)
    {
        return $this->passwordEncoder->isPasswordValid($user, $credentials['password']);
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
    {
        return new JsonResponse($exception->getMessage());
    }

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

        return new JsonResponse('authenticated');
    }
}

C) Update Security configuration

Adding a new provider and a new firewall.

security:
    encoders:
        App\Entity\UserAccount:
            algorithm: bcrypt
            cost: 12
        App\Entity\UserApi:
            algorithm: bcrypt
            cost: 12

    providers:
        app_user_provider:
            entity:
                class: App\Entity\UserAccount
                property: email
        api_user_provider:
            entity:
                class: App\Entity\UserApi
                property: email
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        api:
            pattern: ^/api/
            stateless: true
            anonymous: true
            provider: api_user_provider
            json_login:
                check_path: /api/authentication_token
                username_path: email
                password_path: password
            guard:
                authenticators:
                    - App\Security\TokenAuthenticator
                    - App\Security\ApiJsonAuthenticator
                entry_point: App\Security\ApiJsonAuthenticator
        main:
            pattern: ^/
            anonymous: true
            provider: app_user_provider
            json_login:
                check_path: app_account
                username_path: email
                password_path: password
            guard:
                authenticators:
                    - App\Security\JsonAuthenticator
            logout:
                # The route name the user can go to in order to logout
                path: app_account_logout            

    # 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: '^/api/docs', roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: '^/api', roles: ROLE_API }

D) Create Login Controller

<?php declare(strict_types=1);

namespace App\Controller\Account;

...

final class Account extends AbstractController
{
    /**
     * @Route("/account", name="app_account")
     */
    public function __invoke(AuthenticationUtils $authenticationUtils): Response
    {
        // get the login error if there is one
        $error = $authenticationUtils->getLastAuthenticationError();
        // last username entered by the user
        $lastUsername = $authenticationUtils->getLastUsername();

        return $this->render('account/app.html.twig', [
            'last_username' => $lastUsername,
            'is_authenticated' => json_encode(!empty($this->getUser())),
            'error' => $error
        ]);
    }

    /**
     * @Route("/account/logout", name="app_account_logout", methods={"GET"})
     *
     * @throws \Exception
     */
    public function logout()
    {
        // controller can be blank: it will never be executed!
        throw new \Exception('Don\'t forget to activate logout in security.yaml');
    }
}

That’s all for this section. Now we need to take care of the following steps in order to authenticate the user with a VueJS application.

2. Integration with VueJs

A) Install Vue Router

$ ./node_modules/.bin/yarn add vue-router –save

B) Create Components

We will use two components, Login.vue that performs login and Home.vue that will show the personal data of an authenticated user.

Home.vue

Login.vue

If an user is already authenticated, or if the user has just logged in, the component redirects to the Home.

C) Create Routes

//assets/js/routes/routes.js
import Home from '../components/account/Home.vue'
import Login from '../components/account/Login.vue'

const routes = [
  { path: '/', component: Home, meta: { requiresAuth: true } },
  { path: '/login', component: Login, props: true }
]

export default routes

D) Create store

The store is in charge of sharing the status, in this case authenticated, with all the components.

//assets/js/store/authentication-store.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export const store = new Vuex.Store({
  state: {
    authenticated: false
  },
  mutations: {
    change (state, value) {
      state.authenticated = value
    }
  },
  getters: {
    isAuthenticated: state => {
      if (state.authenticated === 'true' || state.authenticated === true) {
        return true
      }

      return false
    }
  }
})

E) Create app.js

//assets/js/router-app.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import AppRouter from './AppRouter'
import routes from './routes/routes'
import { store } from './store/authentication-store'

Vue.use(VueRouter)

const router = new VueRouter({
  routes: routes
})

router.beforeEach((to, from, next) => {
  if (to.matched.some(record => record.meta.requiresAuth)) {
    console.log('route need authentication')
    if (store.getters.isAuthenticated !== false) {
      console.log('user is authenticated: ' + store.getters.isAuthenticated)
      next()
    } else {
      console.log('page should be redirect')
      next({
        path: '/login',
        query: { redirect: to.fullPath }
      })
    }
  } else {
    next()
  }
})

// eslint-disable-next-line no-unused-vars
var routeVm = new Vue({
  el: '#router-app',
  store,
  data: {
    csrf_token: '',
    last_email: ''
  },
  router,
  template: '<AppRouter v-bind:csrf_token="csrf_token" v-bind:last_email="last_email"/>',
  components: { AppRouter },
  beforeMount () {
    this.csrf_token = this.$el.attributes['data-token'].value
    this.last_email = this.$el.attributes['data-last-email'].value
    this.$store.commit('change', this.$el.attributes['data-is-authenticated'].value)
  }
})

Every time a route is requested, the app checks if the user hasn’t authenticated yet. If he is not logged in, the app will redirect him to the login page.
When the user logs in, he will be automatically redirected to the home.

Be aware that the controller provides an is_authenticated parameter to the view, that is used from the app to initialize the store.
From this point on, the front office app will handle everything about authentication.

E) Update Webpack.config.js

var Encore = require('@symfony/webpack-encore')

Encore
  .setOutputPath('public/build/')
  .setPublicPath('/build')
  .cleanupOutputBeforeBuild()
  .enableSingleRuntimeChunk()
  .enableSassLoader()
  .enableSourceMaps(!Encore.isProduction())
  .enableVersioning(Encore.isProduction())
  ...
  .addEntry('router-app', './assets/js/router-app.js') 
  ...
  ...
  module.exports = Encore.getWebpackConfig()

Et voilà, you can test the result at: https://localhost:8443/account

Conclusions

In my opinion, an important part in the modern web applications is authentication. It enables organizations to keep their networks secure by allowing only authenticated users (or processes) to access its protected resources, which may include computer systems, networks, databases, websites and other network-based applications or services.

In this post we saw how easy it is to implement different types of authentication with Symfony and Api Platform. For the purpose of the article we have used some basic examples. But complex authentication can also be implemented, such as the famous double factor authentication.
We also saw how to implement the process in a front office using VueJs but the logic remains the same if you decide to use another front library such as ReactJs.

I hope this quick tutorial can help you. The sample code used in this post is freely available on GitHub in authentication branch.

A big thank you to Abel Hadjadj, my colleague at France Television, for taking time to review the two posts.

One thought on “Authentication with VueJs using Symfony and Api Platform – Part 2

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s