3.5. Autenticación mediante ACL
En esta sección, vamos a poner en práctica muchas de las cosas que hemos visto en este tema mediante un caso práctico realista y completamente resuelto.
Completamente resuelto pero mejorable, porque una aplicación informática siempre se puede mejorar.
De hecho, en la sección de “Ejercicios propuestos” afrontaremos varias mejoras que tendrás que intentar tú en forma de ejercicios propuestos. ¿O pensabas que iba a hacer yo todo el trabajo?
3.5.1. Qué es la autenticación mediante ACL
Casi todas las aplicaciones web incluyen un subsistema de autenticación de usuarios. El más completo de esos subsistemas es el de las lisas de control de acceso (ACL = Access Control List).
Ese subsistema suele estar basado en este diseño de base de datos:
Esto significa que necesitamos cinco tablas para implementar un ACL completo.
Sin embargo, muchas veces tendremos suficiente con solo tres tablas (users, roles y roles-users), o incluso solo con una (users, añadiendo quizá un campo “type”).
Optar por una solución más o menos compleja dependerá del tipos de sistema que estemos implementando.
En cualquier caso, es conveniente que conozcas el esquema ACL completo (es decir, el de 5 tablas) para que lo pongas en práctica cuando lo necesites. Por eso te lo he presentado. Ahora ya sois oficialmente amigos.
3.5.2. Una implementación de autenticación mediante ACL
Como ocurre con muchos conceptos en el ámbito de la programación, las ACL se entienden mejor viéndolas que explicándolas. Así que vamos a hacer una implementación de ejemplo, que de paso nos servirá para mostrar en acción muchas de las cosas que hemos visto en este tema.
Ojo, que esta es solo una de las posibles implementaciones. Pueden existir mil variaciones. Pero, como sucedía con los ejemplos que hemos visto anteriormente, te servirá como base para tu propia implementación de una ACL.
IMPORTANTE: en esta implementación verás una distribución de archivos un poco peculiar y que, a primera vista, puede resultarte hasta caprichosa. No te agobies. Hemos respetado una arquitectura de aplicaciones denominada modelo-vista-controlador o MVC. Hablaremos largo y tendido sobre esa arquitectura más adelante, y entonces comprenderás que la distribución del código no tenía nada de caprichosa.
Por ahora, solo tienes que seguir la pista a lo que sucede, y ni siquiera es necesario que lo entiendas al 100%. Un 80% ya estaría genial. Un 50% sería suficiente. Tu comprensión de este código aumentará cuando tengas que utilizarlo y adaptarlo a tus propios proyectos.
Nuestras tablas ACL
Vamos a suponer que esta autenticación con ACL se está implementando para un sistema de publicación de noticias (un blog, un periódico digital o algo semejante). Solo por darle un poco de contexto. Realmente, cambiando los permisos, podría utilizarse casi para cualquier web.
Haremos una implementación completa del ACL, es decir, con las cinco tablas. Esas cinco tablas tendrán el siguiente aspecto (te muestro algunos datos de ejemplo para que quede más claro de lo que estamos hablando):
TABLA users
id | passwd | name | telef | etc (otros campos) | |
---|---|---|---|---|---|
1 | pepe@iescelia.org | 1234 | Pepe Pérez | 555 230 111 | etc |
2 | ana@iescelia.org | 1234 | Ana López | 555 398 234 | etc |
Etc | Etc | Etc | Etc | Etc | Etc |
TABLA roles
id | description |
---|---|
1 | Admin |
2 | Editor |
3 | User |
TABLA roles-users
idUser | idRol |
---|---|
1 | 1 |
2 | 2 |
2 | 3 |
etc | etc |
TABLA permissions
id | description | action |
---|---|---|
1 | Crear contenido nuevo | createContentForm |
2 | Editar contenido propio | editMyContentForm |
3 | Editar contenido ajeno | editAnyContentForm |
4 | Borrar contenido propio | deleteMyContentForm |
5 | Borrar contenido ajeno | deleteAnyContentForm |
6 | Publicar contenido propio | publishMyContentForm |
7 | Publicar contenido ajeno | publishAnyContentForm |
8 | Leer contenido publicado | readContent |
TABLA permissions-roles
idRol | idPermission |
---|---|
1 | 1 |
1 | 2 |
1 | 3 |
1 | 4 |
1 | 5 |
1 | 6 |
1 | 7 |
1 | 8 |
2 | 1 |
2 | 2 |
2 | 6 |
2 | 8 |
3 | 8 |
Observa que, con estas tablas, queda perfectamente definido a qué perfil de usuario (o “rol”) pertenece cada usuario y qué cosas puede hacer con ese perfil.
Por ejemplo, el usuario Pepe Pérez, que tiene el Id = 1, es un Administrador, porque tiene asociado el rol 1 en la tabla roles-users. Y los administradores tienen permiso para hacerlo absolutamente todo, según se desprende de la tabla permissions-roles.
En cambio, la usuaria Ana López (Id = 2) tiene perfil de Editor, y los editores solo tienen permiso para cuatro operaciones: Crear contenido nuevo, Editar su propio contenido, Publicar su propio contenido y Leer el contenido publicado.
Código fuente de nuestra implementación
En esta implementación, no escribiremos el código para hacer cosas como “Crear contenido nuevo” o “Publicar contenido”. Eso dependerá del sistema concreto que estemos programando, y no es lo que nos interesa ahora.
Lo que nos interesa es ver cómo se autentica un usuario en una aplicación web y cómo se le puede dar acceso a unas funcionalidades o a otras dependiendo del contenido de las tablas ACL.
Una vez autenticado, el usuario accederá a una vista diferente de la aplicación dependiendo de sus privilegios, donde se le mostrarán las opciones de que dispone. Es decir, si el usuario que se loguea es Pepe Pérez, que tiene rol de Administrador, la aplicación debe mostrarle estas opciones:
- Editar contenido (propio y ajeno)
- Borrar contenido (propio y ajeno)
- Publicar contenido (propio y ajeno)
- Leer contenido
- Crear contenido
En cambio, si se loguea Ana López, que tiene dos perfiles, la aplicación le dará a elegir cuál quiere usar. Si elige el perfil de Editor, las opciones deben reducirse a:
- Editar contenido (propio)
- Publicar contenido (propio)
- Leer contenido
- Crear contenido
Cada una de estas opciones redirigirá la aplicación de regreso a index.php, con un valor diferente para la variable action que se pasará por la URL. Ese valor se saca de la tabla permissions.
Insisto en una idea muy importante: no es necesario que comprendas la totalidad de este código en este momento. Basta con que te esfuerces en captar la idea general. Volverás sobre él, y sobre infinitas variedades de él, más adelante, cada vez con mayor comprensión de lo que está sucediendo. Así que léelo sin prisa y sin agobios, como quien se adentra en la traducción de un texto escrito en una lengua que se parece un poco a la suya sin llegar a serlo.
Una última advertencia: esta solución presenta algunos problemas de seguridad (como no filtrar las variables procedentes de un formulario) que resolveremos en los ejercicios propuestos más adelante.
Archivo index.php
Este archivo captura la variable action desde la URL. Esta variable, como ya vimos en el ejemplo de la Biblioteca, indica a la aplicación qué es lo que debe hacer.
Luego se instancia un objeto de tipo Controller y se invoca un método con el mismo nombre que la action.
<?php
include("controller.php");
$controller = new Controller();
// Miramos a ver si hay alguna acción pendiente de realizar
if (!isset($_REQUEST['action'])) {
// No la hay. Usamos la acción por defecto (mostrar el formulario de login)
$action = "showLoginForm";
} else {
// Sí la hay. La recuperamos.
$action = $_REQUEST['action'];
}
// Ejecutamos el método del controlador que se llame como la acción
$controller->$action();
CONTROLADOR (archivo controller.php)
En el controlador están reflejadas todas las posibles acciones que puede realizar la aplicación.
Es decir, tiene que haber un método por cada posible valor de la variable action.
<?php
include ("view.php");
include ("user.php");
class Controller
{
private $view, $user;
/**
* Constructor. Crea el objeto vista y los modelos
*/
public function __construct()
{
session_start(); // Si no se ha hecho en el index, claro
$this->view = new View(); // Vistas
$this->user = new User(); // Modelo de usuarios
}
/**
* Muestra el formulario de login
*/
public function showLoginForm()
{
$this->view->show("loginForm");
}
/**
* Procesa el formulario de login y, si es correcto, inicia la sesión con el id del usuario.
* Redirige a la vista de selección de rol.
*/
public function processLoginForm()
{
// Validación del formulario
if ($_REQUEST['email'] == "" || $_REQUEST['pass'] == "") {
// Algún campo del formulario viene vacío: volvemos a mostrar el login
$data['errorMsg'] = "El email y la contraseña son obligatorios";
$this->view->show("loginForm", $data);
}
else {
// Hemos pasado la validación del formulario: vamos a procesarlo
$userData = $this->user->checkLogin($_REQUEST['email'], $_REQUEST['pass']);
if ($userData!=null) {
// Login correcto: creamos la sesión y pedimos al usuario que elija su rol
$_SESSION['idUser'] = $userData->id;
$this->SelectUserRolForm();
}
else {
$data['errorMsg'] = "Usuario o contraseña incorrectos";
$this->view->show("loginForm", $data);
}
}
}
/**
* Muestra formulario de selección de rol de usuario
*/
public function selectUserRolForm()
{
$data['roles'] = $this->user->getUserRoles($_SESSION['idUser']);
$this->view->show("selectUserRolForm", $data);
// Posible mejora: si el usuario solo tiene un rol, la aplicación podría seleccionarlo automáticamnte
// y saltar a $this->showMainMenu()
}
/**
* Procesa el formulario de selección de rol de usuario y crea una variable de sesión para almacenarlo.
* Redirige al menú principal.
*/
public function processSelectUserRolForm()
{
$_SESSION['userRol'] = $_REQUEST['idRol'];
$this->showMainMenu();
}
/**
* Muestra el menú de opciones del usuario según la tabla de persmisos
*/
public function showMainMenu()
{
$data['permissions'] = $this->user->getUserPermissions($_SESSION['userRol']);
$this->view->show("mainMenu", $data);
}
}
VISTA (view.php)
Este archivo contiene un método genérico (dentro de la clase View) para mostrar cualquier otra vista, cuyo nombre se le pasa como parámetro desde el controlador.
<?php
class View
{
public function show($viewName, $data = null)
{
include("views/header.php");
include("views/$viewName.php");
include("views/footer.php");
}
}
VISTA loginForm (archivo views/loginForm.php)
Esta vista muestra el formulario de login. La dejamos preparada para mostrar, opcionalmente, un mensaje de error (del tipo “usuario o contraseña incorrectos”) o un mensaje informativo (del tipo “Sesión cerrada con éxito”).
<?php
if (isset($data['errorMsg'])) {
echo "<p style='color:red'>" . $data['errorMsg'] . "</p>";
}
if (isset($data['infoMsg'])) {
echo "<p style='color:blue'>" . $data['infoMsg'] . "</p>";
}
echo "<form action='index.php'>
Email:<input type='text' name='email'><br>
Contraseña:<input type='password' name='pass'><br>
<input type='hidden' name='action' value='processLoginForm'>
<input type='submit'>
</form>";
VISTA selectUserRolForm (archivo views/selectUserRolForm.php)
Esta vista muestra la lista de roles de un usuario. Sirve por si un usuario tiene asignado más de un rol. Así, antes de terminar el login, podrá elegir con qué rol quiere ingresar en la aplicación.
<?php
echo "Selecciona el rol<br>";
echo "<form action='index.php'>";
echo "<select name='idRol'>";
foreach ($data['roles'] as $rol) {
echo "<option value='".$rol->id."'>".$rol->description."<option>";
}
echo "</select>";
echo "<input type='hidden' name='action' value='processSelectUserRolForm'>";
echo "<button type='submit'>Enviar</button>";
echo "</form>";
VISTA mainMenu (archivo views/mainMenu.php)
Esta vista muestra las opciones del programa asociadas a un usuario concreto. Cada opción es un enlace a la propia aplicacion con un valor diferente para la variable action.
<?php
echo "Menú principal<br>";
foreach ($data['permissions'] as $permission) {
echo "<a href='index.php?action=" . $permission->action . "'>" . $permission->description . "</a><br>";
}
MODELO (archivo user.php)
El modelo contiene todos los métodos necesarios para acceder a la base de datos (o, en general, a cualquier recurso del servidor). Esos métodos siempre se invocan desde el controlador.
En este caso, llamamos user.php al modelo porque accederá únicamente a la tabla de usuarios.
<?php
class User
{
private $db;
/**
* Constructor de la clase.
* Crea una conexión con la base de datos y la asigna a la variable $this->db
*/
public function __construct()
{
$this->db = new mysqli("servidor", "usuario", "contraseña", "base-de-datos");
}
/**
* Comprueba si un email y una password pertenecen a algún usuario de la base de datos.
* @param String $email El email del usuario que se quiere comprobar
* @param String $pass La contraseña del usuario que se quiere comprobar
* @return User $usuario Si el usuario existe, devuelve un objeto con todos los campos del usuario en su interior. Si no, devuelve un objeto null
*/
public function checkLogin($email, $pass)
{
if ($result = $this->db->query("SELECT id FROM users WHERE email = '$email' AND password = '$pass'")) {
if ($result->num_rows == 1) {
$usuario = $result->fetch_object();
return $usuario;
}
else {
return null;
}
}
else {
return null;
}
}
/**
* Busca en la base de datos la lista de roles de un usuario
* @param integer $idUser El id del usuario
* @return array $resultArray Un array con todos los roles del usuario, o null si el usuario no existe o no tiene roles asignados
*/
public function getUserRoles($idUser)
{
$resultArray = array();
if ($result = $this->db->query("SELECT roles.* FROM roles
INNER JOIN `roles-users` ON roles.id = `roles-users`.idRol
WHERE `roles-users`.idUser = '$idUser'")) {
if ($result->num_rows > 0) {
while ($rol = $result->fetch_object()) {
$resultArray[] = $rol;
}
return $resultArray;
}
else {
return null;
}
}
else {
return null;
}
}
/**
* Busca en la base de datos los permisos asociados a un rol
* @param integer $idRol El id del rol
* @return array $resultArray Un array con la lista de permisos asociados al rol, o null si el rol no existe o no tiene permisos asociados
*/
public function getUserPermissions($idRol)
{
$resultArray = array();
if ($result = $this->db->query("SELECT permissions.* FROM permissions
INNER JOIN `permissions-roles` ON permissions.id = `permissions-roles`.idPermission
WHERE `permissions-roles`.idRol = '$idRol'")) {
if ($result->num_rows > 0) {
while ($permission = $result->fetch_object()) {
$resultArray[] = $permission;
}
return $resultArray;
}
else {
return null;
}
}
else {
return null;
}
}
}
?>