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 防護。
