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
- Header: Encoded as a Base64Url string.
- Payload: Encoded as a Base64Url string.
- 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 a401 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 Id
, UserName
, NormalizedUserName
, Email
, NormalizedEmail
, 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 databaseLogin
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
- Authentication at controller level
- 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 the403 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
Post a Comment