Autenticación de usuarios
Casi ninguna aplicación web puede prescindir de un mecanismo para iniciar y cerrar la sesión de los usuarios y para verificar sus permisos. En este capítulo hablaremos de:
- iniciar y cerrar la sesión de los usuarios
- autenticadores propios
En los ejemplos usaremos un objeto de la clase Nette\Security\User, que representa al usuario actual y
que obtiene haciendo que se lo pasen mediante dependency injection. En los presenters basta con
llamar a $user = $this->getUser().
Autenticación
Autenticación significa inicio de sesión del usuario, es decir, el proceso durante el cual se verifica la identidad
del usuario. El usuario suele identificarse con un nombre de usuario y una contraseña. La verificación la realiza el llamado Autenticador. Si el inicio de sesión falla, se lanza una
Nette\Security\AuthenticationException.
try {
$user->login($username, $password);
} catch (Nette\Security\AuthenticationException $e) {
$this->flashMessage('El nombre de usuario o la contraseña que ha introducido no son correctos.');
}
Así se cierra la sesión del usuario:
$user->logout();
Y para averiguar si el usuario tiene la sesión iniciada:
echo $user->isLoggedIn() ? 'sí' : 'no';
Muy sencillo, ¿verdad? Y de todos los aspectos de seguridad se encarga Nette por usted.
En los presenters puede verificar el inicio de sesión en el método startup() y redirigir a los usuarios sin
sesión iniciada a la página de inicio de sesión.
protected function startup()
{
parent::startup();
if (!$this->getUser()->isLoggedIn()) {
$this->redirect('Sign:in');
}
}
Expiración
La sesión del usuario caduca junto con la expiración del
almacenamiento, que suele ser la sesión (vea el ajuste de la expiración de la sesión). Pero también puede establecer un
intervalo de tiempo más corto tras el cual se cierra la sesión del usuario. Para eso sirve el método
setExpiration(), que se llama antes de login(). Pase como argumento una cadena con un tiempo
relativo:
// la sesión caduca tras 30 minutos de inactividad
$user->setExpiration('30 minutes');
// cancela la expiración establecida
$user->setExpiration(null);
El método $user->getLogoutReason() revela si la sesión del usuario se cerró porque expiró el intervalo de
tiempo. Devuelve o bien la constante Nette\Security\User::LogoutInactivity (expiró el límite de tiempo), o bien
User::LogoutManual (se llamó al método logout()).
Autenticador
Es un objeto que verifica las credenciales de inicio de sesión, normalmente el nombre de usuario y la contraseña. Una forma trivial es la clase Nette\Security\SimpleAuthenticator, que se puede definir en la configuración:
security:
users:
# nombre de usuario: contraseña
johndoe: 'secret123'
kathy: 'evenmoresecretpassword'
En lugar de las contraseñas en texto plano puede indicar también sus hashes; vea la configuración.
Esta solución es más adecuada para hacer pruebas. Le mostraremos cómo crear un autenticador que verifique las credenciales de inicio de sesión contra una tabla de la base de datos.
Un autenticador es un objeto que implementa la interfaz Nette\Security\Authenticator con el método
authenticate(). Su tarea es devolver una Identidad o lanzar una
Nette\Security\AuthenticationException. También sería posible indicar un código de error para distinguir la
situación con más finura: Authenticator::IdentityNotFound o Authenticator::InvalidCredential.
use Nette;
use Nette\Security\SimpleIdentity;
class MyAuthenticator implements Nette\Security\Authenticator
{
public function __construct(
private Nette\Database\Explorer $database,
private Nette\Security\Passwords $passwords,
) {
}
public function authenticate(string $username, string $password): SimpleIdentity
{
$row = $this->database->table('users')
->where('username', $username)
->fetch();
if (!$row) {
throw new Nette\Security\AuthenticationException('Usuario no encontrado.');
}
if (!$this->passwords->verify($password, $row->password)) {
throw new Nette\Security\AuthenticationException('Contraseña incorrecta.');
}
return new SimpleIdentity(
$row->id,
$row->role, // o un array de roles
['name' => $row->username],
);
}
}
La clase MyAuthenticator se comunica con la base de datos mediante Nette Database Explorer y trabaja con la tabla users, donde la
columna username contiene el nombre de inicio de sesión del usuario y la columna password contiene el
hash de la contraseña. Tras verificar el nombre y la contraseña,
devuelve la identidad, que contiene el ID del usuario, su rol (la columna role de la tabla), del que hablaremos más
más adelante, y un array con datos adicionales (en
nuestro caso, el nombre de usuario).
El autenticador lo añadiremos a la configuración como servicio del contenedor DI:
services:
- MyAuthenticator
Eventos $onLoggedIn, $onLoggedOut
El objeto Nette\Security\User tiene los eventos
$onLoggedIn y $onLoggedOut, así que puede añadir callbacks que se disparan tras un inicio de sesión
correcto o tras cerrar la sesión el usuario, respectivamente.
$user->onLoggedIn[] = function () {
// el usuario acaba de iniciar sesión
};
Identidad
Una identidad es un conjunto de información sobre el usuario que devuelve el autenticador y que después se guarda en la
sesión y se puede obtener con $user->getIdentity(). Eso nos permite obtener el ID, los roles y otros datos del
usuario, tal como los pasamos en el autenticador:
$user->getIdentity()->getId();
// también funciona el atajo $user->getId()
$user->getIdentity()->getRoles();
// los datos del usuario están accesibles como propiedades
// el nombre de usuario que pasamos en MyAuthenticator
$user->getIdentity()->name;
Lo importante es que, al cerrar la sesión con $user->logout(), la identidad no se borra y sigue estando
disponible. Así que, aunque un usuario tenga identidad, no tiene por qué tener la sesión iniciada. Si queremos borrar la
identidad explícitamente, cerramos la sesión del usuario llamando a logout(true).
Gracias a eso puede seguir suponiendo qué usuario está frente al ordenador y mostrar, por ejemplo, ofertas personalizadas en una tienda electrónica, pero su información personal solo se la puede mostrar después de que inicie sesión.
Además de borrar la identidad en cada llamada con logout(true), puede desactivar por
completo su conservación con la propiedad $persistIdentity. Cuando se establece a false, la identidad
se descarta en cada cierre de sesión y en la expiración, así que getIdentity() devuelve entonces
null. Conservar la identidad depende también del almacenamiento: el almacenamiento en cookie no puede conservarla
tras el cierre de sesión, porque siempre borra la cookie.
Una identidad es un objeto que implementa la interfaz Nette\Security\IIdentity. La implementación predeterminada es Nette\Security\SimpleIdentity. Y, como se ha dicho, se mantiene en la sesión, así que si, por ejemplo, cambiamos el rol de alguno de los usuarios con la sesión iniciada, los datos antiguos seguirán en su identidad hasta que vuelva a iniciar sesión.
Almacenamiento del usuario con sesión iniciada
Las dos informaciones básicas sobre el usuario, es decir, si tiene la sesión iniciada y su Identidad, se transmiten normalmente en la sesión. Lo cual se puede cambiar. De guardar esta
información se encarga un objeto que implementa la interfaz Nette\Security\UserStorage. Hay disponibles dos
implementaciones estándar: Nette\Bridges\SecurityHttp\SessionStorage, que transmite los datos en la sesión, y
CookieStorage, que los transmite en una cookie. El almacenamiento se puede elegir y configurar muy cómodamente en la
configuración security ›
authentication.
Además puede influir en cómo transcurren exactamente el guardado (sleep) y la restauración (wakeup) de la
identidad. Lo único que hace falta es que el autenticador implemente la interfaz Nette\Security\IdentityHandler. El
método sleepIdentity() se llama antes de escribir la identidad en el almacenamiento, y wakeupIdentity()
después de leerla. Estos métodos pueden modificar el contenido de la identidad o sustituirla por un objeto nuevo que devuelven.
El método wakeupIdentity() incluso puede devolver null, lo que cierra la sesión del usuario. La
interfaz declara además el método getGuestIdentity(), vea Identidad de
invitado.
Como ejemplo, mostremos la solución a la pregunta frecuente de cómo actualizar los roles de la identidad justo después de
cargarla de la sesión. En el método wakeupIdentity() pasamos a la identidad los roles actuales, p. ej. desde la
base de datos:
final class Authenticator implements
Nette\Security\Authenticator, Nette\Security\IdentityHandler
{
public function sleepIdentity(IIdentity $identity): IIdentity
{
// aquí se puede modificar la identidad antes de escribirla en el almacenamiento tras el inicio de sesión,
// pero ahora no lo necesitamos
return $identity;
}
public function wakeupIdentity(IIdentity $identity): ?IIdentity
{
// actualiza los roles de la identidad
$userId = $identity->getId();
$identity->setRoles($this->facade->getUserRoles($userId));
return $identity;
}
public function getGuestIdentity(): ?IIdentity
{
// aquí no se usa ninguna identidad de invitado
return null;
}
Volvamos ahora al almacenamiento basado en cookies. Permite crear una web donde los usuarios pueden iniciar sesión sin
necesitar sesiones. Así que no necesita escribir en el disco. Así funciona la web que está leyendo ahora mismo, incluido el
foro. En este caso, implementar IdentityHandler es una necesidad. En la cookie guardaremos solo un token aleatorio
que representa al usuario con la sesión iniciada.
Primero, establezca en la configuración el almacenamiento requerido con
security › authentication › storage: cookie.
En la base de datos, cree la columna authtoken, donde cada usuario tendrá una cadena completamente aleatoria, única e imposible de adivinar de longitud suficiente
(al menos 13 caracteres). CookieStorage transmite en la cookie solo el valor $identity->getId(),
así que en sleepIdentity() sustituimos la identidad original por una identidad proxy que contiene el
authtoken en el ID. Al revés, en el método wakeupIdentity() leemos de la base de datos toda la
identidad a partir del authtoken:
final class Authenticator implements
Nette\Security\Authenticator, Nette\Security\IdentityHandler
{
public function authenticate(string $username, string $password): SimpleIdentity
{
$row = $this->db->fetch('SELECT * FROM user WHERE username = ?', $username);
// verifica la contraseña
// ...
// devuelve la identidad con todos los datos de la base de datos
return new SimpleIdentity($row->id, null, (array) $row);
}
public function sleepIdentity(IIdentity $identity): SimpleIdentity
{
// devuelve una identidad proxy donde el ID contiene el authtoken
return new SimpleIdentity($identity->authtoken);
}
public function wakeupIdentity(IIdentity $identity): ?SimpleIdentity
{
// sustituye la identidad proxy por la identidad completa, como en authenticate()
$row = $this->db->fetch('SELECT * FROM user WHERE authtoken = ?', $identity->getId());
return $row
? new SimpleIdentity($row->id, null, (array) $row)
: null;
}
public function getGuestIdentity(): ?IIdentity
{
// aquí no se usa ninguna identidad de invitado
return null;
}
}
Identidad de invitado
A veces viene bien que los visitantes que no han iniciado sesión tengan también una identidad, por ejemplo para darles un
conjunto predeterminado de roles o algunos datos. Si el autenticador implementa IdentityHandler, puede
proporcionarla mediante el método getGuestIdentity(), que se usa siempre que nadie tiene la sesión iniciada.
Entonces getIdentity(), getId() y getRoles() recurren a ella, así que los invitados pueden
tener sus propios roles en lugar de solo el rol guest a secas. Devuelva null si no quiere una identidad
de invitado.
public function getGuestIdentity(): ?IIdentity
{
return new SimpleIdentity('guest', ['guest'], ['name' => 'Guest']);
}
La identidad de invitado no se guarda nunca en el almacenamiento, y el inicio de sesión siempre la sustituye.
Varios inicios de sesión independientes
Es posible tener varios usuarios independientes iniciando sesión a la vez dentro de una misma web y de una misma sesión. Por ejemplo, si queremos tener autenticación separada para la administración y para la parte pública de la web, basta con establecer para cada una un namespace único:
$user->getStorage()->setNamespace('backend');
Es importante acordarse de establecer el namespace siempre en todos los sitios que pertenecen a la parte correspondiente. Si usamos presenters, establecemos el namespace en el antecesor común de esa parte, normalmente BasePresenter. Lo hacemos ampliando el método checkRequirements():
public function checkRequirements($element): void
{
$this->getUser()->getStorage()->setNamespace('backend');
parent::checkRequirements($element);
}
Si cambia el namespace durante una misma petición (después de haber leído ya el estado de autenticación), el objeto
User sigue teniendo en caché el estado del namespace anterior. En ese caso, llame a refreshStorage()
para descartar la caché y forzar una recarga desde el nuevo namespace:
$user->getStorage()->setNamespace('admin');
$user->refreshStorage(); // recarga el estado desde el nuevo namespace
Varios autenticadores
Dividir una aplicación en partes con inicio de sesión independiente suele requerir además autenticadores distintos. Pero si
registrásemos en la configuración de servicios dos clases que implementan Authenticator, Nette no sabría cuál asignar
automáticamente al objeto Nette\Security\User y mostraría un error. Por eso tenemos que restringir el autowiring de los autenticadores para que funcione solo cuando
alguien pida una clase concreta, p. ej. FrontAuthenticator. Eso se consigue eligiendo
autowired: self:
services:
-
create: FrontAuthenticator
autowired: self
class SignPresenter extends Nette\Application\UI\Presenter
{
public function __construct(
private FrontAuthenticator $authenticator,
) {
}
}
El autenticador del objeto User lo establecemos antes de llamar al método login(), así que normalmente en el código del formulario que inicia la sesión:
$form->onSuccess[] = function (Form $form, \stdClass $data) {
$user = $this->getUser();
$user->setAuthenticator($this->authenticator);
$user->login($data->username, $data->password);
// ...
};