github Source code

本文將記錄如何一步步從無到有,使用 Dotnet Core 7.0 建立 ASP.NET Core Web API,其中將會使用到下列技術:

  • Dotnet CLI
  • Entity Framework 7.0
  • Json Web Token
  • PostgreSQL DB (Docker Version)
  • ASP.NET Core Generator

專案完成後的檔案結構

./專案目錄
├── .config/
│   └── dotnet-tools.json
├── .vscode/
│   ├── launch.js
│   └── tasks.json
├── Controller/
│   ├── AuthenticateController.cs
│   ├── TodoController.cs
│   └── WeatherForecast.cs
├── Data/
│   └── ApiDbContext.cs
├── Migrations/
├── Models/
│   ├── AuthenticateData.cs
│   └── TodoList.cs
├── obj/
├── Properties/
│   └── launchSettings.json
├── .gitignore
├── appsettings.Development.json
├── appsettings.json
├── dotnet7-webapi-jwt.csproj
├── global.json
├── Program.cs
├── README.md
└── WeatherForecast.cs

專案完成後所提供的 API 端點

Methods Urls Actions
POST /api/Authenticate/login 註冊新使用者帳號
POST /api/Authenticate/register 使用者帳號登入
POST /api/Authenticate/register-admin 管理者帳號登入
GET /api/Todos get all Todos
POST /api/Todos add New Todo
GET /api/Todos/:id get Todo by id
PUT /api/Todos/:id update Todo by id
DELETE /api/Todos/:id remove Todo by id

建置新專案

dotnet 版本管理檢查

你的 Local 可能已安裝了多個版本的 dotnet 環境,使用以下指令來檢查目前的 dotnet 版本

$ dotnet --version  # 顯示目前所使用的版本
7.0.201 # 預設是最新的版本

使用 dotnet cli 建立新專案

$ dotnet new webapi -o dotnet7-webapi-jwt
歡迎使用 .NET 7.0!
---------------------
SDK 版本: 7.0.201

遙測
---------
.NET 工具會收集使用資料,協助我們改進您的體驗。資料會由 Microsoft 收集,並分享給社群使用。您可以選擇退出遙測,只要使用您慣用的殼層,將 DOTNET_CLI_TELEMETRY_OPTOUT 環境變數設定為 '1''true' 即可。

閱讀更多有關 .NET CLI 工具遙測的內容: https://aka.ms/dotnet-cli-telemetry

----------------
已安裝 ASP.NET Core HTTPS 開發憑證。
若要信任憑證,請執行 'dotnet dev-certs https --trust' (僅限 Windows 與 macOS )。
深入了解 HTTPS: https://aka.ms/dotnet-https
----------------
撰寫第一個應用程式: https://aka.ms/dotnet-hello-world
了解全新功能: https://aka.ms/dotnet-whats-new
探索文件: https://aka.ms/dotnet-docs
於 GitHub 回報問題和尋找來源: https://github.com/dotnet/core
Use 'dotnet --help' 查看可用的命令或瀏覽: https://aka.ms/dotnet-cli
--------------------------------------------------------------------------------------
範本「ASP.NET Core Web API」已成功建立。

正在處理建立後的動作...
正在還原 /home/egs/cal-data/Sources/nhis/dotnet7-webapi-jwt/dotnet7-webapi-jwt.csproj:
  正在判斷要還原的專案...
  已還原 /home/egs/cal-data/Sources/nhis/dotnet7-webapi-jwt/dotnet7-webapi-jwt.csproj (4.93 sec 內)。
還原成功。

新專案

$ cd dotnet7-webapi-jwt
$ ls -al
總用量 40
drwxrwxr-x 5 egs egs 4096  3月  6 10:23 .
drwxrwxr-x 5 egs egs 4096  3月  6 10:22 ..
-rw-rw-r-- 1 egs egs  127  3月  6 10:22 appsettings.Development.json
-rw-rw-r-- 1 egs egs  151  3月  6 10:22 appsettings.json
drwxrwxr-x 2 egs egs 4096  3月  6 10:22 Controllers
-rw-rw-r-- 1 egs egs  463  3月  6 10:22 dotnet7-webapi-jwt.csproj
drwxrwxr-x 2 egs egs 4096  3月  6 10:23 obj
-rw-rw-r-- 1 egs egs  557  3月  6 10:22 Program.cs
drwxrwxr-x 2 egs egs 4096  3月  6 10:22 Properties
-rw-rw-r-- 1 egs egs  267  3月  6 10:22 WeatherForecast.cs

dotnet 版本管理

$ dotnet --list-sdks    # 顯示已安裝的 sdk 版本資訊
5.0.408 [/usr/share/dotnet/sdk]
6.0.406 [/usr/share/dotnet/sdk]
7.0.201 [/usr/share/dotnet/sdk]

$ dotnet --list-runtimes
Microsoft.AspNetCore.App 5.0.17 [/usr/share/dotnet/shared/Microsoft.AspNetCore.App]
Microsoft.AspNetCore.App 6.0.14 [/usr/share/dotnet/shared/Microsoft.AspNetCore.App]
Microsoft.AspNetCore.App 7.0.3 [/usr/share/dotnet/shared/Microsoft.AspNetCore.App]
Microsoft.NETCore.App 5.0.17 [/usr/share/dotnet/shared/Microsoft.NETCore.App]
Microsoft.NETCore.App 6.0.14 [/usr/share/dotnet/shared/Microsoft.NETCore.App]
Microsoft.NETCore.App 7.0.3 [/usr/share/dotnet/shared/Microsoft.NET# .App]

由於 dotnet 版本演化滿快的,所以會建議在專案目錄中要指定使用 SDK 的版本,以免當你又安裝了更新的版本(如8.0)後程式執行出問題。

$ dotnet new globaljson --sdk-version 7.0.201
範本「global.json 檔案」已成功建立。

$ cat global.json
{
  "sdk": {
    "version": "7.0.201"
  }
}

版本控管

使用 dotnet cli 來產生預設的 git ignore 檔案

$ dotnet new gitignore

建立 git 初始版本

$ git init && git add . && git commit -m "Initial commit"

安裝本機工具

此方式安裝的工具,僅限本機存取(只針對目前的目錄和子目錄), 首先透過 dotnet new tool-manifest 命令來產生工具資訊清單檔,再使用 dotnet tool install 來安裝各式工具程式。這樣的方式好處是在專案若多人協助方式時,則可利用 dotnet tool restore 命令將紀錄在 ./.config/dotnet-tools.json 的工具資訊清單檔重建在不同協助人員的電腦中。

$ dotnet new tool-manifest #會產生 .config/dotnet-tools.json 檔案
$ dotnet tool install --local dotnet-ef #使用 local 安裝方式來安裝 Entity Framework 工具
您可使用下列命令,從此目錄叫用工具: 'dotnet tool run dotnet-ef''dotnet dotnet-ef'。
已成功安裝工具 'dotnet-ef' (版本 '7.0.3')。項目已新增至資訊清單檔 /home/egs/cal-data/Sources/nhis/dotnet7-webapi-jwt/.config/dotnet-tools.json。

$ dotnet tool install --local dotnet-aspnet-codegenerator #使用 local 安裝方式來安裝 Code Generator 工具
您可使用下列命令,從此目錄叫用工具: 'dotnet tool run dotnet-aspnet-codegenerator''dotnet dotnet-aspnet-codegenerator'。
已成功安裝工具 'dotnet-aspnet-codegenerator' (版本 '7.0.4')。項目已新增至資訊清單檔 /home/egs/cal-data/Sources/nhis/dotnet7-webapi-jwt/.config/dotnet-tools.json。

$ cat ./.config/dotnet-tools.json 
{
  "version": 1,
  "isRoot": true,
  "tools": {
    "dotnet-ef": {
      "version": "7.0.3",
      "commands": [
        "dotnet-ef"
      ]
    },
    "dotnet-aspnet-codegenerator": {
      "version": "7.0.4",
      "commands": [
        "dotnet-aspnet-codegenerator"
      ]
    }
  }
}

安裝程式使用的相關套件

$ dotnet add package Microsoft.EntityFrameworkCore.Tools #使用 dotnet Entity Framework 時必須安裝此套件
$ dotnet add package Microsoft.EntityFrameworkCore.Design #使用 dotnet Entity Framework 時必須安裝此套件

$ dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore #使用 Identity Framework 時必須安裝此套件
$ dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL #使用 PostgreSQL DB 當後端資料庫須安裝此套件
$ dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer #要使用 Token Base 權限管理須安裝此套件

$ dotnet add package Microsoft.EntityFrameworkCore.SqlServer #使用 dotnet aspnet-codegenerator 時必須安裝此套件 
$ dotnet add package Microsoft.VisualStudio.Web.CodeGeneration.Design #搭配 dotnet-aspnet-codegenerator 使用
$ dotnet add package System.Configuration.ConfigurationManager  #搭配 dotnet-aspnet-codegenerator 使用

安裝的程式套件資訊紀錄在 “專案”.csproj 檔案中

$ cat dotnet7-webapi-jwt.csproj #查看 安裝套件的相關設定值
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net7.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <RootNamespace>dotnet7_webapi_jwt</RootNamespace>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.3" />
    <PackageReference Include="Microsoft.AspNetCore.Identity.entityframeworkCore" Version="7.0.3" />
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.3" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.3">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="7.0.3" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.3">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="Microsoft.VisualStudio.Web.codeGeneration.Design" Version="7.0.4" />
    <PackageReference Include="Npgsql.entityFrameworkCore.PostgreSQL" Version="7.0.3" />
    <PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
    <PackageReference Include="system.Configuration.ConfigurationManager" Version="7.0.0" />
  </ItemGroup>

</Project>

建立 git 新版本

$ git add . && git commit -m "Add EFCore NuGet packages"

執行程式

$ dotnet watch

image

image

image

打開 VS Code

$ code .

目前産生的程式架構

image

設置使用 Entity Framework相關設定

新增 database context (自動產生)

使用 dotnet ef 工具在專案目錄 ./Data 子目錄下新建立一個 ApiDbContext.cs 的 DB Context file

註:在使用前先把 後端資料庫 環境備妥,安裝 PostGreSQL DB 可參考此篇筆紀 使用 Docker 執行 PostgresSQL

$ dotnet ef dbcontext scaffold "User ID =docker;Password=docker;Server=localhost;Port=5432;Database=pg_testdb; Integrated Security=true;Pooling=true" Npgsql.entityFrameworkCore.PostgreSQL -c ApiDbContext -o Data

Build started...
Build succeeded.
To protect potentially sensitive information in your connection string, you should move it out of source code. You can avoid scaffolding the connection string by using the Name= syntax to read it from configuration - see https://go.microsoft.com/fwlink/?linkid=2131148. For more guidance on storing connection strings, see http://go.microsoft.com/fwlink/?LinkId=723263.

image

using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;

namespace dotnet7_webapi_jwt.Data;

public partial class ApiDbContext : DbContext
{
    public ApiDbContext()
    {
    }

    public ApiDbContext(DbContextOptions<ApiDbContext> options)
        : base(options)
    {
    }

    // protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    //     => optionsBuilder.UseNpgsql("User ID =docker;Password=docker;Server=localhost;Port=5432;Database=pg_testdb; Integrated Security=true;Pooling=true");

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        OnModelCreatingPartial(modelBuilder);
    }

    partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

在 appsettings.json 檔案中加入 Connection String

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "ConnStr": "User ID =docker;Password=docker;Server=localhost;Port=5432;Database=pg_testdb; Integrated Security=true;Pooling=true"
  }
}

使用 Asp.Net Core Identity framework 來管理使用者使用權限

ASP.NET Core Identity:

  • 支援使用者介面 (UI) 登入功能的 API。
  • 管理使用者、密碼、設定檔資料、角色、宣告、權杖、電子郵件確認等。

ASP.Net Core Identity Framework 是一個方便且還完善的使用權限管理架構。

將相關 Asp.Net Core Identity framework 功能注入到 container 中

除了安裝相關套件外,還要調整相關程式:

  • 在 Program.cs 檔案中將加入以下程式碼 (before services.AddControllers())
ConfigurationManager _configuration = builder.Configuration;

// Add services to the container.
builder.Services.AddDbContext<ApiDbContext>(
    options => options.UseSqlServer(
        _configuration.GetConnectionString("ConnStr")
    )
);
builder.Services.AddIdentity<IdentityUser, IdentityRole>()
    .AddEntityFrameworkStores<ApiDbContext>()
    .AddDefaultTokenProviders();
  • 使用 AspNetCore Identity,則 DataContext (ApiDbContext.cs 中) 必須要繼承 IdentityDbContext, 同時 Model creationg 時要改成呼叫 base.OnModelCreation
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public partial class ApiDbContext : IdentityDbContext<IdentityUser>
{
    public ApiDbContext()
    {
    }

// ...

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
      // OnModelCreatingPartial(modelBuilder);
      base.OnModelCreating(modelBuilder);
    }
}

新增一個 entity framework 遷移 並 更新資料庫

完成上述程式調整後,來執行資料庫遷移(migrations)

$ dotnet ef migrations add "Add Identity Framework"

Build started...
Build succeeded.
Done. To undo this action, use 'ef migrations remove'

$ dotnet ef database update

在 dotnet ef migrations add “Add Identity Framework” 指令完成後,可以在專案目錄下發生産生新的子目錄 Migrations,並有三個新檔案

image

在 dotnet ef database update 指令完成後,PostgreSQL pg_testdb 資料庫中產生 Identity Framework 會使用到的資料表

image

建立 git 新版本

$ git add . && git commit -m "新增一個 entity framework 遷移 並 更新資料庫"

使用 Jason Web Token

在 appsettings.json 中自定JWT實作會使用到的設定值

{
// ...  
  "ConnectionStrings": {
    "ConnStr": "User ID =docker;Password=docker;Server=localhost;Port=5432;Database=pg_testdb; Integrated Security=true;Pooling=true"
  },
  "JwtSettings": {
    "ValidIssuer": "Dotnet7WebApiDemo",
    "ValidAudience": "Dotnet7WebApiDemo",
    "Secret": "Dotnet7 WebApi Demo. Using Json Web Token Technology to keep user info."
  }
}

新增 使用者註冊和登入時使用的 Data model class (Models/AuthenticateData.cs)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
using System.ComponentModel.DataAnnotations;

namespace dotnet7_webapi_jwt.Models;

public class Response
{
    public string? Status { get; set; }
    public string? Message { get; set; }
}

public class LoginModel
{
    [EmailAddress]
    [Required(ErrorMessage = "Eamil Address is required")]
    public string? Email { get; set; }

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

public class RegisterModel
{
    [Required(ErrorMessage = "User Name is required")]
    public string? Username { get; set; }

    [EmailAddress]
    [Required(ErrorMessage = "Email Address is required")]
    public string? Email { get; set; }

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

public static class UserRoles
{
    public const string Admin = "Admin";
    public const string User = "User";
}

新增 註冊和登入邏輯 (Controllers/AuthenticateControll.cs)

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using dotnet7_webapi_jwt.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;

namespace dotnet7_webapi_jwt.Controllers;

[Route("api/[controller]")]
[ApiController]
public class AuthenticateController : ControllerBase
{
    private readonly UserManager<IdentityUser> _userManager;
    private readonly RoleManager<IdentityRole> _roleManager;
    private readonly IConfiguration _configuration;

    public AuthenticateController(
        UserManager<IdentityUser> userManager,
        RoleManager<IdentityRole> roleManager,
        IConfiguration configuration)
    {
        _userManager = userManager;
        _roleManager = roleManager;
        _configuration = configuration;
    }

    [HttpPost]
    [Route("login")]
    public async Task<IActionResult> Login([FromBody] LoginModel userModel)
    {
        var user = await _userManager.FindByEmailAsync(userModel.Email);
        if (user != null && await _userManager.CheckPasswordAsync(user, userModel.Password))
        {
            var userRoles = await _userManager.GetRolesAsync(user);

            var claims = new List<Claim>
                {
                    new Claim(ClaimTypes.Name, user.UserName),
                    new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
                };

            foreach (var userRole in userRoles)
            {
                claims.Add(new Claim(ClaimTypes.Role, userRole));
            }

            var token = CreateToken(claims);

            return Ok(new
            {
                token = new JwtSecurityTokenHandler().WriteToken(token),
                expiration = token.ValidTo
            });
        }
        return Unauthorized();
    }

    [HttpPost]
    [Route("register")]
    public async Task<IActionResult> Register([FromBody] RegisterModel model)
    {
        var userExists = await _userManager.FindByEmailAsync(model.Email);
        if (userExists != null)
            return StatusCode(StatusCodes.Status500InternalServerError, new Response { 
                Status = "Error", Message = "User already exists!" 
            });

        IdentityUser user = new()
        {
            Email = model.Email,
            SecurityStamp = Guid.NewGuid().ToString(),
            UserName = model.Username
        };
        var result = await _userManager.CreateAsync(user, model.Password);
        if (!result.Succeeded)
            return StatusCode(StatusCodes.Status500InternalServerError, new Response { 
                Status = "Error", Message = "User creation failed! Please check user details and try again." 
            });

        return Ok(new Response { Status = "Success", Message = "User created successfully!" });
    }

    [HttpPost]
    [Route("register-admin")]
    public async Task<IActionResult> RegisterAdmin([FromBody] RegisterModel model)
    {
        var userExists = await _userManager.FindByEmailAsync(model.Email);
        if (userExists != null)
            return StatusCode(StatusCodes.Status500InternalServerError, new Response { 
                Status = "Error", Message = "User already exists!" 
            });

        IdentityUser user = new()
        {
            Email = model.Email,
            SecurityStamp = Guid.NewGuid().ToString(),
            UserName = model.Username
        };
        var result = await _userManager.CreateAsync(user, model.Password);
        if (!result.Succeeded)
            return StatusCode(StatusCodes.Status500InternalServerError, new Response { 
                Status = "Error", Message = "User creation failed! Please check user details and try again." 
            });

        if (!await _roleManager.RoleExistsAsync(UserRoles.Admin))
            await _roleManager.CreateAsync(new IdentityRole(UserRoles.Admin));
        if (!await _roleManager.RoleExistsAsync(UserRoles.User))
            await _roleManager.CreateAsync(new IdentityRole(UserRoles.User));

        if (await _roleManager.RoleExistsAsync(UserRoles.Admin))
        {
            await _userManager.AddToRoleAsync(user, UserRoles.Admin);
        }
        if (await _roleManager.RoleExistsAsync(UserRoles.Admin))
        {
            await _userManager.AddToRoleAsync(user, UserRoles.User);
        }
        return Ok(new Response { Status = "Success", Message = "User created successfully!" });
    }

    private JwtSecurityToken CreateToken(List<Claim> claims)
    {
        var secretkey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(
            _configuration.GetValue<string>("JwtSettings:Secret")));    // _configuration.GetSection("JwtSettings:Secret").Value)

        var credentials = new SigningCredentials(secretkey, SecurityAlgorithms.HmacSha512Signature);

        var token = new JwtSecurityToken(   // 亦可使用 SecurityTokenDescriptor 來産生 Token
            issuer: _configuration.GetValue<string>("JwtSettings:ValidIssuer"),
            audience: _configuration.GetValue<string>("JwtSettings:ValidAudience"),
            expires: DateTime.Now.AddDays(1),
            claims: claims,
            signingCredentials: credentials);

        return token;
    }
}

有關實作 JWT 的流程

透過 JWT 的實作可以讓你的專案實現 Token-base 的身份驗證與授權。 (Json Web Token) 實作的過程大致可以分成三個部分:

  • 在登入成功後産生合法的 JWT Token
  • 每次收到 request 時驗證是否為合法有效的 JWT Token
  • 在特定 API Endpoint 上驗證是否帶有 “合法有效的 JWT Token”,以達到權限管理的需求

産生合法的 Jason Web Token

在上述 AuthenticateController.cs 程式中,我們建立一個 CreateToken() 的 function,並在登入檢核成功時産生一個 token 回傳。

image

設置驗證是否為合法有效的 JWT Token

第一步,透過 DI 將 JWT 相關設定設置好,在 Programe.cs 檔案中的 ”builder.Services.AddControllers();“ 程式碼之前加入以下有關使用 JWT 使用者授權驗證的程式邏輯:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
builder.Services.AddAuthentication(options =>
    {
        options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
        options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;

    })
    .AddJwtBearer(options =>
    {
        // 當驗證失敗時,回應標頭會包含 WWW-Authenticate 標頭,這裡會顯示失敗的詳細錯誤原因
        options.IncludeErrorDetails = true; // 預設值為 true,有時會特別關閉

        options.TokenValidationParameters = new TokenValidationParameters
        {
            // 透過這項宣告,就可以從 "NAME" 取值
            NameClaimType = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
            // 透過這項宣告,就可以從 "Role" 取值,並可讓 [Authorize] 判斷角色
            RoleClaimType = "http://schemas.microsoft.com/ws/2008/06/identity/claims/role",

            // 驗證 Issuer (一般都會)
            ValidateIssuer = true,
            ValidIssuer = _configuration.GetValue<string>("JwtSettings:ValidIssuer"),

            // 驗證 Audience (通常不太需要)
            ValidateAudience = false,
            //ValidAudience = = _configuration.GetValue<string>("JwtSettings:ValidAudience"),

            // 驗證 Token 的有效期間 (一般都會)
            ValidateLifetime = true,

            // 如果 Token 中包含 key 才需要驗證,一般都只有簽章而已
            ValidateIssuerSigningKey = false,

            // 應該從 IConfiguration 取得
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret))
        };
    });

同時宣告一個 Secret 變數,並由 appsettings.json 系統配置檔中讀出 Jwt Secret 的設定。

1
2
3
4
// ...
ConfigurationManager _configuration = builder.Configuration;
var secret = _configuration.GetValue<string>("JwtSettings:Secret");
// ...

第二步,要啟動 request pipeline 中的 Middleware (UseAuthentication & UseAuthorization 都需要)

1
2
3
4
// ...
app.UseAuthentication();
app.UseAuthorization();
// ...

在特定 API EndPoint 上驗證是否帶有合法有效的 JWT Token

在 WeatherForecastController.cs Get() function 上加入 “[Authorize]” 即可

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    [Authorize]
    [HttpGet(Name = "GetWeatherForecast")]
    public IEnumerable<WeatherForecast> Get()
    {
        return Enumerable.Range(1, 5).Select(index => new WeatherForecast
        {
            Date = DateTime.Now.AddDays(index),
            TemperatureC = Random.Shared.Next(-20, 55),
            Summary = Summaries[Random.Shared.Next(Summaries.Length)]
        })
        .ToArray();
    }

使用 OpenApi Swagger 來測試 API

如上程式加入[Authorize]後,以dotnet watch執行程式,進入 OpenApi Swagger 網頁瀏覽 weatherforecast endpoint,會回傳 Status: 401 Unauthorized 的錯誤訊息。

image

在 OpenApi Swagger 網頁點選 register endpoint 來註冊一個新使用者

image

輸入註冊資料,執行送出,畫面顯示成功!

image

可查看資料庫結果,已在 AspNetUsers Table 中新建立一筆使用者資料:

image

OpenApi Swagger 來測試 API 時, Swagger 測試網頁預設是沒有設定 Token 的功能,必須將程式碼中的 “builder.Services.AddSwaggerGen();”改成以下內容

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new OpenApiInfo { Title = "JwtDemo", Version = "v1" });
    c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        In = ParameterLocation.Header,
        Description = "Please enter JWT with Bearer into field",
        Name = "Authorization",
        Type = SecuritySchemeType.ApiKey
    });
    c.AddSecurityRequirement(new OpenApiSecurityRequirement
    {
        {
            new OpenApiSecurityScheme
                {
                    Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer"}
                },
            new string[] {}
        }
    });
});

有了上述的程式設定,當再次 dotnet watch 啟動程式後,瀏覽器呈現的 Swagger 畫面右上角會多出了`Authorize` 的按鈕。按下按鈕就是讓你填入登入成功後回傳的 Token

image

使用合法(已完成註冊)使用者帳號/密碼來登入:

image 登入成功後回傳的 response 中會包含 token:

image

在 “Value:”" 文字框內填入 “Bearer eyJhbGciOiJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGRzaWctbW9yZSNobWFjLXNoYTUxMiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZ…..",再按下 Authorize 按鈕即表下在接下來的 Request 中都會自動帶入 Token 傳給 WebApi Server。 (請注意 Bearer後再先接著一個空白字元再加上 Token值)

image

再次執行 “WeatherForecast” 的測試(Execute) 就可正常的取得回傳值了

image

加入 git 版本控制

$ git commit -m "finished JWT function" -a

使用 aspnet-codegenerator 工具來自動産生程式碼

最後我們來看看如何使用工具來自動産生程式去維護一個新的資料表

在 Models 目錄下新增一個 model(模型) class - TodoList

使用 dotnet cli 來新增一個 class

$ dotnet new class -o Models -n TodoList

産生出來的空的程式框架:

image

將 Models/TodoList.cs 補齊欄位設定

1
2
3
4
5
6
7
8
namespace dotnet7_webapi_jwt.Models;
public class TodoList
{
    public int Id { get; set; }
    public string? Title { get; set; }
    public string? Details { get; set; }
    public bool Done { get; set; }
}

在 ApiDbContext.cs 中宣告一個 TodoList table

        public DbSet<TodoList>? TodoList { get; set; }  #放在程式最後面

新增一個遷移與更新資料庫

$ dotnet build
$ dotnet ef migrations add "Add New Table - TodoList"
$ dotnet ef database update 

註:`$ dotnet ef migrations remove` 可移除最後一個 migration

使用 ASPNET Codegenerator 自動產生 Todo Controller

$ dotnet aspnet-codegenerator controller -name TodoController -async -api -m TodoList -dc ApiDbContext -outDir Controllers

Building project ...
Finding the generator 'controller'...
Running the generator 'controller'...

Minimal hosting scenario!
Attempting to compile the application in memory.
Attempting to figure out the EntityFramework metadata for the model and DbContext: 'TodoList'

Using database provider 'Npgsql.EntityFrameworkCore.PostgreSQL'!

Added Controller : '/Controllers/TodoController.cs'.
RunTime 00:00:08.68

TodoController.cs

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using dotnet7_webapi_jwt;
using dotnet7_webapi_jwt.Data;

namespace dotnet7_webapi_jwt.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class TodoController : ControllerBase
    {
        private readonly ApiDbContext _context;

        public TodoController(ApiDbContext context)
        {
            _context = context;
        }

        // GET: api/Todo
        [HttpGet]
        public async Task<ActionResult<IEnumerable<TodoList>>> GetTodoList()
        {
          if (_context.TodoList == null)
          {
              return NotFound();
          }
            return await _context.TodoList.ToListAsync();
        }

        // GET: api/Todo/5
        [HttpGet("{id}")]
        public async Task<ActionResult<TodoList>> GetTodoList(int id)
        {
          if (_context.TodoList == null)
          {
              return NotFound();
          }
            var todoList = await _context.TodoList.FindAsync(id);

            if (todoList == null)
            {
                return NotFound();
            }

            return todoList;
        }

        // PUT: api/Todo/5
        // To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754
        [HttpPut("{id}")]
        public async Task<IActionResult> PutTodoList(int id, TodoList todoList)
        {
            if (id != todoList.Id)
            {
                return BadRequest();
            }

            _context.Entry(todoList).State = EntityState.Modified;

            try
            {
                await _context.SaveChangesAsync();
            }
            catch (DbUpdateConcurrencyException)
            {
                if (!TodoListExists(id))
                {
                    return NotFound();
                }
                else
                {
                    throw;
                }
            }

            return NoContent();
        }

        // POST: api/Todo
        // To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754
        [HttpPost]
        public async Task<ActionResult<TodoList>> PostTodoList(TodoList todoList)
        {
          if (_context.TodoList == null)
          {
              return Problem("Entity set 'ApiDbContext.TodoList'  is null.");
          }
            _context.TodoList.Add(todoList);
            await _context.SaveChangesAsync();

            return CreatedAtAction("GetTodoList", new { id = todoList.Id }, todoList);
        }

        // DELETE: api/Todo/5
        [HttpDelete("{id}")]
        public async Task<IActionResult> DeleteTodoList(int id)
        {
            if (_context.TodoList == null)
            {
                return NotFound();
            }
            var todoList = await _context.TodoList.FindAsync(id);
            if (todoList == null)
            {
                return NotFound();
            }

            _context.TodoList.Remove(todoList);
            await _context.SaveChangesAsync();

            return NoContent();
        }

        private bool TodoListExists(int id)
        {
            return (_context.TodoList?.Any(e => e.Id == id)).GetValueOrDefault();
        }
    }
}

測試新功能

在 open api - swagger 網頁上透過 POST 的 EndPoint 新增一筆 Toto list

image

送出新增的資料,回覆新增成功

image

由資料庫中可以查詢到新建立的 record

!image

透過 GET 的 EndPoint 也可以查詢到新增 Toto list

image

以上可以發現使用 ASPNET Codegenerator 自動産生的程式就可簡單的完成資料表格的新增、查詢、修改、刪除等日常功能,真是方便呢!

CORS 議題

Web App 與 Web Api Server 若處於“不同源”時,當 App 使用 http request 呼叫 Web Api Server 上端點時就會有“同源策略“的問題,這個狀況在測試環境中尤為明顯。 要解決這個問題就必須透過 CORS (Cross-Origin Resource Sharing) 相關設定來應對。在 DotNet Core 中設定相關簡易,僅須在 Program.cs 中加入以下二段程式碼即可:

以下程式碼放在 “builder.Services.AddControllers();” 之前:

1
2
3
4
5
6
7
8
9
var MyAllOrigins = "allowAll";
builder.Services.AddCors(option => 
    option.AddPolicy(name: MyAllOrigins, 
        policy =>
        {
            policy.WithOrigins("http://localhost:4200").AllowAnyMethod().AllowAnyHeader();
        }
    )
);

以及

以下程式碼放在 “app.UseHttpsRedirection();” 之前:

app.UseCors(MyAllOrigins);

完成後,當前端 Web App (如: angular 在測試環境下預設是使用 localhost:4200) 使用 http request 呼叫後端 Web Api (本例中 Web Api 使用的是 localhost:5023) 時就可避到同源策略的要求。

Program.cs 完整程式碼

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
using System.Text;
using dotnet7_webapi_jwt.Data;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;

var builder = WebApplication.CreateBuilder(args);

ConfigurationManager _configuration = builder.Configuration;
var secret = _configuration.GetValue<string>("JwtSettings:Secret");

// Add services to the container.
builder.Services.AddDbContext<ApiDbContext>(
    options => options.UseNpgsql(
        _configuration.GetConnectionString("ConnStr")
    )
);
builder.Services.AddIdentity<IdentityUser, IdentityRole>()
    .AddEntityFrameworkStores<ApiDbContext>()
    .AddDefaultTokenProviders();

builder.Services.AddAuthentication(options =>
    {
        options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
        options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;

    })
    .AddJwtBearer(options =>
    {
        // 當驗證失敗時,回應標頭會包含 WWW-Authenticate 標頭,這裡會顯示失敗的詳細錯誤原因
        options.IncludeErrorDetails = true; // 預設值為 true,有時會特別關閉

        options.TokenValidationParameters = new TokenValidationParameters
        {
            // 透過這項宣告,就可以從 "NAME" 取值
            NameClaimType = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
            // 透過這項宣告,就可以從 "Role" 取值,並可讓 [Authorize] 判斷角色
            RoleClaimType = "http://schemas.microsoft.com/ws/2008/06/identity/claims/role",

            // 驗證 Issuer (一般都會)
            ValidateIssuer = true,
            ValidIssuer = _configuration.GetValue<string>("JwtSettings:ValidIssuer"),

            // 驗證 Audience (通常不太需要)
            ValidateAudience = false,
            //ValidAudience = = _configuration.GetValue<string>("JwtSettings:ValidAudience"),

            // 驗證 Token 的有效期間 (一般都會)
            ValidateLifetime = true,

            // 如果 Token 中包含 key 才需要驗證,一般都只有簽章而已
            ValidateIssuerSigningKey = false,

            // 應該從 IConfiguration 取得
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret))
        };
    });
        
var allOrigins = "allowAll";
builder.Services.AddCors(option => 
    option.AddPolicy(name: allOrigins, 
        policy =>
        {
            policy.WithOrigins("http://localhost:4200").AllowAnyMethod().AllowAnyHeader();
        }
    )
);

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new OpenApiInfo { Title = "JwtDemo", Version = "v1" });
    c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        In = ParameterLocation.Header,
        Description = "Please enter JWT with Bearer into field",
        Name = "Authorization",
        Type = SecuritySchemeType.ApiKey
    });
    c.AddSecurityRequirement(new OpenApiSecurityRequirement
    {
        {
            new OpenApiSecurityScheme
                {
                    Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer"}
                },
            new string[] {}
        }
    });
});

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseCors(allOrigins);
app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();

app.Run();