Kentico MVC and Intranet SSO

Posted: 10/5/2020 1:23:48 AM by Chris Bass | with 0 comments
Filed under: API, How-To, Security

I've been head-down recently on a number of projects, but by far the most technically interesting one has been an intranet site, that wants to lock down the site to only be accessible by their authenticated users.

In Portal Engine, this would've been an out-of-the-box scenario - users log in, we check off 'requires authentication' on the root in the Pages->Security tab, and ta-da, it's done. Front-end authentication was the same as back-end authentication, so Kentico's built-in SSO handlers logged you into the frontend site just fine. Even if we needed to give permissions to specific roles (or just the automatically-created Authenticated-users role), we could do that in the Kentico CMS interface.

In K12 MVC, however, three challenges present themsleves: that flag no longer shows up in the Security tab of content, there's no longer automatically-handled roles like AuthenticatedUsers, and most importantly, front-end and back-end now require separate authentications since they're separate applications/sessions. 

Kentico's documentation supports these cases:

  1. Backend SSO:
  2. Adding role-based permissions to Controllers/Actions:
  3. Frontend Forms-based external authentication:
  4. Not Kentico, but a Quickstart tutorial on setting up frontend SSO:

Based on these tools, we're left to our own devices. #1 isn't helpful here since it's purely backend and integrated into Kentico, but #2 and #3 can get us most of the way, along with documentation.

The Process

  1. Figure out what sort of claims authentication we should use
  2. Get SSO talking to the MVC site
  3. Get the MVC site talking to Kentico
  4. Get Kentico to authenticate Users against pages.

1. Figure out what sort of claims authentication we should use

For the start, I was using the documentation #3 above - they list out 4 types of authentication that exist as separate middleware options, from Microsoft.Owin.Security.*. Two of them, to me, seem obvious enough - if you want to authenticate with Facebook or Google, use the one called that. 

In our case, we were using Active Directory. If you just go into Visual Studio and follow the Quickstart instructions, you get the app described in documentation #4 above - Visual Studio creates an application in Azure, and wires up a localhost URL to it using OpenIDConnect. By default, it just uses the clientid / tenant, but you can add in a ClientSecret using the Azure interface for extra security. From there you can adjust the URLs and permissions according to your needs.

However, if you've created your own Azure application, you may have clicked through and created your own application that's using SAML and trying to use a client certificate, instead of a secret-key. In that case, you need to use the WS-Federation middleware instead of OpenIDConnect.

Nothing in Azure directly seemed to reference either type of OWIN security by name, so I don't actually know if there's more to the decision than 'secret' vs 'certificate'.

2. Get SSO talking to the MVC site

Kentico's documentation #3 would suggest that you should be using a cookie that sets a LoginPath to your signin action, with a redirect to a signin callback handler that handles actually signing in the Kentico user. In attempting to modify this for SSO, I was finding a few issues:
  1. Because we want *any* action to first log you in, without a button press, all of the 'signin' and 'signincallback' stuff is out the window. The only action a user is taking is 'loading a page', There's no way to inject 'if not authenticated, redirect to /signin/' to every action, so we're stuck using the [Authorize] attribute (which is what documentation #2 suggests anyways).
    1. The Authorize attribute just directly calls the challenge, and only returns if the user was authorized, so there's no direct 'callback' function. 
  2. The Kentico documentation also shows a second, external OWIN cookie created with app.UseExternalSignInCookie, to store temporary signin information. For whatever reason, this code as presented never worked for me - either with or without it, when I signed in the AuthenticationManager.GetExternalLoginInfo (or Async variant) method would always return null. I read about an OWIN cookie bug referred to as the OWIN Cookie Monster that may have been responsible for this, but the proposed fix of adding "SystemWebCookieManager" as the manager for the external cookie, didn't help.
    1. Kentico's documentation showed us needing this for Part 3, but see that part - it didn't matter.
In the end, I created a simple Cookie Authentication with no parameters, and handled the post-authentication logic in the WS-Federation's own response handlers. The underlined properties are just reaching out to the App Config, so we can set it per-environment. These need to match the values in Azure.
app.UseCookieAuthentication(new CookieAuthenticationOptions());

    new WsFederationAuthenticationOptions
        MetadataAddress = metadata,
        Wtrealm = wtrealm, //
        Wreply = domain, // Just defines what domain to redirect back to - the [Authorize] ensures it comes back to the same action it left from.
        TokenValidationParameters = new TokenValidationParameters
            ValidAudiences = new string[] { domain } //Same as wreply
        Notifications = new WsFederationAuthenticationNotifications()
            SecurityTokenReceived = OnAuthReceived, //If you want. I didn't need it.
            SecurityTokenValidated = OnAuthenticationSuccess,
            AuthenticationFailed = context =>
                CMS.EventLog.EventLogProvider.LogException("SAML", "LoginFailed", context.Exception);
                context.Response.Redirect("Account/Error?message=" + context.Exception.Message);  //Any non-authenticated Action that you want to represent 'this authentication come back but failed'. Doing this prevents infinite redirects as loading the response page triggers another authorization attempt.
                return Task.FromResult(0);

Once that's in place, any action with the [Authorize] parameter should automatically reach out to SSO and respond back to the same action if it succeeds, going to /Account/Error instead if it fails. 

You could add a call in your Global.asax.cs to apply the Authorize attribute to every controller, but I find that to be somewhat perilous since you might have Web API calls, error handlers, and other publicly-available pages you might want to not have to individually hunt down. I personally prefer having an 'AuthenticatedControllerBase' class, and putting the [Authorize] attribute on that instead - then any controllers you want to use authentication, can just inherit from that base controller.

3. Get the MVC site talking to Kentico

So, having authentication returned from Azure, the next step is getting it working with Kentico.

app.CreatePerOwinContext(() => UserManager.Initialize(app, new UserManager(new UserStore(SiteContext.CurrentSiteName))));

These two lines tell Owin to instantiate the UserManager (and SignInManager, which we don't end up using) based on the authentication context.

Without the LoginInfo from #2, SignInManager.ExternalSignIn doesn't work. However, I found that when [Authorize] returns we appeared to be logged-in anyways, as long as the user exists in Kentico. I am not sure why - but I am guessing something is happening in the background with the Authorize attribute and the UserManager. In any case, this greatly simplified the Kentico validation logic, to just needing to create a new user if one didn't exist.

private async Task OnAuthenticationSuccess(SecurityTokenValidatedNotification<WsFederationMessage, WsFederationAuthenticationOptions> context)
    EventLogProvider.LogInformation("OAuth", "Validated");
    var userData = GetInfoFromIdentity(context.AuthenticationTicket.Identity);

    var SignInManagerObj = context.OwinContext.Get<SignInManager>();
    var UserManagerObj = context.OwinContext.Get<UserManager>();

    var existingUser = await UserManagerObj.FindByEmailAsync(userData.UserName);
    if (existingUser == null)
        // Attempts to create a new user in Kentico if the authentication failed
        _ = await CreateExternalUser(UserManagerObj, userData);

With that, users are automatically created if they don't exist, and when Authorize returns, we have a logged-in Kentico user based on the authorization credentials.

4. Get Kentico to authenticate Users against pages

It makes some sense that Kentico switched authentication onto Controller Actions (per documentation #2), given their attempted philosophy of 'content aren't pages anymore'. However, it's been proven out that the Kentico community wants and expects content in the CMS tree to be pages. Based on this, we should be able to draw permissions from the Kentico content, and use them to authenticate against frontend Users. 

However, it really just isn't supported directly. Once we're past the Authorize attribute, everything happens in a given action's Controller. So, beyond using Kentico's intended '[Authorize('RoleName')] attribute directly on each controller Action, we only really have two options:

  1. Put something individually on each controller we want to support. 
  2. Create a base controller that handles it, and inherit all the other controllers that represent Pages, from that controller. The same 'AuthenticatedControllerBase' I mentioned in Step 2, is a good place for that.

Either way, the logic is not necessarily complicated, basically just calls to Kentico's built-in IsAuthorizedPerResource and IsAuthorizedPerNode methods against the user Kentico has signed-in.  Of course, you need to know what Content item you're pointing to, but that's a discussion of Dynamic Routing, not authentication :)  Shoutout to Trevor Fayas' Kentico Authorization package which just grabs the page by URL and does the authentication for you - the logic is largely in the KenticoAuthorizationAttribute and could likely be easily transplanted if you needed a non-url way of matching up Action to Kentico page - you'd probably just need to overwrite the GetTreeNode method.

Wrapping Up

I've only worked with this one client on this particular setup, but I felt the sting of this gap in the Kentico documentation, and hopefully this shores it up a bit when the next developer comes along. Definitely if someone more versed in this process comes along and has suggestions, I'd like to hear them, and if you have questions or comments, please feel free to respond below.
Blog post currently doesn't have any comments.
 Security code