ASP Net Rest API – Authenticate – JWT

As not everything should be available to everyone and some things need to have relation to using user. Let’s now add authentication.

The currently most used technique for this is JSON Web Token (JWT).
JWT means, that after authentication, the user is given a small JSON object aka. Token, that contains basic information about the user. This Token is then send by the user with every request and the server will just read it and take the information out of it.

Awesome is, that therefore the user is holding the key, if multiple services are offered, the user just needs to use this key over and over and the services can directly use data from within it.

BUT, you may ask: Trusting data from user is the biggest risk and a nightmare for security!

With this thought you’re absolutely right!
The way JWT deals with this, is by signing this Token and on server side always verify if the signing is done by the server (or companys certifications).
This way, the user cannot change data within this token without us noticing it or invalidating the Token.

But, still the user can see and read data from this Token. So, don’t put interna into it!

Adding JWT authentication

Mainly this will be 2 things

  • Adding Database to hold users
  • Adding Authentication

Add Database

Within Server project we’ll add packages of Entity Framework

dotnet add package Microsoft.EntityFrameworkCore.Tools

# Depending on selected Database
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package Microsoft.EntityFrameworkCore.SqlServer

# Not persistent, should be used only for Testing or temporary data
dotnet add package Microsoft.EntityFrameworkCore.InMemory

Now we can add configuration and instanciate Database Service.

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
+  "ConnectionStrings": {
+    "SQLite": "Data Source=db.sqlite"
+  }
}
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace tryout_blazor_api.Server
{
    public class ApplicationDbContext : IdentityDbContext<IdentityUser>
    {
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options)
        {
        }
    }
}
builder.Services.AddDbContext<ApplicationDbContext>(options => {
    // options.UseInMemoryDatabase("InMemoryDb");
    options.UseSqlite(builder.Configuration.GetConnectionString("SQLite"));

    // Don't do this in production!
    options.EnableSensitiveDataLogging();
});

Setting up database is done by creating migrations, where EF Core will create scripts for structual change of the database to latest entity setup.
And by actually running these updates against the database.

During development this can mostly be executed blindly, as database during development is mostly dropped and setup again anyway.
But as soon as you have persistent and valuable data wihtin the database, you should always check if the changes ef core thinks should be done, are as you expect them and won’t drop important data.

dotnet migrations add Init
dotnet database update

Current code can be found here: https://github.com/sukapx/tryout_blazor_api/commit/1374250760e267af82f302377657912172726483

Add Authentication

Add required packages to Server project.

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore

As well as classes defining the communication between Client and Server.

13 Shared/Login.cs
@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;

namespace tryout_blazor_api.Shared.Auth
{
    public class LoginModel
    {
        [Required(ErrorMessage = "User Name is required")]
        public string? Username { get; set; }

        [Required(ErrorMessage = "Password is required")]
        public string? Password { get; set; }
    }
}
8 Shared/LoginToken.cs
@@ -0,0 +1,8 @@
namespace tryout_blazor_api.Shared.Auth
{
    public class LoginToken
    {
        public string Token { get; set; }
        public DateTime Expiration { get; set; }
    }
}
17 Shared/Register.cs
@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;

namespace tryout_blazor_api.Shared.Auth
{
    public class RegisterModel
    {
        [Required(ErrorMessage = "User Name is required")]
        public string? Username { get; set; }

        [EmailAddress]
        [Required(ErrorMessage = "Email is required")]
        public string? Email { get; set; }

        [Required(ErrorMessage = "Password is required")]
        public string? Password { get; set; }
    }
}
8 Shared/Response.cs
@@ -0,0 +1,8 @@
namespace tryout_blazor_api.Shared
{
    public class Response
    {
        public string? Status { get; set; }
        public string? Message { get; set; }
    }
}
8 Shared/UserRoles.cs
@@ -0,0 +1,8 @@
namespace tryout_blazor_api.Shared.Auth
{
    public static class UserRoles
    {
        public const string Admin = "Admin";
        public const string User = "User";
    }
} 

A basic Controller for authentication handling of register and login could look like this.

using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using tryout_blazor_api.Shared;
using tryout_blazor_api.Shared.Auth;

// https://www.c-sharpcorner.com/article/jwt-authentication-and-authorization-in-net-6-0-with-identity-framework/
namespace tryout_blazor_api.Server.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class AuthController : ControllerBase
    {
        private readonly UserManager<IdentityUser> _userManager;
        private readonly RoleManager<IdentityRole> _roleManager;
        private readonly IConfiguration _configuration;

        public AuthController(
            UserManager<IdentityUser> userManager,
            RoleManager<IdentityRole> roleManager,
            IConfiguration configuration)
        {
            _userManager = userManager;
            _roleManager = roleManager;
            _configuration = configuration;
        }

        [HttpPost]
        [Route("login")]
        public async Task<IActionResult> Login([FromBody] LoginModel model)
        {
            var user = await _userManager.FindByNameAsync(model.Username);
            if (user != null && await _userManager.CheckPasswordAsync(user, model.Password))
            {
                var userRoles = await _userManager.GetRolesAsync(user);

                var authClaims = new List<Claim>
                {
                    new Claim(ClaimTypes.Name, user.UserName),
                    new Claim("ID", user.Id),
                    new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
                };

                foreach (var userRole in userRoles)
                {
                    authClaims.Add(new Claim(ClaimTypes.Role, userRole));
                }

               var token = GetToken(authClaims);

                return Ok(new
                {
                    token = new JwtSecurityTokenHandler().WriteToken(token),
                    expiration = token.ValidTo
                });
            }
            return Unauthorized();
        }

        [HttpPost]
        [Route("register")]
        public async Task<IActionResult> Register([FromBody] RegisterModel model)
        {
            var userExists = await _userManager.FindByNameAsync(model.Username);
            if (userExists != null)
                return StatusCode(StatusCodes.Status500InternalServerError, new Response { Status = "Error", Message = "User already exists!" });

            IdentityUser user = new()
            {
                Email = model.Email,
                SecurityStamp = Guid.NewGuid().ToString(),
                UserName = model.Username
            };
            var result = await _userManager.CreateAsync(user, model.Password);
            if (!result.Succeeded)
                return StatusCode(StatusCodes.Status500InternalServerError, new Response { Status = "Error", Message = "User creation failed! Please check user details and try again." });

            return Ok(new Response { Status = "Success", Message = "User created successfully!" });
        }

        private JwtSecurityToken GetToken(List<Claim> authClaims)
        {
            var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JWT:Secret"]));

            var token = new JwtSecurityToken(
                issuer: _configuration["JWT:ValidIssuer"],
                audience: _configuration["JWT:ValidAudience"],
                expires: DateTime.Now.AddHours(float.Parse(_configuration["JWT:ValidHours"])),
                claims: authClaims,
                signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256)
                );

            return token;
        }
    }
} 

Why no handler for logout? Well, to logout the user will just drop the JWT on clientside and is then unable to authenticate.
But yes there are special cases, where user needs to be disabled or access prevented earlier then the refresh interval of used JWT. So those services still need to validate if the user is allowed to access on a second way then by handed JWT.
We’ll talk about that later.

For startup we need to instantiate, add and configure authentication.

builder.Services.AddIdentity<IdentityUser, IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddDefaultTokenProviders();

builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
    options.SaveToken = true;
    options.RequireHttpsMetadata = false;
    options.TokenValidationParameters = new TokenValidationParameters()
    {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidAudience = builder.Configuration["JWT:ValidAudience"],
        ValidIssuer = builder.Configuration["JWT:ValidIssuer"],
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["JWT:Secret"]))
    };
});

. . .

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

Current code can be found here: https://github.com/sukapx/tryout_blazor_api/commit/12da341c500a0bdfc743dcb248a7669724f399e6

Leave a Reply

Your email address will not be published. Required fields are marked *