lexik/LexikJWTAuthenticationBundle

Stateless CSRF protection -- "Synchronizer Token Pattern"

Opened this issue · 1 comments

Hi there,

I'm implementing astateless CSRF protection. This is a pattern listed by Owasp and implemented by Angular using the X-XSRF-TOKEN.

To do so, I've been using three events,

  1. onJWTCreated: I generate a random value and add it to the payload of the token. I'm currently using Symfony security token generator but let's say it's the string oyo42 for the sake of the example.
  2. onAuthenticationSuccessResponse: I'm using this to add the randomly generated value to the response; currently to the data but I could add a Set-Cookieheader.
  3. onJWTDecoded: when the request method is POST (primary focus), PUT or DELETE, I'm making some verifications. The front-end is sending me the JWT as a cookie and the header X-XSRF-TOKEN containing the oyo42.

The problem is the onAuthenticationSuccessResponse does not have access to the payload of the token, only the JWT as a string and the user. I inspected the code, but I did not see any way of making it available,

My current workaround is to store the payload in my listener;

class JWTListener
{
    /**
     * @var array
     */
    private $payloads = [];

    // [...]

    /**
     * @param JWTCreatedEvent $event
     *
     * @return void
     */
    public function onJWTCreated(JWTCreatedEvent $event)
    {
        $payload = $event->getData();

        // CSRF protection.
        $payload['csrf_token'] = 'oyo42';
        $this->setPayload($this->getPayloadKey($event->getUser()), $payload);

        // Set the data to the event;
        $event->setData($payload);

        return;
    }

    /**
     * @param AuthenticationSuccessEvent $event
     */
    public function onAuthenticationSuccessResponse(AuthenticationSuccessEvent $event)
    {
        $data = $event->getData();

        $data['csrf_token'] = $this->getPayload($this->getPayloadKey($event->getUser()))['csrf_token'];

        $event->setData($data);
    }

    /**
     * @param JWTDecodedEvent $event
     *
     * @return void
     */
    public function onJWTDecoded(JWTDecodedEvent $event)
    {
        $request = $this->requestStack->getCurrentRequest();
        $payload = $event->getPayload();

        if (!in_array($request->getMethod(), ['POST', 'PUT', 'DELETE'])) {
            return;
        }

        $csrfTokenHeaderNames = [
            'CSRF-Token',
            'X-XSRF-TOKEN', // Angular support.
        ];
        $csrfToken = null;
        foreach ($csrfTokenHeaderNames as $headerName) {
            if ($request->headers->has($headerName)) {
                $csrfToken = $request->headers->get($headerName);
            }
        }

        if (!$csrfToken || ($csrfToken && (string)$csrfToken !== (string)$payload['csrf_token'])) {
            $event->markAsInvalid();
        }

        return;
    }

    protected function setPayload($payloadKey, $payload)
    {
        return $this->payloads[$payloadKey] = $payload;
    }

    protected function getPayload($payloadKey)
    {
        return $this->payloads[$payloadKey];
    }

    protected function getPayloadKey($user)
    {
        return sha1($user->getId() . '@' . $user->getActiveChannel()->getId());
    }
}

Working but not elegant at all. Forgive the one-liners too in the snippet above, its a demo/proof-of-concept.

Idea 1:
I feel that the create() method is doing two things; creating the token and encoding it; if there were two methods: create() returning an object and the encode() returning a string like today.

Do you feel it could be something interesting to implement in this bundle ?

Idea 2:
Another way I did not experimented would be this article, splitting the token in two cookies ({header}.{payload} and {signature}) and have a listener before the guard authenticator to concatenate the two which seems quite clever to me!

Thanks a lot, 🙇‍♂️

Hello !

You can decode the token in the onAuthenticationSuccessResponse listener by getting the JWTEncoder service and using its decode() method as you can see below:

class JWTListener
{
    private $decoder;

    public function __construct( JWTEncoderInterface $decoder )
    {
        $this->decoder = $decoder;
    }

    public function onAuthenticationSuccessResponse(AuthenticationSuccessEvent $event)
    {
        $data = $event->getData();

        $payload = $this->decoder->decode( $data[ 'token' ] );
    }
}

Hope it helped ;)