CORS
API 在 5080 端口,前端页面在 5178 端口,浏览器默认不允许页面读取这个 API 的响应。这一章配置跨源资源共享(Cross-Origin Resource Sharing,CORS),允许本地前端调用 API 并读取结果。
在上一章代码中添加 CORS 策略,完整文件如下:
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();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>();
}允许的前端地址来自配置:
{
"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
停止上一章服务,在仓库根目录执行:
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 包:
<!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>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,执行:
node browser/serve.mjs预期输出:
Open http://localhost:5178/必须通过这个 HTTP 地址打开页面,不要双击 HTML 以 file:// 打开。把令牌粘贴到页面的输入框,点击“Read Todos”,首次运行得到:
HTTP 200
Location: (none)
[]再点击“Create Todo”,得到:
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:
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/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 防护。
