Podemos tener básicamente dos motivos para proteger una aplicación web:
Para cubrir estos agujeros, un sistema de seguridad se apoya en tres aspectos importantes:
Desde el punto de vista de quién controla la seguridad en una aplicación web, existen dos formas de implantación:
Seguridad declarativa
Aquella estructura de seguridad sobre una aplicación que es externa a dicha aplicación. Con ella, no tendremos que preocuparnos de gestionar la seguridad en ningún servlet, página JSP, etc, de nuestra aplicación, sino que el propio servidor Web se encarga de todo. Así, ante cada petición, comprueba si el usuario se ha autentificado ya, y si no le pide login y password para ver si puede acceder al recurso solicitado. Todo esto se realiza de forma transparente al usuario. Mediante el descriptor de la aplicación principalmente (fichero web.xml en Tomcat), comprueba la configuración de seguridad que queremos dar a cada aplicación.
Seguridad programada
Mediante la seguridad programada, son los servlets y páginas JSP quienes, al menos parcialmente, controlan la seguridad de la aplicación.
En esta sección se explicarán principalmente algunas técnicas de seguridad declarativa, que es la que puede configurar el administrador del servidor web.
Veremos ahora algunos mecanismos que pueden emplearse con HTTP para autentificar (validar) al usuario que intenta acceder a un determinado recurso.
1. Autentificaciones elementales
El protocolo HTTP incorpora un mecanismo de autentificación básico (basic) basado en cabeceras de autentificación para solicitar datos del usuario (el servidor) y para enviar los datos del usuario (el cliente), de forma que comprobando la exactitud de los datos se permitirá o no al usuario acceder a los recursos. Esta autentificación no proporciona confidencialidad ni integridad, sólo se emplea una codificación Base64.
Una variante de esto es la autentificación digest, donde, en lugar de transmitir el password por la red, se emplea un password codificado. Dicha codificación se realiza tomando el login, password, URI, método HTTP y un valor generado aleatoriamente, y todo ello se combina utilizando el método de encriptado MD5, muy seguro. De este modo, ambas partes de la comunicación conocen el password, y a partir de él pueden comprobar si los datos enviados son correctos. Sin embargo, algunos servidores no soportan este tipo de autentificación.
Finalmente, tenemos la autentificación basada en formularios. Con este tipo de autentificación, el usuario introduce su login y password mediante un formulario HTML (y no con un cuadro de diálogo, como las anteriores). El fichero descriptor contiene para ello entradas que indican la página con el formulario de autentificación y una página de error. Tiene el mismo inconveniente que la autentificación basic: el password se codifica con un mecanismo muy pobre.
2. Certificados digitales y SSL
Las aplicaciones reales pueden requerir un nivel de seguridad mayor que el proporcionado por las autentificaciones basic o digest. También pueden requerir confidencialidad e integridad aseguradas. Todo esto se consigue mediante los certificados digitales.
2.1. Criptografía de clave pública
La clave de los certificados digitales reside en la criptografía de clave pública, mediante la cual cada participante en el proceso tiene dos claves, que le permiten encriptar y desencriptar la información. Una es la clave pública, que se distribuye libremente. La otra es la clave privada, que se mantiene secreta. Este par de claves es asimétrico, es decir, una clave sirve para desencriptar algo codificado con la otra. Por ejemplo, supongamos que A quiere enviar datos encriptados a B. Para ello, hay dos posibilidades:
- A toma la clave pública de B, codifica con ella los datos y se los envía. Luego B utiliza su clave privada (que sólo él conoce) para desencriptar los datos.
- A toma su clave privada, codifica los datos y se los envía a B, que toma la clave pública de A para descodificarlos. Con esto, B sabe que A es el remitente de los datos.
El encriptado con clave pública se basa normalmente en el algoritmo RSA, que emplea números primos grandes para obtener un par de claves asimétricas. Las claves pueden darse con varias longitudes; así, son comunes claves de 1024 o 2048 bits.
2.2. Certificados digitales
Lógicamente, no es práctico teclear las claves del sistema de clave pública, pues son muy largas. Lo que se hace en su lugar es almacenar estas claves en disco en forma de certificados digitales. Estos certificados pueden cargarse por muchas aplicaciones (servidores web, navegadores, gestores de correo, etc).
Notar que con este sistema se garantiza la confidencialidad (porque los datos van encriptados), y la integridad (porque si los datos se desencriptan bien, indica que son correctos). Sin embargo, no proporciona autentificación (B no sabe que los datos se los ha enviado A), a menos que A utilice su clave privada para encriptar los datos, y luego B utilice la clave pública de A para desencriptarlos. Si el proceso tiene éxito, los datos se sabe que han sido enviados por A, porque sólo A conoce su clave privada.
2.3. SSL
SSL (Secure Socket Layer) es una capa situada entre el protocolo a nivel de aplicación (HTTP, en este caso) y el protocolo a nivel de transporte (TCP/IP). Se encarga de gestionar la seguridad mediante criptografía de clave pública que encripta la comunicación entre cliente y servidor. La versión 2.0 de SSL (la primera mundialmente aceptada), proporciona autentificación en la parte del servidor, confidencialidad e integridad. Funciona como sigue:
- Un cliente se conecta a un lugar seguro utilizando el protocolo HTTPS (HTTP + SSL). Podemos detectar estos sitios porque las URLs comienzan con
https://
- El servidor envía su clave pública al cliente.
- El navegador comprueba si la clave está firmada por un certificado de confianza. Si no es así, pregunta al cliente si quiere confiar en la clave proporcionada.
SSL 3.0 proporciona también soporte para certificados y autentificación del cliente. Funcionan de la misma forma que los explicados para el servidor, pero residiendo en el cliente.
Veremos ahora con más profundidad la autentificación basada en formularios comentada anteriormente. Esta es la forma más comúnmente usada para imponer seguridad en una aplicación, puesto que se emplean formularios HTML.
El programador emplea el descriptor de despliegue para identificar los recursos a proteger, e indicar la página con el formulario a mostrar, y la página con el error a mostrar en caso de autentificación incorrecta. Así, un usuario que intente acceder a la parte restringida es redirigido automáticamente a la página del formulario, si no ha sido autentificado previamente. Si se autentifica correctamente accede al recurso, y si no se le muestra la página de error. Todo este proceso lo controla el servidor automáticamente.
Este tipo de autentificación no se garantiza que funcione cuando se emplea reescritura de URLs en el seguimiento de sesiones. También podemos incorporar SSL a este proceso, de forma que no se vea modificado el funcionamiento aparente del mismo.
Para utilizar la autentificación basada en formularios, se siguen los pasos que veremos a continuación. Sólo el primero es dependiente del servidor que se utilice.
1. Establecer los logins, passwords y roles
Aquí se establece una lista de usuarios, con su password y uno o varios roles a los que pueden pertenecer.
Tomcat permite especificar la forma de gestionar estos datos (mediante base de datos, fichero de passwords, etc). Sin embargo, también propone una forma alternativa de tratar esta información, almacenando logins, passwords y roles en el fichero conf/tomcat-users.xml.
Este fichero contiene una cabecera XML, seguida de una etiqueta raíz <tomcat-users>, que a su vez contiene una serie de etiquetas <user>, una por cada usuario que se defina. Cada una de estas etiquetas <user> contiene 3 atributos:
Un ejemplo de fichero sería:
<?xml version="1.0" encoding="ISO-8859-1" ?> <tomcat-users> <user name="pepe" password="pepepw" roles="usuario"/> <user name="manuel" password="manolo" roles="admin"/> <user name="toni" password="toni" roles="usuario, admin"/> </tomcat-users>
Así, por ejemplo, para un recurso (URL) al que sólo puedan acceder roles de tipo admin, podrían acceder los usuarios manuel y toni. Notar también que los passwords están visibles en un fichero de texto fácilmente accesible por casi cualquiera, con lo que no es una buena forma de gestionar los passwords para una aplicación profesional.
2. Indicar al servlet que se empleará autentificación basada en formularios, e indicar las páginas de formulario y error
Se coloca para ello una etiqueta <login-config> en el descriptor de despliegue. Dentro, se emplean las subetiquetas:
Por ejemplo, podemos tener las siguientes líneas en el descriptor de despliegue:
<?xml version="1.0" encoding="ISO-8859-1"?> <!DOCTYPE web-app PUBLIC "-//Sun Microsystems, Inc.//DTD Web Application 2.2//EN" "http://java.sun.com/j2ee/dtds/web-app_2_2.dtd"> <web-app> ... <login-config> <auth-method>FORM</auth-method> <form-login-config> <form-login-page> /login.jsp </form-login-page> <form-error-page> /error.html </form-error-page> </form-login-config> </login-config> ... </web-app>
El formulario de esta página debe contener campos para introducir el login y el password, que deben llamarse j_username y j_password. La acción del formulario debe ser j_security_check, y el METHOD = POST (para no mostrar los datos de identificación en la barra del explorador). Por ejemplo, podríamos tener la página:
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN"> <html> <body> <form action="j_security_check" METHOD="POST"> <table> <tr><td> Login: <input type="text" name="j_username"> </td></tr> <tr><td> Password: <input type="text" name="j_password"> </td></tr> <tr><td> <input type="submit" value="Enviar"> </td></tr> </table> </form> </body> </html>
La página puede tener el mensaje de error que se quiera. Ante fallos de autentificación, se redirigirá a esta página con un código 401. Un ejemplo de página sería:
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN"> <html> <body> <h1>ERROR AL AUTENTIFICAR USUARIO</h1> </body> </html>
5. Indicar qué direcciones deben protegerse con autentificación
Para ello utilizamos etiquetas <security-constraint> en el descriptor de despliegue. Dichos elementos debe ir inmediatamente antes de <login-config>, y utilizan las subetiquetas:
NOTA: este modo de restricción se aplica sólo cuando se accede al recurso directamente, no a través de arquitecturas MVC (Modelo-Vista-Controlador), con un RequestDispatcher. Es decir, si por ejemplo un servlet accede a una página JSP protegida, este mecanismo no tiene efecto, pero sí cuando se intenta a acceder a la página JSP directamente.
En teoría esta etiqueta es opcional, pero omitiéndola indicamos que ningún rol tiene permiso de acceso. Aunque esto puede parecer absurdo, recordar que este sistema sólo se aplica al acceso directo a las URLs (no a través de un modelo MVC), con lo que puede tener su utilidad.
Añadimos alguna dirección protegida al fichero que vamos construyendo:
<?xml version="1.0" encoding="ISO-8859-1"?> <!DOCTYPE web-app PUBLIC "-//Sun Microsystems, Inc.//DTD Web Application 2.2//EN" "http://java.sun.com/j2ee/dtds/web-app_2_2.dtd"> <web-app> <security-constraint> <web-resource-collection> <web-resource-name> Prueba </web-resource-name> <url-pattern> /prueba/* </url-pattern> </web-resource-collection> <auth-constraint> <role-name>admin</role-name> <role-name>subadmin</role-name> </auth-constraint> </security-constraint> <login-config> ... </web-app>
En este caso protegemos todas las URLs de la forma http//host/ruta_aplicacion/prueba/*, de forma que sólo los usuarios que tengan roles de admin o de subadmin podrán acceder a ellas.
6. Desactivar los servlets
Hay que tener en cuenta que al proteger URLs, seguimos dejando libre una vía de acceso para los servlets, puesto que podemos llamarlos utilizando el alias servlet. Por ejemplo, supongamos que mapeamos el servlet paqueteservlets.UnServlet con la dirección /pruebas/Prueba. Supongamos también que protegemos la URL /pruebas/Prueba. De esta forma, si llamamos al servlet con
http://<ruta>/pruebas/Prueba
no podremos hacerlo a no ser que tengamos los permisos adecuados. Pero nadie ha prohibido llamar al servlet con:
http://<ruta>/servlet/paqueteservlets.UnServlet
Hay que desactivar, por tanto, esta forma de llamar a los servlets implicados en el proceso de seguridad. En los conceptos iniciales de servlets vistos anteriormente vimos cómo podría hacerse eso. Una forma muy común es redirigir las direcciones /servlet/* hacia un mismo servlet que muestre una página de error:
<?xml version="1.0" encoding="ISO-8859-1"?> <!DOCTYPE web-app PUBLIC "-//Sun Microsystems, Inc.//DTD Web Application 2.2//EN" "http://java.sun.com/j2ee/dtds/web-app_2_2.dtd"> <web-app> <servlet> <servlet-name>Error</servlet-name> <servlet-class>ErrorServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>Error</servlet-name> <url-pattern>/servlet/*</url-pattern> </servlet-mapping> <security-constraint> ... </web-app>
El servlet ErrorServlet puede simplemente mostrar un texto de error.
EJEMPLO
Juntando todos los pasos previos, tenemos aquí un fichero WAR disponible con los ficheros implicados. El ejemplo tiene la siguiente estructura de directorios y archivos:
Para probar el ejemplo, debemos colocar en el fichero tomcat-users.xml los valores de usuarios y passwords vistos antes en el ejemplo (u otros valores, si queremos). Se proporciona un fichero tomcat-users.xml en el WAR, para copiarlo directamente en el directorio conf de Tomcat (deberíamos copiar previamente el tomcat-users.xml original, para no perderlo). Luego copiamos el fichero WAR en el directorio webapps de Tomcat. Intentamos acceder a las direcciones:
http://localhost:8080/ejemploseguridadform/prueba/index.html
http://localhost:8080/ejemploseguridadform/servlet/PruebaServlet
En el primero caso, probamos a introducir logins y passwords válidos e inválidos, para comprobar qué páginas se muestran en cada caso. En el segundo caso, nos mostrará la página que genera el servlet ErrorServlet.
El método de autentificación basada en formularios tiene algunos inconvenientes: si el navegador no soporta cookies, el proceso tiene que hacerse mediante reescritura de URLs, con lo que no se garantiza el funcionamiento.
Por ello, una alternativa es utilizar el modelo de autentificación basic de HTTP, donde se emplea un cuadro de diálogo para que el usuario introduzca su login y password, y se emplea la cabecera Authorization de petición para recordar qué usuarios han sido autorizados y cuáles no. Una diferencia con respecto al método anterior es que es difícil entrar como un usuario distinto una vez que hemos entrado como un determinado usuario (habría que cerrar el navegador y volverlo a abrir).
Al igual que en el caso anterior, podemos utilizar SSL sin ver modificado el resto del esquema del proceso.
El método de autentificación basic consta de los siguientes pasos:
1. Establecer los logins, passwords y roles
Este paso es exactamente igual que el visto para la autentificación basada en formularios.
2. Indicar al servlet que se empleará autentificación BASIC, y designar los dominios
Se utiliza la misma etiqueta <login-config> vista antes, pero ahora una etiqueta <auth-method> con valor BASIC. Se emplea una subetiqueta <realm-name> para indicar qué dominio se empleará en la autorización. Por ejemplo:
<?xml version="1.0" encoding="ISO-8859-1"?> <!DOCTYPE web-app PUBLIC "-//Sun Microsystems, Inc.//DTD Web Application 2.2//EN" "http://java.sun.com/j2ee/dtds/web-app_2_2.dtd"> <web-app> ... <login-config> <auth-method>BASIC</auth-method> <realm-name>dominio</realm-name> </login-config> ... </web-app>
3. Indicar qué direcciones deben protegerse con autentificación
Este paso también es idéntico al visto en la autentificación basada en formularios.
4. Desactivar los servlets
Este paso también es igual al visto en la autentificación basada en formularios.
EJEMPLO
Juntando todos los pasos previos, tenemos aquí un fichero WAR disponible con los ficheros implicados. El ejemplo tiene la misma estructura de directorios que el visto para autentificación con formularios (quitando las páginas de login y error, que ya no son necesarias). El fichero descriptor queda ahora con esta apariencia.
Para probar el ejemplo, seguimos también los mismos pasos que para el ejemplo anterior, pero accediendo a las direcciones:
http://localhost:8080/ejemploseguridadbasic/prueba/index.html
http://localhost:8080/ejemploseguridadbasic/servlet/PruebaServlet
La seguridad declarativa vista hasta ahora es la más comúnmente usada en las aplicaciones Web. Pero, ¿cómo hacer que nuestros servlets sean independientes de determinadas configuraciones del servidor, como la gestión de logins y passwords de usuarios, o la gestión de roles? Aquí entra en juego la seguridad programada, y con ella, dejamos que los servlets y páginas JSP controlen parcial o totalmente los aspectos de seguridad.
En algunos casos puede interesar mantener parte de las ventajas que ofrece la seguridad declarativa (como la gestión automática de usuarios y passwords), y añadir sobre eso las configuraciones particulares que podemos alcanzar con la seguridad programada. Así, no tenemos que renunciar a los elementos <login-config>, la autentificación basic o la basada en formularios, los elementos <security-constraint>, etc. Simplemente añadimos un tratamiento adicional a estas autentificaciones, mediante métodos de HttpServletRequest.
En otros casos interesa dejar que el servlet o la página JSP controle totalmente la seguridad, convirtiendose ésta en algo completamente programado. Para implementar una seguridad completamente programada utilizando un método basic (el método digest no lo veremos por no estar demasiado difundido), los pasos son:
1. Comprobar si hay una cabecera "Authorization"
Si no la hay, vamos al paso 5
2. Obtener el login y password codificados de dicha cabecera
Si hay cabecera Authorization tendrá el siguiente formato:
Authorization: Basic datos_codificados
Saltándonos los 6 primeros caracteres ("Basic ") obtendremos los datos codificados que deberemos descodificar.
3. Descodificar el login y password con Base64
Se utiliza para ello un objeto Base64Decoder (que viene con JDK, en el paquete sun.misc), y llamando a su método decodeBuffer(), que devuelve una cadena con el formato:
usuario:password
Podemos luego separar login y password procesando esta cadena. Hay que tener en cuenta también que, al estar en el paquete sun.misc, no se garantiza la portabilidad del descodificador entre sistemas distintos.
4. Comprobar si login y password son correctos
Para ello se pueden tener los login y passwords guardados en una base de datos, o en un objeto Properties en el servlet, o cualquier otra posibilidad. Se trata de buscar el password que hay en la base de datos para el usuario que ha entrado y compararlo con el que ha introducido.
5. Si no hay autentificación, o falla, enviar la respuesta apropiada
Se envía un código 401, y una cabecera WWW-Authenticate para que el navegador muestre un diálogo y que el usuario se autentifique.
EJEMPLO
El siguiente servlet emplea las cabeceras de autentificación: envía una cabecera de autentificación si no ha recibido ninguna, o si la que ha recibido no está dentro de un conjunto de Properties predefinido, con logins y passwords válidos. En el caso de introducir un login o password válidos, muestra un mensaje de bienvenida.
Los logins y passwords están en un objeto Properties, definido en el método init(). Podríamos leer estos datos de un fichero, aunque por simplicidad aquí se definen como constantes de cadena.
Los datos de autentificación se envían codificados, y se emplea un objeto sun.misc.BASE64Decoder para descodificarlos y sacar el login y password.
import java.io.*; import java.util.*; import sun.misc.*; import javax.servlet.*; import javax.servlet.http.*; public class ServletPassword extends HttpServlet { // Conjunto de logins y passwords permitidos Properties datos = new Properties(); // Metodo de inicializacion public void init() { datos.setProperty("usuario1", "password1"); datos.setProperty("usuario2", "password2"); } // Metodo para GET public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { response.setContentType("text/html"); // Comprobamos si hay cabecera // de autorizacion String autorizacion = request.getHeader("Authorization"); if (autorizacion == null) { // Enviamos el codigo 401 y // la cabecera para autentificacion response.setStatus(response.SC_UNAUTHORIZED); response.setHeader("WWW-Authenticate", "BASIC realm=\"privileged-few\""); } else { // Obtenemos los datos del usuario // y comparamos con los almacenados // Quitamos los 6 primeros caracteres // que indican tipo de autentificación // (BASIC) String datosUsuario = autorizacion.substring(6).trim(); BASE64Decoder dec = new BASE64Decoder(); String usuarioPassword = new String (dec.decodeBuffer(datosUsuario)); int indice = usuarioPassword.indexOf(":"); String usuario = usuarioPassword.substring(0, indice); String password = usuarioPassword.substring(indice + 1); String passwordReal = datos.getProperty(usuario); if (passwordReal != null && passwordReal.equals(password)) { // Mensaje de bienvenida PrintWriter out = response.getWriter(); out.println ("<HTML><BODY>"); out.println ("OK"); out.println ("</BODY></HTML>"); } else { // Pedir autentificacion response.setStatus (response.SC_UNAUTHORIZED); response.setHeader ("WWW-Authenticate", "BASIC realm=\"privileged-few\""); } } } // Metodo para POST public void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { doGet(request, response); } }
Aquí tenéis el WAR con el ejemplo. Copiadlo en el directorio webapps y probadlo con:
http://localhost:8080/ejemplocabeceras/servlet/ServletPassword
Un ejemplo de login y password válidos para el ejemplo es: login=usuario1, password=password1.