Improve Single Sign-On Compatibility
See original GitHub issueI’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:
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;
});
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());
});
Issue Analytics
- State:
- Created 4 years ago
- Reactions:3
- Comments:17 (8 by maintainers)
Top GitHub Comments
@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!
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.