Search code examples
asp.net-mvcentity-frameworkasp.net-identitymulti-tenantmulti-database

Dynamic database connection using Asp.Net identity


I am working on a multi-tenant application that uses multiple databases. There is one master database that contains user information and then each tenant database also has their own users for that tenant (which are a subset of the users in the master database).

The user will log in which will check the master database, then based on their details (i.e. which tenant they belong to) it will log them into the application using the user details on their tenant database.

I am using the method described in this thread (Dynamic database connection using Asp.net MVC and Identity2) to set the database for UserManager each time because at the point that the application starts it will not know what database to use therefore the following code in "Startup.Auth" would be setting the incorrect database:

app.CreatePerOwinContext<ApplicationUserManager>(ApplicationUserManager.Create);

This seems to be working well for most things but one problem I have is with the user getting logged out after the time set in "validateInterval" shown in the code below (this has been set to 20 seconds for testing):

 app.UseCookieAuthentication(new CookieAuthenticationOptions
        {
            AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
            LoginPath = new PathString("/Account/Login"),
            Provider = new CookieAuthenticationProvider
            {
                // Enables the application to validate the security stamp when the user logs in.
                // This is a security feature which is used when you change a password or add an external login to your account.  
                OnValidateIdentity = SecurityStampValidator.OnValidateIdentity<ApplicationUserManager, ApplicationUser>(
                    validateInterval: TimeSpan.FromSeconds(20),
                    regenerateIdentity: (manager, user) => user.GenerateUserIdentityAsync(manager)),                        
                OnApplyRedirect = ctx =>
                {
                    if (!IsAjaxRequest(ctx.Request))
                    {
                        ctx.Response.Redirect(ctx.RedirectUri);
                    }
                }
            }
        });

I think the problem might be because when the code above is called in the "Startup.Auth" file it does not know what database to use however I have not confirmed this.

If I debug the "GenerateUserIdentityAsync" code I can see that it is getting the correct "securityStamp" for the user from the client database which makes me think it is finding the correct database but I cannot work out why it is still logging out the user after the time set for "validateInterval".

Can anyone offer any advice on how this can be resolved or at least possible ways to try and debug what the problem might be?


Solution

  • Okay this is the full solution I have come up with which partly uses what @jacktric suggested but also allows for validating the security stamp if a users password has been changed elsewhere. Please let me know if anyone can recommend any improvements or see any downfalls in my solution.

    I have removed the OnValidateIdentity section from the UseCookieAuthentication section as follows:

    app.UseCookieAuthentication(new CookieAuthenticationOptions
    {
        AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
        LoginPath = new PathString("/Account/Login"),
        Provider = new CookieAuthenticationProvider
        {
            OnApplyRedirect = ctx =>
            {
                if (!IsAjaxRequest(ctx.Request))
                {
                    ctx.Response.Redirect(ctx.RedirectUri);
                }
            }
        }
    });
    

    I then have the following IActionFilter that is registered in the FilterConfig.cs which checks if the user is logged in (I have parts of the system that can be accessed by anonymous users) and whether the current security stamp matches the one from the database. This check is made every 30 minutes using sessions to find out when the last check was.

    public class CheckAuthenticationFilter : IActionFilter
    {
        public void OnActionExecuting(ActionExecutingContext filterContext)
        {
    
        }
    
        public void OnActionExecuted(ActionExecutedContext filterContext)
        {
            try
            {
                // If not a child action, not an ajax request, not a RedirectResult and not a PartialViewResult
                if (!filterContext.IsChildAction
                    && !filterContext.HttpContext.Request.IsAjaxRequest()
                    && !(filterContext.Result is RedirectResult)
                    && !(filterContext.Result is PartialViewResult))
                {
                    // Get current ID
                    string currentUserId = filterContext.HttpContext.User.Identity.GetUserId();
    
                    // If current user ID exists (i.e. it is not an anonymous function)
                    if (!String.IsNullOrEmpty(currentUserId))
                    {
                        // Variables
                        var lastValidateIdentityCheck = DateTime.MinValue;
                        var validateInterval = TimeSpan.FromMinutes(30);
                        var securityStampValid = true;
    
                        // Get instance of userManager   
                        filterContext.HttpContext.GetOwinContext().Get<DbContext>().Database.Connection.ConnectionString = DbContext.GetConnectionString();
                        var userManager = filterContext.HttpContext.GetOwinContext().GetUserManager<ApplicationUserManager>();
    
                        // Find current user by ID
                        var currentUser = userManager.FindById(currentUserId);
    
                        // If "LastValidateIdentityCheck" session exists
                        if (HttpContext.Current.Session["LastValidateIdentityCheck"] != null)
                            DateTime.TryParse(HttpContext.Current.Session["LastValidateIdentityCheck"].ToString(), out lastValidateIdentityCheck);
    
                        // If first validation or validateInterval has passed
                        if (lastValidateIdentityCheck == DateTime.MinValue || DateTime.Now > lastValidateIdentityCheck.Add(validateInterval))
                        {
                            // Get current security stamp from logged in user
                            var currentSecurityStamp = filterContext.HttpContext.User.GetClaimValue("AspNet.Identity.SecurityStamp");
    
                            // Set whether security stamp valid
                            securityStampValid = currentUser != null && currentUser.SecurityStamp == currentSecurityStamp;
    
                            // Set LastValidateIdentityCheck session variable
                            HttpContext.Current.Session["LastValidateIdentityCheck"] = DateTime.Now;
                        }
    
                        // If current user doesn't exist or security stamp invalid then log them off 
                        if (currentUser == null || !securityStampValid)
                        {
                            filterContext.Result = new RedirectToRouteResult(
                                    new RouteValueDictionary { { "Controller", "Account" }, { "Action", "LogOff" }, { "Area", "" } });
                        }
                    }
                }
            }
            catch (Exception ex)
            {
                // Log error                
            }
        }
    }
    

    I have the following extension methods for getting and updating claims for the logged in user (taken from this post https://stackoverflow.com/a/32112002/1806809):

    public static void AddUpdateClaim(this IPrincipal currentPrincipal, string key, string value)
    {
        var identity = currentPrincipal.Identity as ClaimsIdentity;
        if (identity == null)
            return;
    
        // Check for existing claim and remove it
        var existingClaim = identity.FindFirst(key);
        if (existingClaim != null)
            identity.RemoveClaim(existingClaim);
    
        // Add new claim
        identity.AddClaim(new Claim(key, value));
    
        // Set connection string - this overrides the default connection string set 
        // on "app.CreatePerOwinContext(DbContext.Create)" in "Startup.Auth.cs"
        HttpContext.Current.GetOwinContext().Get<DbContext>().Database.Connection.ConnectionString = DbContext.GetConnectionString();
        var authenticationManager = HttpContext.Current.GetOwinContext().Authentication;
        authenticationManager.AuthenticationResponseGrant = new AuthenticationResponseGrant(new ClaimsPrincipal(identity), new AuthenticationProperties() { IsPersistent = true });
    }
    
    public static string GetClaimValue(this IPrincipal currentPrincipal, string key)
    {
        var identity = currentPrincipal.Identity as ClaimsIdentity;
        if (identity == null)
            return null;
    
        var claim = identity.Claims.FirstOrDefault(c => c.Type == key);
        return claim.Value;
    }
    

    And finally anywhere that the users password is updated I call the following, this updates the security stamp for the user whose password is being edited and if it is the current logged in users password that is being edited then it updates the securityStamp claim for the current user so that they will not get logged out of their current session the next time the validity check is made:

    // Update security stamp
    UserManager.UpdateSecurityStamp(user.Id);
    
    // If updating own password
    if (GetCurrentUserId() == user.Id)
    {
        // Find current user by ID
        var currentUser = UserManager.FindById(user.Id);
    
        // Update logged in user security stamp (this is so their security stamp matches and they are not signed out the next time validity check is made in CheckAuthenticationFilter.cs)
        User.AddUpdateClaim("AspNet.Identity.SecurityStamp", currentUser.SecurityStamp);
    }