28

I am trying to block multiple logins with the same user in my application.
My idea is to update the security stamp when user signin and add that as a Claim, then in every single request comparing the stamp from the cookie with the one in the database. This is how I've implemented that:

        public virtual async Task<ActionResult> Login([Bind(Include = "Email,Password,RememberMe")] LoginViewModel model, string returnUrl)
    {
        if (!ModelState.IsValid)
        {
            return View(model);
        }

        SignInStatus result =
            await SignInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, false);
        switch (result)
        {
            case SignInStatus.Success:
                var user = UserManager.FindByEmail(model.Email);
                var id = user.Id;
                UserManager.UpdateSecurityStamp(user.Id);
                var securityStamp = UserManager.FindByEmail(model.Email).SecurityStamp;
                UserManager.AddClaim(id, new Claim("SecurityStamp", securityStamp));

Then in authentication configuration I've added

        app.UseCookieAuthentication(new CookieAuthenticationOptions
        {
            AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
            LoginPath = new PathString("/Account/Login"),
            Provider = new CookieAuthenticationProvider
            {
                OnValidateIdentity = ctx =>
                {
                    var ret = Task.Run(() =>
                    {
                        Claim claim = ctx.Identity.FindFirst("SecurityStamp");
                        if (claim != null)
                        {
                            var userManager = new UserManager<ApplicationUser>(new UserStore<ApplicationUser>(new ApplicationDbContext()));
                            var user = userManager.FindById(ctx.Identity.GetUserId());

                            // invalidate session, if SecurityStamp has changed
                            if (user != null && user.SecurityStamp != null && user.SecurityStamp != claim.Value)
                            {
                                ctx.RejectIdentity();
                            }
                        }
                    });
                    return ret;
                }
            }

        });

As it shows I have tried to compare the claim from the cookie with the one in the database and reject the identity if they are not the same.
Now, each time the user signs in the security stamp gets updated but the value is different in user's cookie which I can't find out why? I am suspicious maybe it the new updated security stamp doesn't get stored in user's cookie?

Shahin
  • 12,543
  • 39
  • 127
  • 205
  • 1
    I think your approach is too complex. I have done similar. Create a static class with a static list `CurrentUsers`. When a user logs in, check against that list. Reject if current. The complicated decision is what event means removing users for `CurrentUsers`. Logout would clearly be a reason. But do you want to allow a user to retain a cookie but remove them as current when session runs is recycled (browser connection terminates)? In that case, in the `Session_OnEnd()` method you would remove the user from `CurrentUsers`. – Dave Alperovich Aug 27 '15 at 15:41

2 Answers2

33

The solution is somewhat more simple than you have started implementing. But the idea is the same: every time user logs in, change their security stamp. And this will invalidate all other login sessions. Thus will teach users not to share their password.

I have just created a new MVC5 application from standard VS2013 template and successfully managed to implement what you want to do.

Login method. You need to change the security stamp BEFORE you create auth cookie, as after the cookie is set, you can't easily update the values:

[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Login(LoginViewModel model, string returnUrl)
{
    if (!ModelState.IsValid)
    {
        return View(model);
    }


    // check if username/password pair match.
    var loggedinUser = await UserManager.FindAsync(model.Email, model.Password);
    if (loggedinUser != null)
    {
        // change the security stamp only on correct username/password
        await UserManager.UpdateSecurityStampAsync(loggedinUser.Id);
    }

     // do sign-in
    var result = await SignInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, shouldLockout: false);
    switch (result)
    {
        case SignInStatus.Success:
            return RedirectToLocal(returnUrl);
        case SignInStatus.LockedOut:
            return View("Lockout");
        case SignInStatus.RequiresVerification:
            return RedirectToAction("SendCode", new { ReturnUrl = returnUrl, RememberMe = model.RememberMe });
        case SignInStatus.Failure:
        default:
            ModelState.AddModelError("", "Invalid login attempt.");
            return View(model);
    }
}

This way every login will do an update on the user record with the new security stamp. Updating security stamp is only a matter of await UserManager.UpdateSecurityStampAsync(user.Id); - much simplier than you imagined.

Next step is to check for security stamp on every request. You already found the best hook-in point in Startup.Auth.cs but you again overcomplicated. The framework already does what you need to do, you need to tweak it slightly:

app.UseCookieAuthentication(new CookieAuthenticationOptions
{
    // other stuff
    AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
    LoginPath = new PathString("/Account/Login"),
    Provider = new CookieAuthenticationProvider
    {
        OnValidateIdentity = SecurityStampValidator.OnValidateIdentity<ApplicationUserManager, ApplicationUser>(
            validateInterval: TimeSpan.FromMinutes(0), // <-- Note the timer is set for zero
            regenerateIdentity: (manager, user) => user.GenerateUserIdentityAsync(manager))
    }
});            

The time interval is set for zero - means the framework on every request will compare user's security stamp with the database. If stamp in the cookie does not match the stamp in the database, user's auth-cookie is thrown out, asking them to logout.

However, note that this will bear an extra request to your database on every HTTP request from a user. On a large user-base this can be expensive and you can somewhat increase the checking interval to a couple minutes - will give you less requests to your DB, but still will carry your message about not sharing the login details.


Full source in github


More information in a blog-post

trailmax
  • 34,305
  • 22
  • 140
  • 234
  • I try to replicate your solution in new visual studio project but I can't. – Shahin Sep 02 '15 at 10:59
  • Well, It logs me in when I enter my credentials but the website which is a default template of the visual studio doesn't show that the user is authenticated. It supposes to show the user's Username, but it still shows login and register links. – Shahin Sep 02 '15 at 11:02
  • Is there any way to share the project you have created? – Shahin Sep 02 '15 at 11:02
  • Technically although the user is authenticated but Request.IsAuthenticated always returns false in _LoginPartial.cshtml – Shahin Sep 02 '15 at 11:04
  • I've exactly replaced the login code with yours from your answer. – Shahin Sep 02 '15 at 11:10
  • 1
    OK, give me a minute, I'll check it out. – trailmax Sep 02 '15 at 11:13
  • Thanks for sharing your code, I have the same problem with your code as well. Just see when you log in, it shows the username on the top right or not? – Shahin Sep 02 '15 at 11:13
  • Right. I've found the problem. See the `CookieAuthenticationOptions` where you define period of expiry. I had comment saying `// other stuff` - that other stuff was defining the authentication options - should have not been removed from your code, but I have removed it for brevity. I've added the required stuff back into the solution and it works just fine now. – trailmax Sep 02 '15 at 13:00
  • Let us [continue this discussion in chat](http://chat.stackoverflow.com/rooms/88591/discussion-between-shaahin-and-trailmax). – Shahin Sep 02 '15 at 14:45
  • @trailmax Based on the security stamp, the user is redirected to the login page. However this doesn't clear the authentication cookie. So when a different user logs in, the user gets the previous user session and corresponding privileges. Is there a simple way to clean the entire user session information (similar to what is done when SignOff happens) – Sharath Chandra Dec 22 '15 at 09:46
  • @SharathChandra I've never seen this happening. Do you have a working sample of this? – trailmax Dec 22 '15 at 09:47
  • @trailmax I don't have a sample app, let me create a quick sample application and post it. – Sharath Chandra Dec 22 '15 at 09:49
  • @SharathChandra Nope. I've just tried this scenario - logged in as admin, changed security stamp, refreshed the page after 10 minutes. Then logged in as user with no privileges and did not get admin rights. – trailmax Dec 22 '15 at 10:05
  • @trailmax Below are my exact repro steps --- 1) Login to app on IE using admin credentials 2) Login to app on firefox using same admin credentials 3) Go back to IE and click on any link. User is redirected back to login page 4) Don't close the browser, instead login using a normal user credentials 5) The normal user is able to view things which require admin credentials – Sharath Chandra Dec 22 '15 at 10:14
  • @trailmax If i close the browser session once the user is redirected back to login page and then re-login again, things work as expected – Sharath Chandra Dec 22 '15 at 10:15
  • @SharathChandra Nope, still can't reproduce your scenario. Tried with IE and chrome, full admin vs. no-access account. Changed the security stamp on the user just before sign-in and it killed the auth cookie – trailmax Dec 22 '15 at 10:25
  • @trailmax thanks, let me check if I can reproduce the same in a smaller application. On side note, if I need the similar thing for windows authenticated site, any suggestions if we can use similar thing and what part of pipeline I can use to creating/updating security stamp – Sharath Chandra Dec 22 '15 at 10:32
  • 1
    @SharathChandra I've done WinAuth combined with OWIN authentication (without Identity), but this is out of scope of this question. I have not implemented preventing multiple logins as there are legitimate reasons for the same user to login in multiple places. But I'm sure this can be done. – trailmax Dec 22 '15 at 10:37
  • 4
    @trailmax, hi the issue in my application was session was not abandoned and only auth cookie was expired(which is correct). For now, I added a session clean up code on Login action before creating new session for the user. – Sharath Chandra Dec 22 '15 at 11:48
  • @SharathChandra ah, session. Another reason I prefer to avoid it. – trailmax Dec 22 '15 at 12:05
  • 1
    @trailmax Hi, I'm interested to implement this in `ASP.NET Core v.2` could you please update your answer for the new version too? Because the `cookie` code doesn't work in the last release, thanks in advance. – Charanoglu Sep 04 '18 at 08:40
  • @trailmax, you mention "You need to change the security stamp BEFORE you create auth cookie, as after the cookie is set, you can't easily update the values". Care to elaborate on how such a scenario could be implemented if also using 2FA? See also this question: https://stackoverflow.com/q/69358139/1543677 – pkExec Sep 29 '21 at 09:46
1

In the past I've used IAuthorizationFilter and static logged-in user collection to achieve this:

public static class WebAppData
{
     public static ConcurrentDictionary<string, AppUser> Users = new ConcurrentDictionary<string, AppUser>();
}

public class AuthorisationAttribute : FilterAttribute, IAuthorizationFilter {

    public void OnAuthorization(AuthorizationContext filterContext){

            ...
            Handle claims authentication
            ...

            AppUser id = WebAppData.Users.Where(u=>u.Key ==userName).Select(u=>u.Value).FirstOrDefault();
            if (id == null){
                id = new AppUser {...} ;
                id.SessionId = filterContext.HttpContext.Session.SessionID;
                WebAppData.Users.TryAdd(userName, id);
            }
            else
            {
                if (id.SessionId != filterContext.HttpContext.Session.SessionID)
                {
                        FormsAuthentication.SignOut();
                        ...
                        return appropriate error response depending is it ajax request or not
                        ...


                }
            } 
     }
}

On logout:

WebAppData.Users.TryRemove(userName, out user)
Algis
  • 612
  • 1
  • 8
  • 23