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('noreply@myapp.io')
->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/login_check?user=my@email.com&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
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.