跳至主要內容

授權 ​

上一章所有透過身分驗證的人都能刪任務。這一章用授權策略(authorization policy)規定誰可以修改資料。

規則是:透過身分驗證的人可以讀取共享 Todo,帶有 editor 角色的人才可以建立、修改、刪除。 模型、資料庫操作、錯誤處理都沿用上一章。

19-authorization/Program.cs
cs
using System.Security.Claims;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();
builder.Services.AddValidation();
builder.Services.AddProblemDetails();
var connectionString = builder.Configuration.GetConnectionString("Todos")
    ?? throw new InvalidOperationException("Missing ConnectionStrings:Todos configuration.");
builder.Services.AddDbContext<TodoDbContext>(options => options.UseSqlite(connectionString));
builder.Services.AddAuthentication("Bearer").AddJwtBearer();
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("CanWriteTodos", policy => policy.RequireAuthenticatedUser().RequireRole("editor"));
});

var app = builder.Build();

app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseAuthentication();
app.UseAuthorization();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.MapScalarApiReference();
}

// For standalone learning samples only; EnsureCreatedAsync does not update existing tables.
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
    await db.Database.EnsureCreatedAsync();
    if (!await db.Categories.AnyAsync())
    {
        db.Categories.AddRange(new Category { Name = "Work" }, new Category { Name = "Life" });
        await db.SaveChangesAsync();
    }
}

app.MapGet("/me", (ClaimsPrincipal user) => new { Name = user.Identity?.Name }).RequireAuthorization();

var todos = app.MapGroup("/todos").WithTags("Todos").RequireAuthorization();

todos.MapGet("/", async (TodoDbContext db) =>
    await db.Todos.AsNoTracking().OrderBy(t => t.Id)
        .Select(t => new TodoResponse(t.Id, t.Title, t.Done, t.CategoryId)).ToListAsync());

todos.MapGet("/{id:int}", async Task<Results<Ok<TodoResponse>, NotFound>> (int id, TodoDbContext db) =>
{
    var todo = await db.Todos.FindAsync(id);
    return todo is null ? TypedResults.NotFound()
        : TypedResults.Ok(new TodoResponse(todo.Id, todo.Title, todo.Done, todo.CategoryId));
});

todos.MapPost("/", async Task<Results<Created<TodoResponse>, ProblemHttpResult>> (CreateTodo input, TodoDbContext db) =>
{
    if (!await db.Categories.AnyAsync(c => c.Id == input.CategoryId))
    {
        return TypedResults.Problem(statusCode: 400, title: "Category not found");
    }
    var todo = new Todo { Title = input.Title, CategoryId = input.CategoryId };
    db.Todos.Add(todo);
    await db.SaveChangesAsync();
    return TypedResults.Created($"/todos/{todo.Id}",
        new TodoResponse(todo.Id, todo.Title, todo.Done, todo.CategoryId));
}).ProducesProblem(400).RequireAuthorization("CanWriteTodos");

todos.MapPut("/{id:int}", async Task<Results<NoContent, NotFound, ProblemHttpResult>> (int id, ReplaceTodo input, TodoDbContext db) =>
{
    var todo = await db.Todos.FindAsync(id);
    if (todo is null) return TypedResults.NotFound();
    if (!await db.Categories.AnyAsync(c => c.Id == input.CategoryId))
    {
        return TypedResults.Problem(statusCode: 400, title: "Category not found");
    }
    todo.Title = input.Title;
    todo.Done = input.Done;
    todo.CategoryId = input.CategoryId;
    await db.SaveChangesAsync();
    return TypedResults.NoContent();
}).ProducesProblem(400).RequireAuthorization("CanWriteTodos");

todos.MapDelete("/{id:int}", async Task<Results<NoContent, NotFound>> (int id, TodoDbContext db) =>
{
    var todo = await db.Todos.FindAsync(id);
    if (todo is null) return TypedResults.NotFound();
    db.Todos.Remove(todo);
    await db.SaveChangesAsync();
    return TypedResults.NoContent();
}).RequireAuthorization("CanWriteTodos");

app.Run();
19-authorization/Models.cs
cs
using System.ComponentModel.DataAnnotations;

public class Todo
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public bool Done { get; set; }
    public int CategoryId { get; set; }
    public Category Category { get; set; } = null!;
}

public class Category
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public List<Todo> Todos { get; set; } = [];
}

public record CreateTodo(
    [Required, StringLength(100)] string Title,
    [Range(1, int.MaxValue)] int CategoryId);

public record ReplaceTodo(
    [Required, StringLength(100)] string Title,
    bool Done,
    [Range(1, int.MaxValue)] int CategoryId);

public record TodoResponse(int Id, string Title, bool Done, int CategoryId);
19-authorization/TodoDbContext.cs
cs
using Microsoft.EntityFrameworkCore;

public class TodoDbContext(DbContextOptions<TodoDbContext> options) : DbContext(options)
{
    public DbSet<Todo> Todos => Set<Todo>();
    public DbSet<Category> Categories => Set<Category>();
}

為同一專案準備兩種身分 ​

先停止上一章服務,從儲存庫根目錄進入本章專案:

bash
cd samples/19-authorization
dotnet user-jwts create --name alice --valid-for 1h --output token
dotnet user-jwts create --name bob --role editor --valid-for 1h --output token

分別複製兩條指令輸出的完整權杖:alice 沒有 editor 角色,bob 有。這裡使用本章自己的 UserSecretsId 和測試金鑰,請重新產生,不要直接搬用上一章權杖。

bash
dotnet run

在另一個發請求的終端中設定變數:

powershell
$READER_TOKEN = "paste-Alice-token-here"
$EDITOR_TOKEN = "paste-Bob-token-here"
bash
READER_TOKEN="paste-Alice-token-here"
EDITOR_TOKEN="paste-Bob-token-here"

執行與驗證 ​

alice 可以讀取清單。首次執行使用新的 todos-19.db,結果是空陣列:

bash
curl -H "Authorization: Bearer $READER_TOKEN" http://localhost:5080/todos
json
[]

但不能建立任務:

bash
curl -i -X POST http://localhost:5080/todos -H "Authorization: Bearer $READER_TOKEN" -H "Content-Type: application/json" -d '{"title":"Write report","categoryId":1}'

回應摘錄,traceId 是動態值:

http
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.4","title":"Forbidden","status":403,"traceId":"request-trace-id"}

把權杖換成 bob 的,其他請求內容保持一致:

bash
curl -i -X POST http://localhost:5080/todos -H "Authorization: Bearer $EDITOR_TOKEN" -H "Content-Type: application/json" -d '{"title":"Write report","categoryId":1}'

這次是 201,Location: /todos/1,回應本文為:

json
{"id":1,"title":"Write report","done":false,"categoryId":1}

alice 仍不能刪除它,bob 可以:

bash
curl -i -X DELETE http://localhost:5080/todos/1 -H "Authorization: Bearer $READER_TOKEN"
curl -i -X DELETE http://localhost:5080/todos/1 -H "Authorization: Bearer $EDITOR_TOKEN"

依次得到 403 和 204。第二次刪除成功,沒有回應本文;第一個請求沒有執行刪除。

用同一條策略保護三個寫入端點 ​

第 17 行註冊名為 CanWriteTodos 的策略。它要求身分已通過身分驗證,並且包含 editor 角色(role)。在這份 JWT 範例中,角色來自受信任權杖中的宣告,不來自請求本文或一個任意的 HTTP Header。

POST、PUT、DELETE 都呼叫 RequireAuthorization("CanWriteTodos"),授權系統會在處理常式執行前檢查這條策略。這樣以後調整寫權限,只需改一處規則,不用分別修改三個處理常式。

新增寫入端點時,也要附加這條策略;框架不會根據 POST、PUT 等方法名自動要求 editor 角色。

群組上的 RequireAuthorization() 沒有被單個端點上的命名策略替換。這些要求會組合生效:組內端點先具有“需要身分驗證”的要求,寫入端點再增加 editor 角色要求。

401 和 403 分別告訴用戶端什麼 ​

請求情況結果原因
沒有權杖,或者權杖無效401無法建立滿足要求的身分驗證身分
有效的 alice 權杖存取 GET200已透過身分驗證,允許讀取
有效的 alice 權杖存取 POST / PUT / DELETE403身分有效,但不滿足寫入策略
有效的 bob 權杖執行合法寫入對應的 201 或 204身分與權限都滿足要求

403 不是重新登入就一定能解決的問題。如果簽發者沒有給這個使用者 editor 角色,再拿到一枚相同權限的權杖,結果仍然是 403。角色授權說明

角色名稱要與策略一致,本例使用小寫 editor。角色應由身分服務根據使用者權限簽發,不能直接相信用戶端提交的 role 欄位。

角色授權不等於資料歸屬檢查 ​

本章的 editor 可以修改共享清單中的任意任務。要實作“只能修改自己的 Todo”,需要給資料儲存擁有者標識,並在查詢或修改時核對目前使用者;僅檢查角色做不到這一點。

注意

--role editor 只是本地測試工具提供的能力,生產用戶端不能自行簽發角色。使用者權限變化後,舊權杖也不會自動更新;身分服務還需要處理權杖過期和撤銷。

FastAPI 對照

這類似把權限檢查封裝成可複用的依賴,再掛到需要寫權限的操作上。ASP.NET Core 的命名策略由授權系統執行,處理常式只宣告策略名稱。

總結 ​

  • 身分驗證確認身分,授權策略決定這份身分能執行哪些操作。
  • CanWriteTodos 集中宣告寫權限,POST、PUT、DELETE 顯式使用它。
  • 組級與端點級授權要求會組合生效,GET 仍繼承組級身分驗證要求。
  • 未透過身分驗證得到 401,身分有效但權限不足得到 403。
  • 角色來自可信身分宣告;共享清單的角色規則不能替代按資源的歸屬檢查。

下一章:CORS——讓另一個源的瀏覽器頁面呼叫 API。上一章:身分驗證(JWT)。

採用 .NET 10 與 Minimal API · 所有範例皆可直接 dotnet run