ADAM & ASP.NET MVC Part 1

ADAM has always been a rather Web Forms targeted product, allowing developers to build powerful solutions on the ADAM platform quickly and efficiently. Web Forms does have its problems, however. It’s a rather awkward abstraction of the Windows Forms paradigm bolted on top of web requests and responses. View state, post back event handling and client ids are some of the mechanisms that make it possible, but feel unnatural in today’s web with AJAX and client scripting.

But there’s a new kid on the block. ASP.NET MVC follows a different pattern than Web Forms that seems a more natural fit to the way the web works. Sure, it’s “closer to the metal”, but which technology is a better fit depends entirely on the scope of the project and its audience.

ADAM today officially does not support ASP.NET MVC, but it does allow applications to directly use the Core API that forms the business model of all things ADAM. Over the next couple of articles I will explain how you can get started with ADAM and ASP.NET MVC, and help you overcome the issues you might encounter. However, this is not an ASP.NET MVC tutorial, so if you’re new to the whole concept of MVC in general or how to build an ASP.NET MVC app, there’s plenty of tutorials you can find on the Web.

In this article, I will set you up and running with ASP.NET MVC that authenticates with ADAM.

To continue, you’ll need a working ADAM 4.5 installation on your machine, preferably with a database that contains already some assets, classifications, users, fields, and such. We won’t be making any changes to the database in this article, but it’s generally not a good idea to work on a production environment while experimenting with new stuff. Also, since we won’t be using search expressions to limit our queries into ADAM, having a big database may slow you down.

You’ll also need an up-to-date copy of Visual Studio 2008. In the currently released version, ADAM does not yet support the new .NET Framework 4. Also, I’ll be using ASP.NET MVC 2 for this article, so you should install that too. There should be no reason why you wouldn’t get it to work with ASP.NET MVC 1, however.

In short, here’s what we’re going to do:

  1. Create a new ASP.NET MVC 2 project.
  2. Add references to the ADAM assemblies Adam.Core and Adam.Tools.
  3. Create the required classes to handle authentication.
  4. Setup configuration

The first thing you do is create a new ASP.NET MVC project, and verify that it runs. You won’t be needing a Unit Test project, but feel free to add one if you want to take a TDD approach once you’ve gone through this article. Start it up, and you should be greeted with a familiar welcome screen.

Great! We won’t be changing the look & feel of this page (in fact, we won’t be touching the HomeController or its views anyway) so let’s get busy.

To keep things simple, we’re going to use the ADAM objects to render our views. In a larger project, you might want to use your own business model to simplify your rendering code, and to improve testability and “separation of concerns”. But first, we must tackle authentication.

Using ADAM as a data store, authentication works a little different. The application must log into ADAM with the user’s credentials, and a session will be started for that user that will outlive a single request. Contrast this with the usual MVC + SQL Server setup where the user’s session is maintained only in ASP.NET itself, and the authentication with SQL Server is performed on application level, on every request, with the ADO.NET provider pooling the connections for increased performance.

Performing an initial authentication with ADAM is an intensive task that takes up a relatively large amount of CPU and database utilization. That’s why a session is created, and a session identifier is returned for the application to use on subsequent authentications.

The lifetime of this session is controlled by ADAM itself through its configuration, so the ASP.NET application cannot make any assumption on the duration. For this reason, we’ll store the user’s name and password along with the session identifier in a cookie so that on each request we can, potentially, log back into ADAM and pretend the session never did expire.

To help with this, we’ll build a class to capture the information we want to store in the cookie. This class will also be able to write itself to a byte array and read itself from one, and we’ll call it AuthenticationTicket. To keep things orderly, you might want to make a folder “Core” and place the class there (it will also determine its default namespace):

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
using System;
using Adam.Core;
using System.IO;

namespace AdamMVC.Core
{
    /// <summary>
    /// Contains the information about a valid authentication.
    /// </summary>
    public class AuthenticationTicket
    {
        /// <summary>
        /// Sets or gets the name of the registration used during authentication. 
        /// An empty string indicates the default registration was used.
        /// </summary>
        public string RegistrationName { get; set; }

        /// <summary>
        /// Sets or gets the name of the site used during authentication. 
        /// An empty string indicates the default site was used.
        /// </summary>
        public string SiteName { get; set; }

        /// <summary>
        /// Sets or gets the name of the user used during authentication.
        /// </summary>
        public string UserName { get; set; }

        /// <summary>
        /// Sets or gets the identity of the session in progress.
        /// </summary>
        public Guid SessionId { get; set; }

        /// <summary>
        /// Sets or gets the logon status achieved during authentication.
        /// </summary>
        public LogOnStatus LogonStatus { get; set; }

        /// <summary>
        /// Sets or gets the password of the user used during authentication.
        /// </summary>
        public string Password { get; set; }

        /// <summary>
        /// Returns the ticket as an array of bytes.
        /// </summary>
        /// <returns>An array of bytes containing the ticket.</returns>
        public byte[] GetBytes()
        {
            MemoryStream stream = new MemoryStream();

            using (BinaryWriter writer = new BinaryWriter(stream))
            {
                writer.Write(this.RegistrationName ?? string.Empty);
                writer.Write(this.SiteName ?? string.Empty);
                writer.Write(this.UserName ?? string.Empty);
                writer.Write(this.SessionId.ToByteArray());
                writer.Write((byte)this.LogonStatus);
                writer.Write(this.Password ?? string.Empty);
            }

            return stream.ToArray();
        }

        /// <summary>
        /// Reads a ticket from the specified array of bytes and returns it.
        /// </summary>
        /// <param name="bytes">The array of bytes containing the tiket.</param>
        /// <returns>
        /// A new instance of <see cref="AuthenticationTicket" /> 
        /// or null if no valid ticket was read.
        /// </returns>
        /// <remarks>
        /// If this ticket comes from a cookie or another public source, 
        /// it is up to the caller to verify the authenticity of the ticket.
        /// </remarks>
        public static AuthenticationTicket FromBytes(byte[] bytes)
        {
            MemoryStream stream = new MemoryStream(bytes);
            var ticket = new AuthenticationTicket();

            try
            {
                using (BinaryReader reader = new BinaryReader(stream))
                {
                    ticket.RegistrationName = reader.ReadString();
                    ticket.SiteName = reader.ReadString();
                    ticket.UserName = reader.ReadString();
                    ticket.SessionId = new Guid(reader.ReadBytes(16));
                    ticket.LogonStatus = (LogOnStatus)reader.ReadByte();
                    ticket.Password = reader.ReadString();
                }
            }
            catch (IOException)
            {
                // That was not a valid ticket.
                return null;
            }

            return ticket;
        }
    }
}

Other than the auto-implemented properties (which need to further explanation) this class has a GetBytes() method and static FromBytes() method that will convert this ticket to and from a byte array.

The next class is Authentication:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
using System;
using System.Security;
using System.Security.Principal;
using System.Web;
using System.Web.Security;
using Adam.Core;

namespace AdamMVC.Core
{
    public static class Authentication
    {
        #region Constants

        public static readonly TimeSpan Timeout = TimeSpan.FromMinutes(30);
        public const string CookieName = ".ADAM_MVC_AUTH";
        private static readonly Type CacheKey = typeof(AuthenticationTicket);
        private const string UserDoesNotMatchTheOneOfTheTicket = 
            "The name of the currently logged in user does not match the one of the ticket.";

        #endregion

        #region Privates

        private static HttpCookie CreateAuthCookie(string value)
        {
            if (string.IsNullOrEmpty(value))
            {
                var browser = HttpContext.Current.Request.Browser;
                value = browser.SupportsEmptyStringInCookieValue ? string.Empty : "nocookie";
            }

            return new HttpCookie(CookieName, value) { HttpOnly = true, Path = "/", Secure = false };
        }

        private static string EncryptTicket(AuthenticationTicket ticket)
        {
            // TODO: Add encryption.
            byte[] ticketData = ticket.GetBytes();
            return Convert.ToBase64String(ticketData);
        }

        private static AuthenticationTicket DecryptTicket(string ticket)
        {
            // TODO: Add decryption.
            byte[] ticketData = Convert.FromBase64String(ticket);
            var authenticationTicket = new AuthenticationTicket();
            return AuthenticationTicket.FromBytes(ticketData);
        }

        private static string NullIfEmpty(string value)
        {
            return string.IsNullOrEmpty(value) ? null : value;
        }

        private static void RemoveAuthCookie()
        {
            HttpCookie cookie = CreateAuthCookie(null);
            cookie.Expires = new DateTime(1999, 10, 12);

            HttpContext.Current.Response.Cookies.Remove(cookie.Name);
            HttpContext.Current.Response.Cookies.Add(cookie);

            CachedTicket = null;
        }

        private static void StoreLogonInfo(AuthenticationTicket authenticationTicket)
        {
            // Store the ticket.
            HttpCookie cookie = CreateAuthCookie(EncryptTicket(authenticationTicket));
            HttpContext.Current.Response.Cookies.Add(cookie);

            CachedTicket = authenticationTicket;
        }

        private static void UpdateAuthTicketWhenNeeded(AuthenticationTicket ticket)
        {
            IPrincipal principal = HttpContext.Current.User;

            if (principal != null)
            {
                FormsIdentity identity = principal.Identity as FormsIdentity;

                if (identity != null && identity.Ticket.IsPersistent)
                {
                    HttpCookie cookie = CreateAuthCookie(EncryptTicket(ticket));
                    cookie.Expires = DateTime.UtcNow.Add(Timeout);
                    HttpContext.Current.Response.Cookies.Add(cookie);
                }
            }
        }

        private static AuthenticationTicket CachedTicket
        {
            get
            {
                return HttpContext.Current.Items[CacheKey] as AuthenticationTicket;
            }
            set
            {
                if (value != null)
                {
                    HttpContext.Current.Items[CacheKey] = value;
                }
                else
                {
                    HttpContext.Current.Items.Remove(CacheKey);
                }
            }
        }

        #endregion

        /// <summary>
        /// Tries an ADAM login by using the current authentication ticket.
        /// </summary>
        /// <exception cref="SecurityException">
        /// The name of the currently logged in user does not match the one of the ticket.
        /// </exception>
        /// <returns>
        /// An <see cref="Application"/> object when the login was successful; otherwise, 
        /// <b>null</b>.
        /// </returns>
        public static Application TryLogOnWithAuthenticationTicket()
        {
            AuthenticationTicket ticket = CurrentTicket;
            Application application = null;

            if (ticket != null && ticket.LogonStatus == LogOnStatus.LoggedOn)
            {
                // Check if the current user is the same as the one specified.
                IPrincipal principal = HttpContext.Current.User;

                if (principal != null &&
                    principal.Identity != null &&
                        !string.IsNullOrEmpty(principal.Identity.Name) &&
                            !principal.Identity.Name.Equals(ticket.UserName, StringComparison.OrdinalIgnoreCase))
                {
                    RemoveAuthCookie();
                    throw new SecurityException(UserDoesNotMatchTheOneOfTheTicket);
                }

                application = new Application();
                switch (application.LogOn(ticket.RegistrationName, ticket.SessionId))
                {
                    case LogOnStatus.LoggedOn:
                        break;
                    case LogOnStatus.Denied:
                        if (!string.IsNullOrEmpty(ticket.Password))
                        {
                            application.LogOn(
                                ticket.RegistrationName,
                                ticket.UserName,
                                ticket.Password,
                                false,
                                NullIfEmpty(ticket.SiteName));
                        }
                        break;
                    default:
                        application = null;
                        break;
                }
            }

            if (application != null && application.IsLoggedOn)
            {
                if (!ticket.UserName.Equals(application.UserName, StringComparison.OrdinalIgnoreCase))
                {
                    RemoveAuthCookie();
                    throw new SecurityException(UserDoesNotMatchTheOneOfTheTicket);
                }

                UpdateAuthTicketWhenNeeded(ticket);
                return application;
            }

            RemoveAuthCookie();
            FormsAuthentication.SignOut();

            return null;
        }

        public static Application TryLogOnWithCredentials(string registrationName, string siteName, 
            string userName, string password, bool takeOverSession, bool storePassword)
        {
            var application = new Application();
            LogOnStatus status = application.LogOn(registrationName, userName, password, 
                takeOverSession, NullIfEmpty(siteName));

            bool loggedOn = status == LogOnStatus.LoggedOn;

            AuthenticationTicket ticket = new AuthenticationTicket
            {
                RegistrationName = registrationName,
                SiteName = siteName,
                UserName = userName,
                Password = storePassword ? password : null,
                LogonStatus = status,
                SessionId = loggedOn ? application.SessionId : Guid.Empty
            };

            StoreLogonInfo(ticket);

            return loggedOn ? application : null;
        }

        /// <summary>
        /// Gets the current <see cref="AuthenticationTicket" />.
        /// </summary>
        public static AuthenticationTicket CurrentTicket
        {
            get
            {
                AuthenticationTicket ticket = CachedTicket;

                if (ticket != null)
                {
                    return ticket;
                }

                HttpCookie cookie = HttpContext.Current.Request.Cookies[CookieName];

                if (cookie == null)
                {
                    return null;
                }

                ticket = DecryptTicket(cookie.Value);
                CachedTicket = ticket;

                return ticket;
            }
        }
    }
}

This class mostly handles authenticating with. It has two public methods: one to attempt logon using credentials, and one to attempt logon using a ticket from the current HttpContext.

Last but not least, we need a custom Membership implementation to handle authentication of the user. This class has a lot of methods that throw a NotSupportedException or NotImplementedException, since most stuff is out of the scope of this article.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
using System;
using System.Collections.Specialized;
using System.Web.Security;
using Adam.Core;

namespace AdamMVC.Core
{
    public sealed class AdamMembershipProvider : MembershipProvider
    {
        private const bool EnableRememberMe = true;

        #region Methods

        public override bool ValidateUser(string userName, string password)
        {
            return (this.ValidateUser(userName, password, false, EnableRememberMe) == LogOnStatus.LoggedOn);
        }

        public bool ValidateUser(string userName, string password, bool storePassword)
        {
            return (this.ValidateUser(userName, password, storePassword, EnableRememberMe) == LogOnStatus.LoggedOn);
        }

        public LogOnStatus ValidateUser(string userName, string password, bool takeOverSession, bool storePassword)
        {
            Application app = Authentication.TryLogOnWithCredentials(
                Application.DefaultRegistrationName,
                Application.DefaultSiteName,
                userName, password,
                takeOverSession,
                storePassword);

            if (app != null && app.IsLoggedOn)
            {
                return LogOnStatus.LoggedOn;
            }

            AuthenticationTicket ticket = Authentication.CurrentTicket;
            return ticket == null ? LogOnStatus.Denied : ticket.LogonStatus;
        }

        #endregion

        #region Properties

        public override bool EnablePasswordReset
        {
            get { return false; }
        }

        public override bool EnablePasswordRetrieval
        {
            get { return false; }
        }

        #endregion

        #region Not Implemented

        public override string ApplicationName
        {
            get
            {
                throw new NotImplementedException();
            }
            set
            {
                throw new NotImplementedException();
            }
        }

        public override bool ChangePassword(string userName, string oldPassword, string newPassword)
        {
            throw new NotImplementedException();
        }

        public override bool ChangePasswordQuestionAndAnswer(string userName, string password, string newPasswordQuestion, string newPasswordAnswer)
        {
            throw new NotImplementedException();
        }

        public override MembershipUser CreateUser(string userName, string password, string email, string passwordQuestion, string passwordAnswer, bool isApproved, object providerUserKey, out MembershipCreateStatus status)
        {
            throw new NotImplementedException();
        }

        public override bool DeleteUser(string userName, bool deleteAllRelatedData)
        {
            throw new NotImplementedException();
        }

        public override MembershipUserCollection FindUsersByEmail(string emailToMatch, int pageIndex, int pageSize, out int totalRecords)
        {
            throw new NotImplementedException();
        }

        public override MembershipUserCollection FindUsersByName(string userNameToMatch, int pageIndex, int pageSize, out int totalRecords)
        {
            throw new NotImplementedException();
        }

        public override MembershipUserCollection GetAllUsers(int pageIndex, int pageSize, out int totalRecords)
        {
            throw new NotImplementedException();
        }

        public override int GetNumberOfUsersOnline()
        {
            throw new NotImplementedException();
        }

        public override string GetPassword(string userName, string answer)
        {
            throw new NotImplementedException();
        }

        public override MembershipUser GetUser(string userName, bool userIsOnline)
        {
            throw new NotImplementedException();
        }

        public override MembershipUser GetUser(object providerUserKey, bool userIsOnline)
        {
            throw new NotImplementedException();
        }

        public override string GetUserNameByEmail(string email)
        {
            throw new NotImplementedException();
        }

        public override int MaxInvalidPasswordAttempts
        {
            get { throw new NotImplementedException(); }
        }

        public override int MinRequiredNonAlphanumericCharacters
        {
            get { throw new NotImplementedException(); }
        }

        public override int MinRequiredPasswordLength
        {
            get { throw new NotImplementedException(); }
        }

        public override int PasswordAttemptWindow
        {
            get { throw new NotImplementedException(); }
        }

        public override MembershipPasswordFormat PasswordFormat
        {
            get { throw new NotImplementedException(); }
        }

        public override string PasswordStrengthRegularExpression
        {
            get { throw new NotImplementedException(); }
        }

        public override bool RequiresQuestionAndAnswer
        {
            get { throw new NotImplementedException(); }
        }

        public override bool RequiresUniqueEmail
        {
            get { throw new NotImplementedException(); }
        }

        public override string ResetPassword(string userName, string answer)
        {
            throw new NotImplementedException();
        }

        public override bool UnlockUser(string userName)
        {
            throw new NotImplementedException();
        }

        public override void UpdateUser(MembershipUser user)
        {
            throw new NotImplementedException();
        }

        #endregion
    }
}

This class mainly implements ValidateUser(), used by the AccountController to verify the identity of the user that is trying to log in. The AccountController isn’t aware, however, that at the same time we’re starting up an ADAM session.

For this class to work, the Web.config file needs to be adapted. The default AspNetSqlMembershipProvider must be removed, and our new AdamMembershipProvider class must be added. Also, you need to define our new membership provider as the default one.

ASP.NET
1
2
3
4
5
6
7
    <membership defaultProvider="AdamMembershipProvider">
        <providers>
            <clear/>
            <!--<add name="AspNetSqlMembershipProvider" … />-->
            <add name="AdamMembershipProvider" type="AdamMVC.Core.AdamMembershipProvider, AdamMVC" />
        </providers>
    </membership>

We’re also going to use some off-the-shelve configuration sections from ADAM to indicate our default registration & sitename.

ASP.NET
1
2
3
4
5
6
7
8
 <configSections>
        <section name="Adam.Core" type="Adam.Core.ConfigurationHandler, Adam.Core, Version=4.5.0.0, Culture=neutral, PublicKeyToken=63f11f167f68d05b" />
        <section name="Adam.Tools" type="Adam.Tools.ConfigurationHandler, Adam.Tools, Version=4.5.0.0, Culture=neutral, PublicKeyToken=63f11f167f68d05b" />
    </configSections>

    <Adam.Core>
        <defaultRegistration name="<your registration name>" />
    </Adam.Core>

Now you should be able to fire up your app, and log in as well.

In the next article, we’ll get our hands dirty by showing the user data.

Sample Code

The article contains sample code project(s).
You must be logged in to view or download sample code.
Sign in now

Comments

Monday, 07 June 2010Dave Van den Eynde says
Thanks for the nice comment! This code does not in any way try to integrate with the Studio Selector or with Active Directory. My intention was to get someone with an ADAM development environment started up quickly with ASP.NET MVC. Also, I felt that the existing AdamAuthentication in ADAM 4.5 is not a perfect fit as it's too tied into the Web Forms paradigm. The code that I used is actually derived from what's cooking in our labs. We're still undecided if, how and when we'll be supporting ASP.NET MVC out of the box. Ideally we'd provide more infrastructure that can be shared between Web Forms and MVC so that our customers can focus more on the business problem and less on the plumbing. But I'm sure you'll read it here first ;)
Sunday, 06 June 2010Søren Trudsø Mahon says

How does this code deal with:

  • The Studio Selector? IE. register an asp.net mvc with the studio selector and provide single sign on?
  • Providing previews using the existing infrastructure?
  • ActiveDirectory authentication?

Why didn't you use the existing AdamAuthentication class for providing authentication? But anyways i do like you're approach with the membership provider (maybe that should be stock adam functionality ;-) ) and cudos for writing that and handling cookies and all by hand!

What I would rather see is a blog post where you bent stock Adam functionality to support asp.net mvc. As i see it you are building a new infrastructure that you already have in place for webforms, but could be utilized for asp.net mvc.

We use asp.net mvc for some utility functionality. But took a different approach than you. We use a stock signin page, and have a fully functional adam site running. And then in a base controller class we use AdamContext.Current.User.IsLoggedIn and AdamHelper.DefaultApplication for retrieving the Application object, i will gladly share that base controller with you? (we also modified the routing to allow for previews etc.)

But cudos for bringing the new kid on the block into the Adam atmosphere! And you definately showed off some asp.net skills, but I would much rather see you showing off creative ways of using Adam!

An idea could be that you start with an Adam website and then convert that into an asp.net mvc site.

Leave a comment
You must be logged in to post comments.
Sign in now
 
 
Technical
Business
rss feed