En otras ocasiones esto no resulta ser así, como es el caso que nos ocupa. Se trata de evitar que el usuario repita una contraseña que ya ha sido utilizada previamente. En concreto, se trata de evitar que el usuario reutilice cualquiera de las últimas diez contraseñas.
Este requisito apareció a posteriori sobre un desarrollo que ya utilizaba el provider de membership SQLMembershipProvider.
A la hora de implementar esta nueva funcionalidad, podemos optar por dos caminos:
- Opcion 1: Implementar un provider de Membership que, a la hora de evaluar si una contraseña es válida para su uso, a las comprobaciones ya existentes de longitud, número de simbolos, etc., añada la comprobación de contraseñas repetidas.
- Opción 2: Intentar realizar esta comprobación usando el evento ValidatingPassword de membership, que permite añadir comprobaciones adicionales a medida, que se sumarán a las ya realizadas por defecto por el provider utilizado.
En nuestro caso vamos a optar por la opción 2. Usar el evento ValidatingPassword, junto con algunas modificaciones en la BD y algo de código adicional para dar soporte al almacén de contraseñas antiguas. A grandes rasgos, vamos a:
- Añadir una tabla "aspnet_PassHistory", en la que almacenaremos las contraseñas a medida que se van cambiando.
- Crear dos triggers sobre la tabla "aspnet_Membership", para "capturar" el cambio o creación de contraseñas, y poder así guardar una copia de las contraseñas.
- Un procedimiento almacenado "aspnet_Membership_GetOldPasswords" para obtener los datos de las contraseñas antiguas, para poder así detectar si la nueva contraseña ya fue utilizada.
- Un poco de "reflection" para poder acceder a ciertos métodos del provider que están calificados como "Internal".
Añadir tabla para almacenar contraseñas
Con este script se crea la tabla para almacenar contraseñas:
create table aspnet_PassHistory
(
ApplicationId uniqueidentifier not null,
UserId uniqueidentifier not null,
Password nvarchar(128) not null,
PasswordFormat int not null,
PasswordSalt nvarchar(128) not null,
ChangedOn datetime not null,
constraint PK_aspnet_PassHistory primary key (UserId, ChangedOn)
)
Fijaos en que para cada usuario se almacenarán una colección de contraseñas, pero también el formato en que se codifica (sin codificar, hash, encriptación, etc.), así como el "salt" utilizado para reforzar la seguridad de la encriptación.Además, con el objeto de poder eliminar contraseñas que ya no interesa mantener como "prohibidas", guardamos la fecha en la que se fijó cada contraseña.
Triggers para alimentar la tabla de histórico de contraseñas
Se utilizan dos triggers sobre la tabla "aspnet_Membership". El primero de ellos se encarga de almacenar las contraseñas en la tabla "aspnet_PassHistory" cada vez que una nueva contraseña se inserta / modifica. El segundo se encarga de eliminar las contraseñas guardadas cuando se elimina un usuario de membership.
create trigger aspnet_Membership_PassChange
on aspnet_Membership
for insert, update
as
begin
if update(Password)
begin
insert into aspnet_PassHistory (ApplicationId, UserId, Password, PasswordFormat, PasswordSalt, ChangedOn)
select ApplicationId, UserId, Password, PasswordFormat, PasswordSalt, GetDate() from inserted
end
end
create trigger aspnet_Membership_DeleteUser
on aspnet_Membership
for delete
as
begin
delete from aspnet_PassHistory where UserId in (select UserId from deleted)
end
Este segundo trigger podría sustituirse por una referencia de integridad entre las dos tablas y un borrado en cascada.
Procedimiento almacenado para obtener las últimas "n" contraseñas
El siguiente procedimiento almacenado devuelve, para una aplicación y usuario específico, la relación de contraseñas usadas hasta el momento. Además, dado que recibe un parámetro que determina el número de contraseñas a evaluar, se encarga de hacer limpieza en la tabla, eliminando las contraseñas guardadas que ya no son útiles.
create procedure aspnet_Membership_GetOldPasswords
@ApplicationName nvarchar(256),
@UserId uniqueidentifier,
@NumSavedPasswords int
as
begin
-- Eliminamos las filas más antiguas del usuario, en función del número de contraseñas que se deben guardar
;with RowsToKeep as
(
select top(@NumSavedPasswords) aspnet_PassHistory.ApplicationId, aspnet_PassHistory.UserId, aspnet_PassHistory.ChangedOn
from aspnet_Applications inner join aspnet_PassHistory on aspnet_Applications.ApplicationId = aspnet_PassHistory.ApplicationId
where aspnet_Applications.LoweredApplicationName = Lower(@ApplicationName) and aspnet_PassHistory.UserId = @UserId
order by aspnet_PassHistory.ChangedOn desc
)
delete aspnet_PassHistory
from aspnet_PassHistory left join RowsToKeep
on aspnet_PassHistory.ApplicationId = RowsToKeep.ApplicationId and aspnet_PassHistory.UserId = RowsToKeep.UserId and aspnet_PassHistory.ChangedOn = RowsToKeep.ChangedOn
where RowsToKeep.ChangedOn is null
-- Seleccionamos las contraseñas guardadas restantes
select aspnet_PassHistory.ApplicationId,
aspnet_PassHistory.UserId,
aspnet_PassHistory.Password,
aspnet_PassHistory.PasswordFormat,
aspnet_PassHistory.PasswordSalt,
aspnet_PassHistory.ChangedOn
from aspnet_Applications inner join aspnet_PassHistory on aspnet_Applications.ApplicationId = aspnet_PassHistory.ApplicationId
where aspnet_Applications.LoweredApplicationName = Lower(@ApplicationName) and aspnet_PassHistory.UserId = @UserId
end
Acceder a la tabla de passwords antiguas a través de Entity Framework
Para acceder a la tabla de passwords antiguas se puede usar cualquier tecnología de BD que se considere adecuada, pero en nuestro caso, por ser consistentes con el resto del desarrollo, vamos a hacerlo con Entity Framework, incluyendo elementos en un modelo "Database First", que se llama ComunModel, y que ya tenemos en el proyecto para hacer varias tareas. No voy a entrar en detalles de como se hace cada paso, pero básicamente, hay que hacer lo siguiente:- Añadir en el modelo la tabla "aspnet_PassHistory" y el procedimiento almacenado "aspnet_Membership_GetOldPasswords"
- Renombrar la entidad y el "EntitySet" creado para "aspnet_PassHistory" como "OldPassword" y "OldPasswords" respectivamente.
- Añadir una "Import Function" en el modelo, para poder ejecutar el procedimiento almacenado "aspnet_Membership_GetOldPasswords". A esta nueva "Import Function" la hemos llamado "GetOldPasswords"
Evento ValidatingPassword
El evento Membership.ValidatingPassword se dispara cuando se va a fijar una nueva contraseña. Esto puede ocurrir al crear un nuevo usuario, al cambiar de contraseña, o al resetearla(fijar una nueva contraseña sin conocer la antigua). Vamos a incluir en global.asax.cs el código para hacer esta comprobación.Primero definimos el manejador del evento, en Application_Start:
protected void Application_Start(object sender, EventArgs e)
{
Membership.ValidatingPassword += new MembershipValidatePasswordEventHandler(Membership_ValidatingPassword);
}
Y luego lo implementamos:
void Membership_ValidatingPassword(object sender, ValidatePasswordEventArgs e)
{
e.Cancel = false;
if (!e.IsNewUser)
{
List<OldPassword> passwords = GetOldPasswords();
foreach (OldPassword oldPwd in passwords)
{
string newEncodedPwd = EncodePassword(e.Password, oldPwd.PasswordFormat, oldPwd.PasswordSalt);
if (newEncodedPwd == oldPwd.Password)
{
e.Cancel = true;
e.FailureInformation = new Exception("La contraseña ya ha sido utilizada previamente. Por motivos e seguridad no se pueden repetir.");
break;
}
}
}
}
private string EncodePassword(string password, int passwordFormat, string passwordSalt)
{
MethodInfo dynMethod = Membership.Provider.GetType().GetMethod("EncodePassword", BindingFlags.NonPublic | BindingFlags.Instance);
object methodResult = dynMethod.Invoke(Membership.Provider, new object[] { password, passwordFormat, passwordSalt });
return methodResult as string;
}
List<OldPassword> GetOldPasswords()
{
List<OldPassword> passwords = new List<OldPassword>();
MembershipUser usuarioActual = Membership.GetUser(false);
if (usuarioActual != null)
{
using (ComunModel comunModel = new ComunModel())
{
passwords = comunModel.GetOldPasswords(Membership.ApplicationName, new Guid(usuarioActual.ProviderUserKey.ToString()), Entorno.NumPasswordsAntiguas).ToList();
}
}
return passwords;
}
Codificación de passwords. System.Reflection al rescate.
Dado que las contraseñas en la BD de membership pueden guardarse encriptadas, no podemos comparar alegremente las contraseñas guardadas con la contraseña que intentamos validar, porque si las contraseñas guardadas están almacenadas en "abierto", sin encriptar o sin hacer hash, nunca coincidirían, aun siendo iguales.
Mirando la implementación del provider SQLMembershipProvider publicada por Microsoft, y que es de dominio publico, me di cuenta de que necesitaba llamar al método EncodePassword, con los parámetros adecuados, para calcular la contraseña en el formato adecuado, y así poder compararla con la almacenada.
El problema es que el método EncodePassword está clasificado como "internal", de forma que no es accesible desde ensamblado distinto al del propio provider. Para poder llamar a este método, recurrí a la muy util reflexión, que si que nos permite obtener una referencia a un método privado, internal o protected, y ejecutarlo:
private string EncodePassword(string password, int passwordFormat, string passwordSalt)
{
MethodInfo dynMethod = Membership.Provider.GetType().GetMethod("EncodePassword", BindingFlags.NonPublic | BindingFlags.Instance);
object methodResult = dynMethod.Invoke(Membership.Provider, new object[] { password, passwordFormat, passwordSalt });
return methodResult as string;
}
¿Y en otras implementaciones?
Este esquema podría ser válido para otras implementaciones de MembershipProvider, como por ejemplo, las de Oracle o MySQL, pero habría que hacer algunas tareas de investigación antes de poder afirmarlo categóricamente. En mi opinión habría que:- Crear las tablas scripts adecuados para el sistema de base de datos utilizado.
- Analizar, bien usando el código fuente, bien haciendo ingeniería inversa con, por ejemplo, ILSpy, el provider que queremos extender, para estudiar la forma en que se codifica la contraseña. Puede que incluso hayan hecho el método de codificar contraseñas público.
No hay comentarios:
Publicar un comentario