Peter Kerr
Peter Kerr

Reputation: 1749

Asp.Net Identity with 2FA - remember browser cookie not retained after session

I'm using the latest sample code for MVC5.2 with Asp.Identity and Two Factor authentication.

With 2FA enabled, when a user logins, the get prompted for a code (sent by phone or email) and they have the option to "Remember Browser" - so that they don't get ask for codes again on that browser.

This is handled in the VerifyCode action

var result = await SignInManager.TwoFactorSignInAsync(model.Provider, model.Code, isPersistent:  model.RememberMe, rememberBrowser: model.RememberBrowser);

Note that model.RememberMe is not used in the default templates so it is false.

I find when I do this the .AspNet.TwoFactorRememberBrowser that gets set, expires on session end (so it does not remember the browser)

Now if I set isPersistent = true, .AspNet.TwoFactorRememberBrowser gets an expiration of 30 days which is great, but the .AspNet.ApplicationCookie also gets a 30 day expiration - which means that when I close the browser and re-open, I am automatically logged in.

I want it so that it doesn't persist my login, but that it will persist my choice of remembering the 2FA code. Ie the user should always have to login, but they should not be asked for a 2fa code if they have already save it.

Has anybody else seen this, or am I missing something?

Upvotes: 18

Views: 10031

Answers (4)

DennisVM-D2i
DennisVM-D2i

Reputation: 488

To supplement the wealth of information here; we are using v2.2.1 (of the .Core, .EntityFramework & .OWIN packages), and we needed to customise the 'ExpireTimeSpan' for the '(Two Factor) Remember Browser' cookie, so I added this:

public static class MyAppBuilderExtensions { public static void UseTwoFactorRememberBrowserCookie( this IAppBuilder app, string authenticationType, TimeSpan expireTimeSpan, bool? slidingExpiration = null) { if (app == null) { throw new ArgumentNullException(nameof(app)); }
        var cookieAuthOpts =
            new CookieAuthenticationOptions
            {
                // E.g.  'DefaultAuthenticationTypes.TwoFactorRememberBrowserCookie'
                AuthenticationType = authenticationType,
                AuthenticationMode = AuthenticationMode.Passive,
                CookieName = CookiePrefixConst + authenticationType,
                ExpireTimeSpan = expireTimeSpan
            };

        if (slidingExpiration.HasValue)
        {
            // Appears to (currently) be 'True' by default
            cookieAuthOpts.SlidingExpiration = slidingExpiration.Value;
        }

        app.UseCookieAuthentication(cookieAuthOpts);
    }

    private const string CookiePrefixConst = ".AspNet.";
}

E.g. I invoked/called it like so (- replacing the call to the Microsoft one):

// Original/Microsoft's:
//app.UseTwoFactorRememberBrowserCookie(
//    DefaultAuthenticationTypes.TwoFactorRememberBrowserCookie);

// Mine:
app.UseTwoFactorRememberBrowserCookie(
        DefaultAuthenticationTypes.TwoFactorRememberBrowserCookie,
        TimeSpan.FromMinutes(2));

But I was not seeing the additional identity present for the 'TwoFactorRememberBrowser' (/'DefaultAuthenticationTypes.TwoFactorRememberBrowserCookie') cookie; i.e. I tried this (in our MVC controller), and inspected the test/'ts' variables - only the 'ApplicationCookie' one had a value:

// We can see from the 'authTypes' variable value that the App-Builder 'Use...()' method has 'hook'ed/plugged-in the 'TwoFactorRememberBrowser' middleware (to the authentication chain) var authTypes = SignInManager.AuthenticationManager.GetAuthenticationTypes();
// 'TwoFactorRememberBrowser'
var ts1 =
    await SignInManager.AuthenticationManager.AuthenticateAsync(
            DefaultAuthenticationTypes.TwoFactorRememberBrowserCookie);

// 'TwoFactorCookie'
var ts2 =
    await SignInManager.AuthenticationManager.AuthenticateAsync(
            DefaultAuthenticationTypes.TwoFactorCookie);

// 'ApplicationCookie'
var ts3 =
    await SignInManager.AuthenticationManager.AuthenticateAsync(
            DefaultAuthenticationTypes.ApplicationCookie);

// Bearer
var ts4 =
    await SignInManager.AuthenticationManager.AuthenticateAsync(
            @"Bearer");

var tfaBrowserRemembered =
    SignInManager.AuthenticationManager.TwoFactorBrowserRemembered(
        User.Identity.GetUserId());

Debug.WriteLine(
    $"DBG:  2FA Br R = '{tfaBrowserRemembered}'");

Even looking at the identities contained by the 'Principal' within the Sign-in Manager's Authentication Manager's 'AuthenticationResponseGrant' (- 'SignInManager.AuthenticationManager.AuthenticationResponseGrant.Principal'), there was only one identity - only for the 'ApplicationCookie'.

But in my case I found that someone had messed-up in the 'VerifyCode' view (cshtml), so the 'RememberBrowser' value was not being properly maintained (/captured), i.e. the call to the 'TwoFactorSignInAsync()' method was been given a (default/uninitialised) value of 'False'.

Once I fixed the issue, therefore ensuring that a 'True' value for the 'RememberBrowser' was carried through/been passed to the 'TwoFactorSignInAsync()' method call, all seems to work as expected.

So it's probably worth checking for this (more simple) issue first/also.

Upvotes: 0

mcscxv
mcscxv

Reputation: 134

What you can do is assign your own CookieManager class that modifies the expiration time of the TwoFactorRememberBrowserCookie. This seems better than modifing the cookie in Application_PostAuthenticateRequest.

This works around the problem that you can either persist all or none of the authentication cookies.

Put this in your ConfigureAuth, the last line sets your custom cookie manager.

public void ConfigureAuth(IAppBuilder app)
{  
    // left out all but the modified initialization of the TwoFactorRememberBrowserCookie

    var CookiePrefix = ".AspNet.";
    app.UseCookieAuthentication(new CookieAuthenticationOptions {
        AuthenticationType = DefaultAuthenticationTypes.TwoFactorRememberBrowserCookie,
        AuthenticationMode = Microsoft.Owin.Security.AuthenticationMode.Passive,
        CookieName = CookiePrefix + DefaultAuthenticationTypes.TwoFactorRememberBrowserCookie,
        ExpireTimeSpan = TimeSpan.FromDays(14),
        CookieManager = new TwoFactorRememberBrowserCookieManager()
    });
}

Use this CookieManager class only for the TwoFactorRememberBrowserCookie. When you do not persist cookies in TwoFactorSignInAsync, unfortunately the ExpirationTimeout is ignored.

So just set it again in the CookieManager (This is a modified version of the cookie manager coming from Microsoft.Owin.Infrastructure.CookieManager):

public class TwoFactorRememberBrowserCookieManager : Microsoft.Owin.Infrastructure.ICookieManager
{
    string CookiePrefix = ".AspNet.";
    Microsoft.Owin.Infrastructure.ICookieManager cm = new Microsoft.Owin.Infrastructure.ChunkingCookieManager();
    public void AppendResponseCookie(IOwinContext context, string key, string value, CookieOptions options)
    {
        if (key == CookiePrefix + DefaultAuthenticationTypes.TwoFactorRememberBrowserCookie) {
            options.Expires = DateTime.UtcNow.AddDays(14);
        }
        cm.AppendResponseCookie(context, key, value, options);
    }
    public void DeleteCookie(IOwinContext context, string key, CookieOptions options)
    {
        cm.DeleteCookie(context, key, options);
    }
    public string GetRequestCookie(IOwinContext context, string key)
    {
        return cm.GetRequestCookie(context, key);
    }
}

This is what you will get:

enter image description here

Works for me that way.

Upvotes: 2

Barry Hagan
Barry Hagan

Reputation: 281

It doesn't seem like this code was designed to set more than one identity cookie in the same request/response because the OWIN cookie handlers end up sharing the same AuthenticationProperties. This is because the AuthenticationResponseGrant has a single principal, but the principal can have multiple identities.

You can workaround this bug by altering and then restoring the AuthenticationProperties in the ResponseSignIn and ResponseSignedIn events specific to the 2FA cookie provider:

        //Don't use this.
        //app.UseTwoFactorRememberBrowserCookie(DefaultAuthenticationTypes.TwoFactorRememberBrowserCookie);

        //Set the 2FA cookie expiration and persistence directly
        //ExpireTimeSpan and SlidingExpiration should match the Asp.Net Identity cookie setting
        app.UseCookieAuthentication(new CookieAuthenticationOptions()
        {
            AuthenticationType = DefaultAuthenticationTypes.TwoFactorRememberBrowserCookie,
            AuthenticationMode = AuthenticationMode.Passive,
            CookieName = DefaultAuthenticationTypes.TwoFactorRememberBrowserCookie,
            ExpireTimeSpan = TimeSpan.FromHours(2),
            SlidingExpiration = true,
            Provider = new CookieAuthenticationProvider
            {
                OnResponseSignIn = ctx =>
                {
                    ctx.OwinContext.Set("auth-prop-expires", ctx.Properties.ExpiresUtc);
                    ctx.OwinContext.Set("auth-prop-persist", ctx.Properties.IsPersistent);
                    var issued = ctx.Properties.IssuedUtc ?? DateTimeOffset.UtcNow;
                    ctx.Properties.ExpiresUtc = issued.AddDays(14);
                    ctx.Properties.IsPersistent = true;
                },
                OnResponseSignedIn = ctx =>
                {
                    ctx.Properties.ExpiresUtc = ctx.OwinContext.Get<DateTimeOffset?>("auth-prop-expires");
                    ctx.Properties.IsPersistent = ctx.OwinContext.Get<bool>("auth-prop-persist");
                }
            }
        });

Make sure to set the same ExpireTimeSpan and SldingExpiration as your main Asp.Net Identity cookie to preserve those settings (since they get merged in the AuthenticationResponseGrant).

Upvotes: 16

Peter Kerr
Peter Kerr

Reputation: 1749

This still appears to be an issue in Identity 2.2.1 (It may be fixed in Asp.Net Identity 3.0 - but that is currently pre-released and requires a later version of .Net framework that 4.5)

The following work around seems ok for now: The cookie is getting set on the SignInManager.TwoFactorSignInAsync with the wrong values, so on Success of the VerifyCode action, I reset the cookie to be persistent and give it the expiry date that I wish (in this case I set it to a year)

  public async Task<ActionResult> VerifyCode(VerifyCodeViewModel model)
  {
        if (!ModelState.IsValid)
        {
            return View(model);
        }            var result = await SignInManager.TwoFactorSignInAsync(model.Provider, model.Code, isPersistent:  model.RememberMe, rememberBrowser: model.RememberBrowser);
        switch (result)
        {
            case SignInStatus.Success:
                // if we remember the browser, we need to adjsut the expiry date as persisted above
                // Also set the expiry date for the .AspNet.ApplicationCookie 
                if (model.RememberBrowser)
                {
                    var user = await UserManager.FindByIdAsync(await SignInManager.GetVerifiedUserIdAsync());
                    var rememberBrowserIdentity = AuthenticationManager.CreateTwoFactorRememberBrowserIdentity(user.Id);
                    AuthenticationManager.SignIn(new AuthenticationProperties { IsPersistent = true, ExpiresUtc = DateTime.UtcNow.AddDays(365) }, rememberBrowserIdentity);
                }

                return RedirectToLocal(model.ReturnUrl);

Upvotes: 3

Related Questions