احراز هویت و مجوز (Authentication و Authorization)
احراز هویت میپرسد «تو کی هستی؟». مجوز میپرسد «اجازه این کار را داری؟». در API ها معمولاً کاربر با یک توکن JWT شناخته میشود و Policy ها تصمیم میگیرند چه کسی چه کاری بکند.
نویسنده: bezzad
مشکل: دو سؤال متفاوت
در فروشگاه ما، مشتری سفارشهایش را میبیند. پشتیبان میتواند پول یک سفارش را برگرداند. مدیر همه چیز را میبیند. برای هر درخواست، API باید به دو سؤال جدا جواب بدهد:
- احراز هویت (Authentication): این درخواست از طرف چه کسی است؟
- مجوز (Authorization): آیا این شخص اجازه این کار را دارد؟
این دو را قاطی نکن. هر کدام کد خطای خودش را دارد:
توکن JWT چیست؟
بعد از ورود، کاربر یک توکن میگیرد. در هر درخواست، آن را در هدر Authorization با کلمه Bearer میفرستد. رایجترین قالب این توکن JWT است.
سرور چطور به توکن اعتماد میکند؟ قدم به قدم:
- سرور هویت (Identity Provider) توکن را امضا میکند. با کلید خصوصی خودش.
- سرویس API فقط امضا را چک میکند. با کلید عمومی همان سرور هویت. اگر کسی یک حرف از توکن را عوض کند، امضا دیگر درست نیست.
- بعد چند فیلد را چک میکند. صادرکننده (iss)، مخاطب (aud) و زمان انقضا (exp).
- به دیتابیس سر نمیزند. همه چیز داخل خود توکن است. برای همین سریع است. به این حالت stateless میگوییم.
از کجا توکن میگیریم؟ OAuth2 و OpenID Connect
خود API نباید رمز عبور کاربر را بگیرد. این کار را به یک سرور هویت میسپاریم. مثلاً Keycloak، Microsoft Entra ID یا Duende IdentityServer. دو استاندارد این گفتگو را تعریف میکنند:
- پروتکل OAuth2: درباره دسترسی است. به برنامه یک Access Token میدهد تا API را صدا بزند.
- پروتکل OpenID Connect (OIDC): یک لایه روی OAuth2 است. درباره هویت است. یک ID Token هم میدهد که میگوید کاربر کیست.
برای اپ وب و موبایل، روش پیشنهادی Authorization Code همراه با PKCE است:
- کاربر روی «ورود» میزند. اپ او را به صفحه سرور هویت میفرستد.
- کاربر رمزش را فقط آنجا وارد میکند. سرور هویت یک کد کوتاهعمر به اپ برمیگرداند.
- اپ کد را با توکن عوض میکند. همراه با یک راز یکبار مصرف (PKCE). اینطور اگر کسی کد را در راه بدزدد، نمیتواند از آن استفاده کند.
- اپ با Access Token، API را صدا میزند. API فقط توکن را چک میکند.
کد: چک کردن توکن در API
کتابخانه لازم Microsoft.AspNetCore.Authentication.JwtBearer است. با تنظیم Authority، این کتابخانه کلیدهای عمومی سرور هویت را خودش دانلود میکند و امضا، صادرکننده، مخاطب و انقضا را چک میکند:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://login.shop.example"; // the identity provider
options.Audience = "shop-api"; // tokens must be made for this API
options.MapInboundClaims = false; // keep claim names like "sub" as they are
});
builder.Services.AddAuthorizationBuilder()
.AddPolicy("CanRefund", p => p.RequireClaim("permission", "orders.refund"));
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/products", (ShopDb db, CancellationToken ct) => db.Products.ToListAsync(ct))
.AllowAnonymous();
app.MapPost("/orders/{id:guid}/refund", (Guid id, RefundService refunds, CancellationToken ct) =>
refunds.RefundAsync(id, ct))
.RequireAuthorization("CanRefund");
app.Run();
قانون مجوز با اسم (Policy)
به جای اینکه در هر endpoint بنویسیم «اگر نقش پشتیبان بود»، قانون را یک بار با یک اسم تعریف میکنیم. به این قانون Policy میگوییم.
- یک جا تعریف میشود. اگر قانون بازپرداخت عوض شد، فقط یک جا تغییر میکند.
- اسمش کار را میگوید، نه نقش را. اسم «CanRefund» بهتر از «SupportOrAdmin» است. فردا شاید نقش جدیدی هم اجازه بازپرداخت بگیرد.
- میتواند چند شرط داشته باشد. مثلاً یک claim خاص و یک نقش خاص با هم.
مجوز بر اساس داده: «فقط سفارش خودت»
یک Policy معمولی فقط کاربر را میبیند. ولی قانون «مشتری فقط سفارش خودش را ببیند» به خود سفارش هم نیاز دارد. اگر این را چک نکنیم، مشتری ۴۲ با عوض کردن عدد در آدرس، سفارش مشتری ۴۳ را میبیند. به این حفره IDOR میگویند و یکی از رایجترین حفرههای API است.
راه حل، مجوز بر اساس منبع (Resource-based) است:
public sealed class OwnsOrderRequirement : IAuthorizationRequirement;
public sealed class OwnsOrderHandler : AuthorizationHandler<OwnsOrderRequirement, Order>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, OwnsOrderRequirement requirement, Order order)
{
var userId = context.User.FindFirstValue("sub");
if (userId == order.CustomerId.ToString())
context.Succeed(requirement);
return Task.CompletedTask;
}
}
// Program.cs
builder.Services.AddSingleton<IAuthorizationHandler, OwnsOrderHandler>();
app.MapGet("/orders/{id:guid}", async (Guid id, ShopDb db, ClaimsPrincipal user,
IAuthorizationService auth, CancellationToken ct) =>
{
var order = await db.Orders.FindAsync([id], ct);
if (order is null) return Results.NotFound();
var result = await auth.AuthorizeAsync(user, order, new OwnsOrderRequirement());
return result.Succeeded ? Results.Ok(order) : Results.NotFound(); // do not reveal it exists
}).RequireAuthorization();
مشکل بزرگ JWT: باطل کردن
مدیر امنیت، حساب یک کاربر متخلف را در دیتابیس غیرفعال میکند. ولی این کاربر هنوز یک توکن یکساعته دارد. چه اتفاقی میافتد؟
- سرویس API برای چک توکن به دیتابیس نگاه نمیکند. فقط امضا و انقضا را چک میکند.
- پس توکن هنوز معتبر است. تا آخر عمرش کار میکند.
- غیرفعال کردن در دیتابیس اثری ندارد. همان مزیت stateless، اینجا مشکل است.
راهها، از ساده به دقیق:
- عمر کوتاه برای Access Token و یک Refresh Token. توکن دسترسی فقط چند دقیقه معتبر است. برای توکن تازه، اپ Refresh Token را به سرور هویت میفرستد. سرور همان لحظه وضعیت کاربر را چک میکند.
- لیست سیاه. شناسه توکن (فیلد jti) یا شناسه کاربر را تا زمان انقضا در Redis نگه دار. در هر درخواست آن را چک کن. برای حالت اضطراری مناسب است.
- توکن مرجع (Reference Token). توکن فقط یک شناسه است. API هر بار از سرور هویت میپرسد. دقیق است، ولی بار و تأخیر بیشتری دارد.
توکن را در مرورگر کجا نگه داریم؟
خطرناکذخیره در localStorage
- هر کد JavaScript صفحه میتواند آن را بخواند.
- اگر سایت حفره XSS داشته باشد، مهاجم توکن را میدزدد.
امنترکوکی HttpOnly
- کد JavaScript نمیتواند آن را بخواند.
- با تنظیم Secure فقط روی HTTPS میرود.
- با تنظیم SameSite جلوی بیشتر حملههای CSRF گرفته میشود.
برای اپهای SPA، الگوی BFF (Backend for Frontend) رایج است. یک سرور کوچک کنار SPA، توکنها را نگه میدارد و با مرورگر فقط با کوکی HttpOnly حرف میزند.
قانونهای مهم
- احراز هویت قبل از مجوز. در Pipeline، اول متد UseAuthentication و بعد UseAuthorization.
- مخاطب توکن را چک کن. توکنی که برای API دیگری ساخته شده، نباید اینجا قبول شود.
- پیشفرض را «بسته» بگذار. بهتر است همه endpoint ها احراز هویت بخواهند و فقط چند تا صریحاً آزاد باشند. این کار با متد SetFallbackPolicy در همان AddAuthorizationBuilder انجام میشود.
- مجوز را در سرور چک کن. مخفی کردن دکمه در UI امنیت نیست.
- مالکیت داده را چک کن. داشتن نقش «مشتری» یعنی اجازه دیدن سفارشهای خودش، نه همه سفارشها.
- عمر Access Token را کوتاه نگه دار. چند دقیقه، همراه با Refresh Token.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| گذاشتن اطلاعات حساس در JWT | هر کسی که توکن را ببیند، آن را میخواند. | فقط شناسه و claim های لازم. |
| چک نکردن مخاطب (aud) | توکن یک سرویس دیگر، اینجا هم کار میکند. | تنظیم Audience. |
| نبود چک مالکیت | مشتری با عوض کردن شناسه، سفارش دیگران را میبیند. | مجوز بر اساس منبع. |
| توکن یکروزه بدون راه باطل کردن | کاربر غیرفعال تا یک روز دسترسی دارد. | عمر کوتاه و Refresh Token، یا لیست سیاه. |
| نگه داشتن توکن در localStorage | با یک حفره XSS، توکن دزدیده میشود. | کوکی HttpOnly یا الگوی BFF. |
| برگرداندن 403 به جای 401 (یا برعکس) | کلاینت نمیفهمد باید دوباره وارد شود یا نه. | ناشناس: 401. بدون اجازه: 403. |
| قانونهای مجوز پخش در کد | با تغییر قانون، چند جا فراموش میشود. | قانون با اسم (Policy). |
خلاصه در هفت خط
- احراز هویت میگوید کاربر کیست. مجوز میگوید چه کاری اجازه دارد.
- کاربر ناشناس 401 میگیرد. کاربر بدون اجازه 403 میگیرد.
- توکن JWT امضا شده است، نه رمز شده. API بدون دیتابیس آن را چک میکند.
- رمز عبور فقط به سرور هویت میرسد. روش پیشنهادی Authorization Code همراه با PKCE است.
- قانونهای مجوز را با اسم (Policy) تعریف کن. برای «فقط داده خودت» مجوز بر اساس منبع لازم است.
- توکن JWT را نمیشود فوراً باطل کرد. عمر کوتاه، Refresh Token و لیست سیاه کمک میکنند.
- در مرورگر، توکن را در کوکی HttpOnly نگه دار، نه در localStorage.