ASP.NET 8 - Authentication and Authorization in 7 steps

Saruf RatulSaruf Ratul
22 min read

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.

Image description

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

Image description

In the end you should have this folder structure:

Image description


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:

Image description

Start at the Project.Application layer, add reference to the Project.Domain project.

Image description

In the Project.Infrastructure layer add the following references:

Image description

Finally, in the last section Project.Presentation add the following references:

Image description


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

Image description

Project.Application

  1. AutoMapper: Simplifies mapping between different object structures, often used for converting domain models to DTOs (Data Transfer Objects) for API responses.

  2. BCrypt.Net-Next: Provides a secure password hashing library for secure user password storage and verification.

  3. FluentValidation.DependencyInjection: Integrates FluentValidation, a popular library for building validation rules for your application models.

  4. MediatR: Facilitates communication within your application using the Mediator pattern, promoting loose coupling and testability.

Project.WebApi

  1. Microsoft.AspNet.WebApi.Cors: Enables Cross-Origin Resource Sharing (CORS) for your Web API, allowing requests from different origins (domains, ports, protocols).

  2. Microsoft.AspNetCore.Authentication.JwtBearer: Enables implementing JWT (JSON Web Token) based authentication for your Web API, ensuring secure access control.

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

  4. Microsoft.EntityFrameworkCore.Design: Assists with database migrations and design-time tools.

  5. Microsoft.EntityFrameworkCore.Tools: Provides command-line tools for interacting with your database schema and migrations.

  6. Microsoft.VisualStudio.Azure.Containers.Tools.Target (Optional): Might be required for deploying your Web API to Azure containers.

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

  1. Microsoft.EntityFrameworkCore

  2. Microsoft.EntityFrameworkCore.Design

  3. Microsoft.EntityFrameworkCore.Tools

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

Image description

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

Image description

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.

Image description

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:

Image description

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

Image description

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.

Image description

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:

Image description

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.

Image description

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:

Image description

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.

Image description

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

Image description

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

Image description

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.

Image description

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:

Image description

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:

Image description

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

Image description

Checking whether a folder with the name Migrations was created in the Project.Infrastructure layer.

Image description


Sixth step: Docker Compose

In Presentation Layer, click with with the right button in mouse over Project.WebApi, Add and select Container Orchestrator Container.

Image description

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.

Image description

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.

Image description

At top of the Visual Studio you should see:

Image description

Now is just click in this button and let's test our api.

In docker desktop you can check the container:

Image description


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.

Image description

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:

Image description

Select Roles and you should see:

Image description

Let's register a user

{
  "email": "user@email.com",
  "password": "#user123",
  "roleIds": [
    "db2b5b99-396c-4a53-b3f9-3522995befdd"
  ]
}

Image description

After registering, we must authenticate with email and password using the auth/authentication router.

The return will be this:

Image description

Copy the token and add it to swagger's authorize

Image description

And click on authorize, with this Swagger will pass the token on requests.

Image description

Once the user is logged in, we will request your project/user route to access the content.

Image description

If we try to request from the project/admin route, we will get the following response.

Image description

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

Image description

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:

Image description

Changing the token in swagger's Authorize we can access both routes.

Image description

Image description

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.

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