Skip to content

Instantly share code, notes, and snippets.

@ThomasLandauer
Last active August 19, 2022 14:19
Show Gist options
  • Save ThomasLandauer/668d7353dc5794da62be4cec9e8091ab to your computer and use it in GitHub Desktop.
Save ThomasLandauer/668d7353dc5794da62be4cec9e8091ab to your computer and use it in GitHub Desktop.
Magic Link login with Symfony to secure just one route
# config/packages/security.yaml
security:
providers:
user_provider: # just an arbitrary name - you won't need it anywhere
entity:
class: App\Entity\User
property: token # the name of the property (i.e. column) of the entity with the magic link
firewalls:
dev: # disables authentication for Symfony's assets and the profiler
pattern: ^/(_(profiler|wdt)|assets)/
security: false
main: # name of your 'firewall' (i.e. login system)
anonymous: lazy # new feature in Symfony 4.4: https://symfony.com/blog/new-in-symfony-4-4-lazy-firewalls For Symfony <4.4, use `anonymous: ~`
logout:
path: /logout
success_handler: App\Controller\SecurityController
<?php
// src/Controller/SecurityController.php
namespace App\Controller;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Guard\GuardAuthenticatorHandler;
use Symfony\Component\Security\Http\Logout\LogoutSuccessHandlerInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Guard\Token\PostAuthenticationGuardToken;
use App\Entity\User;
class SecurityController extends AbstractController implements LogoutSuccessHandlerInterface
{
private $tokenStorage;
public function __construct(TokenStorageInterface $tokenStorage)
{
$this->tokenStorage = $tokenStorage;
}
/**
* @Route("/{token}", name="Login")
*/
public function login(Request $request, GuardAuthenticatorHandler $guardAuthenticatorHandler, string $token)
{
// TODO: verify the `$token` provided by the user
// If the token is wrong, return:
$response = $this->render(...);
$response->setStatusCode(Response::HTTP_UNAUTHORIZED);
return $response;
// If the token is correct, continue:
$this->getUser(); // only required, if you have `anonymous: lazy` in `config/packages/security.yaml`, see https://github.com/symfony/symfony/issues/36208
// Log the user in:
$postAuthenticationGuardToken = new PostAuthenticationGuardToken($user, 'main', $user->getRoles()); // 'main' is the name of your firewall in `security.yaml`
$guardAuthenticatorHandler->authenticateWithToken($postAuthenticationGuardToken, $request, 'main');
// redirect the user to have the "magic link" disappear from the browser's url bar:
$response = $this->redirectToRoute('SecretUserProfile', ['id' => $user->getId()], Response::HTTP_SEE_OTHER);
$response->headers->set('X-Robots-Tag', 'noindex, nofollow'); // https://developers.google.com/search/reference/robots_meta_tag?hl=en
return $response;
}
/**
* @Route("/logout", name="Logout", methods={"GET"})
*/
public function logout()
{
// can be empty, see https://symfony.com/doc/4.4/security.html#logging-out
}
public function onLogoutSuccess(Request $request)
{
// Workaround for https://github.com/symfony/symfony/issues/36227
if ($this->getUser())
{
$this->tokenStorage->setToken(null);
}
return $this->render(...);
}
}
@ThomasLandauer
Copy link
Author

ThomasLandauer commented Mar 28, 2020

A "magic link" is a password-less login system. The link looks something like www.example.com/jruv8bk3c743c8345asgbu. You send it to your users in an email, they click on the link, and they are logged in instantly.

If you have to secure just one route, the solution presented here is way easier than what the Symfony docs https://symfony.com/doc/current/security.html are telling you. However, it does not scale. So if you need to protect a large portion of your website (i.e. many routes/controllers) behind a login system, do follow the Symfony docs and create Authenticators, Handlers, Voters etc.

I created this in Symfony 4.4, but it probably also works in Symfony 3.

Besides the above two files, you need the "user" entity which implements Symfony\Component\Security\Core\User\UserInterface. The easiest way is to use Symfony's MakerBundle:

php bin/console make:user

The name of the security user class (e.g. User) [User]:
 > User

 Do you want to store user data in the database (via Doctrine)? (yes/no) [yes]:
 > yes

 Enter a property name that will be the unique "display" name for the user (e.g. email, username, uuid) [email]:
 > token

 Will this app need to hash/check user passwords? Choose No if passwords are not needed or will be checked/hashed by some other system (e.g. a single sign-on server).

 Does this app need to hash/check user passwords? (yes/no) [yes]:
 > no

In the crated class, you can simplify getRoles() to:

public function getRoles(): array
{
    return array('ROLE_USER');
}

Now you just need to add a few lines to your "SecretUserProfileController":

/**
 * @Route("/user/{id}", name="SecretUserProfile")
 */
public function profile(UserRepository $userRepository, int $id)
{
    // since the url of this profile page contains the user's ID, you need to protect it from bein guessed:
    $user = $this->getUser();
    if ( ! $user)
    {
        $response = $this->render(...);
        $response->setStatusCode(Response::HTTP_UNAUTHORIZED);
        return $response;
    }

    // it's important to use the logged in user's ID here (`$user->getId()`), not the ID in the url (`$id`):
    $userRepository->find($user->getId());
    // ...
}

To allow users to log out, just offer them a link to /logout.

Clickable links:

@ThomasLandauer
Copy link
Author

This is meanwhile obsolete, due to Symfony's "Authenticator-Based Security", introduced in Symfony 5.1: https://symfony.com/blog/new-in-symfony-5-1-updated-security-system

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment