Jersey es un framework para la creación de servicios REST en Java. Está basado en el estándar JAX-RS, que es básicamente la especificación de como se debe implementar un servicio REST en Java, por lo que Jersey vendría siendo la implementación de esta definición.

Existen muchos métodos para implementar autenticación sobre un servicio, podemos incluir servicios de 3eros como OAuth o bien podemos utilizar métodos más estandarizados como JWT.

En este post mostraré como implementar un mecanismo de autenticación similar a JWT pero con algunas diferencias. Implementar JWT resulta un poco engorroso a mi parecer, por lo que en este approach simplificaremos un poco las cosas pero sin sacrificar seguridad que es en el fondo lo que buscamos.

Overview

Básicamente esto es lo que haremos:

A grandes rasgos tenemos dos rutinas de autenticación:

  • validaUserPass() que valida la combinación usuario-password y finalmente emite un token para ser usado en requests posteriores
  • validaToken() para validar que el token que le enviamos es válido

Las cuales se invocan respectivamente:

  1. Cuando te logeas ingresando user/pass
  2. Cada vez que ingresas a una sección restringida de la aplicación

El token puede estar compuesto por distintos datos, lo importante es que siempre se encuentre debidamente cifrado. Para este ejemplo utilizaré una concatenación de usuario:contraseña para componer el token. La ventaja de usar estos datos en el token, es que al momento de ser validado en el backend, podemos corroborar inmediatamente contra base de datos si la combinación usuario/contraseña es válida, así podemos invalidar la sesión en caso de que la combinación sea incorrecta.

A diferencia del JWT tradicional, donde no se utiliza usuario:contraseña, si el usuario cambia su contraseña, el resto de las sesiones que estén abiertas continúan abiertas, se requiere de un control adicional para manejar estos casos.

Por último mencionar que al token de seguridad es altamente recomendable añadirle algunos condimentos para hacerlo más seguro:

  • User-agent del usuario
  • Dirección de red del usuario
  • Timestamp de caducidad

Digamos que el token de un usuario se vio comprometido. El atacante no podrá hacer mucho si no hace las solicitudes con el mismo navegador e IP del usuario real. Además, con un timestamp de creación de token podemos configurar una caducidad del tiempo que estimemos conveniente.

Una de las ventajas de JWT sobre este método es que JWT no transmite el usuario y la contraseña en cada request, pero si nos preocupamos de mantener nuestro token bien encriptado esto no debería ser un inconveniente.

Puntos a recalcar

  • La librería de encriptación debe ser fiable
  • La llave de encriptación de tokens debe ser compleja y almacenada en un lugar seguro
  • Utilizar HTTPS always

Si rompes uno de estos puntos, la información de tus usuarios puede verse comprometida.

Para nuestro caso de prueba no será necesario implementar todos estos puntos, pero en un escenario productivo estas son reglas básicas.

Flujo

Básicamente el flujo de autenticación sería el siguiente:

  1. Ingreso a la aplicación
  2. Ingreso mis credenciales en la página de login
  3. La API obtiene y valida las credenciales
  4. Si están OK, encripta las credenciales usando una llave simétrica almacenada de forma segura en el servidor
  5. La API retorna las credenciales encriptadas como token de seguridad
  6. Mi navegador almacena el token para futuros request

Ahora cada vez que yo hago un request a la API:

  1. Envío el token en la cacebera del request HTTP
  2. La API toma el token, lo desencripta y comprueba las credenciales
  3. Si están OK, continúa con la petición solicitada
  4. Si no, retorna un error 401 unauthorized

Implementación

A continuación, vamos a revisar en detalle una implementación de ejemplo que he preparado (maven required):

git clone https://github.com/felipeleivav/jersey-auth.git

La estructura del proyecto es bastante simple:

  • bean
    • UserBean
  • filter
    • Authenticator
  • main
    • App
  • rest
    • UserRest
  • utils
    • Symmetric
    • Tokenizer

Veámosla en orden:

  • App.java es la clase principal y que inicializa la API, no hay mucho que ver por acá, solo mencionar que se utiliza Jetty para exponer la API mediante HTTP.
  • UserBean.java es un bean simple, solo lleva el usuario y la contraseña.
  • ApiRest.java es la API. Contiene las 3 rutas de ejemplo que vamos a utilizar:
@Path("/api")
public class ApiRest {

	@POST
	@Path("/login")
	public String login(UserBean userLogin) throws Exception {
		if (user.equals(userLogin.getUsername()) && pass.equals(userLogin.getPassword())) {
			return Tokenizer.generateToken(userLogin.getUsername(), userLogin.getPassword());
		} else {
			return "WRONG_USER";
		}
	}

	@GET
	@Path("/dashboard")
	public String dashboard() {
		return "Esto es área restringida";
	}

	@GET
	@Path("/settings")
	public String settings() {
		return "Esto es otra área restringida";
	}

}
  • Los 3 endpoints son:
    • /api/login donde vamos a enviar nuestro user y pass, y recibiremos un token de vuelta
    • /api/dashboard que es un área restringida que podemos acceder solo con token
    • /api/settings otra área restringida de ejemplo que solo podemos acceder con token
  • Authenticator.java es un filtro de Jersey. Un filtro es básicamente un método que se invoca cada vez que nuestra API recibe un request o retorna un response, también se les conoce como interceptor ya que captura todas las solicitudes entrantes y salientes pudiendo alterarlas o denegarlas dependiendo de las condiciones que agregues.
public void filter(ContainerRequestContext containerRequest) throws WebApplicationException {
        String path = containerRequest.getUriInfo().getPath();
        String method = containerRequest.getMethod();

        // decimos que /login no requiere autenticación, logicamente :P
        if (method.equals("OPTIONS") || path.equalsIgnoreCase("api/login")) {
            return;
        }

        // extraemos el token de la cabecera HTTP
        String token = containerRequest.getHeaderString("Authorization");

        // si no viene el token, entonces arrojamos 401 no autorizado
        if (token == null) {
            throw new WebApplicationException(Status.UNAUTHORIZED);
        }

        // desencriptamos el token para obtener el usuario y contraseña
        String[] userData;
        try {
            userData = Tokenizer.readToken(token.substring(7));
        } catch (Exception e) {
            throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR);
        }

        // si el usuario y contraseña no son validos, retornamos error 401 no autorizado
        if (!user.equals(userData[0]) || !pass.equals(userData[1])) {
            throw new WebApplicationException(Status.UNAUTHORIZED);
        }
}
  • Para este caso, hemos implementado un filtro sobre el request. Cada vez que recibimos un request, validamos que sea sobre una ruta pública (como /user/login), en caso contrario aplicamos una validación de usuario:
    • extraemos el token de la cabecera HTTP
    • desencriptamos el token y obtenemos el usuario y contraseña
    • validamos que el usuario y la contraseña son los que esperamos
    • si no es así, interceptamos la solicitud
  • Symmetric.java es una clase utilitaria que he preparado para trabajar con encriptación simétrica. La utilizaremos para encriptar y desencriptar la combinación user/pass del usuario.
  • Tokenizer.java es otra clase utilitaria para la emisión y lectura de tokens. Contiene dos métodos:
    • generateToken() recibe un usuario y contraseña, los encripta con una llave simétrica, el resultado lo pasa por base64 y finalmente lo retorna como el token
    • readToken() hace el proceso inverso. Decodifica el token en base64, desencripta el contenido con la llave simétrica y devuelve el usuario y contraseña

Ejemplo de invocación

En el siguiente ejemplo hago el ejercicio de invocar la API primeramente para hacer el login, y luego ya para hacer un request autenticado:

  1. Nos logueamos contra /api/login
  2. Recibimos el token
  3. Ejecutamos un request contra /api/dashboard y recibimos error 401
  4. Seteamos el token sobre la request de /api/dashboard
  5. Ejecutamos y voilá, tenemos acceso!

Conclusiones

En este post se utilizó Jersey para implementar autenticación, pero para la mayoría de los frameworks la implementación es similar:

  • creas una API de login que valide user y pass
  • retornas un token de autenticación
  • añades un interceptor para comprobar que las requests vienen con un token válido

Esto no varía mucho de proyecto a proyecto a menos que uses librerías o servicios de terceros que implementen la seguridad por ti, lo cual si bien no deja de ser una buena opción, siempre es mejor en primera instancia aprender como funcionan estos mecanismos y para eso no hay nada mejor que implementarlos uno mismo. Solo hay que tener ojo de implementar todas las medidas de seguridad posibles.