Skip to content
This repository has been archived by the owner on Dec 13, 2022. It is now read-only.

Documentation on How to Support Roles using ASP.Net Core 2.0 MVC and IdentityServer4 #1786

Closed
matthewDDennis opened this issue Nov 21, 2017 · 16 comments
Labels

Comments

@matthewDDennis
Copy link

I have been Googling and reading the code but am still unable to figure out the necessary steps to use Role Authorization in MVC using IdentityServer4 V2 as the token server. Could you please explain how to do this in step by step detail. The are number of different answers that appear to have parts of the answer, but nothing that seems to work.

I have

  1. created an Identity Server using the IdentityServer4.Samples/Quickstarts/Combined_AspNetIdentity_and_EntityFrameworkStorage sample
  2. added an IdentityResource, "roles" with UserClaims = new[] { JwtClaimTypes.Role, ClaimTypes.Role } and it shows up in the discovery document is the scopes_supported with the claims show up in the claims_supported.
        private static IdentityResource rolesResource = new IdentityResource
        {
            Name = "roles",
            DisplayName = "Roles",
            Description = "Allow the service access to your user roles.",
            UserClaims = new[] { JwtClaimTypes.Role, ClaimTypes.Role },
            ShowInDiscoveryDocument = true,
            Required = true,
            Emphasize = true
        };
  1. created an MVC client that includes the Roles IdentityResource
                new Client
                {
                    ClientId      = "mvc",
                    ClientName    = "MVC Client",
                    ClientSecrets = new List<Secret>
                    {
                        new Secret(mvcSecret.Sha256())
                    },
                    ClientUri          = $"{mvcClientUrl}", // public uri of the client
                    AllowedGrantTypes  = GrantTypes.Hybrid,
                    RequireConsent     = false,
                    AllowOfflineAccess = true,
                    RedirectUris       = new List<string>
                    {
                        $"{mvcClientUrl}/signin-oidc"
                    },
                    FrontChannelLogoutUri  = $"{mvcClientUrl}/signout-oidc",
                    PostLogoutRedirectUris = new List<string>
                    {
                        $"{mvcClientUrl}/signout-callback-oidc"
                    },

                    AllowedScopes = new List<string>
                    {
                        IdentityServerConstants.StandardScopes.OpenId,
                        IdentityServerConstants.StandardScopes.Profile,
                        IdentityServerConstants.StandardScopes.Email,
                        IdentityServerConstants.StandardScopes.OfflineAccess,
                        "roles"
                    },
                    AlwaysSendClientClaims = true
                }
  1. I've created a ProfileService to add the "role" claims. When it is triggered by a login from the MVC app, in GetProfileDataAsync the context.Subject.Claims includes the "role" claim with a value of admin for my test user. `'context.
    public class MyProfileService : IProfileService
    {
        public MyProfileService()
        { }

        public Task GetProfileDataAsync(ProfileDataRequestContext context)
        {
            var roleClaims = context.Subject.FindAll(JwtClaimTypes.Role);
            List<string> list = context.RequestedClaimTypes.ToList();
            context.IssuedClaims.AddRange(roleClaims);
            return Task.CompletedTask;
        }

        public Task IsActiveAsync(IsActiveContext context)
        {
            // await base.IsActiveAsync(context);
            return Task.CompletedTask;
        }
    }
  1. When I did this, the MVC User no longer had the "name" claim and the "role" claims were not added.

Please provide and example or Gist that shows all the pieces and steps to allow an MVC app using IdentityServer4 V2 configured to use ASP.NET Core Identity and EntityFramework to be able to use [Authorix(Roles="admin"] attributes.

@brockallen
Copy link
Member

You might want to watch this: https://www.youtube.com/watch?v=EJeZ3YNnqz8

@matthewDDennis
Copy link
Author

Not what I was asking for. I need to add Identity Roles to the claims on the User so that I can authorize, either using an Authorize(Roles="..."] attribute or an Authorization Policy that take the User roles or role claims into account.

I agree that Role base authorization can be problematic, however all I need is a couple of different User types, much like the Doctor, Nurse, Patient example in the video. This can be handled by the Asp.Net Identity User Roles.

Since the IdentityServer is configured to use EF and Asp.Net Identity, it should be possible to get the roles and return them to the MVC app and persist them in the identity cookie so that the User property can check for IsInRole("...").

It looks like that when the user first logs in, the claims seen by the OnUserInformationReceived Event has the roles, but subsequent requests do not have the roles.
Is there something I need to do to add this information to the information stored in the MVC app's identity cookie, or have I got this all wrong?

Would it be better to use a Reference token and get the information on each request?

@matthewDDennis
Copy link
Author

matthewDDennis commented Nov 22, 2017

I didn't close this, at least intentionally.

@brockallen
Copy link
Member

brockallen commented Nov 22, 2017

So then there is this bit of info as well, which might be part of your problem: https://leastprivilege.com/2016/08/21/why-does-my-authorize-attribute-not-work/

Other then these bits of info, this seems like a problem in your ASP.NET Core app, and not specifically an IdentityServer bug. Do you agree?

@matthewDDennis
Copy link
Author

Ok, I found the missing piece. The Microsoft OpenId Middleware does not map the role property from the user information to the claims on the principal. In fact there is not support for mapping of properties that have multiple values (an array) or could be included multiple times with different values.
I have created a RoleClaimsAction which handles this. This is a first pass that needs to be cleaned up and made general but for my first tests works well.

The following code

  1. Gets the 'role' properties from the UserData JObject
  2. Iterates through these properties
    1. If the property is an array, set the roles variable to a list of the values
    2. Else set the roles variable to a string[] containing the single value
    3. Iterate the roles and if the role does not already exist, add it
   internal class RoleClaimAction : ClaimAction
   {
       public RoleClaimAction()
           :base("role", ClaimValueTypes.String)
       {
       }

       public override void Run(JObject userData, ClaimsIdentity identity, string issuer)
       {
           var tokens = userData.SelectTokens("role");
           IEnumerable<string> roles;

           foreach (var token in tokens)
           {
               if (token is JArray )
               {
                   var jarray = token as JArray;
                   roles = jarray.Values<string>();
               }
               else
                   roles = new string[]{ token.Value<string>() };

               foreach (var role in roles)
               {
                       Claim claim = new Claim("role", role, ValueType, issuer);
                   if (!identity.HasClaim(c => c.Subject == claim.Subject
                                            && c.Value == claim.Value))
                   {
                       identity.AddClaim(claim);
                   }
               }
           }
       }
   }

You add this in the Startup of the Client project

            .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
            {
                options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;

                options.Authority = identityUrl;
                options.MetadataAddress = "http://identity/.well-known/openid-configuration";
                options.RequireHttpsMetadata = false;

                options.ClientId = clientId;
                options.ClientSecret = clientSecret;

                options.ClaimActions.Add(new RoleClaimAction()); // <-- add this

                options.ResponseType = "code id_token";
                options.Scope.Add("offline_access");

                options.Scope.Add("roles");// <-- add this

                options.GetClaimsFromUserInfoEndpoint = true;// <-- add this

                options.SaveTokens = true;

                options.TokenValidationParameters.NameClaimType = "name";// <-- add this

                options.TokenValidationParameters.RoleClaimType = "role";// <-- add this

            });

I'm going to look at wrapping this up so I could

  • .AddAspNetIdentityRoles()
  • or options.AddAspNetIdentityRoles()

@matthewDDennis
Copy link
Author

I notice that you used to have a Roles Resource/Scope, but removed it when refactoring to Resource based configuration. I know that you dislike Role based Authorization, but you did say in the video that Identity type Roles (Doctor, Nurse, Patient) were acceptable.

Since Asp.Net Core Identity already has Roles, removing support for them was probably not a good idea, at least without documenting the steps to add them back. In may cases, using roles, along with other claims is a perfectly valid design decision, given that the underlying User store already provide this so there should be no need to go through the several days of digging to make it work.

@brockallen
Copy link
Member

Our docs are part of the OSS, so if you'd like to contribute to them to improve them for the community, feel free to send a PR.

@brockallen
Copy link
Member

This seems to be a general question about IdentityServer - not a bug report or an issue.

Please use one of the our free or commercial support options

See here for more details.

Thanks!

@empitegayan
Copy link

this is exactly what i was looking for. thanks @matthewDDennis .. obviously they not going to disclose everything because they are looking for people to be get stucked and get back to them with commercial support option

@brockallen
Copy link
Member

brockallen commented Feb 3, 2018

obviously they not going to disclose everything because they are looking for people to be get stucked and get back to them with commercial support option

You're getting paid by your employer to write your software, yes? Then why should you expect us to do work for you for free? You're already getting this FOSS that would otherwise cost your company money. Please stop with the childish attitude.

@kacey90
Copy link

kacey90 commented Mar 29, 2018

Thank you very much @matthewDDennis I had this issue on monday and your solution helped me out of it. Thanks. But do I have to do this for every custom claim I would want to map to the user information i.e a class for every claim? or I can have it all in one class? The reason I'm asking is ClaimAction does not accept an array or ICollection of claims.

Good work again. Many thanks.

@Aymeeeric
Copy link

Aymeeeric commented Apr 13, 2018

Hi!

Simpler solution found here:

Just use : options.ClaimActions.MapJsonKey("role", "role", "role"); on your AddOpenIdConnect client's method...

Like :

 .AddOpenIdConnect("oidc", options =>
                {
                    options.SignInScheme = "Cookies";

                    options.Authority = "http://localhost:5000";
                    options.RequireHttpsMetadata = false;

                    options.ClientId = "mvc";
                    options.ClientSecret = "secret";
                    options.ResponseType = "code id_token";

                    options.SaveTokens = true;
                    options.GetClaimsFromUserInfoEndpoint = true;

                    options.Scope.Add("api1");
                    options.Scope.Add("role");
                    options.Scope.Add("offline_access");

                    // Fix for getting roles claims correctly :
                    options.ClaimActions.MapJsonKey("role", "role", "role");

                    options.TokenValidationParameters.NameClaimType = "name";
                    options.TokenValidationParameters.RoleClaimType = "role";
                });

See doc here.

@leastprivilege
Copy link
Member

roles, roles, roles

https://www.youtube.com/watch?v=jLE1xKo6dns

@johnwc
Copy link

johnwc commented Jun 27, 2019

For others that find this... As of today, if you are using Asp .Net Identity and have a role store DbContext registered in your IdentityServer4 project. You can put the claim type either in the ApiClaims table for it to be global to all scopes for the API, or you can add it to a specific scope of the API and create it under the ApiScopeClaims table. You can read further about what you need to do here https://stackoverflow.com/a/56641862/3117194

Once that is done, the role claim will be in your access token.

Note: For clearity, I am not using the AddOpenIdConnect method in my client, but rather the AddIdentityServerAuthentication extention method that comes with the IdentityServer4.AccessTokenValidation nuget package.

@azydevelopment
Copy link

Hmm, for me, as of today using .NET Core 3.1 it seems like this just works as expected once you implement an IProfileService and do the right configuration in Startup.cs. Not sure if it isn't actually working as I expected but I did tests and the Authorize attribute seems to work in both the AND and OR configurations when used with multiple role claims.

public class ProfileService : IProfileService
    {
        protected UserManager mUserManager;

        public ProfileService(UserManager userManager)
        {
            mUserManager = userManager;
        }

        public async Task GetProfileDataAsync(ProfileDataRequestContext context)
        {
            User user = await mUserManager.GetUserAsync(context.Subject);

            IList<string> roles = await mUserManager.GetRolesAsync(user);

            IList<Claim> roleClaims = new List<Claim>();
            foreach (string role in roles)
            {
                roleClaims.Add(new Claim(JwtClaimTypes.Role, role));
            }

            context.IssuedClaims.AddRange(roleClaims);
        }

        public Task IsActiveAsync(IsActiveContext context)
        {
            return Task.CompletedTask;
        }
    }

A single role produces this in the token:

image

Multiple roles produces this in the token:

image

All of these combinations of attributes seemed to work as expected:

OR relationship

[Authorize(Roles = "SystemAdmin,RegisteredUser")]

AND relationship

[Authorize(Roles = "SystemAdmin")]
[Authorize(Roles = "RegisteredUser")]

Authentication required but no roles check

[Authorize]

Relevant part of Startup.cs

                ...

                services.AddDefaultIdentity<User>(options => options.User.RequireUniqueEmail = true)
                    .AddRoles<IdentityRole>()
                    .AddRoleStore<RoleStore<IdentityRole, DbContext, string>>()
                    .AddRoleManager<RoleManager<IdentityRole>>()
                    .AddUserStore<UserStore>()
                    .AddUserManager<UserManager>();

                services.AddIdentityServer()
                    .AddApiAuthorization<User, DbContext>(opt =>
                    {
                        foreach (Client c in opt.Clients)
                            c.AccessTokenLifetime = securityConfig.AccessTokenLifetime;
                    });

                services.AddAuthentication()
                    .AddIdentityServerJwt();

                services.AddTransient<IProfileService, ProfileService>();

                ...

Who knows, maybe it's working for a reason I don't fully understand still.

@lock
Copy link

lock bot commented Feb 2, 2020

This thread has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.

@lock lock bot locked as resolved and limited conversation to collaborators Feb 2, 2020
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Projects
None yet
Development

No branches or pull requests

8 participants