gstreamer0.10-ffmpeg
gstreamer0.10-plugins-good
packages.
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> |
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 | |
{ | |
'/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.
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 | |
{ | |
'/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 | |
{ | |
'/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.
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 |
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.
"Houston: no signs of life"
Start the conversation!
// 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
}
}