Introduction
Microsoft released .NET 6.0 on November 2021. I have already written couple of articles about JWT authentication on C# Corner. Since .NET 6.0 made some significant changes, I wrote one more article about JWT authentication using the .NET 6.0 version.
You can read the full article from the link below.
JWT Authentication and Authorization in .NET 6.0 with Identity Framework
Why do we need Refresh Tokens?
If we are using an access token for a long time, there is a chance a hacker can steal our token and misuse it. Hence it is not very safe to use the access token for a long period.
Refresh tokens are the kind of tokens that can be used to get new access tokens. When the access tokens expire, we can use refresh tokens to get a new access token from the authentication controller. The lifetime of a refresh token is usually much longer compared to the lifetime of an access token.
We will set a short lifetime for an access token. So that, even the access token used by a hacker gets access only for a brief period. We will issue a refresh token along with an access token from the login request. Whenever the access token expires, we can get a new access token using the refresh token.
We will be using Microsoft Identity framework to store user and role information. ASP.NET Core Identity is a membership system which allows you to add login functionality to your application. Users can create an account and login with a username and password, or they can use an external login provider such as Facebook, Google, Microsoft Account, Twitter and more.
Whenever the user login into the application using valid credentials, we will update refresh token and token expiry time in the user table inside the Identity database. After the expiry of access token, if user again tries to get secured resource from the application, it will throw 401 un-authorized error. Then the user can try to refresh the token using current access token and refresh token. In the refresh method, application will confirm expired token and refresh token. If both are valid, the application will issue a new access token and refresh token to the user. Corresponding user can use this new token to access secured resources in the application.
If something went wrong, the refresh token can be revoked which means that when the application tries to use it to get a new access token, that request will be rejected, and the user will have to enter credentials once again and authenticate.
We can see all the implementation step by step.
First, we can create a new ASP.NET Core Web API application using Visual Studio 2022.
We can install the 4 libraries below using NuGet package manager.
- Microsoft.EntityFrameworkCore.SqlServer
- Microsoft.EntityFrameworkCore.Tools
- Microsoft.AspNetCore.Identity.EntityFrameworkCore
- Microsoft.AspNetCore.Authentication.JwtBearer
We can change the appsettings.json with below values.
appsettings.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"ConnStr": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=JWTRefreshTokenDB;Integrated Security=True;ApplicationIntent=ReadWrite;MultiSubnetFailover=False"
},
"JWT": {
"ValidAudience": "http://localhost:4200",
"ValidIssuer": "http://localhost:5000",
"Secret": "JWTRefreshTokenHIGHsecuredPasswordVVVp1OH7Xzyr",
"TokenValidityInMinutes": 1,
"RefreshTokenValidityInDays": 7
}
}
We have given database connection string and a few other configuration values for JWT authentication in the above appsettings. We have given only 1 minute for access token expiration time and 7 days for refresh token expiry time. (You can change these configurations as per your requirements)
We can create a new folder “Auth” and create “ApplicationUser” class under Auth folder and add below code. We will add all the classes related to authentication under the Auth folder.
ApplicationUser.cs
using Microsoft.AspNetCore.Identity;
namespace JWTRefreshToken.NET6._0.Auth
{
public class ApplicationUser : IdentityUser
{
public string? RefreshToken { get; set; }
public DateTime RefreshTokenExpiryTime { get; set; }
}
}
In the above class, we have extended default Identity user with new properties refresh token and refresh token expiry time.
We can create new class “ApplicationDbContext” class under Auth folder and add below code.
ApplicationDbContext.cs
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace JWTRefreshToken.NET6._0.Auth
{
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
}
}
}
Above class is the core of the entity framework and used for controlling the database operations.
Create a static class “UserRoles” and add below values.
UserRoles.cs
namespace JWTRefreshToken.NET6._0.Auth
{
public static class UserRoles
{
public const string Admin = "Admin";
public const string User = "User";
}
}
We have added two constant values “Admin” and “User” as roles. You can add as many roles as you wish.
Create class “RegisterModel” for new user registration.
RegisterModel.cs
using System.ComponentModel.DataAnnotations;
namespace JWTRefreshToken.NET6._0.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; }
}
}
Create class “LoginModel” for user login purpose.
LoginModel.cs
using System.ComponentModel.DataAnnotations;
namespace JWTRefreshToken.NET6._0.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; }
}
}
We can create a class “Response” for returning the response value after user registration and user login. It will also return error messages if the request fails.
Response.cs
namespace JWTRefreshToken.NET6._0.Auth
{
public class Response
{
public string? Status { get; set; }
public string? Message { get; set; }
}
}
We can create a class “TokenModel” which will be used to pass access token and refresh token into the refresh method of the authenticate controller.
TokenModel.cs
namespace JWTRefreshToken.NET6._0.Auth
{
public class TokenModel
{
public string? AccessToken { get; set; }
public string? RefreshToken { get; set; }
}
}
We can create an API controller “AuthenticateController” inside the “Controllers” folder and add below code.
AuthenticateController.cs
using JWTRefreshToken.NET6._0.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
namespace JWTRefreshToken.NET6._0.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class AuthenticateController : ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly RoleManager<IdentityRole> _roleManager;
private readonly IConfiguration _configuration;
public AuthenticateController(
UserManager<ApplicationUser> 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(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
foreach (var userRole in userRoles)
{
authClaims.Add(new Claim(ClaimTypes.Role, userRole));
}
var token = CreateToken(authClaims);
var refreshToken = GenerateRefreshToken();
_ = int.TryParse(_configuration["JWT:RefreshTokenValidityInDays"], out int refreshTokenValidityInDays);
user.RefreshToken = refreshToken;
user.RefreshTokenExpiryTime = DateTime.Now.AddDays(refreshTokenValidityInDays);
await _userManager.UpdateAsync(user);
return Ok(new
{
Token = new JwtSecurityTokenHandler().WriteToken(token),
RefreshToken = refreshToken,
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!" });
ApplicationUser 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!" });
}
[HttpPost]
[Route("register-admin")]
public async Task<IActionResult> RegisterAdmin([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!" });
ApplicationUser 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." });
if (!await _roleManager.RoleExistsAsync(UserRoles.Admin))
await _roleManager.CreateAsync(new IdentityRole(UserRoles.Admin));
if (!await _roleManager.RoleExistsAsync(UserRoles.User))
await _roleManager.CreateAsync(new IdentityRole(UserRoles.User));
if (await _roleManager.RoleExistsAsync(UserRoles.Admin))
{
await _userManager.AddToRoleAsync(user, UserRoles.Admin);
}
if (await _roleManager.RoleExistsAsync(UserRoles.Admin))
{
await _userManager.AddToRoleAsync(user, UserRoles.User);
}
return Ok(new Response { Status = "Success", Message = "User created successfully!" });
}
[HttpPost]
[Route("refresh-token")]
public async Task<IActionResult> RefreshToken(TokenModel tokenModel)
{
if (tokenModel is null)
{
return BadRequest("Invalid client request");
}
string? accessToken = tokenModel.AccessToken;
string? refreshToken = tokenModel.RefreshToken;
var principal = GetPrincipalFromExpiredToken(accessToken);
if (principal == null)
{
return BadRequest("Invalid access token or refresh token");
}
#pragma warning disable CS8600 // Converting null literal or possible null value to non-nullable type.
#pragma warning disable CS8602 // Dereference of a possibly null reference.
string username = principal.Identity.Name;
#pragma warning restore CS8602 // Dereference of a possibly null reference.
#pragma warning restore CS8600 // Converting null literal or possible null value to non-nullable type.
var user = await _userManager.FindByNameAsync(username);
if (user == null || user.RefreshToken != refreshToken || user.RefreshTokenExpiryTime <= DateTime.Now)
{
return BadRequest("Invalid access token or refresh token");
}
var newAccessToken = CreateToken(principal.Claims.ToList());
var newRefreshToken = GenerateRefreshToken();
user.RefreshToken = newRefreshToken;
await _userManager.UpdateAsync(user);
return new ObjectResult(new
{
accessToken = new JwtSecurityTokenHandler().WriteToken(newAccessToken),
refreshToken = newRefreshToken
});
}
[Authorize]
[HttpPost]
[Route("revoke/{username}")]
public async Task<IActionResult> Revoke(string username)
{
var user = await _userManager.FindByNameAsync(username);
if (user == null) return BadRequest("Invalid user name");
user.RefreshToken = null;
await _userManager.UpdateAsync(user);
return NoContent();
}
[Authorize]
[HttpPost]
[Route("revoke-all")]
public async Task<IActionResult> RevokeAll()
{
var users = _userManager.Users.ToList();
foreach (var user in users)
{
user.RefreshToken = null;
await _userManager.UpdateAsync(user);
}
return NoContent();
}
private JwtSecurityToken CreateToken(List<Claim> authClaims)
{
var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JWT:Secret"]));
_ = int.TryParse(_configuration["JWT:TokenValidityInMinutes"], out int tokenValidityInMinutes);
var token = new JwtSecurityToken(
issuer: _configuration["JWT:ValidIssuer"],
audience: _configuration["JWT:ValidAudience"],
expires: DateTime.Now.AddMinutes(tokenValidityInMinutes),
claims: authClaims,
signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256)
);
return token;
}
private static string GenerateRefreshToken()
{
var randomNumber = new byte[64];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(randomNumber);
return Convert.ToBase64String(randomNumber);
}
private ClaimsPrincipal? GetPrincipalFromExpiredToken(string? token)
{
var tokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = false,
ValidateIssuer = false,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JWT:Secret"])),
ValidateLifetime = false
};
var tokenHandler = new JwtSecurityTokenHandler();
var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out SecurityToken securityToken);
if (securityToken is not JwtSecurityToken jwtSecurityToken || !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase))
throw new SecurityTokenException("Invalid token");
return principal;
}
}
}
In the login method, we create an access token and refresh token and return to the response of the request.
In the refresh method, we are checking the expired access token and existing token and if both are confirmed correctly then a new access token and refresh token generate and return to the response.
We have two revoke methods implemented inside the authenticate controller. One method is used to revoke a refresh token for a particular user and the other method is used to revoke refresh token for entire user inside the database.
In .NET 6.0, Microsoft removed the Startup class and only kept Program class. We must define all our dependency injection and other configurations inside the Program class.
Program.cs
using JWTRefreshToken.NET6._0.Auth;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
ConfigurationManager configuration = builder.Configuration;
// Add services to the container.
// For Entity Framework
builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(configuration.GetConnectionString("ConnStr")));
// For Identity
builder.Services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
// Adding Authentication
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
})
// Adding Jwt Bearer
.AddJwtBearer(options =>
{
options.SaveToken = true;
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = new TokenValidationParameters()
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ClockSkew = TimeSpan.Zero,
ValidAudience = configuration["JWT:ValidAudience"],
ValidIssuer = configuration["JWT:ValidIssuer"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["JWT:Secret"]))
};
});
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
// Authentication & Authorization
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
We must create a database and tables needed before running the application. As we are using entity framework, we can use the below database migration command with package manger console to create a migration script.
“add-migration Initial”
Use the command below to create database and tables.
“update-database”
If you check the database using SQL server object explorer, you can see that the tables below are created inside the database.
There are 7 tables created for User, Role and Claims during the database migration process. This is due to the Microsoft Identity framework.
We can add “Authorize” attribute inside the “WeatherForecast” controller.
We can create a new user using a register method in authenticate controller. We use Postman tool to test our entire API methods in the application.
If you look at the user table inside the database, you can see that new user created and refresh token is null and refresh token expiry time is also null.
We can again use Postman tool to login with current user credentials.
If you look at the user table again, you can see that refresh token and token expiry time is now updated with current values we received after login method.
We can use the above token to access our secured WeatherForecast controller get method.
You may notice that we have given only 1 minute as token expiry time.
After one minute, if you again try to access weatherforecast controller, you get 401 un-authorized error.
Now we can use our current access token and refresh token to generate new access token and refresh token using ”refresh-token” method inside the authenticate controller.
Please note that, after refreshing the access token and refresh token we get a new access token and refresh token. We can use this new access token within one minute to access the secured weatherforecast controller. Previously used tokens became invalid now.
If you try to refresh using existing access token and refresh token, you will get an invalid token error message.
You can use the revoke method to revoke refresh token for a particular user or all users.
Using the above method, we have revoked the refresh token for a specific user.
If you check the user table, you can see that the refresh token is null now.
If we are using any client applications (Angular / React) we can keep these access tokens and refresh tokens inside the local storage and we can handle the requests using route guards.
Conclusion
In this post, we have seen how to use refresh token along with JWT access tokens to secure our .NET Core 6.0 Web API application. Refresh tokens are extremely useful to ensure more application security. We usually give small expiration time for access tokens and after expiration, we use refresh tokens to get new access tokens. Hence, if any attacker gets this access token, they can’t use it for longer time. We also supply a revoke method to revoke refresh token for a specific user or all users. So that it will give more security to the application.