授权
上一章所有通过认证的人都能删任务。这一章用授权策略(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。
- 角色来自可信身份声明;共享列表的角色规则不能替代按资源的归属检查。
