Single Sign-Out with CAS in .NET

The old website of Everit will be closed soon. Therefore I am starting to backup the blog posts I wrote years ago and that still have several visitors. Let this be the first one:

There are several tutorials in the CAS wiki how to do SSO (Single Sign On) inside a .NET web application and CAS. There is a CAS client as well that is currently in 1.0 pre state. I tried it but did not work well and as there is not much time I had to find a workaround that handles Single Sign Out as well using FormsAuthentication.

Based on the tutorial available in the CAS wiki I created my own. The difference is that I created a static Dictionary that stores the tickets and the belonging sessions in weak references. Periodically it is checked if a session is not used anymore and that ticket is cleared from the this dictionary (not to have memory leak). When there is a sign out message from CAS the changed code gets out the entry and sets a flag to say next time somebody visits the site the user is logged out. Also the page loader of the master site checks the Dictionary in each request if a logout occured and if yes it drops to the logout page The following steps are necessary to have the solution:

  1. Be sure that both IIS and the CAS server are accessed via https (they do not work with simple http) and they both can see each other based on their domain.
  2. Create a new form called CASLogin.aspx and add a new Label onto it. This label will have the name Label1.
  3. Add the attribute ValidateRequest=”false” to the <%@ Page… element in CASLogin.aspx
  4. Add the following code into CASLogin.aspx.cs (change the url of the cas server):
     using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Web;
    using System.Web.UI;
    using System.Web.UI.WebControls;
    using System.IO;
    using System.Net;
    using System.Xml;
    using System.Web.Security;
    using System.Net.Security;
    using System.Text.RegularExpressions;
    
    namespace WebApplication2.Account
    {
        public partial class CASLogin : System.Web.UI.Page
        {
            // Local specific CAS host
            private const string CASHOST = "https://localhost:8181/cas/";
    
            protected void Page_Load(object sender, EventArgs e)
            {
                String logoutRequest = Request.Params.Get("logoutRequest");
                if (logoutRequest != null)
                {
                    Regex regex = new Regex("<.*\\:?SessionIndex\\>(.*)\\<\\/.*\\:?SessionIndex>");
                    Match match = regex.Match(logoutRequest);
                    if (match != null && match.Success)
                    {
                        string signOutTicket = match.Groups[1].Value;
                        try {
                        CASUtil.CASTicket casTicket = CASUtil.sessionTickets[signOutTicket];
                        casTicket.LogoutHappened = true;
                        
                        } catch (KeyNotFoundException ex) {
    
                            // Do not care about it
                        }
                        return;
                    }
                }
                // Look for the "ticket=" after the "?" in the URL
                string tkt = Request.QueryString["ticket"];
    
                // This page is the CAS service=, but discard any query string residue
                string service = Request.Url.GetLeftPart(UriPartial.Path);
    
                // First time through there is no ticket=, so redirect to CAS login
                if (tkt == null || tkt.Length == 0)
                {
                    string redir = CASHOST + "login?" +
                      "service=" + service;
                    Response.Redirect(redir);
                    return;
                }
    
                // Second time (back from CAS) there is a ticket= to validate
                string validateurl = CASHOST + "serviceValidate?" +
                  "ticket=" + tkt + "&" +
                  "service=" + service;
    
                //Change SSL checks so that all checks pass
                ServicePointManager.ServerCertificateValidationCallback =
                    new RemoteCertificateValidationCallback(
                        delegate
                        { return true; }
                    );
       
                StreamReader Reader = new StreamReader(new WebClient().OpenRead(validateurl));
                string resp = Reader.ReadToEnd();
                // I like to have the text in memory for debugging rather than parsing the stream
    
                // Some boilerplate to set up the parse.
                NameTable nt = new NameTable();
                XmlNamespaceManager nsmgr = new XmlNamespaceManager(nt);
                XmlParserContext context = new XmlParserContext(null, nsmgr, null, XmlSpace.None);
                XmlTextReader reader = new XmlTextReader(resp, XmlNodeType.Element, context);
    
                string netid = null;
    
                // A very dumb use of XML. Just scan for the "user". If it isn't there, its an error.
                while (reader.Read())
                {
                    if (reader.IsStartElement())
                    {
                        string tag = reader.LocalName;
                        if (tag == "user")
                            netid = reader.ReadString();
                    }
                }
                // if you want to parse the proxy chain, just add the logic above
                reader.Close();
                // If there was a problem, leave the message on the screen. Otherwise, return to original page.
                if (netid == null)
                {
                    Label1.Text = "CAS returned to this application, "
                        + "but then refused to validate your identity.";
                }
                else
                {
    
                    HttpContext.Current.Session.Add("CASTicket", tkt);
                    CASUtil.CASTicket casTicket = new CASUtil.CASTicket(HttpContext.Current.Session);
                    CASUtil.sessionTickets.Add(tkt, casTicket);
                    Label1.Text = "Welcome " + netid;
                    FormsAuthentication.RedirectFromLoginPage(netid, false); // set netid in ASP.NET blocks
                }
            }
        }
    }
    
    
  5. Create a new class with the name CASUtil and add the following code into CASUtil.cs:
     using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Web;
    
    namespace WebApplication2
    {
        public class CASUtil
        {
    
            public static Dictionary<String, CASTicket> sessionTickets;
    
            private static System.Timers.Timer sessionTicketsChecker = new System.Timers.Timer();
    
            static CASUtil()
            {
                sessionTickets = new Dictionary<String, CASTicket>();
                sessionTicketsChecker = new System.Timers.Timer(2000);
                sessionTicketsChecker.Elapsed += new System.Timers.ElapsedEventHandler(CASUtil.checkSessionTickets);
                sessionTicketsChecker.Enabled = true;
            }
    
            private static void checkSessionTickets(object sender, System.Timers.ElapsedEventArgs e)
            {
                Dictionary<string, CASTicket>.KeyCollection kCol = sessionTickets.Keys;
                foreach (String ticket in kCol)
                {
                    if (!sessionTickets[ticket].HttpSessionStateWR.IsAlive)
                    {
                        sessionTickets.Remove(ticket);
                    }
                }
            }
    
    
            public class CASTicket
            {
                public CASTicket(System.Web.SessionState.HttpSessionState session)
                {
                    this.httpSessionStateWR = new WeakReference(session);
                }
                private WeakReference httpSessionStateWR;
    
                private bool logoutHappened = false;
    
                public WeakReference HttpSessionStateWR
                {
                    get { return httpSessionStateWR; }
                    set { httpSessionStateWR = value; }
                }
    
                public bool LogoutHappened
                {
                    get { return logoutHappened; }
                    set { logoutHappened = value; }
                }
            }
        }
    }
    
  6. Set the following in web.config:
     <authentication mode="Forms">
          <!-- <forms loginUrl="~/Account/Login.aspx" timeout="2880" />-->
          <forms name="casauth" loginUrl="~/Account/CASLogin.aspx" />
        </authentication>
        <authorization>
          <deny users="?" />
        </authorization>
    
  7. Add the following to the beginning of web.config inside <system.web>:
     <httpRuntime requestValidationMode="2.0" />
    
    
  8. Add the following to Default.aspx.cs:
     using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Web;
    using System.Web.UI;
    using System.Web.UI.WebControls;
    using System.Web.Security;
    
    namespace WebApplication2
    {
        public partial class _Default : System.Web.UI.Page
        {
            protected void Page_Load(object sender, EventArgs e)
            {
                try
                {
                    string ticket = (string)Context.Session["CASTicket"];
                    if (ticket != null)
                    {
                        CASUtil.CASTicket casTicket = CASUtil.sessionTickets[ticket];
                        if (casTicket.LogoutHappened)
                        {
                            FormsAuthentication.SignOut();
                            FormsAuthentication.RedirectToLoginPage();
                        }
                    }
                }
                catch (KeyNotFoundException ex)
                {
                    //Do nothing
                }
                
    
            }
        }
    }
    

Step 3 and 7 are very important with the entry ValidateRequest=”false” in CASLogin.aspx otherwise you will get a http 500 error when the CAS server sends the SAML sign out message. This is because if validate request is turned on ASP.NET does not allow any form parameter that contains possible xml elements. You can find more information about it here.

There might be other and better solutions but I am not a .NET developer. I guess there is a better place to do the flag check than the page load function of the default page. Also with the static variables the solution does not work via a cluster. Also I guessed that a user has to login to see any page. Otherwise the session should be invalidated not by dropping to the logout page but somehow else.

Advertisements

About Balázs Zsoldos

Balazs Zsoldos is the co-founder of Everit. He is the leader of the development of Everit OpenSource Components. Developing Java based solutions is not only his job but also his passion. He believes in simplicity. That is why he decided to design and build as many simple, but useful goal-oriented modules as he can. As the base of the stack, he chose OSGi. Balazs does not believe in monoholitic frameworks, therefore all of the solutions that was designed by him can be used separately. In the beginning of his career, Balazs was a big fan of JEE and Spring. After a while, he changed his mind and started to try replacing everything with non-magical solutions that do not contain interceptors, weaving, etc.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: