Note that the solution I implemented is based on a variation of that 3rd party application SSO solution, which allows the login into their application using this worklow:
- There is a SharedKey between both services (TM and XYZ)
- TM redirects to XYZ with a special token which is made of MD5 of 'SharedKey +email' and the actual email. This is a GET request that looks like: /sso?requestToken=[md5({SharedKey}+{email})]&email={email}
- XYZ app checks that the email value received matches the MD5 provided in the requestToken value, and if it does, and it is a valid user in their system, XYZ will redirect to TM with an unique token called responseToken
- the TM server should reply back to XYZ server with an MD5 of the responseToken + SharedKey. This is a GET request that looks lke: /sso?confirmToken=[md5(responseToken +SharedKey)]
- if confirmToken is good, then the user will be logged in into XYZ system
The problem is that this is authenticating TM users in XYZ, and what we need is to authenticate XYZ users in TM (there are also a number of other security problems with the SSO solution described above, can you spot them?)
Here are the proposed plan/requirements to order to allow XYZ users to login in TM:
- There is a SharedKey between both services (TM and XYZ)
- XYZ will add a link to its website that will point to https://xyz.teammentor.net/_Customizations/sso.aspx?requestToken=[md5({SharedKey}+{email})]&email={email} (i.e. the 1st step of the TM to XYZ SSO sequence described above
- TM will check that the MD5 matches and:
- If the user exists in TM logs the user in (which will set the user’s TM SessionID cookie)
- If the user doesn’t exist, create a new user with that email and log the user in (also setting user’s TM SessionID cookie)
- Once the user is logged in into TeamMentor he should not be automatically logged out on browser close
- In this first implementation (for a shared client), there should be no changes to the main TeamMentor codebase (i.e. the currently released version)
The next part of this post shows how the first working version of this solution was implemented in TM.
From 3.3, TM supports the customization of its deployed version via the 'site specific UserData repository', which can have extra Html/Js/Aspx/Razor files that can be added to a deployed TM instance (i.e. those files and copied ‘on top of the ‘git pulled web root’ files)
This means that if open the current test TM site User_Data folder
and add a folder called WebRoot_Files (with a test file in it)
and use Tbot’s to reload the TM cache:
That test file will be copied to the web root:
and available via the browser (btw, note how git detected the new file in the git managed folder)
Now, it is not a good practice to put customization files on the web root, so usually the recommendation is that all extra code is placed on a _Customizations folder
and if the new file is an *.aspx:
we can run c# code in there:
Note: there is already C# Razor support in TM (which is what Tbot uses) , BUT at the moment TBot will demand admin privileges, so it can’t be used anonymously. There are also other ways to hook the TM request pipeline, but I think in this case a simple ASPX page will do the trick.
From this ASPX page (renamed to SSO.aspx), it is quite easy to access the main TM objects:
which looks like this:
Tip: For the cases where VisualStudio is being used to create aspx pages like this one, it will make my life easier if to automate a bit the testing workflow.
So I opened up the O2’s VisualStudio C# REPL and wrote this script (which created a VS native window with a web browser control)
which can then be used side-by-side with the script under development:
Ok, back to the script,
Let’s start with a simple case of creating users based on a url parameter
which looks like this when executed:
and if I provide an value that currently doesn’t exist in TM (note that the user search is done username, not email)
a new user will be created that user in memory and in the file system:
All the 'user check and creation' action happens in these two lines (where there is an attempt to resolve a user based on the provided value, and if that value doesn’t exists, the user is created)
Note that there are a bunch of other methods that could be used to create a new user. The one used (with only the first value provided) will create a user with random data on all fields except the UserName (see below)
Now that we can create new users based on the a provided value, the next step is to log that user in.
Which can be done with a couple extra lines of code:
And now, every call to sso.asp will either create a new user, or login into an existing user.
For example http://local.:3187/_Customizations/sso.aspx?userName=test :
will login into the current user test:
and http://local.:3187/_Customizations/sso.aspx?userName=XYZ_User
will create and login the XYZ_User user (for that browser session):
Note: to make that SSO page look a bit better, we can just add a reference to bootstrap css
so it now looks like this:
OK, next we need to add the MD5 check and redirect back to the main page if all is good:
To test this we will need a better environment than just a browser.
So back in the VisualStudio C# REPL environment, lets create an GUI that has a browser and a code editor (connected to that browser)
The script above created this GUI, which when executed will fail the SSO (because we didn’t provide the correct values)
here is a better script (with the correct amount of values, but the wrong ssoKey)
and finally, here is the solution working :)
Login-in as an existing user (with url http://local:3187/_Customizations/sso.aspx?userName=asd@asdasd&requestToken=3854dfd50b3db0de8e408382b07f4383 ):
Login-in with a new user (with url http://local:3187/_Customizations/sso.aspx?userName=A_New@user.com&requestToken=0ea6e75eca7682d0acff45d367f6aaf6)
which as expected, created a new user in the local user store, and logged-in into TM as that user:
Note that if we paste that last URL in another browser:
we will be logged-in as that user (in that browser):
and wil; be logged-out in visual studio (where we were logged-in as that user)
and the user activity log will shown a number of new logins
This wraps up the current post.
We have the desired SSO solution which is easy to deploy, test and implement (in any 3.3 TM server).
Finally, can you think of better/more-secure ways to implement this SSO solution?
I have a number of ideas and PoCs that will be writing up in the next couple weeks, so I’ll be interrested in your views :)
Scripts used in this post:
1) version of the SSO.aspx page without MD5 check and with list of current users
1: <%@ Page Language="C#"%>
2:
3: <%@ Import Namespace ="O2.DotNetWrappers.ExtensionMethods" %>
4: <%@ Import Namespace="TeamMentor.CoreLib" %>
5:
6: <%
7: var xmlDatabase = TM_Xml_Database.Current;
8: var userData = xmlDatabase.UserData;
9: var authentication = new TM_Authentication(null);
10: var request = HttpContextFactory.Request;
11:
12: var ssoKey = "AAAAAAAAAa12345BBBBBBB";
13: var userName = request["userName"];
14: var requestToken = request["requestToken"];
15: var expectedToken = (userName + ssoKey).md5Hash();
16:
17:
18: var tmUser = userName.tmUser(); // see if there is a user with the provided value
19:
20: if (tmUser.isNull()) // if not
21: tmUser = userData.newUser(userName) // create it (returns new userId)
22: .tmUser(); // and get the user object from the userId
23:
24: var loginGuid = tmUser.login(); // login user in TM
25: authentication.sessionID = loginGuid; // triggers the update of user's cookies
26:
27: %>
28:
29: <h2>TM SSO page</h2>
30: userName provided <%=userName.htmlEncode()%><br/>
31:
32: <br/>
33: There are currently <%=userData.TMUsers.size() %> users
34: <p>
35: <pre>
36: <%= userData.TMUsers.@select(user=>user.UserName).toString() %>
37: </pre>
38: </p>
39:
40: Current User: <%= new TM_Authentication(null).currentUser.UserName %>
2) Final version of the SSO.aspx page:
1: <%@ Page Language="C#"%>
2:
3: <%@ Import Namespace ="O2.DotNetWrappers.ExtensionMethods" %>
4: <%@ Import Namespace="TeamMentor.CoreLib" %>
5:
6: <link href="../Javascript/bootstrap/bootstrap.v.1.2.0.css" rel="stylesheet" type="text/css" />
7:
8: <%
9: var xmlDatabase = TM_Xml_Database.Current;
10: var userData = xmlDatabase.UserData;
11: var authentication = new TM_Authentication(null);
12: var request = HttpContextFactory.Request;
13: var response = HttpContextFactory.Response;
14:
15: var ssoKey = "AAAAAAAAAa12345BBBBBBB";
16: var userName = request["userName"];
17: var requestToken = request["requestToken"];
18: var expectedToken = (userName + ssoKey).md5Hash();
19:
20: try
21: {
22: if (userName.valid() && requestToken.valid() && expectedToken == requestToken)
23: {
24: var tmUser = userName.tmUser(); // see if there is a user with the provided value
25:
26: if (tmUser.isNull()) // if not
27: tmUser = userData.newUser(userName) // create it (returns new userId)
28: .tmUser(); // and get the user object from the userId
29:
30: var loginGuid = tmUser.login(); // login user in TM
31: authentication.sessionID = loginGuid; // triggers the update of user's cookies
32: response.Redirect("/teammentor"); // redirects user to logged in user
33: }
34: else
35: "[TM SSO] Failed to SSO with the values provided: {0} {1}".error(userName, requestToken);
36: }
37: catch (Exception ex)
38: {
39: ex.log();
40: }
41:
42: %>
43:
44: TM SSO: Failed to login user
3) VisualStudio C# script that creates the popup window (with another C# Repl script connected to a browser)
1: var visualStudio = new VisualStudio_2010();
2: var webBrowser= visualStudio.open_Panel("SSO test")
3: .add_WebBrowser_with_NavigationBar();
4: var firstScript = @"
5: var url = "
6: webBrowser.open(url);
7: return webBrowser;"
; 8: webBrowser.insert_Below().add_Script_Me(webBrowser)
9: .set_Code(firstScript);
10:
11: return visualStudio.dte();
4) popup window that tests the SSO:
1: var userName = "A_New_123@user.com";
2: var ssoKey = "AAAAAAAAAa12345BBBBBBB";
3: var expectedToken = (userName + ssoKey).md5Hash();
4:
5: var urlTemplate = "http://local:3187/_Customizations/sso.aspx?userName={0}&requestToken={1}";
6: var url = urlTemplate.format(userName, expectedToken);
7: webBrowser.open(url);
8: return url;
9: //return webBrowser;