Is it possible to use ASP.NET membership with tables created in ASP.NET Identity 2.0 for authentication?

Viewed 653

I have an MVC4 based web portal that using the membership to authenticate users ONLY(no creating new users, no validating passwords).

Here is a screenshot of tables that used to authenticate users:

enter image description here

Here is the action method that triggered when a user is trying to authenticate:

    [HttpPost]
    public ActionResult Login(string username, string password, string[] rememberMe, string returnUrl)
    {
        if(Membership.ValidateUser(username, password))
        {
            if (rememberMe != null)
            {
                FormsAuthentication.SetAuthCookie(username, true);
            }
            Response.Redirect(returnUrl);
        }
        ViewBag.ErrorMessage = "wrong credentials";
        return View();
    }
    

Here is web config:

<membership defaultProvider="AspNetSqlMembershipProvider" userIsOnlineTimeWindow="15">
  <providers>
    <clear />
    <add name="AspNetSqlMembershipProvider" 
         connectionStringName="SiteSqlServer" 
         enablePasswordRetrieval="false" 
         enablePasswordReset="true" 
         requiresQuestionAndAnswer="false"
         applicationName="/" 
         requiresUniqueEmail="true" 
         passwordFormat="Hashed" 
         maxInvalidPasswordAttempts="8" 
         minRequiredPasswordLength="4" 
         minRequiredNonalphanumericCharacters="0" 
         passwordAttemptWindow="10" 
         passwordStrengthRegularExpression="" 
         type="System.Web.Security.SqlMembershipProvider, System.Web, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d43e4e" />
  </providers>
</membership>

I also have a new database that is created by asp.net identity 2.0:

enter image description here

My task is to make user authentication against new tables (i.e I need the membership to use for authentication, tables that created by asp.net identity 2.0).

So, for this purpose, I think I need to customize the membership to read the new tables.

My question is it possible to make changes on the membership that it will read asp.net identity 2.0 for authentication?

1 Answers

Yes it is possible, but it's one of those issues where the solution will depend on your current specific implementation. There are three solutions that immediately come to mind:

  1. Replace all Authentication logic with calls to the new system
    Long term this is more manageable because you only need to maintain the auth logic in one place.

    • This is easiest solution if you have a deployed API that can can be accessed via HTTP, but it can work by including your dlls, as long as they are compiled against a compatible .Net version.
    • This works especially well if you have other apps (Desktop/Web/Mobile) that also use the new API, this old site becomes a client to the new one.

    This is the lowest code solution, and the only one where we do not have to be concerned with correctly hashing passwords for comparison.

    If the other system is an Online API, then you can use HttpClient to simply call out to the other site to authenticate, how you do this will depend on what type of authentication protocols are supported by the API

    using System.Web.Security;
    
    [HttpPost]
    public async Task<ActionResult> Login(string username, string password, string[] rememberMe, string returnUrl)
    {
        if(await ValidateUser(username, password))
        {
            if (rememberMe != null)
            {
                FormsAuthentication.SetAuthCookie(username, true);
            }
            Response.Redirect(returnUrl);
        }
        ViewBag.ErrorMessage = "wrong credentials";
        return View();
    }
    
    public async Task<bool> Login(string username, string password)
    {
        var client = new System.Net.Http.HttpClient();
        client.DefaultRequestHeaders.Clear();
        client.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
        client.DefaultRequestHeaders.TryAddWithoutValidation("Content-Type", "application/x-www-form-urlencoded");
        Dictionary<string, string> data = new Dictionary<string, string>();
        data.Add("grant_type", "password");
        data.Add("username", username);
        data.Add("password", password);
    
        // Example, replace this with your own.
        string apiAuthUrl = "http://localhost:3000/token";
    
        var response = await client.PostAsync(apiAuthUrl, new System.Net.Http.FormUrlEncodedContent(data));
        return response.IsSuccessStatusCode;
    }
    

    If the other system is not published as an API, so a desktop style app, then you can do something similar by referencing and calling the Auth methods in the dlls directly.

  2. Create a custom Membership Provider
    This is a code based solution that is similar to how it works in 2.0, but a little bit more involved.

    • Scott Gu Published to source code which used to be a helpful reference for these types of activities: Source Code for the Built-in ASP.NET 2.0 Providers Now Available for Download

    • But the links seem to be broken!

    • MS Docs still has some reading, but no source: Implementing a Membership Provider

    • The following article is from a first principals approach, your tables have already been created so just focus on accessing the data, not creating new tables

      (MVC) Custom Membership Providers

      It's not neccessary to implement all aspects of the providers, only those methods that your site code will call.

      This solution is good because you don't have to change any existing code, just new code and a change to the web.config

      Points of Interest

      Change the web.config reference to use your custom provider, replace MyNamespace.Providers with your actual namespace!:

      <membership defaultProvider="CustomMembershipProvider" userIsOnlineTimeWindow="15">
        <providers>
          <clear/>
          <add name="CustomMembershipProvider" 
              type="MyNamespace.Providers.CustomMembershipProvider"
              connectionStringName="SiteSqlServer"
              enablePasswordRetrieval="false"
              enablePasswordReset="true"
              requiresQuestionAndAnswer="false"
              requiresUniqueEmail="true"
              maxInvalidPasswordAttempts="8"
              minRequiredPasswordLength="4"
              minRequiredNonalphanumericCharacters="0"
              passwordAttemptWindow="10"
              applicationName="DotNetNuke" />
        </providers>
      </membership>
      

      Then Implement the membership provider class:

      using System.Web.Security;
      
      public class CustomMembershipProvider : MembershipProvider
      {
          public override MembershipUser CreateUser(string username, string password, 
                 string email, string passwordQuestion, string passwordAnswer, 
                 bool isApproved, object providerUserKey, out MembershipCreateStatus status)
          {
              /// we're not creating new users in this site!
              throw new NotImplementedException();             
          }
      
          public override MembershipUser GetUser(string username, bool userIsOnline)
          {
              var user = /* Your User Repo Lookup */;
      
              if (user != null)
              {
                  MembershipUser memUser = new MembershipUser("CustomMembershipProvider", 
                                                 username, user.UserID, user.UserEmailAddress,
                                                 string.Empty, string.Empty,
                                                 true, false, DateTime.MinValue,
                                                 DateTime.MinValue,
                                                 DateTime.MinValue,
                                                 DateTime.Now, DateTime.Now);
                  return memUser;
              }
              return null;
          }
      
          public override bool ValidateUser(string username, string password)
          {
              // This is highly dependent on how passwords are encrypted in the new system
              // Assuming it is a simple MD5 Hash, we just need to create the same Hash              
              string sha1Pswd = GetMD5Hash(password);
      
              // TODO: Now use this hash to verify that the username and password match a user in your repo
              var user = /*Lookup a user that matches the username and password.*/;
              if (user != null)
                  return true;
              return false;
          }
      
          public override int MinRequiredPasswordLength
          {
              get { return 4; }
          }
      
          /// <summary> Simple MD5 Hash algorithm, you will need to match this against the process used in the other system </summary>
          public static string GetMD5Hash(string value)
          {
              MD5 md5Hasher = MD5.Create();
              byte[] data = md5Hasher.ComputeHash(Encoding.Default.GetBytes(value));
              StringBuilder sBuilder = new StringBuilder();
              for (int i = 0; i < data.Length; i++)
              {
                  sBuilder.Append(data[i].ToString("x2"));
              }
      
              return sBuilder.ToString();
          }
      }
      
      
      
  3. Customise the Stored Procedures used by the AspNetSqlMembershipProvider
    Depends on your strengths, this might be simpler and is an option because the MVC site is already configured with this provider, all we need to do is modify the SQL Stored Procedures that the provider uses to perform authentication requests.

    • The following is a good reading on how to implement the provider

      Using AspNetSqlMembershipProvider in MVC5
      Configuring web application to utilise ASP.NET Application Services database

      • As highlighted in the previous option, this is only going to work if the new site uses the same encryption method, it probably will also require use of the same machine key is set in the web.config.

      You can setup a blank database and provision it with the aspnet_regsql to view the stored procedures (they wont be in your new database!).

      • This will become a bit or trial and error to identify the procedures that will be needed, I suspect you'll only need GetUserByName, the following screenshot shows some of the other stored procedures, the naming convention makes it pretty easy to match up to the Membership API methods:

        ASPNet Membership Stored Procs

        /****** Object:  StoredProcedure [dbo].[aspnet_Membership_GetUserByName]    Script Date: 25/06/2020 11:19:04 AM ******/
        SET ANSI_NULLS ON
        GO
        SET QUOTED_IDENTIFIER OFF
        GO
        CREATE OR ALTER PROCEDURE [dbo].[aspnet_Membership_GetUserByName]
            @ApplicationName      nvarchar(256),
            @UserName             nvarchar(256),
            @CurrentTimeUtc       datetime,
            @UpdateLastActivity   bit = 0
        AS
        BEGIN
            DECLARE @UserId uniqueidentifier
      
            IF (@UpdateLastActivity = 1)
            BEGIN
                -- select user ID from AspnetUsers table
                -- Ignore ApplicationIDs here, assume this is a single Application schema
                SELECT TOP 1 @UserId = u.Id
                FROM    dbo.AspNetUsers u
                WHERE   LOWER(@UserName) = LOWER(u.UserName)
      
                IF (@@ROWCOUNT = 0) -- Username not found
                    RETURN -1
      
                -- We don't have 'Activity' per-se, instead we reset the AccessFailedCount to zero
                -- Your implementation might be different, so think it through :)
                UPDATE   dbo.AspNetUsers
                SET      AccessFailedCount = 0
                WHERE    Id = @UserId
      
                -- Your Schema might be different, here we just map back to the old Schema in the projected response
                -- Make sure the data types match in your response, here we are injected GetDate() for most dates by default
                -- NOTE: LockOutEnabled DOES NOT mean the user is locked out, only that lockout logic should be evaluated
                SELECT Email, '' as PasswordQuestion, '' as Comment, Cast(1 as BIT) as IsApproved,
                        CAST(null as datetime) as CreateDate, GetDate() as LastLoginDate, GetDate() as LastActivityDate, GetDate() as LastPasswordChangedDate,
                        Id as UserId, CASE WHEN LockoutEnabled = 1 AND LockoutEndDateUtc IS NOT NULL THEN 1 ELSE 0 END as IsLockedOut, LockoutEndDateUtc as LastLockoutDate
                FROM    dbo.AspNetUsers
                WHERE  Id = @UserId
            END
            ELSE
            BEGIN
                -- Your Schema might be different, here we just map back to the old Schema in the projected response
                -- Make sure the data types match in your response, here we are injected GetDate() for most dates by default
                -- NOTE: LockOutEnabled DOES NOT mean the user is locked out, only that lockout logic should be evaluated
                SELECT TOP 1 Email, '' as PasswordQuestion, '' as Comment, Cast(1 as BIT) as IsApproved,
                        CAST(null as datetime) as CreateDate, GetDate() as LastLoginDate, GetDate() as LastActivityDate, GetDate() as LastPasswordChangedDate,
                        Id as UserId, CASE WHEN LockoutEnabled = 1 AND LockoutEndDateUtc IS NOT NULL THEN 1 ELSE 0 END as IsLockedOut, LockoutEndDateUtc as LastLockoutDate
                FROM    dbo.AspNetUsers
                WHERE   LOWER(@UserName) = LOWER(UserName)
      
                IF (@@ROWCOUNT = 0) -- Username not found
                    RETURN -1
            END
      
            RETURN 0
        END
      

      You may also need to implement some or all of the views, but again it will largely depend on the methods in the Membership API that your MVC site actually uses.

Related