use. NET 6 developing TodoList application (24) -- Realizing Identity function based on JWT

Posted by jchemie on Sun, 09 Jan 2022 16:48:57 +0100

Series navigation and source code

demand

Yes NET Web API development also has a very important requirement about identity authentication and authorization. This topic is very large, so this article does not intend to introduce the whole topic, but only use NET framework to implement authentication and authorization middleware based on JWT Authentication and authorization functions. Some basic concepts about this topic will not take much time to explain. We still focus on implementation.

target

Add identity authentication and authorization functions for TodoList project.

Principles and ideas

In order to realize identity Authentication and Authorization, we need to use NET comes with Authentication and Authorization components. In this article, we will not cover the content related to Identity Server, which is another major topic. Due to the change of license, Identity Server 4 will no longer be able to be applied to commercial applications with profits exceeding a certain limit for free. See the official website for details IdentityServer . Microsoft is also gradually integrating the related functions of the widely used identity server into the framework: ASP.NET Core 6 and Authentication Servers , which will not be covered in this article.

realization

Introducing Identity components

We added the following Nuget packages to the Infrastructure project:

<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.1" />

And create a new Identity directory to store specific functions related to authentication and authorization. First, add the user class ApplicationUser:

  • ApplicationUser.cs
using Microsoft.AspNetCore.Identity;

namespace TodoList.Infrastructure.Identity;

public class ApplicationUser : IdentityUser
{
    // No customized implementation, only native functions are used
}

Since we want to use the existing SQL Server database to store authentication related information, we also need to modify the DbContext:

  • TodoListDbContext.cs
public class TodoListDbContext : IdentityDbContext<ApplicationUser>
{
    private readonly IDomainEventService _domainEventService;

    public TodoListDbContext(
        DbContextOptions<TodoListDbContext> options,
        IDomainEventService domainEventService) : base(options)
    {
        _domainEventService = domainEventService;
    }
    // Omit other
}

For the convenience of later demonstration, we can also add built-in user data in the logic of adding seed data:

  • TodoListDbContextSeed.cs
// Omit other
public static async Task SeedDefaultUserAsync(UserManager<ApplicationUser> userManager, RoleManager<IdentityRole> roleManager)
{
    var administratorRole = new IdentityRole("Administrator");
    if (roleManager.Roles.All(r => r.Name != administratorRole.Name))
    {
        await roleManager.CreateAsync(administratorRole);
    }
    var administrator = new ApplicationUser { UserName = "admin@localhost", Email = "admin@localhost" };
    if (userManager.Users.All(u => u.UserName != administrator.UserName))
    {
        // The user name created is admin@localhost The password is admin123 and the role is Administrator
        await userManager.CreateAsync(administrator, "admin123");
        await userManager.AddToRolesAsync(administrator, new[] { administratorRole.Name });
    }
}

And modify it in ApplicationStartupExtensions:

  • ApplicationStartupExtensions.cs
public static class ApplicationStartupExtensions
{
    public static async Task MigrateDatabase(this WebApplication app)
    {
        using var scope = app.Services.CreateScope();
        var services = scope.ServiceProvider;

        try
        {
            var context = services.GetRequiredService<TodoListDbContext>();
            context.Database.Migrate();

            var userManager = services.GetRequiredService<UserManager<ApplicationUser>>();
            var roleManager = services.GetRequiredService<RoleManager<IdentityRole>>();
            // Generate built-in users
            await TodoListDbContextSeed.SeedDefaultUserAsync(userManager, roleManager);
            // Omit other
        }
        catch (Exception ex)
        {
            throw new Exception($"An error occurred migrating the DB: {ex.Message}");
        }
    }
}

Finally, we need to modify the DependencyInjection section to introduce identity authentication and authorization services:

  • DependencyInjection.cs
// Omit other
// Configure authentication service
// Configure authentication service
services
    .AddDefaultIdentity<ApplicationUser>(o =>
    {
        o.Password.RequireDigit = true;
        o.Password.RequiredLength = 6;
        o.Password.RequireLowercase = true;
        o.Password.RequireUppercase = false;
        o.Password.RequireNonAlphanumeric = false;
        o.User.RequireUniqueEmail = true;
    })
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<TodoListDbContext>()
    .AddDefaultTokenProviders();

Add authentication service

Add the authentication service interface IIdentityService in Applicaiton/Common/Interfaces:

  • IIdentityService.cs
namespace TodoList.Application.Common.Interfaces;

public interface IIdentityService
{
    // For demonstration purposes, only the following methods are defined, and the actual authentication service will provide more methods
    Task<string> CreateUserAsync(string userName, string password);
    Task<bool> ValidateUserAsync(UserForAuthentication userForAuthentication);
    Task<string> CreateTokenAsync();
}

Then implement IIdentityService interface in Infrastructure/Identity:

  • IdentityService.cs
namespace TodoList.Infrastructure.Identity;

public class IdentityService : IIdentityService
{
    private readonly ILogger<IdentityService> _logger;
    private readonly IConfiguration _configuration;
    private readonly UserManager<ApplicationUser> _userManager;
    private ApplicationUser? User;

    public IdentityService(
        ILogger<IdentityService> logger,
        IConfiguration configuration,
        UserManager<ApplicationUser> userManager)
    {
        _logger = logger;
        _configuration = configuration;
        _userManager = userManager;
    }

    public async Task<string> CreateUserAsync(string userName, string password)
    {
        var user = new ApplicationUser
        {
            UserName = userName,
            Email = userName
        };

        await _userManager.CreateAsync(user, password);

        return user.Id;
    }

    public async Task<bool> ValidateUserAsync(UserForAuthentication userForAuthentication)
    {
        User = await _userManager.FindByNameAsync(userForAuthentication.UserName);

        var result = User != null && await _userManager.CheckPasswordAsync(User, userForAuthentication.Password);
        if (!result)
        {
            _logger.LogWarning($"{nameof(ValidateUserAsync)}: Authentication failed. Wrong username or password.");
        }

        return result;
    }

    public async Task<string> CreateTokenAsync()
    {
        // Not yet to implement this method
        throw new NotImplementedException();
    }
}

And perform dependency injection in DependencyInjection:

  • DependencyInjection.cs
// Omit other
// Injection authentication service
services.AddTransient<IIdentityService, IdentityService>();

Now let's review the completed part: we configured the application to use the built-in Identity service and make it use the existing database storage; We generated seed user data; It also realizes the function of authentication service.

Before proceeding to the next step, we need to migrate the database to make the data tables related to authentication effective:

$ dotnet ef database update -p src/TodoList.Infrastructure/TodoList.Infrastructure.csproj -s src/TodoList.Api/TodoList.Api.csproj
Build started...
Build succeeded.
[14:04:02 INF] Entity Framework Core 6.0.1 initialized 'TodoListDbContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer:6.0.1' with options: MigrationsAssembly=TodoList.Infrastructure, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
# Create related data table
[14:04:03 INF] Executed DbCommand (43ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
CREATE TABLE [AspNetRoles] (
    [Id] nvarchar(450) NOT NULL,
    [Name] nvarchar(256) NULL,
    [NormalizedName] nvarchar(256) NULL,
    [ConcurrencyStamp] nvarchar(max) NULL,
    CONSTRAINT [PK_AspNetRoles] PRIMARY KEY ([Id])
);
# Omit the middle part
[14:04:03 INF] Executed DbCommand (18ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
VALUES (N'20220108060343_AddIdentities', N'6.0.1');
Done.

Run the Api program, and then go to the database to confirm the generated data table:

Seed user:

And roles:

So far, I have integrated the Identity framework. Next, we begin to implement JWT based authentication and API authorization functions:

Use JWT to authenticate and define authorization methods

Add JWT authentication configuration in DependencyInjection of Infrastructure project:

  • DependencyInjection.cs
// Omit other
// Add the authentication method as JWT Token authentication
services
    .AddAuthentication(opt =>
    {
        opt.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
        opt.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
    })
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,

            ValidIssuer = configuration.GetSection("JwtSettings")["validIssuer"],
            ValidAudience = configuration.GetSection("JwtSettings")["validAudience"],
            // For the purpose of demonstration, I changed the SECRET value into a hard coded string here. In the actual environment, it is better to obtain it from the environment variable rather than write it in the code
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("SECRET") ?? "TodoListApiSecretKey"))
        };
    });

// Adding authorization Policy is role-based. The Policy name is OnlyAdmin. The Policy requires Administrator role
services.AddAuthorization(options => 
    options.AddPolicy("OnlyAdmin", policy => policy.RequireRole("Administrator")));

Introduce authentication and authorization Middleware

In the Api Program, MapControllers introduces:

  • Program.cs
// Omit other
app.UseAuthentication();
app.UseAuthorization();

Add JWT configuration

  • appsettings.Development.json
"JwtSettings": {
    "validIssuer": "TodoListApi",
    "validAudience": "http://localhost:5050",
    "expires": 5
}

Add authenticated user Model

Add a type for user authentication in Application/Common/Models:

  • UserForAuthentication.cs
using System.ComponentModel.DataAnnotations;

namespace TodoList.Application.Common.Models;

public record UserForAuthentication
{
    [Required(ErrorMessage = "username is required")]
    public string? UserName { get; set; }

    [Required(ErrorMessage = "password is required")]
    public string? Password { get; set; }
}

Implement the CreateToken method of authentication service

In this article, we do not use the integrated IdentityServer component, but the application itself issues tokens, so we need to implement the CreateTokenAsync method:

  • IdentityService.cs
// Omit other
public async Task<string> CreateTokenAsync()
{
    var signingCredentials = GetSigningCredentials();
    var claims = await GetClaims();
    var tokenOptions = GenerateTokenOptions(signingCredentials, claims);
    return new JwtSecurityTokenHandler().WriteToken(tokenOptions);
}

private SigningCredentials GetSigningCredentials()
{
    // For the purpose of demonstration, I changed the SECRET value into a hard coded string here. In the actual environment, it is better to obtain it from the environment variable rather than write it in the code
    var key = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("SECRET") ?? "TodoListApiSecretKey");
    var secret = new SymmetricSecurityKey(key);

    return new SigningCredentials(secret, SecurityAlgorithms.HmacSha256);
}

private async Task<List<Claim>> GetClaims()
{
    // Demonstrates two types of Claims: return user name and Role
    var claims = new List<Claim>
    {
        new(ClaimTypes.Name, User!.UserName)
    };

    var roles = await _userManager.GetRolesAsync(User);
    claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));

    return claims;
}

private JwtSecurityToken GenerateTokenOptions(SigningCredentials signingCredentials, List<Claim> claims)
{
    // Configure JWT options
    var jwtSettings = _configuration.GetSection("JwtSettings");
    var tokenOptions = new JwtSecurityToken
    (
        jwtSettings["validIssuer"],
        jwtSettings["validAudience"],
        claims,
        expires: DateTime.Now.AddMinutes(Convert.ToDouble(jwtSettings["expires"])),
        signingCredentials: signingCredentials
    );
    return tokenOptions;
}

Add authentication interface

Create a new Controller in the Api project to implement the interface for obtaining Token s:

  • AuthenticationController.cs
using Microsoft.AspNetCore.Mvc;
using TodoList.Application.Common.Interfaces;
using TodoList.Application.Common.Models;

namespace TodoList.Api.Controllers;

[ApiController]
public class AuthenticationController : ControllerBase
{
    private readonly IIdentityService _identityService;
    private readonly ILogger<AuthenticationController> _logger;

    public AuthenticationController(IIdentityService identityService, ILogger<AuthenticationController> logger)
    {
        _identityService = identityService;
        _logger = logger;
    }

    [HttpPost("login")]
    public async Task<IActionResult> Authenticate([FromBody] UserForAuthentication userForAuthentication)
    {
        if (!await _identityService.ValidateUserAsync(userForAuthentication))
        {
            return Unauthorized();
        }

        return Ok(new { Token = await _identityService.CreateTokenAsync() });
    }
}

Protect API resources

We are going to use the TodoList interface to demonstrate the authentication and authorization functions, so the added attributes are as follows:

// Omit other
[HttpPost]
// Demonstrates authorization to use Policy
[Authorize(Policy = "OnlyAdmin")]
[ServiceFilter(typeof(LogFilterAttribute))]
public async Task<ApiResponse<Domain.Entities.TodoList>> Create([FromBody] CreateTodoListCommand command)
{
    return ApiResponse<Domain.Entities.TodoList>.Success(await _mediator.Send(command));
}

verification

Verification 1: verify direct access to create TodoList interface

Start the Api project and directly execute the request to create TodoList:

401 Unauthorized results are obtained.

Verification 2: get Token

Interface requesting Token:

You can see that we have got the JWT Token and put it in JWT After analysis, you can see:

Two Claims and other configuration information can be seen in the payload.

Verification 3: create TodoList interface with Token access

Select the Bearer Token verification method, fill in the obtained Token, and request to create a TodoList again:

Validation 4: replace Policy

Modify infrastructure / dependencyinjection cs

// Omit other
// Adding authorization Policy is role-based. The Policy name is OnlyAdmin. The Policy requires Administrator role
services.AddAuthorization(options =>
{
    options.AddPolicy("OnlyAdmin", policy => policy.RequireRole("Administrator"));
    options.AddPolicy("OnlySuper", policy => policy.RequireRole("SuperAdmin"));
});

And modify the authorization Policy for creating TodoList interface:

// Omit other
[Authorize(Policy = "OnlySuper")]

Or use admin@locahost After obtaining the latest Token from the user's user name and password, carry the Token request to create a new TodoList:

403 Forbidden is returned, and we can see from the log:

Tell us that we need a legal Token of a user with the role of SuperAdmin to be authorized.

So so far, we have implemented based on NET's own Identity framework, which issues tokens and completes the functions of authentication and authorization.

A little expansion

About in The subject of authentication and authorization in the. NET Web API project is very large. First, there are many authentication methods. In addition to the JWT Token based authentication methods demonstrated in this article, there are OpenId authentication, Azure Active Directory based authentication, OAuth protocol based authentication, etc; Secondly, there are many authorization methods, including role-based authorization, Claims based authorization, Policy based authorization, and more customized authorization methods. Then there is the implementation of the specific authorization server, which is based on Identity Server 4. Of course, we can use it instead after changing the protocol NET, and there are many ways to configure it.

Because the knowledge points involved in identity server are too complex, this article does not try to cover all of them. Consider a separate series on identity server in the future Net 6 in Web API development.

summary

In this paper, we implement authentication and authorization based on JWT Token. In the next article, let's take a look at why and how to implement the Refresh Token mechanism.

reference material

  1. IdentityServer
  2. ASP.NET Core 6 and Authentication Servers

Topics: webapi