跳到正文

CORS ​

API 在 5080 端口,前端页面在 5178 端口,浏览器默认不允许页面读取这个 API 的响应。这一章配置跨源资源共享(Cross-Origin Resource Sharing,CORS),允许本地前端调用 API 并读取结果。

在上一章代码中添加 CORS 策略,完整文件如下:

20-cors/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"));
});
builder.Services.AddCors(options => options.AddPolicy("LocalFrontend", policy =>
    policy.WithOrigins(builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? [])
        .WithMethods("GET", "POST", "PUT", "DELETE")
        .WithHeaders("Authorization", "Content-Type")
        .WithExposedHeaders("Location")));

var app = builder.Build();

app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseRouting();
app.UseCors("LocalFrontend");
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();
20-cors/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);
20-cors/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>();
}

允许的前端地址来自配置:

20-cors/appsettings.json
json
{
  "ConnectionStrings": {
    "Todos": "Data Source=todos-20.db"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.EntityFrameworkCore.Database.Command": "Warning"
    }
  },
  "AllowedHosts": "*",
  "Cors": {
    "Origins": [
      "http://localhost:5178"
    ]
  }
}

先分清什么是源 ​

源(origin)由协议、主机和端口共同决定:

地址与 http://localhost:5080 是否同源
http://localhost:5080/todos是,路径不同不影响源
http://localhost:5178否,端口不同
http://127.0.0.1:5080否,主机不同
https://localhost:5080否,协议不同

浏览器的同源策略(same-origin policy)限制脚本读取其他源的响应。配置 CORS 后,服务器可以允许指定源的页面读取响应。

启动 API ​

停止上一章服务,在仓库根目录执行:

bash
cd samples/20-cors
dotnet user-jwts create --name alice --role editor --valid-for 1h --output token
dotnet run

复制工具输出的完整令牌备用。令牌必须在本章项目里生成;数据库使用新的 todos-20.db,初始 Todo 列表为空,分类仍为 Work(1)和 Life(2)。

用真正的浏览器页面验证 ​

示例附带一个 HTML 页面,用 Node.js 提供本地访问地址,不需要安装 npm 包:

20-cors/browser/index.html
html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Todo CORS Demo</title>
</head>
<body>
  <h1>Todo CORS Demo</h1>
  <p>Page origin: localhost:5178; API origin: localhost:5080.</p>
  <label for="token">Local test token (kept only in this page memory)</label>
  <input id="token" type="password" autocomplete="off" spellcheck="false" size="48">
  <button id="read" type="button">Read Todos</button>
  <button id="create" type="button">Create Todo</button>
  <pre id="output" role="status" aria-live="polite">Paste the editor token generated in this chapter.</pre>
  <script type="module">
    const token = document.querySelector('#token');
    const output = document.querySelector('#output');

    async function request(method) {
      if (!token.value.trim()) {
        output.textContent = 'Please enter a local test token.';
        return;
      }
      const headers = { Authorization: `Bearer ${token.value.trim()}` };
      const options = { method, headers, credentials: 'omit' };
      if (method === 'POST') {
        headers['Content-Type'] = 'application/json';
        options.body = JSON.stringify({ title: 'Browser todo', categoryId: 1 });
      }
      try {
        const response = await fetch('http://localhost:5080/todos', options);
        const body = await response.text();
        output.textContent = `HTTP ${response.status}\nLocation: ${response.headers.get('Location') ?? '(none)'}\n${body}`;
      } catch {
        output.textContent = 'Request failed: Check that the API is running and inspect the Network panel for errors.';
      }
    }

    document.querySelector('#read').addEventListener('click', () => request('GET'));
    document.querySelector('#create').addEventListener('click', () => request('POST'));
  </script>
</body>
</html>
20-cors/browser/serve.mjs
js
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';

const page = await readFile(new URL('./index.html', import.meta.url));
createServer((request, response) => {
  if (request.url !== '/') {
    response.writeHead(404).end();
    return;
  }
  response.writeHead(200, {
    'Content-Type': 'text/html; charset=utf-8',
    'Cache-Control': 'no-store',
  }).end(page);
}).listen(5178, '127.0.0.1', () => {
  console.log('Open http://localhost:5178/');
});

另开终端,同样进入 samples/20-cors,执行:

bash
node browser/serve.mjs

预期输出:

text
Open http://localhost:5178/

必须通过这个 HTTP 地址打开页面,不要双击 HTML 以 file:// 打开。把令牌粘贴到页面的输入框,点击“Read Todos”,首次运行得到:

text
HTTP 200
Location: (none)
[]

再点击“Create Todo”,得到:

text
HTTP 201
Location: /todos/1
{"id":1,"title":"Browser todo","done":false,"categoryId":1}

编号以新数据库为前提;再次点击会创建新任务。令牌只保留在当前页面内存中,不写入 localStorage、Cookie 或服务器文件。

预检:先问能不能发,再发实际请求 ​

打开浏览器开发者工具的 Network 面板,可以看到 OPTIONS 预检请求(preflight request)。本例手动发送 Authorization,POST 还使用 application/json,这些条件会触发预检;并非只有 POST 才会预检。浏览器可能缓存预检结果,所以不一定每次点击都出现 OPTIONS。

用 curl 可以查看预检响应。下面的命令不带 JWT,也不会创建 Todo:

bash
curl -i -X OPTIONS http://localhost:5080/todos -H "Origin: http://localhost:5178" -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: authorization,content-type"

关键响应头如下:

http
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5178
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Headers: Authorization,Content-Type

预检不携带实际请求的 Bearer 令牌,所以要在认证和授权之前处理。本例先用 UseRouting() 确定端点,再由 UseCors() 处理预检;实际的 GET、POST 仍需有效令牌,写入仍要求 editor 角色。

策略中的四项设置 ​

设置作用
WithOrigins(...)允许 http://localhost:5178 这个精确的源,不包含路径或末尾斜杠
WithMethods(...)允许前端使用 GET、POST、PUT、DELETE
WithHeaders(...)允许实际请求携带 Authorization、Content-Type
WithExposedHeaders("Location")允许前端 JavaScript 读取响应中的 Location

为什么请求头和响应头要分开配置?允许发送 Authorization,不代表可以读取任意响应头。Location 不属于默认向跨源脚本暴露的响应头,因此需要额外声明;否则网络面板里可能看得到,但 response.headers.get('Location') 返回 null。

本例手动发送 Bearer 请求头,使用 credentials: 'omit' 避免浏览器附带 Cookie,因此不需要 AllowCredentials()。如果以后改用 Cookie 登录,需要另外配置凭据,并防范跨站请求伪造(cross-site request forgery,CSRF)。ASP.NET Core CORS 文档

不允许的源不一定得到 403 ​

把预检命令的 Origin 改成 http://localhost:5179 再执行。本例仍返回 204,但没有 Access-Control-Allow-Origin,浏览器因此不会批准后续的跨源请求。

curl 不执行浏览器的同源策略。带着有效令牌,用 curl 直接发送实际请求,即使伪造一个不允许的 Origin,服务端处理程序仍可能执行,只是响应没有跨源许可头。某些不需要预检的浏览器请求也可能发到服务端,只是脚本读不到响应。

CORS 不能替代认证和授权。本章阻止无权限写入的是 JWT 和 CanWriteTodos;CORS 决定浏览器是否允许页面读取响应。CORS 工作方式

提示

遇到“浏览器报 CORS 错误”,先检查 Network 中的预检和实际请求:API 是否启动、Origin 是否完全一致、方法和请求头是否被允许,以及实际请求是不是 401/403。不要只看到浏览器报错就修改业务权限。

FastAPI 对照

这对应 FastAPI / Starlette 的 CORSMiddleware:分别配置允许的源、方法、请求头和可读取的响应头。浏览器的预检与同源规则不会因为服务端框架不同而改变。

总结 ​

  • 源由协议、主机和端口决定;CORS 授权浏览器脚本读取指定的跨源响应。
  • 精确配置源、方法和请求头;读取 Location 等响应头时还要显式暴露它们。
  • 预检检查跨源许可,实际请求仍需认证和授权;CORS 中间件放在认证授权之前。
  • 不允许的源可能仍得到 HTTP 响应,但没有许可头;curl 成功不能证明浏览器 CORS 配置正确。
  • CORS 不提供用户权限或数据隔离,也不能替代 CSRF 防护。

本章完成「安全」阶段。下一章:测试,用自动化检查保护这些行为。上一章:授权。

基于 .NET 10 与 Minimal API · 所有示例均可直接 dotnet run