If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeEn la pantalla, vemos un dd()
de la contraseña que introduje en el formulario de acceso y el objeto de entidad User
para el correo electrónico que introduje. ¡Algo, de alguna manera, supo tomar el correo electrónico introducido y consultar por el Usuario!
Así es como funciona esto. Después de que devolvamos el objeto Passport
, el sistema de seguridad intenta encontrar el objeto User
a partir de UserBadge
. Si sólo le pasas un argumento a UserBadge
-como es nuestro caso-, lo hace aprovechando nuestro proveedor de usuarios. ¿Recuerdas esa cosa de security.yaml
llamada providers
?
security: | |
... lines 2 - 7 | |
providers: | |
# used to reload user from session & other features (e.g. switch_user) | |
app_user_provider: | |
entity: | |
class: App\Entity\User | |
property: email | |
... lines 14 - 34 |
Como nuestra clase User
es una entidad, estamos utilizando el proveedor entity
que sabe cómo cargar usuarios utilizando la propiedad email
. Así que, básicamente, se trata de un objeto que es muy bueno para consultar la tabla de usuarios a través de la propiedad email
. Así que cuando pasamos sólo el correo electrónico a UserBadge
, el proveedor de usuarios lo utiliza para consultar User
.
Si se encuentra un objeto User
, Symfony intenta entonces "comprobar las credenciales" de nuestro pasaporte. Como estamos utilizando CustomCredentials
, esto significa que ejecuta esta llamada de retorno... en la que volcamos algunos datos. Si no se encuentra un User
- porque hemos introducido un correo electrónico que no está en la base de datos - la autenticación falla. Pronto veremos más sobre estas dos situaciones.
En cualquier caso, la cuestión es la siguiente: si sólo pasas un argumento a UserBadge
, el proveedor de usuarios carga el usuario automáticamente. Eso es lo más fácil de hacer. E incluso puedes personalizar un poco esta consulta si lo necesitas - busca "Usar una consulta personalizada para cargar el usuario" en los documentos de Symfony para ver cómo hacerlo.
O... puedes escribir tu propia lógica personalizada para cargar el usuario aquí mismo. Para ello, vamos a necesitar el UserRepository
. En la parte superior de la clase, añadepublic function __construct()
... y autoconduce un argumento UserRepository
. PulsaréAlt
+Enter
y seleccionaré "Inicializar propiedades" para crear esa propiedad y establecerla:
... lines 1 - 5 | |
use App\Repository\UserRepository; | |
... lines 7 - 17 | |
class LoginFormAuthenticator extends AbstractAuthenticator | |
{ | |
private UserRepository $userRepository; | |
public function __construct(UserRepository $userRepository) | |
{ | |
$this->userRepository = $userRepository; | |
} | |
... lines 26 - 73 | |
} |
En authenticate()
, UserBadge
tiene un segundo argumento opcional llamado cargador de usuario. Pásale una llamada de retorno con un argumento: $userIdentifier
:
... lines 1 - 17 | |
class LoginFormAuthenticator extends AbstractAuthenticator | |
{ | |
... lines 20 - 31 | |
public function authenticate(Request $request): PassportInterface | |
{ | |
... lines 34 - 36 | |
return new Passport( | |
new UserBadge($email, function($userIdentifier) { | |
... lines 39 - 46 | |
}), | |
... lines 48 - 50 | |
); | |
} | |
... lines 53 - 73 | |
} |
Es bastante sencillo: si le pasas un callable, cuando Symfony cargue tu User
, llamará a esta función en lugar de a tu proveedor de usuario. Nuestro trabajo aquí es cargar el usuario y devolverlo. El $userIdentifier
será lo que hayamos pasado al primer argumento de UserBadge
... así que el email
en nuestro caso.
Digamos que $user = $this->userRepository->findOneBy()
para consultar email
se ajusta a$userIdentifier
:
... lines 1 - 17 | |
class LoginFormAuthenticator extends AbstractAuthenticator | |
{ | |
... lines 20 - 31 | |
public function authenticate(Request $request): PassportInterface | |
{ | |
... lines 34 - 36 | |
return new Passport( | |
new UserBadge($email, function($userIdentifier) { | |
// optionally pass a callback to load the User manually | |
$user = $this->userRepository->findOneBy(['email' => $userIdentifier]); | |
... lines 41 - 46 | |
}), | |
... lines 48 - 50 | |
); | |
} | |
... lines 53 - 73 | |
} |
Aquí es donde puedes utilizar cualquier consulta personalizada que quieras. Si no podemos encontrar al usuario, tenemos que lanzar una excepción especial. Así que si no es $user
,throw
new UserNotFoundException(). Eso hará que falle la autenticación. En la parte inferior, devuelve $user
:
... lines 1 - 17 | |
class LoginFormAuthenticator extends AbstractAuthenticator | |
{ | |
... lines 20 - 31 | |
public function authenticate(Request $request): PassportInterface | |
{ | |
... lines 34 - 36 | |
return new Passport( | |
new UserBadge($email, function($userIdentifier) { | |
// optionally pass a callback to load the User manually | |
$user = $this->userRepository->findOneBy(['email' => $userIdentifier]); | |
if (!$user) { | |
throw new UserNotFoundException(); | |
} | |
return $user; | |
}), | |
... lines 48 - 50 | |
); | |
} | |
... lines 53 - 73 | |
} |
Esto... es básicamente idéntico a lo que hacía nuestro proveedor de usuarios hace un minuto... así que no cambiará nada. Pero puedes ver que tenemos el poder de cargar elUser
como queramos.
Actualicemos. Sí El mismo volcado que antes.
Bien, si se encuentra un objeto User
- ya sea desde nuestro callback personalizado o desde el proveedor de usuarios - Symfony comprueba a continuación nuestras credenciales, lo que significa algo diferente dependiendo del objeto de credenciales que pases. Hay 3 principales:PasswordCredentials
- lo veremos más adelante, un SelfValidatingPassport
que sirve para la autenticación de la API y no necesita ninguna credencial - y CustomCredentials
.
Si usas CustomCredentials
, Symfony ejecuta la llamada de retorno... y nuestro trabajo es "comprobar sus credenciales"... sea lo que sea que eso signifique en nuestra aplicación. El argumento $credentials
coincidirá con lo que hayamos pasado al segundo argumento de CustomCredentials
. Para nosotros, eso es la contraseña enviada:
... lines 1 - 17 | |
class LoginFormAuthenticator extends AbstractAuthenticator | |
{ | |
... lines 20 - 31 | |
public function authenticate(Request $request): PassportInterface | |
{ | |
... lines 34 - 36 | |
return new Passport( | |
... lines 38 - 47 | |
new CustomCredentials(function($credentials, User $user) { | |
... line 49 | |
}, $password) | |
); | |
} | |
... lines 53 - 73 | |
} |
¡Imaginemos que todos los usuarios tienen la misma contraseña tada
! Para validarlo, devuelve true si $credentials === 'tada'
:
... lines 1 - 17 | |
class LoginFormAuthenticator extends AbstractAuthenticator | |
{ | |
... lines 20 - 31 | |
public function authenticate(Request $request): PassportInterface | |
{ | |
... lines 34 - 36 | |
return new Passport( | |
... lines 38 - 47 | |
new CustomCredentials(function($credentials, User $user) { | |
return $credentials === 'tada'; | |
}, $password) | |
); | |
} | |
... lines 53 - 73 | |
} |
¡Seguridad hermética!
Si devolvemos true
desde esta función, ¡la autenticación ha sido un éxito! ¡Vaya! Si devolvemos false
, la autenticación falla. Para comprobarlo, baja a onAuthenticationSuccess()
y dd('success')
. Haz lo mismo dentro de onAuthenticationFailure()
:
... lines 1 - 17 | |
class LoginFormAuthenticator extends AbstractAuthenticator | |
{ | |
... lines 20 - 53 | |
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response | |
{ | |
dd('success'); | |
} | |
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response | |
{ | |
dd('failure'); | |
} | |
... lines 63 - 73 | |
} |
Pronto pondremos código real en estos métodos... pero su propósito se explica por sí mismo: si la autenticación tiene éxito, Symfony llamará a onAuthenticationSuccess()
. Si la autenticación falla por cualquier motivo - como un correo electrónico o una contraseña no válidos - Symfony llamará a onAuthenticationFailure()
.
¡Vamos a probarlo! Vuelve directamente a /login
. Utiliza de nuevo el correo electrónico real -abraca_admin@example.com
con la contraseña correcta: tada
. Envía y... ¡sí! llamó a onAuthenticationSuccess()
. ¡La autenticación se ha completado!
Lo sé, todavía no parece gran cosa... así que a continuación, vamos a hacer algo en caso de éxito, como redirigir a otra página. También vamos a conocer el otro trabajo crítico de un proveedor de usuarios: refrescar el usuario de la sesión al principio de cada petición para mantenernos conectados.
Hey @Aaron Kincer!
You got it :). That's exactly what I would do. Let me know if it works out like you want!
Cheers!
Yes this seems like it's going to do what I want. Due to the bind function in the ldap component taking over on invalid credentials I wrote some lower level LDAP auth code using the built-in LDAP facilities so I could trap the invalid credentials myself and return false.
Another thing I did was have the UserBadge callback code create a new symfony user in the database if one didn't exist but the username DOES exist in LDAP so the user could be returned. Since LDAP is the user database registration didn't make sense really.
One oddity is the "Logged in as" on the debug toolbar shows nothing. I feel like I ran in to that once before and you might have even told me the solution. I've eaten and slept since then though. I'll figure it out.
I swear I searched for all instances of "email" in the User entity and didn't see this but nonetheless the answer was indeed simple -- changing the field getUserIdentifier() returns.
Ahh looks like the debug toolbar is determined to use email to identify who's logged on despite me configuring userId in security.yaml under app_user_provider as the identifying property. The getUsername function returns userId so I'm confused here as to why the profiler is determined to use email instead of userId. I'll keep looking.
Thanks for the help. Cheers to you!
Hey @Aaron Kincer!
Ha! Yes, nice job all around - including debugging :). Your setup makes sense to me - including the part of inserting a User if it's found in LDAP but not (yet) in your local database. That is the proper way to do it (not registration).
Cheers!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"babdev/pagerfanta-bundle": "^3.3", // v3.3.0
"composer/package-versions-deprecated": "^1.11", // 1.11.99.4
"doctrine/annotations": "^1.0", // 1.13.2
"doctrine/doctrine-bundle": "^2.1", // 2.6.3
"doctrine/doctrine-migrations-bundle": "^3.0", // 3.1.1
"doctrine/orm": "^2.7", // 2.10.1
"knplabs/knp-markdown-bundle": "^1.8", // 1.9.0
"knplabs/knp-time-bundle": "^1.11", // v1.16.1
"pagerfanta/doctrine-orm-adapter": "^3.3", // v3.3.0
"pagerfanta/twig": "^3.3", // v3.3.0
"phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
"scheb/2fa-bundle": "^5.12", // v5.12.1
"scheb/2fa-qr-code": "^5.12", // v5.12.1
"scheb/2fa-totp": "^5.12", // v5.12.1
"sensio/framework-extra-bundle": "^6.0", // v6.2.0
"stof/doctrine-extensions-bundle": "^1.4", // v1.6.0
"symfony/asset": "5.3.*", // v5.3.4
"symfony/console": "5.3.*", // v5.3.7
"symfony/dotenv": "5.3.*", // v5.3.8
"symfony/flex": "^1.3.1", // v1.17.5
"symfony/form": "5.3.*", // v5.3.8
"symfony/framework-bundle": "5.3.*", // v5.3.8
"symfony/monolog-bundle": "^3.0", // v3.7.0
"symfony/property-access": "5.3.*", // v5.3.8
"symfony/property-info": "5.3.*", // v5.3.8
"symfony/rate-limiter": "5.3.*", // v5.3.4
"symfony/runtime": "5.3.*", // v5.3.4
"symfony/security-bundle": "5.3.*", // v5.3.8
"symfony/serializer": "5.3.*", // v5.3.8
"symfony/stopwatch": "5.3.*", // v5.3.4
"symfony/twig-bundle": "5.3.*", // v5.3.4
"symfony/ux-chartjs": "^1.3", // v1.3.0
"symfony/validator": "5.3.*", // v5.3.8
"symfony/webpack-encore-bundle": "^1.7", // v1.12.0
"symfony/yaml": "5.3.*", // v5.3.6
"symfonycasts/verify-email-bundle": "^1.5", // v1.5.0
"twig/extra-bundle": "^2.12|^3.0", // v3.3.3
"twig/string-extra": "^3.3", // v3.3.3
"twig/twig": "^2.12|^3.0" // v3.3.3
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.0
"symfony/debug-bundle": "5.3.*", // v5.3.4
"symfony/maker-bundle": "^1.15", // v1.34.0
"symfony/var-dumper": "5.3.*", // v5.3.8
"symfony/web-profiler-bundle": "5.3.*", // v5.3.8
"zenstruck/foundry": "^1.1" // v1.13.3
}
}
If one wanted to use LDAP to do the password checking but otherwise have a normal symfony user (and not an LdapUser) would you do the password checking in the custom credentials? That's kind of what I took it to mean by "whatever that means in our app".