程式碼

https://github.com/intervalrain/webapi_ca/

正文

建置 Solution

  • 首先先參考 Clean Architecture 最經典的同心圓,來確定我們需要將我們的解決方案做哪些分層:

    • 我將使用 Restful API 做為我們 I/O (Presentation Layer)
    • 並且我需要配備身份驗證的機制 (Presentation Layer)
    • 我使用 PostgresDB 作為我的 (Infrastructure Layer)
    • 我的核心商業邏輯 (Application / Domain Layer)
  • 創建專案

dotnet new sln -o Mysln
  • 進入專案所在的資料夾
cd Mysln
  • 根據預先的分層建立專案資料夾,並且使用 dotnet 指令建立相對應的專案類型。
    • Api –> WebAPI
    • Infrastructure –> classlib
    • Contracts –> classlib
    • Application –> classlib
    • Domain –> classlib
dotnet new webapi -o Mysln.Api
dotnet new classlib -o Mysln.Contracts
dotnet new classlib -o Mysln.Infrastructure
dotnet new classlib -o Mysln.Application
dotnet new classlib -o Mysln.Domain
  • 接著我們需要把產生的專案資料夾,加入到我們的 Solution。
dotnet sln add Mysln.Api
dotnet sln add Mysln.Application
dotnet sln add Mysln.Contracts
dotnet sln add Mysln.Domain
dotnet sln add Mysln.Infrastructure
  • 接下來按照 Clean Architecture 的依賴原則來設定 dependency,依我的專案來說依賴方向如下。
    graph TD; Api-->Contracts; Api-->Application; Infrastructure-->Application Application-->Domain Api-.->Infrastructure
dotnet add Mysln.Api reference Mysln.Contracts Mysln.Application
dotnet add Mysln.Infrastructure reference Mysln.Application
dotnet add Mysln.Application reference Mysln.Domain
dotnet add Mysln.Api reference Mysln.Infrastructure
  • 至此,已經完成了基本的 hierarchy 建置,接下來要為 Restful Client 做準備。

Login Authentication

  • 作為驗證的需要,我們需要以下三種驗證檔案,包含兩個 Request 與一個 Response
public record RegisterRequest(
    string FirstName,
    string LastName,
    string Email,
    string Password
);

public record LoginRequest(
    string Email,
    string Password
);

public record AuthenticationResponse(
    Guid Id,
    string FirstName,
    string LastName,
    string Email,
    string token
);
  • 到 Controller 去設置註冊與登入的兩個路由,並且將之後的服務介面預先注入到其中。
[ApiController]
[Route("auth")]
public class AuthenticationController : ControllerBase
{
	[HttpPost("register")]
	public IActionResult Register(RegisterRequest request)
	{
		return Ok(request);
	}

	[HttpPost("login")]
	public IActionResult Login(LoginRequest request)
	{
		return Ok(request);
	}
}
  • 接著我們創建 Application 中的服務,注意到因為 Application 不依賴於 Contracts,故我們這邊需要創建自己的 DataModel
public record AuthenticationResult
(
    Guid Id,
    string FirstName,
    string LastName,
    string Email,
    string Token
);
  • 接著我們定義出 Application 的 Service。
public interface IAuthenticationService
{
	AuthenticationResult Register(string firstName, string lastName, string email, string password);
    AuthenticationResult Login(string email, string password);
}
  • 定義好我們的 service interface 之後,就可以到 Presentation 中將我們的 service 注入到 presentation 之中。
[ApiController]
[Route("auth")]
public class AuthenticationController : ControllerBase
{
    private readonly IAuthenticationService _authenticationService;

    public AuthenticationController(IAuthenticationService authenticationService)
    {
        _authenticationService = authenticationService;
    }

    [HttpPost("register")]
	public IActionResult Register(RegisterRequest request)
	{
		var authResult = _authenticationService.Register(
			request.FirstName,
			request.LastName,
			request.Email,
			request.Password);
		var response = new AuthenticationResponse(
            authResult.Id,
            authResult.FirstName,
            authResult.LastName,
            authResult.Email,
            authResult.Token);
		return Ok(response);
	}

	[HttpPost("login")]
	public IActionResult Login(LoginRequest request)
	{
        var authResult = _authenticationService.Login(
            request.Email,
            request.Password);
        var response = new AuthenticationResponse(
            authResult.Id,
            authResult.FirstName,
            authResult.LastName,
            authResult.Email,
            authResult.Token);
        return Ok(response);
    }
}
  • 我們已經定義好我們的 service 後,便可以到 presentation 的 Program(或是其它入口點,如 Startup.cs 或 MauiProgram.cs),做 service 的依賴注入。
using BuberDinner.Application.Services.Authentication;

var builder = WebApplication.CreateBuilder(args);
{
    builder.Services.AddScoped<IAuthenticationService, AuthenticationService>();
    builder.Services.AddControllers();
    builder.Services.AddEndpointsApiExplorer();
    builder.Services.AddSwaggerGen();
}

var app = builder.Build();
{
    app.UseSwagger();
    app.UseSwaggerUI();
    app.UseHttpsRedirection();
    app.MapControllers();
    app.Run();
}
  • 最後,我們先實作一個暫時的 Service,來確認 Api 是可以作業的。
public class AuthenticationService : IAuthenticationService
{
    public AuthenticationResult Register(string firstName, string lastName, string email, string password)
    {
        return new AuthenticationResult(
            Guid.NewGuid(),
            firstName,
            lastName,
            email,
            "token"
            );
    }

    public AuthenticationResult Login(string email, string password)
    {
        return new AuthenticationResult(
            Guid.NewGuid(),
            "firstName",
            "lastName",
            email,
            "token"
            );
    }
}
  • 執行 dotnet run --project .\Mysln.Api\
  • 在 Swagger 中測試我們實作的 registerlogin API,如果正常工作,會回傳 StatusCode: 200。

Dependency Injection

  • 我們想要每一層都可以自己管理自己的注入,此時我們需要引入 Microsoft.Extensions.DependencyInjection
  • 接下來實作 Application 的 DependencyInjection。
public static class DependencyInjection
{
    public static IServiceCollection AddApllication(this IServiceCollection services)
    {
        services.AddScope<IAuthenticationService, AuthenticationService>();
        return services;
    }
}
  • 接下來實作 Infrastructure 的 DependencyInjection。(暫時還沒有注入 repository)
public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(this IServiceCollection services)
    {
        // 未來要注入 repositories
        return services;
    }
}
  • 接下來我們可以改寫 Program.cs
using BuberDinner.Application;
using BuberDinner.Infrastructure;

var builder = WebApplication.CreateBuilder(args);
{
    builder.Services
        .AddApplication()
        .AddInfrastructure();
    builder.Services.AddControllers();
    builder.Services.AddEndpointsApiExplorer();
    builder.Services.AddSwaggerGen();
}

var app = builder.Build();
{
    app.UseSwagger();
    app.UseSwaggerUI();
    app.UseHttpsRedirection();
    app.MapControllers();
    app.Run();
}

實作 JWT Token Generator

  • 首先先在 Application Layer 創建一個 interface 來做依賴反轉
public interface IJwtTokenGenerator
{
    string GenerateToken(Guid userId, string firstName, string lastName);
}
  • 接著我們到 Infrastructure Layer 來實作我們的 JwtTokenGenerator。
  • 首先我們需要 System.IdentityModel.Tokens.Jwt 這個 Package。
  • 接著我們實作 JwtTokenGenerator
public class JwtTokenGenerator : IJwtTokenGenerator
{
    public string GenerateToken(Guid userId, string firstName, string lastName)
    {
        var signingCredentials = new SigningCredentials(
            new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes("super-secret-key")),
            SecurityAlgorithms.HmacSha256);

        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
            new Claim(JwtRegisteredClaimNames.GivenName, firstName),
            new Claim(JwtRegisteredClaimNames.FamilyName, lastName),
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
        };

        var securityToken = new JwtSecurityToken(
            issuer: "Mysln",
            expires: DateTime.Now.AddDays(1),
            claims: claims,
            signingCredentials: signingCredentials);

        return new JwtSecurityTokenHandler().WriteToken(securityToken);
    }
}
  • 接著我們將之注入到服務中,即大功告成了。
public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(this IServiceCollection services)
    {
        services.AddSingleton<IJwtTokenGenerator, JwtTokenGenerator>();
        return services;
    }
}

使用 Options Pattern 注入 JWT Settings

  • 接下來我們要使用 Options Pattern 將 JWT Settings 注入到 JwtTokenGenerator 中。
  • 首先我們先到 Mysln.Apiappsettings.json 中將 options 設置完成。
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
    "JwtSettings": {
    "Secret": "super-secret-key",
    "ExpiryMinutes": 60,
    "Issuer": "Mysln",
    "Audience:": "Mysln"
  }
}
  • 由於我們要使用 Options Pattern,我們需要改寫我們的 Program.cs,並且將 ConfigurationManager 注入到 Infrastructure 的 DependencyInjection。
  • 為此我們需要引入套件 Microsoft.Extensions.ConfigurationMicrosoft.Extensions.Options.ConfigurationExtensions
  • 並且我們需要創建一個 Model。
public class JwtSettings
{
    public const string SectionName = "JwtSettings";
	public string Secret { get; init; } = null!;
	public int ExpiryMinutes { get; init; }
	public string Issuer { get; init; } = null!;
	public string Audience { get; init; } = null!;
}
  • Program.cs 需改寫成:
builder.Services
        .AddApplication()
        .AddInfrastructure(builder.Configuration);
  • DependencyInjection 改寫成:
public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(this IServiceCollection services, ConfigurationManager configuration)
    {
        services.Configure<JwtSettings>(configuration.GetSection(JwtSettings.SectionName));
        services.AddSingleton<IJwtTokenGenerator, JwtTokenGenerator>();
        services.AddSingleton<IDateTimeProvider, DateTimeProvider>();
        return services;
    }
}
  • 接下來,我們可以把 JwtTokenGenerator 改寫成:
public class JwtTokenGenerator : IJwtTokenGenerator
{
    private readonly JwtSettings _jwtSettings;
    private readonly IDateTimeProvider _dateTimeProvider;

    public JwtTokenGenerator(IDateTimeProvider dateTimeProvider, IOptions<JwtSettings> jwtOptions)
    {
        _dateTimeProvider = dateTimeProvider;
        _jwtSettings = jwtOptions.Value;
    }

    public string GenerateToken(Guid userId, string firstName, string lastName)
    {
        var signingCredentials = new SigningCredentials(
            new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(_jwtSettings.Secret)),
            SecurityAlgorithms.HmacSha256);

        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
            new Claim(JwtRegisteredClaimNames.GivenName, firstName),
            new Claim(JwtRegisteredClaimNames.FamilyName, lastName),
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
        };

        var securityToken = new JwtSecurityToken(
            issuer: _jwtSettings.Issuer,
            audience: _jwtSettings.Audience,
            expires: _dateTimeProvider.UtcNow.AddMinutes(_jwtSettings.ExpiryMinutes),
            claims: claims,
            signingCredentials: signingCredentials);

        return new JwtSecurityTokenHandler().WriteToken(securityToken);
    }
}
  • 以上就大功告成了。

使用 dotnet user-secrets 指令

  • 如果不想要將 Options 中的 secret 儲存在程式(appsettings.json)裡面,可以利用 dotnet user-secrets 將 secret 儲存於環境變數裡面。
  • 透過執行以下的指令來初始化專案的 UserSecretsId
dotnet user-secrets init --project Mysln.Api
  • 接著將 UserSecretsId 綁定到我們專案的 JwtSettings:Secret
dotnet user-secrets set --project Mysln.Api "JwtSettings:Secret"
  • 日後可以經由以下指令查詢。
dotnet user-secrets list --project Mysln.Api

Domain Model

  • 先建立一個簡單的 Domain Model(Entity)
public class User
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public string FirstName { get; set; } = null!;
    public string LastName { get; set; } = null!;
    public string Email { get; set; } = null!;
    public string Password { get; set; } = null!;
}

Repository Pattern

  • 在 Application Layer 建立 IRepository
public interface IUserRepository
{
    User? GetUserByEmail(string email);
    void Add(User user);

}
  • 將 IRepository 注入 Application 的 Service
  • 並用查改存推改寫 Service
public class AuthenticationService : IAuthenticationService
{
    private readonly IJwtTokenGenerator _jwtTokenGenerator;
    private readonly IUserRepository _userRepository;
    public AuthenticationService(IJwtTokenGenerator jwtTokenGenerator, IUserRepository userRepository)
    {
        _jwtTokenGenerator = jwtTokenGenerator;
        _userRepository = userRepository;
    }

    public AuthenticationResult Register(string firstName, string lastName, string email, string password)
    {
        // 查
        if (_userRepository.GetUserByEmail(email) is not null)
        {
            throw new Exception("User with given email already exists.");
        }
        // 改
        var user = new User
        {
            FirstName = firstName,
            LastName = lastName,
            Email = email,
            Password = password
        };
        // 存
        _userRepository.Add(user);
        // 推
        var token = _jwtTokenGenerator.GenerateToken(user.Id, firstName, lastName);
        return new AuthenticationResult(user.Id, firstName, lastName, email, token);
    }

    public AuthenticationResult Login(string email, string password)
    {
        // 查
        if (_userRepository.GetUserByEmail(email) is not User user)
        {
            throw new Exception("User with given email does not exist.");
        }
        if (user.Password != password)
        {
            throw new Exception("Invalid password.");
        }
        // 改
        var token = _jwtTokenGenerator.GenerateToken(user.Id, user.FirstName, user.LastName);
        
        return new AuthenticationResult(user.Id, user.FirstName, user.LastName, email, token);
    }
}
  • 接著我們在 Infrastructure Layer 實作我們的 repository,我們暫時先不接資料庫,所以先做一個 InMemory 版本的 repository 來做測試。
public class UserRepository : IUserRepository
{
    private readonly List<User> _users = new();

    public void Add(User user)
    {
        _users.Add(user);
    }

    public User? GetUserByEmail(string email)
    {
        return _users.SingleOrDefault(u => u.Email.Equals(email));
    }
}
  • 實作完需要透過 DependencyInjection 注入到我們的 Service Container 內。
public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(this IServiceCollection services, ConfigurationManager configuration)
    {
        services.Configure<JwtSettings>(configuration.GetSection(JwtSettings.SectionName));
        services.AddSingleton<IJwtTokenGenerator, JwtTokenGenerator>();
        services.AddSingleton<IDateTimeProvider, DateTimeProvider>();
        services.AddSingleton<IUserRepository, UserRepository>();
        return services;
    }
}
  • 至此,我們已經完成了一個簡單的身份認證的 API。