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
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
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
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
@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