Search code examples
identityserver3two-factor-authentication

Two factor auth with IdentityServer3 - remember browser


I'm implementing 2fa with IdentityServer3 + Asp.Net Identity (2.2.1). I'm stuck on the 2fa implementation. I've looked at the "AspNetIdentity_2fa" sample, which helped a lot.

I have everything wired up, except for the cookie that indicates the browser has been successfully authenticated. I can set the cookie during the code confirmation, but I cannot get to the cookie in the PostAuthenticateLocalAsync() call to see whether or not to take the 2fa path.

        protected override Task<AuthenticateResult> PostAuthenticateLocalAsync(User user, SignInMessage message)
    {
        if (user.TwoFactorEnabled) // && !TwoFactorCookieSet...
        {
            return Task.FromResult(new AuthenticateResult("/auth/sendcode", user.Id, user.DisplayName));
        }

        return base.PostAuthenticateLocalAsync(user, message);
    }

I believe I'm taking the correct approach in using the partial logins, but how would I detect that the current browser has already been approved?

More detail: the /auth/sendcode is the standard Asp.Net Identity pages/flow for 2fa, combined with the partial login logic from the sample.


Solution

  • Okay, I found that OwinEnvironmentService can be injected into IdentityServer services. I can get the cookies via OwinEnvironmentService. I'd be interested to hear any opinions on this solution (this isn't meant to be production-ready, it's just a concept):

    internal class UserService : AspNetIdentityUserService<User, string>
    {
        private readonly OwinEnvironmentService _owinEnvironmentService;
    
        public UserService(UserManager userMgr, OwinEnvironmentService owinEnvironmentService) : base(userMgr)
        {
            _owinEnvironmentService = owinEnvironmentService;
            DisplayNameClaimType = IdentityServer3.Core.Constants.ClaimTypes.Name;
        }
    
        protected override Task<AuthenticateResult> PostAuthenticateLocalAsync(User user, SignInMessage message)
        {
            if (user.TwoFactorEnabled)
            {
                var twoFactorNeeded = false;
                object httpContext;
    
                if (_owinEnvironmentService.Environment.TryGetValue("System.Web.HttpContextBase", out httpContext))
                {
                    var cookies = (httpContext as HttpContext)?.Request.Cookies;
                    if (cookies != null && !cookies.AllKeys.Contains(IdentityConstants.CookieNames.TwoFactorCompleted)) twoFactorNeeded = true;
                }
    
                if (twoFactorNeeded)
                    return Task.FromResult(new AuthenticateResult("/auth/sendcode", user.Id, user.DisplayName));
            }
    
            return base.PostAuthenticateLocalAsync(user, message);
        }
    }
    

    UPDATED

    Based on Brock's comment, I think I have a better solution.

    // custom User Service
    internal class UserService : AspNetIdentityUserService<User, string>
    {
        private readonly OwinEnvironmentService _owinEnvironmentService;
    
        public UserService(UserManager userMgr, OwinEnvironmentService owinEnvironmentService) : base(userMgr)
        {
            _owinEnvironmentService = owinEnvironmentService;
            DisplayNameClaimType = IdentityServer3.Core.Constants.ClaimTypes.Name;
        }
    
        protected override async Task<AuthenticateResult> PostAuthenticateLocalAsync(User user, SignInMessage message)
        {
            if (user.TwoFactorEnabled)
            {
                var owinContext = new OwinContext(_owinEnvironmentService.Environment);
                var result = await owinContext.Authentication.AuthenticateAsync(DefaultAuthenticationTypes.TwoFactorRememberBrowserCookie);
                if(result == null) return new AuthenticateResult("/auth/sendcode", user.Id, user.DisplayName);
            }
    
            return await base.PostAuthenticateLocalAsync(user, message);
        }
    }
    
    // (in MVC controller) generate the 2FA security code and send it
    public async Task<ActionResult> SendCode(SendCodeViewModel model)
    {
        // ...some code removed for brevity...
    
        var token = await UserManager.GenerateTwoFactorTokenAsync(userId, model.SelectedProvider);
        var identityResult = await UserManager.NotifyTwoFactorTokenAsync(userId, model.SelectedProvider, token);
        if (!identityResult.Succeeded) return View("Error");
    
        return RedirectToAction("VerifyCode", new { Provider = model.SelectedProvider, model.ReturnUrl, model.RememberMe });
    }
    
    // (in MVC controller) verify the code and sign in with 2FA
    public async Task<ActionResult> VerifyCode(VerifyCodeViewModel model)
    {
        // ...some code removed for brevity...
    
        var signInManager = new SignInManager<User, string>(UserManager, Request.GetOwinContext().Authentication);
        if (await UserManager.VerifyTwoFactorTokenAsync(user.Id, model.Provider, model.Code))
        {
            await UserManager.ResetAccessFailedCountAsync(user.Id);
            await signInManager.SignInAsync(user, model.RememberMe, model.RememberBrowser);
            var resumeUrl = await env.GetPartialLoginResumeUrlAsync();
            return Redirect(resumeUrl);
        }
        else
        {
            await UserManager.AccessFailedAsync(user.Id);
            ModelState.AddModelError("", "Invalid code.");
            return View(model);
        }
    }