Published: January 24 2022
Last updated: February 20 2023

.NET 6.0 - JWT Authentication with Refresh Tokens Tutorial with Example API

Tutorial built with .NET 6.0

Other versions available:

In this tutorial we'll go through an example of how to implement JWT (JSON Web Token) authentication with refresh tokens in a .NET 6.0 (ASP.NET Core) API.

For an extended example that includes email sign up, verification, forgot password and user management (CRUD) functionality see .NET 6.0 - Boilerplate API Tutorial with Email Sign Up, Verification, Authentication & Forgot Password.

Authentication implementation overview

Authentication is implemented with JWT access tokens and refresh tokens. On successful authentication the API returns a short lived JWT access token that expires after 15 minutes, and a refresh token that expires after 7 days in an HTTP Only cookie. The JWT is used for accessing secure routes on the API and the refresh token is used for generating new JWT access tokens when (or just before) they expire.

HTTP Only cookies are used for refresh tokens to increase security because they are not accessible to client-side javascript which prevents XSS (cross site scripting) attacks, and refresh tokens only have access to generate new JWT tokens (via the /users/refresh-token route) which prevents them from being used in CSRF (cross site request forgery) attacks.

ASP.NET Core API endpoints

The example .NET API has the following endpoints/routes to demonstrate authenticating with JWT, refreshing and revoking tokens, and accessing secure routes:

  • /users/authenticate - public route that accepts POST requests containing a username and password in the body. On success a JWT access token is returned with basic user details, and an HTTP Only cookie containing a refresh token.
  • /users/refresh-token - public route that accepts POST requests containing a cookie with a refresh token. On success a new JWT access token is returned with basic user details, and an HTTP Only cookie containing a new refresh token (see refresh token rotation just below for an explanation).
  • /users/revoke-token - secure route that accepts POST requests containing a refresh token either in the request body or in a cookie, if both are present priority is given to the request body. On success the token is revoked and can no longer be used to generate new JWT access tokens.
  • /users - secure route that accepts GET requests and returns a list of all the users in the application.
  • /users/{id} - secure route that accepts GET requests and returns the details of the user with the specified id.
  • /users/{id}/refresh-tokens - secure route that accepts GET requests and returns a list of all refresh tokens (active and revoked) of the user with the specified id.

Refresh token rotation

Each time a refresh token is used to generate a new JWT token (via the /users/refresh-token route), the refresh token is revoked and replaced by a new refresh token. This technique is known as Refresh Token Rotation and increases security by reducing the lifetime of refresh tokens, which makes it less likely that a compromised token will be valid (or valid for long). When a refresh token is rotated the new token is saved in the ReplacedByToken field of the revoked token to create an audit trail in the database.

Revoked and expired refresh token records are kept in the database for the number of days set in the RefreshTokenTTL property in the appsettings.json file. The default is 2 days, after which old inactive tokens are deleted by the user service in the Authenticate() and RefreshToken() methods.

Revoked token reuse detection

If an attempt is made to generate a new JWT token using a revoked refresh token, the API treats this as a potentially malicious user with a stolen (revoked) refresh token, or a valid user attempting to access the system after their token has been revoked by a malicious user with a stolen (active) refresh token. In either case the API revokes all descendant tokens because the token and its descendants were likely created on the same device which may have been compromised. The reason revoked is recorded as "Attempted reuse of revoked ancestor token" so the user can see it in the database or via the /users/{id}/refresh-tokens route.

EF Core InMemory database for testing

To keep the api code as simple as possible, it's configured to use the EF Core InMemory database provider which allows Entity Framework Core to create and connect to an in-memory database rather than you having to install a real db server. A test user is automatically created on startup in the Program.cs file.

The db provider can be easily switched out to connect a real database such as SQL Server, Oracle, MySql etc, for an example api that uses SQLite in development and SQL Server in production see .NET 6.0 - User Registration and Login Tutorial with Example API.

Code on GitHub

The tutorial project is available on GitHub at https://github.com/cornflourblue/dotnet-6-jwt-refresh-tokens-api.


.NET 6.0 Tutorial Contents


Tools required to run the .NET 6.0 Tutorial API Locally

To develop and run .NET 6.0 applications locally, download and install the following:

  • .NET SDK - includes the .NET runtime and command line tools
  • Visual Studio Code - code editor that runs on Windows, Mac and Linux
  • C# extension for Visual Studio Code - adds support to VS Code for developing .NET applications


Run the .NET JWT with Refresh Tokens API Locally

  1. Download or clone the tutorial project code from https://github.com/cornflourblue/dotnet-6-jwt-refresh-tokens-api
  2. Start the api by running dotnet run from the command line in the project root folder (where the WebApi.csproj file is located), you should see the message Now listening on: http://localhost:4000. Follow the instructions below to test with Postman or hook up with the example Angular or Vue application available.


Debugging in VS Code

You can start the application in debug mode in VS Code by opening the project root folder in VS Code and pressing F5 or by selecting Debug -> Start Debugging from the top menu. Running in debug mode allows you to attach breakpoints to pause execution and step through the application code. For more info on debugging .NET in VS Code see VS Code + .NET - Debug a .NET Web App in Visual Studio Code.

Before running in production

Before running in production make sure that you update the Secret property in the appsettings.json file, it is used to sign and verify JWT tokens for authentication, change it to a random string to ensure nobody else can generate a JWT with the same secret and gain unauthorized access to your API. A quick and easy way is join a couple of GUIDs together to make a long random string (e.g. from https://www.guidgenerator.com/).


Test the .NET API with Postman

Postman is a great tool for testing APIs, you can download it at https://www.postman.com/downloads.

Below are instrunctions on how to authenticate a user with Postman to get a JWT and refresh token from the API, how to use refresh tokens and revoke them, and how to fetch user details from secure routes with a JWT.

How to authenticate a user with Postman

To authenticate a user to get a JWT and refresh token follow these steps:

  1. Open a new request tab by clicking the plus (+) button at the end of the tabs.
  2. Change the HTTP method to POST with the dropdown selector on the left of the URL input field.
  3. In the URL field enter the address to the authenticate route of your local API - http://localhost:4000/users/authenticate.
  4. Select the Body tab below the URL field, change the body type radio button to raw, and change the format dropdown selector to JSON.
  5. Enter a JSON object containing the test username and password in the Body textarea:
    {
        "username": "test",
        "password": "test"
    }
  6. Click the Send button, you should receive a "200 OK" response containing the user details and a JWT token in the response body, and a cookie containing the refresh token.

Here's a screenshot of Postman after the request is sent and the user has been authenticated:

And this is the response cookies tab with the refresh token:


How to refresh a token with Postman

This step can only be done after the above authenticate step because a valid refresh token cookie is required.

To use a refresh token cookie to get a new JWT token and a new refresh token follow these steps:

  1. Open a new request tab by clicking the plus (+) button at the end of the tabs.
  2. Change the HTTP method to POST with the dropdown selector on the left of the URL input field.
  3. In the URL field enter the address to the refresh token route of your local API - http://localhost:4000/users/refresh-token.
  4. Click the Send button, you should receive a "200 OK" response containing the user details and a JWT token, and a cookie containing a new refresh token.
  5. Make a copy of the JWT value because we'll be using it in the next steps to access secure routes.

Here's a screenshot of Postman after the request is sent and the token has been refreshed:

And this is the response cookies tab with the new refresh token:


How to make an authenticated request to retrieve all users

To make an authenticated request to get all users with the JWT token from the previous step, follow these steps:

  1. Open a new request tab by clicking the plus (+) button at the end of the tabs.
  2. Change the HTTP method to GET with the dropdown selector on the left of the URL input field.
  3. In the URL field enter the address to the users route of your local API - http://localhost:4000/users.
  4. Select the Authorization tab below the URL field, change the type to >Bearer Token in the type dropdown selector, and paste the JWT token from the previous step into the Token field.
  5. Click the Send button, you should receive a "200 OK" response containing a JSON array with all the user records in the system (just a single test user in the example).

Here's a screenshot of Postman after making an authenticated request to get all users:


How to fetch all refresh tokens for a user

To get all refresh tokens for a user including active, expired and revoked tokens, follow these steps:

  1. Open a new request tab by clicking the plus (+) button at the end of the tabs.
  2. Change the HTTP method to GET with the dropdown selector on the left of the URL input field.
  3. In the URL field enter the address to the users route of your local API - http://localhost:4000/users/1/refresh-tokens.
  4. Select the Authorization tab below the URL field, change the type to Bearer Token in the type dropdown selector, and paste the JWT token from the previous authenticate (or refresh token) step into the Token field.
  5. Click the Send button, you should receive a "200 OK" response containing a JSON array with all the test user's refresh tokens.
  6. Make a copy of the last token value (the active token) because we'll use it in the next step showing how to revoke a token.

Here's a screenshot of Postman after making an authenticated request to get all refresh tokens for the test user:


How to revoke a token with Postman

To revoke a refresh token so it can no longer be used to generate new JWT tokens, follow these steps:

  1. Open a new request tab by clicking the plus (+) button at the end of the tabs.
  2. Change the HTTP method to POST with the dropdown selector on the left of the URL input field.
  3. In the URL field enter the address to the authenticate route of your local API - http://localhost:4000/users/revoke-token.
  4. Select the Authorization tab below the URL field, change the type to Bearer Token in the type dropdown selector, and paste the JWT token from the previous authenticate (or refresh token) step into the Token field.
  5. Select the Body tab below the URL field, change the body type radio button to raw, and change the format dropdown selector to JSON.
  6. Enter a JSON object containing the active refresh token from the previous step in the Body textarea, e.g:
    {
        "token": "ENTER THE ACTIVE REFRESH TOKEN HERE"
    }
  7. Click the Send button, you should receive a "200 OK" response with the message Token revoked.

NOTE: You can also revoke the token in the refreshToken cookie with the /users/revoke-token route, to revoke the refresh token in the cookie simply send the same request with an empty body.

Here's a screenshot of Postman after making the request and the token has been revoked:

 


Connect an Angular app to the JWT Refresh Tokens API

For full details about the example Angular  application see the post Angular 14 - JWT Authentication with Refresh Tokens Example & Tutorial. But to get up and running quickly just follow the below steps.

  1. Install Node.js and npm from https://nodejs.org.
  2. Download or clone the Angular 14 tutorial code from https://github.com/cornflourblue/angular-14-jwt-refresh-tokens
  3. Install all required npm packages by running npm install from the command line in the project root folder (where the package.json is located).
  4. Remove or comment out the line below the comment // provider used to create fake backend located in the /src/app/app.module.ts file.
  5. Start the application by running npm start from the command line in the project root folder, this will launch a browser displaying the Angular example application and it should be hooked up with the .NET 6.0 JWT Refresh Tokens API that you already have running.
 


Connect a Vue app to the JWT Refresh Tokens API

For full details about the example Vue  application see the post Vue 3 + Pinia - JWT Authentication with Refresh Tokens Example & Tutorial. But to get up and running quickly just follow the below steps.

  1. Install Node.js and npm from https://nodejs.org.
  2. Download or clone the Angular 14 tutorial code from https://github.com/cornflourblue/vue-3-pinia-jwt-refresh-tokens
  3. Install all required npm packages by running npm install or npm i from the command line in the project root folder (where the package.json is located).
  4. Remove or comment out the 2 lines below the comment // setup fake backend located in the /src/main.js file.
  5. Start the application by running npm run dev from the command line in the project root folder.
  6. Open a browser and go to the application at http://localhost:3000, it should be hooked up with the .NET 6.0 JWT Refresh Tokens API that you already have running.
 

.NET 6.0 Tutorial Project Structure

The .NET tutorial project is organised into the following folders:

Authorization
Contains the classes responsible for implementing custom JWT authentication and authorization in the api.

Controllers
Define the endpoints / routes for the web api, controllers are the gateways into the web api for client applications via http requests.

Models
Represent request and response models for controller methods, request models define the parameters for incoming requests, and response models define the data that is returned.

Services
Contain business logic, validation and database access code.

Entities
Represent the application data that is stored in the database.
Entity Framework Core (EF Core) maps relational data from the database to instances of C# entity objects to be used within the application for data management and CRUD operations.

Helpers
Anything that doesn't fit into the above folders.

Click any of the below links to jump down to a description of each file along with its code:

 

.NET Allow Anonymous Attribute

Path: /Authorization/AllowAnonymousAttribute.cs

The custom [AllowAnonymous] attribute is used to allow anonymous access to specified action methods of controllers that are decorated with the [Authorize] attribute. It's used in the users controller to allow anonymous access to the authenticate and refresh-token action methods. The custom authorize attribute below skips authorization if the action method is decorated with [AllowAnonymous].

I created a custom allow anonymous (instead of using the built in one) for consistency and to avoid ambiguous reference errors between namespaces.

namespace WebApi.Authorization;

[AttributeUsage(AttributeTargets.Method)]
public class AllowAnonymousAttribute : Attribute
{ }
 

.NET Custom Authorize Attribute

Path: /Authorization/AuthorizeAttribute.cs

The custom [Authorize] attribute is used to restrict access to controllers or specified action methods. Only authorized requests are allowed to access action methods that are decorated with the [Authorize] attribute.

When a controller is decorated with the [Authorize] attribute all action methods in the controller are restricted to authorized requests, except for methods decorated with the custom [AllowAnonymous] attribute above.

Authorization is performed by the OnAuthorization method which checks if there is an authenticated user attached to the current request (context.HttpContext.Items["User"]). An authenticated user is attached by the custom jwt middleware if the request contains a valid JWT access token.

On successful authorization no action is taken and the request is passed through to the controller action method, if authorization fails a 401 Unauthorized response is returned.

namespace WebApi.Authorization;

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using WebApi.Entities;

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AuthorizeAttribute : Attribute, IAuthorizationFilter
{
    public void OnAuthorization(AuthorizationFilterContext context)
    {
        // skip authorization if action is decorated with [AllowAnonymous] attribute
        var allowAnonymous = context.ActionDescriptor.EndpointMetadata.OfType<AllowAnonymousAttribute>().Any();
        if (allowAnonymous)
            return;

        // authorization
        var user = (User)context.HttpContext.Items["User"];
        if (user == null)
            context.Result = new JsonResult(new { message = "Unauthorized" }) { StatusCode = StatusCodes.Status401Unauthorized };
    }
}
 

.NET Custom JWT Middleware

Path: /Authorization/JwtMiddleware.cs

The custom JWT middleware extracts the JWT token from the request Authorization header (if there is one) and validates it with the jwtUtils.ValidateToken() method. If validation is successful the user id from the token is returned, and the authenticated user object is attached to the HttpContext.Items collection to make it accessible within the scope of the current request.

If token validation fails or there is no token, the request is only allowed to access public (anonymous) routes because there isn't an authenticated user object attached to the HTTP context. The authorization logic that checks if the user object is attached is located in the custom authorize attribute, and if authorization fails it returns a 401 Unauthorized response.

namespace WebApi.Authorization;

using Microsoft.Extensions.Options;
using WebApi.Helpers;
using WebApi.Services;

public class JwtMiddleware
{
    private readonly RequestDelegate _next;
    private readonly AppSettings _appSettings;

    public JwtMiddleware(RequestDelegate next, IOptions<AppSettings> appSettings)
    {
        _next = next;
        _appSettings = appSettings.Value;
    }

    public async Task Invoke(HttpContext context, IUserService userService, IJwtUtils jwtUtils)
    {
        var token = context.Request.Headers["Authorization"].FirstOrDefault()?.Split(" ").Last();
        var userId = jwtUtils.ValidateJwtToken(token);
        if (userId != null)
        {
            // attach user to context on successful jwt validation
            context.Items["User"] = userService.GetById(userId.Value);
        }

        await _next(context);
    }
}
 

.NET JWT Utils

Path: /Authorization/JwtUtils.cs

The JWT utils class contains methods for generating and validating JWT tokens, and generating refresh tokens.

The GenerateJwtToken() method returns a short lived JWT token that expires after 15 minutes, it contains the id of the specified user as the "id" claim, meaning the token payload will contain the property "id": <userId> (e.g. "id": 1). The token is created with the JwtSecurityTokenHandler class and digitally signed using the secret key stored in the app settings file.

The ValidateJwtToken() method attempts to validate the provided JWT token and return the user id ("id") from the token claims. If validation fails null is returned.

The GenerateRefreshToken() method returns a new refresh token that expires after 7 days. The created date and user ip address are saved with the token to create an audit trail and to help identify any unusual activity. The local getUniqueToken() method returns a guaranteed unique refresh token string by verifying that it doesn't already exist in the database.

namespace WebApi.Authorization;

using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using WebApi.Entities;
using WebApi.Helpers;

public interface IJwtUtils
{
    public string GenerateJwtToken(User user);
    public int? ValidateJwtToken(string token);
    public RefreshToken GenerateRefreshToken(string ipAddress);
}

public class JwtUtils : IJwtUtils
{
    private DataContext _context;
    private readonly AppSettings _appSettings;

    public JwtUtils(
        DataContext context,
        IOptions<AppSettings> appSettings)
    {
        _context = context;
        _appSettings = appSettings.Value;
    }

    public string GenerateJwtToken(User user)
    {
        // generate token that is valid for 15 minutes
        var tokenHandler = new JwtSecurityTokenHandler();
        var key = Encoding.ASCII.GetBytes(_appSettings.Secret);
        var tokenDescriptor = new SecurityTokenDescriptor
        {
            Subject = new ClaimsIdentity(new[] { new Claim("id", user.Id.ToString()) }),
            Expires = DateTime.UtcNow.AddMinutes(15),
            SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
        };
        var token = tokenHandler.CreateToken(tokenDescriptor);
        return tokenHandler.WriteToken(token);
    }

    public int? ValidateJwtToken(string token)
    {
        if (token == null)
            return null;

        var tokenHandler = new JwtSecurityTokenHandler();
        var key = Encoding.ASCII.GetBytes(_appSettings.Secret);
        try
        {
            tokenHandler.ValidateToken(token, new TokenValidationParameters
            {
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(key),
                ValidateIssuer = false,
                ValidateAudience = false,
                // set clockskew to zero so tokens expire exactly at token expiration time (instead of 5 minutes later)
                ClockSkew = TimeSpan.Zero
            }, out SecurityToken validatedToken);

            var jwtToken = (JwtSecurityToken)validatedToken;
            var userId = int.Parse(jwtToken.Claims.First(x => x.Type == "id").Value);

            // return user id from JWT token if validation successful
            return userId;
        }
        catch
        {
            // return null if validation fails
            return null;
        }
    }

    public RefreshToken GenerateRefreshToken(string ipAddress)
    {
        var refreshToken = new RefreshToken
        {
            Token = getUniqueToken(),
            // token is valid for 7 days
            Expires = DateTime.UtcNow.AddDays(7),
            Created = DateTime.UtcNow,
            CreatedByIp = ipAddress
        };

        return refreshToken;

        string getUniqueToken()
        {
            // token is a cryptographically strong random sequence of values
            var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
            // ensure token is unique by checking against db
            var tokenIsUnique = !_context.Users.Any(u => u.RefreshTokens.Any(t => t.Token == token));

            if (!tokenIsUnique)
                return getUniqueToken();
            
            return token;
        }
    }
}
 
 

.NET Users Controller

Path: /Controllers/UsersController.cs

The .NET users controller defines and handles all routes / endpoints for the api that relate to users, including authentication, refreshing and revoking tokens, and fetching users and refresh tokens. Within each route the controller calls the user service to perform the action required, this is done to keep the controller lean and to maintain a clean separation of concerns between the route handling code in the controller and the business logic + data access code in the service.

The controller actions are secured with JWT using the [Authorize] attribute, with the exception of the Authenticate and RefreshToken methods which allow public access by overriding the [Authorize] attribute on the controller with an [AllowAnonymous] attribute on each action method. I chose this approach so any new action methods added to the controller will be secure by default unless explicitly made public.

The setTokenCookie() helper method appends an HTTP Only cookie with a refresh token to the response. HTTP Only cookies are used to increase security because they are not accessible to client-side javascript which prevents XSS (cross site scripting) attacks, and refresh tokens only have access to generate new JWT tokens (via the /users/refresh-token route) which prevents them from being used in CSRF (cross site request forgery) attacks.

namespace WebApi.Controllers;

using Microsoft.AspNetCore.Mvc;
using WebApi.Authorization;
using WebApi.Models.Users;
using WebApi.Services;

[Authorize]
[ApiController]
[Route("[controller]")]
public class UsersController : ControllerBase
{
    private IUserService _userService;

    public UsersController(IUserService userService)
    {
        _userService = userService;
    }

    [AllowAnonymous]
    [HttpPost("authenticate")]
    public IActionResult Authenticate(AuthenticateRequest model)
    {
        var response = _userService.Authenticate(model, ipAddress());
        setTokenCookie(response.RefreshToken);
        return Ok(response);
    }

    [AllowAnonymous]
    [HttpPost("refresh-token")]
    public IActionResult RefreshToken()
    {
        var refreshToken = Request.Cookies["refreshToken"];
        var response = _userService.RefreshToken(refreshToken, ipAddress());
        setTokenCookie(response.RefreshToken);
        return Ok(response);
    }

    [HttpPost("revoke-token")]
    public IActionResult RevokeToken(RevokeTokenRequest model)
    {
        // accept refresh token in request body or cookie
        var token = model.Token ?? Request.Cookies["refreshToken"];

        if (string.IsNullOrEmpty(token))
            return BadRequest(new { message = "Token is required" });

        _userService.RevokeToken(token, ipAddress());
        return Ok(new { message = "Token revoked" });
    }

    [HttpGet]
    public IActionResult GetAll()
    {
        var users = _userService.GetAll();
        return Ok(users);
    }

    [HttpGet("{id}")]
    public IActionResult GetById(int id)
    {
        var user = _userService.GetById(id);
        return Ok(user);
    }

    [HttpGet("{id}/refresh-tokens")]
    public IActionResult GetRefreshTokens(int id)
    {
        var user = _userService.GetById(id);
        return Ok(user.RefreshTokens);
    }

    // helper methods

    private void setTokenCookie(string token)
    {
        // append cookie with refresh token to the http response
        var cookieOptions = new CookieOptions
        {
            HttpOnly = true,
            Expires = DateTime.UtcNow.AddDays(7)
        };
        Response.Cookies.Append("refreshToken", token, cookieOptions);
    }

    private string ipAddress()
    {
        // get source ip address for the current request
        if (Request.Headers.ContainsKey("X-Forwarded-For"))
            return Request.Headers["X-Forwarded-For"];
        else
            return HttpContext.Connection.RemoteIpAddress.MapToIPv4().ToString();
    }
}
 

.NET Refresh Token Entity

Path: /Entities/RefreshToken.cs

The refresh token entity class represents the data stored in the database for refresh tokens.

Entity classes are also used to pass data between different parts of the application (e.g. between services and controllers) and can be used to return http response data from controller action methods.

The [Owned] attribute marks the refresh token class as an owned entity type, meaning it can only exist as a child / dependant of another entity class. In this example a refresh token is always owned by a user entity.

The [Key] attribute explicitly sets the Id property as the primary key in the refresh token database table, this usually is not required since Entity Framework automatically looks for a property named Id to set as the primary key, however the [Owned] attribute causes Entity Framework to create a composite primary key consisting of the id and owner id (refresh token id + user id) which can cause errors with auto generated id fields. Explicit use of the [Key] attribute prevents a composite primary key from being created by the [Owned] attribute.

The [JsonIgnore] attribute prevents the id from being serialized and returned with refresh token data in api responses.

namespace WebApi.Entities;

using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;

[Owned]
public class RefreshToken
{
    [Key]
    [JsonIgnore]
    public int Id { get; set; }
    public string Token { get; set; }
    public DateTime Expires { get; set; }
    public DateTime Created { get; set; }
    public string CreatedByIp { get; set; }
    public DateTime? Revoked { get; set; }
    public string RevokedByIp { get; set; }
    public string ReplacedByToken { get; set; }
    public string ReasonRevoked { get; set; }
    public bool IsExpired => DateTime.UtcNow >= Expires;
    public bool IsRevoked => Revoked != null;
    public bool IsActive => !IsRevoked && !IsExpired;
}
 

.NET User Entity

Path: /Entities/User.cs

The user entity class represents the data stored in the database for users.

Entity classes are also used to pass data between different parts of the application (e.g. between services and controllers) and can be used to return http response data from controller action methods.

The [JsonIgnore] attribute prevents the PasswordHash and RefreshTokens properties from being serialized and returned with user data in api responses. There is a dedicated route for fetching refresh token data (/users/{id}/refresh-tokens).

namespace WebApi.Entities;

using System.Text.Json.Serialization;

public class User
{
    public int Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Username { get; set; }

    [JsonIgnore]
    public string PasswordHash { get; set; }

    [JsonIgnore]
    public List<RefreshToken> RefreshTokens { get; set; }
}
 

.NET Custom App Exception

Path: /Helpers/AppException.cs

The app exception is a custom exception class used to differentiate between handled and unhandled exceptions in the .NET API. Handled exceptions are generated by application code and used to return friendly error messages, for example business logic or validation exceptions caused by invalid request parameters, whereas unhandled exceptions are generated by the .NET framework or caused by bugs in application code.

namespace WebApi.Helpers;

using System.Globalization;

// custom exception class for throwing application specific exceptions (e.g. for validation) 
// that can be caught and handled within the application
public class AppException : Exception
{
    public AppException() : base() {}

    public AppException(string message) : base(message) { }

    public AppException(string message, params object[] args) 
        : base(String.Format(CultureInfo.CurrentCulture, message, args))
    {
    }
}
 

.NET App Settings Class

Path: /Helpers/AppSettings.cs

The app settings class contains properties defined in the appsettings.json file and is used for accessing application settings via objects that are injected into classes using the .NET built in dependency injection (DI) system. For example the user service accesses app settings via an IOptions<AppSettings> appSettings object that is injected into the constructor.

Mapping of configuration sections to classes is done in the Program.cs file.

namespace WebApi.Helpers;

public class AppSettings
{
    public string Secret { get; set; }

    // refresh token time to live (in days), inactive tokens are
    // automatically deleted from the database after this time
    public int RefreshTokenTTL { get; set; }
}
 

.NET Data Context

Path: /Helpers/DataContext.cs

The data context class is used for accessing application data through Entity Framework. It derives from the Entity Framework DbContext class and has a public Users property for accessing and managing user data. The data context is used by the user service for handling all low level data (CRUD) operations.

options.UseInMemoryDatabase() configures Entity Framework to create and connect to an in-memory database so the API can be tested without a real database, this can be easily updated to connect to a real db server such as SQL Server, Oracle, MySql etc. For an example api that uses SQLite in development and SQL Server in production see .NET 6.0 - User Registration and Login Tutorial with Example API.

namespace WebApi.Helpers;

using Microsoft.EntityFrameworkCore;
using WebApi.Entities;

public class DataContext : DbContext
{
    public DbSet<User> Users { get; set; }

    private readonly IConfiguration Configuration;

    public DataContext(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    protected override void OnConfiguring(DbContextOptionsBuilder options)
    {
        // in memory database used for simplicity, change to a real db for production applications
        options.UseInMemoryDatabase("TestDb");
    }
}
 

.NET Global Error Handler Middleware

Path: /Helpers/ErrorHandlerMiddleware.cs

The global error handler is used catch all errors and remove the need for duplicated error handling code throughout the .NET api. It's configured as middleware in the Porgram.cs file.

Errors of type AppException are treated as custom (app specific) errors that return a 400 Bad Request response, the .NET built-in KeyNotFoundException class is used to return 404 Not Found responses, all other exceptions are unhandled and return a 500 Internal Server Error response.

See the user service for examples of custom errors and not found errors thrown by the api.

namespace WebApi.Helpers;

using System.Net;
using System.Text.Json;

public class ErrorHandlerMiddleware
{
    private readonly RequestDelegate _next;

    public ErrorHandlerMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task Invoke(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception error)
        {
            var response = context.Response;
            response.ContentType = "application/json";

            switch(error)
            {
                case AppException e:
                    // custom application error
                    response.StatusCode = (int)HttpStatusCode.BadRequest;
                    break;
                case KeyNotFoundException e:
                    // not found error
                    response.StatusCode = (int)HttpStatusCode.NotFound;
                    break;
                default:
                    // unhandled error
                    response.StatusCode = (int)HttpStatusCode.InternalServerError;
                    break;
            }

            var result = JsonSerializer.Serialize(new { message = error?.Message });
            await response.WriteAsync(result);
        }
    }
}
 

.NET Authenticate Request Model

Path: /Models/Users/AuthenticateRequest.cs

The authenticate request model defines the parameters for incoming POST requests to the /users/authenticate route, it is attached to the route by setting it as the parameter to the Authenticate action method of the users controller. When an HTTP POST request is received by the route, the data from the body is bound to an instance of the AuthenticateRequest class, validated and passed to the method.

.NET Data Annotations are used to automatically handle model validation, the [Required] attribute sets both the username and password as required fields so if either are missing a validation error message is returned from the api.

namespace WebApi.Models.Users;

using System.ComponentModel.DataAnnotations;

public class AuthenticateRequest
{
    [Required]
    public string Username { get; set; }

    [Required]
    public string Password { get; set; }
}
 

.NET Authenticate Response Model

Path: /Models/Users/AuthenticateResponse.cs

The authenticate response model defines the data returned by the Authenticate method of the users controller after successful authentication. It includes basic user details, a JWT token and a refresh token.

The refresh token is decorated with the [JsonIgnore] attribute to prevent it from being returned in the response body because it is returned in an HTTP Only cookie. An HTTP Only cookie increases security because it is not accessible to client-side javascript which prevents XSS (cross site scripting), and a refresh token only has access to generate a new JWT token (via the /users/refresh-token route) which prevents it from being used in CSRF (cross site request forgery).

namespace WebApi.Models.Users;

using System.Text.Json.Serialization;
using WebApi.Entities;

public class AuthenticateResponse
{
    public int Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Username { get; set; }
    public string JwtToken { get; set; }

    [JsonIgnore] // refresh token is returned in http only cookie
    public string RefreshToken { get; set; }

    public AuthenticateResponse(User user, string jwtToken, string refreshToken)
    {
        Id = user.Id;
        FirstName = user.FirstName;
        LastName = user.LastName;
        Username = user.Username;
        JwtToken = jwtToken;
        RefreshToken = refreshToken;
    }
}
 

.NET Revoke Token Request Model

Path: /Models/Users/RevokeTokenRequest.cs

The revoke token request model defines the parameters for incoming POST requests to the /users/revoke-token route of the api, it is attached to the route by setting it as the parameter to the RevokeToken action method of the users controller. When an HTTP POST request is received by the route, the data from the body is bound to an instance of the RevokeToken class, validated and passed to the method.

The Token property is optional in the request body because the route also supports revoking the token sent in the refreshToken cookie. If both are present priority is given to the token in the request body.

namespace WebApi.Models.Users;

public class RevokeTokenRequest
{
    public string Token { get; set; }
}
 

.NET User Service

Path: /Services/UserService.cs

The user service contains the core logic for authentication, generating JWT and refresh tokens, refreshing and revoking tokens, and fetching user data.

The top of the UserService.cs file contains the IUserService interface which defines the public methods for the user service, below the interface is the concrete UserService class that implements the interface.

The Authenticate() method finds a user by username and verifies the password against the hashed password in the database using BCrypt, on success the user details are returned with a JWT and a refresh token. For more info on hashing and verifying passwords see .NET 6.0 - Hash and Verify Passwords with BCrypt.

The RefreshToken() method accepts an active refresh token and returns the user details with a new JWT token and a new refresh token. The old refresh token (the one used to make the request) is revoked and can no longer be used, this technique is known as refresh token rotation and increases security by making refresh tokens short lived. When a refresh token is rotated the new token is saved in the ReplacedByToken field of the revoked token to create an audit trail in the database.

If an attempt is made to use a revoked refresh token, the API treats it as a potentially malicious user with a stolen (revoked) refresh token, or a valid user attempting to access the system after their token has been revoked by a malicious user with a stolen (active) refresh token. In either case the API revokes all descendant tokens because the token and its descendants were likely created on the same device which may have been compromised.

The RevokeToken() method accepts an active refresh token and revokes it so it can no longer be used. A token is revoked when it has a Revoked date. The source ip address of the request that revoked the token is saved in the RevokedByIp field.

The GetAll() method returns a list of all users in the system, and the GetById() method returns the user with the specified id.

Revoked and expired refresh tokens are kept in the database for the number of days set in the RefreshTokenTTL property in the appsettings.json file. The default is 2 days, after which old inactive (revoked or expired) tokens are deleted by the Authenticate() and RefreshToken() methods.

namespace WebApi.Services;

using BCrypt.Net;
using Microsoft.Extensions.Options;
using WebApi.Entities;
using WebApi.Helpers;
using WebApi.Models.Users;
using WebApi.Authorization;

public interface IUserService
{
    AuthenticateResponse Authenticate(AuthenticateRequest model, string ipAddress);
    AuthenticateResponse RefreshToken(string token, string ipAddress);
    void RevokeToken(string token, string ipAddress);
    IEnumerable<User> GetAll();
    User GetById(int id);
}

public class UserService : IUserService
{
    private DataContext _context;
    private IJwtUtils _jwtUtils;
    private readonly AppSettings _appSettings;

    public UserService(
        DataContext context,
        IJwtUtils jwtUtils,
        IOptions<AppSettings> appSettings)
    {
        _context = context;
        _jwtUtils = jwtUtils;
        _appSettings = appSettings.Value;
    }

    public AuthenticateResponse Authenticate(AuthenticateRequest model, string ipAddress)
    {
        var user = _context.Users.SingleOrDefault(x => x.Username == model.Username);

        // validate
        if (user == null || !BCrypt.Verify(model.Password, user.PasswordHash))
            throw new AppException("Username or password is incorrect");

        // authentication successful so generate jwt and refresh tokens
        var jwtToken = _jwtUtils.GenerateJwtToken(user);
        var refreshToken = _jwtUtils.GenerateRefreshToken(ipAddress);
        user.RefreshTokens.Add(refreshToken);

        // remove old refresh tokens from user
        removeOldRefreshTokens(user);

        // save changes to db
        _context.Update(user);
        _context.SaveChanges();

        return new AuthenticateResponse(user, jwtToken, refreshToken.Token);
    }

    public AuthenticateResponse RefreshToken(string token, string ipAddress)
    {
        var user = getUserByRefreshToken(token);
        var refreshToken = user.RefreshTokens.Single(x => x.Token == token);

        if (refreshToken.IsRevoked)
        {
            // revoke all descendant tokens in case this token has been compromised
            revokeDescendantRefreshTokens(refreshToken, user, ipAddress, $"Attempted reuse of revoked ancestor token: {token}");
            _context.Update(user);
            _context.SaveChanges();
        }

        if (!refreshToken.IsActive)
            throw new AppException("Invalid token");

        // replace old refresh token with a new one (rotate token)
        var newRefreshToken = rotateRefreshToken(refreshToken, ipAddress);
        user.RefreshTokens.Add(newRefreshToken);

        // remove old refresh tokens from user
        removeOldRefreshTokens(user);

        // save changes to db
        _context.Update(user);
        _context.SaveChanges();

        // generate new jwt
        var jwtToken = _jwtUtils.GenerateJwtToken(user);

        return new AuthenticateResponse(user, jwtToken, newRefreshToken.Token);
    }

    public void RevokeToken(string token, string ipAddress)
    {
        var user = getUserByRefreshToken(token);
        var refreshToken = user.RefreshTokens.Single(x => x.Token == token);

        if (!refreshToken.IsActive)
            throw new AppException("Invalid token");

        // revoke token and save
        revokeRefreshToken(refreshToken, ipAddress, "Revoked without replacement");
        _context.Update(user);
        _context.SaveChanges();
    }

    public IEnumerable<User> GetAll()
    {
        return _context.Users;
    }

    public User GetById(int id)
    {
        var user = _context.Users.Find(id);
        if (user == null) throw new KeyNotFoundException("User not found");
        return user;
    }

    // helper methods

    private User getUserByRefreshToken(string token)
    {
        var user = _context.Users.SingleOrDefault(u => u.RefreshTokens.Any(t => t.Token == token));

        if (user == null)
            throw new AppException("Invalid token");

        return user;
    }

    private RefreshToken rotateRefreshToken(RefreshToken refreshToken, string ipAddress)
    {
        var newRefreshToken = _jwtUtils.GenerateRefreshToken(ipAddress);
        revokeRefreshToken(refreshToken, ipAddress, "Replaced by new token", newRefreshToken.Token);
        return newRefreshToken;
    }

    private void removeOldRefreshTokens(User user)
    {
        // remove old inactive refresh tokens from user based on TTL in app settings
        user.RefreshTokens.RemoveAll(x => 
            !x.IsActive && 
            x.Created.AddDays(_appSettings.RefreshTokenTTL) <= DateTime.UtcNow);
    }

    private void revokeDescendantRefreshTokens(RefreshToken refreshToken, User user, string ipAddress, string reason)
    {
        // recursively traverse the refresh token chain and ensure all descendants are revoked
        if(!string.IsNullOrEmpty(refreshToken.ReplacedByToken))
        {
            var childToken = user.RefreshTokens.SingleOrDefault(x => x.Token == refreshToken.ReplacedByToken);
            if (childToken.IsActive)
                revokeRefreshToken(childToken, ipAddress, reason);
            else
                revokeDescendantRefreshTokens(childToken, user, ipAddress, reason);
        }
    }

    private void revokeRefreshToken(RefreshToken token, string ipAddress, string reason = null, string replacedByToken = null)
    {
        token.Revoked = DateTime.UtcNow;
        token.RevokedByIp = ipAddress;
        token.ReasonRevoked = reason;
        token.ReplacedByToken = replacedByToken;
    }
}
 

.NET App Settings

Path: /appsettings.json

Root configuration file containing application settings for all environments, it includes the Secret used for signing and verifying JWT tokens and the refresh token time to live (RefreshTokenTTL) which sets the number of days to keep inactive refresh tokens in the database

IMPORTANT: The "Secret" property is used to sign and verify JWT tokens for authentication, change it to a random string to ensure nobody else can generate a JWT with the same secret and gain unauthorized access to your api. A quick and easy way is join a couple of GUIDs together to make a long random string (e.g. from https://www.guidgenerator.com/).

{
    "AppSettings": {
        "Secret": "THIS IS USED TO SIGN AND VERIFY JWT TOKENS, REPLACE IT WITH YOUR OWN SECRET, IT CAN BE ANY STRING",
        "RefreshTokenTTL": 2
    },
    "Logging": {
        "LogLevel": {
            "Default": "Information",
            "Microsoft.AspNetCore": "Warning"
        }
    }
}
 

OmniSharp Config

Path: /omnisharp.json

This file contains configuration options for the C# extension in VS Code. The useBundledOnly option tells the C# extension to use the bundled version of MSBuild instead of the global version to prevent errors if you have an older version of MSBuild installed globally (e.g. as part of Visual Studio).

{
    "msbuild": {
        "useBundledOnly": true
    }
}
 

.NET Program

Path: /Program.cs

The .NET 6 Program file contains top-level statements which are converted by the new C# 10 compiler into a Main() method and class for the .NET program. The Main() method is the entry point for a .NET application, when an app is started it searches for the Main() method to begin execution. The top-level statements can be located anywhere in the project but are typically placed in the Program.cs file, only one file can contain top-level statements within a .NET application.

The WebApplication class handles app startup, lifetime management, web server configuration and more. A WebApplicationBuilder is first created by calling the static method WebApplication.CreateBuilder(args), the builder is used to configure services for dependency injection (DI), a WebApplication instance is created by calling builder.Build(), the app instance is used to configure the HTTP request pipeline (middleware), then the app is started by calling app.Run().

I wrapped the add services... and configure HTTP... sections in curly brackets {} to group them together visually, the brackets are completely optional.

Internally the WebApplicationBuilder class calls the ConfigureWebHostDefaults() extension method which configures hosting for the web app including setting Kestrel as the web server, adding host filtering middleware and enabling IIS integration. For more info on the default builder settings see https://docs.microsoft.com/aspnet/core/fundamentals/host/generic-host#default-builder-settings.

using System.Text.Json.Serialization;
using WebApi.Authorization;
using WebApi.Entities;
using WebApi.Helpers;
using WebApi.Services;

var builder = WebApplication.CreateBuilder(args);

// add services to DI container
{
    var services = builder.Services;
    var env = builder.Environment;
 
    services.AddDbContext<DataContext>();
    services.AddCors();
    services.AddControllers()
        .AddJsonOptions(x => x.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull);

    // configure strongly typed settings object
    services.Configure<AppSettings>(builder.Configuration.GetSection("AppSettings"));

    // configure DI for application services
    services.AddScoped<IJwtUtils, JwtUtils>();
    services.AddScoped<IUserService, UserService>();
}

var app = builder.Build();

// add hardcoded test user to db on startup
using (var scope = app.Services.CreateScope())
{
    var context = scope.ServiceProvider.GetRequiredService<DataContext>();    
    var testUser = new User
    {
        FirstName = "Test",
        LastName = "User",
        Username = "test",
        PasswordHash = BCrypt.Net.BCrypt.HashPassword("test")
    };
    context.Users.Add(testUser);
    context.SaveChanges();
}

// configure HTTP request pipeline
{
    // global cors policy
    app.UseCors(x => x
        .SetIsOriginAllowed(origin => true)
        .AllowAnyMethod()
        .AllowAnyHeader()
        .AllowCredentials());

    // global error handler
    app.UseMiddleware<ErrorHandlerMiddleware>();

    // custom jwt auth middleware
    app.UseMiddleware<JwtMiddleware>();

    app.MapControllers();
}

app.Run("http://localhost:4000");
 

.NET CSProj File

Path: /WebApi.csproj

The csproj (C# project) is an MSBuild based file that contains target framework and NuGet package dependency information for the application. The ImplicitUsings feature is enabled which tells the compiler to auto generate a set of global using directives based on the project type, removing the need to include a lot of common using statements. The global using statements are auto generated when you build the project and can be found in the file /obj/Debug/net6.0/WebApi.GlobalUsings.g.cs.

For more info on the C# project file see .NET + MSBuild - C# Project File (.csproj) in a Nutshell.

<Project Sdk="Microsoft.NET.Sdk.Web">
    <PropertyGroup>
        <TargetFramework>net6.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
    </PropertyGroup>
    <ItemGroup>
        <PackageReference Include="BCrypt.Net-Next" Version="4.0.2" />
        <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.1" />
        <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="6.0.1" />
        <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.15.0" />
    </ItemGroup>
</Project>

 


Need Some .NET Help?

Search fiverr for freelance .NET developers.


Follow me for updates

On Twitter or RSS.


When I'm not coding...

Me and Tina are on a motorcycle adventure around Australia.
Come along for the ride!


Comments


Supported by