Inicio > Desarrollo, Seguridad, Web > Login en GWT con OpenID y Spring Security

Login en GWT con OpenID y Spring Security

En la última entrada hablábamos de delegar la autenticación a terceros cuando la gestión de esas credenciales no aportase valor o fuese un requisito evitable. Entre las opciones que existen, una de ellas es el estándar OpenID, ya en su versión 2.0.

El objetivo de esta entrada es crear un aplicativo web que autentique la sesión en el servidor a través de OpenID mediante Google Accounts (Cuentas de Google). Todo ello lo integraremos dentro de la arquitectura de Spring Security. En este caso es un aplicativo web basado en la plataforma GWT; pero el 99% del peso funcional recae sobre Spring, por lo que es muy fácilmente portable a aplicativos web basados en otras tecnologías. Para el desarrollo de las llamadas a OpenID hemos utilizado OpenId4Java.

OpenId

OpenID es un estándar de autenticación descentralizado, que permite a una entidad (Proveedor OpendID) identificar usuarios para una tercera (Tercero de confianza) con la que establece esa asociación de confianza. La gestión de credenciales y los mecanismo de autenticación, quedan por completo bajo la responsabilidad de la primera entidad. El Tercero de confianza acepta las identificaciones que el Proveedor OpenID le ofrece; pero es el responsable de la autorización de acceso a los recursos propios. De esta manera, credenciales y autenticación quedan completamente separados e independientes de recursos y autorización. OpenID no describe el modo en que se realizan cualquiera de estas dos funciones, solo cubre el modo en que ambas entidades se comunican.

En OpenID interactúan entre sí:

  • Agente de usuario, que es el navegador que utiliza el usaurio
  • Tercero de confianza, que es el aplicativo o recurso al que el usuario desea acceder con las credenciales del Proveedor OpenID
  • Proveedor OpenID, que autentica al usuario y envía las credenciales validadas al Tercero de confianza para autorizar el acceso al recurso

La secuencia de interacción entre las diferentes partes es la siguiente:

  1. El usuario desea acceder a un recurso del Tercero de confianza. El tercero tiene que autorizar al usuario, por lo que debe identificarle primero. De entre las opciones para identificarle que se le ofrecen al usuario está a través de OpenID con un Proveedor.
  2. El usuario elige la opción de identificarse mediante OpenID
  3. El Tercero de confianza comienza la búsqueda o descubrimiento de los diferentes Proveedores OpenID
  4. El Tercero de confianza elige uno de los Proveedores y crea una asociación con él. Esta asociación contiene un secreto compartido que protege las sucesivas comunicaciones
  5. El Tercero de confianza, redirige al Agente de usuario al Proveedor OpenID. El Proveedor autentica al usuario y autoriza el acceso a atributos del usuario en el Proveedor si han sido solicitados
  6. El Proveedor OpenID redirige al Agente de usuario a una dirección de retorno preestablecida en el Tercero de confianza, junto con las credenciales del usuario
  7. El Tercero de confianza utiliza estas credenciales para autorizar el acceso al usuario

La solución

El usuario accede al aplicativo GWT y Spring Security, al no estar autenticado, le redirige a la pantalla de login. Entre las opciones de autenticación se le permite al usuario hacerlo mediante Google Accounts (Cuentas de Google). El usuario pulsa el botón o enlace en cuestión y es redirigido a Google para autenticarse.

Debemos primero redirigir al usuario hacia la web del Proveedor OpenID de Google. Para ello debemos crear la URL completa con todos los parámetros que nos redirija. Como queremos asociar la sesión del servidor a estas credenciales, haremos toda esta tarea en el lado del servidor mediante un método RPC de GWT que nos descubra el Proveedor OpenID, nos realice la asociación y nos genere la URL. Este método RPC nos devolverá la URL con los parámetros para redirigir al usuario al servidor de autenticación OpenID.

@UiHandler("googleAccountsButton")
void onGoogleAccountsButtonClick(ClickEvent event) {
	// Invocamos al servicio que nos genera la query string al servidor OpendID
	final LoginServiceAsync loginService = GWT.create(LoginService.class);
	loginService.requestOpenIdLogin(new AsyncCallback() {

		public void onFailure(Throwable error) {
			Window.alert("Error: "+error.getMessage());
		}

		public void onSuccess(String openIDQueryString) {
			// Redirigimos al servidor de autenticación OpenID
			Window.open(openIDQueryString, "_self", "");
		}
	});
}

En el lado del servidor, en la implementación del servicio que invocábamos antes:

  1. Descubrimos los servidores OpenID
  2. Creamos una asociación (que almacenamos en la sesión de usuario), para vincular la sesión del usuario actual con las credenciales que el servidor OpenID nos devuelva
  3. Generamos la URL para redirigir al usuario al servidor OpenID. Esta solicitud contiene además los atributos del usuario que queremos que el Proveedor OpenID nos devuelva. Los atributos del usuario no son más que datos personales asociados a la identidad del Proveedor OpenID. Se recupera mediante una extensión de OpenID, OpenID Attribute Exchange
package com.juanfran.server;

import java.util.List;

import org.openid4java.consumer.ConsumerManager;
import org.openid4java.discovery.DiscoveryInformation;
import org.openid4java.message.AuthRequest;
import org.openid4java.message.ax.FetchRequest;

import com.google.gwt.user.server.rpc.RemoteServiceServlet;
import com.juanfran.client.LoginService;

@SuppressWarnings("serial")
public class LoginServiceImpl extends RemoteServiceServlet implements
		LoginService {

	public String requestOpenIdLogin() throws Exception {
		ConsumerManager manager = new ConsumerManager();

		// Obtenemos la información del servidor de autenticación de OpenID
		@SuppressWarnings("unchecked")
		List discoveries = manager
				.discover("https://www.google.com/accounts/o8/id");

		// Creamos asociación con el servidor y la almacenamos en la sesión del
		// usuario
		DiscoveryInformation discovered = manager.associate(discoveries);
		this.getServletContext().setAttribute("discovered", discovered);

		// Creamos solicitud de atributos (OpenID Attribute Exchange)
		FetchRequest fetch = FetchRequest.createFetchRequest();
		fetch.addAttribute("FirstName", "http://axschema.org/namePerson/first",
				true);
		fetch.addAttribute("LastName", "http://axschema.org/namePerson/last",
				true);
		fetch.addAttribute("Email", "http://axschema.org/contact/email", true);
		fetch.addAttribute("Country",
				"http://axschema.org/contact/country/home", true);
		fetch.addAttribute("Language", "http://axschema.org/pref/language",
				true);
		fetch.addAttribute("data", "http://example.com/schema/data", true);

		// Creamos la petición de autenticación y le añadimos
		AuthRequest authRequest = manager.authenticate(discovered, String
				.format("http://%s:%d/Login/authResponse", this
						.getThreadLocalRequest().getServerName(), this
						.getThreadLocalRequest().getServerPort()));
		authRequest.addExtension(fetch);

		// Devolver la query string para redirigir al servidor de autenticación
		// de OpenID
		return authRequest.getDestinationUrl(true);
	}
}

El código onSuccess() de GWT nos redirige a la URL generada en el servidor. Navegamos al Proveedor OpenID, que en este caso es Google. El mecanismo por el que el servidor OpenID nos autentica está fuera del alcance de OpenID, puede ser cualquiera. Nuestro aplicativo confía en el servidor, en las credenciales que remita y en que el mecanismo de autenticación utilizado es seguro.
El Proveedor OpenID, una vez nos hemos autenticado, nos indica si queremos que el acceso al dominio del Tercero de confianza, 127.0.0.1 (porque está corriendo local en mi máquina), sea autorizado con estas credenciales (Cuenta de Google en este caso). Así mismo también nos informa de que, además de las credenciales (que es un token del que no se puede deducir ninguna información adicional), se solicita acceso a algunos atributos personales de las credenciales como son mi nombre completo, el mail y el idioma. No podemos autorizar unas cosas sí, y otras no; autorizamos o denegamos todo en su conjunto.

Solicitud de permisos en Cuentas de Google

Solicitud de permisos en Cuentas de Google

Si autorizamos el acceso, el navegador nos redirigirá de nuevo a la URL de retorno que pasamos como parámetro en la URL, “/Login/authResponse”. En esta URL dentro de los filtros de Security Spring se encuentra escuchando nuestra implementación de AbstractAuthenticationProcessingFilter. Este filtro:

  1. Recoge los parámetros enviados por el servidor OpenID
  2. Verifica que la autenticación haya sido correcta
  3. Extrae los atributos personales del usuario
  4. Crea el objeto Authentication que contiene las credenciales y las devuelve para que Security Spring las gestione y asocie a la sesión del usuario

El filtro debe devolver una clase Authentication o una excepción si no ha sido posible validar la autenticación.

package com.juanfran.server;

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.openid4java.association.AssociationException;
import org.openid4java.consumer.ConsumerManager;
import org.openid4java.consumer.VerificationResult;
import org.openid4java.discovery.DiscoveryException;
import org.openid4java.discovery.DiscoveryInformation;
import org.openid4java.discovery.Identifier;
import org.openid4java.message.AuthSuccess;
import org.openid4java.message.MessageException;
import org.openid4java.message.ParameterList;
import org.openid4java.message.ax.AxMessage;
import org.openid4java.message.ax.FetchResponse;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;

/**
 * Clase que implementa el filtro de autenticación HTTP para capturar las
 * respuestas con las credenciales del servidor OpenID.
 *
 * @author juanfran
 *
 */
public class OpenIdAuthenticationFilter extends
		AbstractAuthenticationProcessingFilter {

	/**
	 * Constructor del filtro de autenticación
	 *
	 * @param url
	 *            URL a la que se espera que lleguen las peticiones con las
	 *            credenciales para autenticar. En nuestro caso, es la URL de
	 *            retorno que se le indica al OpenID Authentication server para
	 *            que devuelva las credenciales una vez autenticado.
	 */
	public OpenIdAuthenticationFilter(String url) {
		super(url);
	}

	@Override
	public Authentication attemptAuthentication(HttpServletRequest request,
			HttpServletResponse response) throws AuthenticationException,
			IOException, ServletException {
		// Creamos un tipo ParameterList
		ParameterList openIDParams = new ParameterList(
				request.getParameterMap());

		// Recuperamos la asociación de la sesión del usuario
		DiscoveryInformation discovered = (DiscoveryInformation) this
				.getServletContext().getAttribute("discovered");

		// Creamos la URL con parámetros incluidos
		StringBuffer receivingURL = request.getRequestURL();
		String queryString = request.getQueryString();
		if (queryString != null && queryString.length() > 0)
			receivingURL.append("?").append(request.getQueryString());

		try {
			ConsumerManager manager = new ConsumerManager();

			// Verificamos la
			VerificationResult verification = manager.verify(
					receivingURL.toString(), openIDParams, discovered);

			// Obtenemos las credenciales autenticadas
			Identifier verified = verification.getVerifiedId();

			// Si hay credenciales es porque se ha autenticado
			if (verified != null) {
				// Obtenemos los atributos de la cuenta
				AuthSuccess authSuccess = (AuthSuccess) verification
						.getAuthResponse();
				if (authSuccess.hasExtension(AxMessage.OPENID_NS_AX)) {
					FetchResponse fetchResp = (FetchResponse) authSuccess
							.getExtension(AxMessage.OPENID_NS_AX);

					String name = fetchResp.getAttributeValue("FirstName")
					 + " " + fetchResp.getAttributeValue("LastName");
					String mail = fetchResp.getAttributeValue("Email");

					// Almacenamos el mail en sesión por si queremos utilizarlo posteriormente
					request.getSession().setAttribute("mail", mail);

					// Creamos la clase Authentication que almacena las credenciales
					Authentication verifiedAuth = new OpenIdAuthentication(
							mail, verified.getIdentifier());
					verifiedAuth.setAuthenticated(true);
					return verifiedAuth;
				} else {
					// Si no se recogieron atributos
					throw new AuthenticationServiceException(
							"Attributes not retrieved");
				}
				// Si no las hay
			} else {
				throw new BadCredentialsException("Not authenticated");
			}

		} catch (MessageException e) {
			e.printStackTrace();
			throw new AuthenticationServiceException(e.getMessage(), e);
		} catch (DiscoveryException e) {
			e.printStackTrace();
			throw new AuthenticationServiceException(e.getMessage(), e);
		} catch (AssociationException e) {
			e.printStackTrace();
			throw new AuthenticationServiceException(e.getMessage(), e);
		}
	}

}

La clase Authentication creada es una instancia de OpenIdAuthentication. Esta clase implementa Authentication. También implementa Principal, pero es solo para poder devolverse a si misma en el método getPrincipal() y ahorrarnos la implementación de una clase. Por defecto, a todos los usuarios se les asigna el rol ROLE_USER.

package com.juanfran.server;

import java.security.Principal;
import java.util.ArrayList;
import java.util.Collection;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;

/**
 * Clase que contiene las credenciales
 *
 */
@SuppressWarnings("serial")
public class OpenIdAuthentication implements Authentication, Principal {

	private boolean authenticated = false;
	Collection authorities;
	private String name;
	private String openId;

	/**
	 * Constructor.
	 *
	 * @param name
	 *            nombre
	 * @param openId
	 *            URI que identifica la usuario
	 */
	public OpenIdAuthentication(String name, String openId) {
		this.name = name;
		this.openId = openId;
		authorities = new ArrayList();
		//Por defecto se le asigna el role "ROLE_USER"
		authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
	}

	/**
	 * Nombre (de Principal)
	 */
	public String getName() {
		return name;
	}

	/**
	 * Authority o permiso otorgado a estas credenciales
	 */
	public Collection getAuthorities() {
		return authorities;

	}

	/**
	 * Credenciales
	 */
	public Object getCredentials() {
		return openId;
	}

	/**
	 * Detalles de usuario. No implementado
	 */
	public Object getDetails() {
		return null;
	}

	/**
	 * Identidad identificada con estas credenciales
	 */
	public Object getPrincipal() {
		return this;
	}

	/**
	 * Indica cuando ha sido autenticado y verificado
	 */
	public boolean isAuthenticated() {
		return this.authenticated;
	}

	/**
	 * Indica cuando ha sido autenticado y verificado
	 */
	public void setAuthenticated(boolean arg0) throws IllegalArgumentException {
		this.authenticated = arg0;

	}
}

Ahora debemos configurar Spring Security y el Servlet de la llamada RPC en el web.xml.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE web-app
    PUBLIC "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
    "http://java.sun.com/dtd/web-app_2_3.dtd">

<web-app>

	<!-- Spring Security -->
	<filter>
		<filter-name>springSecurityFilterChain</filter-name>
		<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
	</filter>
	<filter-mapping>
		<filter-name>springSecurityFilterChain</filter-name>
		<url-pattern>/*</url-pattern>
	</filter-mapping>
	<listener>
		<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
	</listener>

	<!-- Servlets -->
	<servlet>
		<servlet-name>loginServlet</servlet-name>
		<servlet-class>com.juanfran.server.LoginServiceImpl</servlet-class>
	</servlet>
	<servlet-mapping>
		<servlet-name>loginServlet</servlet-name>
		<url-pattern>/Login/login</url-pattern>
	</servlet-mapping>

	<!-- Default page to serve -->
	<welcome-file-list>
		<welcome-file>Hello.jsp</welcome-file>
	</welcome-file-list>

</web-app>

Por último, creamos todas las Beans y contexto de Spring en el fichero applicationContext.xml.
Como nuestro filtro OpenIdAuthenticationFilter sustituye al tradicional filtro web de usuario y contraseña UsernamePasswordAuthenticationFilter, no podemos utilizar auto-config="true". Esto nos obliga a definir el entry-point. Es una particularidad de configuración de Spring.
El filtro OpenIdAuthenticationFilter tiene referencias a los Handler correspondientes en caso de autenticación correcta y errónea, junto con sus URL de cada caso y su referencia al AuthenticationManager.
Para separar la parte pública de la privada, la aplicación GWT se ha dividido en dos módulos diferenciados, Login y Hello. Login es el encargado de las tareas de autenticación, por eso es accesible anónimamente; y Hello es la parte protegida, que solo es accesible una vez autenticado y autorizado.

<?xml version="1.0" encoding="UTF-8"?>
<beans:beans xmlns="http://www.springframework.org/schema/security"
	xmlns:beans="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
                        http://www.springframework.org/schema/security http://www.springframework.org/schema/security/spring-security-3.1.xsd">

	<http auto-config="false" entry-point-ref="loginUrlAuthenticationEntryPoint">
		<intercept-url pattern="/Login.html" access="IS_AUTHENTICATED_ANONYMOUSLY" />
		<intercept-url pattern="/Login/**" access="IS_AUTHENTICATED_ANONYMOUSLY" />
		<intercept-url pattern="/Hello/**" access="ROLE_USER" />
		<intercept-url pattern="/Hello.jsp" access="ROLE_USER" />
		<intercept-url pattern="/**" access="IS_AUTHENTICATED_ANONYMOUSLY" />
		<logout logout-success-url='/Login.html?gwt.codesvr=127.0.0.1:9997' />
		<custom-filter position="FORM_LOGIN_FILTER" ref="authenticationFilter" />
	</http>

	<beans:bean id="loginUrlAuthenticationEntryPoint"
		class="org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint">
		<beans:property name="loginFormUrl"
			value="/Login.html?gwt.codesvr=127.0.0.1:9997" />
	</beans:bean>

	<beans:bean id="authenticationFilter"
		class="com.juanfran.server.OpenIdAuthenticationFilter">
		<beans:constructor-arg type="String"
			value="/Login/authResponse" />
		<beans:property name="authenticationManager" ref="authenticationManager" />
		<beans:property name="authenticationFailureHandler"
			ref="failureHandler" />
		<beans:property name="authenticationSuccessHandler"
			ref="successHandler" />
	</beans:bean>

	<beans:bean id="successHandler"
		class="org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler">
		<beans:property name="defaultTargetUrl"
			value="/Hello.jsp?gwt.codesvr=127.0.0.1:9997" />
	</beans:bean>
	<beans:bean id="failureHandler"
		class="org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler">
		<beans:property name="defaultFailureUrl"
			value="/Login.html?gwt.codesvr=127.0.0.1:9997" />
	</beans:bean>

	<authentication-manager alias="authenticationManager" />
</beans:beans>

El host y el puerto en que está desplegado el aplicativo se deben a que está configurado para ser ejecutado localmente por el servidor Jetty de GWT. Y el parámetro “gwt.codesvr=127.0.0.1:9997” en todas las URLs se incluye para permitir el debug.

Un saludo.

Licencia Creative Commons
Esta entrada de Juan Francisco Adame Lorite está bajo una licencia Creative Commons Atribución-CompartirIgual 3.0 Unported.

Anuncios
Categorías:Desarrollo, Seguridad, Web Etiquetas: , ,
  1. Pablo
    Miércoles, 4 julio 2012, 23:46 en 23:46

    Saludos. ¿Podrías compartir el proyecto de ejemplo completo de esta entrada?

    Muchas gracias.

    • Juan Francisco Adame Lorite
      Jueves, 5 julio 2012, 23:30 en 23:30

      Hola. Claro, no hay ningún problema. Lo único es que el proyecto está bastante guarrete, no he tenido tiempo de adecentarlo y me da un pelín de vergüenza ;-P. Te lo mando por correo.
      Un saludo,

  2. Pablo
    Martes, 10 julio 2012, 1:05 en 1:05

    Muchisimas gracias. Estoy con un proyecto personal de GWT+GAE y la parte de integrar spring security y separar la aplicación en dos modulos es justo lo que necesitaba. Le echaré un vistazo y ya te contaré.

    Un saludo.

  3. Alexander
    Miércoles, 21 agosto 2013, 5:40 en 5:40

    para un pregunta cuando recuperas la asociación de la sesión del usuario el this.getServletContext().getAttribute(“discovered”) a quien se refieren el this????

    • Lunes, 26 agosto 2013, 23:28 en 23:28

      Hola Alexander,
      Se refiere a la propia clase, LoginServiceImpl; pero la propiedad viene heredada de la clase padre, RemoteServiceServlet.

  4. Alexander
    Miércoles, 28 agosto 2013, 3:27 en 3:27

    a pero me sale un error en esa parte hey que pena molestarte, me puedes pasar las librerías Spring que utilizaste he buscado y encontré unas que no me ayudaron mucho y me sale un error en el log de apache jejeje pero buscando creo que son las librerías

    • Domingo, 1 septiembre 2013, 23:48 en 23:48

      Hola,
      Son la versión 3.1.0.RELEASE. Las librerías no las descargo porque es un proyecto Maven, y es el propio Maven el que se encarga de descargar la versión de la librería que le digas en el pom.xml. En este caso 3.1.0.RELEASE.
      Un saludo,

  5. Alexander
    Miércoles, 28 agosto 2013, 3:30 en 3:30

    y el this me refiero a OpenIdAuthenticationFilter que hereda de AbstractAuthenticationProcessingFilter // Recuperamos la asociación de la sesión del usuario
    DiscoveryInformation discovered = (DiscoveryInformation) this
    .getServletContext().getAttribute(“discovered”); linea 58

    Muchas muchas muchas gracias tu tutorial me ha servido bastante 😀

  1. No trackbacks yet.

Comparte con nosotros tu opinión...

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión / Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión / Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión / Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión / Cambiar )

Conectando a %s

A %d blogueros les gusta esto: