ASP.NET MVC - Définir IIdentity personnalisé ou IPrincipal


650

Je dois faire quelque chose d'assez simple: dans mon application ASP.NET MVC, je veux définir un IIdentity / IPrincipal personnalisé. Selon ce qui est plus facile / plus approprié. Je souhaite étendre la valeur par défaut afin de pouvoir appeler quelque chose comme User.Identity.Idet User.Identity.Role. Rien d'extraordinaire, juste quelques propriétés supplémentaires.

J'ai lu des tonnes d'articles et de questions, mais j'ai l'impression de rendre les choses plus difficiles qu'elles ne le sont en réalité. Je pensais que ce serait facile. Si un utilisateur se connecte, je souhaite définir une IIdentité personnalisée. J'ai donc pensé, je vais implémenter Application_PostAuthenticateRequestdans mon global.asax. Cependant, cela est appelé à chaque demande, et je ne veux pas appeler la base de données à chaque demande qui demanderait toutes les données de la base de données et mettrait un objet IPrincipal personnalisé. Cela semble également très inutile, lent et au mauvais endroit (faire des appels de base de données là-bas) mais je peux me tromper. Ou d'où proviendraient ces données?

J'ai donc pensé que chaque fois qu'un utilisateur se connecte, je peux ajouter des variables nécessaires dans ma session, que j'ajoute à la IIdentity personnalisée dans le Application_PostAuthenticateRequestgestionnaire d'événements. Cependant, mon Context.Sessionest nulllà, donc ce n'est pas non plus la voie à suivre.

Je travaille là-dessus depuis un jour maintenant et je sens que je manque quelque chose. Cela ne devrait pas être trop difficile à faire, non? Je suis également un peu dérouté par toutes les choses (semi) liées à cela. MembershipProvider, MembershipUser, RoleProvider, ProfileProvider, IPrincipal, IIdentity, FormsAuthentication.... Suis - je le seul qui trouve tout cela très confus?

Si quelqu'un pouvait me dire une solution simple, élégante et efficace pour stocker des données supplémentaires sur une IIdentity sans tout le fuzz supplémentaire .. ce serait génial! Je sais qu'il y a des questions similaires sur SO mais si la réponse dont j'ai besoin est là-dedans, j'ai dû l'oublier.


1
Salut Domi, c'est une combinaison de stocker uniquement des données qui ne changent jamais (comme un ID utilisateur) ou de mettre à jour le cookie directement après que l'utilisateur a modifié immédiatement les données qui doivent être reflétées dans le cookie. Si un utilisateur le fait, je mets simplement à jour le cookie avec les nouvelles données. Mais j'essaie de ne pas stocker de données qui changent souvent.
Razzie

26
cette question a 36k vues et beaucoup de votes positifs. est-ce vraiment une exigence si commune - et si oui, n'y a-t-il pas un meilleur moyen que tous ces «trucs personnalisés»?
Simon_Weaver

2
@Simon_Weaver Il y a ASP.NET Identity know, qui prend en charge plus facilement des informations personnalisées supplémentaires dans le cookie chiffré.
John

1
Je suis d' accord avec vous, il y a à l' information un peu comme vous avez publié: MemberShip..., Principal, Identity. ASP.NET devrait rendre cela plus facile, plus simple et au plus deux approches pour gérer l'authentification.
haut débit

1
@Simon_Weaver Cela montre clairement qu'il existe une demande pour un système d'identité plus simple, plus flexible et à mon humble avis.
niico

Réponses:


838

Voici comment je le fais.

J'ai décidé d'utiliser IPrincipal au lieu de IIdentity car cela signifie que je n'ai pas à implémenter à la fois IIdentity et IPrincipal.

  1. Créer l'interface

    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 - pour sérialiser des informations personnalisées dans le champ de données utilisateur dans l'objet FormsAuthenticationTicket.

    public class CustomPrincipalSerializeModel
    {
        public int Id { get; set; }
        public string FirstName { get; set; }
        public string LastName { get; set; }
    }
    
  4. Méthode de connexion - configuration d'un cookie avec des informations personnalisées

    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 - Lecture du cookie et remplacement de l'objet HttpContext.User, cela se fait en remplaçant 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. Accès dans les vues Razor

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

et en code:

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

Je pense que le code est explicite. Si ce n'est pas le cas, faites-le moi savoir.

De plus, pour rendre l'accès encore plus facile, vous pouvez créer un contrôleur de base et remplacer l'objet utilisateur renvoyé (HttpContext.User):

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

puis, pour chaque contrôleur:

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

qui vous permettra d'accéder à des champs personnalisés en code comme celui-ci:

User.Id
User.FirstName
User.LastName

Mais cela ne fonctionnera pas dans les vues. Pour cela, vous devez créer une implémentation WebViewPage personnalisée:

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; }
    }
}

Faites-en un type de page par défaut dans 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>

et dans les vues, vous pouvez y accéder comme ceci:

@User.FirstName
@User.LastName

9
Belle mise en œuvre; faites attention à RoleManagerModule remplaçant votre principal personnalisé par un RolePrincipal. Cela m'a causé beaucoup de douleur - stackoverflow.com/questions/10742259/…
David Keaveny

9
ok j'ai trouvé la solution, il suffit d'ajouter un commutateur else qui passe "" (chaîne vide) car l'email et l'identité seront anonymes.
Pierre-Alain Vigeant

3
DateTime.Now.AddMinutes (N) ... comment faire pour qu'il ne déconnecte pas l'utilisateur après N minutes, l'utilisateur connecté peut-il être persisté (lorsque l'utilisateur coche 'Remember Me' par exemple)?
1110

4
Si vous utilisez le WebApiController, vous aurez besoin de mettre Thread.CurrentPrincipalà Application_PostAuthenticateRequestpour que cela fonctionne car elle ne repose surHttpContext.Current.User
Jonathan Levison

3
@AbhinavGujjar FormsAuthentication.SignOut();fonctionne très bien pour moi.
LukeP

109

Je ne peux pas parler directement pour ASP.NET MVC, mais pour ASP.NET Web Forms, l'astuce consiste à créer un FormsAuthenticationTicketet à le chiffrer dans un cookie une fois que l'utilisateur a été authentifié. De cette façon, vous n'avez qu'à appeler la base de données une fois (ou AD ou tout ce que vous utilisez pour effectuer votre authentification), et chaque demande suivante s'authentifiera en fonction du ticket stocké dans le cookie.

Un bon article à ce sujet: http://www.ondotnet.com/pub/a/dotnet/2004/02/02/effectiveformsauth.html (lien brisé)

Éditer:

Étant donné que le lien ci-dessus est rompu, je recommanderais la solution de LukeP dans sa réponse ci-dessus: https://stackoverflow.com/a/10524305 - Je suggérerais également que la réponse acceptée soit remplacée par celle-ci.

Edit 2: une alternative pour le lien brisé: https://web.archive.org/web/20120422011422/http://ondotnet.com/pub/a/dotnet/2004/02/02/effectiveformsauth.html


Venant de PHP, j'ai toujours mis les informations comme UserID et autres éléments nécessaires pour accorder un accès restreint dans Session. Le stocker côté client me rend nerveux, pouvez-vous expliquer pourquoi cela ne sera pas un problème?
John Zumbrum

@JohnZ - le ticket lui-même est crypté sur le serveur avant d'être envoyé sur le câble, donc ce n'est pas comme si le client allait avoir accès aux données stockées dans le ticket. Notez que les ID de session sont également stockés dans un cookie, donc ce n'est pas vraiment si différent.
John Rasch

3
Si vous êtes ici, vous devriez regarder la solution de
LukeP

2
J'ai toujours été préoccupé par le potentiel de dépassement de la taille maximale des cookies ( stackoverflow.com/questions/8706924/… ) avec cette approche. J'ai tendance à utiliser le Cachecomme Sessionremplacement pour conserver les données sur le serveur. Quelqu'un peut-il me dire s'il s'agit d'une approche défectueuse?
Red Taz

2
Belle approche. Un problème potentiel avec cela est que si votre objet utilisateur a plus de quelques propriétés (et surtout si des objets imbriqués), la création du cookie échouera silencieusement une fois que la valeur chiffrée est supérieure à 4 Ko (beaucoup plus facile à frapper alors vous pourriez penser). Si vous ne stockez que des données clés, c'est bien, mais vous devrez alors appuyer sur DB pour le reste. Une autre considération est la «mise à niveau» des données de cookie lorsque l'objet utilisateur a des modifications de signature ou de logique.
Geoffrey Hudik

63

Voici un exemple pour faire le travail. bool isValid est défini en regardant une banque de données (disons votre base de données utilisateur). UserID est juste un ID que je conserve. Vous pouvez ajouter des informations supplémentaires telles que l'adresse e-mail aux données utilisateur.

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);
     }
}

dans le golbal asax ajoutez le code suivant pour retrouver vos informations

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;
    }
}

Lorsque vous allez utiliser les informations ultérieurement, vous pouvez accéder à votre principal personnalisé comme suit.

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

cela vous permettra d'accéder aux informations utilisateur personnalisées.


2
Pour info - c'est Request.Cookies [] (pluriel)
Dan Esparza

10
N'oubliez pas de définir Thread.CurrentPrincipal ainsi que Context.User sur CustomPrincipal.
Russ Cam

6
D'où vient GetUserIdentity ()?
Ryan

Comme je l'ai mentionné dans le commentaire, cela donne une implémentation de System.Web.Security.IIdentity. Google à propos de cette interface
Sriwantha Attanayake

16

MVC vous fournit la méthode OnAuthorize qui se bloque à partir de vos classes de contrôleur. Vous pouvez également utiliser un filtre d'action personnalisé pour effectuer l'autorisation. MVC le rend assez facile à faire. J'ai posté un blog à ce sujet ici. http://www.bradygaster.com/post/custom-authentication-with-mvc-3.0


Mais la session peut être perdue et l'utilisateur peut toujours s'authentifier. Non ?
Dragouf

@brady gaster, j'ai lu votre article de blog (merci!), Pourquoi quelqu'un utiliserait-il le remplacement "OnAuthorize ()" comme mentionné sur votre article sur l'entrée global.asax "... AuthenticateRequest (..)" mentionné par l'autre réponses? Est-ce que l'un est préféré à l'autre pour définir l'utilisateur principal?
RayLoveless

10

Voici une solution si vous devez connecter certaines méthodes à @User pour les utiliser dans vos vues. Aucune solution pour une personnalisation sérieuse de l'adhésion, mais si la question d'origine était nécessaire pour les seules vues, cela suffirait peut-être. Ce qui suit a été utilisé pour vérifier une variable renvoyée par un filtre d'autorisation, utilisé pour vérifier si certains liens devaient être présentés ou non (pas pour tout type de logique d'autorisation ou d'octroi d'accès).

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
            }
        }
    }

Ensuite, ajoutez simplement une référence dans les zones web.config, et appelez-la comme ci-dessous dans la vue.

@User.IsEditor()

1
Dans votre solution, nous devons à nouveau effectuer des appels de base de données à chaque fois. Parce que l'objet utilisateur n'a pas de propriétés personnalisées. Il n'a que Name et IsAuthanticated
oneNiceFriend

Cela dépend entièrement de votre implémentation et du comportement souhaité. Mon échantillon contient 0 ligne de logique de base de données ou de rôle. Si l'on utilise IsInRole, il pourrait à son tour être mis en cache dans un cookie, je crois. Ou vous implémentez votre propre logique de mise en cache.
Base

3

Basé sur la réponse de LukeP , et ajoutez quelques méthodes de configuration timeoutet de requireSSLcoopération Web.config.

Les liens de références

Codes modifiés de LukeP

1, ensemble timeoutbasé sur Web.Config. Le FormsAuthentication.Timeout obtiendra la valeur de délai d' attente, qui est défini dans web.config. J'ai encapsulé les éléments suivants pour être une fonction, qui renvoie un ticketretour.

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, configurez le cookie pour qu'il soit sécurisé ou non, en fonction de la RequireSSLconfiguration.

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

3

Très bien, donc je suis un chiffreur sérieux ici en faisant glisser cette très vieille question, mais il y a une approche beaucoup plus simple à cela, qui a été abordée par @Baserz ci-dessus. Et cela consiste à utiliser une combinaison de méthodes d'extension C # et de mise en cache (NE PAS utiliser de session).

En fait, Microsoft a déjà fourni un certain nombre de ces extensions dans l' Microsoft.AspNet.Identity.IdentityExtensionsespace de noms. Par exemple, GetUserId()est une méthode d'extension qui renvoie l'ID utilisateur. Il existe également GetUserName()et FindFirstValue(), qui renvoie des réclamations basées sur l'IPrincipal.

Il vous suffit donc d'inclure l'espace de noms, puis d'appeler User.Identity.GetUserName()pour obtenir le nom des utilisateurs tel que configuré par ASP.NET Identity.

Je ne suis pas certain si cela est mis en cache, car l'ancienne identité ASP.NET n'est pas open source, et je n'ai pas pris la peine de procéder à une rétro-ingénierie. Cependant, si ce n'est pas le cas, vous pouvez écrire votre propre méthode d'extension, qui mettra en cache ce résultat pendant une durée spécifique.


Pourquoi "ne pas utiliser de session"?
Alex

@jitbit - car la session n'est pas fiable et n'est pas sécurisée. Pour la même raison, vous ne devez jamais utiliser la session à des fins de sécurité.
Erik Funkenbusch

"Non fiable" peut être résolu en remplissant de nouveau la session (si vide). "Non sécurisé" - il existe des moyens de se protéger contre le détournement de session (en utilisant uniquement HTTPS + d'autres moyens). Mais je suis en fait d'accord avec vous. Où le mettriez-vous en cache alors? Des informations comme IsUserAdministratorou UserEmailetc.? Tu penses HttpRuntime.Cache?
Alex

@jitbit - C'est une option, ou une autre solution de mise en cache si vous l'avez. Veillez à expirer l'entrée de cache après un certain temps. Insecure s'applique également au système local, car vous pouvez modifier manuellement le cookie et deviner les ID de session. L'homme au milieu n'est pas la seule préoccupation.
Erik Funkenbusch

2

En plus du code LukeP pour les utilisateurs de Web Forms (pas MVC) si vous souhaitez simplifier l'accès dans le code derrière vos pages, ajoutez simplement le code ci-dessous à une page de base et dérivez la page de base dans toutes vos pages:

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

Ainsi, dans votre code derrière, vous pouvez simplement accéder à:

User.FirstName or User.LastName

Ce qui me manque dans un scénario de formulaire Web, c'est comment obtenir le même comportement dans du code non lié à la page, par exemple dans httpmodules dois-je toujours ajouter un cast dans chaque classe ou existe-t-il un moyen plus intelligent pour l'obtenir?

Merci pour vos réponses et merci à LukeP que j'ai utilisé vos exemples comme base pour mon utilisateur personnalisée (qui a maintenant User.Roles, User.Tasks, User.HasPath(int), User.Settings.Timeoutet bien d' autres choses belles)


0

J'ai essayé la solution suggérée par LukeP et j'ai constaté qu'elle ne prend pas en charge l'attribut Authorize. Donc, je l'ai un peu modifié.

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);
    }

Et enfin dans 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;
        }
    }

Maintenant, je peux accéder aux données dans les vues et les contrôleurs simplement en appelant

User.ExInfo()

Pour me déconnecter, j'appelle

AuthManager.SignOut();

où AuthManager est

HttpContext.GetOwinContext().Authentication
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.