Quantcast
Channel: chsakell's Blog
Viewing all 42 articles
Browse latest View live

ASP.NET Core Identity Series – External provider authentication & registration strategy

$
0
0

There is no doubt that external provider authentication is a must have feature in new modern applications and makes sense because users are able to easily register new accounts and also login using their social account credentials. The entire process is based on OAuth 2.0 flows which were presented in detail in the OAuth 2.0, OpenID Connect & IdentityServer blog post of the ASP.NET Core Identity Series. In case you haven’t read it, I totally recommend you to do so. Web applications redirect users to sign in to the selected external provider which in turn redirects back to a callback url providing basic profile information such as email address, full name and username. This info is used to register a new account or sign in the user if already registered. If I were to tell what is the biggest advantage or gain when using social login I would say one world, TRUST. A trust that is built on the very well formed OAuth 2.0 protocol and the fact that user accounts are already confirmed in the external providers.

Love all, trust a few. – William Shakespeare

This post is a continuation of the ASP.NET Core Identity Series:

The post is divided in the following sections:

In the first section we are going to configure authentication with Google, Facebook, Twitter, Microsoft, GitHub, LinkedIn and DropBox. We could add more but as you will see later, the process is always the same with a few minor changes for some providers (e.g. Twitter). For each provider there will be a step by step guide with screenshots along with the required commands and code to setup in our web application.

The second section gets even more interesting. The first part of enabling social login is to configure the external providers in the application. The second part is how you actually use them. Let’s consider the scenario where you have created a user account using the normal registration process, meaning you provided your email address, a username and a password. At some point the web site allows you to sign in using your Facebook account but unfortunately your facebook account uses a different email address. Does this mean that signing in with Facebook should create a new account? Or should you have the option to associate your Facebook account with your already confirmed one? The second one sounds a lot better because you will continue to use the same account containing the same data as before (e.g. orders, emails, chat messages, etc..). In order to support this kind of functionality you need to create rules and flows and implementing these two is what the registration strategy is all about. And of course ASP.NET Core Identity supports by default user account association with external login providers.

Configuring external providers authentication in ASP.NET Core

The steps to enable authentication for an external provider are always the same:

  • Step 1: Create an application to the external provider
  • Step 2: Configure the callback url properly
  • Step 3: Register the authentication handler for the external provider in Startup/ConfigureServices

All external providers (Facebook, Google, GitHub, etc..) provide a developer(s) website that you can use it and leverage their APIs and services. Here are some developer websites.

First you navigate to the developer app URL and create a new application for the website you want to add external provider authentication. The required details are usually the name and the URL of your website. To enable authentication in your app you have to set the callback URL which points to a route in your website. This is not an actual route you have created in your web app but a route that the registered authentication handler listens to and handles the external authentication result properly. By default the callback URL an authentication handler listens to is usually signin-<provider-name>, for example the google authentication handler listens to signin-google. This means that if your website’s URL is https://mywebsite.com then the callback URL at Google would be https://mywebsite.com/signin-google. Of course callback URLs are totally configurable, you can add more than one or change them at any time you want. Enough with the theory, let’s see in action how to configure external providers authentication.

The source code for the series is available here. Each part has a related branch on the repository. To follow along with this part clone the repository and checkout the external-authentication branch as follow:

git clone https://github.com/chsakell/aspnet-core-identity.git
cd .\aspnet-core-identity
git checkout external-authentication

In this post we will be working with th AspNetCoreIdentity project in the solution and we will add external providers authentication for multiple providers.

Let me remind you that the app already implements many ASP.NET Core Identity related features and can be run with or without an SQL Server. You can find instructions to setup the project on the README file. More over the AspNetCoreIdentity web app project is configured to run at http://localhost:5000 and this is the base URL that we will be using when we configuring the OAuth callback URL for the external providers.

In case you don’t want to set authentication for all external providers, just read the instructions for those you are interested in

Configure Google Authentication

  • Navigate to the Integrate Google Sign-In page and click the CONFIGURE A PROJECT button. Here you will create a Google API Console project and client ID
  • Enter the name for your project and click Next. This name won’t be used when the app requests access to your Google account. Here’s an example.
  • Next enter the name that will be shown on the user consent screen. The consent screen is shown to the user the very first time the app requests access to your Google account.

    Click Next and proceed to setup the callback URL
  • Select Web server for our application environment and add http://localhost:5000/signin-google as an authorized redirect URI.
  • The following screen displays the Client ID and Client Secret generated for your new Console project.
  • Switch to AspNetCoreIdentity project and search for the ExternalProvidersRegistrations class file. You will find a section to setup your Google project’s client Id and Secret.
    // Google
    
    //dotnet user-secrets set "Authentication:Google:ClientId" ""
    //dotnet user-secrets set "Authentication:Google:ClientSecret" ""
    
    if (configuration["Authentication:Google:ClientId"] != null)
    {
        services.AddAuthentication().AddGoogle(o =>
        {
            o.ClientId = configuration["Authentication:Google:ClientId"];
            o.ClientSecret = configuration["Authentication:Google:ClientSecret"];
        });
    }
    

    All you have to do is open the Package Manager Console, cd to the AspNetCoreIdentity folder and run the dotnet user-secrets set command using your google project’s credentials.

    dotnet user-secrets set "Authentication:Google:ClientId" "<your-client-id>"
    dotnet user-secrets set "Authentication:Google:ClientSecret" "<your-client-secret-id>"
    

  • One last thing you need to know is that you can always re-configure your console project’s settings in console.developers.google.com. When you do that make sure you select the correct project from the top list and select Credentials from the left menu.

    If you click OAuth client you will see the OAuth configuration setup for the project.

    It’s always good to set a logo for your app and this can be done through the OAuth consent screen

    Configure Facebook Authentication

  • Navigate to the Facebook apps page, click the Add a New App button and fill the Create a New App ID popup form. The display name will be the name of the app will appear in the consent page.
  • When the App ID is created you will be redirected to the app’s page. Click the plus icon next to the Products menu item on the bottom left
  • Locate the Facebook Login product and click Set Up
  • Click the Settings menu item under the Facebook Login list and add http://localhost:5000/signin-facebook as an authorized redirect URI. Don’t forget to save the changes.

    Lately Facebook returns a message that localhost redirects do not need to be added. If you get this message just proceed with the next step

  • Click the Basic menu item under the Settings to retrieve your app’s credentials, App ID and App Secret
  • Switch to AspNetCoreIdentity project and search for the ExternalProvidersRegistrations class file. You will find a section to setup your Facebook app’s app Id and secret.
     
    // Facebook
    
    // dotnet user-secrets set Authentication:Facebook:AppId ""
    // dotnet user-secrets set Authentication:Facebook:AppSecret ""
    
    if (configuration["Authentication:Facebook:AppId"] != null)
    {
        services.AddAuthentication().AddFacebook(facebookOptions =>
        {
            facebookOptions.AppId = configuration["Authentication:Facebook:AppId"];
            facebookOptions.AppSecret = configuration["Authentication:Facebook:AppSecret"];
        });
    }
    

    Open the Package Manager Console, cd to the AspNetCoreIdentity folder and run the dotnet user-secrets set command using your facebook app’s credentials.

    dotnet user-secrets set Authentication:Facebook:AppId "<your-app-id>"
    dotnet user-secrets set Authentication:Facebook:AppSecret "<your-app-secret>"
    
  • Configure Twitter Authentication

  • Navigate to the Twitter apps page, click the Create an App button and fill the App details form. You will find that some fields are required, such as the Website URL. In case you don’t have valid values just fill with a fake one. The most important thing is to add the http://localhost:5000/signin-twitter as an authorized callback URI.

  • Fill and consent to any developer related terms required by Twitter
  • In the app’s view click the Keys and tokens tab to find the Twitter credentials for your app
  • Switch to AspNetCoreIdentity project and search for the ExternalProvidersRegistrations class file. You will find a section to setup your Twitter’s Consumer API key and secret.
    // Twitter
    
    // dotnet user-secrets set Authentication:Twitter:ConsumerAPIKey ""
    // dotnet user-secrets set Authentication:Twitter:ConsumerAPISecret ""
    
    if (configuration["Authentication:Twitter:ConsumerAPIKey"] != null)
    {
        services.AddAuthentication().AddTwitter(twitterOptions =>
        {
            twitterOptions.ConsumerKey = configuration["Authentication:Twitter:ConsumerAPIKey"];
            twitterOptions.ConsumerSecret = configuration["Authentication:Twitter:ConsumerAPISecret"];
            twitterOptions.RetrieveUserDetails = true;
        });
    }
    

  • You might think you have finished setting up Twitter authentication but you haven’t. Our app needs to read the external provider logged in user’s email address and by default Twitter doesn’t give this permission. Here’s a sneak peek to a debugging session while logged in with Twitter (we will study the code in more detail in the registration strategy..)

    As you can see there’s no email claim to retrieve and proceed with the authentication process. What you need to do is go back to the Twitter app’s App details view and fill the Terms of Service URL and Privacy policy URL fields.

    Next click the Permissions tab and check the Request email address from users checkbox. You won’t be able to check it unless you fill the previous fields.

    After doing so, here’s how the debugging session looks like.
  • Configure Microsoft Account Authentication

  • Navigate to the Azure apps registration page, click the New registration button and fill the form as follow. You can give your own name but make sure you set the Redirect URI to http://localhost:5000/signin-microsoft.
  • Select the Certificates & secrets from the left menu and click New client secret

    Check never and click >Add.

    Copy and save the client secret created.
  • To view your Application client id select Overview from the left menu.
  • Now that you have your app’s credentials switch to AspNetCoreIdentity project and search for the ExternalProvidersRegistrations class file. You will find a section to setup your Micorosoft app’s credentials.
    // Microsoft
    
    // dotnet user-secrets set Authentication:Microsoft:ClientId ""
    // dotnet user-secrets set Authentication:Microsoft:ClientSecret ""
    
    if (configuration["Authentication:Microsoft:ClientId"] != null)
    {
        services.AddAuthentication().AddMicrosoftAccount(microsoftOptions =>
        {
            microsoftOptions.ClientId = configuration["Authentication:Microsoft:ClientId"];
            microsoftOptions.ClientSecret = configuration["Authentication:Microsoft:ClientSecret"];
        });
    }
    

  • In case you want to add a logo for your app select Branding from the left menu and upload your logo.
  • Configure GitHub Authentication

  • Navigate to the GitHub developers page and select OAuth Apps from the left menu. Next click Register a new application if it’s the first time you create an app on GitHub or the New OAuth app button.
  • Fill the form and make sure to add http://localhost:5000/signin-github as an authorized callback URL.
  • When the app is created you can get its client id and client secret. You can also set an application logo.
  • Switch to AspNetCoreIdentity project and search for the ExternalProvidersRegistrations class file. You will find a section to setup your GitHub app’s credentials.
    // dotnet user-secrets set Authentication:GitHub:ClientId ""
    // dotnet user-secrets set Authentication:GitHub:ClientSecret ""
    
    if (configuration["Authentication:GitHub:ClientId"] != null)
    {
        services.AddAuthentication().AddGitHub(gitHubOptions =>
        {
            gitHubOptions.ClientId = configuration["Authentication:GitHub:ClientId"];
            gitHubOptions.ClientSecret = configuration["Authentication:GitHub:ClientSecret"];
        });
    }
    

  • Configure LinkedIn Authentication

  • Navigate to the LinkedIn developers page and click the Create app button.
  • Fill all the form fields and optionally set an app logo. For the Company field set any company you want since it’s not going to be validated unless you request to.
  • In the app’s page select the Auth tab and get its OAuth credentials.
  • Switch to AspNetCoreIdentity project and search for the ExternalProvidersRegistrations class file. You will find a section to setup your LinkedIn app’s credentials.
    // LinkedIn
    
    // dotnet user-secrets set Authentication:LinkedIn:ClientId ""
    // dotnet user-secrets set Authentication:LinkedIn:ClientSecret ""
    
    if (configuration["Authentication:LinkedIn:ClientId"] != null)
    {
        services.AddAuthentication().AddLinkedIn(linkedInOptions =>
        {
            linkedInOptions.ClientId = configuration["Authentication:LinkedIn:ClientId"];
            linkedInOptions.ClientSecret = configuration["Authentication:LinkedIn:ClientSecret"];
            //linkedInOptions.CallbackPath = "/signin-linkedin";
        });
    }
    

  • Configure DropBox Authentication

  • Navigate to the DropBox developers apps page and click the Create app button.
  • Select Dropbox API and check the App folder – access to a single folder created specifically for your app checkbox. Next name your app as you wish.

    When you create a new app, DropBox will also create a new folder for this app.
  • In the app’s Settings tab add http://localhost:5000/signin-dropbox as an authorized redirect URI. Notice that the Settings view also contains the App key and App secret.
  • Switch to AspNetCoreIdentity project and search for the ExternalProvidersRegistrations class file. You will find a section to setup your DropBox app’s credentials.
    // DropBox
    
    // dotnet user-secrets set Authentication:DropBox:ClientKey ""
    // dotnet user-secrets set Authentication:DropBox:ClientSecret ""
    
    if (configuration["Authentication:DropBox:ClientKey"] != null)
    {
        services.AddAuthentication().AddDropbox(dropBoxOptions =>
        {
            dropBoxOptions.ClientId = configuration["Authentication:DropBox:ClientKey"];
            dropBoxOptions.ClientSecret = configuration["Authentication:DropBox:ClientSecret"];
            //dropBoxOptions.CallbackPath = "/signin-dropbox";
        });
    }
    

  • The Branding tab can be used to set your application logo. It seems that you can set a logo that already exists on your DropBox account.
  • Now that we have set authentication for all these providers let’s see how the consent page looks like for some of them:

    Facebook consent page


    Twitter consent page


    Microsoft consent page


    GitHub consent page


    LinkedIn consent page


    Dropbox consent page

    Any time you want to remove access given to an app, sign in to the specific provider and find the page for your account permissions. For example for Google permissions you can navigate to myaccount.google.com/permissions.

    External providers registration strategy

    After adding external providers authentication to your web application you will soon realize that there will be cases where you need to make a decision. The simplest scenario is where you have already registered an account through the normal process by providing your email, a username and a password. After using your credentials for a long time to authenticate, someday you decide that you want to sign in through your Facebook account. The thing is that your Facebook account is registered with a different email address which brings us to the million-dollar question: Is the app going to create a new account for that email address or is it going to provide the option to associate the Facebook address to an existing account? And if it’s the latter what is the right process to do so? In this post and the associated source code we will provide both the options, allowing the user to decide either to create a new account or associate an existing one. Before start exploring the source code we need to write down the rules and flows this strategy follows.

  • Rule #1: Users can sign in only if their email is confirmed. Normally websites let you sign in even if you haven’t confirmed your email address but prevent you from using their services (such as placing orders for example) till you do so. Our app isn’t that complex so we can stick with this rule for simplicity reasons
  • Rule #2: Users authenticated by an external provider are considered trusted. This means that if a user tries to register a new account through an external provider and selects the option to register a new account using the email address used in the provider, then the user won’t have to confirm the email address because it’s already confirmed by the provider
  • Rule #3: Users authenticated by an external provider that select the option to associate the email address used in the provider with an existing account registered with a different email address, will have to confirm the external provider association through the existing account’s email address
  • Rule #4: An association can only happen with already confirmed accounts
  • Rule #5: Users authenticated by an external provider but have an existing account with the same email address that hasn’t been confirmed, have to confirm the association which eventually will automatically confirm the existing account as well. The reason is simple: Consider the scenario where some stranger uses your email address and registers an account through the normal process. This means that a stranger knows the password for that account. You on the other side decide someday to sign in through an external provider that uses the same email address. If we automatically confirm the account that already exists in the database then the stranger has instant access to your account through the normal authentication process (username & password)
  • Now that we know the rules let’s take a look at the flows running during registration and external providers association process.

    • When registering a new account through the normal process, a confirmation email is sent to the email address used. The email contains a link that changes the user account EmailConfirmed status to True
    • When trying to associate an external login provider with an existing confirmed account that has a different email address, a confirmation email is sent to that address. Clicking on the link in the email adds the login provider to the existing account
    • When trying to associate an external login provider with an existing un-confirmed account that has a different email address, nothing happens. A message is shown to the user that the existing account’s email address needs to be confirmed first
    • Registering a new account through an external login provider marks instantly the account as confirmed and hence no confirmation email is sent

    External provider registration strategy implementation

    Time to check how all the above requirements are implemented. We will start by making email confirmation required. In the Startup when you configure Identity you need to set the following:

    services.AddIdentity<IdentityUser, IdentityRole>(config =>
    {
        config.SignIn.RequireConfirmedEmail = true;
    })
    .AddEntityFrameworkStores<IdentityDbContext>()
    .AddDefaultTokenProviders();
    

    There are two controllers related to user accounts, the default AccountController and the SocialAccountController that deals with all the external provider related tasks. In the Login action of the AccountController we will add a check to see if the user can sign in.

    if (await _userManager.CheckPasswordAsync(user, model.Password))
    {
        // Rule #1
        if (!await _signInManager.CanSignInAsync(user))
        {
            result.Status = Status.Error;
            result.Data = "<li>Email confirmation required</li>";
    
            return result;
        }
    // code omitted 
    

    Always remember to use the CanSignInAsync method of the SignInManager because it knows to run all validations have been configured in the Identity system.

    public virtual async Task<bool> CanSignInAsync(TUser user)
    {
        if (Options.SignIn.RequireConfirmedEmail && !(await UserManager.IsEmailConfirmedAsync(user)))
        {
            Logger.LogWarning(0, "User {userId} cannot sign in without a confirmed email.", await UserManager.GetUserIdAsync(user));
            return false;
        }
        if (Options.SignIn.RequireConfirmedPhoneNumber && !(await UserManager.IsPhoneNumberConfirmedAsync(user)))
        {
            Logger.LogWarning(1, "User {userId} cannot sign in without a confirmed phone number.", await UserManager.GetUserIdAsync(user));
            return false;
        }
        if (Options.SignIn.RequireConfirmedAccount && !(await _confirmation.IsConfirmedAsync(UserManager, user)))
        {
            Logger.LogWarning(4, "User {userId} cannot sign in without a confirmed account.", await UserManager.GetUserIdAsync(user));
            return false;
        }
        return true;
    }
    

    Now if you create a new user through the default process its EmailConfirmed column in the AspNetUsers table will be False.

    And if you try to sign in with your credential you will see the following error message.

    Next thing we need to do is add email support to our app. This is very easy to accomplish using the SendGrid service. It’s totally free so go ahead and create a new account. You will be able to send at least 100 emails per day so it’s more than enough for development purposes. After creating the account open the API Keys view under the Settings menu on the left and create a new API key.

    Copy your API Key. You also need your account’s username which you can find in the Account Details menu item. Next run the following commands in the Package Manager Console:

    dotnet user-secrets set SendGridUser "<your-sendgrid-username>"
    dotnet user-secrets set SendGridKey "<your-sendgrid-apikey>"
    

    When registering a new account through the normal process a new email confirmation token is created and sent to the email address used. This is done in the AccountController Register action.

    var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
    var callbackUrl = Url.Action("ConfirmEmail", "Account",
        values: new { userId = user.Id, code = code },
        protocol: Request.Scheme);
    
    await _emailSender.SendEmailAsync(user.Email, "Confirm your email",
        $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
    
    return new ResultVM
    {
        Status = Status.Success,
        Message = "Email confirmation is pending",
        Data = user
    };
    

    You can generate several types of tokens as you can see from the implementation of the GenerateEmailConfirmationTokenAsync method.

    public virtual Task<string> GenerateEmailConfirmationTokenAsync(TUser user)
    {
        ThrowIfDisposed();
        return GenerateUserTokenAsync(user, Options.Tokens.EmailConfirmationTokenProvider, ConfirmEmailTokenPurpose);
    }
    

    The Url.Action will create a link to the ConfirmEmail action of the same controller. This action calls the UserManager ConfirmEmailsAsync method which validates the token and if valid will update the user record in the database as well.

    public virtual async Task<IdentityResult> ConfirmEmailAsync(TUser user, string token)
    {
        ThrowIfDisposed();
        var store = GetEmailStore();
        if (user == null)
        {
            throw new ArgumentNullException(nameof(user));
        }
    
        if (!await VerifyUserTokenAsync(user, Options.Tokens.EmailConfirmationTokenProvider, ConfirmEmailTokenPurpose, token))
        {
            return IdentityResult.Failed(ErrorDescriber.InvalidToken());
        }
        await store.SetEmailConfirmedAsync(user, true, CancellationToken);
        return await UpdateUserAsync(user);
    }
    

    Let’s understand what happens when you add support for an external provider authentication through an extension method such as services.AddAuthentication().AddGoogle. We will examine the case of the Google provider. What happens is that the builder registers a handler of type Microsoft.AspNetCore.Authentication.OAuth.OAuthHandler.

    public static class GoogleExtensions
    {
        // code omitted 
    
        public static AuthenticationBuilder AddGoogle(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action<GoogleOptions> configureOptions)
            => builder.AddOAuth<GoogleOptions, GoogleHandler>(authenticationScheme, displayName, configureOptions);
    }
    

    Each of these handlers has some type of default OAuthOptions configuration, so now you know where the /signin-google, signin-facebook etc.. comes from.

    public class GoogleOptions : OAuthOptions
    {
        /// <summary>
        /// Initializes a new <see cref="GoogleOptions"/>.
        /// </summary>
        public GoogleOptions()
        {
            CallbackPath = new PathString("/signin-google");
            AuthorizationEndpoint = GoogleDefaults.AuthorizationEndpoint;
            TokenEndpoint = GoogleDefaults.TokenEndpoint;
            UserInformationEndpoint = GoogleDefaults.UserInformationEndpoint;
            Scope.Add("openid");
            Scope.Add("profile");
            Scope.Add("email");
    
            ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id");
            ClaimActions.MapJsonKey(ClaimTypes.Name, "name");
            ClaimActions.MapJsonKey(ClaimTypes.GivenName, "given_name");
            ClaimActions.MapJsonKey(ClaimTypes.Surname, "family_name");
            ClaimActions.MapJsonKey("urn:google:profile", "link");
            ClaimActions.MapJsonKey(ClaimTypes.Email, "email");
        }
    
        /// <summary>
        /// access_type. Set to 'offline' to request a refresh token.
        /// </summary>
        public string AccessType { get; set; }
    }
    

    These handlers know how to create a ChallengeUrl which is the Url that redirects the user to the external provider for signing in.

    protected override string BuildChallengeUrl(AuthenticationProperties properties, string redirectUri)
    {
        // Google Identity Platform Manual:
        // https://developers.google.com/identity/protocols/OAuth2WebServer
    
        var queryStrings = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        queryStrings.Add("response_type", "code");
        queryStrings.Add("client_id", Options.ClientId);
        queryStrings.Add("redirect_uri", redirectUri);
    
        AddQueryString(queryStrings, properties, GoogleChallengeProperties.ScopeKey, FormatScope, Options.Scope);
        AddQueryString(queryStrings, properties, GoogleChallengeProperties.AccessTypeKey, Options.AccessType);
        AddQueryString(queryStrings, properties, GoogleChallengeProperties.ApprovalPromptKey);
        AddQueryString(queryStrings, properties, GoogleChallengeProperties.PromptParameterKey);
        AddQueryString(queryStrings, properties, GoogleChallengeProperties.LoginHintKey);
        AddQueryString(queryStrings, properties, GoogleChallengeProperties.IncludeGrantedScopesKey, v => v?.ToString().ToLower(), (bool?)null);
    
        var state = Options.StateDataFormat.Protect(properties);
        queryStrings.Add("state", state);
    
        var authorizationEndpoint = QueryHelpers.AddQueryString(Options.AuthorizationEndpoint, queryStrings);
        return authorizationEndpoint;
    }
    

    When a OAuth handler fires, its CreateTicketAsync method runs and if the response payload from the external provider is valid then an AuthenticationTicket is returned.

    protected override async Task<AuthenticationTicket> CreateTicketAsync(
        ClaimsIdentity identity,
        AuthenticationProperties properties,
        OAuthTokenResponse tokens)
    {
        // Get the Google user
        var request = new HttpRequestMessage(HttpMethod.Get, Options.UserInformationEndpoint);
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
    
        var response = await Backchannel.SendAsync(request, Context.RequestAborted);
        if (!response.IsSuccessStatusCode)
        {
            throw new HttpRequestException($"An error occurred when retrieving Google user information ({response.StatusCode}). Please check if the authentication information is correct.");
        }
    
        using (var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync()))
        {
            var context = new OAuthCreatingTicketContext(new ClaimsPrincipal(identity), properties, Context, Scheme, Options, Backchannel, tokens, payload.RootElement);
            context.RunClaimActions();
            await Events.CreatingTicket(context);
            return new AuthenticationTicket(context.Principal, context.Properties, Scheme.Name);
        }
    }
    

    As we mentioned, each OAuth handler has some default values and one of them is the AuthenticationScheme.

    public static partial class GoogleDefaults
    {
        public const string AuthenticationScheme = "Google";
        public static readonly string AuthorizationEndpoint;
        public static readonly string DisplayName;
        public static readonly string TokenEndpoint;
        public static readonly string UserInformationEndpoint;
    }
    

    The AuthenticationBuilder.AddOAuth extension method ends up adding mappings of Authentication schemes with the relative handlers..

    public static AuthenticationBuilder AddOAuth<TOptions, THandler>(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action<TOptions> configureOptions)
        where TOptions : OAuthOptions, new()
        where THandler : OAuthHandler<TOptions>
    {
        builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<TOptions>, OAuthPostConfigureOptions<TOptions, THandler>>());
        return builder.AddRemoteScheme<TOptions, THandler>(authenticationScheme, displayName, configureOptions);
    }
    

    When you run the app you will see all external providers that have been setup on the login screen.

    This is an API call to the Providers action of the SocialAccountController that invokes the _signInManager.GetExternalAuthenticationSchemesAsync method.

    [HttpGet]
    public async Task<IActionResult> Providers()
    {
        var schemes = await _signInManager.GetExternalAuthenticationSchemesAsync();
    
        return Ok(schemes.Select(s => s.DisplayName).ToList());
    }
    

    The GetExternalAuthenticationSchemesAsync method returns all the schemes that have been added for external login providers.

    public virtual async Task<IEnumerable<AuthenticationScheme>> GetExternalAuthenticationSchemesAsync()
    {
        var schemes = await _schemes.GetAllSchemesAsync();
        return schemes.Where(s => !string.IsNullOrEmpty(s.DisplayName));
    }
    

    This concludes how the authentication handlers are added and work behind the scenes. Each of the external providers icon you see on the login screen points to the SocialAccountController Login action, passing the respective provider parameter value.

    [HttpGet]
    public IActionResult Login(string provider, string returnUrl = null)
    {
        var redirectUrl = Url.Action("Callback", "SocialAccount");
        var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
        return new ChallengeResult(provider, properties);
    }
    

    The redirectUrl is the action method that will handle the response redirect from the external provider and in our case is the Callback action of the same controller. When the external provider’s response hits this action, first we retrieve the provider’s info.

    var info = await _signInManager.GetExternalLoginInfoAsync();
    

    Next it tries to sign in using the login provider name and its provider key.

    var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey,
                    isPersistent: false, bypassTwoFactor: true);
    

    Let’s pause a little bit here to see what this means. A user account or an AspNetUsers table record, can be bound with multiple external providers through the AspNetUserLogins table. In the following screenshot you can see that my account has 3 external providers that may use different email addresses from the original one.

    The provider key is unique for every user in the external provider. The ExternalLoginSignInAsync method tries to find a user that has added a login provider having a specific provider key.

    public virtual async Task<SignInResult> ExternalLoginSignInAsync(string loginProvider, string providerKey, bool isPersistent, bool bypassTwoFactor)
    {
        var user = await UserManager.FindByLoginAsync(loginProvider, providerKey);
        if (user == null)
        {
            return SignInResult.Failed;
        }
    
        var error = await PreSignInCheck(user);
        if (error != null)
        {
            return error;
        }
        return await SignInOrTwoFactorAsync(user, isPersistent, loginProvider, bypassTwoFactor);
    }
    

    If a user with this provider details found, the PreSignInCheck method will check if the user can sign in.

    protected virtual async Task<SignInResult> PreSignInCheck(TUser user)
    {
        if (!await CanSignInAsync(user))
        {
            return SignInResult.NotAllowed;
        }
        if (await IsLockedOut(user))
        {
            return await LockedOut(user);
        }
        return null;
    }
    

    What follows next in the Callback action is the business logic we have decided to implement.

    • On Success: It means that the user has already added this external provider and the account is confirmed
      var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey,
                      isPersistent: false, bypassTwoFactor: true);
      if (result.Succeeded)
      {
          return LocalRedirect(returnUrl);
      }
      // code omitted
      
    • Failure & account with email address doesn’t exist:The user is redirected to the register view to choose either to create a new account or associate it with an existing one
    • Failure & account with email exists but is unconfirmed: A confirmation email is sent. If the user clicks the email link two actions will follow: The account will be confirmed and the external provider will be added as well. The action that will handle this is the ConfirmExternalProvider action in the Account controller.
      // RULE #5
      if (!userDb.EmailConfirmed)
      {
          var token = await _userManager.GenerateEmailConfirmationTokenAsync(userDb);
      
          var callbackUrl = Url.Action("ConfirmExternalProvider", "Account",
              values: new
              {
                  userId = userDb.Id,
                  code = token,
                  loginProvider = info.LoginProvider,
                  providerDisplayName = info.LoginProvider,
                  providerKey = info.ProviderKey
              },
              protocol: Request.Scheme);
      
          await _emailSender.SendEmailAsync(userDb.Email, $"Confirm {info.ProviderDisplayName} external login",
              $"Please confirm association of your {info.ProviderDisplayName} account by clicking <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>here</a>.");
      
          return LocalRedirect(
              $"{returnUrl}?message=External account association with {info.ProviderDisplayName} is pending.Please check your email");
      }
      
    • Failure & account with email exists and is already confirmed: This means that we can proceed by adding the login provider to that account.
      // Add the external provider
      await _userManager.AddLoginAsync(userDb, info);
      
      await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey,
      isPersistent: false, bypassTwoFactor: true);
      
      return LocalRedirect(
          $"{returnUrl}?message={info.ProviderDisplayName} has been added successfully");
      

    When signing in with an external provider it’s important to use the ExternalLoginSignInAsync method. This will add the AuthenticationMethod claim to the user and you will be able to find the provider the user is signed in to the app.

    [HttpGet]
    public UserStateVM Authenticated()
    {
        return new UserStateVM
        {
            IsAuthenticated = User.Identity.IsAuthenticated,
            Username = User.Identity.IsAuthenticated ? User.Identity.Name : string.Empty,
            AuthenticationMethod = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.AuthenticationMethod)?.Value
        };
    }
    

    External provider association with an existing account

    When you sign in with an external provider and no user with the email used in the provider found, the app redirects you to the register page giving you two options. The first one is to simply provide a username and create a new account.

    Hitting register will invoke the associate action of the SocialAccountController passing the following params on the request’s body. Notice that all the required provider’s details are available (read from the query string) to add the login provider to the new user account to be created.

    On the other hand, if you click the associate an existing account checkbox and choose to associate the external provider with an existing account, you need to enter the email address of the existing account you wish to associate your provider login.

    This time the associateExistingAccount param is True. The code will check if the existing account exists in the database and if it doesn’t it will return a simple message. If it does exist it will check its email confirmation status. If the email isn’t confirmed again it will send back a simple message that the existing account should be confirmed first before associating an external provider (Rule #5). If the account is confirmed then a confirmation email will be sent to the existing account’s email address in order to confirm and add the external provider login (Rule #4).

    if (userDb != null)
    {
        // Rule #5
        if (!userDb.EmailConfirmed)
        {
            return new ResultVM
            {
                Status = Status.Error,
                Message = "Invalid data",
                Data = $"<li>Associated account (<i>{associate.AssociateEmail}</i>) hasn't been confirmed yet.</li><li>Confirm the account and try again</li>"
            };
        }
    
        // Rule #4
        var token = await _userManager.GenerateEmailConfirmationTokenAsync(userDb);
    
        var callbackUrl = Url.Action("ConfirmExternalProvider", "Account",
            values: new
            {
                userId = userDb.Id,
                code = token,
                loginProvider = associate.LoginProvider,
                providerDisplayName = associate.LoginProvider,
                providerKey = associate.ProviderKey
            },
            protocol: Request.Scheme);
    
        await _emailSender.SendEmailAsync(userDb.Email, $"Confirm {associate.ProviderDisplayName} external login",
            $"Please confirm association of your {associate.ProviderDisplayName} account by clicking <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>here</a>.");
    
        return new ResultVM
        {
            Status = Status.Success,
            Message = "External account association is pending. Please check your email"
        };
    }
    

    That’s it, we finished! I encourage you run the AspNetCoreIdentity app and test all the authentication methods we mentioned in the post. One thing that I have intentionally left for the end, is to advise you to never share the external provider details in emails. We used it in our app for simplicity but in a production environment, you should use some type of encryption and/or a database store. Also, don’t forget that when registering a new account through an external provider the record in the AspNetUsers database table has NULL value for the PasswordHash value. This means that if the external provider is not available (Facebook & Instagram have lots of incidents lately..), the users won’t be able to sign in. For this reason, you should create a view for the user to set a password as well. I will probably push it to the repo for you in the near future.

    We have seen how to add external login provider authentication for many providers and how their handlers work behind the scenes. We also implemented an external login provider registration strategy that allows the user to choose either to create a new account from the external provider login details or associate it with an existing one.

    In case you find my blog’s content interesting, register your email to receive notifications of new posts and follow chsakell’s Blog on its Facebook or Twitter accounts.

    Facebook Twitter
    .NET Web Application Development by Chris S.
    facebook twitter-small
    twitter-small

    ASP.NET Core Identity Series – Two Factor Authentication

    $
    0
    0

    Two-Factor Authentication is an additional security layer used to address the vulnerabilities of a standard password-only approach. All popular websites such as Facebook, Twitter, LinkedIn or DropBox recommend their users to enable the feature and prevent unauthorized access to their accounts or at least minimize the probability of compromising them. How does it work? In a nutshell, after authenticating using the standard username-password or email-password credentials, the user is asked to provide a code that only he/she has access to. This code is generated usually by a Time-based One-time Password Algorithm running on the user’s smartphone’s authenticator app, which means that is valid for only a small time of period. So 2FA is a Multi-Factor Authentication model that requires a combination of something you have and something you know in order to access your account. One question arising is how the authenticator knows to generate valid time-based codes. As we will explain later in the post the web app and the authenticator app usually share a key, the authenticator key, which is used to generate the tokens. When you decide to enable 2FA in a website, you will be asked to enter this shared key to your smartphone’s authenticator app. This key can be either manually typed or shared via a QR Code and automatically added to your app. ASP.NET Core Identity totally supports 2FA Time-based One-time Password Algorithm (TOTP) and this is what this post is all about. We will implement all the available 2FA steps one by one and also explain how it works behind the scenes. After understanding its behavior we will override some default implementations to enhance the security level that 2FA provides. Let’s see the contents of the post in detail:

    • Implement all Two-Factor Authentication related tasks:
      • Enable/Disable 2FA – QR Code included
      • Generate/Reset recovery tokens
      • Reset authenticator app
    • Explore the 2FA code and database schema
    • Enhance the security level of 2FA by overriding the default implementation
      • Encrypt authenticator key
      • Encrypt recovery tokens

    The source code for the series is available here. Each part has a related branch on the repository. To follow along with this part clone the repository and checkout the two-factor-authentication branch as follow:

    git clone https://github.com/chsakell/aspnet-core-identity.git
    cd .\aspnet-core-identity
    git checkout two-factor-authentication
    

    This post is part of the ASP.NET Core Identity Series:

    Enable Two-Factor Authentication

    All 2FA features have been added to the AspNetCoreIdentity web app project and a new manage/account route contains all the related UI logic. There are two tabs, one to display the current status of the 2FA and another one to configure it.

    There are 3 properties on the account that we are interested in, the 2FA Enabled the Has Authenticator and the 2FA Client remember statuses. All the 2FA backend implementation logic exists in the TwoFactorAuthenticationController and when the manage/account route is activated a call to its Details action is made.

    [HttpGet]
    [Authorize]
    public async Task<AccountDetailsVM> Details()
    {
        var user = await _userManager.GetUserAsync(User);
        var logins = await _userManager.GetLoginsAsync(user);
    
        return new AccountDetailsVM
        {
            Username = user.UserName,
            Email = user.Email,
            EmailConfirmed = user.EmailConfirmed,
            PhoneNumber = user.PhoneNumber,
            ExternalLogins = logins.Select(login => login.ProviderDisplayName).ToList(),
            TwoFactorEnabled = await _userManager.GetTwoFactorEnabledAsync(user),
            HasAuthenticator = await _userManager.GetAuthenticatorKeyAsync(user) != null,
            TwoFactorClientRemembered = await _signInManager.IsTwoFactorClientRememberedAsync(user),
            RecoveryCodesLeft = await _userManager.CountRecoveryCodesAsync(user)
        };
    }
    

    _userManager.GetTwoFactorEnabledAsync(user) checks the TwoFactorEnabled column in the AspNetUsers table because we use the Entity’s Framework implementation. It’s just a flag defining either if the 2FA is enabled or not.

    If you want to override where this value comes from, all you have to do is provide a custom implementation for the IUserTwoFactorStore interface.

    public virtual async Task<bool> GetTwoFactorEnabledAsync(TUser user)
    {
        ThrowIfDisposed();
        var store = GetUserTwoFactorStore();
        if (user == null)
        {
            throw new ArgumentNullException(nameof(user));
        }
        return await store.GetTwoFactorEnabledAsync(user, CancellationToken);
    }     
    

    The _userManager.GetAuthenticatorKeyAsync(user) returns the authenticator key (if exists) that is used to generate valid time-based tokens. This defines if the user has configured an authenticator app or not and it’s different than the previous one which tells if 2FA is enabled. This means that you may have setup an authenticator but at the same time you can also have disabled the 2FA. To setup an authenticator in our app, select the 2 Factor Authentication menu item in the Security tab.

    Next click the Setup authenticator button.

    This will present you a key to enter in your authenticator app and a QR Code in case you prefer to scan it instead of typing it.

    What changed in the database is that a User Token record has been added for your user.

    Notice that the Value column is the exact key generated and asked to type in your authenticator app. If you get back in the Profile tab you will see that Has Authenticator column is now true. This means that _userManager.GetAuthenticatorKeyAsync(user) checks if there’s a record in the AspNetUserTokens table for the logged in user that has LoginProvider = [AspNetUserStore].

    public virtual Task<string> GetAuthenticatorKeyAsync(TUser user)
    {
        ThrowIfDisposed();
        var store = GetAuthenticatorKeyStore();
        if (user == null)
        {
            throw new ArgumentNullException(nameof(user));
        }
        return store.GetAuthenticatorKeyAsync(user, CancellationToken);
    }
    

    InMemoryUserStore and the abstract UserStoreBase classes provide implementations for the IUserAuthenticatorKeyStore interface. As shown below, both of them use the same values for the LoginProvider name, the AuthenticatorKeyTokenName and the RecoveryCodeTokenName.

    private const string AuthenticatorStoreLoginProvider = "[AspNetAuthenticatorStore]";
    private const string AuthenticatorKeyTokenName = "AuthenticatorKey";
    private const string RecoveryCodeTokenName = "RecoveryCodes";
    

    Two-Factor Authentication functionality is also available with the In-Memory database provider. To test it in our app, use “InMemoryProvider”: true in appsettings.json

    We haven’t seen yet anything about recovery codes, but you can already guess that when created, a new record in the AspNetUserTokens table will be added, having the same LoginProvider value but RecoveryCodes as the Name value. Let’s pause for a little and understand what happened when you clicked the Setup authenticator button. A GET request made to the SetupAuthenticator action.

    [HttpGet]
    [Authorize]
    public async Task<AuthenticatorDetailsVM> SetupAuthenticator()
    {
        var user = await _userManager.GetUserAsync(User);
        var authenticatorDetails = await GetAuthenticatorDetailsAsync(user);
    
        return authenticatorDetails;
    }
    
    private async Task<AuthenticatorDetailsVM> GetAuthenticatorDetailsAsync(IdentityUser user)
    {
        // Load the authenticator key & QR code URI to display on the form
        var unformattedKey = await _userManager.GetAuthenticatorKeyAsync(user);
        if (string.IsNullOrEmpty(unformattedKey))
        {
            await _userManager.ResetAuthenticatorKeyAsync(user);
            unformattedKey = await _userManager.GetAuthenticatorKeyAsync(user);
        }
    
        var email = await _userManager.GetEmailAsync(user);
    
        return new AuthenticatorDetailsVM
        {
            SharedKey = FormatKey(unformattedKey),
            AuthenticatorUri = GenerateQrCodeUri(email, unformattedKey)
        };
    }
    

    If no authenticator key found we call the _userManager.ResetAuthenticatorKeyAsync(user) method to create one. This method can also be used to Reset authenticator app which simply changes the key value on the store. The FormatKey method just adds a space every 4 letters so it can more readable to user. To create a QR Code you need a valid authenticator app Uri that contains all the required information for your authenticator app to work properly.

    private string GenerateQrCodeUri(string email, string unformattedKey)
    {
        const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6";
    
        return string.Format(
            AuthenticatorUriFormat,
            _urlEncoder.Encode("ASP.NET Core Identity"),
            _urlEncoder.Encode(email),
            unformattedKey);
    }
    

    If you hover on the QR Code generated you can see this URI.

    The most important is that contains the key to be used for generating 6 digits authentication tokens using the Time-based One-time Password Algorithm (TOTP). In javascript, a qr code library used to paint the QR Code. As you can see you can configure several properties to match your requirements.

    self.generatedQRCode = new QRCode(document.getElementById("genQrCode"),
        {
            text: self.authenticatorDetails.authenticatorUri,
            width: 150,
            height: 150,
            colorDark: "#000",
            colorLight: "#ffffff",
            correctLevel: QRCode.CorrectLevel.H
        });
    

    Proceed with either scanning the QR Code on your authenticator app in your smartphone or by manually typing it. Google Authenticator and AUTHY are the most popular authenticator apps. When you click the Verify button, 2FA will be enabled for your account and you will also get 10 recovery codes.

    Make sure to copy and save these codes. Let’s see the VefiryAuthenticator action in code.

    public async Task<ResultVM> VerifyAuthenticator([FromBody] VefiryAuthenticatorVM verifyAuthenticator)
    {
        // code omitted
    
        var verificationCode = verifyAuthenticator.VerificationCode.Replace(" ", string.Empty).Replace("-", string.Empty);
    
        var is2FaTokenValid = await _userManager.VerifyTwoFactorTokenAsync(
            user, _userManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode);
    
        await _userManager.SetTwoFactorEnabledAsync(user, true);
    
        var result = new ResultVM
        {
            Status = Status.Success,
            Message = "Your authenticator app has been verified",
        };
    
        if (await _userManager.CountRecoveryCodesAsync(user) != 0) return result;
    
        var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
        result.Data = new { recoveryCodes };
        return result;
    }
    

    The _userManager.VerifyTwoFactorTokenAsync is the method that knows to verify an authenticator token.

    public virtual async Task<bool> VerifyTwoFactorTokenAsync(TUser user, string tokenProvider, string token)
    {
        // Make sure the token is valid
        var result = await _tokenProviders[tokenProvider].ValidateAsync("TwoFactor", token, this, user);
        if (!result)
        {
            Logger.LogWarning(10, $"{nameof(VerifyTwoFactorTokenAsync)}() failed for user {await GetUserIdAsync(user)}.");
        }
        return result;
    }
    

    The ValidateAsync method exists in an AuthenticatorTokenProvider class that implements the IUserTwoFactorTokenProvider interface.

    public virtual async Task<bool> ValidateAsync(string purpose, string token, UserManager<TUser> manager, TUser user)
    {
        var key = await manager.GetAuthenticatorKeyAsync(user);
        int code;
        if (!int.TryParse(token, out code))
        {
            return false;
        }
    
        var hash = new HMACSHA1(Base32.FromBase32(key));
        var unixTimestamp = Convert.ToInt64(Math.Round((DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0)).TotalSeconds));
        var timestep = Convert.ToInt64(unixTimestamp / 30);
        // Allow codes from 90s in each direction (we could make this configurable?)
        for (int i = -2; i <= 2; i++)
        {
            var expectedCode = Rfc6238AuthenticationService.ComputeTotp(hash, (ulong)(timestep + i), modifier: null);
            if (expectedCode == code)
            {
                return true;
            }
        }
        return false;
    }
    

    Here is where the generated authenticator’s app token is being validated. Back to the VerifyAuthenticator action the code checks if there any recovery tokens exist and if not, creates 10 of them using the userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10) method. The default implementation will add the generated codes semicolon seperated as shown in the database screenshot below.

    Authenticating with 2FA

    At this point, you should have 2FA configured and enabled for your account. Logout and try to sign in, you should be asked to enter either the 6-digit code generated by the authenticator app configured on your phone or use a recovery code generated before.

    The login action will end up in the TwoFaLogin private method.

    private async Task<ResultVM> TwoFaLogin(string code, bool isRecoveryCode, bool rememberMachine = false)
    {
        SignInResult result = null;
    
        var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
    
        var authenticatorCode = code.Replace(" ", string.Empty).Replace("-", string.Empty);
    
        if (!isRecoveryCode)
        {
            result = await _signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, true,
                rememberMachine);
        }
        else
        {
            result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(authenticatorCode);
        }
    
        // Code omitted
    }
    

    The code is self explanatory. First it needs to know which user tried to login and presented with the 2FA form. Next, depending on whether the user chose to login with an generated or a recovery code calls the _signInManager.TwoFactorAuthenticatorSignInAsync or _signInManager.TwoFactorRecoveryCodeSignInAsync respectively. On the first case the SignInManager uses the UserManager VerifyTwoFactorTokenAsync we described before and if the token is valid signs in the user.

    public virtual async Task<SignInResult> TwoFactorAuthenticatorSignInAsync(string code, bool isPersistent, bool rememberClient)
        {
            // code omitted
            var user = await UserManager.FindByIdAsync(twoFactorInfo.UserId);
        
            if (await UserManager.VerifyTwoFactorTokenAsync(user, Options.Tokens.AuthenticatorTokenProvider, code))
            {
                await DoTwoFactorSignInAsync(user, twoFactorInfo, isPersistent, rememberClient);
                return SignInResult.Success;
            }
            // If the token is incorrect, record the failure which also may cause the user to be locked out
            await UserManager.AccessFailedAsync(user);
            return SignInResult.Failed;
        }
    

    If the Remember machine checkbox is checked then an Identity.TwoFactorRememberMe cookie will be saved in your browser and you wont be asked again to type an authenticator code on the browser you used.

    In the second case with the recovery code, it uses the UserManager.RedeemTwoFactorRecoveryCodeAsync method to check that the recovery code provided is valid.

    public virtual async Task<SignInResult> TwoFactorRecoveryCodeSignInAsync(string recoveryCode)
    {
        // code omitted
        var user = await UserManager.FindByIdAsync(twoFactorInfo.UserId);
    
        var result = await UserManager.RedeemTwoFactorRecoveryCodeAsync(user, recoveryCode);
        if (result.Succeeded)
        {
            await DoTwoFactorSignInAsync(user, twoFactorInfo, isPersistent: false, rememberClient: false);
            return SignInResult.Success;
        }
    
        // We don't protect against brute force attacks since codes are expected to be random.
        return SignInResult.Failed;
    }
    

    Here’s the entire flow using a GIF (click it to view it in better quality..).

    Reset recovery codes

    After enabling 2FA, two buttons will be enabled in the app, the Reset recovery codes and the Disable 2FA.

    Clicking the Reset recovery codes button will hit the GenerateRecoveryCodes action in the TwoFactorAuthenticationController.

    [HttpPost]
    [Authorize]
    public async Task<ResultVM> GenerateRecoveryCodes()
    {
        var user = await _userManager.GetUserAsync(User);
    
        var isTwoFactorEnabled = await _userManager.GetTwoFactorEnabledAsync(user);
    
        // code omitted
    
        var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
    
        return new ResultVM
        {
            Status = Status.Success,
            Message = "You have generated new recovery codes",
            Data = new { recoveryCodes }
        };
    }
    

    The new codes are generated with the same way generated on the first time and replace the existing ones using the ReplaceCodesAsync method.

    public virtual async Task<IEnumerable<string>> GenerateNewTwoFactorRecoveryCodesAsync(TUser user, int number)
    {
        var store = GetRecoveryCodeStore();
    
        var newCodes = new List<string>(number);
        for (var i = 0; i < number; i++)
        {
            newCodes.Add(CreateTwoFactorRecoveryCode());
        }
    
        await store.ReplaceCodesAsync(user, newCodes.Distinct(), CancellationToken);
        var update = await UpdateAsync(user);
        if (update.Succeeded)
        {
            return newCodes;
        }
        return null;
    }
    

    Always save the recovery tokens somewhere secure, maybe on the cloud. In case you lose your phone you will need them to sign in and reset or disable 2FA on your account

    Reset authenticator

    Clicking the Disable 2FA button will hit the Disable2FA action which simply updates the TwoFactorEnabled property on the user to False.

    [HttpPost]
    [Authorize]
    public async Task<ResultVM> Disable2FA()
    {
        var user = await _userManager.GetUserAsync(User);
    
        if (!await _userManager.GetTwoFactorEnabledAsync(user))
        {
            return new ResultVM
            {
                Status = Status.Error,
                Message = "Cannot disable 2FA as it's not currently enabled"
            };
        }
    
        var result = await _userManager.SetTwoFactorEnabledAsync(user, false);
    
        return new ResultVM
        {
            Status = result.Succeeded ? Status.Success : Status.Error,
            Message = result.Succeeded ? "2FA has been successfully disabled" : $"Failed to disable 2FA {result.Errors.FirstOrDefault()?.Description}"
        };
    }
    

    Remember: This won’t affect your authenticator app configuration in your phone because nothing happens at the AspNetUserTokens table. To re-enable it, click the Re-enable 2FA button which is actually the same Setup authenticator button.

    In case you haven’t reset the authenticator before disabling the 2FA, you can use the tokens being generating by your authenticator app, they will be still valid because the authenticator key remains the same in the database. On the other hand, if you reset the authenticator, the key changes so your authenticator app gets useless and its tokens will be invalid. In this case, you need to re-configured (remove and then add) the authenticator key in your app.

    Secure authenticator key and recovery tokens

    From what we have said so far it should be clear that the web app and the authenticator app in our smartphones share the authenticator key. Leaving the default implementation for generating authenticator keys and recovery tokens can be dangerous and expose your accounts to potential hackers. The reason is that everyone that gets access to the authenticator key in the database can create and use valid codes, hence access your account. Databases can be compromised in many ways such as SQL Injection. First, let’s confirm that we can generate valid authenticator codes without the help of the authenticator app in our smartphones. Check the Show possible verification codes checkbox in the QR Code screen and see what happens.

    You can click on one of the codes returned and confirm that verification will pass successfully. Moreover, the most interesting thing is that your authenticator app in your phone will always display one of the codes you see in green…
    When the checkbox is checked, a polling to the ValidAutheticatorCodes action method will start.

    [HttpGet]
    [Authorize]
    public async Task<List<int>> ValidAutheticatorCodes()
    {
        List<int> validCodes = new List<int>();
    
        var user = await _userManager.GetUserAsync(User);
    
        var key = await _userManager.GetAuthenticatorKeyAsync(user);
    
        var hash = new HMACSHA1(Infrastructure.Identity.Internals.Base32.FromBase32(key));
        var unixTimestamp = Convert.ToInt64(Math.Round((DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0)).TotalSeconds));
        var timestep = Convert.ToInt64(unixTimestamp / 30);
        // Allow codes from 90s in each direction (we could make this configurable?)
        for (int i = -2; i <= 2; i++)
        {
            var expectedCode = Infrastructure.Identity.Internals.Rfc6238AuthenticationService.ComputeTotp(hash, (ulong)(timestep + i), modifier: null);
            validCodes.Add(expectedCode);
        }
    
        return validCodes;
    }
    

    Do you remember this code? That’s the exact code used in the ValidateAsync method we described before! Of course Base32 and Rfc6238AuthenticationService classes were internal so I fetched them to our app for demonstration reasons. To be honest, I already feel sick knowing that even if I have 2FA enabled my account could be compromised that easy. So can we do something about it? The answer is yes.

    Encrypting Authenticator key

    The first thing to enhance 2FA security is to encrypt the authenticator key stored in the database. So when a new authenticator key is created, instead of this…

    .. we wish to have this!

    Now the value is encrypted and if even if revealed to hackers they are useless because they have no clue on how to get the original value. Let’s get to the implementation for securing the authenticator key. First, you will find a TwoFactorAuthentication:EncryptionEnabled property in the appsettings.json which when equals True, tokens will be encrypted.

    "TwoFactorAuthentication:EncryptionEnabled":  true 
    

    Encryption – Decryption Disclaimer

    We won’t be doing the Ninja with the encryption & decryption here but instead we will use an open-source library named NETCore.Encrypt for simplicity. We will use an AES algorithm which is a symmetric-key algorithm, meaning the same key is used for both encrypting and decrypting the data. You are free though to use your own super secure encryption/decryption logic.
    AES needs a symmetric-key to setup and to get one, fire the app and navigate to http://localhost:5000/api/twofactorauthentication/aeskey. Copy the code, open a terminal, cd to the AspNetCoreIdentity folder and run the following command to set it as a secret key.

        dotnet user-secrets set "TwoFactorAuthentication:EncryptionKey" "<eas-key-here>"
        

    To encrypt the authenticator key we need to override two methods in the UserManager which means we have to create our own implementation of the UserManager and use it in ASP.NET Core Identity. In our case we created the AppUserManager.

    In the Startup configuration we have to tell Identity to use our UserManager implementation.

    services.AddIdentity<IdentityUser, IdentityRole>(config => { config.SignIn.RequireConfirmedEmail = true; })
        .AddEntityFrameworkStores<IdentityDbContext>()
        .AddUserManager<AppUserManager>()
        .AddDefaultTokenProviders();
    

    To understand which methods needs to be overridden we need to trace the ResetAuthenticatorKeyAsync which generates the authenticator tokens.

    public virtual async Task<IdentityResult> ResetAuthenticatorKeyAsync(TUser user)
    {
        ThrowIfDisposed();
        var store = GetAuthenticatorKeyStore();
        if (user == null)
        {
            throw new ArgumentNullException(nameof(user));
        }
        await store.SetAuthenticatorKeyAsync(user, GenerateNewAuthenticatorKey(), CancellationToken);
        await UpdateSecurityStampInternal(user);
        return await UpdateAsync(user);
    }
    

    The GenerateNewAuthenticatorKey is the one that does the job..

    public virtual string GenerateNewAuthenticatorKey()
                => NewSecurityStamp();
    
    private static string NewSecurityStamp()
    {
        byte[] bytes = new byte[20];
        _rng.GetBytes(bytes);
        return Base32.ToBase32(bytes);
    }
    

    So all we have to do to encrypt the authenticator key is to provide a new implementation for the GenerateNewAuthenticatorKey method. Back to the AppUserManager..

    public override string GenerateNewAuthenticatorKey()
    {
        var originalAuthenticatorKey = base.GenerateNewAuthenticatorKey();
    
        // var aesKey = EncryptProvider.CreateAesKey();
    
        bool.TryParse(_configuration["TwoFactorAuthentication:EncryptionEnabled"], out bool encryptionEnabled);
    
        var encryptedKey = encryptionEnabled
            ? EncryptProvider.AESEncrypt(originalAuthenticatorKey, _configuration["TwoFactorAuthentication:EncryptionKey"])
            : originalAuthenticatorKey;
    
        return encryptedKey;
    }
    

    The code first creates the default key and then if encryption is enabled, encrypts it. This is not the only thing required to secure the authenticator key. If you leave it as is you won’t be able to generate or verify tokens because the app at this point doesn’t know how to deal with the encrypted value. The next step is to provide an override for reading the encrypted authenticator key. We have already mentioned the ValidateAsync method that validates the tokens being created by the authenticator app.

    public virtual async Task<bool> ValidateAsync(string purpose, string token, UserManager<TUser> manager, TUser user)
    {
        var key = await manager.GetAuthenticatorKeyAsync(user);
        int code;
        if (!int.TryParse(token, out code))
        {
            return false;
        }
    
        var hash = new HMACSHA1(Base32.FromBase32(key));
        var unixTimestamp = Convert.ToInt64(Math.Round((DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0)).TotalSeconds));
        var timestep = Convert.ToInt64(unixTimestamp / 30);
        // Allow codes from 90s in each direction (we could make this configurable?)
        for (int i = -2; i <= 2; i++)
        {
            var expectedCode = Rfc6238AuthenticationService.ComputeTotp(hash, (ulong)(timestep + i), modifier: null);
            if (expectedCode == code)
            {
                return true;
            }
        }
        return false;
    }
    

    All we have to do is override the UserManager.GetAuthenticatorKeyAsync method. Back to the AppUserManager

    public override async Task<string> GetAuthenticatorKeyAsync(IdentityUser user)
    {
        var databaseKey = await base.GetAuthenticatorKeyAsync(user);
    
        if (databaseKey == null)
        {
            return null;
        }
    
        // Decryption
        bool.TryParse(_configuration["TwoFactorAuthentication:EncryptionEnabled"], out bool encryptionEnabled);
    
        var originalAuthenticatorKey = encryptionEnabled
            ? EncryptProvider.AESDecrypt(databaseKey, _configuration["TwoFactorAuthentication:EncryptionKey"])
            : databaseKey;
    
        return originalAuthenticatorKey;
    }
    

    The code gets the authenticator key existing in the store and if encryption is enabled returns the decrypted one. That’s all you have to do to support encrypted authentication keys.

    Encrypting recovery codes

    Encrypting the recovery codes follows almost the same logic. Instead of having hard coded the codes in the store we wish to end up in an encrypted text as follow.

    We override the way each recovery token is created, not all tokens together by providing an implementation for the CreateTwoFactorRecoveryCode in the AppUserManager.

    protected override string CreateTwoFactorRecoveryCode()
    {
        var originalRecoveryCode = base.CreateTwoFactorRecoveryCode();
    
        bool.TryParse(_configuration["TwoFactorAuthentication:EncryptionEnabled"], out bool encryptionEnabled);
    
        var encryptedRecoveryCode = encryptionEnabled
            ? EncryptProvider.AESEncrypt(originalRecoveryCode, _configuration["TwoFactorAuthentication:EncryptionKey"])
            : originalRecoveryCode;
    
        return encryptedRecoveryCode;
    }
    

    First we create the recovery token in the way it used to and then if encryption is enabled we encrypt it. The next step is to re-implement the GenerateNewTwoFactorRecoveryCodesAsync which returns the plain-text list of recovery tokens generated. We want this one because each recovery token in the database is now encrypted.

    public override async Task<IEnumerable<string>> GenerateNewTwoFactorRecoveryCodesAsync(IdentityUser user, int number)
    {
        var tokens = await base.GenerateNewTwoFactorRecoveryCodesAsync(user, number);
    
        var generatedTokens = tokens as string[] ?? tokens.ToArray();
        if (!generatedTokens.Any())
        {
            return generatedTokens;
        }
    
        bool.TryParse(_configuration["TwoFactorAuthentication:EncryptionEnabled"], out bool encryptionEnabled);
    
        return encryptionEnabled
            ? generatedTokens
                .Select(token =>
                    EncryptProvider.AESDecrypt(token, _configuration["TwoFactorAuthentication:EncryptionKey"]))
            : generatedTokens;
    
    }
    

    The last step for completing the recovery token encryption is to override the RedeemTwoFactorRecoveryCodeAsync method in the AppUserManager. This method verifies a recovery token provided by the user. The logic here is slightly different than the authenticator key meaning that the recovery code provided by the user must be encrypted (not decrypted) in order to be matched with one existing in the store.

    public override Task<IdentityResult> RedeemTwoFactorRecoveryCodeAsync(IdentityUser user, string code)
    {
        bool.TryParse(_configuration["TwoFactorAuthentication:EncryptionEnabled"], out bool encryptionEnabled);
    
        if (encryptionEnabled && !string.IsNullOrEmpty(code))
        {
            code = EncryptProvider.AESEncrypt(code, _configuration["TwoFactorAuthentication:EncryptionKey"]);
        }
    
        return base.RedeemTwoFactorRecoveryCodeAsync(user, code);
    }
    

    Discussion

    You must be aware that switching encryption from enabled to disabled or vice versa will break the two-factor authentication feature in your app. For example, if an authenticator key and the recovery tokens were created when the encryption was enabled and you decide to disable the encryption, then the user won’t be able to sign in. It means that this is a one-time decision to be made rather than switching it any time you want. My recommendation is always use the encryption and in case your app already supports 2FA with its default implementation, migrate to the encryption by running any required scripts in your database.

    You probably noticed that when you checked the Show possible verification codes 5 valid codes are always displayed and every few seconds a new one is added. This sliding behavior is explained in a comment at the original code..

    public virtual async Task<bool> ValidateAsync(string purpose, string token, UserManager<TUser> manager, TUser user)
    {
        var key = await manager.GetAuthenticatorKeyAsync(user);
        int code;
        if (!int.TryParse(token, out code))
        {
            return false;
        }
     
        var hash = new HMACSHA1(Base32.FromBase32(key));
        var unixTimestamp = Convert.ToInt64(Math.Round((DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0)).TotalSeconds));
        var timestep = Convert.ToInt64(unixTimestamp / 30);
        // Allow codes from 90s in each direction (we could make this configurable?)
        for (int i = -2; i <= 2; i++)
        {
            var expectedCode = Rfc6238AuthenticationService.ComputeTotp(hash, (ulong)(timestep + i), modifier: null);
            if (expectedCode == code)
            {
                return true;
            }
        }
        return false;
    }
    

    I really enjoyed the question “we could make this configurable?”.. Of course, the reason you get 5 valid codes is that there are 5 iterations from -2 to 2. If we could change this to -1 to 1 then we would get only 3 valid codes at a time which is kind of more strict. Anyway, the reason I am mentioning this is that sometimes while typing the code generated by the authenticator on your phone, you might believe it won’t work cause it seems to be expired on the app right before you have actually managed to enter it. You will be surprised though that most of the times it actually works due to this 90s in each direction.. If the code you see in your app is at the middle of the list presented above then you probably have more time than you think to use it. If though the code is the one to be replaced on the next iteration then make sure to enter it as long as you see it in the authenticator.

    Another thing to keep in mind is the white area around the QR Code. This is not for our eyes, it’s a requirement for the authenticator apps to work and detect the QR Code properly. Always remember to leave some extra white pixels around the QR Code

    That’s it, we have finished! We have implemented all the 2FA related tasks in our app and explored what happens at the store level. Last but not least we saw how to enhance 2FA security by encrypting the authenticator tokens and recovery codes being generating in the store.

    In case you find my blog’s content interesting, register your email to receive notifications of new posts and follow chsakell’s Blog on its Facebook or Twitter accounts.

    Facebook Twitter
    .NET Web Application Development by Chris S.
    facebook twitter-small
    twitter-small
    Viewing all 42 articles
    Browse latest View live