ASP.NET 8 - Authentication and Authorization in 7 steps
Introduction
In this tutorial, you will learn how to develop an API for user permission-based authentication and authorization. In addition, the Clean Architecture, Unit of Work, and Mediator patterns will be used.
Tools
C#
.NET8
Visual Studio 2022
Docker
Azure Data Studio
Patterns
Mediator
This pattern aims to improve the way different parts of your application communicate with each other. Different parts of your application (components) don't talk directly to each other. Instead, they send requests to the mediator which is as a central point of communication in your application.
Unit of Work
Is a design pattern used to manage a series of database operations as a single unit. The Unit of Work pattern groups database operations (Create, Delete, and Update) into a single transaction. This ensures that all operations are reflected in the database (commit). In case of an error, the pattern performs a rollback.
Clean Architecture
Clean Architecture is a software design pattern that promotes maintainability, testability, and reusability by separating different concerns within the application into distinct layers. It's often visualized as an onion, with the core business logic (domain) at the center, surrounded by outward layers that handle progressively more external concerns.
The layers in Clean Architecture:
Domain
Represents the core business logic of your application.
Contains entities, value objects and interfaces.
Application
Implements the use cases of your application.
Defines application services that manipulate domain entities.
Infrastructure
Contains concrete implementations for technical details.
Provides interfaces for persistence (databases), external APIs, email services, etc.
Presentation
User access layer who can make requests to the application.
Contains controllers, extensions and configurions.
First Step: Create Project
With Visual Studio open, create a new project and select Blank Solution name it as follows Project, inside the solution create four Solution Folder, with the following names: Domain, Application, Infrastructure and Presentation.
Open the Domain folder and add a new project and select Class Libary, name it as follows Project.Domain, do the same with the layers Application (Project.Application) and Infrastructure(Project.Infrastructure).
In sequence in the folder Presentation, Add a new project and this time select the option ASP.NET Core Web API name it as follows Project.WebApi
Select the box Enable Docker and select the OS Linux
In the end you should have this folder structure:
Second Step: Layer references
Now let's configure the references for each layer, in projects like Class Library and delete the Class.cs files.
To add a reference, select the Dependencies folder and right-click and select Add Project Reference.. as in the image below:
Start at the Project.Application layer, add reference to the Project.Domain project.
In the Project.Infrastructure layer add the following references:
Finally, in the last section Project.Presentation add the following references:
Third Step: Install Packages
Now let's install the packages, to do this in the Visual Studio menu click on Tools, select NuGet Package Manager and finally click on Manager NuGet Packages For Solution..
Project.Application
AutoMapper: Simplifies mapping between different object structures, often used for converting domain models to DTOs (Data Transfer Objects) for API responses.
BCrypt.Net-Next: Provides a secure password hashing library for secure user password storage and verification.
FluentValidation.DependencyInjection: Integrates FluentValidation, a popular library for building validation rules for your application models.
MediatR: Facilitates communication within your application using the Mediator pattern, promoting loose coupling and testability.
Project.WebApi
Microsoft.AspNet.WebApi.Cors: Enables Cross-Origin Resource Sharing (CORS) for your Web API, allowing requests from different origins (domains, ports, protocols).
Microsoft.AspNetCore.Authentication.JwtBearer: Enables implementing JWT (JSON Web Token) based authentication for your Web API, ensuring secure access control.
Microsoft.EntityFrameworkCore: Provides core functionalities and a SQL Server provider for interacting with your database from the Web API layer. (Note: Only needed if using SQL Server)
Microsoft.EntityFrameworkCore.Design: Assists with database migrations and design-time tools.
Microsoft.EntityFrameworkCore.Tools: Provides command-line tools for interacting with your database schema and migrations.
Microsoft.VisualStudio.Azure.Containers.Tools.Target (Optional): Might be required for deploying your Web API to Azure containers.
Swashbuckle.AspNetCore & Swashbuckle.AspNetCore.SwaggerUI: This combination generates OpenAPI (Swagger) documentation for your Web API, allowing developers to explore API endpoints and functionalities.
Project.Infrastructure
Microsoft.EntityFrameworkCore
Microsoft.EntityFrameworkCore.SqlServer: provides the Entity Framework Core (EF Core) database provider for Microsoft SQL Server.
Fourth Step: Implementation of layers
Domain Layer
Create a folder with name Entities and add the following Class files:
We need a entity base Id and the timestamp to Create, Update and Delete operations:
using System.ComponentModel.DataAnnotations;
namespace Project.Domain.Entities
{
public class EntityBase
{
[Key]
public Guid Id { get; set; }
public DateTimeOffset DateCreated { get; set; }
public DateTimeOffset? DateUpdated { get; set; }
public DateTimeOffset? DateDeleted { get; set; }
protected EntityBase() => Id = Guid.NewGuid();
}
}
User class:
namespace Project.Domain.Entities
{
public class User : EntityBase
{
public string Email { get; set; }
public string Password { get; set; }
public List<Role> Roles { get; set; } = new();
public Guid RefreshToken { get; set; } = Guid.NewGuid();
public User(string email, string password)
{
Email = email;
Password = password;
}
public void UpdateUser(User newUser)
{
Email = newUser.Email;
Password = newUser.Password;
Roles = newUser.Roles;
}
public void GenerateRefreshToken()
{
RefreshToken = Guid.NewGuid();
}
public void addRole(Role role)
{
Roles.Add(role);
}
}
}
Role class:
namespace Project.Domain.Entities
{
public class Role : EntityBase
{
public string Name { get; set; }
public List<User> Users { get; set; } = new();
public Role(string name)
{
Name = name;
}
}
}
Let's create the Domain Interfaces for Repositories
Base Interface Repository with the CRUD basic operations.
using Project.Domain.Entities;
namespace Project.Domain.Interfaces
{
public interface IBaseRepository<T> where T : EntityBase
{
void Create(T entity);
void Update(T entity);
void Delete(T entity);
T GetById(Guid id);
List<T> GetAll();
}
}
In Role Interface Repository we need a functions that brings all roles by a list of ids passed
using Project.Domain.Entities;
namespace Project.Domain.Interfaces
{
public interface IRoleRepository : IBaseRepository<Role>
{
Task<List<Role>> GetRoles(List<Guid> ids);
}
}
In User Interface Repository we need more three methods GetUserByEmailAsync, GetUserByRefreshCode and AnyAsync.
using Project.Domain.Entities;
namespace Project.Domain.Interfaces
{
public interface IUserRepository : IBaseRepository<User>
{
Task<User?> GetUserByEmailAsync(string email, CancellationToken cancellationToken);
public Task<User?> GetUserByRefreshCode(Guid refreshToken, CancellationToken cancellationToken);
Task<bool> AnyAsync(string email, CancellationToken cancelationToken);
}
}
Now let's create the Unit of Work interface to commit the transactions in database:
namespace Project.Domain.Interfaces
{
public interface IUnitOfWork
{
Task Commit(CancellationToken cancellationToken);
}
}
Now we need a class to receive the environment variables that we can manipulate in code.
Configuration class:
namespace Project.Domain.Security
{
public static class Configuration
{
public static SecretsConfiguration Secrets { get; set; } = new();
public class SecretsConfiguration
{
public string ApiKey { get; set; } = string.Empty;
public string JwtPrivateKey { get; set; } = string.Empty;
public string PasswordSaltKey { get; set; } = string.Empty;
}
}
}
Application Layer
First, let's start with DTOs and Handler Response, following this structure of folder and class:
User Response DTO:
namespace Project.Application.DTOs
{
public class UserResponseDTO
{
public Guid Id { get; set; }
public string Email { get; set; }
public List<RoleResponseDTO> Roles { get; set; }
public string? Token { get; set; }
public Guid? RefreshToken { get; set; }
}
}
Role Response DTO:
namespace Project.Application.DTOs
{
public class RoleResponseDTO
{
public Guid Id { get; set; }
public string Name { get; set; }
}
}
In Handler Response folder, create a class named has Response, we gonna use this class to patronize all handlers response.
using Project.Application.DTOs;
namespace Project.Application.HandlerResponse
{
public class Response
{
public string Message { get; set; }
public int Status { get; set; }
public UserResponseDTO? Data { get; set; }
public Response(string message, int status)
{
Message = message;
Status = status;
}
public Response(string message, int status, UserResponseDTO? data)
{
Message = message;
Status = status;
Data = data;
}
}
}
Now we need to create Mappers to transform entities to his respective DTOs
User Mapper:
using AutoMapper;
using Project.Application.DTOs;
using Project.Domain.Entities;
namespace Project.Application.Mappers
{
public class UserMapper : Profile
{
public UserMapper()
{
CreateMap<User, UserResponseDTO>().ReverseMap();
}
}
}
Role Mapper:
using AutoMapper;
using Project.Application.DTOs;
using Project.Domain.Entities;
namespace Project.Application.Mappers
{
public class UserMapper : Profile
{
public UserMapper()
{
CreateMap<User, UserResponseDTO>().ReverseMap();
}
}
}
We will be using Fluent Validation to validate some inputs. However, before we proceed, we need to configure it.
Validation Behavior class:
using FluentValidation;
using MediatR;
namespace Project.Application.Shared
{
public sealed class ValidationBehavior<TRequest, TResponse> :
IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResponse> Handle(TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
if (!_validators.Any()) return await next();
var context = new ValidationContext<TRequest>(request);
if (_validators.Any())
{
context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
var failures = validationResults.SelectMany(r => r.Errors).Where(f => f != null).ToList();
if (failures.Count != 0)
throw new FluentValidation.ValidationException(failures);
}
return await next();
}
}
}
In sequence let's create one interface and implement the service to Hash Password for store User password in database:
Password Hashing Service Interface:
HashPassword function to hash the string password then return a hash and the VerifyHashedPassword to compare if a string password match with the hash stored in database to allow user to login.
namespace Project.Application.Interfaces
{
public interface IPasswordHashingService
{
string HashPassword(string password);
bool VerifyHashedPassword(string hashedPassword, string providedPassword);
}
}
Service Implementation:
I chose to use the BCrypt library to hash and verify passwords conveniently.
using Project.Application.Interfaces;
namespace Project.Application.Services
{
public class PasswordHashingService : IPasswordHashingService
{
public string HashPassword(string password)
{
if (password == null)
{
throw new ArgumentNullException(nameof(password));
}
return BCrypt.Net.BCrypt.HashPassword(password);
}
public bool VerifyHashedPassword(string hashedPassword, string providedPassword)
{
if (hashedPassword == null) throw new ArgumentNullException(nameof(hashedPassword));
if (providedPassword == null) throw new ArgumentNullException(nameof(providedPassword));
return BCrypt.Net.BCrypt.Verify(providedPassword, hashedPassword);
}
}
}
Now we need to configure services for the Application layer.
This file configures essential services for the application layer:
AutoMapper for data mapping
MediatR for handling requests using the Mediator pattern
FluentValidation for model validation
Custom ValidationBehavior integrated with MediatR for request validation
PasswordHashingService implementation for secure password hashing
using FluentValidation;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Project.Application.Interfaces;
using Project.Application.Services;
using Project.Application.Shared;
using System.Reflection;
namespace Project.Application.Configuration
{
public static class ServiceExtensions
{
public static void ConfigureApplicationApp(this IServiceCollection services)
{
services.AddAutoMapper(Assembly.GetExecutingAssembly());
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
services.AddScoped<IPasswordHashingService, PasswordHashingService>();
}
}
}
After configuration, we need to implement the
business rule for Create User, Authentication and Refresh token, we gonna follow the structure of Request refers to an object representing a specific action or operation the application needs to perform, Validator is a component responsible for checking the validity of data contained within a request object and Handler responsible for processing a specific type of request.
Following this folder and file structure:
Use Case: Create User
Create Request:
using MediatR;
using Project.Application.HandlerResponse;
namespace Project.Application.UseCases.Create
{
public record CreateUserRequest(
string Email,
string Password,
List<Guid> RoleIds
) : IRequest<Response>;
}
Create User Validator:
using FluentValidation;
namespace Project.Application.UseCases.Create
{
public class CreateUserValidator : AbstractValidator<CreateUserRequest>
{
public CreateUserValidator()
{
RuleFor(x => x.RoleIds).NotEmpty().NotNull();
RuleFor(x => x.Email).NotEmpty().MinimumLength(3).MaximumLength(100);
RuleFor(x => x.Password).NotEmpty().MinimumLength(3).MaximumLength(100);
}
}
}
Create User Handler:
This handler search if email passed is already in use, then search all roles passed to associate to the user, create the user object with password hashed by the method HashPassword and finally save users in database.
using MediatR;
using Project.Application.HandlerResponse;
using Project.Application.Interfaces;
using Project.Domain.Entities;
using Project.Domain.Interfaces;
namespace Project.Application.UseCases.Create
{
public class CreateUserHandler : IRequestHandler<CreateUserRequest, Response>
{
private readonly IUserRepository _userRepository;
private readonly IRoleRepository _roleRepository;
private readonly IUnitOfWork _unitOfWork;
private readonly IPasswordHashingService _service;
public CreateUserHandler(IUserRepository userRepository, IRoleRepository roleRepository, IUnitOfWork unitOfWork, IPasswordHashingService service)
{
_userRepository = userRepository;
_roleRepository = roleRepository;
_unitOfWork = unitOfWork;
_service = service;
}
public async Task<Response> Handle(CreateUserRequest request, CancellationToken cancellationToken)
{
// Get roles
List<Role> roles = [];
try
{
roles = await _roleRepository.GetRoles(request.RoleIds);
}
catch
{
return new Response("Internal Server Error", 500);
}
try
{
// Check if email is avaliable
bool isAvaliable = await _userRepository.AnyAsync(request.Email, cancellationToken);
if (isAvaliable)
{
return new Response("Email already in use", 404);
}
}
catch
{
return new Response("Internal Server Error", 500);
}
// Generate User object
User user = new User(request.Email, _service.HashPassword(request.Password));
user.Roles = roles;
try
{
// Save user in database
_userRepository.Create(user);
// Commit the chages in database
await _unitOfWork.Commit(cancellationToken);
}
catch
{
return new Response("Internal Server Error", 500);
}
return new Response("User created", 201);
}
}
}
Use Case: Authentication
Authentication Request:
using MediatR;
using Project.Application.HandlerResponse;
namespace Project.Application.UseCases.Authentication
{
public record AuthenticationRequest
(
string Email,
string Password
) : IRequest<Response>;
}
Authentication Validator:
For purpose of this tutorial i chose to do a simple validate, but you can check the email format with some external library for regex.
using FluentValidation;
namespace Project.Application.UseCases.Authentication
{
public class AuthenticationValidator : AbstractValidator<AuthenticationRequest>
{
public AuthenticationValidator()
{
RuleFor(x => x.Email).NotEmpty().MinimumLength(3).MaximumLength(100);
RuleFor(x => x.Password).NotEmpty().MinimumLength(3).MaximumLength(100);
}
}
}
Authentication Handler:
This files search user in database, check if password passed match to password hashed in database and return the user object.
using AutoMapper;
using MediatR;
using Project.Application.DTOs;
using Project.Application.HandlerResponse;
using Project.Application.Interfaces;
using Project.Domain.Entities;
using Project.Domain.Interfaces;
namespace Project.Application.UseCases.Authentication
{
public class AuthenticationHandler : IRequestHandler<AuthenticationRequest, Response>
{
private readonly IUserRepository _repository;
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly IPasswordHashingService _service;
public AuthenticationHandler(IUserRepository repository, IUnitOfWork unitOfWork, IMapper mapper, IPasswordHashingService service)
{
_repository = repository;
_unitOfWork = unitOfWork;
_mapper = mapper;
_service = service;
}
public async Task<Response> Handle(AuthenticationRequest request, CancellationToken cancellationToken)
{
User? user;
try
{
// Search user in database
user = await _repository.GetUserByEmailAsync(request.Email, cancellationToken);
if (user is null)
return new Response("User not found", 404);
}
catch
{
return new Response("Internal Server Error", 500);
}
// Validate user password
bool isVerified = _service.VerifyHashedPassword(user.Password, request.Password);
if (!isVerified)
{
return new Response("Password dont match", 404);
}
try
{
// Commit the chages in database
await _unitOfWork.Commit(cancellationToken);
}
catch
{
return new Response("Internal Server Error", 500);
}
// Mapper user to DTO
UserResponseDTO userDTO = _mapper.Map<UserResponseDTO>(user);
return new Response("User authenticated", 200, userDTO);
}
}
}
Use Case: Refresh Token
Refresh Token Request
using MediatR;
using Project.Application.HandlerResponse;
namespace Project.Application.UseCases.RefreshToken
{
public record RefreshTokenRequest(
Guid RefreshToken
) : IRequest<Response>;
}
Refresh Token Validator
using FluentValidation;
namespace Project.Application.UseCases.RefreshToken
{
public class RefreshTokenValidator : AbstractValidator<RefreshTokenRequest>
{
public RefreshTokenValidator()
{
RuleFor(x => x.RefreshToken).NotEmpty().NotNull();
}
}
}
Refresh Token Hander
This handler search user to his refresh code, generate a new refresh token then update in database.
using AutoMapper;
using MediatR;
using Project.Application.DTOs;
using Project.Application.HandlerResponse;
using Project.Domain.Entities;
using Project.Domain.Interfaces;
namespace Project.Application.UseCases.RefreshToken
{
public class RefreshTokenHandler : IRequestHandler<RefreshTokenRequest, Response>
{
private readonly IUserRepository _userRepository;
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
public RefreshTokenHandler(IUserRepository userRepository, IUnitOfWork unitOfWork, IMapper mapper)
{
_userRepository = userRepository;
_unitOfWork = unitOfWork;
_mapper = mapper;
}
public async Task<Response> Handle(RefreshTokenRequest request, CancellationToken cancellationToken)
{
// Search user
User? user;
try
{
// Search user role
user = await _userRepository.GetUserByRefreshCode(request.RefreshToken, cancellationToken);
if (user is null)
{
return new Response("User not found", 404);
}
}
catch
{
return new Response("Internal Server Error", 500);
}
// Update Refresh token
user.GenerateRefreshToken();
try
{
// Commit the chages in database
await _unitOfWork.Commit(cancellationToken);
}
catch
{
return new Response("Internal Server Error", 500);
}
// Mapper user to dto
UserResponseDTO userDTO = _mapper.Map<UserResponseDTO>(user);
return new Response("Token Refreshed", 200, userDTO);
}
}
}
Infraestruture Layer
Here we start in Repositories.
Base Repository:
using Project.Domain.Entities;
using Project.Domain.Interfaces;
using Project.Infrastructure.Context;
namespace Project.Infrastructure.Repositories
{
public class BaseRepository<T> : IBaseRepository<T> where T : EntityBase
{
private readonly AppDbContext _context;
public BaseRepository(AppDbContext context)
{
_context = context;
}
public void Create(T entity)
{
entity.DateCreated = DateTimeOffset.UtcNow;
_context.Add(entity);
}
public void Delete(T entity)
{
entity.DateDeleted = DateTimeOffset.UtcNow;
_context.Remove(entity);
}
public List<T> GetAll()
{
return _context.Set<T>().ToList();
}
public T GetById(Guid id)
{
return _context.Set<T>().FirstOrDefault(x => x.Id == id);
}
public void Update(T entity)
{
entity.DateUpdated = DateTimeOffset.UtcNow;
_context.Update(entity);
}
}
}
Role Repository:
using Microsoft.EntityFrameworkCore;
using Project.Domain.Entities;
using Project.Domain.Interfaces;
using Project.Infrastructure.Context;
namespace Project.Infrastructure.Repositories
{
public class RoleRepository : BaseRepository<Role>, IRoleRepository
{
private readonly AppDbContext _context;
public RoleRepository(AppDbContext context) : base(context)
{
_context = context;
}
public async Task<List<Role>> GetRoles(List<Guid> ids)
{
return await _context.Roles.Where(x => ids.Contains(x.Id)).ToListAsync();
}
}
}
User Repository:
using Microsoft.EntityFrameworkCore;
using Project.Domain.Entities;
using Project.Domain.Interfaces;
using Project.Infrastructure.Context;
namespace Project.Infrastructure.Repositories
{
public class UserRepository : BaseRepository<User>, IUserRepository
{
private readonly AppDbContext _context;
public UserRepository(AppDbContext context) : base(context)
{
_context = context;
}
public Task<bool> AnyAsync(string email, CancellationToken cancelationToken)
{
return _context.Users
.AnyAsync(x => x.Email == email, cancelationToken);
}
public Task<User?> GetUserByEmailAsync(string email, CancellationToken cancellationToken)
{
return _context.Users
.Include(x => x.Roles)
.FirstOrDefaultAsync(x => x.Email == email, cancellationToken);
}
public Task<User?> GetUserByRefreshCode(Guid refreshToken, CancellationToken cancellationToken)
{
return _context.Users
.Include(x => x.Roles)
.FirstOrDefaultAsync(x => x.RefreshToken == refreshToken, cancellationToken: cancellationToken);
}
}
}
Unit of Work:
using Project.Domain.Interfaces;
using Project.Infrastructure.Context;
namespace Project.Infrastructure.Repositories
{
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _context;
public UnitOfWork(AppDbContext context)
{
_context = context;
}
public async Task Commit(CancellationToken cancellationToken)
{
await _context.SaveChangesAsync();
}
}
}
Now move on to the folder Entities Configuration
Role Configuration:
Here is important to highlight the insert the roles "Admin" and "User", this inserts will be executed in momennrt to run migrations in database.
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Project.Domain.Entities;
namespace Project.Infrastructure.EntitiesConfiguration
{
public class RoleConfiguration : IEntityTypeConfiguration<Role>
{
public void Configure(EntityTypeBuilder<Role> builder)
{
builder.HasKey(x => x.Id);
builder.Property(x => x.Name)
.HasColumnName("Name")
.HasColumnType("NVARCHAR")
.HasMaxLength(50)
.IsRequired(true);
builder.HasData(new Role("ADMIN"), new Role("USER"));
}
}
}
User Configuration:
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Project.Domain.Entities;
namespace Project.Infrastructure.EntitiesConfiguration
{
public class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.HasKey(x => x.Id);
builder.Property(x => x.Email)
.HasColumnName("Name")
.HasColumnType("NVARCHAR")
.HasMaxLength(100)
.IsRequired();
builder.Property(x => x.Password)
.HasColumnName("Password")
.IsRequired();
builder.Property(x => x.RefreshToken)
.HasColumnName("RefreshToken");
builder
.HasMany(x => x.Roles)
.WithMany(x => x.Users)
.UsingEntity<Dictionary<string, object>>(
"UserRole",
role => role
.HasOne<Role>()
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade),
user => user
.HasOne<User>()
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade));
}
}
}
Now let's configure the context of the database
App Db Context:
using Microsoft.EntityFrameworkCore;
using Project.Domain.Entities;
namespace Project.Infrastructure.Context
{
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(GetType().Assembly);
}
public DbSet<User> Users { get; set; }
public DbSet<Role> Roles { get; set; }
}
}
For last we need to add some configurations, then create a file with name Service Extensions inside in the layer.
This files is reponsible for:
Registers the AppDbContext with EF Core for database interactions.
Defines repository implementations for data access using the context.
Uses AddScoped for services that should exist within a single request scope.
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Project.Domain.Interfaces;
using Project.Infrastructure.Context;
using Project.Infrastructure.Repositories;
namespace Project.Infrastructure
{
public static class ServiceExtensions
{
public static void ConfigurePersistenceApp(this IServiceCollection services, IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString("SqlServer");
IServiceCollection serviceCollection = services.AddDbContext<AppDbContext>(opt => opt.UseSqlServer(connectionString, x => x.MigrationsAssembly("Project.Infrastructure")), ServiceLifetime.Scoped);
services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IRoleRepository, RoleRepository>();
}
}
}
Presentation Layer
Inside in this layer click in appsettings.json and paste the following code that contains database string connection and some secrets for JWT service.
{
"ConnectionStrings": {
"SqlServer": "Server=sqlserver,1433;Database=project;User ID=sa;Password=0tI52#fa@vkz;Trusted_Connection=False;TrustServerCertificate=True;"
},
"Secrets": {
"ApiKey": "da088158b6bebabd07de25d02ec2dd8e",
"JwtPrivateKey": "ce8a25b97b7ad21fdb76c70f163f1e43",
"PasswordSaltKey": "af07d7ea0910e49903c69ede15d987a7"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
Remember that environment variables should not be disposable in public access, if you need to protect this, use dotnet-secrets to store your variables.
The create a folter with name Extensions:
CORS configuration:
For purpose of this tutorial i chose to set CORS acessible to any client, but you need to specify the clients that are allowed to request your api.
namespace Project.WebApi.Extensions
{
public static class CorsPolicyExtensions
{
public static void ConfigureCorsPolicy(this IServiceCollection services)
{
services.AddCors(opt =>
{
opt.AddDefaultPolicy(builder => builder
.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader());
});
}
}
}
Builder Extension:
In method AddConfiguration we are retrieve the enviroment variables from appsettings to use along in the code. The method AddJwtAuthentication enables the JWT authentication.
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Project.Domain.Security;
using System.Text;
namespace Project.WebApi.Extensions
{
public static class BuilderExtension
{
public static void AddConfiguration(this WebApplicationBuilder builder)
{
Configuration.Secrets.ApiKey = builder.Configuration.GetSection("Secrets").GetValue<string>("ApiKey") ?? string.Empty;
Configuration.Secrets.JwtPrivateKey = builder.Configuration.GetSection("Secrets").GetValue<string>("JwtPrivateKey") ?? string.Empty;
Configuration.Secrets.PasswordSaltKey = builder.Configuration.GetSection("Secrets").GetValue<string>("PasswordSaltKey") ?? string.Empty;
}
public static void AddJwtAuthentication(this WebApplicationBuilder builder)
{
builder.Services
.AddAuthentication(x =>
{
x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(x =>
{
x.RequireHttpsMetadata = false;
x.SaveToken = true;
x.TokenValidationParameters = new TokenValidationParameters
{
IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(Configuration.Secrets.JwtPrivateKey)),
ValidateIssuer = false,
ValidateAudience = false
};
});
builder.Services.AddAuthorization();
}
}
}
JwtExtension:
This file provides a way to generate JWT tokens to our Web API and the JWT token is valid for 2 hours, then the client need to request a new token with refresh token.
using Microsoft.IdentityModel.Tokens;
using Project.Application.DTOs;
using Project.Domain.Security;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace Project.WebApi.Extensions
{
public static class JwtExtension
{
public static string Generate(UserResponseDTO data)
{
var handler = new JwtSecurityTokenHandler();
var key = Encoding.ASCII.GetBytes(Configuration.Secrets.JwtPrivateKey);
var credentials = new SigningCredentials(
new SymmetricSecurityKey(key),
SecurityAlgorithms.HmacSha256Signature);
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = GenerateClaims(data),
Expires = DateTime.UtcNow.AddHours(2),
SigningCredentials = credentials,
};
var token = handler.CreateToken(tokenDescriptor);
return handler.WriteToken(token);
}
private static ClaimsIdentity GenerateClaims(UserResponseDTO user)
{
var ci = new ClaimsIdentity();
ci.AddClaim(new Claim("Id", user.Id.ToString()));
ci.AddClaim(new Claim(ClaimTypes.Name, user.Email));
foreach (var role in user.Roles)
ci.AddClaim(new Claim(ClaimTypes.Role, role.Name));
return ci;
}
}
}
Move on to the controllers:
Auth Controller:
This controller is responsible to create, authentication and refresh the token requests.
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Project.Application.UseCases.Authentication;
using Project.Application.UseCases.Create;
using Project.Application.UseCases.RefreshToken;
using Project.WebApi.Extensions;
namespace Project.WebApi.Controllers
{
[ApiController]
[Route("auth")]
public class AuthController : ControllerBase
{
private readonly IMediator _mediator;
public AuthController(IMediator mediadtor)
{
_mediator = mediadtor;
}
[HttpPost("authentication")]
public async Task<IActionResult> Authentication([FromBody] AuthenticationRequest request, CancellationToken cancellation)
{
var response = await _mediator.Send(request, cancellation);
if (response is null) return BadRequest();
response.Data.Token = JwtExtension.Generate(response.Data);
return Ok(response);
}
[HttpPost("refresh")]
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellation)
{
var response = await _mediator.Send(request, cancellation);
if (response is null) return BadRequest();
response.Data.Token = JwtExtension.Generate(response.Data);
return Ok(response);
}
[HttpPost("register")]
public async Task<IActionResult> Create([FromBody] CreateUserRequest request, CancellationToken cancellation)
{
var response = await _mediator.Send(request, cancellation);
if (response is null) return BadRequest();
return Ok(response);
}
}
}
And the Project Controller has just two endpoints to user and admin which is needed permission to access.
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Project.WebApi.Controllers
{
[ApiController]
[Route("project")]
public class ProjectController : ControllerBase
{
[HttpGet("user")]
[Authorize(Roles = "USER")]
public ActionResult User()
{
return Ok("Hello User");
}
[HttpGet("admin")]
[Authorize(Roles = "ADMIN")]
public ActionResult Admin()
{
return Ok("Hello Admin");
}
}
}
Now let's configure the Program.cs in this layer.
This file is responsible to configurations for building
ASP.NET Web API with features like database access, JWT authentication, Swagger documentation, CORS, Migrations, Application Services, etc.
using Microsoft.OpenApi.Models;
using Project.Application.Configuration;
using Project.Infrastructure;
using Project.Infrastructure.Context;
using Project.WebApi.Extensions;
using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
// Insert this to migrations run
builder.Services.ConfigurePersistenceApp(builder.Configuration);
// Mediator
builder.Services.ConfigureApplicationApp();
builder.Services.AddMvc()
.AddJsonOptions(x => x.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles);
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
// Request JWT token in Swagger
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo { Title = "Demo API", Version = "v1" });
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
In = ParameterLocation.Header,
Description = "Please enter a valid token",
Name = "Authorization",
Type = SecuritySchemeType.Http,
BearerFormat = "JWT",
Scheme = "Bearer"
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type=ReferenceType.SecurityScheme,
Id="Bearer"
}
},
new string[]{}
}
});
options.CustomSchemaIds(type => type.ToString());
});
// Add CORS extension
builder.Services.ConfigureCorsPolicy();
// Add extensions (Mine)
builder.AddConfiguration();
builder.AddJwtAuthentication();
var app = builder.Build();
CreateDatabase(app);
// Allow CORS POLICY
app.UseCors();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
static void CreateDatabase(WebApplication app)
{
var serviceScope = app.Services.CreateScope();
var dataContext = serviceScope.ServiceProvider.GetService<AppDbContext>();
dataContext?.Database.EnsureCreated();
}
Inside the Dockerfile in Presentation layer, paste this following configurations:
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER app
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Project.WebApi/Project.WebApi.csproj", "Project.WebApi/"]
COPY ["Project.Domain/Project.Domain.csproj", "Project.Domain/"]
COPY ["Project.Application/Project.Application.csproj", "Project.Application/"]
COPY ["Project.Infrastructure/Project.Infrastructure.csproj", "Infrastructure.WebApi/"]
RUN dotnet restore "./Project.WebApi/Project.WebApi.csproj"
COPY . .
WORKDIR "/src/Project.WebApi"
RUN dotnet build "./Project.WebApi.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM buildbase as migrations
RUN dotnet tool install --version 8.0.2 --global dotnet-ef
ENV PATH="$PATH:/root/.dotnet/tools"
#ENTRYPOINT dotnet ef database update -s src/SlaveOneBack.WebAPI
ENTRYPOINT dotnet-ef database update --project src/Project.Infrastructure/ --startup-project src/Project.WebApi
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./Project.WebApi.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Project.WebApi.dll"]
This configuration guarantees copying all files from this project to the docker environment and running the update database with migrations and API execution.
Fifth step: Execute Migrations
Let's execute the migrations, open the terminal and access the Project.Infraestructura layer and execute the following command:
dotnet ef migrations add -s ..\Project.Presentation v1
If migration is successful you should see in the
Checking whether a folder with the name Migrations was created in the Project.Infrastructure layer.
Sixth step: Docker Compose
In Presentation Layer, click with with the right button in mouse over Project.WebApi, Add and select Container Orchestrator Container.
In the popup, select docker and then linux.
You should see that docker is be executed and a new folder called docker-compose will be generated.
Open this folder and click in file docker-compose.yml, then paste this configuration.
version: '3.4'
services:
project.webapi:
image: ${DOCKER_REGISTRY-}projectwebapi
build:
context: .
dockerfile: Project.WebApi/Dockerfile
ports:
- "3001:8080"
- "3000:8081"
migrations:
container_name: service-migrations
image: service-migrations
build:
context: .
dockerfile: Project.WebApi/Dockerfile
target: migrations
depends_on:
- sqlserver
sqlserver:
image: mcr.microsoft.com/mssql/server
container_name: sqlserver
ports:
- "1433:1433"
environment:
ACCEPT_EULA: "Y"
MSSQL_SA_PASSWORD: "0tI52#fa@vkz"
restart: unless-stopped
This configurations will set the Web Api, Migration and SQL Server database.
With mouse right-click over docker-compose then set as startup project.
At top of the Visual Studio you should see:
Now is just click in this button and let's test our api.
In docker desktop you can check the container:
Seventh step: Testing
Ao executar o projeto a interface do Swagger deve abrir, ao tentar requisitar qualquer rota do endpoint project receberemos um erro com código 401 de não autorizado.
Creating a user:
To access the roles we must open the database, for this I will use Azure Data Studio with the database credentials that we defined in docker-compose.yml.
After connecting an interface to the bank you can follow the following path:
Select Roles and you should see:
Let's register a user
{
"email": "user@email.com",
"password": "#user123",
"roleIds": [
"db2b5b99-396c-4a53-b3f9-3522995befdd"
]
}
After registering, we must authenticate with email and password using the auth/authentication router.
The return will be this:
Copy the token and add it to swagger's authorize
And click on authorize, with this Swagger will pass the token on requests.
Once the user is logged in, we will request your project/user route to access the content.
If we try to request from the project/admin route, we will get the following response.
the auth/refresh route brings a new jwt token valid for another 2 hours. By passing the refresh token in the request, the login will be prolonged
Now let's create a user of type admin and user:
{
"email": "admin@email.com",
"password": "#admin",
"roleIds": [
"db2b5b99-396c-4a53-b3f9-3522995befdd",
"1b81a69d-c95f-43bd-b1e2-a51b73b48198"
]
}
When logging in with this new user, we can notice that he has both existing permissions in the system:
Changing the token in swagger's Authorize we can access both routes.
Conclusion
Implementing user authentication and authorization in a system is always a major challenge in the IT world. In this tutorial, in addition to implementing this API together, we briefly cover the benefits of Clean Architecture, Mediator and Unit of Work for a robust, secure and easily scalable system foundation.
Subscribe to my newsletter
Read articles from Saruf Ratul directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Saruf Ratul
Saruf Ratul
A Senior Software Engineer with five years of professional experience, specializing in fullstack development, Angular, Asp.Net, MySQL, Oracle, and AWS. A proven track record of managing large scale software engineering projects to support cloud deployments and integrations.