4

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?

Michael
  • 13,950
  • 57
  • 145
  • 288
  • What have you tried? Usually we just write a wrapper to replace the membership provider, not a big deal, but have you tried this? – Chris Schaller Jun 24 '20 at 06:35
  • Do you use profile or roles in the MVC side? Short-circuits things if you do not – Chris Schaller Jun 24 '20 at 07:38
  • @ChrisSchaller, I don't' use roles, can you please clarify what is a profile? – Michael Jun 24 '20 at 15:47
  • Thats a good response, it means you're not using it :) Profile was cool, don't get me wrong, I loved the concept but only ever used it in two apps over the years. – Chris Schaller Jun 24 '20 at 23:26
  • Is the other system that uses the new schema an online API? can you call out to that via http, simply replacing `Membership.ValidateUser()`? That would be the shortest path if you aren't using any other calls to the membership API – Chris Schaller Jun 24 '20 at 23:29

1 Answers1

4

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.

Chris Schaller
  • 13,704
  • 3
  • 43
  • 81
  • Chris, thanks for the post. I am trying to use 2 solution. But, in Login finction I get error on this row Membership.ValidateUser(username, password) and this FormsAuthentication.SetAuthCookie the error text is: The name 'FormsAuthentication' does not exist in the current context and The name 'Membership' does not exist in the current context. Any idea why i get the error? – Michael Jun 29 '20 at 04:20
  • You need to include references and a using statement for the standard `System.Web.Security` namespace. I omitted this because you said that you're extending a system that already had the authentication, I'll update the post. – Chris Schaller Jun 29 '20 at 04:50
  • Yes, system has authentication, I created a new project added all needed references and made all overloads according to the article in codeproject, made changes in web.config file and created app.config file in new project.The reference to the new project added to the main project. When I navigate on this row: Membership.ValidUser and click defenition, I expect that I would be navigated to the overloaded class, but it takes me to the original dll's. So I commented the using system.web.security. – Michael Jun 29 '20 at 05:14
  • It doesn't quite work that way, if the membership provider is registered correctly, then at runtime a call to `Membership.ValidateUser()` will internally call your `CustomMembershipProvider.ValidateUser()`. We call this a _Provider Pattern_ and sometimes a _Plugin Pattern_. It is correct that it calls the `System.Web.Security` namespace, the point of this is to make a simple change in web.config and no code changes and _voila_ your site is using a different provider/architexture for security. – Chris Schaller Jun 29 '20 at 05:57
  • So it was not necessary to start a new project, the point of this solution is that it is non-destructive and easy to implement in an existing project. Only the change in `web.config` triggers the auth calls to use the `CustomMembershipProvider`. – Chris Schaller Jun 29 '20 at 05:59
  • Hello Chris, I try to make work the project, but I get this error when I try to run it: Parser Error Message: This method cannot be called during the application's pre-start initialization phase. I get the error on this row in membership definition in web.config: type="namespace.CustomMembershipProvider" Any idea what cause it? – Michael Jun 30 '20 at 00:53
  • Have you had a look into this post on SO: https://stackoverflow.com/q/4626647/1690217 it won't match your issue exactly but may help. Check that your Membership provider doesn't try to aggressively load data or even the data context in the constructor. You haven't listed the code for your data repo, so can only speculate, but that is a common issue if you try to maintain a single connection for the lifetime of the class, instead load your data repo on demand. – Chris Schaller Jun 30 '20 at 01:25