Authenticating Against an External User Store in Mura CMS

Posted on Mar 23, 2010

At my office, we are working on a Mura CMS implementation for an major rewrite currently under developpment. One of the issues we needed to resolve was that front-end users in Mura, as in those people who could add comments and ratings to posts, needed to be validated against a user store that exists outside Mura. Thankfully, Mura makes it relatively easy to handle these sorts of issues through the long list of event hooks it supports. This post will cover how, with some guidance from Matt Levine at Blue River Interactive, I created and registered my event handler to handle user creation and authentication (and synced this with Mura's user store). Keep in mind this is still a proof-of-concept from a development standpoint, but everything is working nicely.

Registering an Event Handler
An event handler in Mura is just a standard CFC that extends mura.cfobject. Other than that, the only real requirement of the component is that it implements methods for whichever Mura events you would like it to respond to. For example, my event handler, which is called external login has methods for onSiteRequestStart, onSiteLogin and onBeforeUserSave (it has other internal methods as well, but only these specifically tie into Mura events). I placed this component in my site's /[siteID]/includes/ directory.

Once you have this CFC created, you need to register it within Mura. The Mura blog has a full, detailed example of how you would accomplish this. In my case, it was just a case of adding two lines of code to my site's eventHandler.cfc (/[siteID]/includes/eventHandler.cfc) within the onApplicationLoad method:

<cfset var externalLogin = createObject("component","externalLogin") />
<cfset application.pluginManager.addEventHandler(externalLogin,event.getValue("siteID")) />

Capturing the Login
Let's first take a look at the process of manually logging in. By this I mean, a user clicks on something that requires logging in and is prompted to enter their username and password (later we'll discuss automatically logging in, whereby your user may have already logged into the non-Mura portion of the site and we don't want to ask them to re-authenticate). In order to do this, we need to create a method for the onSiteLogin event in which we will just call another internal method that handles the details of the manual login process:

<cffunction name="onSiteLogin" access="public" output="false" returntype="void">
   <cfargument name="event" type="any" required="true" />

   <cfset manualLogin(
      arguments.event.getValue("username"),
      arguments.event.getValue("password"),
      arguments.event.getValue("siteid")
   ) />

</cffunction>

As you can see, we are already passing along the username and password from the event. Next we need to authenticate that against the non-Mura portion of our application. In my case, I decided to call my external component as a web service. This is because my external application is using ColdFusion 9 ORM to handle the data and I could not instantiate the component directly within my Mura application container as it did not have the proper ORM settings enabled (and I didn't want to toy with core files).

<cffunction name="manualLogin" access="private" output="false" returntype="void">
   <cfargument name="username" type="string" required="true" default="" />
   <cfargument name="password" type="string" required="true" default="" />
   <cfargument name="siteid" type="string" required="false" default="" />
   
   <cfset var userBean = "" />
   <cfset var user = "" />
   <cfinvoke webservice="http://mysite/private/com/mysite/user/login.cfc?wsdl"
      method="loginUser" returnvariable="user">

      <cfinvokeargument name="username" value="#arguments.username#" />
      <cfinvokeargument name="password" value="#arguments.password#" />
   </cfinvoke>
   <!--- the password on my side gets 1 way encryption, for some reason this caused the login to fail --->
   <cfset user.password = arguments.password />
   <cfset userBean = unpackUser(user,arguments.siteID) />
   <cfif isObject(userBean) and len(userBean.getUserID()) gt 0>
      <cfset getBean("userUtility").loginByUserID(userBean.getUserID(), userBean.getSiteID())>
   </cfif>
</cffunction>

Another issue I ran into that I couldn't quite understand is that the password I am passing gets a standard 1-way encryption. However, even though the authentication on the loginByUserID() method returned true and the cookie would be created, the login form would return a message that the login had failed. I found that by simply resetting the password to the unencrypted passed version caused this issue to go away.

Synchronizing the User Data
You'll notice in the above code there is a call to an unPackUser() method in there, which is where a lot of the logic occurs. This method is a slightly altered version of code provided to me by Matt at Blue River and is where the synchronization of data between your external user database and Mura's user database happens. One key item I modified from what Matt sent was the addition of the remoteID. This field already exists in the Mura user database but I wanted to ensure it gets populated when the user information is synchronized this way I can easily query Mura to find what comments or ratings a user from our primary application has submitted. Matt's code also included logic for handling groups which I left in there despite the fact that, at this point in time, this isn't an issue for my application.

<cffunction name="unpackUser" access="private" output="false" returntype="any">
   <cfargument name="user" type="struct" required="true" />
   <cfargument name="siteid" type="string" required="false" default="" />
   
   <cfset var groups = "" />
   <cfset var userBean = "" />
   <cfset var isNew = true />
   <cfset var rolelist="" />
   <cfset var userManager=getBean("userManager") />
   
      <!--- Check if the user is a member of any Mura groups with the same name. --->
      <cfif not len(arguments.siteID)>
         <cfset arguments.siteID=getBean("contentServer").bindToDomain() />
      </cfif>
                     
      <cfset groups=userManager.getPublicGroups(arguments.siteID) />   
      <cfloop query="groups">
         <cfif listFindNoCase(arguments.user.roles,groups.groupname)>
            <cfset rolelist=listappend(rolelist,groups.userID) />
         </cfif>
      </cfloop>
            
      <cfset groups=userManager.getPrivateGroups(arguments.siteID) />   
      <cfloop query="groups">
         <cfif listFindNoCase(arguments.user.roles,groups.groupname)>
            <cfset rolelist=listappend(arguments.rolelist,groups.userID) />
          </cfif>
      </cfloop>
                           
      <!--- Check to see if the user has previous login into the system --->
      <cfset userBean=getBean("user").loadBy(username=arguments.user.username,siteID=arguments.siteID)/>
      <!--- only do sync for site members not adminsitrative users --->
      <cfif userBean.getIsPublic()>
         <cfset userBean.setUsername(arguments.user.username) />
         <cfset userBean.setPassword(arguments.user.password) />
         <cfset userBean.setFname(arguments.user.firstname) />
         <cfset userBean.setLname(arguments.user.lastname) />
         <cfset userBean.setEmail(arguments.user.email) />
         <cfset userBean.setlastUpdateBy('System') />         
         <cfset userBean.setGroupID(rolelist) />
         <cfif structKeyExists(arguments.user,"remoteID") and len(arguments.user.remoteID)>
            <cfset userBean.setRemoteID(arguments.user.remoteID) />
         </cfif>
               
         <cfif userBean.getIsNew()>
            <cfset userBean.setSiteID(getBean("settingsManager").getSite(arguments.siteID).getPublicUserPoolID()) />
            <cfset userBean.setIsPublic(1) />
         </cfif>
         <cfset userBean.save() />
      </cfif>
      <cfreturn userBean />
   </cffunction>
</cfcomponent>

Automatic Login
So what happens if the user has already logged in to my external application but have not authenticated within Mura. Since, from my user's perspective these applications are one and the same, I don't want to make them re-login. In order to do this, I tie my event handler to the onSiteRequestStart event. Keep in mind, both applications in my case will live under the same primary domain so the cookies are shared - if this is not the case for you, then you would need to alter this logic to handle that.

First I have the event handler method for capturing the onSiteRequestStart event in Mura which simply calls my automaticLogin() method:

<cffunction name="onSiteRequestStart" access="public" output="false" returntype="void">
   <cfargument name="event" type="any" required="true" />
   
   <cfset automaticLogin(arguments.event) />
</cffunction>

Now let's take a look at the automatic login method. The key thing going on here is that I am not logging the user in by their ID as before but rather via the remoteID property since the cookie does not contain their Mura ID (also keep in mind this is a proof of concept and I may add some encryption of the userID in a later version but for now its stored as clear text in the cookie). The only other issue to note here is that even though we don't need to use result of the unpackUser() method, we still need to call it since this is where the data synchronization happens. Otherwise, I think this method is fairly straightfoward to understand.

<cffunction name="automaticLogin" access="private" output="false" returntype="void">
   <cfargument name="event">
   
   <cfset var userBean="" />
   <cfset var user = "" />
   <cfset var siteID = arguments.event.getValue('siteID') />

   <!--- check to see if they are already logged in to the external site --->
   <cfif structKeyExists(cookie,"user")>
      <cfinvoke webservice="http://mysite/private/com/mysite/user/login.cfc?wsdl"
         method="getUserByID" returnvariable="user">

         <cfinvokeargument name="id" value="#cookie.user#" />
      </cfinvoke>
      <!--- call this simply to sync data --->
      <cfset unpackUser(user,siteID)>
      <!--- load via the remoteID property which is our ID in our external store --->
      <cfset userBean = getBean("user").loadBy(remoteID=user.ID,siteID=siteID) />
      <cfif isObject(userBean) and len(userBean.getUserID()) gt 0>
         <cfset getBean("userUtility").loginByUserID(userBean.getUserID(),siteID) />
      </cfif>
   </cfif>
</cffunction>

Creating a New User
Now that a user can login using Mura's login form and be authenticated against my external user store, what happens if they don't have an account and decide to create one on the Mura side. Well, we need to capture this submission, ensure it meets our business rules for our external users (and if so, insert them) and finally synchronize this new user on the Mura side. We do this by tying into the onBeforeUserSave event. I chose before rather than after because if the user fails our external application's tests (for example, they already have an account within the external user store) then I do not want the process to continue normally. Here is the code for the onBeforeUserSave() method in my externalLogin.cfc event handler:

<cffunction name="onBeforeUserSave" access="public" output="false" returntype="void">
   <cfargument name="event" type="any" required="true" />
   
   <cfset var userBean = event.getValues().userBean />
   <cfset var user = "" />
   <!--- if user does not already have a remote user record --->
   <cfif not len(userBean.getAllValues().remoteID)>
      <cfinvoke webservice="http://mysite/private/com/mysite/user/login.cfc?wsdl"
         method="registerUser" returnvariable="user">

         <cfinvokeargument name="email" value="#userBean.getUsername()#" />
         <cfinvokeargument name="password" value="#userBean.getPassword()#" />
         <cfinvokeargument name="firstname" value="#userBean.getFName()#" />
         <cfinvokeargument name="lastname" value="#userBean.getLName()#" />
      </cfinvoke>
      
      <!--- errors is just a structure in my user structure --->
      <cfif structIsEmpty(user.errors)>
         <!--- set the id from the external user store for mura db --->
         <cfset userBean.setRemoteID(user.ID) />
      <cfelse>
         <!--- set errors to pass back to the user --->
         <cfset userBean.setErrors(user.errors) />
      </cfif>
   </cfif>
</cffunction>

A couple of items of particular note here. You will notice I wrap most of the logic in an if statement that checks to see if the remoteID already exists on the user. This is because this event will fire every time you run the unpackUser method since, in synchronizing the data, it actually saves the user. By adding this if, I ignore re-attempting to save a user I have already saved during the manual login or automatic login process synchronization. The second item is the addition of errors passed back by my external register method to the userBean. This is actually how you force Mura not to save the user. Apparently, if Mura sees errors in this field (which is a simple ColdFusion structure), it will not save the user and will display these messages as errors to the user in the form.

That's All Folks!
All in all, once you get a sense of what is passed and how to handle capturing Mura events, this is pretty straightforward and opens up a lot of possibilities even beyond simply authentication. While I likely couldn't have gotten this working without the Blue River team's help (again keeping in mind this is still a work in progress on my end), I am hoping this tutorial will help anyone looking to implement this sort of feature down the road.

Comments

Steve Withington Brian, thanks for sharing this information man! I've had a few inquiries about this and just haven't had time to whip something as nice as this up. You rock!

Posted By Steve Withington / Posted on 03/24/2010 at 9:41 AM


Michael Runyon Brian,

Thank you for sharing this. Your solution is remarkably complete (even adding new users, etc). It has helped me immensely.

Posted By Michael Runyon / Posted on 05/26/2010 at 8:22 AM


Chris Thanks for the great article Brian. I'm fairly new to working with Mura, and I can say without this - I wouldn't have had a clue where to start in solving this problem. So many includes, so many ways to do what we're trying to do. This is very well done, and easy to follow/extend.

I've extended it to suit my purposes and it's working like a charm for my site.

Thanks so much for sharing, this has saved me a bucket load of time and effort.

Where do I send you a beer/wine/martini/packets of smokes/cigar/T shirt etc.??

Posted By Chris / Posted on 06/15/2010 at 9:57 PM


Brian Rinaldi @Chris - awesome! yeah, some of this stuff in Mura isn't always straightforward to figure out or fully documented yet. Glad I could help.

Posted By Brian Rinaldi / Posted on 06/16/2010 at 4:18 AM


Write your comment



(it will not be displayed)





About

My name is Brian Rinaldi and I am the Web Community Manager for Flash Platform at Adobe. I am a regular blogger, speaker and author. I also founded RIA Unleashed conference in Boston. The views expressed on this site are my own & not those of my employer.