ASP.NET MVC: establece una identidad personalizada o IPrincipal

650

Necesito hacer algo bastante simple: en mi aplicación ASP.NET MVC, quiero establecer un IIdentity / IPrincipal personalizado. Lo que sea más fácil / más adecuado. Quiero extender el valor predeterminado para poder llamar a algo como User.Identity.Idy User.Identity.Role. Nada especial, solo algunas propiedades adicionales.

He leído toneladas de artículos y preguntas, pero siento que lo estoy haciendo más difícil de lo que realmente es. Pensé que iba a ser fácil. Si un usuario inicia sesión, quiero establecer una identidad personalizada. Entonces pensé, lo implementaré Application_PostAuthenticateRequesten mi global.asax. Sin embargo, eso se llama en cada solicitud, y no quiero hacer una llamada a la base de datos en cada solicitud que solicitaría todos los datos de la base de datos y colocaría un objeto IPrincipal personalizado. Eso también parece muy innecesario, lento y en el lugar equivocado (haciendo llamadas a la base de datos allí) pero podría estar equivocado. ¿O de dónde más vendrían esos datos?

Así que pensé, cada vez que un usuario inicia sesión, puedo agregar algunas variables necesarias en mi sesión, que agrego a la identidad personalizada en el Application_PostAuthenticateRequestcontrolador de eventos. Sin embargo, mi Context.Sessionestá nullallí, por lo que tampoco es el camino a seguir.

Llevo un día trabajando en esto y siento que me falta algo. Esto no debería ser demasiado difícil de hacer, ¿verdad? También estoy un poco confundido por todas las cosas (semi) relacionadas que vienen con esto. MembershipProvider, MembershipUser, RoleProvider, ProfileProvider, IPrincipal, IIdentity, FormsAuthentication.... ¿Soy el único que encuentra todo esto muy confuso?

Si alguien pudiera decirme una solución simple, elegante y eficiente para almacenar algunos datos adicionales en una identidad sin toda la confusión adicional ... ¡eso sería genial! Sé que hay preguntas similares sobre SO, pero si la respuesta que necesito está ahí, debo haberla pasado por alto.

Razzie
fuente
1
Hola Domi, es una combinación de almacenar solo datos que nunca cambian (como una ID de usuario) o actualizar la cookie directamente después de que el usuario cambie los datos que deben reflejarse en la cookie de inmediato. Si un usuario hace eso, simplemente actualizo la cookie con los nuevos datos. Pero trato de no almacenar datos que cambian a menudo.
Razzie
26
Esta pregunta tiene 36k vistas y muchos votos a favor. ¿Es realmente un requisito tan común? Y si es así, ¿no hay una mejor manera que todas estas 'cosas personalizadas'?
Simon_Weaver
2
@Simon_Weaver Se conoce la identidad ASP.NET, que admite información personalizada adicional en la cookie cifrada con mayor facilidad.
John
1
Estoy de acuerdo con usted, no hay mucha información al igual que usted envió: MemberShip..., Principal, Identity. ASP.NET debería hacer esto más fácil, simple y como máximo dos enfoques para tratar con la autenticación.
banda ancha
1
@Simon_Weaver Esto muestra claramente que existe una demanda de un sistema de identidad más simple, más flexible, en mi humilde opinión.
niico

Respuestas:

838

Así es como lo hago.

Decidí usar IPrincipal en lugar de IIdentity porque significa que no tengo que implementar tanto IIdentity como IPrincipal.

  1. Crea la interfaz

    interface ICustomPrincipal : IPrincipal
    {
        int Id { get; set; }
        string FirstName { get; set; }
        string LastName { get; set; }
    }
  2. CustomPrincipal

    public class CustomPrincipal : ICustomPrincipal
    {
        public IIdentity Identity { get; private set; }
        public bool IsInRole(string role) { return false; }
    
        public CustomPrincipal(string email)
        {
            this.Identity = new GenericIdentity(email);
        }
    
        public int Id { get; set; }
        public string FirstName { get; set; }
        public string LastName { get; set; }
    }
  3. CustomPrincipalSerializeModel: para serializar información personalizada en el campo de datos de usuario en el objeto FormsAuthenticationTicket.

    public class CustomPrincipalSerializeModel
    {
        public int Id { get; set; }
        public string FirstName { get; set; }
        public string LastName { get; set; }
    }
  4. Método de inicio de sesión: configuración de una cookie con información personalizada

    if (Membership.ValidateUser(viewModel.Email, viewModel.Password))
    {
        var user = userRepository.Users.Where(u => u.Email == viewModel.Email).First();
    
        CustomPrincipalSerializeModel serializeModel = new CustomPrincipalSerializeModel();
        serializeModel.Id = user.Id;
        serializeModel.FirstName = user.FirstName;
        serializeModel.LastName = user.LastName;
    
        JavaScriptSerializer serializer = new JavaScriptSerializer();
    
        string userData = serializer.Serialize(serializeModel);
    
        FormsAuthenticationTicket authTicket = new FormsAuthenticationTicket(
                 1,
                 viewModel.Email,
                 DateTime.Now,
                 DateTime.Now.AddMinutes(15),
                 false,
                 userData);
    
        string encTicket = FormsAuthentication.Encrypt(authTicket);
        HttpCookie faCookie = new HttpCookie(FormsAuthentication.FormsCookieName, encTicket);
        Response.Cookies.Add(faCookie);
    
        return RedirectToAction("Index", "Home");
    }
  5. Global.asax.cs: leyendo la cookie y reemplazando el objeto HttpContext.User, esto se hace anulando PostAuthenticateRequest

    protected void Application_PostAuthenticateRequest(Object sender, EventArgs e)
    {
        HttpCookie authCookie = Request.Cookies[FormsAuthentication.FormsCookieName];
    
        if (authCookie != null)
        {
            FormsAuthenticationTicket authTicket = FormsAuthentication.Decrypt(authCookie.Value);
    
            JavaScriptSerializer serializer = new JavaScriptSerializer();
    
            CustomPrincipalSerializeModel serializeModel = serializer.Deserialize<CustomPrincipalSerializeModel>(authTicket.UserData);
    
            CustomPrincipal newUser = new CustomPrincipal(authTicket.Name);
            newUser.Id = serializeModel.Id;
            newUser.FirstName = serializeModel.FirstName;
            newUser.LastName = serializeModel.LastName;
    
            HttpContext.Current.User = newUser;
        }
    }
  6. Acceso en vistas de maquinilla de afeitar

    @((User as CustomPrincipal).Id)
    @((User as CustomPrincipal).FirstName)
    @((User as CustomPrincipal).LastName)

y en código:

    (User as CustomPrincipal).Id
    (User as CustomPrincipal).FirstName
    (User as CustomPrincipal).LastName

Creo que el código se explica por sí mismo. Si no es así, házmelo saber.

Además, para facilitar aún más el acceso, puede crear un controlador base y anular el objeto Usuario devuelto (HttpContext.User):

public class BaseController : Controller
{
    protected virtual new CustomPrincipal User
    {
        get { return HttpContext.User as CustomPrincipal; }
    }
}

y luego, para cada controlador:

public class AccountController : BaseController
{
    // ...
}

que le permitirá acceder a campos personalizados en código como este:

User.Id
User.FirstName
User.LastName

Pero esto no funcionará dentro de las vistas. Para eso necesitaría crear una implementación personalizada de WebViewPage:

public abstract class BaseViewPage : WebViewPage
{
    public virtual new CustomPrincipal User
    {
        get { return base.User as CustomPrincipal; }
    }
}

public abstract class BaseViewPage<TModel> : WebViewPage<TModel>
{
    public virtual new CustomPrincipal User
    {
        get { return base.User as CustomPrincipal; }
    }
}

Conviértalo en un tipo de página predeterminado en Views / web.config:

<pages pageBaseType="Your.Namespace.BaseViewPage">
  <namespaces>
    <add namespace="System.Web.Mvc" />
    <add namespace="System.Web.Mvc.Ajax" />
    <add namespace="System.Web.Mvc.Html" />
    <add namespace="System.Web.Routing" />
  </namespaces>
</pages>

y en las vistas, puedes acceder de esta manera:

@User.FirstName
@User.LastName
LukeP
fuente
99
Buena implementación; cuidado con RoleManagerModule reemplazando su principal personalizado con un RolePrincipal. Eso me causó mucho dolor - stackoverflow.com/questions/10742259/…
David Keaveny
99
ok, encontré la solución, simplemente agregue un interruptor else que pase "" (cadena vacía) como el correo electrónico y la identidad será anónima.
Pierre-Alain Vigeant
3
DateTime.Now.AddMinutes (N) ... cómo hacer esto para que no cierre la sesión del usuario después de N minutos, ¿puede persistir el usuario conectado (cuando el usuario marca 'Recordarme' por ejemplo)?
1110
44
Si está utilizando el WebApiController, se tendrá que establecer Thread.CurrentPrincipalen Application_PostAuthenticateRequestpara que funcione, ya que no se basa enHttpContext.Current.User
Jonathan Levison
3
@AbhinavGujjar FormsAuthentication.SignOut();funciona bien para mí.
LukeP
109

No puedo hablar directamente para ASP.NET MVC, pero para ASP.NET Web Forms, el truco es crear FormsAuthenticationTickety cifrarlo en una cookie una vez que el usuario se haya autenticado. De esta manera, solo tiene que llamar a la base de datos una vez (o AD o lo que sea que esté utilizando para realizar su autenticación), y cada solicitud posterior se autenticará en función del ticket almacenado en la cookie.

Un buen artículo sobre esto: http://www.ondotnet.com/pub/a/dotnet/2004/02/02/effectiveformsauth.html (enlace roto)

Editar:

Dado que el enlace anterior está roto, recomendaría la solución de LukeP en su respuesta anterior: https://stackoverflow.com/a/10524305 - También sugeriría que la respuesta aceptada se cambie a esa.

Edición 2: Una alternativa para el enlace roto: https://web.archive.org/web/20120422011422/http://ondotnet.com/pub/a/dotnet/2004/02/02/effectiveformsauth.html

John Rasch
fuente
Viniendo de PHP, siempre he puesto la información como ID de usuario y otras piezas necesarias para otorgar acceso restringido en la sesión. Almacenarlo en el lado del cliente me pone nervioso, ¿puedes comentar por qué eso no será un problema?
John Zumbrum
@JohnZ: el ticket en sí está encriptado en el servidor antes de enviarse por cable, por lo que no es como si el cliente tuviera acceso a los datos almacenados dentro del ticket. Tenga en cuenta que los ID de sesión también se almacenan en una cookie, por lo que en realidad no es tan diferente.
John Rasch
3
Si estás aquí, deberías mirar la solución de
LukeP
2
Siempre me ha preocupado el potencial de exceder el tamaño máximo de cookie ( stackoverflow.com/questions/8706924/… ) con este enfoque. Tiendo a usar el Cachecomo un Sessionreemplazo para mantener los datos en el servidor. ¿Alguien puede decirme si este es un enfoque defectuoso?
Red Taz
2
Buen enfoque. Un problema potencial con esto es si su objeto de usuario tiene más de unas pocas propiedades (y especialmente si hay objetos anidados), la creación de la cookie fallará en silencio una vez que el valor cifrado sea superior a 4KB (mucho más fácil de pegar de lo que podría pensar). Si solo almacena datos clave, está bien, pero aún tendría que presionar DB para el resto. Otra consideración es "actualizar" los datos de cookies cuando el objeto del usuario tiene cambios de firma o lógica.
Geoffrey Hudik
63

Aquí hay un ejemplo para hacer el trabajo. bool isValid se configura mirando algún almacén de datos (digamos su base de datos de usuario). UserID es solo una ID que estoy manteniendo. Puede agregar información adicional como dirección de correo electrónico a los datos del usuario.

protected void btnLogin_Click(object sender, EventArgs e)
{         
    //Hard Coded for the moment
    bool isValid=true;
    if (isValid) 
    {
         string userData = String.Empty;
         userData = userData + "UserID=" + userID;
         FormsAuthenticationTicket ticket = new FormsAuthenticationTicket(1, username, DateTime.Now, DateTime.Now.AddMinutes(30), true, userData);
         string encTicket = FormsAuthentication.Encrypt(ticket);
         HttpCookie faCookie = new HttpCookie(FormsAuthentication.FormsCookieName, encTicket);
         Response.Cookies.Add(faCookie);
         //And send the user where they were heading
         string redirectUrl = FormsAuthentication.GetRedirectUrl(username, false);
         Response.Redirect(redirectUrl);
     }
}

en el asabal golbal agregue el siguiente código para recuperar su información

protected void Application_AuthenticateRequest(Object sender, EventArgs e)
{
    HttpCookie authCookie = Request.Cookies[
             FormsAuthentication.FormsCookieName];
    if(authCookie != null)
    {
        //Extract the forms authentication cookie
        FormsAuthenticationTicket authTicket = 
               FormsAuthentication.Decrypt(authCookie.Value);
        // Create an Identity object
        //CustomIdentity implements System.Web.Security.IIdentity
        CustomIdentity id = GetUserIdentity(authTicket.Name);
        //CustomPrincipal implements System.Web.Security.IPrincipal
        CustomPrincipal newUser = new CustomPrincipal();
        Context.User = newUser;
    }
}

Cuando vaya a utilizar la información más adelante, puede acceder a su principal personalizado de la siguiente manera.

(CustomPrincipal)this.User
or 
(CustomPrincipal)this.Context.User

esto le permitirá acceder a información personalizada del usuario.

Sriwantha Attanayake
fuente
2
FYI - es Request.Cookies [] (plural)
Dan Esparza
10
No olvide establecer Thread.CurrentPrincipal y Context.User en CustomPrincipal.
Russ Cam
66
¿De dónde viene GetUserIdentity ()?
Ryan
Como mencioné en el comentario, ofrece una implementación de System.Web.Security.IIdentity. Google sobre esa interfaz
Sriwantha Attanayake
16

MVC le proporciona el método OnAuthorize que se cuelga de sus clases de controlador. O bien, puede usar un filtro de acción personalizado para realizar la autorización. MVC lo hace bastante fácil de hacer. Publiqué una publicación de blog sobre esto aquí. http://www.bradygaster.com/post/custom-authentication-with-mvc-3.0

brady gaster
fuente
Pero la sesión se puede perder y el usuario aún se autentica. No ?
Dragouf
@brady gaster, leí tu publicación de blog (¡gracias!), ¿Por qué alguien usaría la anulación "OnAuthorize ()" como se menciona en tu publicación sobre la entrada global.asax "... AuthenticateRequest (..)" mencionada por el otro respuestas? ¿Se prefiere uno sobre el otro para establecer el usuario principal?
RayLoveless
10

Aquí hay una solución si necesita conectar algunos métodos a @User para usarlos en sus vistas. No hay solución para una personalización seria de la membresía, pero si la pregunta original fuera necesaria solo para las vistas, entonces esto quizás sería suficiente. Lo siguiente se usó para verificar una variable devuelta desde un filtro de autorización, para verificar si algunos enlaces no se presentaban o no (no para ningún tipo de lógica de autorización o concesión de acceso).

using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Web;
    using System.Security.Principal;

    namespace SomeSite.Web.Helpers
    {
        public static class UserHelpers
        {
            public static bool IsEditor(this IPrincipal user)
            {
                return null; //Do some stuff
            }
        }
    }

Luego, simplemente agregue una referencia en las áreas web.config y llámela como se muestra a continuación en la vista.

@User.IsEditor()
Base
fuente
1
En su solución, nuevamente necesitamos hacer llamadas a la base de datos cada vez. Porque el objeto de usuario no tiene propiedades personalizadas. Solo tiene Nombre e IsAuthanticated
oneNiceFriend
Eso depende completamente de su implementación y comportamiento deseado. Mi muestra contiene 0 líneas de base de datos, o rol, lógica. Si uno usa IsInRole, a su vez, podría almacenarse en caché en una cookie, creo. O implementa su propia lógica de almacenamiento en caché.
Base
3

Basado en la respuesta de LukeP , y agregue algunos métodos para configurar timeouty requireSSLcooperar Web.config.

Los enlaces de referencias

Códigos modificados de LukeP

1, conjunto timeoutbasado en Web.Config. El FormsAuthentication.Timeout obtendrá el valor de tiempo de espera, que se define en web.config. Envolví los siguientes para ser una función, que devuelve un ticketrespaldo.

int version = 1;
DateTime now = DateTime.Now;

// respect to the `timeout` in Web.config.
TimeSpan timeout = FormsAuthentication.Timeout;
DateTime expire = now.Add(timeout);
bool isPersist = false;

FormsAuthenticationTicket ticket = new FormsAuthenticationTicket(
     version,          
     name,
     now,
     expire,
     isPersist,
     userData);

2, configure la cookie para que sea segura o no, según la RequireSSLconfiguración.

HttpCookie faCookie = new HttpCookie(FormsAuthentication.FormsCookieName, encTicket);
// respect to `RequreSSL` in `Web.Config`
bool bSSL = FormsAuthentication.RequireSSL;
faCookie.Secure = bSSL;
AechoLiu
fuente
3

Muy bien, entonces soy un criptólogo serio arrastrando esta vieja pregunta, pero hay un enfoque mucho más simple para esto, que fue mencionado por @Baserz arriba. Y eso es usar una combinación de métodos de extensión de C # y almacenamiento en caché (NO use sesión).

De hecho, Microsoft ya ha proporcionado varias extensiones de este tipo en el Microsoft.AspNet.Identity.IdentityExtensionsespacio de nombres. Por ejemplo, GetUserId()es un método de extensión que devuelve el ID de usuario. También hay GetUserName()y FindFirstValue(), que devuelve reclamos basados ​​en IPrincipal.

Por lo tanto, solo necesita incluir el espacio de nombres y luego llamar User.Identity.GetUserName()para obtener el nombre de los usuarios según lo configurado por ASP.NET Identity.

No estoy seguro de si esto se almacena en caché, ya que la identidad ASP.NET anterior no es de código abierto, y no me he molestado en realizar ingeniería inversa. Sin embargo, si no es así, puede escribir su propio método de extensión, que almacenará en caché este resultado durante un período de tiempo específico.

Erik Funkenbusch
fuente
¿Por qué "no usar sesión"?
Alex
@jitbit: porque la sesión no es confiable e insegura. Por la misma razón, nunca debe usar la sesión por motivos de seguridad.
Erik Funkenbusch
"No confiable" puede abordarse repoblando la sesión (si está vacía). "No seguro": hay formas de protegerse del secuestro de sesión (mediante HTTPS solo + otras formas). Pero en realidad estoy de acuerdo contigo. ¿Dónde lo guardarías entonces? ¿Información como IsUserAdministratoro UserEmailetc.? Estas pensando HttpRuntime.Cache?
Alex
@jitbit: esa es una opción u otra solución de almacenamiento en caché si la tiene. Asegurarse de caducar la entrada de caché después de un período de tiempo. La inseguridad también se aplica al sistema local, ya que puede modificar manualmente la cookie y adivinar las ID de sesión. El hombre en el medio no es la única preocupación.
Erik Funkenbusch
2

Como una adición al código LukeP para usuarios de formularios web (no MVC) si desea simplificar el acceso en el código detrás de sus páginas, simplemente agregue el siguiente código a una página base y obtenga la página base en todas sus páginas:

Public Overridable Shadows ReadOnly Property User() As CustomPrincipal
    Get
        Return DirectCast(MyBase.User, CustomPrincipal)
    End Get
End Property

Entonces, en su código detrás, simplemente puede acceder a:

User.FirstName or User.LastName

Lo que me falta en un escenario de formulario web es cómo obtener el mismo comportamiento en el código que no está vinculado a la página, por ejemplo, en httpmodules, ¿ debería agregar siempre un elenco en cada clase o hay una forma más inteligente de obtener esto?

Gracias por sus respuestas y gracias a LukeP desde que utiliza sus ejemplos como base para mi usuario personalizado (que ahora tiene User.Roles, User.Tasks, User.HasPath(int), User.Settings.Timeouty muchas otras cosas buenas)

Manight
fuente
0

Probé la solución sugerida por LukeP y descubrí que no es compatible con el atributo Autorizar. Entonces, lo modifiqué un poco.

public class UserExBusinessInfo
{
    public int BusinessID { get; set; }
    public string Name { get; set; }
}

public class UserExInfo
{
    public IEnumerable<UserExBusinessInfo> BusinessInfo { get; set; }
    public int? CurrentBusinessID { get; set; }
}

public class PrincipalEx : ClaimsPrincipal
{
    private readonly UserExInfo userExInfo;
    public UserExInfo UserExInfo => userExInfo;

    public PrincipalEx(IPrincipal baseModel, UserExInfo userExInfo)
        : base(baseModel)
    {
        this.userExInfo = userExInfo;
    }
}

public class PrincipalExSerializeModel
{
    public UserExInfo UserExInfo { get; set; }
}

public static class IPrincipalHelpers
{
    public static UserExInfo ExInfo(this IPrincipal @this) => (@this as PrincipalEx)?.UserExInfo;
}


    [HttpPost]
    [AllowAnonymous]
    [ValidateAntiForgeryToken]
    public async Task<ActionResult> Login(LoginModel details, string returnUrl)
    {
        if (ModelState.IsValid)
        {
            AppUser user = await UserManager.FindAsync(details.Name, details.Password);

            if (user == null)
            {
                ModelState.AddModelError("", "Invalid name or password.");
            }
            else
            {
                ClaimsIdentity ident = await UserManager.CreateIdentityAsync(user, DefaultAuthenticationTypes.ApplicationCookie);
                AuthManager.SignOut();
                AuthManager.SignIn(new AuthenticationProperties { IsPersistent = false }, ident);

                user.LastLoginDate = DateTime.UtcNow;
                await UserManager.UpdateAsync(user);

                PrincipalExSerializeModel serializeModel = new PrincipalExSerializeModel();
                serializeModel.UserExInfo = new UserExInfo()
                {
                    BusinessInfo = await
                        db.Businesses
                        .Where(b => user.Id.Equals(b.AspNetUserID))
                        .Select(b => new UserExBusinessInfo { BusinessID = b.BusinessID, Name = b.Name })
                        .ToListAsync()
                };

                JavaScriptSerializer serializer = new JavaScriptSerializer();

                string userData = serializer.Serialize(serializeModel);

                FormsAuthenticationTicket authTicket = new FormsAuthenticationTicket(
                         1,
                         details.Name,
                         DateTime.Now,
                         DateTime.Now.AddMinutes(15),
                         false,
                         userData);

                string encTicket = FormsAuthentication.Encrypt(authTicket);
                HttpCookie faCookie = new HttpCookie(FormsAuthentication.FormsCookieName, encTicket);
                Response.Cookies.Add(faCookie);

                return RedirectToLocal(returnUrl);
            }
        }
        return View(details);
    }

Y finalmente en Global.asax.cs

    protected void Application_PostAuthenticateRequest(Object sender, EventArgs e)
    {
        HttpCookie authCookie = Request.Cookies[FormsAuthentication.FormsCookieName];

        if (authCookie != null)
        {
            FormsAuthenticationTicket authTicket = FormsAuthentication.Decrypt(authCookie.Value);
            JavaScriptSerializer serializer = new JavaScriptSerializer();
            PrincipalExSerializeModel serializeModel = serializer.Deserialize<PrincipalExSerializeModel>(authTicket.UserData);
            PrincipalEx newUser = new PrincipalEx(HttpContext.Current.User, serializeModel.UserExInfo);
            HttpContext.Current.User = newUser;
        }
    }

Ahora puedo acceder a los datos en vistas y controladores simplemente llamando

User.ExInfo()

Para cerrar sesión solo llamo

AuthManager.SignOut();

donde está AuthManager

HttpContext.GetOwinContext().Authentication
Vasily Ivanov
fuente