Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

API Login Form with json_login

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.

On the homepage, which is built in Vue, we have a login form. The goal is that, when we submit this, it will send an AJAX request with the email & password to and endpoint that will validate it.

The form itself is built over here in assets/vue/LoginForm.vue:

<template>
<form
v-on:submit.prevent="handleSubmit"
class="book shadow-md rounded px-8 pt-6 pb-8 mb-4 sm:w-1/2 md:w-1/3"
>
... lines 6 - 45
</form>
</template>
<script setup>
import { ref } from 'vue';
... lines 52 - 95
</script>

If you're not familiar with Vue, don't worry. We will do some light coding in it, but I'm mostly using it as an example to make some API requests.

Down near the bottom, on submit, we make a POST request to /login sending the email and password as JSON. So our first goal is to create this endpoint:

... lines 1 - 48
<script setup>
... lines 50 - 65
const handleSubmit = async () => {
... lines 67 - 69
const response = await fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: email.value,
password: password.value
})
});
... lines 80 - 93
}
</script>

Creating the Login Controller

Fortunately, Symfony has a built-in mechanism just for this. To start, even though it won't do much, we need a new controller! In src/Controller/, create a new PHP class. Let's call it SecurityController. This will look very traditional: extend AbstractController, then add a public function login() that will return a Response, the one from HttpFoundation:

... lines 1 - 2
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
... lines 7 - 8
class SecurityController extends AbstractController
{
... line 11
public function login(): Response
{
}
}

Above, give this a Route with a URL of /login to match what our JavaScript is sending to. Name the route app_login. Oh, and we don't really need to do this, but we can also add methods: ['POST']:

... lines 1 - 6
use Symfony\Component\Routing\Annotation\Route;
class SecurityController extends AbstractController
{
#[Route('/login', name: 'app_login', methods: ['POST'])]
public function login(): Response
{
... line 14
}
}

There won't be a /login page on our site that we make a GET request to: we're only going to POST to this URL.

Returning the Current User Id

As you'll see in a minute, we're not going to process the email and password in this controller... but this will be executed after a successful login. So... what should we return after a successful login? I don't know! And honestly it mostly depends on what would be useful in our JavaScript. I haven't thought about it much yet, but maybe... the user id? Let's start there.

If authentication was successful, then, at this point, the user will be logged in like normal. To get the currently-authenticated user, I'm going to leverage a newer feature of Symfony. Add an argument with a PHP attribute called #[CurrentUser]. Then we can use the normal User type-hint, call it $user and default it to null, in case we're not logged in for some reason:

... lines 1 - 7
use Symfony\Component\Security\Http\Attribute\CurrentUser;
class SecurityController extends AbstractController
{
#[Route('/login', name: 'app_login', methods: ['POST'])]
public function login(#[CurrentUser] $user = null): Response
{
... lines 15 - 17
}
}

We'll talk about how that's possible in a minute.

Then, return $this->json() with a user key set to $user->getId():

... lines 1 - 9
class SecurityController extends AbstractController
{
#[Route('/login', name: 'app_login', methods: ['POST'])]
public function login(#[CurrentUser] $user = null): Response
{
return $this->json([
'user' => $user ? $user->getId() : null,
]);
}
}

Cool! And that's all we need our controller to do.

Activating json_login

To activate the system that will do the real work of reading the email & password, head to config/packages/security.yaml. Under the firewall, add json_login and below that check_path... which should be set to the name of the route that we just created. So, app_login:

security:
... lines 2 - 11
firewalls:
... lines 13 - 15
main:
... lines 17 - 18
json_login:
check_path: app_login
... lines 21 - 46

This activates a security listener: it's a bit of code that will now be watching every request to see if it is a POST request to this route. So, a POST to /login. If it is, it will decode the JSON on that request, read the email and password keys off of that JSON, validate the password and log us in.

Though, we do need to tell it what keys in the JSON we're using. Our JavaScript is sending email and password: super creative. So below this, set username_path to email and password_path to password:

security:
... lines 2 - 11
firewalls:
... lines 13 - 15
main:
... lines 17 - 18
json_login:
check_path: app_login
username_path: email
password_path: password
... lines 23 - 48

The User Provider

Done! But wait! If we POST an email and password to this endpoint... how the heck does the system know how to find that user? How is it supposed to know that it should query the user table WHERE email = the email from the request?

Excellent question! In episode 1, we ran:

php ./bin/console make:user

This created a User entity with the basic security stuff that we need:

... lines 1 - 38
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 41 - 43
private ?int $id = null;
... lines 45 - 49
private ?string $email = null;
... lines 51 - 52
private array $roles = [];
... lines 54 - 59
private ?string $password = null;
... lines 61 - 64
private ?string $username = null;
... lines 66 - 187
}

In security.yaml, it also created a user provider:

security:
... lines 2 - 4
# 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
... lines 12 - 48

This is an entity provider: it tells the security system to find users in the database by querying by the email property. This means our system will decode the JSON, fetch the email key, query for a User with a matching email, then validate the password. In other words... we're ready!

Looking back at LoginForm.vue, the JavaScript is also ready: handleSubmit() will be called when we submit the form... and it makes the AJAX call:

... lines 1 - 48
<script setup>
... lines 50 - 65
const handleSubmit = async () => {
isLoading.value = true;
error.value = '';
const response = await fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: email.value,
password: password.value
})
});
isLoading.value = false;
if (!response.ok) {
const data = await response.json();
console.log(data);
// TODO: set error
return;
}
email.value = '';
password.value = '';
//emit('user-authenticated', userIri);
}
</script>

So let's try this thing! Move over and refresh just to be sure. Try it with a fake email and password first. Submit and... nothing happened? Open up your browser's inspector and go to the console. Yes! You see a 401 status code and it dumped this error: invalid credentials. That's coming from right here in our JavaScript: after the request finishes, if the response is "not okay" - meaning there was a 4XX or 5XX status code - we decode the JSON and log it.

Apparently, when we fail authentication with json_login, it returns a small bit of JSON with "Invalid Credentials".

Next: let's turn this error into something we can see on the form, handle another error case, and then think about what to do when authentication is successful.

Leave a comment!

0
Login or Register to join the conversation
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