Skip to content

jporcarn/identityserver4

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 

Repository files navigation

Identityserver4 and 2FA

How to use IdentityServer4 including Asp.net Identity MVC and 2FA (2 factor authentication) to protect the API

Step by Step

  1. Create an ASP.NET Core empty project. Name it IdentityServer42FA
  2. Add local git repository
  3. Add Visual Studio .gitignore
  4. Add IdentityServer4 template

IdentityServer42FA> dotnet new is4aspid --force

  1. Do not seed the data yet
  2. Change the database provider to SqlServer
  • Change the default connection to SqlServer
{
  "ConnectionStrings": {
    // "DefaultConnection": "Data Source=AspIdUsers.db;",
    "DefaultConnection": "Integrated Security=SSPI;Persist Security Info=False;Initial Catalog=IdentityServer42FA;Data Source=.\\sqlexpress"
  }
}
  • Install Microsoft.EntityFrameworkCore.SqlServer
  • Unistall Microsoft.EntityFrameworkCore.Sqlite
  • Change Startup.cs to use SqlServer instead of SqlLite
  • Change SeedData.cs to use SqlServer instead of SqlLite
  1. Delete existing Migrations
  2. Build the project
  3. Open Package Manager Console
  4. Add Initial migration

PM> Add-Migration -Name InitialCreate

  1. Update the database

PM> Update-Database

  1. Open the Developer Command Prompt and run .\IdentityServer42FA.exe /seed to seed the data
  2. Commit your changes
  1. Open Visual Studio
  2. Create a new Asp.net Core 2.0 MVC web application with Authentication: Individual Accounts. Name it MVC2FA.
  • Name it with the same name than the previous project and place it in another folder to avoid namespace conflicts when copy/ paste
  1. Copy the folowing folders and its files from MVC2FA project to IdentityServer42FA
  • Views
  • Model
  • Services
  • Extensions
  1. Copy from MVC2FA Controllers/ManageController to IdentityServer42FA QuickStart/Manage/ManageController
  2. Copy/ Paste the missing methods from MVC2FA Controllers/AccountController to IdentityServer42FA Account/AccountController
  3. Rebuild the project and solve the conflicts
  1. Add Twitter Bootstrap client side library
  2. Reference bootstrap bundle javascript file in _Layout.cshtml
  3. Reference bootstrap css in _Layout.cshtml
  4. Migrate markup to bootstrap 4

Migrating jQuery

  1. Upgrade Client side libraries
  • jquery-validate
  • jquery-validation-unobtrusive
  1. Add reference to jquery in IdentityServer42FA\Views\Shared_Layout.cshtml
  2. Update references to jquery in _ValidationScriptsPartial.cshtml
  1. Download the qrcode.js javascript library to the wwwroot\lib folder in your project using Client Side Library Manager (libman.json)
  2. Update IdentityServer42FA/Views/Manage/EnableAuthenticator.cshtml according to the article above.
@section Scripts {
    @await Html.PartialAsync("_ValidationScriptsPartial")

    <script type="text/javascript" src="~/lib/qrcode.js"></script>

    <environment include="Development">
        <script src="~/lib/qrcodejs/qrcode.js"></script>
    </environment>
    <environment exclude="Development">
        <script src="~/lib/qrcodejs/qrcode.min.js"></script>
    </environment>

    <script type="text/javascript">
        new QRCode(document.getElementById("qrCode"),
            {
                text: "@Html.Raw(Model.AuthenticatorUri)",
                width: 150,
                height: 150
            });
    </script>
}

Adding support for login with 2FA

  1. Update the http post Login method in IdentityServer42FA\Quickstart\Account\AccountController.cs class with code similar to the following:
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Login(LoginInputModel model, string button)
        {
            // check if we are in the context of an authorization request
            var context = await _interaction.GetAuthorizationContextAsync(model.ReturnUrl);

            // the user clicked the "cancel" button
            if (button != "login")
            {
                if (context != null)
                {
                    // if the user cancels, send a result back into IdentityServer as if they
                    // denied the consent (even if this client does not require consent).
                    // this will send back an access denied OIDC error response to the client.
                    await _interaction.DenyAuthorizationAsync(context, AuthorizationError.AccessDenied);

                    // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null
                    if (context.IsNativeClient())
                    {
                        // The client is native, so this change in how to
                        // return the response is for better UX for the end user.
                        return this.LoadingPage("Redirect", model.ReturnUrl);
                    }

                    return Redirect(model.ReturnUrl);
                }
                else
                {
                    // since we don't have a valid context, then we just go back to the home page
                    return Redirect("~/");
                }
            }

            if (ModelState.IsValid)
            {
                var result = await _signInManager.PasswordSignInAsync(model.Username, model.Password, model.RememberLogin, lockoutOnFailure: true);
                if (result.Succeeded)
                {
                    var user = await _userManager.FindByNameAsync(model.Username);
                    await _events.RaiseAsync(new UserLoginSuccessEvent(user.UserName, user.Id, user.UserName, clientId: context?.Client.ClientId));

                    if (context != null)
                    {
                        if (context.IsNativeClient())
                        {
                            // The client is native, so this change in how to
                            // return the response is for better UX for the end user.
                            return this.LoadingPage("Redirect", model.ReturnUrl);
                        }

                        // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null
                        return Redirect(model.ReturnUrl);
                    }

                    // request for a local page
                    if (Url.IsLocalUrl(model.ReturnUrl))
                    {
                        return Redirect(model.ReturnUrl);
                    }
                    else if (string.IsNullOrEmpty(model.ReturnUrl))
                    {
                        return Redirect("~/");
                    }
                    else
                    {
                        // user might have clicked on a malicious link - should be logged
                        throw new Exception("invalid return URL");
                    }
                }

                await _events.RaiseAsync(new UserLoginFailureEvent(model.Username, "invalid credentials", clientId: context?.Client.ClientId));
                ModelState.AddModelError(string.Empty, AccountOptions.InvalidCredentialsErrorMessage);

                if (result.RequiresTwoFactor)
                {
                    return RedirectToAction(nameof(LoginWith2fa), new { model.ReturnUrl, model.RememberLogin });
                }

                if (result.IsLockedOut)
                {
                    _logger.LogWarning("User account locked out.");
                    return RedirectToAction(nameof(Lockout));
                }
            }

            // something went wrong, show form with error
            var vm = await BuildLoginViewModelAsync(model);
            return View(vm);
        }
  1. Update http post LoginWith2fa method in IdentityServer42FA\Quickstart\Account\AccountController.cs class with code similar to the following:
        [HttpPost]
        [AllowAnonymous]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> LoginWith2fa(LoginWith2faViewModel model, string button, bool rememberMe, string returnUrl = null)
        {
            // check if we are in the context of an authorization request
            var context = await _interaction.GetAuthorizationContextAsync(returnUrl);

            // the user clicked the "cancel" button
            if (button != "login")
            {
                if (context != null)
                {
                    // if the user cancels, send a result back into IdentityServer as if they
                    // denied the consent (even if this client does not require consent).
                    // this will send back an access denied OIDC error response to the client.
                    await _interaction.DenyAuthorizationAsync(context, AuthorizationError.AccessDenied);

                    // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null
                    if (context.IsNativeClient())
                    {
                        // The client is native, so this change in how to
                        // return the response is for better UX for the end user.
                        return this.LoadingPage("Redirect", returnUrl);
                    }

                    return Redirect(returnUrl);
                }
                else
                {
                    // since we don't have a valid context, then we just go back to the home page
                    return Redirect("~/");
                }
            }

            if (!ModelState.IsValid)
            {
                return View(model);
            }

            var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
            if (user == null)
            {
                throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
            }

            var authenticatorCode = model.TwoFactorCode.Replace(" ", string.Empty).Replace("-", string.Empty);

            var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, rememberMe, model.RememberMachine);

            if (result.Succeeded)
            {
                _logger.LogInformation("User with ID {UserId} logged in with 2fa.", user.Id);

                // var user = await _userManager.FindByNameAsync(model.Username);
                await _events.RaiseAsync(new UserLoginSuccessEvent(user.UserName, user.Id, user.UserName, clientId: context?.Client.ClientId));

                if (context != null)
                {
                    if (context.IsNativeClient())
                    {
                        // The client is native, so this change in how to
                        // return the response is for better UX for the end user.
                        return this.LoadingPage("Redirect", returnUrl);
                    }

                    // we can trust returnUrl since GetAuthorizationContextAsync returned non-null
                    return Redirect(returnUrl);
                }

                // request for a local page
                if (Url.IsLocalUrl(returnUrl))
                {
                    return Redirect(returnUrl);
                }
                else if (string.IsNullOrEmpty(returnUrl))
                {
                    return Redirect("~/");
                }
                else
                {
                    // user might have clicked on a malicious link - should be logged
                    throw new Exception("invalid return URL");
                }

                return RedirectToLocal(returnUrl);
            }
            else if (result.IsLockedOut)
            {
                _logger.LogWarning("User with ID {UserId} account locked out.", user.Id);
                return RedirectToAction(nameof(Lockout));
            }
            else
            {
                _logger.LogWarning("Invalid authenticator code entered for user with ID {UserId}.", user.Id);
                ModelState.AddModelError(string.Empty, "Invalid authenticator code.");
                return View();
            }
        }
  1. Update IdentityServer42FA\Views\Account\LoginWith2fa.cshtml view with markup similar to the following
@model IdentityServer42FA.Models.AccountViewModels.LoginWith2faViewModel
@{
    ViewData["Title"] = "Two-factor authentication";
}

<h2>@ViewData["Title"]</h2>
<hr />
<p>Your login is protected with an authenticator app. Enter your authenticator code below.</p>
<div class="row">
    <div class="col-md-4">
        <form method="post" asp-route-returnUrl="@ViewData["ReturnUrl"]">
            <input asp-for="RememberMe" type="hidden" />
            <div asp-validation-summary="All" class="text-danger"></div>
            <div class="form-group">
                <label asp-for="TwoFactorCode"></label>
                <input asp-for="TwoFactorCode" class="form-control" autocomplete="off" />
                <span asp-validation-for="TwoFactorCode" class="text-danger"></span>
            </div>
            <div class="form-group">
                <div class="checkbox">
                    <label asp-for="RememberMachine">
                        <input asp-for="RememberMachine" />
                        @Html.DisplayNameFor(m => m.RememberMachine)
                    </label>
                </div>
            </div>
            <div class="form-group">
                <button type="submit" class="btn btn-secondary" name="button" value="login">Log in</button>
            </div>
        </form>
    </div>
</div>
<p>
    Don't have access to your authenticator device? You can
    <a asp-action="LoginWithRecoveryCode" asp-route-returnUrl="@ViewData["ReturnUrl"]">log in with a recovery code</a>.
</p>

@section Scripts {
    @await Html.PartialAsync("_ValidationScriptsPartial")
}

Check IdentityServer42FA using a JavaScript client.

  1. Clone or download jsOidc sample JsOidc
  2. Add js_oidc client to IdentityServer42FA allowed clients with code similar to the following:
        public static IEnumerable<Client> Clients =>
            new Client[]
            {

                new Client
                {
                    ClientId = "js_oidc",
                    ClientSecrets = { new Secret("8DBE4132-387F-41FC-9596-3D3BB76CB6A3".Sha256()) },
                    RequireClientSecret = false, // browser based applications can’t be trusted to securely keep the secret

                    AllowedGrantTypes = GrantTypes.Code,

                    RedirectUris = { "https://localhost:44300/callback.html", "https://localhost:44300/popup.html" },
                    PostLogoutRedirectUris = { "https://localhost:44300/index.html" },

                    AllowOfflineAccess = true,
                    AllowedScopes = { "openid", "profile", "email", "resource1.scope1", "resource2.scope1" },
                },
            };
  1. Add js_oidc uris to IdentityServer42FA default cors policy
public void ConfigureServices(IServiceCollection services)
        {
            services.AddSingleton<ICorsPolicyService>((container) =>
            {
                var logger = container.GetRequiredService<ILogger<DefaultCorsPolicyService>>();
                var cors = new DefaultCorsPolicyService(logger)
                {
                    AllowedOrigins = { "https://localhost:44300" }
                };
                return cors;
            });

            services.AddControllersWithViews();

            // ...
        }

TODO:

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published