Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

On Authentication Success

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

If you refresh the page and check the web debug toolbar, you can see that we're not logged in. Let's try using a real email and password. We can cheat by clicking the email and password links: this user exists in our AppFixtures, so it should work. And... okay... the boxes disappear! But nothing else happens. We'll improve that in a minute.

Thanks Session!

But for now, refresh the page and look at the web debug toolbar again. We're authenticated! Yea! Just by making a successful AJAX request to that login endpoint, that was enough to create the session and keep us logged in. Even better, if we started making requests to our API from JavaScript, those requests would be authenticated too. That's right! We don't need a fancy API token system where we attach a token to every request. We can just make a request and through the magic of cookies, that request will be authenticated.

REST and What Data to Return from our Authentication Endpoint?

So, logging in worked... but nothing happened on the page. What should we do after authentication? Once again, it doesn't really matter. If you're writing your auth system for your own JavaScript, you should do whatever is useful for your frontend. We're currently returning the user id. But we could, if we wanted, return the entire user object as JSON.

But there's one tiny problem with that. It's not super RESTful. This is one of those "REST purity" things. Every URL in your API, on a technical level, represents a different resource. This represents the collection resource, and this URL represents a single User resource. And if you have a different URL, that's understood to be a different resource. The point is that, in a perfect world, you would just return a User resource from a single URL instead of having five different endpoints to fetch a user.

If we return the User JSON from this endpoint, we're "technically" creating a new API resource. In fact, anything we return from this endpoint, from a REST point of view, becomes a new resource in our API. To be honest, this is all technical semantics and you should feel free to do whatever you want. But, I do have a fun suggestion.

Returning the IRI

To try be helpful to our frontend and somewhat RESTful, I have another idea. What if we return nothing from the endpoint.... but sneak the user's IRI onto the Location header of the response. Then, our frontend could use that to know who just logged in.

Let me show you. First, instead of returning the User ID, we're going to return the IRI, which will look something like '/api/users/'.$user->getId(). But I don't want to hard code that because we could potentially change the URL in the future. I'd rather have API Platform generate that for me.

And fortunately, API Platform gives us an autowireable service to do that! Before the optional argument, add a new argument type-hinted with IriConverterInterface and call it $iriConverter:

... lines 1 - 4
use ApiPlatform\Api\IriConverterInterface;
... lines 6 - 10
class SecurityController extends AbstractController
{
#[Route('/login', name: 'app_login', methods: ['POST'])]
public function login(IriConverterInterface $iriConverter, #[CurrentUser] $user = null): Response
{
... lines 16 - 24
}
}

Then, down here, return new Response() (the one from HttpFoundation) with no content and a 204 status code:

... lines 1 - 10
class SecurityController extends AbstractController
{
#[Route('/login', name: 'app_login', methods: ['POST'])]
public function login(IriConverterInterface $iriConverter, #[CurrentUser] $user = null): Response
{
... lines 16 - 21
return new Response(null, 204, [
... line 23
]);
}
}

The 204 means it was "successful... but there's no content to return". We'll also pass a Location header set to $iriConverter->getIriFromResource():

... lines 1 - 10
class SecurityController extends AbstractController
{
#[Route('/login', name: 'app_login', methods: ['POST'])]
public function login(IriConverterInterface $iriConverter, #[CurrentUser] $user = null): Response
{
... lines 16 - 21
return new Response(null, 204, [
'Location' => $iriConverter->getIriFromResource($user),
]);
}
}

So you can get the resource from an IRI or the IRI string from the resource, the resource being your object. Pass this $user.

Using the IRI in JavaScript

How nice is that? Now that we're returning this how can we use this in JavaScript? Ideally, after we log in, we would automatically show some user info over on the right. This area is built by another Vue file called TreasureConnectApp.vue:

<template>
<div class="purple flex flex-col min-h-screen">
... lines 3 - 5
<div class="flex-auto flex flex-col sm:flex-row justify-center px-8">
<LoginForm
v-on:user-authenticated="onUserAuthenticated"></LoginForm>
<div
class="book shadow-md rounded sm:ml-3 px-8 pt-8 pb-8 mb-4 sm:w-1/2 md:w-1/3 text-center">
<div v-if="user">
Authenticated as: <strong>{{ user.username }}</strong>
| <a href="/logout" class="underline">Log out</a>
</div>
<div v-else>Not authenticated</div>
... lines 17 - 20
</div>
</div>
... line 23
</div>
</template>
<script setup>
import { ref } from 'vue';
import LoginForm from '../LoginForm';
import coinLogoPath from '../../images/coinLogo.png';
import goldPilePath from '../../images/GoldPile.png';
defineProps(['entrypoint']);
const user = ref(null);
const onUserAuthenticated = async (userUri) => {
const response = await fetch(userUri);
user.value = await response.json();
}
</script>

I won't go into the details, but as long as that component has user data, it will print it out here. And LoginForm.vue is already set up to pass that user data to TreasureConnectApp.vue. Down at the bottom, after a successful authentication, this is where we clear the email and password state, which empties the boxes after we log in. If we emit an event called user-authenticated and pass it the userIri, TreasureConnectApp.vue is already set up to listen to this event. It will then make an AJAX request to userIri, get the JSON back, and populate its own data.

If you're not comfortable with Vue, that's ok. The point is that all we need to do is grab the IRI string from the Location header, emit this event, and everything should work.

To read the header, say const userIri = response.headers.get('Location'). I'll also uncomment this so we can emit it:

... lines 1 - 48
<script setup>
... lines 50 - 65
const handleSubmit = async () => {
... lines 67 - 89
email.value = '';
password.value = '';
const userIri = response.headers.get('Location');
emit('user-authenticated', userIri);
}
</script>

This should be good! Move over and refresh. The first thing I want you to notice is that we're still logged in, but our Vue app doesn't know that we're logged in. We're going to fix that in a minute. Log in again using our valid email and password. And... beautiful! We made the POST request, it returned the IRI and then our JavaScript made a second request to that IRI to fetch the user data, which it displayed here.

Next: Let's talk about what it means to log out of an API. Then, I'll show you a simple way of telling your JavaScript who is logged in on page load. Because, right now, even though we are logged in, as soon as I refresh, our JavaScript thinks we're not. Lame.

Leave a comment!

5
Login or Register to join the conversation
Sebastian-K Avatar
Sebastian-K Avatar Sebastian-K | posted 1 month ago | edited

Hi Ryan, it's me again. I have a problem:

I want to add conditions in my QueryExtension, you know where Project in (:allowedProjects). So far, so easy. I create the Extension, get the user from the Token, add some joins and conditions and done.
But I find is quite cumbersome to always add some nested joins to "get back" to my Project-User Mapping to know which projects my user has access to, so I tried a different approach:

1) A subscriber that happens right after authorization (In my case 'kernel.request' => [['addProjects', 8]]). Here, I get the User from the Token, fetch all my Projects from different sources (he can be the owner or was granted access by another way) and add them to the user

public function addProjects(RequestEvent $requestEvent): void
{
    if (!$requestEvent->isMainRequest()) return;
    
    $user = $this->security->getUser();
    // fetch projects
    $user->addProjects($projects); // NOT a Doctrine relation, just a list of entities
}

2) In my QueryExtension, I get the User from the Token and can simply where project in (:projects) and setParameter('projects', $user->getProjects()) and done

My Problem: The user in my Subscriber and the user in my Query ARE NOT THE SAME, event if it's only one request. I mean, it's the same email address, but a different object (used spl_object_hash), so the User in the QueryExtension has an empty projects list

In other words: How to modify the user that is used in the QueryExtension?

Reply

Hey @Sebastian-K!

Ah, that's an interesting solution! So the problem is that, with the priority 8 on your event listener, your listener is running before the security system, most importantly before the core ContextListener, whose job it is to take the User object from the previous request (which had been serialized into the session), grab its id, then query for a fresh User (to make sure the User object has the most up to date database data). So very good digging to figure out via spl_object_hash() that these are not the same objects. Actually, I think the core system also has a priority of 8, so it's probably just bad luck that your's is getting called too late. I'm guessing you chose those on purpose, for example, to be early enough in API Platform's process.

What I would do instead (and I would probably do this even if you didn't have this problem) is create some sort of UserContext service and give IT the addProjects() method. Then, autowire this service where you need it. That should solve the problem as you won't be trying to change the User object anymore. I like this solution better anyway because (though perhaps slightly less convenient), I always feel weird setting non-persisted data onto an entity... not there there is anything SO wrong about this.

Anyway, let me know if this helps!

Cheers!

Reply
Jean-tilapin Avatar
Jean-tilapin Avatar Jean-tilapin | posted 2 months ago | edited

Hello there!
I'm currently creating a login form just like yours and it works great, thanks. But I'm also trying to register my users through something similar, and I meet some difficulties to authenticate the new User after registration, due to the Json_login technic.
How could I do that, please?
For now, I've got this:

    #[Route('/register', name: 'app_register', methods: ['POST'])]
    public function register(Request $request, UserPasswordHasherInterface $userPasswordHasher, IriConverterInterface $iriConverter, EntityManagerInterface $entityManager, UserAuthenticatorInterface $userAuthenticator, JsonLoginAuthenticator $authenticator): Response
    {
        $user = new User();
        $temp = json_decode($request->getContent(), true); 
        $user->setRoles(["ROLE_USER"]);               
        $user->setEmail($temp['email']);
        $user->setPassword(
            $userPasswordHasher->hashPassword(
                $user,
                $temp['password']
            )
        );

        $entityManager->persist($user);
        $entityManager->flush();

        return $userAuthenticator->authenticateUser(
                $user,
                $authenticator,
                $request
            );
            

        /*return new Response(null, 204, [
            'Location' => $iriConverter->getIriFromResource($user),
        ]);*/            
    }

If I uncomment the last response (and obviously comment the previous one), it does work as long the new user doesn't refresh its page. And if I use $userAuthenticator, I have a 500 error:

Cannot autowire argument $authenticator of "App\Controller\RegistrationController::register()": it references class "Symfony\Component\Security\Http\Authenticator\JsonLoginAuthenticator" but no such service exists. You should maybe alias this class to the existing "security.authenticator.json_login.main" service.

What should I change to automatically Login the new User, please?

Thank you.

Reply
Jean-tilapin Avatar
Jean-tilapin Avatar Jean-tilapin | sadikoff | posted 2 months ago

Great, thank you!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^3.0", // v3.1.2
        "doctrine/annotations": "^2.0", // 2.0.1
        "doctrine/doctrine-bundle": "^2.8", // 2.8.3
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.14", // 2.14.1
        "nelmio/cors-bundle": "^2.2", // 2.2.0
        "nesbot/carbon": "^2.64", // 2.66.0
        "phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
        "phpstan/phpdoc-parser": "^1.15", // 1.16.1
        "symfony/asset": "6.2.*", // v6.2.5
        "symfony/console": "6.2.*", // v6.2.5
        "symfony/dotenv": "6.2.*", // v6.2.5
        "symfony/expression-language": "6.2.*", // v6.2.5
        "symfony/flex": "^2", // v2.2.4
        "symfony/framework-bundle": "6.2.*", // v6.2.5
        "symfony/property-access": "6.2.*", // v6.2.5
        "symfony/property-info": "6.2.*", // v6.2.5
        "symfony/runtime": "6.2.*", // v6.2.5
        "symfony/security-bundle": "6.2.*", // v6.2.6
        "symfony/serializer": "6.2.*", // v6.2.5
        "symfony/twig-bundle": "6.2.*", // v6.2.5
        "symfony/ux-react": "^2.6", // v2.7.1
        "symfony/ux-vue": "^2.7", // v2.7.1
        "symfony/validator": "6.2.*", // v6.2.5
        "symfony/webpack-encore-bundle": "^1.16", // v1.16.1
        "symfony/yaml": "6.2.*" // v6.2.5
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "mtdowling/jmespath.php": "^2.6", // 2.6.1
        "phpunit/phpunit": "^9.5", // 9.6.3
        "symfony/browser-kit": "6.2.*", // v6.2.5
        "symfony/css-selector": "6.2.*", // v6.2.5
        "symfony/debug-bundle": "6.2.*", // v6.2.5
        "symfony/maker-bundle": "^1.48", // v1.48.0
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/phpunit-bridge": "^6.2", // v6.2.5
        "symfony/stopwatch": "6.2.*", // v6.2.5
        "symfony/web-profiler-bundle": "6.2.*", // v6.2.5
        "zenstruck/browser": "^1.2", // v1.2.0
        "zenstruck/foundry": "^1.26" // v1.28.0
    }
}
userVoice