Last Updated on November 13, 2022 by Aram
In this tutorial we will learn how to Apply JWT Access Tokens and Refresh Tokens in ASP.NET Core Web API. We will build a simple, secure and reliable RESTful API project to properly authenticate users and authorize them to perform operations on the APIs.
Note: I have updated this tutorial and the GitHub source code to the latest version of .NET, which is .NET 7.
We will use the latest and greatest version of Visual Studio 2022 – Community Edition to build our ASP.NET Core Web API using the latest and fastest .NET: which is .NET 7 and C# 11.
The good news is that VS 2022 comes bundled with the latest version of .NET and C#, so you don’t have to worry about searching and installing any of them. So, if you haven’t got the VS 2022 already, make sure you download and install Visual Studio 2022 so we can get started with our tutorial to Apply JWT Access Tokens and Refresh Tokens in ASP.NET Core Web API.
In a previous tutorial we learned how to Secure ASP.NET Core Web API using JWT Authentication but that was only using access tokens. Now this is a new tutorial built from the ground up explaining everything about JWT Authentication while using both access and refresh tokens.
Access Tokens vs Refresh Tokens
We use an access token to grant a user the proper authorization to access some resources on the server when it is provided in the Authorization header. An access token is usually short-timed and signed, as for a JWT Token, this will include the signature, claims, headers.
On the other hand, a refresh token is usually a reference that can be used only to refresh the access token. Such token is usually persisted in a backend storage and can be used to revoke access for users who, for example, who are longer eligible to access these resources or in the case of a malicious user who stole an access token.
In such cases, you can just remove the refresh token for these devices, so once their access token is expired they won’t be able to renew (refresh) it using the revoked refresh token because their once-valid refresh token is no longer valid and they will no longer be able to access your resources. Therefore, the user will be signed out in the app or web so they will have to re login and go through the usual login process again.
Now enough with the rigid texts, let’s jump right into building our APIs that will implement the JWT Authentication using both access and refresh tokens using ASP.NET Core Web API in NET.
Starting the tutorial
We will build a simple tasks management System, that allows the authenticated user to manage their own tasks. This is just a simple and basic representation of the tasks management system.
Feel free to fork it from GitHub and build on it for your personal projects.
Database Preparation
For most of my tutorials, I am using SQL Server Express to create the database and the tables needed. So make sure you download and install the latest version of SQL Server Management Studio and SQL Server Express.
Once both are installed, open SQL Server Management Studio and connect to your local machine where the SQL Server Express is installed:
From the object explorer, right-click on databases and choose “Create new database” give it a name like TasksDb:
Then Run the below commands to create the table and populate it with the data required for this tutorial:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 |
USE [TasksDb] GO /****** Object: Table [dbo].[RefreshToken] Script Date: 1/18/2022 6:10:48 PM ******/ SET ANSI_NULLS ON GO SET QUOTED_IDENTIFIER ON GO CREATE TABLE [dbo].[RefreshToken]( [Id] [int] IDENTITY(1,1) NOT NULL, [UserId] [int] NOT NULL, [TokenHash] [nvarchar](1000) NOT NULL, [TokenSalt] [nvarchar](50) NOT NULL, [TS] [smalldatetime] NOT NULL, [ExpiryDate] [smalldatetime] NOT NULL, CONSTRAINT [PK_RefreshToken] PRIMARY KEY CLUSTERED ( [Id] ASC )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY] ) ON [PRIMARY] GO /****** Object: Table [dbo].[Task] Script Date: 1/18/2022 6:10:48 PM ******/ SET ANSI_NULLS ON GO SET QUOTED_IDENTIFIER ON GO CREATE TABLE [dbo].[Task]( [Id] [int] IDENTITY(1,1) NOT NULL, [UserId] [int] NOT NULL, [Name] [nvarchar](100) NOT NULL, [IsCompleted] [bit] NOT NULL, [TS] [smalldatetime] NOT NULL, CONSTRAINT [PK_Task] PRIMARY KEY CLUSTERED ( [Id] ASC )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY] ) ON [PRIMARY] GO /****** Object: Table [dbo].[User] Script Date: 1/18/2022 6:10:48 PM ******/ SET ANSI_NULLS ON GO SET QUOTED_IDENTIFIER ON GO CREATE TABLE [dbo].[User]( [Id] [int] IDENTITY(1,1) NOT NULL, [Email] [nvarchar](50) NOT NULL, [Password] [nvarchar](255) NOT NULL, [PasswordSalt] [nvarchar](255) NOT NULL, [FirstName] [nvarchar](255) NOT NULL, [LastName] [nvarchar](255) NOT NULL, [TS] [smalldatetime] NOT NULL, [Active] [bit] NOT NULL, CONSTRAINT [PK_User] PRIMARY KEY CLUSTERED ( [Id] ASC )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY] ) ON [PRIMARY] GO SET IDENTITY_INSERT [dbo].[Task] ON GO INSERT [dbo].[Task] ([Id], [UserId], [Name], [IsCompleted], [TS]) VALUES (1, 1, N'Blog about Access Token and Refresh Token Authentication', 1, CAST(N'2022-01-14T00:00:00' AS SmallDateTime)) GO INSERT [dbo].[Task] ([Id], [UserId], [Name], [IsCompleted], [TS]) VALUES (3, 1, N'Vaccum the House', 0, CAST(N'2022-01-14T00:00:00' AS SmallDateTime)) GO INSERT [dbo].[Task] ([Id], [UserId], [Name], [IsCompleted], [TS]) VALUES (4, 1, N'Farmers Market Shopping', 0, CAST(N'2022-01-14T00:00:00' AS SmallDateTime)) GO INSERT [dbo].[Task] ([Id], [UserId], [Name], [IsCompleted], [TS]) VALUES (5, 1, N'Practice Juggling', 0, CAST(N'2022-01-15T00:00:00' AS SmallDateTime)) GO SET IDENTITY_INSERT [dbo].[Task] OFF GO SET IDENTITY_INSERT [dbo].[User] ON GO INSERT [dbo].[User] ([Id], [Email], [Password], [PasswordSalt], [FirstName], [LastName], [TS], [Active]) VALUES (1, N'coding@codingsonata.com', N'miLgvYoSVrotOON6/lRp8ACrrbAxCPCmsrsy355x/DI=', N'L5hziA8V93SNGTlYdz+meS0B6DPzB3IwsRhDf1vO1GM=', N'Coding', N'Sonata', CAST(N'2022-01-14T00:00:00' AS SmallDateTime), 1) GO INSERT [dbo].[User] ([Id], [Email], [Password], [PasswordSalt], [FirstName], [LastName], [TS], [Active]) VALUES (2, N'test@codingsonata.com', N'Fm7/SI9lYAFglzWXLD5oLz0cuq00MQmPkzDZ+nDZNmc=', N'kjgIDmRKgUbbWypCOOUHuxlQzZAszdEKw358ds4Xyc4=', N'test', N'postman', CAST(N'2022-01-16T14:23:00' AS SmallDateTime), 1) GO SET IDENTITY_INSERT [dbo].[User] OFF GO ALTER TABLE [dbo].[RefreshToken] WITH CHECK ADD CONSTRAINT [FK_RefreshToken_User] FOREIGN KEY([UserId]) REFERENCES [dbo].[User] ([Id]) GO ALTER TABLE [dbo].[RefreshToken] CHECK CONSTRAINT [FK_RefreshToken_User] GO ALTER TABLE [dbo].[Task] WITH CHECK ADD CONSTRAINT [FK_Task_User] FOREIGN KEY([UserId]) REFERENCES [dbo].[User] ([Id]) GO ALTER TABLE [dbo].[Task] CHECK CONSTRAINT [FK_Task_User] GO |
Project Creation
Open Visual Studio 2022, and create a new project of type ASP.NET Core Web API:
Give it a name like TasksApi:
Then choose .NET 7.0 and create the project:
Once VS completes the initialization of the project, press F5 to do an initial run for the template project to make sure that it works fine.
Now, let’s remove some unneeded classes from the template project. From solution explorer, delete WeatherForecastController and WeatherForecast files.
Entity Framework Core and DbContext
Let’s add Nuget package for EF Core and EF Core Sql:
As you can notice, now we have EF Core 7, there are many great improvements happened from EF Core 6 to 7, check the release notes of EF Core 7 to learn more.
Entities
Now let’s create the needed entities that will bind to the Database tables through the EF Core DbContext class.
We will create 3 entities that will map to the Tasks database.
RefreshToken
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// <auto-generated> This file has been auto generated by EF Core Power Tools. </auto-generated> #nullable disable namespace TasksApi { public partial class RefreshToken { public int Id { get; set; } public int UserId { get; set; } public string TokenHash { get; set; } public string TokenSalt { get; set; } public DateTime Ts { get; set; } public DateTime ExpiryDate { get; set; } public virtual User User { get; set; } } } |
Task
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// <auto-generated> This file has been auto generated by EF Core Power Tools. </auto-generated> #nullable disable using System; using System.Collections.Generic; namespace TasksApi { public partial class Task { public int Id { get; set; } public int UserId { get; set; } public string Name { get; set; } public bool IsCompleted { get; set; } public DateTime Ts { get; set; } public virtual User User { get; set; } } } |
User
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
// <auto-generated> This file has been auto generated by EF Core Power Tools. </auto-generated> #nullable disable using System; using System.Collections.Generic; namespace TasksApi { public partial class User { public User() { RefreshTokens = new HashSet<RefreshToken>(); Tasks = new HashSet<Task>(); } public int Id { get; set; } public string Email { get; set; } public string Password { get; set; } public string PasswordSalt { get; set; } public string FirstName { get; set; } public string LastName { get; set; } public DateTime Ts { get; set; } public bool Active { get; set; } public virtual ICollection<RefreshToken> RefreshTokens { get; set; } public virtual ICollection<Task> Tasks { get; set; } } } |
DbContext
Now let’s add the TasksDbContext that will inherit from the DbContext class of the EF Core:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 |
// <auto-generated> This file has been auto generated by EF Core Power Tools. </auto-generated> #nullable disable using Microsoft.EntityFrameworkCore; namespace TasksApi { public partial class TasksDbContext : DbContext { public TasksDbContext() { } public TasksDbContext(DbContextOptions<TasksDbContext> options) : base(options) { } public virtual DbSet<RefreshToken> RefreshTokens { get; set; } public virtual DbSet<Task> Tasks { get; set; } public virtual DbSet<User> Users { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<RefreshToken>(entity => { entity.Property(e => e.ExpiryDate).HasColumnType("smalldatetime"); entity.Property(e => e.TokenHash) .IsRequired() .HasMaxLength(1000); entity.Property(e => e.TokenSalt) .IsRequired() .HasMaxLength(1000); entity.Property(e => e.Ts) .HasColumnType("smalldatetime") .HasColumnName("TS"); entity.HasOne(d => d.User) .WithMany(p => p.RefreshTokens) .HasForeignKey(d => d.UserId) .OnDelete(DeleteBehavior.ClientSetNull) .HasConstraintName("FK_RefreshToken_User"); entity.ToTable("RefreshToken"); }); modelBuilder.Entity<Task>(entity => { entity.Property(e => e.Name) .IsRequired() .HasMaxLength(100); entity.Property(e => e.Ts) .HasColumnType("smalldatetime") .HasColumnName("TS"); entity.HasOne(d => d.User) .WithMany(p => p.Tasks) .HasForeignKey(d => d.UserId) .OnDelete(DeleteBehavior.ClientSetNull) .HasConstraintName("FK_Task_User"); entity.ToTable("Task"); }); modelBuilder.Entity<User>(entity => { entity.Property(e => e.Email) .IsRequired() .HasMaxLength(50); entity.Property(e => e.FirstName) .IsRequired() .HasMaxLength(255); entity.Property(e => e.LastName) .IsRequired() .HasMaxLength(255); entity.Property(e => e.Password) .IsRequired() .HasMaxLength(255); entity.Property(e => e.PasswordSalt) .IsRequired() .HasMaxLength(255); entity.Property(e => e.Ts) .HasColumnType("smalldatetime") .HasColumnName("TS"); entity.ToTable("User"); }); OnModelCreatingPartial(modelBuilder); } partial void OnModelCreatingPartial(ModelBuilder modelBuilder); } } |
And in your program.cs file, add the below just before the builder.build() call:
1 |
builder.Services.AddDbContext<TasksDbContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("TasksDbConnectionString"))); |
Then in your appsettings.json , make sure to include the connection string for the database:
1 2 3 4 5 6 7 8 9 10 11 12 |
{ "ConnectionStrings": { "TasksDbConnectionString": "Server=Home\\SQLEXPRESS;Database=TasksDb;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True" }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*" } |
Breaking Change in EF Core 7
Important note here about the TrustServerCertifiicate=True, this is only an unsecure workaround just for the sake of testing localhost.
Since there has been a breaking change with the release of EF Core 7, which is related to the connection encryption with SQL Server. This means that now by default any connection to SQL Server database is encrypted, through the Encrypt=True
portion of the connection string in case it is not provided otherwise, and there must be a trusted certificate installed on the machine hosting the SQL Server. You can read more about the breaking change in EF Core 7.
So, just to show you what will happen when we try to connect to SQL server in .NET 7 using .NET Core 7, using the default connection string without installing a trusted certificate, I will fast forward this tutorial and try to run the application on Postman via https://localhost, I will get the below error message:
Microsoft.Data.SqlClient.SqlException (0x80131904): A connection was successfully established with the server, but then an error occurred during the login process. (provider: SSL Provider, error: 0 - The certificate chain was issued by an authority that is not trusted.)
---> System.ComponentModel.Win32Exception (0x80090325): The certificate chain was issued by an authority that is not trusted.
So only for localhost testing purposes, as a workaround, we can bypass this through adding TrustServerCertificate=True
.
You can instead install a trusted certificate and then you can remove the TrustServerCertificate=True
from the connection string.
EF Core Power Tools
Note: I have used the wonderful extension EF Core Power Tools which in a magical way it can translate a whole database structure and relationships into neat and proper DbContext entities and configurations.
You can install it from Extensions tab -> Manage Extensions of your Visual Studio 2022
If you are following the design-first model for building your database first and then your EF Core mapping, I would highly recommend that you use this blazing fast and reliable tool to perform such operation, which will therefore boost your productivity and reduce the number of errors that might be introduced from manually creating the entities and configurations.
PasswordHashHelper
In order to save passwords on the database, we need to use secure hash HMAC 256 and salt from a secure random bytes of 256-bit sized, so we can protect the valuable users’ passwords from those nasty lurking thieves!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
using Microsoft.AspNetCore.Cryptography.KeyDerivation; using System.Security.Cryptography; namespace TasksApi.Helpers { public class PasswordHelper { public static byte[] GetSecureSalt() { // Starting .NET 6, the Class RNGCryptoServiceProvider is obsolete, // so now we have to use the RandomNumberGenerator Class to generate a secure random number bytes return RandomNumberGenerator.GetBytes(32); } public static string HashUsingPbkdf2(string password, byte[] salt) { byte[] derivedKey = KeyDerivation.Pbkdf2(password, salt, KeyDerivationPrf.HMACSHA256, iterationCount: 300000, 32); return Convert.ToBase64String(derivedKey); } } } |
We will also use these helper methods to save the refresh tokens on the database in a hashed format alongside their associated salts.
Implementing the JWT Authentication
Let’s add the needed JWT Bearer Package, which is also available in .NET 7:
Token Helper to build both Access Tokens and Refresh Tokens
Now let’s add the TokenHelper, which will include 2 methods to generate JWT-based access tokens and the other to generate a 32-byte based refresh tokens:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
using Microsoft.IdentityModel.Tokens; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Security.Cryptography; namespace TasksApi.Helpers { public class TokenHelper { public const string Issuer = "http://codingsonata.com"; public const string Audience = "http://codingsonata.com"; public const string Secret = "p0GXO6VuVZLRPef0tyO9jCqK4uZufDa6LP4n8Gj+8hQPB30f94pFiECAnPeMi5N6VT3/uscoGH7+zJrv4AuuPg=="; public static async Task<string> GenerateAccessToken(int userId) { var tokenHandler = new JwtSecurityTokenHandler(); var key = Convert.FromBase64String(Secret); var claimsIdentity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId.ToString()) }); var signingCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature); var tokenDescriptor = new SecurityTokenDescriptor { Subject = claimsIdentity, Issuer = Issuer, Audience = Audience, Expires = DateTime.Now.AddMinutes(15), SigningCredentials = signingCredentials, }; var securityToken = tokenHandler.CreateToken(tokenDescriptor); return await System.Threading.Tasks.Task.Run(() => tokenHandler.WriteToken(securityToken)); } public static async Task<string> GenerateRefreshToken() { var secureRandomBytes = new byte[32]; using var randomNumberGenerator = RandomNumberGenerator.Create(); await System.Threading.Tasks.Task.Run(() => randomNumberGenerator.GetBytes(secureRandomBytes)); var refreshToken = Convert.ToBase64String(secureRandomBytes); return refreshToken; } } } |
Now let’s make sure to add the needed authentication and authorization middleware to the pipeline in program.cs file:
Add the below before the builder.build method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateIssuerSigningKey = true, ValidIssuer = TokenHelper.Issuer, ValidAudience = TokenHelper.Audience, IssuerSigningKey = new SymmetricSecurityKey(Convert.FromBase64String(TokenHelper.Secret)) }; }); builder.Services.AddAuthorization(); |
And then before the app.run method, make sure you the app will be using both middleware to authenticate and authorize your users:
1 2 |
app.UseAuthentication(); app.UseAuthorization(); |
Requests and Responses
It is always advised that you accept and return structured objects instead of separated data, this is why we will prepare some Request and Response class that we will use them throughout our API:
Let’s add the below requests classes:
LoginRequest
1 2 3 4 5 6 7 8 |
namespace TasksApi.Requests { public class LoginRequest { public string Email { get; set; } public string Password { get; set; } } } |
RefreshTokenRequest
1 2 3 4 5 6 7 8 9 |
namespace TasksApi.Requests { public class RefreshTokenRequest { public int UserId { get; set; } public string RefreshToken { get; set; } } } |
SignupRequest
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
using System.ComponentModel.DataAnnotations; namespace TasksApi.Requests { public class SignupRequest { [Required] [EmailAddress] public string Email { get; set; } [Required] public string Password { get; set; } [Required] public string ConfirmPassword { get; set; } [Required] public string FirstName { get; set; } [Required] public string LastName { get; set; } [Required] public DateTime Ts { get; set; } } } |
TaskRequest
1 2 3 4 5 6 7 8 9 |
namespace TasksApi.Requests { public class TaskRequest { public string Name { get; set; } public bool IsCompleted { get; set; } public DateTime Ts { get; set; } } } |
Now let’s add the responses classes, these will be used to return the structured responses for the UI client calling the API:
BaseResponse
This will be used a base class so other response classes can inherit from and extend their properties:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
using System.Text.Json.Serialization; namespace TasksApi.Responses { public abstract class BaseResponse { [JsonIgnore()] public bool Success { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string ErrorCode { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string Error { get; set; } } } |
DeleteTaskResponse
1 2 3 4 5 6 7 8 9 10 |
using System.Text.Json.Serialization; namespace TasksApi.Responses { public class DeleteTaskResponse : BaseResponse { [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public int TaskId { get; set; } } } |
GetTasksResponse
1 2 3 4 5 6 7 |
namespace TasksApi.Responses { public class GetTasksResponse : BaseResponse { public List<Task> Tasks { get; set; } } } |
LogoutResponse
1 2 3 4 5 6 |
namespace TasksApi.Responses { public class LogoutResponse : BaseResponse { } } |
SaveTaskResponse
1 2 3 4 5 6 7 |
namespace TasksApi.Responses { public class SaveTaskResponse : BaseResponse { public Task Task { get; set; } } } |
SignupResponse
1 2 3 4 5 6 7 |
namespace TasksApi.Responses { public class SignupResponse : BaseResponse { public string Email { get; set; } } } |
TaskResponse
1 2 3 4 5 6 7 8 9 10 |
namespace TasksApi.Responses { public class TaskResponse { public int Id { get; set; } public string Name { get; set; } public bool IsCompleted { get; set; } public DateTime Ts { get; set; } } } |
TokenResponse
1 2 3 4 5 6 7 8 9 |
namespace TasksApi.Responses { public class TokenResponse: BaseResponse { public string AccessToken { get; set; } public string RefreshToken { get; set; } } } |
ValidateRefreshTokenResponse
1 2 3 4 5 6 7 8 |
namespace TasksApi.Responses { public class ValidateRefreshTokenResponse : BaseResponse { public int UserId { get; set; } } } |
Interfaces
We will define 3 interfaces that will be implemented within the services. The interfaces are the abstractions that the Controllers would need to use to be able to process the related business logic and database calls, each interface would be implemented within a service which would be injected at runtime.
This is a very useful strategy (or design pattern) to make your API loosely coupled and easily testable.
ITokenService
1 2 3 4 5 6 7 8 9 10 11 |
using TasksApi.Requests; using TasksApi.Responses; namespace TasksApi.Interfaces { public interface ITokenService { Task<Tuple<string, string>> GenerateTokensAsync(int userId); Task<ValidateRefreshTokenResponse> ValidateRefreshTokenAsync(RefreshTokenRequest refreshTokenRequest); Task<bool> RemoveRefreshTokenAsync(User user); } } |
IUserService
1 2 3 4 5 6 7 8 9 10 11 |
using TasksApi.Requests; using TasksApi.Responses; namespace TasksApi.Interfaces { public interface IUserService { Task<TokenResponse> LoginAsync(LoginRequest loginRequest); Task<SignupResponse> SignupAsync(SignupRequest signupRequest); Task<LogoutResponse> LogoutAsync(int userId); } } |
ITasksInterface
1 2 3 4 5 6 7 8 9 10 |
using TasksApi.Responses; namespace TasksApi.Interfaces { public interface ITaskService { Task<GetTasksResponse> GetTasks(int userId); Task<SaveTaskResponse> SaveTask(Task task); Task<DeleteTaskResponse> DeleteTask(int taskId, int userId); } } |
Services
Services act as the intermediate layer between your Controllers and your DbContext, it also includes any business related logic that the Controller should not bother about. Services implement the interfaces.
We will add 3 services:
TokenService
This will includes methods to generate tokens, validate and remove refresh tokens:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
using Microsoft.EntityFrameworkCore; using TasksApi.Helpers; using TasksApi.Interfaces; using TasksApi.Requests; using TasksApi.Responses; namespace TasksApi.Services { public class TokenService : ITokenService { private readonly TasksDbContext tasksDbContext; public TokenService(TasksDbContext tasksDbContext) { this.tasksDbContext = tasksDbContext; } public async Task<Tuple<string, string>> GenerateTokensAsync(int userId) { var accessToken = await TokenHelper.GenerateAccessToken(userId); var refreshToken = await TokenHelper.GenerateRefreshToken(); var userRecord = await tasksDbContext.Users.Include(o => o.RefreshTokens).FirstOrDefaultAsync(e => e.Id == userId); if (userRecord == null) { return null; } var salt = PasswordHelper.GetSecureSalt(); var refreshTokenHashed = PasswordHelper.HashUsingPbkdf2(refreshToken, salt); if (userRecord.RefreshTokens != null && userRecord.RefreshTokens.Any()) { await RemoveRefreshTokenAsync(userRecord); } userRecord.RefreshTokens?.Add(new RefreshToken { ExpiryDate = DateTime.Now.AddDays(30), Ts = DateTime.Now, UserId = userId, TokenHash = refreshTokenHashed, TokenSalt = Convert.ToBase64String(salt) }); await tasksDbContext.SaveChangesAsync(); var token = new Tuple<string, string>(accessToken, refreshToken); return token; } public async Task<bool> RemoveRefreshTokenAsync(User user) { var userRecord = await tasksDbContext.Users.Include(o => o.RefreshTokens).FirstOrDefaultAsync(e => e.Id == user.Id); if (userRecord == null) { return false; } if (userRecord.RefreshTokens != null && userRecord.RefreshTokens.Any()) { var currentRefreshToken = userRecord.RefreshTokens.First(); tasksDbContext.RefreshTokens.Remove(currentRefreshToken); } return false; } public async Task<ValidateRefreshTokenResponse> ValidateRefreshTokenAsync(RefreshTokenRequest refreshTokenRequest) { var refreshToken = await tasksDbContext.RefreshTokens.FirstOrDefaultAsync(o => o.UserId == refreshTokenRequest.UserId); var response = new ValidateRefreshTokenResponse(); if (refreshToken == null) { response.Success = false; response.Error = "Invalid session or user is already logged out"; response.ErrorCode = "R02"; return response; } var refreshTokenToValidateHash = PasswordHelper.HashUsingPbkdf2(refreshTokenRequest.RefreshToken, Convert.FromBase64String(refreshToken.TokenSalt)); if (refreshToken.TokenHash != refreshTokenToValidateHash) { response.Success = false; response.Error = "Invalid refresh token"; response.ErrorCode = "R03"; return response; } if (refreshToken.ExpiryDate < DateTime.Now) { response.Success = false; response.Error = "Refresh token has expired"; response.ErrorCode = "R04"; return response; } response.Success = true; response.UserId = refreshToken.UserId; return response; } } } |
UserService
This will include methods related to login, logout and signup:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 |
using Microsoft.EntityFrameworkCore; using TasksApi.Helpers; using TasksApi.Interfaces; using TasksApi.Requests; using TasksApi.Responses; namespace TasksApi.Services { public class UserService : IUserService { private readonly TasksDbContext tasksDbContext; private readonly ITokenService tokenService; public UserService(TasksDbContext tasksDbContext, ITokenService tokenService) { this.tasksDbContext = tasksDbContext; this.tokenService = tokenService; } public async Task<TokenResponse> LoginAsync(LoginRequest loginRequest) { var user = tasksDbContext.Users.SingleOrDefault(user => user.Active && user.Email == loginRequest.Email); if (user == null) { return new TokenResponse { Success = false, Error = "Email not found", ErrorCode = "L02" }; } var passwordHash = PasswordHelper.HashUsingPbkdf2(loginRequest.Password, Convert.FromBase64String(user.PasswordSalt)); if (user.Password != passwordHash) { return new TokenResponse { Success = false, Error = "Invalid Password", ErrorCode = "L03" }; } var token = await System.Threading.Tasks.Task.Run(() => tokenService.GenerateTokensAsync(user.Id)); return new TokenResponse { Success = true, AccessToken = token.Item1, RefreshToken = token.Item2 }; } public async Task<LogoutResponse> LogoutAsync(int userId) { var refreshToken = await tasksDbContext.RefreshTokens.FirstOrDefaultAsync(o => o.UserId == userId); if (refreshToken == null) { return new LogoutResponse { Success = true }; } tasksDbContext.RefreshTokens.Remove(refreshToken); var saveResponse = await tasksDbContext.SaveChangesAsync(); if (saveResponse >= 0) { return new LogoutResponse { Success = true }; } return new LogoutResponse { Success = false, Error = "Unable to logout user", ErrorCode = "L04" }; } public async Task<SignupResponse> SignupAsync(SignupRequest signupRequest) { var existingUser = await tasksDbContext.Users.SingleOrDefaultAsync(user => user.Email == signupRequest.Email); if (existingUser != null) { return new SignupResponse { Success = false, Error = "User already exists with the same email", ErrorCode = "S02" }; } if (signupRequest.Password != signupRequest.ConfirmPassword) { return new SignupResponse { Success = false, Error = "Password and confirm password do not match", ErrorCode = "S03" }; } if (signupRequest.Password.Length <= 7) // This can be more complicated than only length, you can check on alphanumeric and or special characters { return new SignupResponse { Success = false, Error = "Password is weak", ErrorCode = "S04" }; } var salt = PasswordHelper.GetSecureSalt(); var passwordHash = PasswordHelper.HashUsingPbkdf2(signupRequest.Password, salt); var user = new User { Email = signupRequest.Email, Password = passwordHash, PasswordSalt = Convert.ToBase64String(salt), FirstName = signupRequest.FirstName, LastName = signupRequest.LastName, Ts = signupRequest.Ts, Active = true // You can save is false and send confirmation email to the user, then once the user confirms the email you can make it true }; await tasksDbContext.Users.AddAsync(user); var saveResponse = await tasksDbContext.SaveChangesAsync(); if (saveResponse >= 0) { return new SignupResponse { Success = true, Email = user.Email }; } return new SignupResponse { Success = false, Error = "Unable to save the user", ErrorCode = "S05" }; } } } |
TaskService
This includes the methods for adding, removing and getting tasks:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
using Microsoft.EntityFrameworkCore; using TasksApi.Interfaces; using TasksApi.Responses; namespace TasksApi.Services { public class TaskService : ITaskService { private readonly TasksDbContext tasksDbContext; public TaskService(TasksDbContext tasksDbContext) { this.tasksDbContext = tasksDbContext; } public async Task<DeleteTaskResponse> DeleteTask(int taskId, int userId) { var task = await tasksDbContext.Tasks.FindAsync(taskId); if (task == null) { return new DeleteTaskResponse { Success = false, Error = "Task not found", ErrorCode = "T01" }; } if (task.UserId != userId) { return new DeleteTaskResponse { Success = false, Error = "You don't have access to delete this task", ErrorCode = "T02" }; } tasksDbContext.Tasks.Remove(task); var saveResponse = await tasksDbContext.SaveChangesAsync(); if (saveResponse >= 0) { return new DeleteTaskResponse { Success = true, TaskId = task.Id }; } return new DeleteTaskResponse { Success = false, Error = "Unable to delete task", ErrorCode = "T03" }; } public async Task<GetTasksResponse> GetTasks(int userId) { var tasks = await tasksDbContext.Tasks.Where(o => o.UserId == userId).ToListAsync(); if (tasks.Count == 0) { return new GetTasksResponse { Success = false, Error = "No tasks found for this user", ErrorCode = "T04" }; } return new GetTasksResponse { Success = true, Tasks = tasks }; } public async Task<SaveTaskResponse> SaveTask(Task task) { await tasksDbContext.Tasks.AddAsync(task); var saveResponse = await tasksDbContext.SaveChangesAsync(); if (saveResponse >= 0) { return new SaveTaskResponse { Success = true, Task = task }; } return new SaveTaskResponse { Success = false, Error = "Unable to save task", ErrorCode = "T05" }; } } } |
Now, once you add those Interfaces and Tasks, let’s make sure that we configure them within the project’s builder pipeline:
1 2 3 |
builder.Services.AddTransient<ITokenService, TokenService>(); builder.Services.AddTransient<IUserService, UserService>(); builder.Services.AddTransient<ITaskService, TaskService>(); |
Controllers
Now it is the last part of the API, which is to build the endpoints that will be used by the users to access the backend resources:
First, we will make a new Controller that will inherit for the ControllerBase and inside it we will have a small method and property to retrieve the logged in UserId, whenever the access token is provided, from the JWT-based access token claims:
So let’s add an API Controller, like the below:
BaseApiController
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
using Microsoft.AspNetCore.Mvc; using System.Security.Claims; namespace TasksApi.Controllers { public class BaseApiController : ControllerBase { protected int UserID => int.Parse(FindClaim(ClaimTypes.NameIdentifier)); private string FindClaim(string claimName) { var claimsIdentity = HttpContext.User.Identity as ClaimsIdentity; var claim = claimsIdentity.FindFirst(claimName); if (claim == null) { return null; } return claim.Value; } } } |
Now we can create our controllers that will inherit from this BaseApiController.
UsersController
Let’s start with the UsersController, it will include 4 methods: login, logout, signup and refresh the access token:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using TasksApi.Interfaces; using TasksApi.Requests; using TasksApi.Responses; namespace TasksApi.Controllers { [Route("api/[controller]")] [ApiController] public class UsersController : BaseApiController { private readonly IUserService userService; private readonly ITokenService tokenService; public UsersController(IUserService userService, ITokenService tokenService) { this.userService = userService; this.tokenService = tokenService; } [HttpPost] [Route("login")] public async Task<IActionResult> Login(LoginRequest loginRequest) { if (loginRequest == null || string.IsNullOrEmpty(loginRequest.Email) || string.IsNullOrEmpty(loginRequest.Password)) { return BadRequest(new TokenResponse { Error = "Missing login details", ErrorCode = "L01" }); } var loginResponse = await userService.LoginAsync(loginRequest); if (!loginResponse.Success) { return Unauthorized(new { loginResponse.ErrorCode, loginResponse.Error }); } return Ok(loginResponse); } [HttpPost] [Route("refresh_token")] public async Task<IActionResult> RefreshToken(RefreshTokenRequest refreshTokenRequest) { if (refreshTokenRequest == null || string.IsNullOrEmpty(refreshTokenRequest.RefreshToken) || refreshTokenRequest.UserId == 0) { return BadRequest(new TokenResponse { Error = "Missing refresh token details", ErrorCode = "R01" }); } var validateRefreshTokenResponse = await tokenService.ValidateRefreshTokenAsync(refreshTokenRequest); if (!validateRefreshTokenResponse.Success) { return UnprocessableEntity(validateRefreshTokenResponse); } var tokenResponse = await tokenService.GenerateTokensAsync(validateRefreshTokenResponse.UserId); return Ok(new { AccessToken = tokenResponse.Item1, Refreshtoken = tokenResponse.Item2 }); } [HttpPost] [Route("signup")] public async Task<IActionResult> Signup(SignupRequest signupRequest) { if (!ModelState.IsValid) { var errors = ModelState.Values.SelectMany(x => x.Errors.Select(c => c.ErrorMessage)).ToList(); if (errors.Any()) { return BadRequest(new TokenResponse { Error = $"{string.Join(",", errors)}", ErrorCode = "S01" }); } } var signupResponse = await userService.SignupAsync(signupRequest); if (!signupResponse.Success) { return UnprocessableEntity(signupResponse); } return Ok(signupResponse.Email); } [Authorize] [HttpPost] [Route("logout")] public async Task<IActionResult> Logout() { var logout = await userService.LogoutAsync(UserID); if (!logout.Success) { return UnprocessableEntity(logout); } return Ok(); } } } |
Note above that only the logout endpoint has the Authorize decoration, this is because we know that the user will be able to logout as long as he is logged in, which means he has a valid access token and refresh token.
TasksController
This includes all the endpoints that will allow the user to perform tasks-related operations, like getting all user’s tasks, saving and deleting the task for that user.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using TasksApi.Interfaces; using TasksApi.Requests; using TasksApi.Responses; namespace TasksApi.Controllers { [Authorize] [Route("api/[controller]")] [ApiController] public class TasksController : BaseApiController { private readonly ITaskService taskService; public TasksController(ITaskService taskService) { this.taskService = taskService; } [HttpGet] public async Task<IActionResult> Get() { var getTasksResponse = await taskService.GetTasks(UserID); if (!getTasksResponse.Success) { return UnprocessableEntity(getTasksResponse); } var tasksResponse = getTasksResponse.Tasks.ConvertAll(o => new TaskResponse { Id = o.Id, IsCompleted = o.IsCompleted, Name = o.Name, Ts = o.Ts }); return Ok(tasksResponse); } [HttpPost] public async Task<IActionResult> Post(TaskRequest taskRequest) { var task = new Task { IsCompleted = taskRequest.IsCompleted, Ts = taskRequest.Ts, Name = taskRequest.Name, UserId = UserID }; var saveTaskResponse = await taskService.SaveTask(task); if (!saveTaskResponse.Success) { return UnprocessableEntity(saveTaskResponse); } var taskResponse = new TaskResponse { Id = saveTaskResponse.Task.Id, IsCompleted = saveTaskResponse.Task.IsCompleted, Name = saveTaskResponse.Task.Name, Ts = saveTaskResponse.Task.Ts }; return Ok(taskResponse); } [HttpDelete("{id}")] public async Task<IActionResult> Delete(int id) { var deleteTaskResponse = await taskService.DeleteTask(id, UserID); if (!deleteTaskResponse.Success) { return UnprocessableEntity(deleteTaskResponse); } return Ok(deleteTaskResponse.TaskId); } } } |
Note that the [Authorize] attribute is decorating the whole Controller since all these operations require an authenticated user with a valid access token.
Now we are completed with the development part. Press F5 and check the browser is showing Swagger UI for your APIs
Testing on Postman
Now comes the QA part of testing our whole work to make sure everything is working fine and as per the requirements.
Of course, make sure you have the latest version of Postman installed and opened.
Let’s create a new collection and name it , Tasks Api.
First thing we need to test the login endpoint since we have some test user already inserted in the database (included in the script part at the beginning of the tutorial)
Let’s try to invalidate the email and see the result:
Now let’s test the signup method:
Take a look at the database User Table:
Notice the 3rd record, the password was never saved in plain format, and the random salt was associated with it.
The access token usually would have a short duration, 10 or 15 minutes long, and once this is expired you have to silently refresh the access token using the refresh token, which is much longer in duration, like 10 days or 3 weeks for example, and these tokens are sliding in time, so whenever you want to refresh and access token you can just use the below endpoint to generate new pair of tokens.
refresh_token endpoint
Now let’s test the refresh token endpoint. You will need this endpoint to refresh the access token for the user after it becomes expired through any of the authorized API calls, such as the below:
You will get 401 response, because the access token is no longer valid and you have to request for a new access token using the refresh token that you had from the first login.
Let’s try to refresh token:
If we try to refresh the same token used before, it won’t work, simply because the refresh token triggers generating both new access token and new refresh token, so the previous refresh token would be invalidated (removed from the RefreshToken table on the database).
Now let’s logout the user
Malicious or already logged out user
Now let’s try this scenario, a valid user logs out of the system, but incidentally a malicious user has already obtained the refresh token for that user (in some way), and tries to refresh that user’s token so that he can gain unauthorized access, our APIs would guard our valid user by returning the below response for the malicious user.
This way the malicious user cannot gain access to the valid user’s data.
Let’s now do Get Tasks for the user:
And let’s try adding a new task
and now let’s delete a task
Summary
Today we have learned how to build a small simple tasks management system starting from the database using SQL Server Express, connecting it with ASP.NET Core Web API in .NET 7 and C# 11 within Visual Studio 2022.
We mapped the database with EF Core 7 (with the generous help of the great EF Core Power Tools) and then using the JWTBearer Nuget package we managed to setup and implement the JWT-based authentication on the API project alongside applying the refresh tokens to make it even more practical for end users to get authenticated and authorized for the APIs without having to re-do the login process every 10 or 15 minutes.
If you think this tutorial is useful, please feel free to share it within your online network and with your colleagues. And don’t forget to subscribe to my blog to be notified once a new tutorial is posted.
Please let me know your thoughts or inquiries down below in the comments section.
References
You can find the source code, updated to .NET 7, for this tutorial in my GitHub account.
I have another tutorial that explains a little more about JWT, you can take a look sometime Secure ASP.NET Core Web API using JWT Authentication.
Feel free to check my other tutorials as well, I will make sure to update them to .NET 7 the soonest:
- Secure ASP.NET Core Web API using API Key Authentication
- Exception Handling and Logging in ASP.NET Core Web API
- A Complete Tutorial to Connect Android with ASP.NET Core Web API
Also for more details about improving your Web API Security, you can check my article: Boost your Web API Security with These Tips
If you are new to ASP.NET Core Web API, feel free to read my post: A Quick Guide to Learn ASP.NET Core Web API
Bonus
Please enjoy listening to this beautiful piano sonata.
Have a great day or night wherever you are, have fun listening and coding! and most importantly .. Please Stay Safe!
Mozart – Piano Sonata No. 8 in A minor, K. 310 (1st Movement)
Best tutorial. I have a question : Why we use partial class for db context, refresh token and others?
Thanks for your message and sorry for the late reply. These classes are generated by the EF Core Power Tools. Partial classes allow splitting your class into multiple files. You can read this SO thread to learn more about it. https://stackoverflow.com/questions/1057851/what-are-the-benefits-to-using-a-partial-class-as-opposed-to-an-abstract-one
this content is gold, thanks a lot, appreciate your efforts brother.
I had an issue with setting the “Expires” time but adjusted it to be Expires = DateTime.UtcNow.AddMinutes(15) with the UtcNow and it worked for me. Great job mate!
Excellent content. It really helped me connect the gaps in the material I am working on.
I noticed an inconsistency in your code. Your SQL commands set the length of TokenSalt to 50, but your generated TasksDbContext has the max length as 1000. My generated TasksDbContext has the same length as was set in the db.
I am not very experienced with Authentication. If 50 is too short I am sure I will find out. Just an observation.
Excellent post! One of the best tutorials I have read.
I have 2 questions:
#1
I have a question about the ‘malicious user scenario’.
After a user logs out, will the malicious user be able to use the
access token
to perform authenticated calls? The access token has not expired and was not invalidated.After the access token expires, the malicious user should not be able to perform authenticated actions… However, in the time between the expiration of the access token, and the time that the user log’s out — can the malicious user perform authenticated actions?
#2 – Why did you create a new database table called ‘RefreshToken’ instead of use AspNetUserTokens?
Thanks!
Thank you for your nice words. I am glad that you find this tutorial helpful.
Regarding your questions:
#1 Access tokens are usually meant to be short time-lived tokens that get invalidated once the lifetime of the token ends, and they are generated for users for a time-limited usage and they are not linked or stored with refresh token, so usually there is no way to specifically invalidate a valid access or JWT token. So that malicious user ‘will be’ able to access your APIs with the previous access token since it is still valid, but that should be only for a very short duration. This is why you should always keep your access tokens very short lived (usually 15 or 30 mins) and silently refresh them for a better user experience, and then if you think your tokens were compromised, then you can just remove the refresh token from your database, this will result in any user having your old refresh token won’t be able to generate new access token / refresh token until they get signed out from the application.
# The AspNetUserTokens is used by the ASP.NET Identity to store token information of your users who log in through the external identity providers like Google, Twitter, Github … etc. So usually the usage of this table comes bundled with the usage of ASP.NET Identity.
On the other hand, this tutorial helps in building a custom implementation for JWT Access tokens and refresh tokens (using the JWT , where your APIs also act as the identity provider, in this way we can control our own implementation for identity by storing and managing those refresh tokens for your users.
Great article, thank you.
Sign-up and Sign-in requests work as expected. Subsequent access_token generation works and allows accessing protected data.
I am a bit confused about the sign-out and refresh_token requests. First, I am not sure about the order. Does it matter? I’ve tried both ways expecting that protected API endpoints would not longer be accessible. But after I refresh_token and sign-out, the previously generated access_token still grants users access to protected endpoints. What am I doing wrong?
Regards,
Tolga
Hello, Really sorry for the very late reply, thank you for your message. Access Tokens are not revokable, they can only be invalidated when their lifetime is over, and they are not tightly coupled with their refresh tokens, so you can have as many refresh tokens as possible and still the first access token will remain valid until it expires. This is why the recommendations are to use very short lived access tokens ( 15 or 30 minutes ) and have refresh token setup with database persistence so you can control the devices/channels that are connected and revoke ( delete ) the refresh token for any suspicious device activity. I hope that answered your question. Sorry again.
Thanks a lot bor ,you’r greate it’s realy helpfull content keep going.
Great demo. Thanks a lot!