John Haynes
John Haynes

Reputation: 63

OWIN Middleware with CAS

I am attempting to implement a custom OWIN middleware authentication that redirects to a CAS server (Central Authentication Service). A colleague built the middleware, which seems to be working for the most part, but neither of us know how to store a cookie and redirect to the ExternalCallbackLogin on the AccountController once the middleware has successfully authenticated the user, in order to allow the user to visit the protected content.

The program flow should follow the Web flow diagram located at: Jasig CAS Webflow Diagram

I am being redirected to our internal CAS server as expected, and when I sign-in, I am able to retrieve the XML provided by the server to generate claims, but from here I am unaware as to how I can create the application cookie and access the claims outside of the middleware.

We did not implement ASP.Net Identity into this application, we followed this tutorial to set up CAS as the only login option. We have no intentions of allowing other external logins.

Any help would be greatly appreciated. If I need to provide more information, I will be glad to.

Below is all of the code for the middleware:

CasOptions.cs

using Microsoft.Owin.Security;
using System;

namespace owin.cas.client {
  public class CasOptions : AuthenticationOptions {
    private string _casVersion;
    private string _callbackPath;

    public CasOptions() : base(Constants.AuthenticationType) {
      this.AuthenticationMode = AuthenticationMode.Passive;
      this.AuthenticationType = Constants.AuthenticationType; // Default is owin.cas.client

      this.callbackPath = "/casHandler";
      this.casVersion = "3";

      this.Caption = Constants.AuthenticationType;
    }

    /// <summary>
    /// The local URI path that will handle callbacks from the remote CAS server. The default is "/casHandler".
    /// </summary>
    /// <value>The callback path.</value>
    public string callbackPath
    {
          get{ return this._callbackPath; }
          set{
                if (value.StartsWith("/", StringComparison.InvariantCulture)){
                    this._callbackPath = value;
                }
                else
                {
                    this._callbackPath = "/" + value;
                }
          }
    }

    /// <summary>
    /// This must be the base URL for your application as it is registered with the remote CAS server, minus the
    /// callback path. For example, if your service is registered as "https://example.com/casHandler" with the
    /// remote CAS server then you would set this property to "https://example.com".
    /// </summary>
    /// <value>The application URL.</value>
    public string applicationURL { get; set; }

    /// <summary>
    /// This must be set to the base URL for the remote CAS server. For example, if the remote CAS server's login
    /// URL is "https://cas.example.com/login" you would set this value to "https://cas.example.com".
    /// </summary>
    /// <value>The cas base URL.</value>
    public string casBaseUrl {
      get { return this._casVersion; }
      set {
        this._casVersion = value.TrimEnd('/');
      }
    }

        public string Caption
        {
            get { return Description.Caption; }
            set { Description.Caption = value; }
        }

    /// <summary>
    /// Set to the CAS protocol version the remote CAS server supports. The default is "3". Acceptable values
    /// are "1", "2", or "3".
    /// </summary>
    /// <value>The cas version.</value>
    public string casVersion { get; set; }

    // Used to store the Url that requires authentication. Typically marked by an Authorize tag.
    public string externalRedirectUrl { get; set; }
  }
}

CasMiddleware.cs

using Microsoft.Owin;
using Microsoft.Owin.Security.Infrastructure;
using Owin;
using System.Net.Http;
using System.Configuration;

namespace owin.cas.client {
  public class CasMiddleware : AuthenticationMiddleware<CasOptions> {

    private readonly HttpClient httpClient;
    private readonly ICasCommunicator casCommunicator;

    public CasMiddleware(OwinMiddleware next, IAppBuilder app, CasOptions options) : base(next, options) {
      if (string.IsNullOrEmpty(options.casBaseUrl)) {
        throw new SettingsPropertyNotFoundException("Missing required casBaseUrl option.");
      }
      if (string.IsNullOrEmpty(options.applicationURL)) {
        throw new SettingsPropertyNotFoundException("Missing required serviceUrl option.");
      }

      this.httpClient = new HttpClient();

      switch (options.casVersion) {
        case "1":
          this.casCommunicator = new Cas10(this.httpClient, options);
          break;
        case "3":
          this.casCommunicator = new Cas30(this.httpClient, options);
          break;
      }
    }

    protected override AuthenticationHandler<CasOptions> CreateHandler() {
      return new CasHandler(this.casCommunicator);
    }
  }
}

CasHandler.cs

        using System.Collections.Generic;
        using System.Threading.Tasks;
        using System;
        using Microsoft.Owin;
        using Microsoft.Owin.Security;
        using Microsoft.Owin.Security.Infrastructure;
        using System.Security.Claims;

        namespace owin.cas.client {
          public class CasHandler : AuthenticationHandler<CasOptions> {
            private readonly ICasCommunicator casCommunicator;

            public CasHandler(ICasCommunicator casCommunicator) {
              this.casCommunicator = casCommunicator;
            }

            public override async Task<bool> InvokeAsync() {
              // Handle the callback from the remote CAS server
              if (this.Request.Path.ToString().Equals(this.Options.callbackPath)) {
                return await this.InvokeCallbackAsync();
              }

              // Let the next middleware do its thing instead.
              return false;
            }

            protected override async Task<AuthenticationTicket> AuthenticateCoreAsync() {
              IReadableStringCollection query = Request.Query;
              IList<string> tickets = query.GetValues("ticket");
              string ticket = (tickets.Count == 1) ? tickets[0] : null;

              if (string.IsNullOrEmpty(ticket)) {
                return new AuthenticationTicket(null, new AuthenticationProperties());
              }

              CasIdentity casIdentity = await this.casCommunicator.validateTicket(ticket);

              return new AuthenticationTicket(casIdentity, casIdentity.authenticationProperties);
            }

            protected override Task ApplyResponseChallengeAsync() {
              if (Response.StatusCode != 401) {
                return Task.FromResult<object>(null);
              }

              AuthenticationResponseChallenge challenge = this.Helper.LookupChallenge(this.Options.AuthenticationType, this.Options.AuthenticationMode);
              if (challenge != null) {
                string authUrl = this.Options.casBaseUrl + "/login?service=" + Uri.EscapeUriString(this.Options.applicationURL + this.Options.callbackPath);

this.Options.externalRedirectUrl = challenge.Properties.RedirectUri;
                this.Response.StatusCode = 302;
                this.Response.Headers.Set("Location", authUrl);
              }

              return Task.FromResult<object>(null);
            }

            // Basically the same thing as InvokereplyPathAsync() found in most    
            // middleware

            protected async Task<bool> InvokeCallbackAsync() {
          AuthenticationTicket authenticationTicket = await this.AuthenticateAsync();
          if (authenticationTicket == null) {
            this.Response.StatusCode = 500;
            this.Response.Write("Invalid authentication ticket.");
            return true;
          }

          // this.Context.Authentication.SignIn(authenticationTicket.Identity);
          this.Context.Authentication.SignIn(authenticationTicket.Properties, authenticationTicket.Identity);

if(this.Options.externalRedirectUrl != null) {
        Response.Redirect(this.Options.externalRedirectUrl);
      }

          return true;
        }
      }
    }

Constants.cs

using System;

namespace owin.cas.client {
  internal static class Constants {
    internal const string AuthenticationType = "owin.cas.client";

    internal const string V1_VALIDATE = "/validate";

    internal const string V2_VALIDATE = "/serviceValidate";

    internal const string V3_VALIDATE = "/p3/serviceValidate";
  }
}

ICasCommunicator.cs

using System;
using System.Threading.Tasks;

namespace owin.cas.client {
  public interface ICasCommunicator {
    Task<CasIdentity> validateTicket(string ticket);
  }
}

CasIdentity.cs

using System.Collections.Generic;
using System.Security.Claims;
using Microsoft.Owin.Security;

namespace owin.cas.client {
  public class CasIdentity : ClaimsIdentity {
    public CasIdentity() : base() { }
    public CasIdentity(IList<Claim> claims) : base(claims) { }
    public CasIdentity(IList<Claim> claims, string authType) : base(claims, authType) { }

    public AuthenticationProperties authenticationProperties { get; set; }
  }
}

CasExtensions.cs

using Owin;
using Microsoft.Owin.Extensions;

namespace owin.cas.client {
  public static class CasExtensions {
    public static IAppBuilder UseCasAuthentication(this IAppBuilder app, CasOptions options) {
            app.Use(typeof(CasMiddleware), app, options);
            app.UseStageMarker(PipelineStage.Authenticate);
            return app;
        }
  }
}

Cas30.cs (This corresponds with the current protocol version of Jasig CAS)

using System;
using System.Net.Http;
using System.Threading.Tasks;
using System.Security.Claims;
using Microsoft.Owin.Security;
using System.Xml;
using XML;
using System.Collections.Generic;
using System.Diagnostics;

namespace owin.cas.client {
  public class Cas30 : ICasCommunicator {
      private readonly HttpClient httpClient;
      private readonly CasOptions options;

    public Cas30(HttpClient httpClient, CasOptions options) {
        this.httpClient = httpClient;
        this.options = options;
    }

    public async Task<CasIdentity> validateTicket(string ticket) {
        CasIdentity result = new CasIdentity();
        HttpResponseMessage response = await this.httpClient.GetAsync(
          this.options.casBaseUrl + Constants.V3_VALIDATE +
          "?service=" + Uri.EscapeUriString(this.options.applicationURL + this.options.callbackPath) +
          "&ticket=" + Uri.EscapeUriString(ticket)
        );

        string httpResult = await response.Content.ReadAsStringAsync();
        XmlDocument xml = XML.Documents.FromString(httpResult);

        //Begin modification
        XmlNamespaceManager nsmgr = new XmlNamespaceManager(xml.NameTable);
        nsmgr.AddNamespace("cas", "http://www.yale.edu/tp/cas");


        if (xml.GetElementsByTagName("cas:authenticationFailure").Count > 0) {
        result = new CasIdentity();
        result.authenticationProperties = new AuthenticationProperties();
        } else {
        IList<Claim> claims = new List<Claim>();

        string username = xml.SelectSingleNode("//cas:user", nsmgr).InnerText;

        claims.Add(new Claim(ClaimTypes.Name, username));
        claims.Add(new Claim(ClaimTypes.NameIdentifier, username));
        XmlNodeList xmlAttributes = xml.GetElementsByTagName("cas:attributes");

        AuthenticationProperties authProperties = new AuthenticationProperties();
        if (xmlAttributes.Count > 0){ 
          foreach (XmlElement attr in xmlAttributes) {
            if (attr.HasChildNodes) { 
                for (int i = 0; i < attr.ChildNodes.Count; i++) {

                    switch (attr.ChildNodes[i].Name)
                    {
                        case "cas:authenticationDate":
                            authProperties.Dictionary.Add(attr.ChildNodes[i].Name, DateTime.Parse(attr.ChildNodes[i].InnerText).ToString());
                            break;
                        case "cas:longTermAuthenticationRequestTokenUsed":
                        case "cas:isFromNewLogin":
                            authProperties.Dictionary.Add(attr.ChildNodes[i].Name, Boolean.Parse(attr.ChildNodes[i].InnerText).ToString());
                            break;
                        case "cas:memberOf":
                            claims.Add(new Claim(ClaimTypes.Role, attr.ChildNodes[i].InnerText));
                            break;
                        default:
                            authProperties.Dictionary.Add(attr.ChildNodes[i].Name, attr.ChildNodes[i].InnerText);
                            break;
                    }
                }
            }
          }

          result = new CasIdentity(claims, this.options.AuthenticationType);

        }

        result.authenticationProperties = authProperties;

      }

      return result;
    }
  }
}

Startup.Auth.cs

using Microsoft.Owin;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Cookies;
using Microsoft.Owin.Security.Google;
using owin.cas.client;
using Owin;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;

[assembly: OwinStartup(typeof(TestCASapp.Startup))] // This specifies the startup class. 
namespace TestCASapp
{
    public partial class Startup
    {
        public void ConfigureAuth(IAppBuilder app)
        {
            var cookieOptions = new CookieAuthenticationOptions
            {
                LoginPath = new PathString("/Account/Login"),
            };

            app.UseCookieAuthentication(cookieOptions);

            app.SetDefaultSignInAsAuthenticationType(cookieOptions.AuthenticationType = "owin.cas.client");

            CasOptions casOptions = new CasOptions();

            casOptions.applicationURL = "http://www.yourdomain.com/TestCASapp"; // The application URL registered with the CAS server minus the callback path, in this case /casHandler
            casOptions.casBaseUrl = "https://devcas.int.*****.com"; // The base url of the remote CAS server you are targeting for login.
            casOptions.callbackPath = "/casHandler"; // Callback path picked up by the middleware to begin the authentication ticket process

            casOptions.AuthenticationMode = AuthenticationMode.Passive;

            app.UseCasAuthentication(casOptions);

        }
    }
}

AccountController

using Microsoft.Owin.Security;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;


namespace TestCASapp.Controllers
{
    public class AccountController : Controller
    {
        public ActionResult Login(string returnUrl)
        {
            // Request a redirect to the external login provider
            return new ChallengeResult("owin.cas.client",
                Url.Action("ExternalLoginCallback", "Account", new { ReturnUrl = returnUrl }));

            //return new ChallengeResult("Google",
            //    Url.Action("ExternalLoginCallback", "Account", new { ReturnUrl = returnUrl }));
        }

        public ActionResult ExternalLoginCallback(string returnUrl)
        {
            return new RedirectResult(returnUrl);
        }

        // Implementation copied from a standard MVC Project, with some stuff
        // that relates to linking a new external login to an existing identity
        // account removed.
        private class ChallengeResult : HttpUnauthorizedResult
        {
            public ChallengeResult(string provider, string redirectUri)
            {
                LoginProvider = provider;
                RedirectUri = redirectUri;
            }

            public string LoginProvider { get; set; }
            public string RedirectUri { get; set; }

            public override void ExecuteResult(ControllerContext context)
            {
                var properties = new AuthenticationProperties() { RedirectUri = RedirectUri };
                context.HttpContext.GetOwinContext().Authentication.Challenge(properties, LoginProvider);
            }
        }
    }
}

Startup.cs

using Microsoft.Owin;
using Owin;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;

[assembly: OwinStartupAttribute(typeof(TestCASapp.Startup))]

namespace TestCASapp
{
    public partial class Startup
    {
        public void Configuration(IAppBuilder app)
        {
            ConfigureAuth(app);
        }
    }
}

Upvotes: 3

Views: 1444

Answers (1)

John Haynes
John Haynes

Reputation: 63

I figured out what was causing the middleware not to store the cookie information. In the article "Using Owin External Login without Identity", I found the solution in the following sentence, "The cookie middleware will only issue a cookie if the AuthenticationType matches the one in the identity created by the social login middleware."

When I posted the question, I had the cookie middleware authentication type set to its default property, which was "ApplicationCookie" if I'm not mistaken. However, I needed the authentication type to be set to "owin.cas.client" in order for it to match the identity created by the external login middleware. Once I set this accordingly, my application began setting the cookie as expected.

The other issue I was having involved the middleware not redirecting to the ExternalLoginCallback on the Account controller. This was due to the fact that I was not saving the redirectUrl, created when calling the ChallangeResult class, in the CasMiddleware. I added the RedirectUrl to the CasOptions class, and then, once authentication was complete, I simply redirected the user back to the page that required authentication.

I have updated my original question to reflect my changes in hopes that this can prove beneficial to others in the future.

Upvotes: 2

Related Questions