Vue.js and Symfony — User Authentication
Authenticating users in Symfony — with Vue.js as frontend framework.
Join the DZone community and get the full member experience.
Join For FreeIn this article I will skip the Symfony authentication process as this can be found in the official documentation (it’s more about presenting the solutions in case of using Vue.js):
JWT Token-based Authentication — Does It Have to Be This Way?
Vue.js allows us to either create a SPA (Single-Page Application) or to be used in the form of hybrid where we can inject the components into already existing code or use Vue.js as an extension of current frontend code (In which case I learned the hard way that it can become very messy — You can find more here) — as for both cases the authentication can be implemented/solved in different ways.
Personally, I didn’t even intend to test both solutions, as the hybrid one works very well — but — I’ve already had a chance to see some interesting/confusing authentication in Symfony by using JWT to validate user on Angular based frontend (in this case the solution/end goal was different than simple login form).
In general, the JWT solution does work, makes everything it should, there is no problem — so what’s the point? Since Vue.js is the first modern JS framework used on my own alongside with backend framework I wondered:
“Do I really need to use some tokens, as the Symfony auth no longer works the way it should? Is it always like this in modern JS?”
A long story short — it doesn’t need to be this way, there is actually no magical, special token required in the authentication process — although the full SPA does require special logic, it’s still 'almost' standard Symfony logic.
The Easiest Solution — Hybrid Authentication
In this case, the login form works is the only one page that is fully rendered by Twig — no JS logic is used here, thus a normal authentication process is being used.
1. Authentication Page — simple Twig based template with a login route.
{% extends 'user/base.twig' %} {# A base template to extend from #}
{% block content %}
{% include 'user/components/login-form.twig' %} {# Standrad symfony generated form #}
{# Links #}
{% set rowStyle = "text-align: center;display: flex;justify-content: center; height: 25px; margin-top: 10px;"%}
{% set linkStyle = "text-decoration: none; color: #4a5073;text-align: center; " %}
<div class="row" style="{{ rowStyle }}">
<p>
<i class="fab fa-github" aria-hidden="true"></i>
<a href="https://github.com/Volmarg/notifier-proxy-logger" style="{{ linkStyle }}" target="_blank"> {{ 'pages.login.githubProject' | trans }}</a>
</p>
</div>
{% endblock %}
2. Authentication Route — route to return the login page.
x
/**
* @param AuthenticationUtils $authenticationUtils
* @return Response
*/
#[Route("/login", name: "login", methods: ["GET", "POST"])]
public function login(AuthenticationUtils $authenticationUtils): Response
{
$userLoginForm = $this->application->getForms()->getUserLoginForm();
$error = $authenticationUtils->getLastAuthenticationError();
$errorMessage = "";
if( !empty($error) ){
$errorMessage = $error->getMessage();
}
$templateData = [
'errorMessage' => $errorMessage,
'userLoginForm' => $userLoginForm->createView(),
];
return $this->render("user/login/login.twig", $templateData);
}
Adding Vue.js Routes to Symfony
Personally, I’ve got a very simple solution for this, which is defining one method (with base twig template for it — it contains the mounting #id for Vue-App):
1. Handler in backend.
xxxxxxxxxx
namespace App\Action;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* This class contains the global actions defined for Vue calls
*
* Class BaseAction
* @package App\Action
*/
class BaseVueAction extends AbstractController
{
#[Route("/modules/mailing/overview", name: "modules_mailing_overview", methods: ["GET"])]
#[Route("/modules/mailing/history", name: "modules_mailing_history", methods: ["GET"])]
#[Route("/modules/mailing/settings", name: "modules_mailing_settings", methods: ["GET"])]
#[Route("/modules/dashboard/overview", name: "modules_dashboard_overview", methods: ["GET"])]
#[Route("/modules/discord/history", name: "modules_discord_history", methods: ["GET"])]
#[Route("/modules/discord/manage-webhooks", name: "modules_discord_manage_webhooks", methods: ["GET"])]
#[Route("/modules/discord/test-sending", name: "modules_discord_test_sending", methods: ["GET"])]
public function renderBaseTemplate(): Response
{
return $this->render('base.twig');
}
}
2. Twig with mounting #id
x
<!DOCTYPE html>
<html lang="en">
<head>
{% include 'head.twig' %}
</head>
<body>
<div id="app"></div> <!-- Vue.js is initialized for this id-->
<section>
{% include 'footer.twig' %}
</section>
</body>
</html>
With this — if we open the SPA page new tab it will still work as it’s just a template, which later in the process gets rebuilt/controlled by Vue.js.
3. Mounting Vue.js
xxxxxxxxxx
const app = Vue.createApp({
template: `
<Sidebar/>
<Main/>
`,
components: {
Sidebar,
Main
}
})
// add plugins
app.use(router.getRouter());
app.use(VueAxios, axios);
// Mount the main app to the DOM
app.mount('#app');
The Tricky Solution — API Call Authentication
In this solution everything is handled in Vue.js besides the usage of base Twig template (just scroll up to 'Adding Vue.js Routes to Symfony').
1. Login Authenticator (backend)
So what’s so special in this authenticator?
The response — that’s the important thing, it’s a JsonResponse (BaseApiDTO
) consumed in the frontend for each call.
xxxxxxxxxx
namespace App\Service\Security;
use App\Controller\Core\Form;
use App\Controller\Core\Services;
use App\Controller\UserController;
use App\DTO\BaseApiDTO;
use App\DTO\Internal\Form\Security\LoginFormDataDTO;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Guard\AbstractGuardAuthenticator;
use Symfony\Component\Security\Guard\Token\PostAuthenticationGuardToken;
/**
* Handles the authentication for API called by VUE
* @link https://symfony.com/doc/current/security/guard_authentication.html#the-guard-authenticator-methods
*
* Class VueApiLoginAuthenticator
* @package App\Service\Security
*/
class VueApiLoginAuthenticator extends AbstractGuardAuthenticator
{
/**
* @var EntityManagerInterface $em
*/
private EntityManagerInterface $em;
/**
* @var Services $services
*/
private Services $services;
/**
* @var Form $form
*/
private Form $form;
/**
* @var UserController $userController
*/
private UserController $userController;
/**
* @var UrlGeneratorInterface $urlGenerator
*/
private UrlGeneratorInterface $urlGenerator;
/**
* @var TokenStorageInterface $tokenStorage
*/
private TokenStorageInterface $tokenStorage;
public function __construct(
EntityManagerInterface $em,
Services $services,
Form $form,
UserController $userController,
UrlGeneratorInterface $urlGenerator,
TokenStorageInterface $tokenStorage
)
{
$this->userController = $userController;
$this->tokenStorage = $tokenStorage;
$this->urlGenerator = $urlGenerator;
$this->services = $services;
$this->form = $form;
$this->em = $em;
}
/**
* Called on every request to decide if this authenticator should be
* used for the request. Returning `false` will cause this authenticator
* to be skipped.
*/
public function supports(Request $request): bool
{
// check if is logged in, if yes then support = false
if(
$request->getMethod() === Request::METHOD_POST
&& $request->getRequestUri() === $this->urlGenerator->generate("login")
){
return true;
}
return false;
}
/**
* @param UserInterface $user
* @param string $providerKey
* @return PostAuthenticationGuardToken
*/
public function createAuthenticatedToken(UserInterface $user, string $providerKey): PostAuthenticationGuardToken
{
return parent::createAuthenticatedToken($user, $providerKey);
}
/**
* Called on every request. Return whatever credentials you want to
* be passed to getUser() as $credentials.
*
* @throws Exception
*/
public function getCredentials(Request $request)
{
$loginForm = $this->services->getFormService()->handlePostFormForAxiosCall($this->form->getLoginForm(), $request);
if( $loginForm->isSubmitted() ) {
/**@var LoginFormDataDTO $loginFormData */
$loginFormData = $loginForm->getData();
return $loginFormData;
}
return new LoginFormDataDTO();
}
/**
* @param LoginFormDataDTO $credentials
* @param UserProviderInterface $userProvider
* @return UserInterface|null
*/
public function getUser(mixed $credentials, UserProviderInterface $userProvider): ?UserInterface
{
if( empty($credentials->getUsername()) ){
$this->services->getLoggerService()->getLogger()->warning("Username is empty");
return null;
}
$user = $this->userController->getOneByUsername($credentials->getUsername());
if( empty($user) ){
$this->services->getLoggerService()->getLogger()->warning("No user was found for give username. ", [
"username" => $credentials->getUsername(),
]);
return null;
}
return $user;
}
/**
* @param LoginFormDataDTO $credentials
* @param UserInterface $user
* @return bool
*/
public function checkCredentials($credentials, UserInterface $user): bool
{
$validationResult = $this->services->getValidationService()->validateAndReturnArrayOfInvalidFieldsWithMessages($credentials);
if( !$validationResult->isSuccess() ){
$this->services->getLoggerService()->getLogger()->warning("Could not log-in, there are login form violations", [
"violations" => $validationResult->getViolationsWithMessages(),
]);
return false;
}
$isPasswordValid = $this->services->getUserSecurityService()->validatePasswordForUser($credentials->getPassword(), $user);
if(!$isPasswordValid){
$this->services->getLoggerService()->getLogger()->warning("Provided password is invalid");
return false;
}
// Return `true` to cause authentication success
return true;
}
/**
* @param Request $request
* @param TokenInterface $token
* @param string $providerKey
* @return JsonResponse
*/
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $providerKey): JsonResponse
{
$message = $this->services->getTranslator()->trans('security.login.messages.OK');
return BaseApiDTO::buildRedirectResponse('modules_dashboard_overview', $message)->toJsonResponse();
}
/**
* @param Request $request
* @param AuthenticationException $exception
* @return JsonResponse|null
*/
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?JsonResponse
{
$message = $this->services->getTranslator()->trans('security.login.messages.UNAUTHORIZED');
return BaseApiDTO::buildUnauthorizedResponse($message)->toJsonResponse();
}
/**
* Called when authentication is needed, but it's not sent
*/
public function start(Request $request, AuthenticationException $authException = null): Response
{
if( !$request->isXmlHttpRequest() ){
return new RedirectResponse($this->urlGenerator->generate('login'));
}
$message = $this->services->getTranslator()->trans('security.login.messages.UNAUTHORIZED');
$response = BaseApiDTO::buildUnauthorizedResponse($message);
$response->setRedirectRoute("login");;
return $response->toJsonResponse();
}
/**
* @return bool
*/
public function supportsRememberMe(): bool
{
return false;
}
}
2. Handling call in Vue.js (frontend)
xxxxxxxxxx
<!-- Template -->
<template>
<main class="d-flex w-100 h-100">
<div class="container d-flex flex-column">
<div class="row vh-100">
<div class="col-sm-10 col-md-8 col-lg-6 mx-auto d-table h-100">
<div class="d-table-cell align-middle">
<div class="text-center mt-4">
<h1 class="h2">{{ trans('pages.security.login.form.header.main') }}</h1>
<p class="lead">
{{ trans('pages.security.login.form.header.sub') }}
</p>
</div>
<div class="card">
<div class="card-body">
<div class="m-sm-4">
<form>
<div class="mb-3">
<label class="form-label">{{ trans('pages.security.login.form.inputs.username.label') }}</label>
<input class="form-control form-control-lg"
type="text"
name="username"
:placeholder="trans('pages.security.login.form.inputs.username.placeholder')"
ref="usernameInput"
@keypress.enter="loginFormSubmitted"
>
</div>
<div class="mb-3">
<label class="form-label">{{ trans('pages.security.login.form.inputs.password.label') }}</label>
<input class="form-control form-control-lg"
type="password"
name="password"
:placeholder="trans('pages.security.login.form.inputs.password.placeholder')"
ref="passwordInput"
@keypress.enter="loginFormSubmitted"
>
</div>
<div class="text-center mt-3">
<a href="#" class="btn btn-lg btn-primary" @click="loginFormSubmitted">{{ trans('pages.security.login.form.buttons.login') }}</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</template>
<!-- Script -->
<script>
import SymfonyRoutes from "../../../../scripts/core/symfony/SymfonyRoutes";
import ToastifyService from "../../../../scripts/libs/toastify/ToastifyService";
import TranslationsService from "../../../../scripts/core/service/TranslationsService";
import StringUtils from "../../../../scripts/core/utils/StringUtils";
import SpinnerService from "../../../../scripts/core/service/SpinnerService";
import LoggedInUserDataDto from "../../../../scripts/core/dto/LoggedInUserDataDto";
import LocalStorageService from "../../../../scripts/core/service/LocalStorageService";
let translationService = new TranslationsService();
export default {
methods: {
/**
* @description handles the login form submission
*/
loginFormSubmitted(){
let data = {
username : this.$refs.usernameInput.value ?? "",
password : this.$refs.passwordInput.value ?? "",
}
/** @var BaseApiDto baseApiResponse */
SpinnerService.showSpinner();
this.postWithCsrf(SymfonyRoutes.getPathForName(SymfonyRoutes.ROUTE_NAME_LOGIN), data).then( (baseApiResponse) => {
SpinnerService.hideSpinner();
if(baseApiResponse.success){
ToastifyService.showGreenNotification(baseApiResponse.message)
if( StringUtils.isEmptyString(baseApiResponse.data.redirectRouteName) ){
ToastifyService.showRedNotification(translationService.getTranslationForString('general.responseCodes.500'))
return;
}
// this must be handled without Vue as the login page contains different base component (blank)
location.href = SymfonyRoutes.getPathForName(baseApiResponse.data.redirectRouteName);
}else{
ToastifyService.showRedNotification(baseApiResponse.message);
}
}).catch( (response) => {
SpinnerService.hideSpinner();
ToastifyService.showRedNotification(translationService.getTranslationForString('general.responseCodes.500'))
console.warn(response);
});
},
/**
* @description will check if user should be able to access the login page
* - if not logged in then yes,
* - if logged in then go to dashboard,
*/
checkLoginPageAccessAttempt(){
SpinnerService.showSpinner();
// check if user is logged in, and if yes then go to dashboard
this.axios.get(SymfonyRoutes.getPathForName(SymfonyRoutes.ROUTE_NAME_GET_LOGGED_IN_USER_DATA)).then( response => {
let loggedInUserDataDto = LoggedInUserDataDto.fromAxiosResponse(response);
if(
!loggedInUserDataDto.success
&& 401 !== loggedInUserDataDto.code
){
SpinnerService.hideSpinner();
ToastifyService.showRedNotification(translationService.getTranslationForString('general.responseCodes.500'))
return;
}
if(loggedInUserDataDto.loggedIn){
// already logged in and tries to enter login page
if( LocalStorageService.isLoggedInUserSet() ){
ToastifyService.showOrangeNotification(translationService.getTranslationForString('security.login.messages.alreadyLoggedIn'))
}else{
LocalStorageService.setLoggedInUser(loggedInUserDataDto);
}
// let user read the message
setTimeout(() => {
// this must be handled without Vue as the login page contains different base component (blank)
location.href = SymfonyRoutes.getPathForName(SymfonyRoutes.ROUTE_NAME_MODULE_DASHBOARD_OVERVIEW);
}, 1000)
return;
}
SpinnerService.hideSpinner();
}).catch( (response) => {
SpinnerService.hideSpinner();
ToastifyService.showRedNotification(translationService.getTranslationForString('general.responseCodes.500'))
console.warn(response);
});
}
},
beforeMount(){
this.checkLoginPageAccessAttempt();
}
}
</script>
3. The response
4. Logout user
xxxxxxxxxx
/**
* Will return the @see LoggedInUserDataDto as the json
* @return JsonResponse
*/
#[Route("/invalidate-user", name:"invalidate_user", methods: [Request::METHOD_POST])]
#[InternalActionAttribute]
public function invalidateUser(): JsonResponse
{
try{
$this->userController->invalidateUser();
}catch(Exception $e){
$this->services->getLoggerService()->logException($e);
return BaseApiDTO::buildInternalServerErrorResponse()->toJsonResponse();
}
return BaseApiDTO::buildOkResponse()->toJsonResponse();
}
Invalidate user
xxxxxxxxxx
namespace App\Controller;
use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
/**
* Class UserController
* @package App\Controller
*/
class UserController extends AbstractController
{
/**
* @var UserRepository $userRepository
*/
private UserRepository $userRepository;
/**
* @var TokenStorageInterface $tokenStorage
*/
private TokenStorageInterface $tokenStorage;
public function __construct(UserRepository $userRepository, TokenStorageInterface $tokenStorage)
{
$this->tokenStorage = $tokenStorage;
$this->userRepository = $userRepository;
}
/**
* Will invalidate currently logged in user by cleaning up token
*/
public function invalidateUser(): void
{
$this->tokenStorage->setToken(null);
}
}
Summarizing
Both solutions work like a charm, but if I was to choose the faster one — then it’s definitely the Hybrid-one.
Do keep in mind — that for both solutions, it’s required to send the JsonResponse to the front, so it’s a good idea to use some BaseResponse which will always consist of user authentication status.
Published at DZone with permission of Dariusz Włodarczyk. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments