Levelwise
فارسی
ASP.NET Core

احراز هویت و مجوز (Authentication و Authorization)

احراز هویت می‌پرسد «تو کی هستی؟». مجوز می‌پرسد «اجازه این کار را داری؟». در API ها معمولاً کاربر با یک توکن JWT شناخته می‌شود و Policy ها تصمیم می‌گیرند چه کسی چه کاری بکند.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۷ دقیقهمثال API فروشگاه اینترنتیکد C# و .NET 10

نویسنده: bezzad

مشکل: دو سؤال متفاوت

در فروشگاه ما، مشتری سفارش‌هایش را می‌بیند. پشتیبان می‌تواند پول یک سفارش را برگرداند. مدیر همه چیز را می‌بیند. برای هر درخواست، API باید به دو سؤال جدا جواب بدهد:

  1. احراز هویت (Authentication): این درخواست از طرف چه کسی است؟
  2. مجوز (Authorization): آیا این شخص اجازه این کار را دارد؟

این دو را قاطی نکن. هر کدام کد خطای خودش را دارد:

درخواستبا توکن یا بدون آناحراز هویتتو کی هستی؟مجوزاجازه داری؟بازپرداخت200 OK401نمی‌دانم کی هستی403می‌شناسمت، ولی نه
اگر نمی‌دانیم کاربر کیست، جواب 401 است. اگر می‌دانیم کیست ولی اجازه ندارد، جواب 403 است.
یک قانون ساده: کد 401 یعنی «خودت را معرفی کن». کد 403 یعنی «تو را می‌شناسم، ولی نه». اسم 401 در استاندارد HTTP «Unauthorized» است، ولی معنای واقعی‌اش «احراز هویت نشده» است.

توکن JWT چیست؟

بعد از ورود، کاربر یک توکن می‌گیرد. در هر درخواست، آن را در هدر Authorization با کلمه Bearer می‌فرستد. رایج‌ترین قالب این توکن JWT است.

eyJhbGciOi....eyJzdWIiOiI0Mi....SflKxwRJSMeK...سربرگ"alg": "RS256""typ": "JWT"الگوریتم امضامحتوا"sub": "42""aud": "shop-api""exp": 1767225600"permission": "orders.read"همه می‌توانند بخوانندامضابا کلید خصوصی سرور هویتروی سربرگ و محتواتغییر محتوا را لو می‌دهد
سه بخش با نقطه از هم جدا می‌شوند. بخش وسط فقط Base64 است، پس هر کسی می‌تواند آن را بخواند.

سرور چطور به توکن اعتماد می‌کند؟ قدم به قدم:

  1. سرور هویت (Identity Provider) توکن را امضا می‌کند. با کلید خصوصی خودش.
  2. سرویس API فقط امضا را چک می‌کند. با کلید عمومی همان سرور هویت. اگر کسی یک حرف از توکن را عوض کند، امضا دیگر درست نیست.
  3. بعد چند فیلد را چک می‌کند. صادرکننده (iss)، مخاطب (aud) و زمان انقضا (exp).
  4. به دیتابیس سر نمی‌زند. همه چیز داخل خود توکن است. برای همین سریع است. به این حالت stateless می‌گوییم.
امضا شده، نه رمز شده: هر کسی می‌تواند محتوای JWT را بخواند. پس هیچ وقت رمز، شماره کارت یا اطلاعات حساس داخل آن نگذار.

از کجا توکن می‌گیریم؟ OAuth2 و OpenID Connect

خود API نباید رمز عبور کاربر را بگیرد. این کار را به یک سرور هویت می‌سپاریم. مثلاً Keycloak، Microsoft Entra ID یا Duende IdentityServer. دو استاندارد این گفتگو را تعریف می‌کنند:

  • پروتکل OAuth2: درباره دسترسی است. به برنامه یک Access Token می‌دهد تا API را صدا بزند.
  • پروتکل OpenID Connect (OIDC): یک لایه روی OAuth2 است. درباره هویت است. یک ID Token هم می‌دهد که می‌گوید کاربر کیست.

برای اپ وب و موبایل، روش پیشنهادی Authorization Code همراه با PKCE است:

کاربراپ فروشگاهسرور هویتسرویس سفارش۱. رمز عبور، فقط به سرور هویت۲. یک کد کوتاه‌عمر۳. کد و راز یک‌بار مصرفAccess Token, ID Token۴. درخواست با توکن دسترسیفقط توکن را چک می‌کند
رمز عبور فقط به سرور هویت می‌رسد. API فقط Access Token را می‌بیند.
  1. کاربر روی «ورود» می‌زند. اپ او را به صفحه سرور هویت می‌فرستد.
  2. کاربر رمزش را فقط آنجا وارد می‌کند. سرور هویت یک کد کوتاه‌عمر به اپ برمی‌گرداند.
  3. اپ کد را با توکن عوض می‌کند. همراه با یک راز یک‌بار مصرف (PKCE). این‌طور اگر کسی کد را در راه بدزدد، نمی‌تواند از آن استفاده کند.
  4. اپ با Access Token، API را صدا می‌زند. API فقط توکن را چک می‌کند.
کدام توکن برای کیست؟ توکن ID Token برای خود اپ است تا بداند کاربر کیست. توکن Access Token برای API است. هیچ وقت ID Token را به 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 می‌گوییم.

  1. یک جا تعریف می‌شود. اگر قانون بازپرداخت عوض شد، فقط یک جا تغییر می‌کند.
  2. اسمش کار را می‌گوید، نه نقش را. اسم «CanRefund» بهتر از «SupportOrAdmin» است. فردا شاید نقش جدیدی هم اجازه بازپرداخت بگیرد.
  3. می‌تواند چند شرط داشته باشد. مثلاً یک claim خاص و یک نقش خاص با هم.
چرا Claim بهتر از Role است؟ نقش (Role) یک برچسب کلی است. Claim یک تکه اطلاعات دقیق درباره کاربر است، مثل «اجازه بازپرداخت دارد». قانون‌های ریزتر با 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();
چرا 404 به جای 403؟ اگر برای سفارش دیگران 403 بدهیم، مهاجم می‌فهمد این شماره سفارش وجود دارد. با 404، فرقی بین «نیست» و «مال تو نیست» دیده نمی‌شود.

مشکل بزرگ JWT: باطل کردن

مدیر امنیت، حساب یک کاربر متخلف را در دیتابیس غیرفعال می‌کند. ولی این کاربر هنوز یک توکن یک‌ساعته دارد. چه اتفاقی می‌افتد؟

  1. سرویس API برای چک توکن به دیتابیس نگاه نمی‌کند. فقط امضا و انقضا را چک می‌کند.
  2. پس توکن هنوز معتبر است. تا آخر عمرش کار می‌کند.
  3. غیرفعال کردن در دیتابیس اثری ندارد. همان مزیت stateless، اینجا مشکل است.
زمان ←کاربر غیرفعال شدتوکن یک ساعتههنوز دسترسی دارد، تا آخر ساعتتوکن پنج دقیقه‌ایکمیتمدید رد شد، دسترسی قطع شد
با عمر کوتاه توکن، فاصله بین غیرفعال کردن و قطع دسترسی کوتاه می‌شود.

راه‌ها، از ساده به دقیق:

  1. عمر کوتاه برای Access Token و یک Refresh Token. توکن دسترسی فقط چند دقیقه معتبر است. برای توکن تازه، اپ Refresh Token را به سرور هویت می‌فرستد. سرور همان لحظه وضعیت کاربر را چک می‌کند.
  2. لیست سیاه. شناسه توکن (فیلد jti) یا شناسه کاربر را تا زمان انقضا در Redis نگه دار. در هر درخواست آن را چک کن. برای حالت اضطراری مناسب است.
  3. توکن مرجع (Reference Token). توکن فقط یک شناسه است. API هر بار از سرور هویت می‌پرسد. دقیق است، ولی بار و تأخیر بیشتری دارد.
چرخش Refresh Token: هر بار که Refresh Token استفاده شد، یک Refresh Token تازه بده و قبلی را باطل کن. اگر یک توکن باطل‌شده دوباره رسید، یعنی دزدیده شده است. همه توکن‌های آن نشست را باطل کن.

توکن را در مرورگر کجا نگه داریم؟

خطرناکذخیره در localStorage

  • هر کد JavaScript صفحه می‌تواند آن را بخواند.
  • اگر سایت حفره XSS داشته باشد، مهاجم توکن را می‌دزدد.

امن‌ترکوکی HttpOnly

  • کد JavaScript نمی‌تواند آن را بخواند.
  • با تنظیم Secure فقط روی HTTPS می‌رود.
  • با تنظیم SameSite جلوی بیشتر حمله‌های CSRF گرفته می‌شود.

برای اپ‌های SPA، الگوی BFF (Backend for Frontend) رایج است. یک سرور کوچک کنار SPA، توکن‌ها را نگه می‌دارد و با مرورگر فقط با کوکی HttpOnly حرف می‌زند.

قانون‌های مهم

  1. احراز هویت قبل از مجوز. در Pipeline، اول متد UseAuthentication و بعد UseAuthorization.
  2. مخاطب توکن را چک کن. توکنی که برای API دیگری ساخته شده، نباید اینجا قبول شود.
  3. پیش‌فرض را «بسته» بگذار. بهتر است همه endpoint ها احراز هویت بخواهند و فقط چند تا صریحاً آزاد باشند. این کار با متد SetFallbackPolicy در همان AddAuthorizationBuilder انجام می‌شود.
  4. مجوز را در سرور چک کن. مخفی کردن دکمه در UI امنیت نیست.
  5. مالکیت داده را چک کن. داشتن نقش «مشتری» یعنی اجازه دیدن سفارش‌های خودش، نه همه سفارش‌ها.
  6. عمر Access Token را کوتاه نگه دار. چند دقیقه، همراه با Refresh Token.

اشتباه‌های رایج

اشتباه نتیجه راه درست
گذاشتن اطلاعات حساس در JWT هر کسی که توکن را ببیند، آن را می‌خواند. فقط شناسه و claim های لازم.
چک نکردن مخاطب (aud) توکن یک سرویس دیگر، اینجا هم کار می‌کند. تنظیم Audience.
نبود چک مالکیت مشتری با عوض کردن شناسه، سفارش دیگران را می‌بیند. مجوز بر اساس منبع.
توکن یک‌روزه بدون راه باطل کردن کاربر غیرفعال تا یک روز دسترسی دارد. عمر کوتاه و Refresh Token، یا لیست سیاه.
نگه داشتن توکن در localStorage با یک حفره XSS، توکن دزدیده می‌شود. کوکی HttpOnly یا الگوی BFF.
برگرداندن 403 به جای 401 (یا برعکس) کلاینت نمی‌فهمد باید دوباره وارد شود یا نه. ناشناس: 401. بدون اجازه: 403.
قانون‌های مجوز پخش در کد با تغییر قانون، چند جا فراموش می‌شود. قانون با اسم (Policy).

خلاصه در هفت خط

  1. احراز هویت می‌گوید کاربر کیست. مجوز می‌گوید چه کاری اجازه دارد.
  2. کاربر ناشناس 401 می‌گیرد. کاربر بدون اجازه 403 می‌گیرد.
  3. توکن JWT امضا شده است، نه رمز شده. API بدون دیتابیس آن را چک می‌کند.
  4. رمز عبور فقط به سرور هویت می‌رسد. روش پیشنهادی Authorization Code همراه با PKCE است.
  5. قانون‌های مجوز را با اسم (Policy) تعریف کن. برای «فقط داده خودت» مجوز بر اساس منبع لازم است.
  6. توکن JWT را نمی‌شود فوراً باطل کرد. عمر کوتاه، Refresh Token و لیست سیاه کمک می‌کنند.
  7. در مرورگر، توکن را در کوکی HttpOnly نگه دار، نه در localStorage.