Asp.net core JWT authentication and role-based authorization (.NET 8.0)

 




Let's take the example of an eCommerce application. It is a single-page application built with a JavaScript framework like Angular, React, Vue, or Svelte. Data is being handled through APIs. On the home page, products are displayed (e.g., the data is being fetched from the GetAllProducts API). Everyone can see these products, but not everyone can add, update, or delete them. Only authorized users can perform these actions. These tasks will be assigned to specific users. For example, the AddProduct, UpdateProduct, and DeleteProduct APIs need some kind of protection.

We have several options to protect our API. You can use cookie authentication or token-based authentication. We are not going to cover the benefits or downsides of each authentication method. Instead, we will focus only on token-based authentication. For which we are going to use JWT aka JSON WEB TOKEN.

Json Web Token

A compact, URL-safe token format that contains a set of claims and is signed using a secret key or a public/private key pair. Since RESTful APIs are stateless, so JWTs are useful in that scenario because it contains user's related information (eg. name).

Structure of a JWT

  1. Header: Encoded as a Base64Url string.
  2. Payload: Encoded as a Base64Url string. 
  3. Signature: Encoded as a Base64Url string. These parts are concatenated together with dots (.) to form the JWT string.

Example of a JWT String

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Breakdown of the Example JWT

1. Header:

{
  "alg": "HS256",
  "typ": "JWT"
}

Encoded as: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

2. Payload:

{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

Encoded as: eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.

  • sub (Subject) Description: The sub claim stands for "subject" and is used to identify the principal (user or entity) that the JWT is intended for. It is usually a unique identifier for the user in the context of the system. Example: "sub": "1234567890" indicates that the subject of this token is the user with the unique identifier 1234567890.

  • iat (Issued At) Description: The iat claim stands for "issued at" and indicates the time at which the JWT was issued. This is expressed as a Unix timestamp, which is the number of seconds that have elapsed since January 1, 1970 (midnight UTC/GMT). Example: "iat": 1516239022 means that the token was issued at the Unix time 1516239022, which corresponds to a specific date and time (e.g., "Monday, January 18, 2018 3:17:02 PM GMT-5").

  • name: name is the claim. Which contains the name of the user.It typically contains the username of the user.

3. Signature:

Created by signing the encoded header and payload with a secret key using the algorithm specified in the header (HS256 in this case). Signature: SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Authentication and Authorization

  • Authentication is the process of verifying the identity of a user or entity. It answers the question, "Who are you?".
  • Authorization is the process of determining what an authenticated user is allowed to do. It answers the question, "What can you do?"

How JWT authentication works?

  • Let's take an example. Suppose we have an API endpoint api/greetings. The client makes a request to this API endpoint, and the server returns a response.
  • Now, let's suppose the api/greetings endpoint is secured. The client cannot directly access it. If someone tries to access the endpoint without proper authorization, they will receive a 401 Unauthorized status code.
  • To gain access, the client must first use the login API endpoint. In the response, the client will receive a JSON Web Token (JWT).
  • Next, the client will call the api/greetings endpoint again, but this time, the client will include the JWT in the Authorization header.
  • In this way, the client can access the authenticated endpoints.

Now we have the breif understanding of the authentication, authorization, JWT and how authentication works. We will focus on the practical things now.

๐Ÿ’ปSource code

๐Ÿ“–GitHub Repo: https://github.com/rd003/AspnetCoreJwt

๐ŸŒฟPlease Checkout with this branch for this tutorial: jwt-access-token

Create an asp.net core API project

Create new WebAPI project with this command

 dotnet new webapi --use-controllers -n "AspnetCoreJwt"

Open the project in VS Code with this command

 code AspnetCoreJwt

Swagger UI config for VsCode

When you run project in VS Code it does not open the swagger ui by default (It does in Visual Studio). I don't know any trick for that. I found this solution in miscrosoft docs. So add this swagger config in program.cs file.

 if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    // app.UseSwaggerUI();  // replace this line with below
    // new Line
    app.UseSwaggerUI(opt =>
    {
        opt.SwaggerEndpoint("/swagger/v1/swagger.json", "v1");
        opt.RoutePrefix = string.Empty;
    });
}
⚠️Note: I have removed all the unnecessary files comes with project (eg. WeatherForecastController.cs, WeatherForecast.cs)

Create new Controller

Create a new directory named Controllers. Inside this directory, create a new file called named GreetingsController.cs

//Controllers/GreetingsController.cs

using Microsoft.AspNetCore.Mvc;

namespace AspnetCoreJwt.Controllers;

[Route("/api/greetings")]
[ApiController]
public class GreetingsController : ControllerBase
{
    [HttpGet]
    public IActionResult Get() => Ok("Hello everyone");

}

In this controller, we have a GET method named Get. You can access this method through the GET endpoint /api/greetings.

Nuget packages

We need to install few nuget packages. If you are using Visual studio, you need to work with Package manager console. If you are VS Code user, then you will use the terminal.

For vs code

If you are using ef core cli tools for the first time. Then you need to install it locally or globally. I prefer globally. So run this command.

dotnet tool install --global dotnet-ef

Otherwise run these commands to update.

dotnet tool update --global dotnet-ef

Now we need to install these packages.

dotnet add package Microsoft.EntityFrameworkCore.Design

dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore

dotnet add package Microsoft.EntityFrameworkCore.Sqlite

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

For Visual Studio

install-package Microsoft.EntityFrameworkCore.Tools

install-package Microsoft.AspNetCore.Identity.EntityFrameworkCore

install-package Microsoft.EntityFrameworkCore.SqlServer

install-package Microsoft.AspNetCore.Authentication.JwtBearer

Application User

Create a directory names Models in the root and inside this directory create a new file named ApplicationUser.cs.

//AspnetCoreJwt.Models

using Microsoft.AspNetCore.Identity;

namespace AspnetCoreJwt.Models;

public class ApplicationUser : IdentityUser
{
    public required string Name { get; set; }
}

IdentityUser is a built-in class provided by the ASP.NET Core Identity framework. It represents a user entity in the application's authentication and authorization system.The IdentityUser class typically includes properties like IdUserNameNormalizedUserNameEmailNormalizedEmail, etc. You can find these columns in the AspNetUsers table in the database.

What is I want to add some extra information about the user, like it's Name. Then you have to create a class ApplicationUser and inherit the IdentityUser class. Inside this class you can add desired propertie (eg. Name).

Db context

Inside the Models directory, create a new file named ApplicationDbContext

//AspnetCoreJwt.Models

using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace AspnetCoreJwt.Models;

public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options)
    {

    }
}

We are creating a custom database context class ApplicationDbContext, specifically tailored for our application. By inheriting from IdentityDbContext<ApplicationUser>, we ensure that our database context includes functionality for managing user-related data, such as users, roles, and claims. The constructor allows us to configure the database context with the provided options, ensuring that it is properly set up to interact with the database.

Program.cs

We need to register few services in the Program.cs file.

// Database Context
builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlite("Data Source=AspNetCoreJwt.db"));

// Identity
builder.Services
       .AddIdentity<ApplicationUser, IdentityRole>()
       .AddEntityFrameworkStores<ApplicationDbContext>()
       .AddDefaultTokenProviders();

Migration Commands

To create the database, we need to run the migration commands. Follow the command according to your IDE/Editor.

#! for vs code/.net cli
dotnet ef migrations add InitialCreate

dotnet ef database update

#! for visual studio
add-migration InitialCreate

update-database

Note: At this stage, we have created our database with required tables. A file with the name AspNetCoreJwt.db will be shown in the root directory. It is our portable database in sqlite. To open and query with database, you need a vs code extension. You can find it in the extension market place.

Let's work on JWT

In appsettings.json file, we are going to add JWT related configurations. So add these lines in the appsettings.json file.

"JWT": {
"ValidAudience": "https://localhost:7176",
"ValidIssuer": "https://localhost:7176",
"Secret": "ByYM000OLlMQG6VVVp1OH7Xzyr7gHuw1qvUC5dcGt3SNM"
 }

Here ValidAudience is the valid audience for the app and ValidIssuer is the issuer of token

Note: https://localhost:7176 is the url of our app that will run on kestrel server. You can find it in Bookstore.Api/Properties/appsettings.json.

JWT service configuration in Program.cs

Now open the program.cs file and add these lines.

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"],
        ClockSkew = TimeSpan.Zero,
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["JWT:Secret"]))
    };
}
);

Note: You have noticed a property ClockSkew above. Clock skew refers to the allowable difference in time between the server issuing the JWT (JSON Web Token) and the client receiving it. It accounts for any discrepancies in time between different systems, such as differences in system clocks or network delays. In the code snippet we provided, ClockSkew = TimeSpan.Zero; is setting the clock skew to zero, which means that there is no allowance for time differences between the server and the client. This configuration ensures that the JWT will only be considered valid if its timestamp matches exactly with the current time on the client, without any allowance for variance.

Ahentication and Authorization middlewares

In Program.cs, add the UseAuthentication() and UseAuthorization() middlewares above the MapControllers() middleware.

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

User Roles

To maintain the consistency, we are going to create constants for user Roles. Create a new file named UserRoles.cs inside the Models directory.

// Models/UserRoles.cs
namespace AspnetCoreJwt.Models;

public static class UserRoles
{
    public const string Admin = "Admin";
    public const string User = "User";
}

Signup and Login, Login response models

Create a LoginModel class inside the Models directory.

using System.ComponentModel.DataAnnotations;

namespace AspnetCoreJwt.Models;

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

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

Create SignupModel class inside the Models directory.

using System.ComponentModel.DataAnnotations;

namespace AspnetCoreJwt.Models;

public class SignupModel
{
    [Required(ErrorMessage = "Name is required")]
    public string? Name { get; set; }

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

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

Create LoginResponse.cs file inside the Models directory. Replace it's content with the below. I am using Record here, for no specific reason. If you want to use the class then you can use it.

namespace AspnetCoreJwt.Models;
public record LoginResponse(string AccessToken);

Auth Service

Let's create the directory named Services and create the file named AuthService.cs inside it. Usually, we create an interface in the separate file. But I have generated it in the same file.

// Services/AuthService.cs
namespace AspnetCoreJwt.Services;
public class AuthService : IAuthService
{
    readonly IConfiguration _configuration;
    readonly UserManager<ApplicationUser> _userManager;
    readonly RoleManager<IdentityRole> _roleManager;

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

    public async Task<(int, string)> Register(SignupModel signup, string role)
    {
        // check user exists or not
        var user = await _userManager.FindByEmailAsync(signup.Email);
        if (user != null)
        {
            return (0, "User is already exists");
        }
        ApplicationUser appUser = new()
        {
            Name = signup.Name,
            UserName = signup.Email,
            Email = signup.Email,
            SecurityStamp = Guid.NewGuid().ToString()
        };
        var createdUserResult = await _userManager.CreateAsync(appUser, signup.Password);
        if (!createdUserResult.Succeeded)
        {
            return (0, "User creation failed! Please check user details and try again.");
        }
        // create role if does not exists
        bool isRoleExists = await _roleManager.RoleExistsAsync(role);
        if (!isRoleExists)
        {
            await _roleManager.CreateAsync(new IdentityRole(role));
        }
        // add user's role
        await _userManager.AddToRoleAsync(appUser, role);
        return (1, "User created successfully!");
    }

    public async Task<(int, string)> Login(LoginModel model)
    {
        // find user by email
        ApplicationUser? user = await _userManager.FindByEmailAsync(model.Username);
        if (user == null)
        {
            return (0, "Invalid Email");
        }
        // match password
        bool isPasswordValid = await _userManager.CheckPasswordAsync(user, model.Password);
        if (!isPasswordValid)
        {
            return (0, "Invalid Password");
        }

        // get user's roles
        IList<string> userRoles = await _userManager.GetRolesAsync(user);

        // create claims
        List<Claim> claims = [
            new Claim(ClaimTypes.Name,user.UserName),
            new Claim(JwtRegisteredClaimNames.Jti,Guid.NewGuid().ToString()),
        ];
        foreach (string role in userRoles)
        {
            claims.Add(new Claim(ClaimTypes.Role, role));
        }
        string token = GenerateToken(claims);
        // generate token
        return (1, token);
    }

    private string GenerateToken(IEnumerable<Claim> claims)
    {
        var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JWT:Secret"]));

        var tokenDescriptor = new SecurityTokenDescriptor
        {
            Issuer = _configuration["JWT:ValidIssuer"],
            Audience = _configuration["JWT:ValidAudience"],
            Expires = DateTime.UtcNow.AddMinutes(2), // expires in 2 minutes
            SigningCredentials = new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256),
            Subject = new ClaimsIdentity(claims)
        };

        var tokenHandler = new JwtSecurityTokenHandler();
        var token = tokenHandler.CreateToken(tokenDescriptor);
        return tokenHandler.WriteToken(token);
    }
}

public interface IAuthService
{
    Task<(int, string)> Register(SignupModel signup, string role);
    Task<(int, string)> Login(LoginModel model);
}

You also need to add these using statements at the top.

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using AspnetCoreJwt.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens;

Now, we need to add our AuthService to the DI container. So add the line (shown below) in the Program.cs file.

builder.Services.AddTransient<IAuthService, AuthService>();

AuthService class contains two method Register and Login.

  • Register method add the user and it's role to the database
  • Login method handles the authentication and returns the JsonWebToken.

Authentication Controller

Create AuthenticationController inside the Controllers directory.

using Microsoft.AspNetCore.Mvc;

namespace AspnetCoreJwt.Controllers;

[ApiController]
public class AuthenticationController : ControllerBase
{
}

Inject the IAuthService and ILogger services to it.

using AspnetCoreJwt.Services;
using Microsoft.AspNetCore.Mvc;

namespace AspnetCoreJwt.Controllers;

[ApiController]
public class AuthenticationController : ControllerBase
{
    private readonly IAuthService _authService;
    private readonly ILogger<AuthenticationController> _logger;
    public AuthenticationController(IAuthService authService, ILogger<AuthenticationController> logger)
    {
        _authService = authService;
        _logger = logger;
    }
}

Let's create the Register method.

    [HttpPost("/register")]
    public async Task<IActionResult> Register(SignupModel model)
    {
        try
        {
            (int status, string message) = await _authService.Register(model, UserRoles.User);
            // For Admin
            // (int status, string message) = await _authService.Register(model, UserRoles.Admin);
            if (status == 1)
            {
                return Ok(new { model.Email, model.Name });
            }
            else
            {
                return BadRequest(message);
            }
        }
        catch (Exception ex)
        {
            _logger.LogError(ex.Message);
            return StatusCode(StatusCodes.Status500InternalServerError, "Something went wrong");
        }
    }

Do not forget to use this using statement at the top. using AspnetCoreJwt.Models;

Let's add the Login method.

    [HttpPost("/login")]
    public async Task<IActionResult> Login(LoginModel model)
    {
        try
        {
            (int status, string message) = await _authService.Login(model);
            if (status == 1)
            {
                return Ok(new LoginResponse(AccessToken: message));
            }
            else
            {
                return BadRequest(message);
            }
        }
        catch (Exception ex)
        {
            _logger.LogError(ex.Message);
            return StatusCode(StatusCodes.Status500InternalServerError, "Something went wrong");
        }
    }

Authorizing your endpoints

Let's secure our Greetings endpoints. We can authorize or endpoints in two ways

  1. Authentication at controller level
  2. Authenticate the individual methods

1. Authentication at controller level

If we put the [Authorize] keyword above the controller, then all the methods defined inside the Controller will be authenticated. Only an authenticated user can access them.

using AspnetCoreJwt.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace AspnetCoreJwt.Controllers;

[Route("/api/greetings")]
[Authorize]
[ApiController]
public class GreetingsController : ControllerBase
{
    [HttpGet]
    public IActionResult Get() => Ok("Hello everyone");

    [HttpPost]
    public IActionResult Post() => Ok();

}

Testing the Endpoints with postman

Open the postman and test the GET endpoint http://localhost:5227/api/greeting. In response, you will get 401 Unauthorized status code.

Test the Register endpoint

(i) Registering the user

Endpoint: http://localhost:5227/register (POST) Request body:

{
  "name": "jack",
  "email": "jack@gmail.com",
  "password": "Jack@123"
}

Response:

Status: 200 ok

{
  "email": "jack@gmail.com",
  "name": "jack"
}

(ii) Registering the Admin

Open your controller's Register method. 

Replace the line 

(int status, string message) = await _authService.Register(model, UserRoles.User);

 with 

(int status, string message) = await _authService.Register(model, UserRoles.Admin)

Now run the application and hit the http://localhost:5227/register (POST) endpoint. Remaining process will be the same as the previous one.

⚠️Note: Make sure to revert this change, once you registered the admin. Because you do not want to give the permission to create an admin account.

Test the Login endpoint

Endpoint: http://localhost:5227/login (POST)

Request body:

{
  "username": "jack@gmail.com",
  "password": "Jack@123"
}

Response:

Status: 200 ok

{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1bmlxdWVfbmFtZSI6ImphY2tAZ21haWwuY29tIiwianRpIjoiZmE5OGJmNjUtYjhhZi00NTYyLTk3ZDItMTMwM2EzMGRlMzFjIiwicm9sZSI6IlVzZXIiLCJuYmYiOjE3MTU4Nzc3MzIsImV4cCI6MTcxNTg3Nzg1MiwiaWF0IjoxNzE1ODc3NzMyLCJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo3MTc2IiwiYXVkIjoiaHR0cHM6Ly9sb2NhbGhvc3Q6NzE3NiJ9.rtf1CJTm8FZy2NkRv3eGP3CsWu9EyYhz11NaBhv3agE"
}

Test the Greeting endpoint

Endoint: http://localhost:5227/api/greetings (get)

Response: In the response you will get 401 Unauthorized Error.

Test the Greeting endpoint with token

Endpoint: http://localhost:5227/api/greetings (get)

Now, go to the Authorization tab, select Bearer Token in the Type dropdown. Set Token's value to the token you have received during login. Now hit the endpoint and you will get the 200 ok status.



What If I want to access some controller method without authentication?

Well, You can. By adding [AllowAnonymous] attribute, above the method, you can bypass the authentication for that method.

[Route("/api/greetings")]
[Authorize]
public class GreetingsController : ControllerBase
{
    // GET: api/greetings
    [HttpGet]
    public IActionResult Get() => Ok("Hello everyone");

    // GET: api/greetings/public
    [HttpGet("public")]
    [AllowAnonymous]
    public IActionResult PublicMethod() => Ok("Hello everyone");

    // POST: api/greetings
    [HttpPost]
    public IActionResult Post() => Ok("Posted successfully");

}

Now, You can call the api/greetings/public endpoint, without authentication.

2. Authenticate the individual methods

You can authenticate the specific endpoint by adding [Authorize] attribute above it.

[Route("/api/greetings")]
[ApiController]
public class GreetingsController : ControllerBase
{
    // GET: api/greetings
    [HttpGet]
    public IActionResult Get() => Ok("Hello everyone");

    // GET: api/greetings/public
    [HttpGet("public")]
    public IActionResult PublicMethod() => Ok("Hello everyone");

    // POST: api/greetings
    [HttpPost]
    [Authorize]
    public IActionResult Post() => Ok("Posted successfully");

}

Only the Post method is authenticated here.

Authorization

In some cases. You only want to expose your APIs to certain person (e.g. Admin). We have implemented role base authorization in this tutorial. If you want to expose certain/all endpoint(s) to the Admin only, then you need to replace [Authorize] with [Authorize(Roles = UserRoles.Admin)].

[Route("/api/greetings")]
[Authorize(Roles = UserRoles.Admin)]
[ApiController]
public class GreetingsController : ControllerBase
{
    // GET: api/greetings
    [HttpGet]
    public IActionResult Get() => Ok("Hello everyone");

    // GET: api/greetings/public
    [HttpGet("public")]
    [AllowAnonymous]
    public IActionResult PublicMethod() => Ok("Hello everyone");

    // POST: api/greetings
    [HttpPost]
    public IActionResult Post() => Ok("Posted successfully");

}

Test the authorized endpoint

  • First, log in with user's account and copy the token. 
  • Hit the endpoint http://localhost:5227/api/greetings (get) and pass the copied token. In the status you will get the 403 Forbidden status. Because only admin can access this endpoint. 
  • Now, login with admin account and pass the token and you will get the 200 ok status code along with the response body.

So, that was all about the authentication and role-based authorization in asp.net core web APIs with JWT. 

My handles

๐Ÿ‘‰ YouTube: https://youtube.com/@ravindradevrani

๐Ÿ‘‰ Twitter: https://twitter.com/ravi_devrani

๐Ÿ‘‰ Blog: https://ravindradevrani.blogspot.com

๐Ÿ‘‰ Medium.com: https://medium.com/@ravindradevrani

๐Ÿ‘‰ GitHub: https://github.com/rd003

Become a supporter❣️

You can buy me a coffee ๐Ÿต: https://www.buymeacoffee.com/ravindradevrani

Thanks a lot ๐Ÿ™‚๐Ÿ™‚

Comments

Popular posts from this blog

Registering Multiple Implementation With Same Interface