Published: September 01 2021

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

Tutorial built with .NET 5.0

Other versions available:

In this tutorial we'll cover how to build a boilerplate sign up and authentication API in .NET 5.0 that supports the following functionality:

  • Email sign up and verification
  • JWT authentication with refresh tokens
  • Role based authorization with support for two roles (User & Admin)
  • Forgot password and reset password functionality
  • Account management (CRUD) routes with role based access control
  • Swagger api documentation route


Tutorial Contents

The tutorial is organised into the following main sections:


.NET Boilerplate Overview

There are no users registered in the .NET boilerplate api by default, in order to authenticate you must first register and verify an account. The api sends a verification email after registration with a token to verify the account. Email SMTP settings must be set in the appsettings.json file for email to work correctly, you can create a free test account in one click at https://ethereal.email/ and copy the options below the title SMTP configuration.

The first user registered is assigned to the Admin role and subsequent users are assigned to the regular User role. Admins have full access to CRUD routes for managing all users, while regular users can only modify their own account.

JWT authentication with refresh tokens

Authentication is implemented with JWT access tokens and refresh tokens. On successful authentication the boilerplate 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 increased security because they 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 JWT token from the /accounts/refresh-token route which prevents CSRF (cross site request forgery).

Refresh token rotation

As an added security measure in the RefreshToken() method of the account service, each time a refresh token is used to generate a new JWT token, 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 refresh token methods.

SQL database setup and configuration

The boilerplate api is configured to use a SQLite database because it's self-contained and doesn't require a database server to be installed. To use a different database (e.g. SQL Server, MySql, PostgreSQL) update the database provider in the EF Core DataContext.cs class then delete and regenerate the database migrations with the command dotnet ef migrations add InitialCreate. Database migrations are run on startup so the database is created automatically the first time you start the api.

Code on GitHub

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


Tools required to develop .NET 5.0 applications

To develop and run .NET 5.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 5.0 Boilerplate API Locally

  1. Download or clone the tutorial project code from https://github.com/cornflourblue/dotnet-5-signup-verification-api
  2. Configure SMTP settings for email within the AppSettings section in the /appsettings.json file. For testing you can create a free account in one click at https://ethereal.email/ and copy the options below the title SMTP configuration.
  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 an example Angular or React application.

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 Boilerplate API

For full details about the boilerplate Angular 10 app see the post Angular 10 Boilerplate - Email Sign Up with Verification, Authentication & Forgot Password. But to get up and running quickly just follow the below steps.

  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 Boilerplate API that you already have running.


Run a React App with the .NET Boilerplate API

For full details about the boilerplate React app see the post React Boilerplate - Email Sign Up with Verification, Authentication & Forgot Password. But to get up and running quickly just follow the below steps.

  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 5.0 Boilerplate API that you already have running.


Test the .NET Boilerplate API with Postman

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

Below are instructions on how to use Postman to:


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 request 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 user properties in the "Body" textarea, e.g:
    {
        "title": "Mr",
        "firstName": "Jason",
        "lastName": "Watmore",
        "email": "[email protected]",
        "password": "my-super-secret-password",
        "confirmPassword": "my-super-secret-password",
        "acceptTerms": true
    }
  6. 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 new user has been registered:

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

Back to top


How to verify an account with Postman

To verify an account with the .NET 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 request 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, 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:

Back to top


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 an account:

  1. Open a new request tab by clicking the plus (+) button at the end of the tabs.
  2. Change the http request 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, 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:

Back to top


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 request 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": "new-super-secret-password",
        "confirmPassword": "new-super-secret-password"
    }
  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:

Back to top


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 request 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": "my-super-secret-password"
    }
  6. Click the "Send" button, you should receive a "200 OK" response with the user 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 is the response cookies tab with the refresh token:

Back to top


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 admin users.

To get a list of all accounts from the .NET 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 request 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/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:

Back to top


How to update an account with Postman

This is a secure request that requires a JWT authentication token from the authenticate step. Admin users can update any account including its role, while regular users are restricted to their own account and cannot update role. Omitted or empty properties are 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 request 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": "Frank",
        "lastName": "Murphy"
    }
  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:

Back to top


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 request 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 is the response cookies tab with the new refresh token:

Back to top


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 users can revoke the tokens of any account, while regular users 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 request 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:

Back to top


How to delete an account with Postman

This is a secure request that requires a JWT authentication token from the authenticate step. Admin users can delete any account, while regular users 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 request 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:

Back to top


.NET 5.0 Boilerplate Project Structure

The tutorial project is organised into the following folders:

Controllers

Define the end points / routes for the web api, controllers are the entry point into the web api from 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 what data is returned.

Services

Contain core business logic, validation and data 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.

Migrations

Database migration files based on the Entities classes that are used to automatically create the SQLite database for the api and automatically update it with changes when the entities are changed. Migrations are generated with the Entity Framework Core CLI, the migrations in this example were generated with the command dotnet ef migrations add InitialCreate.
To use a different database (e.g. SQL Server) change the database type in the DataContext class then delete and regenerate the migrations with the same command.

Middleware

Custom middleware added to the request pipeline for handling common tasks such as global error handling and jwt token validation.

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:

 

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.

Routes that require authorization include the [Authorize] attribute and optionally specify a role (e.g. [Authorize(Role.Admin)], if a role is specified then the route is restricted to users in that role, otherwise the route is restricted to all authenticated users regardless of role. The auth logic is located in the custom authorize attribute.

The route methods RevokeToken, GetById, Update and Delete include an extra custom authorization check to prevent non-admin users from accessing accounts other than their own. So regular user accounts (Role.User) have CRUD access to their own account but not to others, and admin accounts (Role.Admin) 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).

using AutoMapper;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using WebApi.Entities;
using WebApi.Models.Accounts;
using WebApi.Services;

namespace WebApi.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class AccountsController : BaseController
    {
        private readonly IAccountService _accountService;
        private readonly IMapper _mapper;

        public AccountsController(
            IAccountService accountService,
            IMapper mapper)
        {
            _accountService = accountService;
            _mapper = mapper;
        }

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

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

        [Authorize]
        [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" });
        }

        [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" });
        }

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

        [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" });
        }

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

        [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);
        }

        [Authorize]
        [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);
        }

        [Authorize]
        [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);
        }

        [Authorize]
        [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();
        }
    }
}
 

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.

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

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

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.

using System;
using System.Collections.Generic;

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;
        }
    }
}
 

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.

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

namespace WebApi.Entities
{
    [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 bool IsExpired => DateTime.UtcNow >= Expires;
        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 bool IsActive => Revoked == null && !IsExpired;
    }
}
 

Role Enum Entity

Path: /Entities/Role.cs

The role enum defines all the available roles in the 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
    }
}
 

Custom 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 where different exception types are handled in the global error handler middleware.

using System;
using System.Globalization;

namespace WebApi.Helpers
{
    // 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))
        {
        }
    }
}
 

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 in the ConfigureServices method of the Startup.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; }
    }
}
 

Custom Authorize Attribute

Path: /Helpers/AuthorizeAttribute.cs

The custom authorize attribute is added to controller 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.

Authorization is performed by the OnAuthorization method which checks if there is an authenticated account attached to the current request (context.HttpContext.Items["Account"]) and that the account is authorized based on its 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.

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using System;
using System.Collections.Generic;
using System.Linq;
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)
    {
        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 };
        }
    }
}
 

Path: /Helpers/AutoMapperProfile.cs

The auto mapper profile contains the mapping configuration used by the 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.

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

namespace WebApi.Helpers
{
    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;
                    }
                ));
        }
    }
}
 

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) update the database provider in the OnConfiguring method then delete and regenerate the database migrations with the command dotnet ef migrations add InitialCreate. Database migrations are run on startup so the database is created automatically the first time you start the api.

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using WebApi.Entities;

namespace WebApi.Helpers
{
    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"));
        }
    }
}
 

Global Error Handler Middleware

Path: /Middleware/ErrorHandlerMiddleware.cs

The global error handler is used catch all errors and remove the need for duplicated error handling code throughout the boilerplate application. It's configured as middleware in the Configure method of the Startup.cs class.

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.

using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Net;
using System.Text.Json;
using System.Threading.Tasks;
using WebApi.Helpers;

namespace WebApi.Middleware
{
    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);
            }
        }
    }
}
 

Custom JWT Middleware

Path: /Middleware/JwtMiddleware.cs

The custom JWT middleware checks if there is a token in the request Authorization header, and if so attempts to:

  1. Validate the token
  2. Extract the account id from the token
  3. Attach the authenticated account to the current HttpContext.Items collection to make it accessible within the scope of the current request

If there is no token in the request header or if any of the above steps fails then no account is attached to http context and the request will only be able to access public routes. Authorization is performed by the custom authorize attribute which checks that an account is attached to the http context, if authorization fails a 401 Unauthorized response is returned.

using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using WebApi.Helpers;

namespace WebApi.Middleware
{
    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)
        {
            var token = context.Request.Headers["Authorization"].FirstOrDefault()?.Split(" ").Last();

            if (token != null)
                await attachAccountToContext(context, dataContext, token);

            await _next(context);
        }

        private async Task attachAccountToContext(HttpContext context, DataContext dataContext, string token)
        {
            try
            {
                var tokenHandler = new JwtSecurityTokenHandler();
                var key = Encoding.ASCII.GetBytes(_appSettings.Secret);
                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);

                // attach account to context on successful jwt validation
                context.Items["Account"] = await dataContext.Accounts.FindAsync(accountId);
            }
            catch 
            {
                // do nothing if jwt validation fails
                // account is not attached to context so request won't have access to secure routes
            }
        }
    }
}
 

Account Response Model

Path: /Models/Accounts/AccountResponse.cs

The account response model defines the account data returned by the GetAll, GetById, Create 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.

using System;

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; }
    }
}
 

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.

using System.ComponentModel.DataAnnotations;

namespace WebApi.Models.Accounts
{
    public class AuthenticateRequest
    {
        [Required]
        [EmailAddress]
        public string Email { get; set; }

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

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.

using System;
using System.Text.Json.Serialization;

namespace WebApi.Models.Accounts
{
    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; }
    }
}
 

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.

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

namespace WebApi.Models.Accounts
{
    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; }
    }
}
 

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.

using System.ComponentModel.DataAnnotations;

namespace WebApi.Models.Accounts
{
    public class ForgotPasswordRequest
    {
        [Required]
        [EmailAddress]
        public string Email { get; set; }
    }
}
 

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.

using System.ComponentModel.DataAnnotations;

namespace WebApi.Models.Accounts
{
    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; }
    }
}
 

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.

using System.ComponentModel.DataAnnotations;

namespace WebApi.Models.Accounts
{
    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; }
    }
}
 

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; }
    }
}
 

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.

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

namespace WebApi.Models.Accounts
{
    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;
        }
    }
}
 

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.

using System.ComponentModel.DataAnnotations;

namespace WebApi.Models.Accounts
{
    public class ValidateResetTokenRequest
    {
        [Required]
        public string Token { get; set; }
    }
}
 

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.

using System.ComponentModel.DataAnnotations;

namespace WebApi.Models.Accounts
{
    public class VerifyEmailRequest
    {
        [Required]
        public string Token { get; set; }
    }
}
 

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 all interaction 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.

using AutoMapper;
using BC = BCrypt.Net.BCrypt;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using WebApi.Entities;
using WebApi.Helpers;
using WebApi.Models.Accounts;

namespace WebApi.Services
{
    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 IMapper _mapper;
        private readonly AppSettings _appSettings;
        private readonly IEmailService _emailService;

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

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

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

            // authentication successful so generate jwt and refresh tokens
            var jwtToken = generateJwtToken(account);
            var refreshToken = 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 (refreshToken, account) = getRefreshToken(token);

            // replace old refresh token with a new one and save
            var newRefreshToken = generateRefreshToken(ipAddress);
            refreshToken.Revoked = DateTime.UtcNow;
            refreshToken.RevokedByIp = ipAddress;
            refreshToken.ReplacedByToken = newRefreshToken.Token;
            account.RefreshTokens.Add(newRefreshToken);

            removeOldRefreshTokens(account);

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

            // generate new jwt
            var jwtToken = generateJwtToken(account);

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

        public void RevokeToken(string token, string ipAddress)
        {
            var (refreshToken, account) = getRefreshToken(token);

            // revoke token and save
            refreshToken.Revoked = DateTime.UtcNow;
            refreshToken.RevokedByIp = ipAddress;
            _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 = randomTokenString();

            // hash password
            account.PasswordHash = BC.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 = randomTokenString();
            account.ResetTokenExpires = DateTime.UtcNow.AddDays(1);

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

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

        public void ValidateResetToken(ValidateResetTokenRequest model)
        {
            var account = _context.Accounts.SingleOrDefault(x =>
                x.ResetToken == model.Token &&
                x.ResetTokenExpires > DateTime.UtcNow);

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

        public void ResetPassword(ResetPasswordRequest model)
        {
            var account = _context.Accounts.SingleOrDefault(x =>
                x.ResetToken == model.Token &&
                x.ResetTokenExpires > DateTime.UtcNow);

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

            // update password and remove reset token
            account.PasswordHash = BC.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 = BC.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 taken");

            // hash password if it was entered
            if (!string.IsNullOrEmpty(model.Password))
                account.PasswordHash = BC.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 (RefreshToken, Account) getRefreshToken(string token)
        {
            var account = _context.Accounts.SingleOrDefault(u => u.RefreshTokens.Any(t => t.Token == token));
            if (account == null) throw new AppException("Invalid token");
            var refreshToken = account.RefreshTokens.Single(x => x.Token == token);
            if (!refreshToken.IsActive) throw new AppException("Invalid token");
            return (refreshToken, 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 RefreshToken generateRefreshToken(string ipAddress)
        {
            return new RefreshToken
            {
                Token = randomTokenString(),
                Expires = DateTime.UtcNow.AddDays(7),
                Created = DateTime.UtcNow,
                CreatedByIp = ipAddress
            };
        }

        private void removeOldRefreshTokens(Account account)
        {
            account.RefreshTokens.RemoveAll(x => 
                !x.IsActive && 
                x.Created.AddDays(_appSettings.RefreshTokenTTL) <= DateTime.UtcNow);
        }

        private string randomTokenString()
        {
            using var rngCryptoServiceProvider = new RNGCryptoServiceProvider();
            var randomBytes = new byte[40];
            rngCryptoServiceProvider.GetBytes(randomBytes);
            // convert random bytes to hex string
            return BitConverter.ToString(randomBytes).Replace("-", "");
        }

        private void sendVerificationEmail(Account account, string origin)
        {
            string message;
            if (!string.IsNullOrEmpty(origin))
            {
                var verifyUrl = $"{origin}/account/verify-email?token={account.VerificationToken}";
                message = $@"<p>Please click the below link to verify your email address:</p>
                             <p><a href=""{verifyUrl}"">{verifyUrl}</a></p>";
            }
            else
            {
                message = $@"<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: $@"<h4>Verify Email</h4>
                         <p>Thanks for registering!</p>
                         {message}"
            );
        }

        private void sendAlreadyRegisteredEmail(string email, string origin)
        {
            string message;
            if (!string.IsNullOrEmpty(origin))
                message = $@"<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: $@"<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 = $@"<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 = $@"<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: $@"<h4>Reset Password Email</h4>
                         {message}"
            );
        }
    }
}
 

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 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.

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

namespace WebApi.Services
{
    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);
        }
    }
}
 

App Settings

Path: /appsettings.json

The appsettings.json file is the base configuration file in a .NET app that contains settings for all environments (e.g. Development, Production). You can override values for different environments by creating environment specific appsettings files (e.g. appsettings.Development.json, appsettings.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 testing you can create a free account in one click at https://ethereal.email/ and copy the options below the title SMTP configuration.

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": {
            "Microsoft": "Warning",
            "Microsoft.Hosting.Lifetime": "Information"
        }
    }
}
 

.NET Program Class with Main Method

Path: /Program.cs

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 method can be located anywhere in a project but is typically placed in the Program class.

A .NET web app is run within a host which handles app startup, lifetime management, web server configuration and more. A host is created and launched by calling Build().Run() on a host builder (an instance of the IHostBuilder interface). A generic host builder with pre-configured defaults is created with the CreateDefaultBuilder() convenience method provided by the static Host class (Microsoft.Extensions.Hosting.Host).

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

The x.UseStartup<Startup>() method specifies which startup class to use when building a host for the web app.

using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;

namespace WebApi
{
    public class Program
    {
        public static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args)
        {
            return Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(x => 
                {
                    x.UseStartup<Startup>();
                    x.UseUrls("http://localhost:4000");
                });
        }
    }
}
 

.NET Startup Class

Path: /Startup.cs

The Startup class configures the services available to the .NET Dependency Injection (DI) container in the ConfigureServices() method, and configures the .NET request pipeline for the application in the Configure() method. Both methods are called by the .NET runtime when the app starts, first ConfigureServices() followed by Configure().

The .NET host passes an IApplicationBuilder to the Configure() method, all DI services are also available to Configure() and can be added as parameters to the method (e.g. public void Configure(IApplicationBuilder app, DataContext context) { ... }). For more info on the startup class and both configure methods see https://docs.microsoft.com/aspnet/core/fundamentals/startup.

using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System;
using WebApi.Helpers;
using WebApi.Middleware;
using WebApi.Services;

namespace WebApi
{
    public class Startup
    {
        public IConfiguration Configuration { get; }

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

        // add services to the DI container
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext<DataContext>();
            services.AddCors();
            services.AddControllers().AddJsonOptions(x => x.JsonSerializerOptions.IgnoreNullValues = true);
            services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies());
            services.AddSwaggerGen();

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

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

        // configure the HTTP request pipeline
        public void Configure(IApplicationBuilder app, DataContext context)
        {
            // migrate database changes on startup (includes initial db creation)
            context.Database.Migrate();

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

            app.UseRouting();

            // 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.UseEndpoints(x => x.MapControllers());
        }
    }
}
 

.NET MSBuild C# Project File (.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.

<Project Sdk="Microsoft.NET.Sdk.Web">
    <PropertyGroup>
        <TargetFramework>net5.0</TargetFramework>
    </PropertyGroup>
    <ItemGroup>
        <PackageReference Include="AutoMapper" Version="10.1.1" />
        <PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="8.1.1" />
        <PackageReference Include="BCrypt.Net-Next" Version="4.0.2" />
        <PackageReference Include="MailKit" Version="2.15.0" />
        <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.9" />
        <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.9">
            <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
            <PrivateAssets>all</PrivateAssets>
        </PackageReference>
        <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.9" />
        <PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.1" />
        <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.12.2" />
    </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