Search code examples
authenticationsymfonysession-cookies

How to adapt Passwordless Login Link Authentication to headless architecture?


I am implementing an api using a symfony backend and for authentication I use the built in library for passwordless authentication described here https://symfony.com/doc/current/security/login_link.html#1-configure-the-login-link-authenticator

I only implemented the login endpoint, login_check is handled by the library.

What login does is only generate the link and send it to the user on his mailbox like below:

<?php

namespace App\Controller\Api;

use App\Entity\User;
use Psr\Log\LoggerInterface;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;

class AuthenticationController extends BaseController
{

    public function __construct(
        LoggerInterface $logger,
        protected MailerInterface $mailer,
        protected EntityManagerInterface $entityManager
    ) {
        parent::__construct($logger);
    }

    #[Route(path: '/login', name: 'login', methods: ['POST'])]
    public function login(
        LoginLinkHandlerInterface $loginLinkHandler,
        UserRepository $userRepository,
        Request $request
    ): Response {
        // load the user in some way (e.g. using the form input)
        $email = $request->getPayload()->get('email');

        if (!$this->isValidEmail($email)) {
            throw new BadRequestHttpException("Email $email is not valid!");
        }

        $user = $userRepository->findOneBy(['email' => $email]);

        $emailTitle = "Your beligent.io Login link.";
        if (!$user) {
            $user = new User();
            $user->setEmail($email);
            $this->entityManager->persist($user);
            $this->entityManager->flush();
            $this->logger->info("User {$user->getEmail()} created!");
            $emailTitle = "Successfully registered to beligent.io, your login link.";
        }
        // create a login link for $user this returns an instance
        // of LoginLinkDetails
        $loginLinkDetails = $loginLinkHandler->createLoginLink($user);
        $loginLink = $loginLinkDetails->getUrl();

        $regex = '/^https?:\/\/[^\/]+(\/.*)$/';

        if (!preg_match($regex, $loginLink, $matches)) {
            throw new \Exception("Could not decode login link");
        }

        $feBaseURL = getenv("FRONTEND_URL");
        $customLoginLink = $feBaseURL . $matches[1];

        $email = (new Email())
            ->from('[email protected]')
            ->to($user->getEmail())
            ->subject($emailTitle)
            ->text('Myapp.io')
            ->html("<p>$customLoginLink</p>");

        $this->mailer->send($email);

        return new Response(null, Response::HTTP_NO_CONTENT);
    }

    private function isValidEmail(string $email)
    {
        $emailPattern = '/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/';
        return preg_match($emailPattern, $email);
    }
}

Then I receive this link in my email:

localhost:8888/[email protected]&expires=1718457559&hash=at3wXVy9CFjNZauoIs7Yds85pH4HBQplPUDznaEm1H0~D60nAF03Qti0aU2B2Z5nVMOl_evP1uYHUVXRtHzgea0~

My config is as follows:

security.yaml:

security:
  # 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: email
  firewalls:
    dev:
      pattern: ^/(_(profiler|wdt)|css|images|js)/
      security: false
    main:
      lazy: true
      provider: app_user_provider
      login_link:
        check_route: login_check
        signature_properties: ["id"]
        lifetime: 3600
        success_handler: App\Security\Authentication\AuthenticationSuccessHandler

      # 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: ^/login, roles: PUBLIC_ACCESS }
    - { path: ^/login_check, roles: PUBLIC_ACCESS }
    - { path: ^/doc, roles: PUBLIC_ACCESS }
    - { path: ^/, roles: IS_AUTHENTICATED_FULLY }

And I also use nelmio_cors bundle whose config is below:

nelmio_cors.yaml

nelmio_cors:
    defaults:
        origin_regex: true
        allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
        allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
        allow_headers: ['Content-Type', 'Authorization']
        allow_credentials: true
        expose_headers: ['Link']
        max_age: 3600
    paths:
        '^/': null

And frameworks.yml:

framework:
  secret: "%env(APP_SECRET)%"
  #csrf_protection: true

  # Note that the session will be started ONLY if you read or write from it.
  session:
    cookie_samesite: "none"
    cookie_secure: true

Everything works well when testing with postman but when testing with a browser this one doesn't store the PHPSESSID cookie despite the fact that Set-Cookie is contained in the server response. I'm using chrome and when opening the Application tab in debug mode I don't see any cookie stored.

Here is the server response when calling login_check:

HTTP/1.1 200 OK
Server: nginx/1.22.1
Content-Type: application/json
Transfer-Encoding: chunked
Connection: keep-alive
X-Powered-By: PHP/8.2.20
Cache-Control: max-age=0, must-revalidate, private
Date: Sat, 15 Jun 2024 12:32:13 GMT
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Expose-Headers: link
X-Robots-Tag: noindex
Expires: Sat, 15 Jun 2024 12:32:13 GMT
Set-Cookie: PHPSESSID=4f43da26e66330f7b41cf749a5306a93; path=/; httponly; samesite=lax

Set-Cookie is received, but I cannot figure out why this cookie is not stored. For now I might be wrong but I think it might come from the implicit login_check endpoint that may mess up things in headers as it is thought for monolith architectures, I know how to change this in the config but I don't know what logic there is behind that.

Basically, the question is what would be the most obvious reason why a browser does not store the session cookie?

Symfony version: 7 PHP version: 8.2


Solution

  • It was revealed in the comments-section that the OP was making a fetch request (indirectly, via Axios) which did not have credentials: 'include' specified, which means browsers will default to not including Cookies in any outgoing requests, nor persisting any new cookies in any Set-Cookie response headers.

    ...though it is disappointing that their browser didn't warn them of this fact.