How to setup a custom login redirection in EPiServer for a multi-tenants project
Our journey of discovery inside EPiServer continues with a requirement coming from one of our clients regarding one of the projects we are managing. The project we are talking about has the following setup:
- Multi-sites solution with
- Multiple home pages per sites and
- Multiple frontend roles
For one of the websites that we are creating, we must create a "portal" for users to log in / register and check information about their profile like name, billing address, etc. As we have multiple "home pages" we must make sure that we redirect the user to the right login page, then to the right portal on the right website.
The content tree is expected to look like this:
- Site1
- HomePage1
- LoginPage1
- MyPortal1 (restricted)
- AccountPage1 (restricted)
- OtherPage1 (restricted)
- HomePage2
- LoginPage2
- MyPortal2 (restricted)
- AccountPages2 (restricted)
- OtherPage2 (restricted)
- HomePage1
- Site2
- Random pages
In a nutshell, users accessing MyPortal1 must be redirected to LoginPage1 while users accessing MyPortal2 must be redirected to LoginPage2. For the sake of this example, we can assume that the portals share the same page type. We cannot allow users to be redirected to the standard EPiServer login page.
As the portals share the same page type, they also share the same PageController so we started working on a function inside the Index function for when we render the page.
//the code has been simplified for clarity purposes
public ActionResult Index(MyAccountPage currentPage)
{
//test if authenticated
if (User.Identity.IsAuthenticated && User.IsInRole("authenticatedMember"))
return View(currentPage);
else
return Redirect(loginPage);
}
But we quickly realized:
- There was no code reusability here
- Other controller actions were not restricted so it was technically possible to trigger other actions (like a form submit inside the MyAccountPage) while being anonymous 😱
- We would need to do that for every page type that needs to be restricted 😱 😱
So we started looking at a cleaner approach. To solve the point 2. we could override the OnAuthorization function but that was not solving the first and third point.
Coming from asp.net MVC, I knew that there was a possibility to inherit from AuthorizeAttribute and add the redirection logic in the newly created class. We were solving point 1 and 3 ! 😀
//the code has been simplified for clarity purpose
public class MyAuthorizeAttribute : AuthorizeAttribute
{
public override void OnAuthorization(AuthorizationContext filterContext)
{
base.OnAuthorization(filterContext);
//authentication is fine
if (filterContext.Result == null)
return;
//edit mode we do not need to redirect
if (PageEditing.PageIsInEditMode)
return;
//if authentication didn't work
if (filterContext.Result.GetType() == typeof(HttpUnauthorizedResult))
{
filterContext.Result = new RedirectResult(loginPage);
}
}
}
//And for each controller we just need to add one line/attribute to describe the controller class so that every action needs to be coming from an authenticated //user
[MyAuthorize(Roles = "authenticatedMember")]
public class MyAccountPageController : PageController<MyAccountPage>
{
public ActionResult Index(KarndeanAccountPage currentPage)
{
return View(currentPage);
}
}
However, even though the code appeared to be working well, we quickly realised 2 issues with it:
- I didn't check if there was a built-in feature / API in EPiServer, I went straight with the ASP.NET MVC solution
- My code was "breaking" Access Rights
By using access rights in Episerver, you can control the content that a website visitor sees, as well as what editors can do and where they can do it in the content structure. More info available here.
The issue with my code is that it was ignoring / overriding Access Rights permissions. We needed to override the default redirection behavior while maintaining Acess Rights integrity.
The first step was to restrict the MyPortal1 page and their descendants to disallow anonymous members. It is possible by unchecking the 'Read' option for the 'Everyone' group for the page we wanted to restrict & their descendants and allow this page for the relevant group:
Once this operation was successful, trying to access the page as anonymous user would redirect us to the default EPiServer login page:
While we were successfully redirecting unauthorized users, we still needed to redirect to the "right" login page. This is where the AuthorizeContentAttribute comes handy.
This class allows the following behaviour:
When added to an MVC controller this action filter checks authorization for a previously routed node available through the request context.
What it means: it will check access rights for the requested node while allowing an option for custom behaviour, with a native IContentLoader property, this is just what we need ! 🤩
//the code has been simplified for clarity purpose
public sealed class MyAuthorizeContentAttribute : AuthorizeContentAttribute, IAuthorizationFilter
{
public new void OnAuthorization(AuthorizationContext filterContext)
{
base.OnAuthorization(filterContext);
if (filterContext == null)
return;
//authentication is fine
if (filterContext.Result == null)
return;
//edit mode we do not need to redirect
if (PageEditing.PageIsInEditMode)
return;
//if authentication didn't work
if (filterContext.Result.GetType() == typeof(HttpUnauthorizedResult))
{
//we find the login page and setup the redirection
filterContext.Result = new RedirectResult(loginPage);
}
}
}
//and for the controller:
[MyAuthorizeContent(RequiredLevel = EPiServer.Security.AccessLevel.Read)]
public class MyAccountPageController : PageController<MyAccountPage>
{
public ActionResult Index(MyAccountPage currentPage)
{
return View(currentPage);
}
}
And we now redirect to our custom login page every time an anonymous user tries to access the portal page ! WOOHOO ! 🥳
The last part of this article is about selecting the right login page. Based on our requirements we must navigate the content tree to find the right login page based on the page we are trying to access.
There are 2 schools of thought regarding content tree navigation:
- Bottom up Navigation
- Top down Navigation
I was a big believer in top down navigation due to the simplicity of the code involved. With a start from the top & a few .FirstOrDefault() based on page type and the allowed children, it is possible to find any node in a few lines of code. Adding some validations on top of that approach to allow 'singleton' restrictions for specific nodes (like login pages, portal pages, etc.) and we could wrap it up. However this approach doesn't go that well in the following scenarios:
- With multi-sites projects or websites with multiple entry points (like multiple home pages).
- The 'singleton' validation could be ignored by programmatically adding pages to the tree
For those reasons I decided to use bottom up navigation more often. The approach I follow is:
- Allow a top node to define the 'default' page reference that we want to access, it can be the default login page, default search page or any other page that has value as singleton page.
- From that node, navigate all the way to that top node to find the default page reference that we need.
Based on our current content tree, the best place to set a 'default login page' property would be at the home page level. From there, the only remaining bit was to update our AuthorizeContentAttribute:
public sealed class MyAuthorizeContentAttribute : AuthorizeContentAttribute, IAuthorizationFilter
{
private Injected<IPageRouteHelper> _pageRouteHelperProxy;
private Injected<IUrlResolver> _urlResolverProxy;
public new void OnAuthorization(AuthorizationContext filterContext)
{
base.OnAuthorization(filterContext);
if (filterContext == null)
return;
//authentication is fine
if (filterContext.Result == null)
return;
//edit mode we do not need to redirect
if (PageEditing.PageIsInEditMode)
return;
//if authentication didn't work
if (filterContext.Result.GetType() == typeof(HttpUnauthorizedResult))
{
var currentPage = _pageRouteHelperProxy.Service.Page;
var loginPageReference = this.ContentLoader.GetAncestorOrSelf<HomePage>(currentPage).DefaultLoginPage;
if (ContentReference.IsNullOrEmpty(loginPageReference))
filterContext.Result = new RedirectResult("/");
var loginUrl = _urlResolverProxy.Service.GetUrl(loginPageReference);
var currentPageUrl = _urlResolverProxy.Service.GetUrl(currentPage.ContentLink);
var redirectUrl = $"{loginUrl}?returnUrl={currentPageUrl}";
filterContext.Result = new RedirectResult(redirectUrl);
}
}
}
With that code, we can dynamically redirect from any restricted page to the corresponding login page in complex scenarios where restricted pages might be associated with specific login pages while keeping Access Rights integrity.
IPageRouteHelper is a great helper to quickly find the current page regardless of the context. As we are using Dependency Injection in our project, it is recommended to use a constructor parameter to inject a dependency. As it was possible when inheriting from AuthorizeContentAttribute we had to settle for the property injection using Injected<>.
Do you use another method to redirect to a custom login page ? I would love to hear about it in the comments ! 😊
Many thanks to Jon S. and Al for the feedback regarding my article and code.
I tried using this approach in EpiServer version 12, but it looks like the .NET Core implementation doesn't route traffic to the controller when authentication is resticted to the page. After many failures I found a way to easily hijack the login URL by adding an event to application cookies. Below is what changed.
Create a cookie event implementation, which overrides the redirect URL.
In Starup.cs, wire up cookie event:
Thank you Chris for your input regarding cms12. Works perfectly with ConfigureApplicationCookie.