Post Cover

Implementing Cookie Authentication in ASP.NET Core without Identity

ASP.NET Core Authentication Posted Jul 15, 2021

A Cookie is a small piece of data placed on the client browser, which applications can use for storing short-lived data. Cookie based authentication is a concept in which the server stores some authenticated context within the client browser and uses it to manage user sessions in the application.

In this article, let's look at how we can setup cookie based authentication in an ASP.NET Core MVC application, which doesn't use the AspNetIdentity setup for its user store.

Setting up the Project Components:

To get started, let's create a simple MVC web application called CookieReader which has one Login, Registration and a Profile page along with the default Index page.

> dotnet new mvc --name CookieReader

We'll use SQLite database for storing user information, which is handled by Entity Framework Core for access. The database is maintained in a local file called app.db. For Entity Framework Core, let's install the necessary packages:

> dotnet add package Microsoft.EntityFrameworkCore.Design
> dotnet add package Microsoft.EntityFrameworkCore.Sqlite

Once we've added these packages, we'll be now able to create and work with the entities and database context for our purpose. The Users are maintained in a table called Users and they are accessed in the application using a type called CookieUser. To keep things simple, I've marked all the required data annotations within the type, some may like it to be separated. The type looks as below:

namespace CookieReaders.Models.Entities
{
    public class CookieUser
    {
        [Key]
        public Guid Id { get; set; }
        public string EmailAddress { get; set; }
        public string PasswordHash { get; set; }
        public string Salt { get; set; }
        public string Name { get; set; }
        public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
    }
}

The CookieUser stores a combination of PasswordHash and its Salt instead of storing the password directly, which is a best practice in terms of security and integrity. Since we aren't using AspNetIdentity, we've to create user store and validation for ourselves. So we'd setup a simple user store with minimal attributes and password mechanism.

Database Context for Storing Users:

The database context that maps this class to the database is CookieReadersContext and is as below:

namespace CookieReaders.Models.Entities
{
    public class CookieReadersContext : DbContext
    {
        public CookieReadersContext(DbContextOptions options) : base(options)
        {
        }

        public DbSet<CookieUser> Users { get; set; }
    }
}

To complete the database mapping, let's register this CookieReadersContext within the ConfigureServices and run a migration for the table schema to be generated.

services.AddDbContext<CookieReadersContext>(options =>
{
    options.UseSqlite("Data Source=app.db");
});
> dotnet ef migrations add InitialCreate
> dotnet ef database update

Once these steps are complete, we'll have our database setup ready to store and retrieve Users from the store. We'll now wire up this context within our Login, Registration pages.

Repositories for keeping Business Logic:

We do it via a UserRepository type that encapsulates the validation logic. This type is also responsible for the generation and validation of passwords accordingly.

The type implements an interface IUserRepository and is as below:

namespace CookieReaders.Providers.Repositories
{
    public interface IUserRepository
    {
        CookieUserItem Register(RegisterVm model);
        CookieUserItem Validate(LoginVm model);
    }

    public class UserRepository : IUserRepository
    {
        private CookieReadersContext _db;

        public UserRepository(CookieReadersContext db)
        {
            _db = db;
        }

        public CookieUserItem Validate(LoginVm model)
        {
            var emailRecords = _db.Users.Where(x => x.EmailAddress == model.EmailAddress);

            var results = emailRecords.AsEnumerable()
            .Where(m => m.PasswordHash == Hasher.GenerateHash(model.Password, m.Salt))
            .Select(m => new CookieUserItem
            {
                UserId = m.Id,
                EmailAddress = m.EmailAddress,
                Name = m.Name,
                CreatedUtc = m.CreatedUtc
            });

            return results.FirstOrDefault();
        }

        public CookieUserItem Register(RegisterVm model)
        {
            var salt = Hasher.GenerateSalt();
            var hashedPassword = Hasher.GenerateHash(model.Password, salt);

            var user = new CookieUser
            {
                Id = Guid.NewGuid(),
                EmailAddress = model.EmailAddress,
                PasswordHash = hashedPassword,
                Salt = salt,
                Name = "Some User",
                CreatedUtc = DateTime.UtcNow
            };

            _db.Users.Add(user);
            _db.SaveChanges();

            return new CookieUserItem
            {
                UserId = user.Id,
                EmailAddress = user.EmailAddress,
                Name = user.Name,
                CreatedUtc = user.CreatedUtc
            };
        }
    }
}

It exposes two methods; one for Validating incoming login request and the other for creating new user based on registration. In both the cases the repository returns the resultant CookieUserItem object which is a projection on the CookieUser entity minus the password fields.

using System;

namespace CookieReaders.Models
{
    public class CookieUserItem
    {
        public Guid UserId { get; set; }
        public string EmailAddress { get; set; }
        public string Name { get; set; }
        public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
    }
}

Until this point, we've completed wiring up our database layer and built the necessary logic to store and validate user data for the cases of existing user login and a new user signup. We've also built interfaces that are consumed by the UI layer in the form of repositories, to decouple UI from domain.

Now we move on to the meaty portion - adding Cookie Authentication and then registering a cookie context in the case of a successful login or signup.

Auth Setup - Adding Cookie Middleware:

To get started, we add the Cookie Authentication middleware that ensures the existence of cookies and validates them. We add it as a chain on to the AddAuthentication() service, so as to specify to the AspNetCore runtime which authentication service to be employed. This is similar to adding a JwtBearer authentication service for the case of a Token based Authentication.

services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultSignOutScheme = CookieAuthenticationDefaults.AuthenticationScheme;
}).AddCookie(
    CookieAuthenticationDefaults.AuthenticationScheme, (options) =>
    {
        options.LoginPath = "/Account/Login";
        options.LogoutPath = "/Account/Logout";
    });

In the above code block, we're specifying the DefaultScheme to be used as CookieAuthenticationDefaults.AuthenticationScheme which is a constant with value "Cookies". Also within the AddCookie() service configuration we provide the Login and Logout paths. The application redirects to these respective paths in case of a Login or a Logout operation.

We add the Authentication middleware in the Configure() method so that this Authentication method is picked up and executed for every request that hits the server. This is IMPORTANT, because the entire authentication process that we're working on doesn't work if this one line isn't added in the Configure() method.

public void Configure(
    IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    else
    {
        app.UseExceptionHandler("/Home/Error");
        app.UseHsts();
    }

    app.UseHttpsRedirection();
    app.UseStaticFiles();
    
    // ::IMPORTANT::
    // add authentication middleware
    // cookie auth doesn't work
    // if this is not added
    app.UseAuthentication();

    app.UseRouting();
    
    app.UseAuthorization();

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

Configuring Controllers for Presentation:

Next, we define these Action paths, Login and Logout and wire them up with our backend repositories. Additionally, we also add two more flows: one is the Signup flow for creating new user in the database and the other is a Profile path where we'd just see the logged in user details. We need to design this Profile path such that it's accessible only for logged in users.

The Controller class that hosts these paths is as below:

namespace CookieReaders.Controllers
{
    public class AccountController : Controller
    {
        private readonly IUserRepository _userRepository;

        public AccountController(
            IUserRepository userRepository)
        {
            _userRepository = userRepository;
        }

        public IActionResult Login()
        {
            return View();
        }

        [Authorize]
        public IActionResult Profile()
        {
            return View(
                this.User.Claims.ToDictionary(
                    x => x.Type, x => x.Value));
        }

        [HttpPost]
        public async Task<IActionResult> LoginAsync(LoginVm model)
        {
            if (!ModelState.IsValid)
                return View(model);

            var user = _userRepository.Validate(model);

            if (user == null) return View(model);

            return LocalRedirect("~/Home/Index");
        }

        public IActionResult Register()
        {
            return View();
        }

        [HttpPost]
        public async Task<IActionResult> RegisterAsync(RegisterVm model)
        {
            if (!ModelState.IsValid)
                return View(model);

            var user = _userRepository.Register(model);

            return LocalRedirect("~/Home/Index");
        }

        public async Task<IActionResult> LogoutAsync()
        {
            return RedirectPermanent("~/Home/Index");
        }
    }
}

In this class we've wired up the incoming Login and Register view models and passing these data into the respective methods for validating user and creating new user into the database. The controller (UI) is completely decoupled from the backend (infra) implementation and communicates only via an abstraction (IUserRepository).

Observe that we've added Authorize attribute on top of the Profile action. Why? Because we want this Action to be invoked only when the incoming request is authenticated with the configured scheme, which is the Cookie Authentication we configured.

But are we done here? Not yet. We've just implemented logic for validating incoming login/signup request and responding back. In the next step, we need to create an AuthenticatedContext for the users who've been successfully validated or successfully signed up and then add a cookie to the browser so that a logged in session is maintained in the browser thereafter.

Setting up Authentication Context with a UserManager:

We maintain this functionality inside a class called UserManager. This UserManager type implements IUserManager that defines two methods; SignIn and SignOut. The SignIn method contains logic for creating a LoginContext for the authenticated user and creates the cookie in the browser. The SignOut method removes this created cookie and logs out the user.

namespace CookieReaders.Providers
{
    public interface IUserManager
    {
        Task SignIn(
            HttpContext httpContext, 
            CookieUserItem user, 
            bool isPersistent = false);
        
        Task SignOut(HttpContext httpContext);
    }
}

Let's first look at the SignIn method implementation. This is a very simple approach although sounds complicated. We follow the below steps:

  1. Create Claims based on the Authenticated User information
  2. Create a ClaimsIdentity based on these Claims
  3. Create a ClaimsPrincipal for this Identity
  4. pass this ClaimsPrincipal to the HttpContext.SignIn method, along with the AuthenticationScheme; which in our case is "Cookies" or CookieAuthenticationDefaults.AuthenticationScheme

That's all to it.

public async Task SignIn(
    HttpContext httpContext, 
    CookieUserItem user, 
    bool isPersistent = false)
{
    string authenticationScheme = CookieAuthenticationDefaults.AuthenticationScheme;

    // Generate Claims from DbEntity
    var claims = GetUserClaims(user);

    ClaimsIdentity claimsIdentity = new ClaimsIdentity(
            claims, authenticationScheme);

    ClaimsPrincipal claimsPrincipal = new ClaimsPrincipal(
            claimsIdentity);

    var authProperties = new AuthenticationProperties
    {
        // AllowRefresh = <bool>,
        // Refreshing the authentication session should be allowed.
        // ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(10),
        // The time at which the authentication ticket expires. A 
        // value set here overrides the ExpireTimeSpan option of 
        // CookieAuthenticationOptions set with AddCookie.
        // IsPersistent = true,
        // Whether the authentication session is persisted across 
        // multiple requests. Required when setting the 
        // ExpireTimeSpan option of CookieAuthenticationOptions 
        // set with AddCookie. Also required when setting 
        // ExpiresUtc.
        // IssuedUtc = <DateTimeOffset>,
        // The time at which the authentication ticket was issued.
        // RedirectUri = "~/Account/Index"
        // The full path or absolute URI to be used as an http 
        // redirect response value.
    };

    await httpContext.SignInAsync(
        authenticationScheme, 
        claimsPrincipal, 
        authProperties);
}

private List<Claim> GetUserClaims(CookieUserItem user)
{
    List<Claim> claims = new List<Claim>();
    claims.Add(new Claim(ClaimTypes.NameIdentifier, user.UserId.ToString()));
    claims.Add(new Claim(ClaimTypes.Name, user.Name));
    claims.Add(new Claim(ClaimTypes.Email, user.EmailAddress));
    return claims;
}

We pass certain AuthenticationProperties along with the ClaimsPrincipal while signing in. These properties configure several things such as the cookie lifetime, the url to be redirected once the context is authenticated, and so on. For now we don't set any of these values which leaves them to their default settings.

We then call this method inside our Login and Signup methods of the controller.

public class AccountController : Controller
{
    private readonly IUserManager _userManager;
    private readonly IUserRepository _userRepository;

    public AccountController(
        IUserManager userManager, 
        IUserRepository userRepository)
    {
        _userManager = userManager;
        _userRepository = userRepository;
    }

    [HttpPost]
    public async Task<IActionResult> LoginAsync(LoginVm model)
    {
        if (!ModelState.IsValid)
            return View(model);

        var user = _userRepository.Validate(model);

        if (user == null) return View(model);

        // +1 line added for SignIn
        await _userManager.SignIn(this.HttpContext, user, false);

        return LocalRedirect("~/Home/Index");
    }

    [HttpPost]
    public async Task<IActionResult> RegisterAsync(RegisterVm model)
    {
        if (!ModelState.IsValid)
            return View(model);

        var user = _userRepository.Register(model);

        // +1 line added for SignIn
        await _userManager.SignIn(this.HttpContext, user, false);

        return LocalRedirect("~/Home/Index");
    }
}

For SignOut, we just need to invalidate this context so that the cookie is removed. The SignOut method in the UserManager does the same. We'd pass the AuthenticationScheme on which this SignOut happens.

public async Task SignOut(HttpContext httpContext)
{
    await httpContext.SignOutAsync(
        CookieAuthenticationDefaults.AuthenticationScheme);
}

Conditional Menu based on Auth Context:

To verify that this is all working, we'd have a condition based menu system in the application navbar, which shows different set of menus for authenticated user.

<ul class="navbar-nav flex-grow-1">
    <li class="nav-item">
        <a class="nav-link text-dark" 
        asp-area="" asp-controller="Home" asp-action="Index">Home</a>
    </li>
    <li class="nav-item">
        <a class="nav-link text-dark" 
        asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
    </li>
@if (User.Identity.IsAuthenticated)
{
    @if (@User.Claims.Any(
            x => x.Type == System.Security.Claims.ClaimTypes.Name))
    {
        <li class="nav-item">
            <a class="nav-link text-dark" 
            asp-controller="Account" asp-action="Profile">
                @User.Claims.FirstOrDefault(
                    x => x.Type == System.Security.Claims.ClaimTypes.Name).Value
            </a>
        </li>
    }
    <li class="nav-item">
        <a class="nav-link text-dark" 
        asp-area="" asp-controller="Account" asp-action="Logout">Logout</a>
    </li>
}
else
{
    <li class="nav-item">
        <a class="nav-link text-dark" 
        asp-area="" asp-controller="Account" asp-action="Login">Login</a>
    </li>
}
</ul>

In this menu, we've added an if-else condition with razor such that for logged in users who have an Identity set in the context we'd have a link to navigate to their "Profile" which is decorated with the Authorize attribute in the controller.

Authorize attribute ensures that only authenticated access is allowed and when an unauthenticated user tries to access the link he'd be redirected to the default LoginPath configured in the middleware ("/Account/Login").

We'd also have a Logout link available for only authenticated users. unauthenticated users can see only the Login link in the menu.

A Little Advanced - Setting up a Cookie Policy:

With the recent change in the global web rules, applications are now mandated to take consent from the users for creating cookies. The users must accept the cookies that the application creates in order to continue using the application.

Now we need to add a little logic to our application so that the application would request consent from the user to create cookie while using the application. For this we configure the CookiePolicy options available within the Startup class as below:

services.Configure<CookiePolicyOptions>(options =>
{
    options.CheckConsentNeeded = context => true;
    options.MinimumSameSitePolicy = SameSiteMode.None;
});

Then we add corresponding middleware to the Configure() method.

// +1 added CookiePolicy
app.UseCookiePolicy();

app.UseAuthentication();

app.UseRouting();

app.UseAuthorization();

Now we create a simple alert popup for the users to be shown as they open the application. This alert is a partial, that checks if the user has already accepted the cookie policy. If not the message is shown and when the user clicks on Accept the policy sets up the consent in the browser. This is available until the browser is cleared off from all the cookies.

@using Microsoft.AspNetCore.Http.Features

@{
    var consentFeature = Context.Features.Get<ITrackingConsentFeature>();
    var showBanner = !consentFeature?.CanTrack ?? false;
    var cookieString = consentFeature?.CreateConsentCookie();
}

@if (showBanner)
{
    <div id="cookieConsent" class="fixed-bottom alert alert-primary text-center" style="margin:0 2rem 3.25rem 2rem;">
        We use cookies to provide you with a great user experience, analyze traffic and serve targeted
        promotions.&nbsp;&nbsp;
        <a href="~/privacy"><u>Learn More</u></a>&nbsp;&nbsp;
        <a class="text-primary font-weight-bolder" href="javascript:void(0)" data-dismiss="alert" aria-label="Close"
        data-cookie-string="@cookieString">
            Accept
        </a>
    </div>
    <script>
        (function () {
            document.querySelector("#cookieConsent a[data-cookie-string]").addEventListener("click", function (el) {
                document.cookie = el.target.dataset.cookieString;
                document.querySelector("#cookieConsent").classList.add("d-none");
            }, false);
        })();
    </script>
}

Testing in Action - How this one works?

To understand what happens behind the scenes, let's run our application. The full source code of the application we've created so far is available in the repository so you can simply pull that code.

When we run this code, we'd be taken to the Index page or the Home page. And the navbar contains the Login link. This is because we're now in an unauthenticated state. Click on the link and we're taken to the login page.

Click on the link to signup and create a new user. The application redirects to the Home page again, and now you can notice that the navbar now shows Profile and Logout links instead of Login. Also the Logged in Name is shown for the Profile link, which gives a good user experience.

So what happened? When we open the DevTools for this application and navigate to Application section, before logging in or signing up we see that there are no entries under Cookies section.

data/Admin/2021/7/prelogin_empty_cookie_1.png

When we login/signup and are redirected with an authenticated context, we see that the Cookies section now contains a cookie key-value with some encrypted value.

data/Admin/2021/7/postlogin_cookie.png

And when we accept the cookie consent alert, internally we have another cookie created with value "yes" meaning that we've accepted the consent.

data/Admin/2021/7/postconsent_cookie.png

The complete example is available at: https://github.com/referbruv/cookie-authentication-example-aspnetcore

Author-Image

Ram

I'm a full-stack developer and a software enthusiast who likes to play around with cloud and tech stack out of curiosity.

You can now show your support. 😊

We use cookies to provide you with a great user experience, analyze traffic and serve targeted promotions.   Learn More   Accept