授權
上一章所有透過身分驗證的人都能刪任務。這一章用授權策略(authorization policy)規定誰可以修改資料。
規則是:透過身分驗證的人可以讀取共享 Todo,帶有 editor 角色的人才可以建立、修改、刪除。 模型、資料庫操作、錯誤處理都沿用上一章。
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();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
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);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
using Microsoft.EntityFrameworkCore;
public class TodoDbContext(DbContextOptions<TodoDbContext> options) : DbContext(options)
{
public DbSet<Todo> Todos => Set<Todo>();
public DbSet<Category> Categories => Set<Category>();
}2
3
4
5
6
7
為同一專案準備兩種身分
先停止上一章服務,從儲存庫根目錄進入本章專案:
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 和測試金鑰,請重新產生,不要直接搬用上一章權杖。
dotnet run在另一個發請求的終端中設定變數:
$READER_TOKEN = "paste-Alice-token-here"
$EDITOR_TOKEN = "paste-Bob-token-here"READER_TOKEN="paste-Alice-token-here"
EDITOR_TOKEN="paste-Bob-token-here"執行與驗證
alice 可以讀取清單。首次執行使用新的 todos-19.db,結果是空陣列:
curl -H "Authorization: Bearer $READER_TOKEN" http://localhost:5080/todos[]但不能建立任務:
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/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 的,其他請求內容保持一致:
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,回應本文為:
{"id":1,"title":"Write report","done":false,"categoryId":1}alice 仍不能刪除它,bob 可以:
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 權杖存取 GET | 200 | 已透過身分驗證,允許讀取 |
| 有效的 alice 權杖存取 POST / PUT / DELETE | 403 | 身分有效,但不滿足寫入策略 |
| 有效的 bob 權杖執行合法寫入 | 對應的 201 或 204 | 身分與權限都滿足要求 |
403 不是重新登入就一定能解決的問題。如果簽發者沒有給這個使用者 editor 角色,再拿到一枚相同權限的權杖,結果仍然是 403。角色授權說明
角色名稱要與策略一致,本例使用小寫 editor。角色應由身分服務根據使用者權限簽發,不能直接相信用戶端提交的 role 欄位。
角色授權不等於資料歸屬檢查
本章的 editor 可以修改共享清單中的任意任務。要實作“只能修改自己的 Todo”,需要給資料儲存擁有者標識,並在查詢或修改時核對目前使用者;僅檢查角色做不到這一點。
注意
--role editor 只是本地測試工具提供的能力,生產用戶端不能自行簽發角色。使用者權限變化後,舊權杖也不會自動更新;身分服務還需要處理權杖過期和撤銷。
FastAPI 對照
這類似把權限檢查封裝成可複用的依賴,再掛到需要寫權限的操作上。ASP.NET Core 的命名策略由授權系統執行,處理常式只宣告策略名稱。
總結
- 身分驗證確認身分,授權策略決定這份身分能執行哪些操作。
CanWriteTodos集中宣告寫權限,POST、PUT、DELETE 顯式使用它。- 組級與端點級授權要求會組合生效,GET 仍繼承組級身分驗證要求。
- 未透過身分驗證得到 401,身分有效但權限不足得到 403。
- 角色來自可信身分宣告;共享清單的角色規則不能替代按資源的歸屬檢查。
