.NET 6.0 – Boilerplate API Tutorial with Email Sign Up, Verification, Authentication & Forgot Password

Tutorial: .NET 6.0 – Boilerplate API Tutorial with Email Sign Up, Verification, Authentication & Forgot Password

.NET Tutorial Contents

The boilerplate tutorial is organised into the following main sections:

.NET 6.0 Boilerplate Overview

The boilerplate API allows you to register a user account, login and perform different actions based on your role. The Admin role has full access to manage (add/edit/delete) any account in the system, the User role has access to update/delete their own account. The first account registered is automatically assigned the Admin role and subsequent registrations are assigned the User role.

On registration the API sends a verification email with a token and instructions to the account email address, accounts must be verified before they can authenticate. SMTP settings for email are configured in appsettings.json. If you don’t have an SMTP service, for quick testing you can use the fake SMTP service https://ethereal.email/ to create a temporary inbox, just click Create Ethereal Account and copy the SMTP configuration options.

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. Refresh tokens only have access to generate new JWT tokens (via the /accounts/refresh-token route), they cannot perform any other secure action which prevents them from being used in CSRF (cross site request forgery) attacks.

API endpoints

The example .NET 6 API has the following endpoints/routes to demonstrate email sign up and verification, authentication and role based autorization, refreshing and revoking tokens, forgot password and reset password, and secure account management routes:

  • POST /accounts/authenticate – public route that accepts POST requests containing an email and password in the body. On success a JWT access token is returned with basic account details, and an HTTP Only cookie containing a refresh token.
  • POST /accounts/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 account details, and an HTTP Only cookie containing a new refresh token (see refresh token rotation just below for an explanation).
  • POST /accounts/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.
  • POST /accounts/register – public route that accepts POST requests containing account registration details. On success the account is registered and a verification email is sent to the email address of the account, accounts must be verified before they can authenticate.
  • POST /accounts/verify-email – public route that accepts POST requests containing an account verification token. On success the account is verified and can now login.
  • POST /accounts/forgot-password – public route that accepts POST requests containing an account email address. On success a password reset email is sent to the email address of the account. The email contains a single use reset token that is valid for one day.
  • POST /accounts/validate-reset-token – public route that accepts POST requests containing a password reset token. A message is returned to indicate if the token is valid or not.
  • POST /accounts/reset-password – a public route that accepts POST requests containing a reset token, password and confirm password. On success, the account password is reset.
  • GET /accounts – secure route restricted to the Admin the role that accepts GET requests and returns a list of all the accounts in the application.
  • POST /accounts – secure route restricted to the Admin role that accepts POST requests containing new account details. On success the account is created and automatically verified.
  • GET /accounts/{id} – the secure route that accepts GET requests and returns the details of the account with the specified id. The Admin role can access any account, the User role can only access their own account.
  • PUT /accounts/{id} – the secure route that accepts PUT requests to update the details of the account with the specified id. The Admin role can update any account including its role, the User role can only update its own account details except for the role.
  • DELETE /accounts/{id} – a secure route that accepts DELETE requests to delete the account with the specified id. The Admin role can delete any account, the User role can only delete their own account.

Refresh token rotation

Each time a refresh token is used to generate a new JWT token (via the /accounts/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 account 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" against the revoked tokens in the database.

SQL database setup and configuration

To try to keep things simple the boilerplate API uses an SQLite database, SQLite is self-contained and doesn’t require a full database server to be installed. The database is automatically created on startup in the Program.cs file by triggering the execution of the EF Core migrations in the /Migrations folder.

Code on GitHub

The boilerplate API project is available on GitHub at https://github.com/cornflourblue/dotnet-6-signup-verification-api.

Tools required to run the .NET 6.0 Tutorial Example 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

Install dotnet ef tools

The .NET Entity Framework Core tools (dotnet ef) are used to generate EF Core migrations, to install the EF Core tools globally run dotnet tool install -g dotnet-ef, or to update run dotnet tool update -g dotnet-ef.

For more info on EF Core tools see https://docs.microsoft.com/ef/core/cli/dotnet.

For more info on EF Core migrations see https://docs.microsoft.com/ef/core/managing-schemas/migrations.

Run the .NET 6.0 Boilerplate API Locally

  1. Download or clone the tutorial project code from https://github.com/cornflourblue/dotnet-6-signup-verification-api
  2. Configure SMTP settings for email within the AppSettings section in the /appsettings.json file. For quick testing you can create a temporary inbox at https://ethereal.email/ and copy the SMTP configuration options.
  3. 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, and you can view the Swagger API documentation at http://localhost:4000/swagger.
  4. Follow the instructions below to test with Postman or hook up with one of the example single page applications available (Angular or React).

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.

Before running in production

Before running in production also 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/).

Run an Angular App with the .NET 6.0 Boilerplate API

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

Run a React App with the .NET 6.0 Boilerplate API.

  1. Download or clone the React tutorial code from https://github.com/cornflourblue/react-signup-verification-boilerplate
  2. 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).
  3. Remove or comment out the 2 lines below the comment // setup fake backend located in the /src/index.jsx file.
  4. Start the application by running npm start from the command line in the project root folder, this will launch a browser displaying the application and it should be hooked up with the .NET 6 Boilerplate API that you already have running.

Test the .NET 6.0 Boilerplate API with Postman

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

Below are instructions on how to use Postman to perform the following actions:

How to register a new account with Postman

To register a new account with the boilerplate API 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 register route of your local API – http://localhost:4000/accounts/register
  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 required account properties in the Body text area, e.g
{
    "title": "Mr",
    "firstName": "George",
    "lastName": "Costanza",
    "email": "[email protected]",
    "password": "george-likes-spicy-chicken",
    "confirmPassword": "george-likes-spicy-chicken",
    "acceptTerms": true
}
  1. Click the Send button, you should receive a “200 OK” response with a “registration successful” message in the response body.

Here’s a screenshot of Postman after the request is sent and the account has been registered:

.NET 6.0 - Boilerplate API Tutorial with Email Sign Up, Verification, Authentication & Forgot Password

And this is a screenshot of the verification email received with the token to verify the account:

.NET 6.0 - Boilerplate API Tutorial with Email Sign Up, Verification, Authentication & Forgot Password

How to verify an account with Postman

To verify an account with the .NET 6 API 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/accounts/verify-email
  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 token received in the verification email (in the previous step) in the Body textarea, e.g:
{
    "token": "REPLACE THIS WITH YOUR TOKEN"
}

6. Click the Send button, and you should receive a “200 OK” response with a “verification successful” message in the response body.

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

How to access an account if you forgot the password

To re-enable access to an account with a forgotten password you need to submit the email address of the account to the /accounts/forgot-password route, the route will then send a token to the email which will allow you to reset the password of the account in the next step.

Follow these steps in Postman if you forgot the password for your account:

  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/accounts/forgot-password
  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 email of the account with the forgotten password in the Body textarea, e.g:
{
    "email": "[email protected]"
}

6. Click the Send button, and you should receive a “200 OK” response with the message “Please check your email for password reset instructions” in the response body.

Here’s a screenshot of Postman after the request is sent and the email has been sent:

And this is a screenshot of the email received with the token to reset the password of the account

How to reset the password of an account with Postman

To reset the password of an account with the API 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/accounts/reset-password
  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 password reset token received in the email from the forgot password step, along with a new password and matching confirmPassword, into the Body textarea, e.g:
{
    "token": "REPLACE THIS WITH YOUR TOKEN",
    "password": "george-is-gettin-upset!",
    "confirmPassword": "george-is-gettin-upset!"
}

6. Click the Send button, you should receive a “200 OK” response with a “password reset successful” message in the response body.

Here’s a screenshot of Postman after the request is sent and the account password has been reset:

How to authenticate with Postman

To authenticate an account with the API and get a JWT 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/accounts/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 account email and password in the Body textarea
{
    "email": "[email protected]",
    "password": "george-is-gettin-upset!"
}

6. Click the Send button, you should receive a “200 OK” response with the account details including a JWT token in the response body and a refresh token in the response cookies.

7. Copy the JWT token value because we’ll be using it in the next steps to make authenticated requests.

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

And this shows the cookies tab with the refresh token in the response:

How to get a list of all accounts with Postman

This is a secure request that requires a JWT authentication token from the authenticate step. The API route is restricted to the Admin role.

To get a list of all accounts from the .NET 6 boilerplate API 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 accounts route of your local API – http://localhost:4000/accounts
  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 step into the Token field.
  5. Click the Send button, you should receive a “200 OK” response containing a JSON array with all of the account records in the system.

Here’s a screenshot of Postman after making an authenticated request to get all accounts:

How to update an account with Postman

This is a secure request that requires a JWT authentication token from the authenticate step. Admin accounts can update any account including its role, User accounts are restricted to their own account and cannot update role. Omitted or empty properties are ignored and not updated.

To update an account with the API 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 PUT with the dropdown selector on the left of the URL input field.
  3. In the URL field enter the address to the /accounts/{id} route with the id of the account you want to update, e.g – http://localhost:4000/accounts/1
  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 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 in the Body textarea containing the properties you want to update, for example to update the first and last names:
{
    "firstName": "Art",
    "lastName": "Vandelay"
}

7. Click the Send button, you should receive a “200 OK” response with the updated account details in the response body.

Here’s a screenshot of Postman after the request is sent and the account has been updated:

How to use a refresh token to get a new JWT token

This step can only be done after the 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/accounts/refresh-token
  4. Click the Send button, you should receive a “200 OK” response with the account details including a new JWT token in the response body and a new refresh token in the response cookies.
  5. Copy the JWT token value because we’ll be using it in the next steps to make authenticated requests.

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

And this shows the cookies tab with the refresh token in the response:

How to revoke a refresh token with Postman

This is a secure request that requires a JWT authentication token from the authenticate (or refresh token) step. Admin accounts can revoke the tokens of any account, User accounts can only revoke their own tokens.

To revoke a refresh token so it can no longer be used to generate 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/accounts/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 /accounts/revoke-token route, to revoke the refresh token 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:

How to delete an account with Postman

This is a secure request that requires a JWT authentication token from the authenticate step. Admin accounts can delete any account, User accounts are restricted to their own account.

To delete an account with the API 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 DELETE with the dropdown selector on the left of the URL input field.
  3. In the URL field enter the address to the /accounts/{id} route with the id of the account you want to delete, e.g – http://localhost:4000/accounts/1
  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 step into the Token field.
  5. Click the Send button, you should receive a “200 OK” response with the message “Account deleted successfully” in the response body.

Here’s a screenshot of Postman after the request is sent and the account has been deleted:

.NET 6.0 Boilerplate 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 end points / routes for the API, controller action methods are the entry points into the API for client applications via HTTP requests.

Migrations
Database migration files based on the classes in the /Entities folder that are used to automatically create and update the database for the API. Migrations are generated with the Entity Framework Core Tools for the .NET CLI (dotnet ef), the migrations in this example were generated with the command dotnet ef migrations add InitialCreate.

Models
Represent request and response models for controller action 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 in the application for data management and CRUD operations.

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

Click the below links to jump 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 public access to specific action methods when a controller class is decorated with the [Authorize] attribute. It’s used in the accounts controller to allow anonymous access to several methods including AuthenticateRegister and ForgotPassword.

The logic for allowing public access is located in the custom authorize attribute below, authorization is skipped if the action method is decorated with [AllowAnonymous].

The reason I created a custom AllowAnonymous attribute instead of using the one in the .NET Core framework (Microsoft.AspNetCore.Authorization) is for consistency with the other custom auth classes in the project and to avoid ambiguous reference errors between namespaces.

namespace WebApi.Authorization;

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

Back to top

.NET Custom Authorize Attribute

Path: /Authorization/AuthorizeAttribute.cs

The custom [Authorize] attribute is added to controller classes or action methods that require the user to be authenticated and optionally have a specified role. If a role is specified (e.g. [Authorize(Role.Admin)]) then the route is restricted to users in that role, otherwise the route is restricted to all authenticated users regardless of role.

When a controller class 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"]) and that the user is in an authorized role (if specified).

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
{
    private readonly IList<Role> _roles;

    public AuthorizeAttribute(params Role[] roles)
    {
        _roles = roles ?? new Role[] { };
    }

    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 account = (Account)context.HttpContext.Items["Account"];
        if (account == null || (_roles.Any() && !_roles.Contains(account.Role)))
        {
            // not logged in or role not authorized
            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 account id from the token is returned and the authenticated account object is added to the HttpContext.Items collection which makes it accessible to other classes within the scope of the current request.

If token validation fails (or there is no token) the request is anonymous and only allowed to access public routes because there isn’t an authenticated account object attached to the HTTP context. The authorization logic that checks for the account object is located in the custom authorize attribute above. When an anonymous/unauthorized request is sent to a secure route the authorize attribute returns a 401 Unauthorized HTTP response.

namespace WebApi.Authorization;

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

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, DataContext dataContext, IJwtUtils jwtUtils)
    {
        var token = context.Request.Headers["Authorization"].FirstOrDefault()?.Split(" ").Last();
        var accountId = jwtUtils.ValidateJwtToken(token);
        if (accountId != null)
        {
            // attach account to context on successful jwt validation
            context.Items["Account"] = await dataContext.Accounts.FindAsync(accountId.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 account as the "id" claim, meaning the token payload will contain the property "id": <accountId> (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 account 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 request ip address are saved with the token to create an audit trail and to help identify any unusual activity. The method returns a guaranteed unique refresh token string by verifying that it doesn’t already exist in the database and recursively calling itself if it does.

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(Account account);
    public int? ValidateJwtToken(string token);
    public RefreshToken GenerateRefreshToken(string ipAddress);
}

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

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

    public string GenerateJwtToken(Account account)
    {
        // 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", account.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 accountId = int.Parse(jwtToken.Claims.First(x => x.Type == "id").Value);

            // return account id from JWT token if validation successful
            return accountId;
        }
        catch
        {
            // return null if validation fails
            return null;
        }
    }

    public RefreshToken GenerateRefreshToken(string ipAddress)
    {
        var refreshToken = new RefreshToken
        {
            // token is a cryptographically strong random sequence of values
            Token = Convert.ToHexString(RandomNumberGenerator.GetBytes(64)),
            // token is valid for 7 days
            Expires = DateTime.UtcNow.AddDays(7),
            Created = DateTime.UtcNow,
            CreatedByIp = ipAddress
        };

        // ensure token is unique by checking against db
        var tokenIsUnique = !_context.Accounts.Any(a => a.RefreshTokens.Any(t => t.Token == refreshToken.Token));

        if (!tokenIsUnique)
            return GenerateRefreshToken(ipAddress);

        return refreshToken;
    }
}

.NET Accounts Controller

Path: /Controllers/AccountsController.cs

The accounts controller defines and handles all routes / endpoints for the API that relate to accounts including sign up & verification, authentication & forgot password, refreshing & revoking tokens, and account management (CRUD) operations. Within each route method the controller calls the account service to perform the action required, this enables the controller to stay ‘lean’ and completely separate from the business logic and data access code of each action.

Controller methods/routes are secure by default with the [Authorize] attribute on the class, methods that are restricted to a specific role are decorated with the authorize attribute and the role (e.g. [Authorize(Role.Admin)]). Several methods are decorated with the [AllowAnonymous] attribute which overrides the class-level [Authorize] attribute to make them publicly accessible, I chose this approach to prevent any new methods from being accidentally made public. Auth logic is located in the custom authorize attribute.

The route methods RevokeTokenGetByIdUpdate and Delete include an extra custom authorization check to prevent non-admin accounts from accessing accounts other than their own. User accounts have CRUD access to their own account but not to others, Admin accounts have full CRUD access to all accounts.

The setTokenCookie() helper method appends an HTTP Only cookie containing the refresh token to the response for increased security. HTTP Only cookies are not accessible to client-side javascript which prevents XSS (cross site scripting), and the refresh token can only be used to fetch a new token from the /accounts/refresh-token route which prevents CSRF (cross site request forgery).

namespace WebApi.Controllers;

using Microsoft.AspNetCore.Mvc;
using WebApi.Authorization;
using WebApi.Entities;
using WebApi.Models.Accounts;
using WebApi.Services;

[Authorize]
[ApiController]
[Route("[controller]")]
public class AccountsController : BaseController
{
    private readonly IAccountService _accountService;

    public AccountsController(IAccountService accountService)
    {
        _accountService = accountService;
    }

    [AllowAnonymous]
    [HttpPost("authenticate")]
    public ActionResult<AuthenticateResponse> Authenticate(AuthenticateRequest model)
    {
        var response = _accountService.Authenticate(model, ipAddress());
        setTokenCookie(response.RefreshToken);
        return Ok(response);
    }

    [AllowAnonymous]
    [HttpPost("refresh-token")]
    public ActionResult<AuthenticateResponse> RefreshToken()
    {
        var refreshToken = Request.Cookies["refreshToken"];
        var response = _accountService.RefreshToken(refreshToken, ipAddress());
        setTokenCookie(response.RefreshToken);
        return Ok(response);
    }

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

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

        // users can revoke their own tokens and admins can revoke any tokens
        if (!Account.OwnsToken(token) && Account.Role != Role.Admin)
            return Unauthorized(new { message = "Unauthorized" });

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

    [AllowAnonymous]
    [HttpPost("register")]
    public IActionResult Register(RegisterRequest model)
    {
        _accountService.Register(model, Request.Headers["origin"]);
        return Ok(new { message = "Registration successful, please check your email for verification instructions" });
    }

    [AllowAnonymous]
    [HttpPost("verify-email")]
    public IActionResult VerifyEmail(VerifyEmailRequest model)
    {
        _accountService.VerifyEmail(model.Token);
        return Ok(new { message = "Verification successful, you can now login" });
    }

    [AllowAnonymous]
    [HttpPost("forgot-password")]
    public IActionResult ForgotPassword(ForgotPasswordRequest model)
    {
        _accountService.ForgotPassword(model, Request.Headers["origin"]);
        return Ok(new { message = "Please check your email for password reset instructions" });
    }

    [AllowAnonymous]
    [HttpPost("validate-reset-token")]
    public IActionResult ValidateResetToken(ValidateResetTokenRequest model)
    {
        _accountService.ValidateResetToken(model);
        return Ok(new { message = "Token is valid" });
    }

    [AllowAnonymous]
    [HttpPost("reset-password")]
    public IActionResult ResetPassword(ResetPasswordRequest model)
    {
        _accountService.ResetPassword(model);
        return Ok(new { message = "Password reset successful, you can now login" });
    }

    [Authorize(Role.Admin)]
    [HttpGet]
    public ActionResult<IEnumerable<AccountResponse>> GetAll()
    {
        var accounts = _accountService.GetAll();
        return Ok(accounts);
    }

    [HttpGet("{id:int}")]
    public ActionResult<AccountResponse> GetById(int id)
    {
        // users can get their own account and admins can get any account
        if (id != Account.Id && Account.Role != Role.Admin)
            return Unauthorized(new { message = "Unauthorized" });

        var account = _accountService.GetById(id);
        return Ok(account);
    }

    [Authorize(Role.Admin)]
    [HttpPost]
    public ActionResult<AccountResponse> Create(CreateRequest model)
    {
        var account = _accountService.Create(model);
        return Ok(account);
    }

    [HttpPut("{id:int}")]
    public ActionResult<AccountResponse> Update(int id, UpdateRequest model)
    {
        // users can update their own account and admins can update any account
        if (id != Account.Id && Account.Role != Role.Admin)
            return Unauthorized(new { message = "Unauthorized" });

        // only admins can update role
        if (Account.Role != Role.Admin)
            model.Role = null;

        var account = _accountService.Update(id, model);
        return Ok(account);
    }

    [HttpDelete("{id:int}")]
    public IActionResult Delete(int id)
    {
        // users can delete their own account and admins can delete any account
        if (id != Account.Id && Account.Role != Role.Admin)
            return Unauthorized(new { message = "Unauthorized" });

        _accountService.Delete(id);
        return Ok(new { message = "Account deleted successfully" });
    }

    // helper methods

    private void setTokenCookie(string token)
    {
        var cookieOptions = new CookieOptions
        {
            HttpOnly = true,
            Expires = DateTime.UtcNow.AddDays(7)
        };
        Response.Cookies.Append("refreshToken", token, cookieOptions);
    }

    private string ipAddress()
    {
        if (Request.Headers.ContainsKey("X-Forwarded-For"))
            return Request.Headers["X-Forwarded-For"];
        else
            return HttpContext.Connection.RemoteIpAddress.MapToIPv4().ToString();
    }
}

.NET Base Controller

Path: /Controllers/BaseController.cs

The base controller is inherited by all other controllers in the boilerplate API and includes common properties and methods that are accessible to all controllers.

The Account property returns the current authenticated account for the request from the HttpContext.Items collection, or returns null if the request is not authenticated. The current account is added to the HttpContext.Items collection by the custom jwt middleware when the request contains a valid JWT token in the authorization header.

namespace WebApi.Controllers;

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

[Controller]
public abstract class BaseController : ControllerBase
{
    // returns the current authenticated account (null if not logged in)
    public Account Account => (Account)HttpContext.Items["Account"];
}

.NET Account Entity

Path: /Entities/Account.cs

The account entity class represents the data for an account in the application.

The IsVerified property returns true if either the Verified date or PasswordReset date has a value, this is to enable account verification after registration via the forgot password + reset password steps.

The OwnsToken method is a convenience method that returns true if the specified refresh token belongs to the account, it’s used in the RevokeToken method of the accounts controller to improve code readability by making it more intention revealing.

namespace WebApi.Entities;

public class Account
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Email { get; set; }
    public string PasswordHash { get; set; }
    public bool AcceptTerms { get; set; }
    public Role Role { get; set; }
    public string VerificationToken { get; set; }
    public DateTime? Verified { get; set; }
    public bool IsVerified => Verified.HasValue || PasswordReset.HasValue;
    public string ResetToken { get; set; }
    public DateTime? ResetTokenExpires { get; set; }
    public DateTime? PasswordReset { get; set; }
    public DateTime Created { get; set; }
    public DateTime? Updated { get; set; }
    public List<RefreshToken> RefreshTokens { get; set; }

    public bool OwnsToken(string token) 
    {
        return this.RefreshTokens?.Find(x => x.Token == token) != null;
    }
}

Back to top

.NET Refresh Token Entity

Path: /Entities/RefreshToken.cs

The refresh token entity class represents the data for a refresh token in the application.

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 an account entity.

The [Key] attribute explicitly sets the id field as the primary key in the database table. Properties with the name Id are automatically made primary keys by EF Core, however in the case of Owned entities EF Core creates a composite primary key consisting of the id and the owner id which can cause errors with auto generated id fields. Explicitly marking the id with the [Key] attribute tells EF Core to make only the id field the primary key in the db table.

namespace WebApi.Entities;

using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;

[Owned]
public class RefreshToken
{
    [Key]
    public int Id { get; set; }
    public Account Account { 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 => Revoked == null && !IsExpired;
}
.NET Role Enum

Path: /Entities/Role.cs

The role enum defines all the available roles in the .NET boilerplate API. I created it to avoid passing roles around as strings, so instead of 'Admin' we can use Role.Admin.

namespace WebApi.Entities;

public enum Role
{
    Admin,
    User
}

.NET App Exception

Path: /Helpers/AppException.cs

The app exception is a custom exception class used to differentiate between handled and unhandled exceptions. Handled exceptions are ones generated by the application and used to display friendly error messages to the client, for example business logic or validation exceptions caused by incorrect input from the user. Unhandled exceptions are generated by the .NET framework and can be caused by bugs in the application code.

See the account service for examples of app exceptions that are thrown. See how different exception types are handled in the global error handler middleware.

namespace WebApi.Helpers;

using System.Globalization;

// custom exception class for throwing application specific exceptions 
// 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 account service accesses app settings via an IOptions<AppSettings> appSettings object that is injected into the constructor.

Mapping of configuration sections to classes is done on startup 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; }

    public string EmailFrom { get; set; }
    public string SmtpHost { get; set; }
    public int SmtpPort { get; set; }
    public string SmtpUser { get; set; }
    public string SmtpPass { get; set; }
}

.NET AutoMapper Profile

Path: /Helpers/AutoMapperProfile.cs

The auto mapper profile contains the mapping configuration used by the .NET application, AutoMapper is a package available on Nuget that enables automatic mapping of property values between different class types based on property names. In the example we’re using it to map between Account entities and a few different request and response model types.

The mapping from UpdateRequest to Account includes some custom configuration to ignore empty properties on the request model when mapping to an account entity, this is to make fields optional when updating an account.

namespace WebApi.Helpers;

using AutoMapper;
using WebApi.Entities;
using WebApi.Models.Accounts;

public class AutoMapperProfile : Profile
{
    // mappings between model and entity objects
    public AutoMapperProfile()
    {
        CreateMap<Account, AccountResponse>();

        CreateMap<Account, AuthenticateResponse>();

        CreateMap<RegisterRequest, Account>();

        CreateMap<CreateRequest, Account>();

        CreateMap<UpdateRequest, Account>()
            .ForAllMembers(x => x.Condition(
                (src, dest, prop) =>
                {
                    // ignore null & empty string properties
                    if (prop == null) return false;
                    if (prop.GetType() == typeof(string) && string.IsNullOrEmpty((string)prop)) return false;

                    // ignore null role
                    if (x.DestinationMember.Name == "Role" && src.Role == null) return false;

                    return true;
                }
            ));
    }
}

.NET Data Context

Path: /Helpers/DataContext.cs

The data context class is used for accessing application data through Entity Framework Core and is configured to connect to a SQLite database. It derives from the EF Core DbContext class and has a public Accounts property for accessing and managing account data. The data context is used by services for handling all low level data operations.

To use a different database (e.g. SQL Server, MySql, PostgreSQL) add the database provider package from NuGet (e.g. Microsoft.EntityFrameworkCore.SqlServer for SQL Server), update the OnConfiguring method to use the new database provider, then delete the database migrations in the /Migrations folder and regenerate them for the new db with the command dotnet ef migrations add InitialCreate.

namespace WebApi.Helpers;

using Microsoft.EntityFrameworkCore;
using WebApi.Entities;

public class DataContext : DbContext
{
    public DbSet<Account> Accounts { get; set; }
    
    private readonly IConfiguration Configuration;

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

    protected override void OnConfiguring(DbContextOptionsBuilder options)
    {
        // connect to sqlite database
        options.UseSqlite(Configuration.GetConnectionString("WebApiDatabase"));
    }
}

.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 boilerplate application. It’s configured as middleware in the Program.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 as well as being logged to the console.

See the account 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;
    private readonly ILogger _logger;

    public ErrorHandlerMiddleware(RequestDelegate next, ILogger<ErrorHandlerMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    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
                    _logger.LogError(error, error.Message);
                    response.StatusCode = (int)HttpStatusCode.InternalServerError;
                    break;
            }

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

.NET Account Response Model

Path: /Models/Accounts/AccountResponse.cs

The account response model defines the account data returned by the GetAllGetByIdCreate and Update methods of the accounts controller and account service. It includes basic account details and excludes sensitive data such as hashed passwords and tokens.

namespace WebApi.Models.Accounts;

public class AccountResponse
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Email { get; set; }
    public string Role { get; set; }
    public DateTime Created { get; set; }
    public DateTime? Updated { get; set; }
    public bool IsVerified { get; set; }

.NET Authenticate Request Model

Path: /Models/Accounts/AuthenticateRequest.cs

The authenticate request model defines the parameters for incoming POST requests to the /accounts/authenticate route, it is attached to the route by setting it as the parameter to the Authenticate action method of the accounts 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 email and password as required fields so if either are missing a validation error message is returned from the API. Likewise the [EmailAddress] attribute validates that the email property contains a valid email address.

namespace WebApi.Models.Accounts;

using System.ComponentModel.DataAnnotations;

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

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

.NET Authenticate Response Model

Path: /Models/Accounts/AuthenticateResponse.cs

The authenticate response model defines the data returned by the Authenticate and RefreshToken methods of the accounts controller and account service. It includes basic account details, a jwt token and a refresh token.

The refresh token property is decorated with the [JsonIgnore] attribute which prevents the property from being returned in the API response body. This is because the refresh token is returned as an HTTP Only cookie instead of in the body.

namespace WebApi.Models.Accounts;

using System.Text.Json.Serialization;

public class AuthenticateResponse
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Email { get; set; }
    public string Role { get; set; }
    public DateTime Created { get; set; }
    public DateTime? Updated { get; set; }
    public bool IsVerified { get; set; }
    public string JwtToken { get; set; }

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

.NET Create Request Model

Path: /Models/Accounts/CreateRequest.cs

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

.NET Data Annotations are used to automatically handle model validation, [Required] makes all properties required, [EmailAddress] validates that the email property contains a valid email address, [EnumDataType(typeof(Role))] validates that the role property matches one of the API roles (Admin or User), [MinLength(6)] validates that the password contains at least six characters, and [Compare("Password")] validates that the confirm password property matches the password property.

namespace WebApi.Models.Accounts;

using System.ComponentModel.DataAnnotations;
using WebApi.Entities;

public class CreateRequest
{
    [Required]
    public string Title { get; set; }

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

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

    [Required]
    [EnumDataType(typeof(Role))]
    public string Role { get; set; }

    [Required]
    [EmailAddress]
    public string Email { get; set; }

    [Required]
    [MinLength(6)]
    public string Password { get; set; }

    [Required]
    [Compare("Password")]
    public string ConfirmPassword { get; set; }
}

.NET Forgot Password Request Model

Path: /Models/Accounts/ForgotPasswordRequest.cs

The forgot password request model defines the parameters for incoming POST requests to the /accounts/forgot-password route of the boilerplate API, it is attached to the route by setting it as the parameter to the ForgotPassword action method of the accounts controller. When an HTTP POST request is received by the route, the data from the body is bound to an instance of the ForgotPasswordRequest class, validated and passed to the method.

.NET Data Annotations are used to automatically handle model validation, [Required] makes the email required, and [EmailAddress] validates that it contains a valid email address.

namespace WebApi.Models.Accounts;

using System.ComponentModel.DataAnnotations;

public class ForgotPasswordRequest
{
    [Required]
    [EmailAddress]
    public string Email { get; set; }
}

.NET Register Request Model

Path: /Models/Accounts/RegisterRequest.cs

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

.NET Data Annotations are used to automatically handle model validation, [Required] makes all properties required, [EmailAddress] validates that the email property contains a valid email address, [MinLength(6)] validates that the password contains at least six characters, [Compare("Password")] validates that the confirm password property matches the password property, and [Range(typeof(bool), "true", "true")] validates that the accept terms property contains true.

namespace WebApi.Models.Accounts;

using System.ComponentModel.DataAnnotations;

public class RegisterRequest
{
    [Required]
    public string Title { get; set; }

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

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

    [Required]
    [EmailAddress]
    public string Email { get; set; }

    [Required]
    [MinLength(6)]
    public string Password { get; set; }

    [Required]
    [Compare("Password")]
    public string ConfirmPassword { get; set; }

    [Range(typeof(bool), "true", "true")]
    public bool AcceptTerms { get; set; }
}

.NET Reset Password Request Model

Path: /Models/Accounts/ResetPasswordRequest.cs

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

.NET Data Annotations are used to automatically handle model validation, [Required] makes all properties required, [MinLength(6)] validates that the password contains at least six characters, and [Compare("Password")] validates that the confirm password property matches the password property.

namespace WebApi.Models.Accounts;

using System.ComponentModel.DataAnnotations;

public class ResetPasswordRequest
{
    [Required]
    public string Token { get; set; }

    [Required]
    [MinLength(6)]
    public string Password { get; set; }

    [Required]
    [Compare("Password")]
    public string ConfirmPassword { get; set; }
}
.NET Revoke Token Request Model

Path: /Models/Accounts/RevokeTokenRequest.cs

The revoke token request model defines the parameters for incoming POST requests to the /accounts/revoke-token route of the boilerplate API, it is attached to the route by setting it as the parameter to the RevokeToken action method of the accounts 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 field is optional in the request body because it can also be passed in the refreshToken cookie, see the accounts controller for details.

namespace WebApi.Models.Accounts;

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

.NET Update Request Model

Path: /Models/Accounts/UpdateRequest.cs

The update request model defines the parameters for incoming PUT requests to the /accounts/{id:int} route, it is attached to the route by setting it as the parameter to the Update action method of the accounts controller. When an HTTP PUT request is received by the route, the data from the body is bound to an instance of the UpdateRequest class, validated and passed to the method.

.NET Data Annotations are used to automatically handle model validation, [EnumDataType(typeof(Role))] validates that the role property matches one of the API roles (Admin or User), [EmailAddress] validates that the email property contains a valid email address, [MinLength(6)] validates that the password contains at least six characters, and [Compare("Password")] validates that the confirm password property matches the password property.

None of the properties have the [Required] attribute making them all optional, and any omitted fields are not updated in the database.

Some validation attributes don’t handle empty strings well, so the properties with validation attributes replace empty strings with null on set to ensure that empty string values are ignored.

namespace WebApi.Models.Accounts;

using System.ComponentModel.DataAnnotations;
using WebApi.Entities;

public class UpdateRequest
{
    private string _password;
    private string _confirmPassword;
    private string _role;
    private string _email;
    
    public string Title { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }

    [EnumDataType(typeof(Role))]
    public string Role
    {
        get => _role;
        set => _role = replaceEmptyWithNull(value);
    }

    [EmailAddress]
    public string Email
    {
        get => _email;
        set => _email = replaceEmptyWithNull(value);
    }

    [MinLength(6)]
    public string Password
    {
        get => _password;
        set => _password = replaceEmptyWithNull(value);
    }

    [Compare("Password")]
    public string ConfirmPassword 
    {
        get => _confirmPassword;
        set => _confirmPassword = replaceEmptyWithNull(value);
    }

    // helpers

    private string replaceEmptyWithNull(string value)
    {
        // replace empty string with null to make field optional
        return string.IsNullOrEmpty(value) ? null : value;
    }
}

Back to top

.NET Validate Reset Token Request Model

Path: /Models/Accounts/ValidateResetTokenRequest.cs

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

.NET Data Annotations are used to automatically handle model validation, [Required] makes the token required.

namespace WebApi.Models.Accounts;

using System.ComponentModel.DataAnnotations;

public class ValidateResetTokenRequest
{
    [Required]
    public string Token { get; set; }
}

.NET Verify Email Request Model

Path: /Models/Accounts/VerifyEmailRequest.cs

The verify email request model defines the parameters for incoming POST requests to the /accounts/verify-email route of the boilerplate API, it is attached to the route by setting it as the parameter to the VerifyEmail action method of the accounts controller. When an HTTP POST request is received by the route, the data from the body is bound to an instance of the VerifyEmail class, validated and passed to the method.

.NET Data Annotations are used to automatically handle model validation, [Required] makes the token required.

namespace WebApi.Models.Accounts;

using System.ComponentModel.DataAnnotations;

public class VerifyEmailRequest
{
    [Required]
    public string Token { get; set; }
}

.NET Account Service

Path: /Services/AccountService.cs

The account service contains the core business logic for account sign up & verification, authentication with JWT & refresh tokens, forgot password & reset password functionality, as well as CRUD methods for managing account data. The service encapsulates interactions with the EF Core data context and exposes a simple set of methods which are used by the accounts controller.

The top of the file contains the IAccountService interface which defines the public methods for the account service, and below the interface is the concrete AccountService class that implements the interface.

namespace WebApi.Services;

using AutoMapper;
using BCrypt.Net;
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.Authorization;
using WebApi.Entities;
using WebApi.Helpers;
using WebApi.Models.Accounts;

public interface IAccountService
{
    AuthenticateResponse Authenticate(AuthenticateRequest model, string ipAddress);
    AuthenticateResponse RefreshToken(string token, string ipAddress);
    void RevokeToken(string token, string ipAddress);
    void Register(RegisterRequest model, string origin);
    void VerifyEmail(string token);
    void ForgotPassword(ForgotPasswordRequest model, string origin);
    void ValidateResetToken(ValidateResetTokenRequest model);
    void ResetPassword(ResetPasswordRequest model);
    IEnumerable<AccountResponse> GetAll();
    AccountResponse GetById(int id);
    AccountResponse Create(CreateRequest model);
    AccountResponse Update(int id, UpdateRequest model);
    void Delete(int id);
}

public class AccountService : IAccountService
{
    private readonly DataContext _context;
    private readonly IJwtUtils _jwtUtils;
    private readonly IMapper _mapper;
    private readonly AppSettings _appSettings;
    private readonly IEmailService _emailService;

    public AccountService(
        DataContext context,
        IJwtUtils jwtUtils,
        IMapper mapper,
        IOptions<AppSettings> appSettings,
        IEmailService emailService)
    {
        _context = context;
        _jwtUtils = jwtUtils;
        _mapper = mapper;
        _appSettings = appSettings.Value;
        _emailService = emailService;
    }

    public AuthenticateResponse Authenticate(AuthenticateRequest model, string ipAddress)
    {
        var account = _context.Accounts.SingleOrDefault(x => x.Email == model.Email);

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

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

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

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

        var response = _mapper.Map<AuthenticateResponse>(account);
        response.JwtToken = jwtToken;
        response.RefreshToken = refreshToken.Token;
        return response;
    }

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

        if (refreshToken.IsRevoked)
        {
            // revoke all descendant tokens in case this token has been compromised
            revokeDescendantRefreshTokens(refreshToken, account, ipAddress, $"Attempted reuse of revoked ancestor token: {token}");
            _context.Update(account);
            _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);
        account.RefreshTokens.Add(newRefreshToken);


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

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

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

        // return data in authenticate response object
        var response = _mapper.Map<AuthenticateResponse>(account);
        response.JwtToken = jwtToken;
        response.RefreshToken = newRefreshToken.Token;
        return response;
    }

    public void RevokeToken(string token, string ipAddress)
    {
        var account = getAccountByRefreshToken(token);
        var refreshToken = account.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(account);
        _context.SaveChanges();
    }

    public void Register(RegisterRequest model, string origin)
    {
        // validate
        if (_context.Accounts.Any(x => x.Email == model.Email))
        {
            // send already registered error in email to prevent account enumeration
            sendAlreadyRegisteredEmail(model.Email, origin);
            return;
        }

        // map model to new account object
        var account = _mapper.Map<Account>(model);

        // first registered account is an admin
        var isFirstAccount = _context.Accounts.Count() == 0;
        account.Role = isFirstAccount ? Role.Admin : Role.User;
        account.Created = DateTime.UtcNow;
        account.VerificationToken = generateVerificationToken();

        // hash password
        account.PasswordHash = BCrypt.HashPassword(model.Password);

        // save account
        _context.Accounts.Add(account);
        _context.SaveChanges();

        // send email
        sendVerificationEmail(account, origin);
    }

    public void VerifyEmail(string token)
    {
        var account = _context.Accounts.SingleOrDefault(x => x.VerificationToken == token);

        if (account == null) 
            throw new AppException("Verification failed");

        account.Verified = DateTime.UtcNow;
        account.VerificationToken = null;

        _context.Accounts.Update(account);
        _context.SaveChanges();
    }

    public void ForgotPassword(ForgotPasswordRequest model, string origin)
    {
        var account = _context.Accounts.SingleOrDefault(x => x.Email == model.Email);

        // always return ok response to prevent email enumeration
        if (account == null) return;

        // create reset token that expires after 1 day
        account.ResetToken = generateResetToken();
        account.ResetTokenExpires = DateTime.UtcNow.AddDays(1);

        _context.Accounts.Update(account);
        _context.SaveChanges();

        // send email
        sendPasswordResetEmail(account, origin);
    }

    public void ValidateResetToken(ValidateResetTokenRequest model)
    {
        getAccountByResetToken(model.Token);
    }

    public void ResetPassword(ResetPasswordRequest model)
    {
        var account = getAccountByResetToken(model.Token);

        // update password and remove reset token
        account.PasswordHash = BCrypt.HashPassword(model.Password);
        account.PasswordReset = DateTime.UtcNow;
        account.ResetToken = null;
        account.ResetTokenExpires = null;

        _context.Accounts.Update(account);
        _context.SaveChanges();
    }

    public IEnumerable<AccountResponse> GetAll()
    {
        var accounts = _context.Accounts;
        return _mapper.Map<IList<AccountResponse>>(accounts);
    }

    public AccountResponse GetById(int id)
    {
        var account = getAccount(id);
        return _mapper.Map<AccountResponse>(account);
    }

    public AccountResponse Create(CreateRequest model)
    {
        // validate
        if (_context.Accounts.Any(x => x.Email == model.Email))
            throw new AppException($"Email '{model.Email}' is already registered");

        // map model to new account object
        var account = _mapper.Map<Account>(model);
        account.Created = DateTime.UtcNow;
        account.Verified = DateTime.UtcNow;

        // hash password
        account.PasswordHash = BCrypt.HashPassword(model.Password);

        // save account
        _context.Accounts.Add(account);
        _context.SaveChanges();

        return _mapper.Map<AccountResponse>(account);
    }

    public AccountResponse Update(int id, UpdateRequest model)
    {
        var account = getAccount(id);

        // validate
        if (account.Email != model.Email && _context.Accounts.Any(x => x.Email == model.Email))
            throw new AppException($"Email '{model.Email}' is already registered");

        // hash password if it was entered
        if (!string.IsNullOrEmpty(model.Password))
            account.PasswordHash = BCrypt.HashPassword(model.Password);

        // copy model to account and save
        _mapper.Map(model, account);
        account.Updated = DateTime.UtcNow;
        _context.Accounts.Update(account);
        _context.SaveChanges();

        return _mapper.Map<AccountResponse>(account);
    }

    public void Delete(int id)
    {
        var account = getAccount(id);
        _context.Accounts.Remove(account);
        _context.SaveChanges();
    }

    // helper methods

    private Account getAccount(int id)
    {
        var account = _context.Accounts.Find(id);
        if (account == null) throw new KeyNotFoundException("Account not found");
        return account;
    }

    private Account getAccountByRefreshToken(string token)
    {
        var account = _context.Accounts.SingleOrDefault(u => u.RefreshTokens.Any(t => t.Token == token));
        if (account == null) throw new AppException("Invalid token");
        return account;
    }

    private Account getAccountByResetToken(string token)
    {
        var account = _context.Accounts.SingleOrDefault(x =>
            x.ResetToken == token && x.ResetTokenExpires > DateTime.UtcNow);
        if (account == null) throw new AppException("Invalid token");
        return account;
    }

    private string generateJwtToken(Account account)
    {
        var tokenHandler = new JwtSecurityTokenHandler();
        var key = Encoding.ASCII.GetBytes(_appSettings.Secret);
        var tokenDescriptor = new SecurityTokenDescriptor
        {
            Subject = new ClaimsIdentity(new[] { new Claim("id", account.Id.ToString()) }),
            Expires = DateTime.UtcNow.AddMinutes(15),
            SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
        };
        var token = tokenHandler.CreateToken(tokenDescriptor);
        return tokenHandler.WriteToken(token);
    }

    private string generateResetToken()
    {
        // token is a cryptographically strong random sequence of values
        var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(64));

        // ensure token is unique by checking against db
        var tokenIsUnique = !_context.Accounts.Any(x => x.ResetToken == token);
        if (!tokenIsUnique)
            return generateResetToken();
        
        return token;
    }

    private string generateVerificationToken()
    {
        // token is a cryptographically strong random sequence of values
        var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(64));

        // ensure token is unique by checking against db
        var tokenIsUnique = !_context.Accounts.Any(x => x.VerificationToken == token);
        if (!tokenIsUnique)
            return generateVerificationToken();
        
        return token;
    }

    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(Account account)
    {
        account.RefreshTokens.RemoveAll(x => 
            !x.IsActive && 
            x.Created.AddDays(_appSettings.RefreshTokenTTL) <= DateTime.UtcNow);
    }

    private void revokeDescendantRefreshTokens(RefreshToken refreshToken, Account account, string ipAddress, string reason)
    {
        // recursively traverse the refresh token chain and ensure all descendants are revoked
        if (!string.IsNullOrEmpty(refreshToken.ReplacedByToken))
        {
            var childToken = account.RefreshTokens.SingleOrDefault(x => x.Token == refreshToken.ReplacedByToken);
            if (childToken.IsActive)
                revokeRefreshToken(childToken, ipAddress, reason);
            else
                revokeDescendantRefreshTokens(childToken, account, 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;
    }

    private void sendVerificationEmail(Account account, string origin)
    {
        string message;
        if (!string.IsNullOrEmpty(origin))
        {
            // origin exists if request sent from browser single page app (e.g. Angular or React)
            // so send link to verify via single page app
            var verifyUrl = $"{origin}/account/verify-email?token={account.VerificationToken}";
            message = [email protected]"<p>Please click the below link to verify your email address:</p>
                            <p><a href=""{verifyUrl}"">{verifyUrl}</a></p>";
        }
        else
        {
            // origin missing if request sent directly to api (e.g. from Postman)
            // so send instructions to verify directly with api
            message = [email protected]"<p>Please use the below token to verify your email address with the <code>/accounts/verify-email</code> api route:</p>
                            <p><code>{account.VerificationToken}</code></p>";
        }

        _emailService.Send(
            to: account.Email,
            subject: "Sign-up Verification API - Verify Email",
            html: [email protected]"<h4>Verify Email</h4>
                        <p>Thanks for registering!</p>
                        {message}"
        );
    }

    private void sendAlreadyRegisteredEmail(string email, string origin)
    {
        string message;
        if (!string.IsNullOrEmpty(origin))
            message = [email protected]"<p>If you don't know your password please visit the <a href=""{origin}/account/forgot-password"">forgot password</a> page.</p>";
        else
            message = "<p>If you don't know your password you can reset it via the <code>/accounts/forgot-password</code> api route.</p>";

        _emailService.Send(
            to: email,
            subject: "Sign-up Verification API - Email Already Registered",
            html: [email protected]"<h4>Email Already Registered</h4>
                        <p>Your email <strong>{email}</strong> is already registered.</p>
                        {message}"
        );
    }

    private void sendPasswordResetEmail(Account account, string origin)
    {
        string message;
        if (!string.IsNullOrEmpty(origin))
        {
            var resetUrl = $"{origin}/account/reset-password?token={account.ResetToken}";
            message = [email protected]"<p>Please click the below link to reset your password, the link will be valid for 1 day:</p>
                            <p><a href=""{resetUrl}"">{resetUrl}</a></p>";
        }
        else
        {
            message = [email protected]"<p>Please use the below token to reset your password with the <code>/accounts/reset-password</code> api route:</p>
                            <p><code>{account.ResetToken}</code></p>";
        }

        _emailService.Send(
            to: account.Email,
            subject: "Sign-up Verification API - Reset Password",
            html: [email protected]"<h4>Reset Password Email</h4>
                        {message}"
        );
    }
}

.NET Email Service

Path: /Services/EmailService.cs

The email service is a lightweight wrapper around the .NET MailKit mail client library to simplify sending emails from anywhere in the .NET 6 boilerplate API. It is used by the account service to send account verification and password reset emails.

For more info on MailKit see https://github.com/jstedfast/MailKit.

namespace WebApi.Services;

using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.Extensions.Options;
using MimeKit;
using MimeKit.Text;
using WebApi.Helpers;

public interface IEmailService
{
    void Send(string to, string subject, string html, string from = null);
}

public class EmailService : IEmailService
{
    private readonly AppSettings _appSettings;

    public EmailService(IOptions<AppSettings> appSettings)
    {
        _appSettings = appSettings.Value;
    }

    public void Send(string to, string subject, string html, string from = null)
    {
        // create message
        var email = new MimeMessage();
        email.From.Add(MailboxAddress.Parse(from ?? _appSettings.EmailFrom));
        email.To.Add(MailboxAddress.Parse(to));
        email.Subject = subject;
        email.Body = new TextPart(TextFormat.Html) { Text = html };

        // send email
        using var smtp = new SmtpClient();
        smtp.Connect(_appSettings.SmtpHost, _appSettings.SmtpPort, SecureSocketOptions.StartTls);
        smtp.Authenticate(_appSettings.SmtpUser, _appSettings.SmtpPass);
        smtp.Send(email);
        smtp.Disconnect(true);
    }
}

.NET 6 App Settings JSON

Path: /appsettings.json

The appsettings.json file is the base configuration file in a .NET app that contains settings for all environments (e.g. DevelopmentProduction). You can override values for different environments by creating environment specific appsettings files (e.g. appsettings.Development.jsonappsettings.Production.json).

It includes the WebApiDatabase connection string to the SQLite database, the Secret used for signing and verifying JWT tokens, the refresh token time to live (RefreshTokenTTL) which sets the number of days to keep inactive refresh tokens in the database, the EmailFrom address used to send emails, and the Smtp* options used to connect and authenticate with an email server.

Configure SMTP settings for email with the Smtp* properties. For quick testing you can create a temporary inbox at https://ethereal.email/ and copy the SMTP configuration options.

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,
        "EmailFrom": "[email protected]",
        "SmtpHost": "[ENTER YOUR OWN SMTP OPTIONS OR CREATE FREE TEST ACCOUNT IN ONE CLICK AT https://ethereal.email/]",
        "SmtpPort": 587,
        "SmtpUser": "",
        "SmtpPass": ""
    },
    "ConnectionStrings": {
        "WebApiDatabase": "Data Source=WebApiDatabase.db"
    },
    "Logging": {
        "LogLevel": {
            "Default": "Information",
            "Microsoft.AspNetCore": "Warning"
        }
    }
}

.NET 6 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 Program 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 Microsoft.EntityFrameworkCore;
using System.Text.Json.Serialization;
using WebApi.Authorization;
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 => 
    {
        // serialize enums as strings in api responses (e.g. Role)
        x.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
    });
    services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies());
    services.AddSwaggerGen();

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

    // configure DI for application services
    services.AddScoped<IJwtUtils, JwtUtils>();
    services.AddScoped<IAccountService, AccountService>();
    services.AddScoped<IEmailService, EmailService>();
}

var app = builder.Build();

// migrate any database changes on startup (includes initial db creation)
using (var scope = app.Services.CreateScope())
{
    var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();    
    dataContext.Database.Migrate();
}

// configure HTTP request pipeline
{
    // generated swagger json and swagger ui middleware
    app.UseSwagger();
    app.UseSwaggerUI(x => x.SwaggerEndpoint("/swagger/v1/swagger.json", ".NET Sign-up and Verification API"));

    // 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 6 Web Api csproj

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 in each class file. 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="AutoMapper" Version="11.0.1" />
        <PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="11.0.0" />
        <PackageReference Include="BCrypt.Net-Next" Version="4.0.2" />
        <PackageReference Include="MailKit" Version="3.1.1" />
        <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.2" />
        <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.2">
            <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
            <PrivateAssets>all</PrivateAssets>
        </PackageReference>
        <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.2" />
        <PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" />
        <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.16.0" />
    </ItemGroup>
</Project>

More Error Tutorials >>

Vue 3+VeeValidate – Form Validation Example (Composition API)

Vue 3+VeeValidate – Required Checkbox Example (Composition API)

Vue 3+Pinia-JWT Authentication Tutorial & Example

Leave a Reply

Your email address will not be published.

error: Content is protected !!