Piranha.core: Improve Single Sign-On Compatibility

Created on 6 Jun 2019  路  16Comments  路  Source: PiranhaCMS/piranha.core

I'm in charge of a web application with the a microservices-style architecture with an authentication service implemented on IdentityServer4 and an Angular 2+ UI. We have need to add new features which require content management, and I selected Piranha CMS for the job based on it's clean API, large selection of example implementations (Angular!), and apparent development activity compared to competitors.

My goal is to package the management UI and content API into a single ASP.NET Core MVC service with the identity provider (IdentityServer4) providing identity and authentication for both the manager and the API. It's common in a microservices architecture to keep one service with one database per data domain and force all data interaction for that domain to pass through the one service.

I have it mostly working at this point, but the one sticking point in the framework at this point is ISecurity. It appears that the manager is designed to expect users to enter their credentials directly into the built-in login page instead of allowing the possibility of redirecting to an external IDp, and this is manifested in the ISecurity interface's SignIn method. At first I simply avoided implementing ISecurity, but I soon discovered that it's an important component of logging out the user when they click the logout button. Since I want that to work, I'll have to implement ISecurity. Now I'm considering implementing the SignOut method and simply throwing a NotSupportedException in the SignIn method. I'm not 100% sure what the solution would be, but I'm proposing that the Piranha framework should be changed a little bit to accommodate external authentication better by loosening or eliminating the assumption that a username and password will be supplied directly within Piranha's login page. I imagine that this change would also help with #429 .

If it's decided that this is worth implementing, I would be happy to help do the work and create a PR, but at this moment, I'm unsure of the best solution to the problem, and I would like input.

For anyone who stumbles across this later, I would like to share my solution for getting Piranha working with IdentityServer4, bypassing Piranha manager's login screen:

I created two authorization policies in the Piranha project: one to control the manager and one to control the APIs. This is likely what you'll want, because you likely have different requirements for content managers versus content consumers (your users). Then I added a convention to select the right policy based on which controller was being accessed.

Adding authorization policies In Startup.ConfigureServices:

services.AddAuthorization(options =>
{
    options.AddPolicy("managerpolicy", b =>
    {
        b.RequireAuthenticatedUser();
    });

    //if you require your content consumers to be logged in to view content,
    //add a policy for the API
    //options.AddPolicy("apipolicy", b =>
    //{
    //    b.RequireAuthenticatedUser();
    //    b.AuthenticationSchemes = new List<string> { JwtBearerDefaults.AuthenticationScheme };
    //});
});

Creating a new convention class to apply the correct policy based on whether the bound controller is in the "manager" area or not:

public class AddAuthorizeFiltersControllerConvention : IControllerModelConvention
{
    public void Apply(ControllerModel controller)
    {
        if (controller.RouteValues.Any()
            && controller.RouteValues.TryGetValue("area", out string area)
            && area.Equals("manager", StringComparison.OrdinalIgnoreCase))
        {
            controller.Filters.Add(new AuthorizeFilter("managerpolicy"));
        }
        //if you require your content consumers to be logged in to view content,
        //add a policy for the API
        //else
        //{
        //    controller.Filters.Add(new AuthorizeFilter("apipolicy"));
        //}
    }
}

Applying the convention in Startup.ConfigureServices:

services.AddMvc(config =>
{
    config.Conventions.Add(new AddAuthorizeFiltersControllerConvention());

    config.ModelBinderProviders.Insert(0, new Piranha.Manager.Binders.AbstractModelBinderProvider());
});

You'll also need to provide authentication configuration for both policies to use. Right now, I'm still working on having the MVC (cookies) and API (bearer) schemes working correctly side-by-side, so I'll just give some example code for cookies authentication only:

    .AddAuthentication(options =>
    {
        options.DefaultScheme = "Cookies";
        options.DefaultChallengeScheme = "oidc";
    })
    .AddCookie("Cookies")
    .AddOpenIdConnect("oidc", options =>
    {
        options.Authority = "http://localhost:49508/";
        options.RequireHttpsMetadata = false;

        options.ClientId = "cmsmanager";
        options.SaveTokens = true;
    });

The other thing to consider is permissions and user claims. I'm choosing to keep permissions information within the services where they are relevant, so when my managers log in, the IdentityServer doesn't know anything about Piranha claims, and therefore they are not automatically included on the ClaimsPrinciple on log in. To load user claims from the local database after receiving the identity info from the IdentityServer, you'll have to create a new class that inherits from ClaimAction:

public class RetrieveLocalClaimsAction : ClaimAction, IDisposable
{
    public RetrieveLocalClaimsAction() : base(null, null)
    {
        var options = new DbContextOptionsBuilder<CmsDbContext>();
        _dbContext = new CmsDbContext(options.UseSqlServer(GetConnectionString()).Options);
    }

    private readonly CmsDbContext _dbContext;

    public override void Run(JObject userData, ClaimsIdentity identity, string issuer)
    {
        string userId = identity.FindFirst("sub").Value;

        IEnumerable<Claim> claims = _dbContext.UserRoles
            .Where(ur => ur.UserId == userId)
            .Join(_dbContext.Roles, ur => ur.RoleId, r => r.Id, (ur, r) => r)
            .Join(_dbContext.RoleClaims, r => r.Id, rc => rc.RoleId, (r, rc) => rc)
            .ToArray()
            .Select(rc => rc.ToClaim());

        identity.AddClaims(claims);
    }

    public void Dispose()
    {
        _dbContext?.Dispose();
    }

    private static string GetConnectionString()
    {
        IConfigurationBuilder configurationBuilder = new ConfigurationBuilder()
            .SetBasePath(Directory.GetCurrentDirectory())
            .AddJsonFile("appsettings.json");

        IConfigurationRoot config = configurationBuilder.Build();

        return config.GetConnectionString("CmsDbContext");
    }
}

And then you'll need to add it to your OpenIdConnect options so it runs:
options.ClaimActions.Add(new RetrieveLocalClaimsAction());

Together, the Startup.ConfigureServices method should look something like this:

services.AddMvc(config =>
{
    config.Conventions.Add(new AddAuthorizeFiltersControllerConvention());

    config.ModelBinderProviders.Insert(0, new Piranha.Manager.Binders.AbstractModelBinderProvider());
});

services.AddAuthorization(options =>
{
    options.AddPolicy("managerpolicy", b =>
    {
        b.RequireAuthenticatedUser();
    });

    //if you require your content consumers to be logged in to view content,
    //add a policy for the API
    //options.AddPolicy("apipolicy", b =>
    //{
    //    b.RequireAuthenticatedUser();
    //    b.AuthenticationSchemes = new List<string> { JwtBearerDefaults.AuthenticationScheme };
    //});
});

JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();

services
.AddAuthentication(options =>
{
    options.DefaultScheme = "Cookies";
    options.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookies")
.AddOpenIdConnect("oidc", options =>
{
    options.Authority = "http://localhost:49508/";
    options.RequireHttpsMetadata = false;

    options.ClientId = "cmsmanager";
    options.SaveTokens = true;
    options.ClaimActions.Add(new RetrieveLocalClaimsAction());
});
core enhancement request

Most helpful comment

@x3haloed I tweaked your solution a bit and here's an abridged version of what I got working on my side.

Essentially, when we get the callback from OIDC (Auth0 in my case), I grab their email, check if the user exists, and add one if not. Then I can retrieve their claims via associated roles. There's some refinement that could be done, but it meets my case.

Thanks for the notes!

options.Events = new OpenIdConnectEvents
                    {
                        OnTicketReceived = async (context) =>
                        {
                            if (context.Principal.Identity is ClaimsIdentity identity)
                            {
                                var db =
                                    context
                                    .HttpContext
                                    .RequestServices
                                    .GetService<IdentitySQLiteDb>();

                                var email =
                                    identity
                                    .FindFirst("email")?
                                    .Value;

                                var user =
                                    await db
                                    .Users
                                    .Where(u => u.Email == email)
                                    .SingleOrDefaultAsync();

                                if (user == null)
                                {
                                    user = new Piranha.AspNetCore.Identity.Data.User
                                    {
                                        UserName = email,
                                        Email = email,
                                        NormalizedEmail = email,
                                        NormalizedUserName = email,
                                        EmailConfirmed = true
                                    };

                                    db.Users.Add(user);

                                    await db.SaveChangesAsync();
                                }

                                IEnumerable<Claim> claims =
                                   db
                                   .UserRoles
                                   .Where(ur => ur.UserId == user.Id)
                                   .Join(db.Roles, ur => ur.RoleId, r => r.Id, (ur, r) => r)
                                   .Join(db.RoleClaims, r => r.Id, rc => rc.RoleId, (r, rc) => rc)
                                   .ToArray()
                                   .Select(rc => rc.ToClaim());

                                identity.AddClaims(claims);
                            }
                        }
                    };

All 16 comments

That's awesome @x3haloed! First of, we're looking for guest writers for piranhacms.org, writing about cool stuff they've done with Piranha. Authors will be presented with name, bio, photo and link to their (or their company's) website. Would you be interested in an article on this subject, as I see it, much of what you wrote can be reused without modifications?

Secondly, security is not my speciality. That's why we try to do as little as possible of it in Piranha 馃榾 If you have specific suggestions on how the current setup could be made more flexible for external auth, don't hesitate to send a PR with the functionality, especially since we're working on finalizing 7.0 and we like to keep breaking changes to major versions.

Best regards

H氓kan

I would be happy to write! I'll have to check with my company to see if they're alright with it.

I belive that just a couple of tweaks could improve SSO compatibility, probably without making Piranha any more complicated. I'll give it a shot.

I'm new to contributing on GitHub and to Piranha in General. I've looked at the contribution guide. If Master is the branch with the latest code, it looks like you're moving to Razor pages. Is that right? Is there any documention or discussion about the themes and initiatives for 7.0? I just want to understand the broader context.

Great! The main difference with the new 7.0 manager is that the main part of the application is written in Vue.js and ApiControllers. The choice of using Razor Pages for the application was simply because there really wasn't that much logic left in the MVC part of the application and the additional controller structure seemed overkill.

As a developer extending the manager with custom views you can decide whether to use MVC, Razor Pages or Vue. For example the AspNetCore.Identity module has just been upgraded to Bootstrap 4, but it still implemented in MVC without Vue.

So I pulled the latest code on the master branch and did some thinking and experimenting. Every solution I came up with was basically a fancy way to throw a NotSupportedException in the SignIn method of the ISecurity implementation class. I can't think of a way to make it better without making things more complicated. Since simplicity is the aim, I think the best solution for SSO is Piranha right now is to implement SignOut in the security service and just throw NotSupportedException in SignIn. That will allow ISecurity to resolve successfully and will allow the logout button in the manager to function. When combined with the security policy solution in my original post, ISecurity.SignIn should never be called.

Well if the Sign Out button is the only real issue, my suggestion is that we add that button into the Piranha.Manager.Menu item collection instead of hard-coding it in the UI. That way you can just change the route of the button to point to your own Sign Out action.

@tidyui I think moving the Sign Out button to an item in the Menu would be a great thing to do. I have the need to simply remove the Sign Out button. Is that something we could get in a backlog or would you accept a Pull Request to make that change?

Our goal is to release a second pre-release by the end of this week. We should be able to squeeze this in!

Is there a way to disable authentication for Piranha Api? or have a simple x-api-header type authentication?

@x3haloed I tweaked your solution a bit and here's an abridged version of what I got working on my side.

Essentially, when we get the callback from OIDC (Auth0 in my case), I grab their email, check if the user exists, and add one if not. Then I can retrieve their claims via associated roles. There's some refinement that could be done, but it meets my case.

Thanks for the notes!

options.Events = new OpenIdConnectEvents
                    {
                        OnTicketReceived = async (context) =>
                        {
                            if (context.Principal.Identity is ClaimsIdentity identity)
                            {
                                var db =
                                    context
                                    .HttpContext
                                    .RequestServices
                                    .GetService<IdentitySQLiteDb>();

                                var email =
                                    identity
                                    .FindFirst("email")?
                                    .Value;

                                var user =
                                    await db
                                    .Users
                                    .Where(u => u.Email == email)
                                    .SingleOrDefaultAsync();

                                if (user == null)
                                {
                                    user = new Piranha.AspNetCore.Identity.Data.User
                                    {
                                        UserName = email,
                                        Email = email,
                                        NormalizedEmail = email,
                                        NormalizedUserName = email,
                                        EmailConfirmed = true
                                    };

                                    db.Users.Add(user);

                                    await db.SaveChangesAsync();
                                }

                                IEnumerable<Claim> claims =
                                   db
                                   .UserRoles
                                   .Where(ur => ur.UserId == user.Id)
                                   .Join(db.Roles, ur => ur.RoleId, r => r.Id, (ur, r) => r)
                                   .Join(db.RoleClaims, r => r.Id, rc => rc.RoleId, (r, rc) => rc)
                                   .ToArray()
                                   .Select(rc => rc.ToClaim());

                                identity.AddClaims(claims);
                            }
                        }
                    };

That's great! We're thinking about moving to Auth0 too. I'm sure this would come in handy. Thanks for sharing!

Hi..
Can I get an update on the state of this code above ?

Is the Open ID Connect in the trunk ? Has this been basically implemented ?

Thanks
Andrew

I haven't implemented anything regarding it and I don't think I've received any Pull Requests on it either. But since security isn't my speciality I'm open for contributions on it 馃榾

Hello,

Please let me know, what is the best method to integrate Piranha CMS with Wordpress?
I would like only one of them would require a login from a user.

Wordpress would be good to get many ready stuff like Woo-commerce, while Piranha is for customized programmed pages for admin control panels.

Do you have a ready SSO option common for both mentioned CMSes?

Another choice for integration could be nopCommerce:
https://www.nopcommerce.com/en/

I am considering to take the plunge in implementing SSO for my Piranha project. This is, however, driven by my curiosity rather than commersial factors. If I get any lessions learned that is applicable to this I'm happy to share them. When and if that happen. :)

We鈥檙e grateful for all the help we can get in terms of docs and tutorials on this area as this is NOT our area of expertise 馃榿

Was this page helpful?
0 / 5 - 0 ratings

Related issues

rengert picture rengert  路  3Comments

OmidID picture OmidID  路  3Comments

w0ns88 picture w0ns88  路  6Comments

SamBray picture SamBray  路  3Comments

aneff-official picture aneff-official  路  6Comments