Pierre's Sweet and Savory Treats

Photo by Toa Heftiba on Unsplash

Pierre's Sweet and Savory Treats

Adding User Authentication and Authorization to .Net 7 MVC

Github repo: PierresSweetSavoryTreats
Readme includes instructions for setting up the database using migrations

Refer Week12.Solutions for practice projects in preparation for Code Review

Project Overview

Language: C#
Framework: ASP.NET MVC
Database: MySQL (Identity: Entity Framework Core)

Develop an app to manage flavors and treats. All users can view the lists of flavors and treats, and their details. Only authenticated users can create, update or delete flavors and treats, as well as add relations between them.

Problem Statements

  1. Change header and footer based on authentication

  2. Keep user logged in after registering

  3. Enable modification of user information

Introduction

Resources
Tutorials for ASP.NET Core (2021): YogiHosting
Identity on ASP.NET Core: Microsoft Docs

Skip to Solutions if introduction to C# or MVC is not required.

Assembly: Project (or app) folder. Contains setup for an app, but may not be root directory as a project may have multiple assemblies or consist of multiple smaller projects.

Every assembly has a Program.cs and AssemblyName.csproj in the app's root, alongside the folders separating controllers, models and views. If using a database, it will also have an appsettings.json.

Sample Code: Bakery.csproj

<Project Sdk="Microsoft.NET.Sdk.Web">
    <PropertyGroup>
        <TargetFramework>net7.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
    </PropertyGroup>
    <ItemGroup>
      <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="7.0.9" />
      <PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.9" />
      <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.9">
        <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
        <PrivateAssets>all</PrivateAssets>
      </PackageReference>
      <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="7.0.0" />
    </ItemGroup>
</Project>

Sample Code: Program.cs

global using System;
global using System.Collections.Generic;
global using System.Linq;
global using Microsoft.EntityFrameworkCore;
global using Microsoft.AspNetCore.Builder;
global using Microsoft.AspNetCore.Mvc;

using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Identity;
using Bakery.Models;

namespace Bakery;

class Program
{
    static void Main(string[] args)
    {

        WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

        builder.Services.AddControllersWithViews();

        builder.Services.AddDbContext<BakeryContext>(
            dbContextOptions => dbContextOptions
            .UseMySql(
                builder.Configuration["ConnectionStrings:DefaultConnection"], 
                ServerVersion.AutoDetect(builder.Configuration["ConnectionStrings:DefaultConnection"]
            )
            )
        );

        builder.Services.AddIdentity<ApplicationUser, IdentityRole>()
            .AddDefaultTokenProviders()
            .AddRoles<IdentityRole>()
            .AddEntityFrameworkStores<BakeryContext>();

        builder.Services.Configure<IdentityOptions>(options =>
        {
            options.User.RequireUniqueEmail = true;
            options.Password.RequireDigit = true;
            options.Password.RequireLowercase = true;
            options.Password.RequireNonAlphanumeric = true;
            options.Password.RequireUppercase = true;
            options.Password.RequiredLength = 8;
            options.Password.RequiredUniqueChars = 1;
        });

        WebApplication app = builder.Build();

        app.UseHttpsRedirection();
        app.UseStaticFiles();

        app.UseRouting();

        app.UseAuthentication();
        app.UseAuthorization();

        app.MapControllerRoute(
            name: "default",
            pattern: "{controller=Home}/{action=Index}/{id?}");

        app.Run();
    }
}

Many directives are made global as they are used throughout the rest of the assembly. Based on recollection, there are only one or two other files that require .Builder, so that directive should maybe be just a local directive.

Uncomment out app.UseDeveloperExceptionPage() for viewing exceptions in the browser window, when the app is run, using either dotnet run or dotnet watch run.

Sample Code: appsettings.json

{
    "ConnectionStrings": {
        "DefaultConnection": "Server=[hostname];Port=[portnumber];database=[dbname];uid=[username];pwd=[password];"
    }
}

The appsettings.json file can have multiple connections listed, but will only use one at a time. The connections are stated in the following line in Program.cs:

builder.Services.AddDbContext<FactoryContext>(
    dbContextOptions => dbContextOptions.UseMySql(
        builder.Configuration["ConnectionStrings:DefaultConnection"],
        ServerVersion.AutoDetect(builder.Configuration["ConnectionStrings:DefaultConnection"])
    )
);

A project's .gitignore and README.md go in the project root, apart from the assemblies, even if there is only one.

Database File

Sample Code: BakeryContext.cs

using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
namespace Bakery.Models;
public class BakeryContext : IdentityDbContext<ApplicationUser>
{
    public DbSet<Treat> Treats { get; set; }
    public DbSet<Flavor> Flavors { get; set; }
    public DbSet<TreatFlavor> TreatFlavors { get; set; }

    public BakeryContext(DbContextOptions options) : base(options) {}
}

Each property declared corresponds to a table in the database and is responsible for managing its operation: create, pull, update and delete. Pull = querying data to be read.

Note that when incorporating Identity, the DbContext is IdentityDbContext. It establishes the name of the model used for managing user data. Looking in the database once created, one will find that six tables, not one were created for the user. The default is the IdentityUser, but to keep track of more information than just a username, email, phone number and few other properties unrelated to personal information. Refer below.

Models

Folder in assembly: ends with .cs

  • ApplicationUser

  • Flavor

  • Treat

  • TreatFlavor

Sample Model:

namespace Bakery.Models;
public class Flavor
{
    public int FlavorId { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }

    public List<TreatFlavor> Treats { get; }
}

Models correspond to entities in a database (table). When data is pulled from the database, it is held by a model, which is used for creating, updating, and reading data. For deletion, only the id is necessary.

Sample Code: ApplicationUser

using Microsoft.AspNetCore.Identity;
namespace Bakery.Models;
public class ApplicationUser : IdentityUser
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public DateTime DOB { get; set; }
}

Inherited Properties used here:

  • Id

  • UserName

  • NormalizedUserName (generated)

  • Email

  • NormalizedEmail (generated)

Controllers

  • Home: Index

  • Inventory: Index

  • Account: Profile (GET/POST), Register (GET/POST), Login (GET/POST)

  • Treats: Create (GET/POST), Details, AddFlavor, RemoveFlavor, Edit (GET/POST), Delete

  • Flavors: Create (GET/POST), Details, AddTreat, RemoveTreat, Edit (GET/POST), Delete

Sample Controller:

using Bakery.Models;
namespace Bakery.Controllers;
public class HomeController : Controller
{
    private readonly BakeryContext _db;

    public HomeController(BakeryContext db)
    {
        _db = db;
    }

    public ActionResult Index()
    {
        Dictionary<string, object> model = new Dictionary<string, object>();
        List<Treat> treats = _db.Treats.ToList();
        List<Flavor> flavors = _db.Flavors.ToList();
        model.Add("Treats", treats);
        model.Add("Flavors", flavors);
        return View(model);
    }
}

Controllers contain methods, the names of which correspond to views. When a route is called for in the browser, the route will resemble /ControllerName/MethodName/*. The route decorator, [Http("/")], is not necessary, but shows the route as it would appear in the browser's url bar.

Controllers pass models, or data held in structures like dictionaries and lists, into the view, which then displays the data. However, the model passed acts as an ui variable that can also pass information back to another method.

Views

Shared/_Layout.cshtml

<!DOCTYPE html>
<html>
<head>
    <title>Pierre's Sweet and Savory Treats</title>
    <link rel="stylesheet" href="~/styles.css">
</head>
<body>
    @RenderBody()
</body>
</html>

A view consists of html embedded with C# code. These are, to an extent, what razor pages are. Elements can consist of html helpers (@Html.ActionLink) or tag helpers (asp-for="") on traditional elements to enable use of data and variables.

@{
    Layout = "_Layout";
}
@using Bakery.Models;
<!-- @model Flavor -->

<main>
    <ul>
        @foreach (Flavor flavor in Model)
        {
            <li>flavor.Name</li>
        }
    </ul>
</main>

Adding @model specifies an object or data type that will be generated or used for transferring data from forms. An object of this type can be passed in from the controller to populate the page. For managing multiple models, either uses a dictionary or a tuple (no need for @model), or a viewmodel.

Plain Example:
<a href="/Flavors/Details/@flavor.FlavorId">@flavor.Name</a>

Example of Html Helper:
@Html.ActionLink(@flavor.Name, "Details", "Flavors", new { id = @flavor.FlavorId })

If routing to a method within the same controller as the current page, the controller does not need to be specified.

ViewModels

Sample ViewModel:

using System.ComponentModel.DataAnnotations;
namespace Bakery.ViewModels;
public class LoginViewModel
{
    [Required]
    [Display(Name = "Username or Email")]
    public string UserNameOrEmail { get; set; }

    [Required]
    [DataType(DataType.Password)]
    public string Password { get; set; }
}

ViewModels are models for receiving and holding data that a model can't or shouldn't. Models represent entities in data, or tables in the database. While they can hold references to related entities, such as a list of TreatFlavors in Flavor, they should not contain duplicates within their entries, nor throughout the database. To avoid duplicates, such as asking for a password and then a confirmation password, use a viewmodel.

Solutions

View: Shared/_Layout.cshtml

<header>
    <h1>Pierre's Sweet and Savory Treats</h1>
    <nav>
        @if (!User.Identity.Authenticated)
        {
            <li>@Html.ActionLink("Catalog", "Index", "Home"</li>
        }
        else
        {
            <li>@User.Identity.Name</li>
            <li>@Html.ActionLink("Catalog", "Index", "Inventory")</li>
            <li>@Html.ActionLink("Profile", "Profile", "Account")</li>
            <li>@Html.ActionLink("Logout", "LogOut", "Account")</li>
        }
    </nav>
</header>

User.Identity.Authenticated is functionality provided by Identity.EntityFrameworkCore. It is not used in the controller, most likely due to use of an authentication schema for the client, enabling access to user data, but it will block use of PII (personally-identifying information). Note that PII applies to the user within the server data structure, and/or database. Identifying information like one's phone number, email and given name are still accessible. Username and system id are not.

Keep user logged in after registering

Controller: AccountController.cs (Login code)

public async Task<ActionResult> Login(LoginViewModel model)
{
    ...
    string login = model.UserNameOrEmail;
    ApplicationUser user = _db.Users.FirstOrDefault(user => user.Email == model.UserNameOrEmail);
    if (user != null)
        login = user.UserName;

    Microsoft.AspNetCore.Identity.SignInResult result = await _signinManager.PasswordSignInAsync(login, model.Password);
    if (result.Succeeded)
        return RedirectToAction("Index", "Inventory")
    else
        ...
}

Since user can login with either email or username, check if the input is the user's email. Otherwise, the login is the username.

Note that Microsoft.AspNetCore.Identity... is required for SigninResult, even if included in directives. There is a conflict with, forgotten, but may be System.Threading.Task or the main Microsoft.AspNetCore. Thus it must be specified.

Controller: AccountController.cs (Register)

public async Task<ActionResult> Register(RegisterViewModel model)
{
    ...
    ApplicationUser user = new ApplicationUser { ... };
    IdentityResult result = await _userManager.CreateAsync(user, model.Password);
    if (result.Succeeded)
    {
        Microsoft.AspNetCore.Identity.SignInResult signinresult = await _signinManager.PasswordSignInAsync(model.UserName, model.Password);
        ...
    }
    else
        ...
}

Incorporate the login code in the register if the result of creating the user is successful.

Enable modification of user information

Notes: RegisterViewModel derives from AccountViewModel, which keeps track of user information. All fields in the AccountViewModel are required, even two passwords, enabling use of an old password and new password for resetting. The AccountViewModel corresponds with the custom ApplicationUser class, which is based on the given IdentityUser, inheriting values: Id, UserName, NormalizedUserName, Email, and NormalizedEmail.

Controller: AccountController.cs (Details)

public async Task<ActionResult> Details()
{
    string userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
    ApplicationUser user = await _userManager.FindByIdAsync(userId);

    AccountViewModel model = new AccountViewModel { ... };

    return View(model);
}

The authenticated user is held in the server with Claims, an additional feature that comes with Identity. If these claims exist, the user is looked up. It can be assumed that there will be a user as the given method can only be reached once having been authenticated.

Controller: AccountController.cs (Details: POST)

public async Task<ActionResult> Details(AccountViewModel model)
{
    if (!ModelState.IsValid)
        return View(model);
    ... // get current user
    if (user.FirstName == model.FirstName && user.LastName == model.LastName
        && user.DOB == model.DOB && user.Email == model.Email && user.UserName == model.UserName)
        return View(model); // model unchanged
    if (user.UserName != model.UserName && user.Email != model.Email)
    {
        ModelState.AddModelError("", "Cannot change both username and email.");
        return View(model);
    }
    if (model.FirstName != user.FirstName)
        user.FirstName = model.FirstName;
    ... // Check for changes and update attributes
    IdentityResult result = await _userManager.UpdateAsync(user);
    if (!result.Succeeded)
        ... // Add errors to ModelState and return model
    if (!string.IsNullOrEmpty(model.Password) && !string.IsNullOrEmpty(model.NewPassword))
    {
        IdentityResult pwresult = await _userManager.ChangePasswordAsync(user, model.Password, model.NewPassword);
        ... // !result.Succeeded: Add errors to ModelState and return model
    }
    return RedirectToAction("Details");
}

Check inputs and compare them with existing information. Update the attributes, then update the user in the database. Change the password if the fields for the old password and new password are not empty or null.