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:
- Create a new ASP.NET MVC 2 project.
- Add references to the ADAM assemblies Adam.Core and Adam.Tools.
- Create the required classes to handle authentication.
- 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.