How to prevent multiple login in SAAS application?

Viewed 965

What I need to do

I'm developing an application using ASP.NET CORE and I actually encountered a problem using the Identity implementation.

In the official doc infact there is no reference about the multiple session, and this is bad because I developed a SaaS application; in particular a user subscribe a paid plan to access to a specific set of features and him can give his credentials to other users so they can access for free, this is a really bad scenario and I'll lose a lot of money and time.

What I though

After searching a lot on the web I found many solutions for the older version of ASP.NET CORE, so I'm not able to test, but I understood that the usually the solution for this problem is related to store the user time stamp (which is a GUID generated on the login) inside the database, so each time the user access to a restricted page and there are more session (with different user timestamp) the old session will closed.

I don't like this solution because an user can easily copy the cookie of the browser and share it will other users.

I though to store the information of the logged in user session inside the database, but this will require a lot of connection too.. So my inexperience with ASP.NET CORE and the lack of resource on the web have sent me in confusion.

Someone could share a generic idea to implement a secure solution for prevent multiple user login?

4 Answers

I've created a github repo with the changes to the default .net core 2.1 template needed to only allow single sessions. https://github.com/xKloc/IdentityWithSession

Here is the gist.

First, override the default UserClaimsPrincipalFactory<IdentityUser> class with a custom one that will add your session to the user claims. Claims are just a key/value pair that will be stored in the user's cookie and also on the server under the AspNetUserClaims table.

Add this class anywhere in your project.

public class ApplicationClaimsPrincipalFactory : UserClaimsPrincipalFactory<IdentityUser>
{
    private readonly UserManager<IdentityUser> _userManager;

    public ApplicationClaimsPrincipalFactory(UserManager<IdentityUser> userManager, IOptions<IdentityOptions> optionsAccessor) : base(userManager, optionsAccessor)
    {
        _userManager = userManager;
    }

    public async override Task<ClaimsPrincipal> CreateAsync(IdentityUser user)
    {
        // find old sessions and remove
        var claims = await _userManager.GetClaimsAsync(user);

        var session = claims.Where(e => e.Type == "session");

        await _userManager.RemoveClaimsAsync(user, session);

        // add new session claim
        await _userManager.AddClaimAsync(user, new Claim("session", Guid.NewGuid().ToString()));

        // create principal
        var principal = await base.CreateAsync(user);

        return principal;
    }
}

Next we will create an authorization handler that will check that the session is valid on every request.

The handler will match the session claim from the user's cookie to the session claim stored in the database. If they match, the user is authorized to continue. If they don't match, the user will get a Access Denied message.

Add these two classes anywhere in your project.

public class ValidSessionRequirement : IAuthorizationRequirement
{

}

public class ValidSessionHandler : AuthorizationHandler<ValidSessionRequirement>
{
    private readonly UserManager<IdentityUser> _userManager;
    private readonly SignInManager<IdentityUser> _signInManager;

    public ValidSessionHandler(UserManager<IdentityUser> userManager, SignInManager<IdentityUser> signInManager)
    {
        _userManager = userManager ?? throw new ArgumentNullException(nameof(userManager));
        _signInManager = signInManager ?? throw new ArgumentNullException(nameof(signInManager));
    }

    protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, ValidSessionRequirement requirement)
    {
        // if the user isn't authenticated then no need to check session
        if (!context.User.Identity.IsAuthenticated)
            return;

        // get the user and session claim
        var user = await _userManager.GetUserAsync(context.User);

        var claims = await _userManager.GetClaimsAsync(user);

        var serverSession = claims.First(e => e.Type == "session");

        var clientSession = context.User.FindFirst("session");

        // if the client session matches the server session then the user is authorized
        if (serverSession?.Value == clientSession?.Value)
        {
            context.Succeed(requirement);
        }
        return;
    }
}

Finally, just register these new classes in start up so they get called.

Add this code to your Startup class under the ConfigureServices method, right below services.AddDefaultIdentity<IdentityUser>() .AddEntityFrameworkStores<ApplicationDbContext>();

        // build default authorization policy
        var defaultPolicy = new AuthorizationPolicyBuilder()
            .RequireAuthenticatedUser()
            .AddRequirements(new ValidSessionRequirement())
            .Build();

        // add authorization to the pipe
        services.AddAuthorization(options =>
        {
            options.DefaultPolicy = defaultPolicy;
        });

        // register new claims factory
        services.AddScoped<IUserClaimsPrincipalFactory<IdentityUser>, ApplicationClaimsPrincipalFactory>();

        // register valid session handler
        services.AddTransient<IAuthorizationHandler, ValidSessionHandler>();

You can use UpdateSecurityStamp to invalidate any existing authentication cookies. For example:

public async Task<IActionResult> Login(LoginViewModel model)
{
    var user = await _userManager.FindByEmailAsync(model.Email);
    if (user == null)
    {
        ModelState.AddModelError(string.Empty, "Invalid username/password.");
        return View();
    }

    if (await _userManager.ValidatePasswordAsync(user, model.Password))
    {
        await _userManager.UpdateSecurityStampAsync(user);
        var result = await _signInManager.SignInAsync(user, isPersistent: false);
        // handle `SignInResult` cases
    }
}

By updating the security stamp will cause all existing auth cookies to be invalid, basically logging out all other devices where the user is logged in. Then, you sign in the user on this current device.

Best way is to do something similar to what Google, Facebook and others do -- detect if user is logging in from a different device. For your case, I believe you would want to have a slight different behavior -- instead of asking access, you'll probably deny it. It's almost like you're creating a license "per device", or a "single tenant" license.

This Stack Overflow thread talks about this solution.

The most reliable way to detect a device change is to create a fingerprint of the browser/device the browser is running on. This is a complex topic to get 100% right, and there are commercial offerings that are pretty darn good but not flawless.

Note: if you want to start simple, you could start with a Secure cookie, which is less likely to be exposed to cookie theft via eavesdropping. You could store a hashed fingerprint, for instance.

There are some access management solutions (ForgeRock, Oracle Access Management) that implement this Session Quota functionality. ForgeRock has a community version and its source code is available on Github, maybe you can take a look at how it is implemented there. There is also a blog post from them giving a broad view of the functionality (https://blogs.forgerock.org/petermajor/2013/01/session-quota-basics/)

If this is too complex for your use case, what I would do is combine the "shared memory" approach that you described with an identity function, similar to what Fabio pointed out in another answer.

Related