原文:Forms Authentication in ASP.NET MVC 4
Contents:
- Introduction
- Implement a custom membership provider
- Implement a custom role provider
- Implement a custom user principal and identity
- Implement a custom authorization filter
- Summary
1. Introduction
For adding authorization and authentication features to an ASP.NET MVC site we will be using the same approach as for a classic Web Forms project. The classes that stay at the base of the ASP.NET security model can be used in both MVC and Web Forms projects. The authentication happens like in this image:
- The login page collects the user credentials and then calls the Membership class in order to validate them.
- The Membership class uses the web.config to determine what MembershipProvider to use.
- In the end the Membership class calls the ValiadateUser method of the membership provider that was determined in step 2. The ValidateUser method verifies if the specified username and password exist and are valid.
The MembershipProvider acts like a mediator between the ASP.NET authentication system and the collection of users. It defines methods for validating the user credentials, for creating new users, modifying the user password and a lot of other user account related operations. For more information here is an introduction to membership in MSDN. So, it provides a way to decouple the data source where the user information is stored (e.g. DB, active directory) from the authentication system so that the authentication will work in the same way no matter where the user information is stored.
Microsoft provides out of the box implementations for ActiveDirectoryMembershipProvider and SqlMembershipProvider but we can also create our own custom implementation by inheriting from the MembershipProvider class and implementing the methods that we need.
Note: if there is a method that you don’t need just leave an empty implementation because it will not be invoked except if you’ll call it explicitly from the code.
In a similar way works also the authorization:
- We use the AuthorizeAttribute inside the controller classes to mark the action methods that can be invoked only if the user is authenticated and/or has a given role. Then the AuthorizeAttribute uses the Roles class to check if the currently logged user has the required role.
- The Roles class uses the web.config to understand what RoleProvider to use.
- The RoleProvider is an abstract class that defines the basic methods that all role providers will have. We can use the supplied role providers (e.g. SqlRoleProvider) that are included with the .NET Framework, or we can implement our own custom provider.
2. Implement a custom membership provider
We will continue the demo application we used throughout the previous ASP.NET MVC 4 tutorials and we will add a login page that uses a custom membership provider to authenticate the users. You can download the source code for the demo application (that we are using here as a starting point) from CodePlex.
First we will create a custom membership provider that inherits from the MembershipProvider class.
using System; using System.Linq; using System.Web.Security; using HelloWorld.Code.DataAccess; namespace HelloWorld.Code.Security { public class CustomMembershipProvider : MembershipProvider { #region Overrides of MembershipProvider /// <summary> /// Verifies that the specified user name and password exist in the data source. /// </summary> /// <returns> /// true if the specified username and password are valid; otherwise, false. /// </returns> /// <param name="username">The name of the user to validate. </param><param name="password">The password for the specified user. </param> public override bool ValidateUser(string username, string password) { if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password)) return false; using (var context = new MvcDemoEntities()) { var user = (from u in context.Users where String.Compare(u.Username, username, StringComparison.OrdinalIgnoreCase) == 0 && String.Compare(u.Password, password, StringComparison.OrdinalIgnoreCase) == 0 && !u.Deleted select u).FirstOrDefault(); return user != null; } } #endregion #region Overrides of MembershipProvider that throw 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 ChangePasswordQuestionAndAnswer(string username, string password, string newPasswordQuestion, string newPasswordAnswer) { throw new NotImplementedException(); } public override string GetPassword(string username, string answer) { throw new NotImplementedException(); } public override bool ChangePassword(string username, string oldPassword, string newPassword) { throw new NotImplementedException(); } public override string ResetPassword(string username, string answer) { throw new NotImplementedException(); } public override void UpdateUser(MembershipUser user) { throw new NotImplementedException(); } public override bool UnlockUser(string userName) { throw new NotImplementedException(); } public override string GetUserNameByEmail(string email) { throw new NotImplementedException(); } public override bool DeleteUser(string username, bool deleteAllRelatedData) { 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 MembershipUserCollection FindUsersByName(string usernameToMatch, int pageIndex, int pageSize, out int totalRecords) { throw new NotImplementedException(); } public override MembershipUserCollection FindUsersByEmail(string emailToMatch, int pageIndex, int pageSize, out int totalRecords) { throw new NotImplementedException(); } public override bool EnablePasswordRetrieval { get { throw new NotImplementedException(); } } public override bool EnablePasswordReset { get { throw new NotImplementedException(); } } public override bool RequiresQuestionAndAnswer { get { throw new NotImplementedException(); } } public override string ApplicationName { get { throw new NotImplementedException(); } set { throw new NotImplementedException(); } } public override int MaxInvalidPasswordAttempts { get { throw new NotImplementedException(); } } public override int PasswordAttemptWindow { get { throw new NotImplementedException(); } } public override bool RequiresUniqueEmail { get { throw new NotImplementedException(); } } public override MembershipPasswordFormat PasswordFormat { get { throw new NotImplementedException(); } } public override int MinRequiredPasswordLength { get { throw new NotImplementedException(); } } public override int MinRequiredNonAlphanumericCharacters { get { throw new NotImplementedException(); } } public override string PasswordStrengthRegularExpression { get { throw new NotImplementedException(); } } public override MembershipUser GetUser(object providerUserKey, bool userIsOnline) { throw new NotImplementedException(); } public override MembershipUser GetUser(string username, bool userIsOnline) { throw new NotImplementedException(); } #endregion } }
Note that we provided an implementation only for the ValidateUser method because this is the only method needed to validate the user credentials and, for the moment, we don’t need the other features of the membership provider, like, for example, the ResetPassword method.
Next we will edit the web.config file and enable forms authentication. We will also specify that we will use the CustomMembershipProvider that we just created:
<authentication mode="Forms"> <forms loginUrl="~/Account/Login" defaultUrl="~/" timeout="20" slidingExpiration="true" /> </authentication> <membership defaultProvider="CustomMembershipProvider"> <providers> <clear /> <add name="CustomMembershipProvider" type="HelloWorld.Code.Security.CustomMembershipProvider" /> </providers> </membership>
Now we will create a simple log in page that will use the Membership class to check if the user credentials are valid and the FormsAuthentication class to manage the forms authentication inside our site. The implementation is straight forward and I think the code speaks for itself so I’ll copy-paste here the code with minimal annotations.
For displaying the log in page data I created a simple view model that helps with the required data validation:
using System.ComponentModel.DataAnnotations; namespace HelloWorld.Models { public class Login { [Required] [Display(Name = "User name")] public string UserName { get; set; } [Required] [DataType(DataType.Password)] [Display(Name = "Password")] public string Password { get; set; } [Display(Name = "Remember me?")] public bool RememberMe { get; set; } } }
This view model is used inside the view:
@model HelloWorld.Models.Login @{ ViewBag.Title = "Log in"; } <h2>@ViewBag.Title</h2> <hr/> @Html.ValidationSummary(true) <br/> @using (Html.BeginForm(null, null, new { ReturnUrl = ViewBag.ReturnUrl }, FormMethod.Post)) { @Html.LabelFor(m => m.UserName) @Html.TextBoxFor(m => m.UserName) @Html.ValidationMessageFor(m => m.UserName) <br/> @Html.LabelFor(m => m.Password) @Html.PasswordFor(m => m.Password) @Html.ValidationMessageFor(m => m.Password) <br/> <label> @Html.CheckBoxFor(m => m.RememberMe) @Html.LabelFor(m => m.RememberMe) </label> <br/> <input type="submit" value="Log in" /> }
The controller class that works with this view and authenticates the users is:
using System.Web.Mvc; using System.Web.Security; using HelloWorld.Models; namespace HelloWorld.Controllers { public class AccountController : Controller { [HttpGet] [AllowAnonymous] public ActionResult Login(string returnUrl = "") { if (User.Identity.IsAuthenticated) { return LogOut(); } ViewBag.ReturnUrl = returnUrl; return View(); } [HttpPost] [AllowAnonymous] public ActionResult Login(Login model, string returnUrl = "") { if(ModelState.IsValid) { if (Membership.ValidateUser(model.UserName, model.Password)) { FormsAuthentication.RedirectFromLoginPage(model.UserName, model.RememberMe); } ModelState.AddModelError("", "Incorrect username and/or password"); } return View(model); } [HttpPost] [AllowAnonymous] public ActionResult LogOut() { FormsAuthentication.SignOut(); return RedirectToAction("Login", "Account", null); } } }
Notice here the use of the AllowAnonymousAttribute. This attribute can be used to indicate that an action method inside a controller or a whole controller doesn’t require user authorization, hence can be accessed by anonymous users. This attribute is used in conjunction with the AuthorizeAttribute that provides a way to restrict the access. Both of this attributes can be set at action method level or at controller level, if we want the same attribute to be applied to all the action methods inside a controller.
Next, we will apply the authorize attribute to our UsersController and HomeController like this:
[Authorize] public class HomeController : Controller { //code omitted for brevity }
If we run now the application we will not be able to see the home page until we log in.
3. Implement a custom role provider
Now we would like to allow the access to the users controller only for administrators. For doing this we will use the same AuthorizeAttribute that we used before but, this time, we will provide also the name of the user role that is needed to invoke this controller:
[Authorize(Roles = "Administrator")] public class UsersController : Controller { //code omitted for brevity }
For this to work we need to add a custom role provider that will be used to return the roles that a user has.
using System; using System.Collections.Specialized; using System.Linq; using System.Data.Entity; using System.Web; using System.Web.Caching; using System.Web.Security; using HelloWorld.Code.DataAccess; namespace HelloWorld.Code.Security { public class CustomRoleProvider : RoleProvider { #region Properties private int _cacheTimeoutInMinutes = 30; #endregion #region Overrides of RoleProvider /// <summary> /// Initialize values from web.config. /// </summary> /// <param name="name">The friendly name of the provider.</param> /// <param name="config">A collection of the name/value pairs representing the provider-specific attributes specified in the configuration for this provider.</param> public override void Initialize(string name, NameValueCollection config) { // Set Properties int val; if (!string.IsNullOrEmpty(config["cacheTimeoutInMinutes"]) && Int32.TryParse(config["cacheTimeoutInMinutes"], out val)) _cacheTimeoutInMinutes = val; // Call base method base.Initialize(name, config); } /// <summary> /// Gets a value indicating whether the specified user is in the specified role for the configured applicationName. /// </summary> /// <returns> /// true if the specified user is in the specified role for the configured applicationName; otherwise, false. /// </returns> /// <param name="username">The user name to search for.</param><param name="roleName">The role to search in.</param> public override bool IsUserInRole(string username, string roleName) { var userRoles = GetRolesForUser(username); return userRoles.Contains(roleName); } /// <summary> /// Gets a list of the roles that a specified user is in for the configured applicationName. /// </summary> /// <returns> /// A string array containing the names of all the roles that the specified user is in for the configured applicationName. /// </returns> /// <param name="username">The user to return a list of roles for.</param> public override string[] GetRolesForUser(string username) { //Return if the user is not authenticated if (!HttpContext.Current.User.Identity.IsAuthenticated) return null; //Return if present in Cache var cacheKey = string.Format("UserRoles_{0}", username); if (HttpRuntime.Cache[cacheKey] != null) return (string[])HttpRuntime.Cache[cacheKey]; //Get the roles from DB var userRoles = new string[] { }; using (var context = new MvcDemoEntities()) { var user = (from u in context.Users.Include(usr => usr.UserRole) where String.Compare(u.Username, username, StringComparison.OrdinalIgnoreCase) == 0 select u).FirstOrDefault(); if (user != null) userRoles = new[]{user.UserRole.UserRoleName}; } //Store in cache HttpRuntime.Cache.Insert(cacheKey, userRoles, null, DateTime.Now.AddMinutes(_cacheTimeoutInMinutes), Cache.NoSlidingExpiration); // Return return userRoles.ToArray(); } #endregion #region Overrides of RoleProvider that throw NotImplementedException public override void CreateRole(string roleName) { throw new NotImplementedException(); } public override bool DeleteRole(string roleName, bool throwOnPopulatedRole) { throw new NotImplementedException(); } public override bool RoleExists(string roleName) { throw new NotImplementedException(); } public override void AddUsersToRoles(string[] usernames, string[] roleNames) { throw new NotImplementedException(); } public override void RemoveUsersFromRoles(string[] usernames, string[] roleNames) { throw new NotImplementedException(); } public override string[] GetUsersInRole(string roleName) { throw new NotImplementedException(); } public override string[] GetAllRoles() { throw new NotImplementedException(); } public override string[] FindUsersInRole(string roleName, string usernameToMatch) { throw new NotImplementedException(); } public override string ApplicationName { get { throw new NotImplementedException(); } set { throw new NotImplementedException(); } } #endregion } }
And, of course, we need to specify that we are using this role provider inside the web.config class:
<roleManager defaultProvider="CustomRoleProvider" enabled="true"> <providers> <clear /> <add name="CustomRoleProvider" type="HelloWorld.Code.Security.CustomRoleProvider" cacheTimeoutInMinutes="30" /> </providers> </roleManager>
Now, if we try to access the user list page while being authenticated as users we will be automatically redirected to the log in page.
Next we will add a log out link in the _Layout.chtml file, inside the header div, that will be visible only if the current user is logged in:
<span >@if (HttpContext.Current.User.Identity.IsAuthenticated) { @Html.ActionLink("Log out", "Logout", "Account"); }</span>
We are using HttpContext.Current to get all the HTTP-specific information for the current HTTP request. One of these properties is the User property that encapsulates the security information for the current request. The security information related to the current user is accessed through the IPrincipal interface. This interface can be used to check if the current user belong to a given role and gives access to the user’s identity through the IIdentity interface. The IIdentity encapsulates the user data and can be seen as the interface that defines who the user is while the IPrincipal object defines who the current user is and what he is allowed to do.
Sometimes it is useful to extend the default identity and principal objects to include additional user information. This is what we will do in the next section.
4. Implement a custom user principal and identity
First we will define a custom identity class that inherits from IIdentity and receives as an input parameter the default identity object that is created by the forms authentication.
using System; using System.Security.Principal; using System.Web.Security; namespace HelloWorld.Code.Security { /// <summary> /// An identity object represents the user on whose behalf the code is running. /// </summary> [Serializable] public class CustomIdentity : IIdentity { #region Properties public IIdentity Identity { get; set; } public string FirstName { get; set; } public string LastName { get; set; } public string Email { get; set; } public int UserRoleId { get; set; } public string UserRoleName { get; set; } #endregion #region Implementation of IIdentity /// <summary> /// Gets the name of the current user. /// </summary> /// <returns> /// The name of the user on whose behalf the code is running. /// </returns> public string Name { get { return Identity.Name; } } /// <summary> /// Gets the type of authentication used. /// </summary> /// <returns> /// The type of authentication used to identify the user. /// </returns> public string AuthenticationType { get { return Identity.AuthenticationType; } } /// <summary> /// Gets a value that indicates whether the user has been authenticated. /// </summary> /// <returns> /// true if the user was authenticated; otherwise, false. /// </returns> public bool IsAuthenticated { get { return Identity.IsAuthenticated; } } #endregion #region Constructor public CustomIdentity(IIdentity identity) { Identity = identity; var customMembershipUser = (CustomMembershipUser) Membership.GetUser(identity.Name); if(customMembershipUser != null) { FirstName = customMembershipUser.FirstName; LastName = customMembershipUser.LastName; Email = customMembershipUser.Email; UserRoleId = customMembershipUser.UserRoleId; UserRoleName = customMembershipUser.UserRoleName; } } #endregion } }
The constructor uses the name of the default identity and the current membership provider to get the user data. We will modify our custom membership provider by implementing the methods GetUser and Initialize. We need to implement the Initialize method because we will use caching to remember the user information between post backs and we want to be able to set the caching time in the web.config like this:
<membership defaultProvider="CustomMembershipProvider"> <providers> <clear /> <add name="CustomMembershipProvider" type="HelloWorld.Code.Security.CustomMembershipProvider" cacheTimeoutInMinutes="30" /> </providers> </membership>
In the initialize method we will read the cacheTimeoutInMinutes value as it is defined in the web.config.
private int _cacheTimeoutInMinutes = 30; /// <summary> /// Initialize values from web.config. /// </summary> /// <param name="name">The friendly name of the provider.</param> /// <param name="config">A collection of the name/value pairs representing the provider-specific attributes specified in the configuration for this provider.</param> public override void Initialize(string name, NameValueCollection config) { // Set Properties int val; if (!string.IsNullOrEmpty(config["cacheTimeoutInMinutes"]) && Int32.TryParse(config["cacheTimeoutInMinutes"], out val)) _cacheTimeoutInMinutes = val; // Call base method base.Initialize(name, config); }
Than we implement the method for retrieving the user data. Because we want to return more user information than we can store in the default MembershipUser, that is the default return type of the GetUser method, we will first create a custom implementation for the MembershipUser.
using System; using System.Web.Security; using HelloWorld.Code.DataAccess; namespace HelloWorld.Code.Security { public class CustomMembershipUser : MembershipUser { #region Properties public string FirstName { get; set; } public string LastName { get; set; } public int UserRoleId { get; set; } public string UserRoleName { get; set; } #endregion public CustomMembershipUser(User user) : base("CustomMembershipProvider", user.Username, user.UserId, user.Email, string.Empty, string.Empty, true, false, DateTime.Now, DateTime.Now, DateTime.Now, DateTime.Now, DateTime.Now) { FirstName = user.FirstName; LastName = user.LastName; UserRoleId = user.UserRoleId; UserRoleName = user.UserRole.UserRoleName; } } }
Now we can implement the method for retrieving the user data inside our custom membership provider:
/// <summary> /// Gets information from the data source for a user. Provides an option to update the last-activity date/time stamp for the user. /// </summary> /// <returns> /// A <see cref="T:System.Web.Security.MembershipUser"/> object populated with the specified user's information from the data source. /// </returns> /// <param name="username">The name of the user to get information for. </param><param name="userIsOnline">true to update the last-activity date/time stamp for the user; false to return user information without updating the last-activity date/time stamp for the user. </param> public override MembershipUser GetUser(string username, bool userIsOnline) { var cacheKey = string.Format("UserData_{0}", username); if (HttpRuntime.Cache[cacheKey] != null) return (CustomMembershipUser)HttpRuntime.Cache[cacheKey]; using (var context = new MvcDemoEntities()) { var user = (from u in context.Users.Include(usr => usr.UserRole) where String.Compare(u.Username, username, StringComparison.OrdinalIgnoreCase) == 0 && !u.Deleted select u).FirstOrDefault(); if (user == null) return null; var membershipUser = new CustomMembershipUser(user); //Store in cache HttpRuntime.Cache.Insert(cacheKey, membershipUser, null, DateTime.Now.AddMinutes(_cacheTimeoutInMinutes), Cache.NoSlidingExpiration); return membershipUser; } }
We will also create a custom principal that works with our new CustomIdentity class.
using System; using System.Security.Principal; namespace HelloWorld.Code.Security { [Serializable] public class CustomPrincipal : IPrincipal { #region Implementation of IPrincipal /// <summary> /// Determines whether the current principal belongs to the specified role. /// </summary> /// <returns> /// true if the current principal is a member of the specified role; otherwise, false. /// </returns> /// <param name="role">The name of the role for which to check membership. </param> public bool IsInRole(string role) { return Identity is CustomIdentity && string.Compare(role, ((CustomIdentity) Identity).UserRoleName, StringComparison.CurrentCultureIgnoreCase) == 0; } /// <summary> /// Gets the identity of the current principal. /// </summary> /// <returns> /// The <see cref="T:System.Security.Principal.IIdentity"/> object associated with the current principal. /// </returns> public IIdentity Identity { get; private set; } #endregion public CustomIdentity CustomIdentity { get { return (CustomIdentity)Identity; } set { Identity = value; } } public CustomPrincipal(CustomIdentity identity) { Identity = identity; } } }
The only thing left is to replace the default HttpContext.Current.User with our new CustomProvider. We will do this by adding the methodApplication_PostAuthenticateRequest inside Global.asax:
protected void Application_PostAuthenticateRequest(object sender, EventArgs e) { if (Request.IsAuthenticated) { var identity = new CustomIdentity(HttpContext.Current.User.Identity); var principal = new CustomPrincipal(identity); HttpContext.Current.User = principal; } }
Now we are able to use our new custom identity and principal. For example we will modify the _Layout.chtml file to display more information about the currently logged user in the header.
<div id="header"> <i>=(^.^)= @ViewBag.Title</i> <span >@if (HttpContext.Current.User.Identity.IsAuthenticated) { var identity = ((CustomPrincipal)HttpContext.Current.User).CustomIdentity; @Html.Label(string.Format("Welcome {0} {1}, you are logged as {2}", identity.FirstName, identity.LastName, identity.UserRoleName)) @Html.ActionLink("Log out", "Logout", "Account") }</span> </div>
And, the final touch, we create an extension method that converts an IPrincipal object to a CustomIdentity object. We do this just because the code will be more elegant:
using System.Security.Principal; namespace HelloWorld.Code.Security { public static class SecurityExtentions { public static CustomPrincipal ToCustomPrincipal(this IPrincipal principal) { return (CustomPrincipal) principal; } } }
Now we can get the custom identity like this:
var identity = HttpContext.Current.User.ToCustomPrincipal().CustomIdentity;
The final result will be like this:
5. Implement a custom authorization filter
It is possible to create custom attributes that, when applied to a controller class and/or a controller action method will perform an additional logic before or after the action method is executed. These attributes must inherit from the FilterAttribute class and must implement at least one of the following interfaces:
If an action method has more than one filter then they will be executed in the same order as in the list above, starting with the authorization filters and continuing with the action, result and, in the end, exception filters. You can find more information about action filtering and about the order in which the action filters are executed on MSDN.
As a first example, we will create a custom attribute that inherits from the System.Web.Mvc.AuthorizeAttribute and will allow us to pass the allowed user roles as a list of UserRole enumerations instead of passing a string containing the comma separated list of allowed user roles.
using System.Linq; namespace HelloWorld.Code.Security { public class UserRoleAuthorizeAttribute : System.Web.Mvc.AuthorizeAttribute { public UserRoleAuthorizeAttribute(){} public UserRoleAuthorizeAttribute(params UserRole[] roles) { Roles = string.Join(",", roles.Select(r => r.ToString())); } } public enum UserRole { Administrator = 1, User = 2 } }
Usage example:
[UserRoleAuthorize(UserRole.Administrator, UserRole.User)]
As a second example we will create a new custom filter that will allow the execution of an action method only between two given hours. For example, we want to allow the user add, edit and delete functionality only between 9 in the morning and 19 in the evening. If we are outside this time frame then we will redirect the user to the home page. Again we will be inheriting the System.Web.Mvc.AuthorizeAttribute because it is easier to start from it than from the basic FilterAttributeclass.
using System; using System.Web; using System.Web.Mvc; using System.Web.Routing; namespace HelloWorld.Code.Security { public class TimeAuthorizeAttribute : AuthorizeAttribute { public int StartTime { get; set; } public int EndTime { get; set; } protected override bool AuthorizeCore(HttpContextBase httpContext) { if (DateTime.Now.Hour < StartTime) return false; if (EndTime <= DateTime.Now.Hour) return false; return true; } protected override void HandleUnauthorizedRequest(AuthorizationContext filterContext) { filterContext.Result = new RedirectToRouteResult(new RouteValueDictionary { { "controller", "Home" }, { "action", "Index" } }); } } }
All we did was to override the method that decides if the current user is authorized AuthorizeCore. In case the user is not authorized we will redirect him to the home page. We accomplish this by overriding the method HandleUnauthorizedRequest.
6. Summary
In this post we had a look on how to implement custom authentication in an ASP.NET MVC 4 application. You can download the source code that contains all the points discussed here from Codeplex.