.NET 5.0 - Boilerplate API with Email Sign Up, Verification, Authentication & Forgot Password
Tutorial built with .NET 5.0
Other versions available:
- .NET: .NET 6.0, ASP.NET Core 3.1
- Node: Node.js + MongoDB, Node.js + MySQL
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
- Tools required to develop .NET 5.0 applications
- Run the boilerplate API locally
- Run with an Angular App
- Run with a React App
- Test the API with Postman
- Boilerplate project structure and code documentation
.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
- Download or clone the tutorial project code from https://github.com/cornflourblue/dotnet-5-signup-verification-api
- 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. - 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 messageNow listening on: http://localhost:4000
, and you can view the Swagger API documentation athttp://localhost:4000/swagger
. - 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.
- Download or clone the Angular 10 tutorial code from https://github.com/cornflourblue/angular-10-signup-verification-boilerplate
- Install all required npm packages by running
npm install
from the command line in the project root folder (where the package.json is located). - Remove or comment out the line below the comment
// provider used to create fake backend
located in the/src/app/app.module.ts
file. - 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.
- Download or clone the React tutorial code from https://github.com/cornflourblue/react-signup-verification-boilerplate
- Install all required npm packages by running
npm install
ornpm i
from the command line in the project root folder (where the package.json is located). - Remove or comment out the 2 lines below the comment
// setup fake backend
located in the/src/index.jsx
file. - 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:
- Register a new account
- Verify an account
- Access an account with a forgotten password
- Reset the password of an account
- Authenticate to get a JWT token and a refresh token
- Get a list of all accounts
- Update an account
- Use a refresh token to get a new JWT token
- Revoke a refresh token
- Delete an account
How to register a new account with Postman
To register a new account with the boilerplate api follow these steps:
- Open a new request tab by clicking the plus (+) button at the end of the tabs.
- Change the http request method to "POST" with the dropdown selector on the left of the URL input field.
- In the URL field enter the address to the register route of your local API -
http://localhost:4000/accounts/register
- Select the "Body" tab below the URL field, change the body type radio button to "raw", and change the format dropdown selector to "JSON".
- 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 }
- 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:
How to verify an account with Postman
To verify an account with the .NET api follow these steps:
- Open a new request tab by clicking the plus (+) button at the end of the tabs.
- Change the http request method to "POST" with the dropdown selector on the left of the URL input field.
- In the URL field enter the address to the authenticate route of your local API -
http://localhost:4000/accounts/verify-email
- Select the "Body" tab below the URL field, change the body type radio button to "raw", and change the format dropdown selector to "JSON".
- 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" }
- 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:
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:
- Open a new request tab by clicking the plus (+) button at the end of the tabs.
- Change the http request method to "POST" with the dropdown selector on the left of the URL input field.
- In the URL field enter the address to the authenticate route of your local API -
http://localhost:4000/accounts/forgot-password
- Select the "Body" tab below the URL field, change the body type radio button to "raw", and change the format dropdown selector to "JSON".
- Enter a JSON object containing the email of the account with the forgotten password in the "Body" textarea, e.g:
{ "email": "[email protected]" }
- 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:
How to reset the password of an account with Postman
To reset the password of an account with the api follow these steps:
- Open a new request tab by clicking the plus (+) button at the end of the tabs.
- Change the http request method to "POST" with the dropdown selector on the left of the URL input field.
- In the URL field enter the address to the authenticate route of your local API -
http://localhost:4000/accounts/reset-password
- Select the "Body" tab below the URL field, change the body type radio button to "raw", and change the format dropdown selector to "JSON".
- 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" }
- 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:
- Open a new request tab by clicking the plus (+) button at the end of the tabs.
- Change the http request method to "POST" with the dropdown selector on the left of the URL input field.
- In the URL field enter the address to the authenticate route of your local API -
http://localhost:4000/accounts/authenticate
- Select the "Body" tab below the URL field, change the body type radio button to "raw", and change the format dropdown selector to "JSON".
- Enter a JSON object containing the account email and password in the "Body" textarea:
{ "email": "[email protected]", "password": "my-super-secret-password" }
- 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.
- 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:
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:
- Open a new request tab by clicking the plus (+) button at the end of the tabs.
- Change the http request method to "GET" with the dropdown selector on the left of the URL input field.
- In the URL field enter the address to the users route of your local API -
http://localhost:4000/accounts
- 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.
- 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 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:
- Open a new request tab by clicking the plus (+) button at the end of the tabs.
- Change the http request method to "PUT" with the dropdown selector on the left of the URL input field.
- 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
- 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.
- Select the "Body" tab below the URL field, change the body type radio button to "raw", and change the format dropdown selector to "JSON".
- 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" }
- 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:
- Open a new request tab by clicking the plus (+) button at the end of the tabs.
- Change the http request method to "POST" with the dropdown selector on the left of the URL input field.
- In the URL field enter the address to the refresh token route of your local API -
http://localhost:4000/accounts/refresh-token
- 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.
- 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:
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:
- Open a new request tab by clicking the plus (+) button at the end of the tabs.
- Change the http request method to "POST" with the dropdown selector on the left of the URL input field.
- In the URL field enter the address to the authenticate route of your local API -
http://localhost:4000/accounts/revoke-token
- 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.
- Select the "Body" tab below the URL field, change the body type radio button to "raw", and change the format dropdown selector to "JSON".
- 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" }
- 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 users can delete any account, while regular users are restricted to their own account.
To delete an account with the api follow these steps:
- Open a new request tab by clicking the plus (+) button at the end of the tabs.
- Change the http request method to "DELETE" with the dropdown selector on the left of the URL input field.
- 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
- 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.
- 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 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:
- Controllers
- Entities
- Helpers
- Middleware
- Migrations
- 20200715105414_InitialCreate.cs
- 20200715105414_InitialCreate.Designer.cs
- DataContextModelSnapshot.cs
- Models
- Services
- appsettings.json
- Program.cs
- Startup.cs
- WebApi.csproj
Accounts Controller
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
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
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
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
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
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))
{
}
}
}
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
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 };
}
}
}
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;
}
));
}
}
}
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
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
The custom JWT middleware checks if there is a token in the request Authorization
header, and if so attempts to:
- Validate the token
- Extract the account id from the token
- 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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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)
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
When I'm not coding...
Me and Tina are on a motorcycle adventure around Australia.
Come along for the ride!