Skip to content

Latest commit

 

History

History
719 lines (624 loc) · 27.2 KB

4. Add auth features.md

File metadata and controls

719 lines (624 loc) · 27.2 KB

Add ability to log in to the website

In this module we're going to add the capability for users to register and sign-in on the front-end web app with a username and password. We'll do this using ASP.NET Core Identity.

Scaffold in ASP.NET Core Identity and Default UI

We'll start by scaffolding the default Identity experience into the front-end web app.

Adding Identity using Visual Studio

  1. Right-mouse click on the FrontEnd project in Solution Explorer and select "Add" and then "New Scaffolded Item..."
  2. Select the "Identity" category from the left-hand menu and then select "Identity" from the list and click the "Add" button
  3. In the "Add Identity" dialog, click the '+' button to add a data context class. Call it FrontEnd.Data.IdentityDbContext.
  4. In the same dialog, click the '+' button to add a user class. Call it FrontEnd.Data.User.
  5. Click the "Add" button

Adding Identity via the Command Line

TODO

Organize the newly created files

Note the new files added to the project in the "Areas/Identity" folder. We're going to clean these up a little to better match this project's conventions.

  1. Staying in IdentityHostingStartup.cs, add a call to configure the Identity UI to use Bootstrap 4:
    services.AddDefaultIdentity<User>()
        .AddDefaultUI(UIFramework.Bootstrap4)
        .AddEntityFrameworkStores<IdentityDbContext>();
  2. Delete the _ValidationScriptsPartial.cshtml file in the /Areas/Identity/Pages folder, as we already have one in our project's regular pages folder.
  3. Delete the ScaffoldingReadme.txt file.

Add the Identity links to the site header

The scaffolded out Identity system includes a Razor partial view that contains the Identity-related UI for the site header, e.g. Login and Register links, user name once logged in, etc. We need to add a call to this partial from our site's own layout page:

  1. Open the _Layout.cs file and find the following line: <div class="navbar-collapse collapse d-sm-inline-flex flex-sm-row-reverse">
  2. Immediately after this line, add a call to render the newly added _LoginPartial.cshtml using the <partial /> Tag Helper:
    <div class="navbar-collapse collapse d-sm-inline-flex flex-sm-row-reverse">
        <partial name="_LoginPartial" />
        <ul class="navbar-nav flex-grow-1">
            <li class="nav-item">
                <a class="nav-link text-dark" asp-area="" asp-page="/Index">Home</a>
            </li>
        </ul>
    </div>

Update the app to support admin users

Identity supports simple customization of the classes representing users, and when using the default Entity Framework Core, these changes will result in automatic schema updates for storage. We can also customize the default Identity UI by just scaffolding in the pages we want to change. Let's add the ability to create an admin user.

Customize the User class to support admin users

  1. Open the newly created User class in the /Areas/Identity/Data folder
  2. Add a bool property called IsAdmin to indicate whether the user is an admin:
    public class User : IdentityUser
    {
        public bool IsAdmin { get; set; }
    }

Generate the Entity Framework migration for our Identity schema

Now that we've modified the Identity model, we need to create the Entity Framework migration to create the matching database schema

  1. Ensure the project builds successfully
  2. Open a command prompt and run the following command to create the migration: dotnet ef migrations add CreateIdentitySchema
  3. Run the following command to create and update the database: dotnet ef database update

Add the authentication middleware

We need to ensure that the request pipeline contains the Authentication middleware before any other middleware that represents resources we want to potentially authorize, e.g. Razor Pages

  1. Open the Startup.cs file
  2. In the Configure method, add a call to add the Authentication middleware before the call that adds MVC:
    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        else
        {
            app.UseExceptionHandler("/Error");
            app.UseHsts();
        }
    
        app.UseHttpsRedirection();
        app.UseStaticFiles();
    
        app.UseAuthentication();
    
        app.UseMvc();
    }

Allow creation of an admin user

Let's make it so the site allows creation of an admin user when there isn't one already, but only if the user also has a special single-use creation key. That way, we can easily create an admin user without access to the database when we first run the app in any environment.

  1. Create a new class AdminService in the Services folder. This class will be responsible for managing the creation key generation and tracking whether the site should allow creating admin users.
  2. Add code to the class that will create an appropriately long creation key and expose it via a property:
    private readonly Lazy<long> _creationKey = new Lazy<long>(() => BitConverter.ToInt64(Guid.NewGuid().ToByteArray(), 7));
    private readonly IdentityDbContext _dbContext;
    private bool _adminExists;
    
    public AdminService(IdentityDbContext dbContext)
    {
        _dbContext = dbContext;
    }
    
    public long CreationKey => _creationKey.Value;
    
    public async Task<bool> AllowAdminUserCreationAsync()
    {
        if (_adminExists)
        {
            return false;
        }
        else
        {
            if (await _dbContext.Users.AnyAsync(user => user.IsAdmin))
            {
                // There are already admin users so disable admin creation
                _adminExists = true;
                return false;
            }
    
            // There are no admin users so enable admin creation
            return true;
        }
    }
  3. Extract an interface from the class and call it IAdminService
    public interface IAdminService
    {
        long CreationKey { get; }
    
        Task<bool> AllowAdminUserCreationAsync();
    }
  4. In the Startup class, modify the ConfigureServices method to add the new service to the DI container:
    services.AddSingleton<IAdminService, AdminService>();

We now need to override the default Register page to add UI for accepting the admin creation key and verifying it when processing the request

  1. Run the Identity scaffolder again, but this time select the Account\Register page in the list of files to override and select the IdentityDbContext (FrontEnd.Data)
  2. Update the RegisterModel class in the Register.cshtml.cs file to accept IAdminService and IdentityDbContext parameters and save them to local members:
    [AllowAnonymous]
    public class RegisterModel : PageModel
    {
        private readonly SignInManager<User> _signInManager;        
        private readonly UserManager<User> _userManager;
        private readonly ILogger<RegisterModel> _logger;
        private readonly IEmailSender _emailSender;
        private readonly IAdminService _adminService;
        private readonly IdentityDbContext _dbContext;
    
        public RegisterModel(
            UserManager<User> userManager,
            SignInManager<User> signInManager,
            ILogger<RegisterModel> logger,
            IEmailSender emailSender,
            IAdminService adminService,
            IdentityDbContext dbContext)
        {
            _userManager = userManager;
            _signInManager = signInManager;
            _logger = logger;
            _emailSender = emailSender;
            _adminService = adminService;
            _dbContext = dbContext;
        }
    
        ...
  3. Add a bool proprety to the page model to indicate to the page whether admin creation is currently allowed:
    public bool AllowAdminCreation { get; set; }
  4. Add a bool property to to the page's InputModel class to capture when the incoming request wants to attempt to create an admin user:
    [DataType(DataType.Password)]
    [Display(Name = "Admin creation key")]
    public long? AdminCreationKey { get; set; }
  5. Add code to the OnGet method to use the IAdminService to see if admin creation is enabled and log the creation key if so. You'll also need to change the method to be async by updating the method signature to the following: public async Task OnGetAsync(string returnUrl = null)
    if (await _adminService.AllowAdminUserCreationAsync())
    {
        AllowAdminCreation = true;
        _logger.LogInformation("Admin creation is enabled. Use the following key to create an admin user: {adminKey}", _adminService.CreationKey);
    }
  6. Add code to the OnPostAsync that marks the new user as an admin if the admin creation key was submitted and matches the in the IAdminService, before creating the user:
    if (await _adminService.AllowAdminUserCreationAsync() && Input.AdminCreationKey == _adminService.CreationKey)
    {
        // Set as admin user
        user.IsAdmin = true;
    }
    
    var result = await _userManager.CreateAsync(user, Input.Password);
  7. Update the code that logs a message when users are created to indicate when an admin user is created:
    if (user.IsAdmin)
    {
        _logger.LogInformation("Admin user created a new account with password.");
    }
    else
    {
        _logger.LogInformation("User created a new account with password.");
    }
  8. Update the registration form to allow entering the admin creation key by adding a text input to the end of Register.cshtml:
    ...
        <div class="form-group">
    	<label asp-for="Input.ConfirmPassword"></label>
    	<input asp-for="Input.ConfirmPassword" class="form-control" />
    	<span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span>
        </div>
        @if (Model.AllowAdminCreation)
        {
    	<div class="form-group">
    	    <label asp-for="Input.AdminCreationKey"></label>
    	    <input asp-for="Input.AdminCreationKey" class="form-control" />
    	    <span asp-validation-for="Input.AdminCreationKey" class="text-danger"></span>
    	</div>
        }
        <button type="submit" class="btn btn-primary">Register</button>
    </form>
    ...

If you run the app at this point, you'll see an exception stating that you can't inject a scoped type into a type registered as a singleton. This is the DI system protecting you from a common anti-pattern that can arise when using IoC containers. Let's fix the AdminService to use the scoped IdentityDbContext correctly.

  1. Open the AdminService.cs file and change the code to accept an IServiceProvider instead of the IdentityDbContext in its constructor:
    public class AdminService : IAdminService
    {
        private readonly Lazy<long> _creationKey = new Lazy<long>(() => BitConverter.ToInt64(Guid.NewGuid().ToByteArray(), 7));
        private readonly IServiceProvider _serviceProvider;
    
        private bool _adminExists;
    
        public AdminService(IServiceProvider serviceProvider)
        {
            _serviceProvider = serviceProvider;
        }
    
        // ...
  2. Now update the AllowAdminUserCreationAsync method to create a service scope so we can ask for an instance of the IdentityDbContext within a scoped context:
    public async Task<bool> AllowAdminUserCreationAsync()
    {
        if (_adminExists)
        {
            return false;
        }
        else
        {
            using (var scope = _serviceProvider.CreateScope())
            {
                var dbContext = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
    
                if (await dbContext.Users.AnyAsync(user => user.IsAdmin))
                {
                    // There are already admin users so disable admin creation
                    _adminExists = true;
                    return false;
                }
    
                // There are no admin users so enable admin creation
                return true;
            }
        }
    }
  3. Re-launch the application and now you shouldn't get an exception.

Adding admin section

Add an admin policy

Rather than looking up the user in the database each time the app needs to check if a user is an admin, we can read this information once when the user logs in, then store it as an additional claim on the user identity. We also need to add an authoriation policy to the app that corresponds to this claim, that we can use to protect resources we only want admins to be able to access.

  1. Add a new class ClaimsPrincipalFactory in the /Areas/Identity folder and add code that adds an admin claim for users who are admins:

    public class ClaimsPrincipalFactory : UserClaimsPrincipalFactory<User>
    {
        private readonly IApiClient _apiClient;
    
        public ClaimsPrincipalFactory(IApiClient apiClient, UserManager<User> userManager, IOptions<IdentityOptions> optionsAccessor)
            : base(userManager, optionsAccessor)
        {
            _apiClient = apiClient;
        }
    
        protected override async Task<ClaimsIdentity> GenerateClaimsAsync(User user)
        {
            var identity = await base.GenerateClaimsAsync(user);
    
            if (user.IsAdmin)
            {
                identity.MakeAdmin();
            }
    
            return identity;
        }
    }
  2. Register the custom UserClaimsPrincipalFactory<User> in the IdentityHostingStartup class. You can also take this opportunity to tweak the default password policy to be less or more strict if you wish:

    services.AddDefaultIdentity<User>(options =>
    {
        options.Password.RequireDigit = false;
        options.Password.RequiredLength = 1;
        options.Password.RequiredUniqueChars = 0;
        options.Password.RequireLowercase = false;
        options.Password.RequireUppercase = false;
        options.Password.RequireNonAlphanumeric = false;
    })
    .AddEntityFrameworkStores<IdentityDbContext>()
    .AddClaimsPrincipalFactory<ClaimsPrincipalFactory>();
  3. Add a new class file AuthHelpers.cs in the Infrastructure folder and add the following helper methods for reading and setting the admin claim:

    namespace FrontEnd.Infrastructure
    {
        public static class AuthConstants
        {
            public static readonly string IsAdmin = nameof(IsAdmin);
            public static readonly string IsAttendee = nameof(IsAttendee);
            public static readonly string TrueValue = "true";
        }
    }
    
    namespace System.Security.Claims
    {
        public static class AuthnHelpers
        {
            public static bool IsAdmin(this ClaimsPrincipal principal) =>
                principal.HasClaim(AuthConstants.IsAdmin, AuthConstants.TrueValue);
    
            public static void MakeAdmin(this ClaimsPrincipal principal) =>
                principal.Identities.First().MakeAdmin();
    
            public static void MakeAdmin(this ClaimsIdentity identity) =>
                identity.AddClaim(new Claim(AuthConstants.IsAdmin, AuthConstants.TrueValue));
        }
    }
    
    namespace Microsoft.Extensions.DependencyInjection
    {
        public static class AuthzHelpers
        {
            public static AuthorizationPolicyBuilder RequireIsAdminClaim(this AuthorizationPolicyBuilder builder) =>
                builder.RequireClaim(AuthConstants.IsAdmin, AuthConstants.TrueValue);
        }
    }
  4. Add authorization services with an admin policy to the ConfigureServices() method of Startup.cs that uses the just-added helper methods to require the admin claim:

    services.AddAuthorization(options =>
    {
        options.AddPolicy("Admin", policy =>
        {
            policy.RequireAuthenticatedUser()
                  .RequireIsAdminClaim();
        });
    });
  5. Add Microsoft.AspNetCore.Authorization to the list of usings in Index.cshtml.cs, then use the helper method in the page model to determine if the current user is an administrator.

    public bool IsAdmin { get; set; }
    
    public async Task OnGetAsync(int day = 0)
    {
        IsAdmin = User.IsAdmin();
    
        // More stuff here
        // ...
    }
  6. On the Index razor page, add an edit link to allow admins to edit sessions. You'll add the following code directly after the session foreach loop:

     <div class="card-footer">
         <ul class="list-inline mb-0">
     	@foreach (var speaker in session.Speakers)
     	{
     	    <li class="list-inline-item">
     		<a asp-page="Speaker" asp-route-id="@speaker.ID">@speaker.Name</a>
     	    </li>
     	}
     	@if (Model.IsAdmin)
     	{
     	    <li>
     		<a asp-page="/Admin/EditSession" asp-route-id="@session.ID" class="btn btn-default btn-xs">Edit</a>
     	    </li>
     	}
         </ul>
     </div>
  7. Add a nested Admin folder to the Pages folder then add an EditSession.cshtml razor page and EditSession.cshtml.cs page model to it.

  8. Next, we'll protect pages in the Admin folder with an Admin policy by making the following change to the services.AddMvc() call in Startup.ConfigureServices:

    services.AddMvc()
            .AddRazorPagesOptions(options =>
            {
               options.Conventions.AuthorizeFolder("/Admin", "Admin");
            })

Add a form for editing a session

  1. Change EditSession.cshtml.cs to render the session in the edit form:

    public class EditSessionModel : PageModel
    {
       private readonly IApiClient _apiClient;
    
       public EditSessionModel(IApiClient apiClient)
       {
          _apiClient = apiClient;
       }
    
       public Session Session { get; set; }
    
       public async Task OnGetAsync(int id)
       {
          var session = await _apiClient.GetSessionAsync(id);
          Session = new Session
          {
              ID = session.ID,
              ConferenceID = session.ConferenceID,
              TrackId = session.TrackId,
              Title = session.Title,
              Abstract = session.Abstract,
              StartTime = session.StartTime,
              EndTime = session.EndTime
          };
       }
    }
  2. Add the "{id}" route to the EditSession.cshtml form:

    @page "{id:int}"
    @model EditSessionModel
  3. Add the following edit form to EditSession.cshtml:

     <h3>Edit Session</h3>
    
     <form method="post" class="form-horizontal">
         <div asp-validation-summary="All" class="text-danger"></div>
         <input asp-for="Session.ID" type="hidden" />
         <input asp-for="Session.ConferenceID" type="hidden" />
         <input asp-for="Session.TrackId" type="hidden" />
         <div class="form-group">
     	<label asp-for="Session.Title" class="col-md-2 control-label"></label>
     	<div class="col-md-10">
     	    <input asp-for="Session.Title" class="form-control" />
     	    <span asp-validation-for="Session.Title" class="text-danger"></span>
     	</div>
         </div>
         <div class="form-group">
     	<label asp-for="Session.Abstract" class="col-md-2 control-label"></label>
     	<div class="col-md-10">
     	    <textarea asp-for="Session.Abstract" class="form-control"></textarea>
     	    <span asp-validation-for="Session.Abstract" class="text-danger"></span>
     	</div>
         </div>
         <div class="form-group">
     	<label asp-for="Session.StartTime" class="col-md-2 control-label"></label>
     	<div class="col-md-10">
     	    <input asp-for="Session.StartTime" class="form-control" />
     	    <span asp-validation-for="Session.StartTime" class="text-danger"></span>
     	</div>
         </div>
         <div class="form-group">
     	<label asp-for="Session.EndTime" class="col-md-2 control-label"></label>
     	<div class="col-md-10">
     	    <input asp-for="Session.EndTime" class="form-control" />
     	    <span asp-validation-for="Session.EndTime" class="text-danger"></span>
     	</div>
         </div>
         <div class="form-group">
     	<div class="col-md-offset-2 col-md-10">
     	    <button type="submit" class="btn btn-primary">Save</button>
     	    <button type="submit" asp-page-handler="Delete" class="btn btn-danger">Delete</button>
     	</div>
         </div>
     </form>
     
     @section Scripts {
         <partial name="_ValidationScriptsPartial" />
     }
  4. Add code to handle the Save and Delete button actions in EditSession.cshtml.cs:

    public async Task<IActionResult> OnPostAsync()
    {
       if (!ModelState.IsValid)
       {
           return Page();
       }
    
       await _apiClient.PutSessionAsync(Session);
    
       return Page();
    }
    
    public async Task<IActionResult> OnPostDeleteAsync(int id)
    {
       var session = await _apiClient.GetSessionAsync(id);
    
       if (session != null)
       {
           await _apiClient.DeleteSessionAsync(id);
       }
    
       return Page();
    }
  5. Add a [BindProperty] attribute to the Session property in EditSession.cshtml.cs to make sure properties get bound on form posts:

    [BindProperty]
    public Session Session { get; set; }
  6. The form should be fully functional.

Add success message to form post and use the PRG pattern

  1. Add a TempData decorated Message property and a ShowMessage property to EditSession.cshtml.cs:

    [TempData]
    public string Message { get; set; }
    
    public bool ShowMessage => !string.IsNullOrEmpty(Message);
  2. Set a success message in the OnPostAsync and OnPostDeleteAsync methods and change Page() to RedirectToPage():

    public async Task<IActionResult> OnPostAsync()
    {
       if (!ModelState.IsValid)
       {
           return Page();
       }
       
       Message = "Session updated successfully!";
    
       await _apiClient.PutSessionAsync(Session);
    
       return RedirectToPage();
    }
    
    public async Task<IActionResult> OnPostDeleteAsync(int id)
    {
       var session = await _apiClient.GetSessionAsync(id);
    
       if (session != null)
       {
           await _apiClient.DeleteSessionAsync(id);
       }
       
       Message = "Session deleted successfully!";
    
       return RedirectToPage("/Index");
    }
  3. Update EditSession.cshtml to show the message after posting. Add the following code directly below the <h3> tag at the top:

    @if (Model.ShowMessage)
    {
        <div class="alert alert-success alert-dismissible" role="alert">
            <button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span>   </button>
            @Model.Message
        </div>
    }

TempData-backed properties also flow across pages, so we can update the Index page to show the message value too, e.g. when the session is deleted

  1. Copy the message display markup from the top of the EditSession.cshtml file to the top of the Index.cshtml file:
    @if (Model.ShowMessage)
    {
        <div class="alert alert-success alert-dismissible" role="alert">
            <button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span>   </button>
            @Model.Message
        </div>
    }
  2. Copy the properties from the EditSession.cshtml.cs Page Model class file to the Index.cshtml.cs Page Model too:
    [TempData]
    public string Message { get; set; }
    
    public bool ShowMessage => !string.IsNullOrEmpty(Message);
  3. Rebuild and run the app then delete a session and observe it redirect to the home page and display the success message

Create a Tag Helper for setting authorization requirements for UI elements

We're currently using if blocks to determine whether to show parts of the UI based the user's auth policies. We can clean up this code by creating a custom Tag Helper.

  1. Create a new folder called TagHelpers in the root of the FrontEnd project. Right-click on the folder, select Add / New Item... / Razor Tag Helper. Name the Tag Helper AuthzTagHelper.cs.
  2. Modify the HtmlTargetElement attribute to bind to all elements with an "authz" attribute:
    [HtmlTargetElement("*", Attributes = "authz")]
  3. Add an additional HtmlTargetElement attribute to bind to all elements with an "authz-policy" attribute:
    [HtmlTargetElement("*", Attributes = "authz-policy")]
  4. Inject the AuthorizationService as shown:
    private readonly IAuthorizationService _authzService;
    
    public AuthzTagHelper(IAuthorizationService authzService)
    {
        _authzService = authzService;
    }
  5. Add the following properties which will represent the auth and authz attributes we're binding to:
    [HtmlAttributeName("authz")]
    public bool RequiresAuthentication { get; set; }
    
    [HtmlAttributeName("authz-policy")]
    public string RequiredPolicy { get; set; } 
  6. Add a ViewContext property:
    [ViewContext]
    public ViewContext ViewContext { get; set; }
  7. Mark the ProcessAsync method as async.
  8. Add the following code to the ProcessAsync method:
    public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {
        var requiresAuth = RequiresAuthentication || !string.IsNullOrEmpty(RequiredPolicy);
        var showOutput = false;
    
        if (context.AllAttributes["authz"] != null && !requiresAuth && !ViewContext.HttpContext.User.Identity.IsAuthenticated)
        {
            // authz="false" & user isn't authenticated
            showOutput = true;
        }
        else if (!string.IsNullOrEmpty(RequiredPolicy))
        {
            // auth-policy="foo" & user is authorized for policy "foo"
            var authorized = false;
            var cachedResult = ViewContext.ViewData["AuthPolicy." + RequiredPolicy];
            if (cachedResult != null)
            {
                authorized = (bool)cachedResult;
            }
            else
            {
                var authResult = await _authzService.AuthorizeAsync(ViewContext.HttpContext.User, RequiredPolicy);
                authorized = authResult.Succeeded;
                ViewContext.ViewData["AuthPolicy." + RequiredPolicy] = authorized;
            }
    
            showOutput = authorized;
        }
        else if (requiresAuth && ViewContext.HttpContext.User.Identity.IsAuthenticated)
        {
            // auth="true" & user is authenticated
            showOutput = true;
        }
    
        if (!showOutput)
        {
            output.SuppressOutput();
        }
    }   
  9. Register the new Tag Helper in the _ViewImports.cshtml file:
    @namespace FrontEnd.Pages
    @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
    @addTagHelper *, FrontEnd
  10. We can now update the Index.cshtml page to replace the if block which controls the Edit button's display with declarative code using our new Tag Helper. Remove the if block and add authz="true to the <li> which displays the edit button:
     <div class="card-footer">
         <ul class="list-inline mb-0">
     	@foreach (var speaker in session.Speakers)
     	{
     	    <li class="list-inline-item">
     		<a asp-page="Speaker" asp-route-id="@speaker.ID">@speaker.Name</a>
     	    </li>
     	}
     	<li authz-policy="Admin">
     	    <a asp-page="/Admin/EditSession" asp-route-id="@session.ID" class="btn btn-default btn-xs">Edit</a>
     	</li>
         </ul>
     </div>

Next: Session #5 - Add Agenda | Previous: Session #3 - Add front-end