Archivo

Archive for the ‘Desarrollo’ Category

Los pequeños detalles sí importan

domingo, 23 septiembre 2012, 23:24 Deja un comentario

He descubierto hace poco un blog muy sencillo y minimalista referente a los interfaces de usuario y a ejemplos de detalles de calidad que se pueden encontrar en muchos sitios y software. Se llama Little Big Details.
Cada entrada hace referencia a un ejemplo de característica o funcionalidad de UI, a modo de pildorilla, que muestra una especial calidad o cuidado en su diseño y ejecución y que nos puede servir de ejemplo para cuando nos toque diseñar uno.
Una buena idea y muy práctica.

Categorías: Desarrollo, UI, Web Etiquetas:

Feedback y canales de comunicación en interfaces de usuario (UI)

domingo, 8 abril 2012, 12:42 2 comentarios

Leía y veía esta entrada hace poco en Genbeta:dev y me pareció muy recomendable.


En relación con lo que hablábamos en la entrada anterior la exposición de Bret es muy buena.  Comienza ganándose al público con golpes de efecto: los prototipos que presenta son muy llamativos y representativos de lo que quiere mostrar, nos enseña un buen conjunto de ellos y están enfocados al auditorio para el que habla, que seguro que son desarrolladores en su mayoría.
Una vez tiene el público ganado, desarrolla toda su exposición de manera lineal, como si fuese una historia, explicando cuál es el motivo y motor de su trabajo hasta llegar al concepto del principio (principle) que, según él, mueve toda iniciativa creadora o emprendedora. Este es el punto álgido de la exposición en el que con un entusiasmo contagioso y una credibilidad envidiable cierra su exposición. Lo que trata tiene sustancia y cuerpo (no es humo) y además, en mi opinión, lo presenta muy bien. Si tuviera que sacar un pero, el tiempo, se me antoja un poco larga; pero posiblemente le pidieron intervenir durante una hora.

De lo que trata Bret es de los tiempos de respuesta o feedback de los interfaces de usuario. Se centra en los entornos de desarrollo, pero es extrapolable a otros ámbitos. Si aplicamos la Teoría de la Comunicación Humana, en toda comunicación existe un emisor (el usuario), un receptor (el sistema), un mensaje (qué quiere hacer el usuario), un canal y un código (ambos representados en el interfaz de usuario). El primer axioma de Paul Watzlawick dice «Es imposible no comunicar», pero esto no es siempre es verdad cuando el receptor es una máquina («¿esto se ha quedado colgado?», «¿cuánto falta para que acabe?»). El hilo conductor que mantiene toda conversación es la retroalimentación del receptor al emisor. Los interfaces de usuario llevan trabajando las últimas décadas en este sentido: el relojito de espera de los entornos Windows se creó para indicar al usuario que el equipo estaba realizando una tarea y que no estaba colgado, la barra de estado y de progreso se crearon a continuación para indicar el estado de realización de dicha tarea y hoy en día existen opciones que dan información más completa del estado de la máquina y sus tareas a su interlocutor, como cuadros de diálogo que aventuran una tiempo de finalización.

Volviendo al punto de vista del desarrollo es muy común estar resolviendo problemas en los que, o bien porque su solución es muy compleja o porque la plataforma utilizada no es lo suficientemente eficiente, el retorno que recibimos no llega a tiempo o no nos da suficiente información. Los tiempos de compilación son muy grandes, como para probar cada pequeño cambio; o el despliegue de la solución y el camino a seguir para replicar la situación que queremos comprobar es muy largo. Esto ocasiona que el desarrollador no tenga retorno por parte del sistema del trabajo que está haciendo y reduzca mucho su productividad, puesto que la vuelta atrás en caso de error puede venir muy tarde.

Existen soluciones como hacer un buen diseño en profundidad, pero el trabajo del desarrollador sigue siendo principalmente creativo y un diseño no puede cubrirlo todo. Otras alternativas más adecuadas y elegantes son el uso de pruebas unitarias de restrinjan el alcance y ámbito a probar al punto de desarrollo en que nos encontramos. Su ejecución es mucho más sencilla y rápida que el despliegue y ejecución de toda la solución y nos pueden estar dando feedback incluso en segundo plano mientras desarrollamos. Pero el desarrollo de estas pruebas unitarias, de los conjuntos de datos para lanzarlas y de los mockup necesarios para emular las situaciones a probar requieren un tiempo de desarrollo para nada despreciable y muchas veces análogo al de la solución en ciernes. Es en este punto donde se centra la solución propuesta por Bret, que sea la plataforma la que provea de mecanismos rápidos para la definición en el propio código del alcance de la prueba unitaria y para establecer la condiciones de entorno de la prueba (parámetros de entrada, estado del aplicativo y respuesta esperada). Una idea que mejoraría muchísimo la productividad de los entornos de desarrollo.

Un saludo,

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

Categorías: Desarrollo Etiquetas:

Login en GWT con OpenID y Spring Security

sábado, 4 febrero 2012, 3:00 9 comentarios

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.

Categorías: Desarrollo, Seguridad, Web Etiquetas: , ,
A %d blogueros les gusta esto: