认证(JWT)
上一章任何人都可以修改 Todo。这一章加入认证(authentication):验证调用者的凭据,确认身份,并要求访问 Todo 接口的人通过认证。
下面在第 17 章的代码上加入 JWT Bearer 认证。模型和上下文不变,仍附在本章项目中,方便独立运行:
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();
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);
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);
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();
});
app.Run();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);using Microsoft.EntityFrameworkCore;
public class TodoDbContext(DbContextOptions<TodoDbContext> options) : DbContext(options)
{
public DbSet<Todo> Todos => Set<Todo>();
public DbSet<Category> Categories => Set<Category>();
}准备本地测试令牌
JWT(JSON Web Token)是一种令牌格式。本例客户端通过 Authorization: Bearer <令牌> 提交它;Bearer 表示持有者可以使用这份凭据,因此不要把令牌放进 URL 或日志。
先用 SDK 自带的 dotnet user-jwts 生成开发令牌,再让 API 验证它。这样可以先学会保护接口,暂不涉及登录和用户注册。
先停止上一章服务,在仓库根目录执行:
cd samples/18-authentication
dotnet user-jwts create --name alice --valid-for 1h --output token终端会输出三段以点分隔的令牌。每次生成的内容不同,请完整复制,下一步会用到。
项目已经声明验证令牌的包,以及供开发工具使用的 UserSecretsId:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>aspnetcore-first-steps-18-authentication</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
<PackageReference Include="Scalar.AspNetCore" Version="2.17.10" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
</ItemGroup>
</Project>user-jwts 会在本机用户配置目录保存测试签名密钥,并更新开发环境的 Bearer 配置。本例只监听 http://localhost:5080,对应的配置如下:
{
"Authentication": {
"Schemes": {
"Bearer": {
"ValidAudiences": [
"http://localhost:5080"
],
"ValidIssuer": "dotnet-user-jwts"
}
}
}
}ValidIssuer 是接受的签发者(issuer),ValidAudiences 是接受的受众(audience,即令牌面向的 API 标识)。配置和测试密钥由 AddJwtBearer() 的配置机制读取;源码里没有固定签名密钥。本地 JWT 工具说明
注意
这些令牌和签名密钥只用于本地开发。User Secrets 不加密保存的内容,也不要把它提交到仓库,详见第 12 章。上线时应接入可信身份服务,按其文档配置令牌验证,并通过 HTTPS 传输令牌。
运行与验证
在刚才的项目目录启动 API:
dotnet run另开终端,先不带令牌:
curl -i http://localhost:5080/todos响应摘录,traceId 用占位文字表示本次请求的动态值:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Unauthorized","status":401,"traceId":"request-trace-id"}在这个发请求的终端里,把刚才复制的完整令牌放入变量。根据使用的 shell 选择一种:
$TOKEN = "paste-the-complete-token-here"TOKEN="paste-the-complete-token-here"带上请求头,再请求当前用户和列表:
curl -H "Authorization: Bearer $TOKEN" http://localhost:5080/me{"name":"alice"}curl -H "Authorization: Bearer $TOKEN" http://localhost:5080/todos本章的新数据库 todos-18.db 初始没有任务,所以得到 []。再创建一个:
curl -i -X POST http://localhost:5080/todos -H "Authorization: Bearer $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}把请求头里的令牌换成 not-a-valid-token 再请求 /todos,得到 401。令牌无效时,处理程序不会执行,更不会写入数据库。
注册认证,不等于自动保护所有端点
这几处配置各有职责:
| 配置 | 职责 |
|---|---|
AddAuthentication("Bearer").AddJwtBearer() | 注册默认的 Bearer 认证方案和 JWT 验证处理器 |
UseAuthentication() | 验证请求中的令牌,成功后建立用户身份 |
AddAuthorization() / UseAuthorization() | 注册并执行访问规则;本章只要求已认证 |
RequireAuthorization() | 将“必须通过认证”这个要求附加到端点或整个分组 |
为什么要分别配置?认证回答“凭据是否有效、对应谁”,端点的访问规则回答“这里是否需要身份”。只注册 JWT 服务,却不为端点添加保护要求,不会自动让所有接口变成私有。
本例将要求加在整个 /todos 分组上,CRUD 都受保护;/me 单独添加同样的要求。UseAuthentication() 放在 UseAuthorization() 前面,先建立身份,再检查规则。文档端点仍只在开发环境开放。
服务器怎样判断令牌有效
本例的 JWT 载荷不是加密数据:能读出其中的用户名,不代表它是真的。服务端要校验签名、签发者、受众和有效期,不能只做 Base64 解码就相信客户端。
令牌被篡改、签发者或受众不符,都会验证失败。过期检查还允许一定的时钟偏差,所以到期后可能有短暂宽限。JWT Bearer 验证说明
ClaimsPrincipal 是验证后建立的用户对象,其中的声明(claim)是描述身份的键值信息。本地工具生成的用户名经过默认映射后,可以通过 user.Identity?.Name 读取。不同身份服务的声明名称和映射规则可能不同,不能假设所有 JWT 都有相同的用户名字段。
本章还没有细分权限
现在拥有有效令牌的人都能读写同一份 Todo 列表。下一章会区分“普通读者”和“编辑者”;按用户隔离任务还需要另外检查数据归属。
FastAPI 对照
FastAPI 的 HTTPBearer 或 OAuth2PasswordBearer 可以提取请求中的 Bearer 凭据,但仍需配合令牌验证逻辑。这里的 AddJwtBearer 负责验证,RequireAuthorization 负责要求端点通过访问检查。
总结
- JWT Bearer 认证验证令牌并建立身份,不能只解码载荷就信任它。
dotnet user-jwts仅提供本地测试令牌;生产环境接入可信身份服务并使用 HTTPS。- 注册认证服务不等于保护端点,端点或分组还要声明
RequireAuthorization()。 - 缺少或无法通过验证的令牌访问受保护端点时得到 401。
ClaimsPrincipal提供验证后的身份;是否能修改数据,要由下一章的授权规则决定。
