مقالات › ASP.NET Core

احراز هویت و مجوزدهی با JWT در ASP.NET Core

پیاده‌سازی امن احراز هویت JWT در ASP.NET Core قدم‌به‌قدم: صدور توکن، اعتبارسنجی، نقش‌ها و Policyها، Refresh Token و اشتباهات امنیتی که باید از آن‌ها دوری کرد.

بیشتر APIهای امروزی — مخصوصاً آن‌هایی که React، اپلیکیشن موبایل یا سرویس‌های دیگر از آن‌ها استفاده می‌کنند — با JSON Web Token (JWT) احراز هویت می‌کنند. در این آموزش احراز هویت JWT را از صفر در ASP.NET Core پیاده می‌کنیم، مجوزدهی مبتنی بر نقش و Policy اضافه می‌کنیم، درباره‌ی Refresh Token صحبت می‌کنیم و جزئیات امنیتی‌ای را پوشش می‌دهیم که خیلی از آموزش‌ها جا می‌اندازند.

تفاوت احراز هویت و مجوزدهی

  • احراز هویت (Authentication) به سؤال تو چه کسی هستی؟ جواب می‌دهد — تأیید هویت (ورود).
  • مجوزدهی (Authorization) به سؤال اجازه‌ی انجام چه کاری را داری؟ جواب می‌دهد — بررسی دسترسی‌ها.

در ASP.NET Core این دو، دو میان‌افزار جدا هستند و ترتیبشان مهم است: UseAuthentication() باید قبل از UseAuthorization() بیاید.

JWT چیست؟

JWT یک رشته‌ی فشرده و امضاشده است که از سه بخش Base64URL تشکیل شده و با نقطه از هم جدا می‌شوند:

header.payload.signature
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJBZG1pbiJ9.Xb5...
  • Header — الگوریتم امضا (HS256، RS256...).
  • Payload — Claimها: شناسه‌ی کاربر، نام، نقش‌ها، زمان انقضا (exp)، صادرکننده (iss) و مخاطب (aud).
  • Signature — ثابت می‌کند توکن را سرور شما صادر کرده و تغییر نکرده است.

هشدار: Payload رمزگذاری نشده، فقط کدگذاری شده است. هر کسی می‌تواند آن را در jwt.io بخواند. هرگز رمز عبور، کد ملی یا اطلاعات محرمانه را در Claimها قرار ندهید.

چون توکن خودبسنده و امضاشده است، سرور بدون مراجعه به دیتابیس می‌تواند آن را اعتبارسنجی کند؛ به همین دلیل JWT مقیاس‌پذیری خوبی دارد.

۱. نصب و پیکربندی

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

فایل appsettings.json:

{
  "Jwt": {
    "Issuer": "MyApi",
    "Audience": "MyApiClients",
    "Key": "a-very-long-random-secret-of-at-least-32-bytes!!",
    "AccessTokenMinutes": 30
  }
}

نکته: کلیدهای واقعی را هرگز در سورس‌کنترل قرار ندهید. در محیط توسعه از User Secrets و در Production از متغیرهای محیطی یا یک مخزن امن کلید استفاده کنید.

فایل Program.cs:

var jwt = builder.Configuration.GetSection("Jwt");

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = jwt["Issuer"],
            ValidAudience = jwt["Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt["Key"]!)),
            ClockSkew = TimeSpan.FromSeconds(30)   // پیش‌فرض ۵ دقیقه است
        };
    });

builder.Services.AddAuthorization();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

۲. صدور توکن

public class TokenService(IConfiguration configuration)
{
    public string CreateAccessToken(User user)
    {
        var jwt = configuration.GetSection("Jwt");
        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt["Key"]!));

        var claims = new List<Claim>
        {
            new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
            new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
            new(ClaimTypes.Name, user.UserName),
        };
        claims.AddRange(user.Roles.Select(role => new Claim(ClaimTypes.Role, role)));

        var token = new JwtSecurityToken(
            issuer: jwt["Issuer"],
            audience: jwt["Audience"],
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(jwt.GetValue<int>("AccessTokenMinutes")),
            signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));

        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}

۳. Endpoint ورود

[ApiController]
[Route("api/auth")]
public class AuthController(AppDbContext db, TokenService tokens, IPasswordHasher<User> hasher) : ControllerBase
{
    [HttpPost("login")]
    public async Task<IActionResult> Login(LoginRequest request)
    {
        var user = await db.Users.Include(u => u.Roles)
            .FirstOrDefaultAsync(u => u.UserName == request.UserName);

        if (user is null ||
            hasher.VerifyHashedPassword(user, user.PasswordHash, request.Password) == PasswordVerificationResult.Failed)
        {
            return Unauthorized(new { message = "Invalid username or password." });
        }

        return Ok(new { accessToken = tokens.CreateAccessToken(user) });
    }
}

دو نکته‌ی مهم:

  • رمز عبور را هش کنید با یک الگوریتم کند و نمک‌دار (PasswordHasher<T>، PBKDF2، BCrypt یا Argon2)؛ هرگز متن ساده ذخیره یا مقایسه نکنید.
  • برای «کاربر پیدا نشد» و «رمز اشتباه» پیام یکسان برگردانید تا مهاجم نتواند نام‌های کاربری معتبر را شناسایی کند.

۴. محافظت از Endpointها

[Authorize]                       // هر کاربر واردشده
[HttpGet("profile")]
public IActionResult Profile()
{
    var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
              ?? User.FindFirstValue(ClaimTypes.NameIdentifier);
    return Ok(new { userId, name = User.Identity!.Name });
}

[Authorize(Roles = "Admin")]       // مبتنی بر نقش
[HttpDelete("users/{id:int}")]
public IActionResult DeleteUser(int id) => NoContent();

[AllowAnonymous]                   // استثنا روی کنترلر محافظت‌شده
[HttpGet("public")]
public IActionResult Public() => Ok();

کلاینت توکن را در هدر Authorization می‌فرستد:

Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

۵. مجوزدهی مبتنی بر Policy

با زیاد شدن دسترسی‌ها، نقش‌ها شلوغ می‌شوند. Policyها نیازمندی‌ها را با یک نام توصیف می‌کنند:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("CanManageArticles", p => p.RequireRole("Admin", "Editor"));
    options.AddPolicy("VerifiedEmail", p => p.RequireClaim("email_verified", "true"));
    options.AddPolicy("Adult", p => p.RequireAssertion(ctx =>
        int.TryParse(ctx.User.FindFirstValue("age"), out var age) && age >= 18));
});

[Authorize(Policy = "CanManageArticles")]
public IActionResult Publish(int id) => Ok();

برای قوانین وابسته به منبع (مثلاً «فقط نویسنده می‌تواند این پست را ویرایش کند»)، یک AuthorizationHandler<TRequirement, TResource> بنویسید و IAuthorizationService.AuthorizeAsync(User, post, "EditPolicy") را صدا بزنید.

۶. Refresh Token

توکن دسترسی باید کوتاه‌عمر باشد (۵ تا ۳۰ دقیقه) تا اگر لو رفت، بازه‌ی آسیب کوچک باشد. برای اینکه کاربر مدام مجبور به ورود نشود، کنار آن یک Refresh Token صادر کنید:

  1. هنگام ورود، یک توکن دسترسی و یک Refresh Token تصادفی و بلندعمر (مثلاً ۷ تا ۳۰ روز) برگردانید.
  2. هش Refresh Token را همراه با زمان انقضا و شناسه‌ی کاربر در دیتابیس ذخیره کنید.
  3. وقتی توکن دسترسی منقضی شد، کلاینت /api/auth/refresh را با Refresh Token صدا می‌زند.
  4. سرور آن را اعتبارسنجی و چرخش (Rotate) می‌دهد: یک Refresh Token جدید صادر و قبلی را باطل می‌کند و توکن دسترسی جدید برمی‌گرداند.
  5. اگر یک Refresh Token باطل‌شده دوباره استفاده شد، فرض را بر سرقت بگذارید و کل خانواده‌ی توکن‌ها را باطل کنید.
public static string CreateRefreshToken() =>
    Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));

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

محل ذخیره ریسک XSS ریسک CSRF توضیح
localStorage زیاد — هر اسکریپت تزریق‌شده می‌خواندش ندارد ساده؛ برای پنل‌های مدیریتی کم‌خطر با رعایت سخت‌گیرانه‌ی XSS قابل قبول
حافظه (متغیر JS) کمتر ندارد با رفرش صفحه از بین می‌رود؛ همراه با کوکی Refresh Token
کوکی HttpOnly + Secure + SameSite با JS قابل خواندن نیست نیاز به SameSite / Anti-forgery مقاوم‌ترین گزینه برای اپ‌های مرورگر

برای برنامه‌های با امنیت بالا، Refresh Token را در کوکی HttpOnly و توکن دسترسی را در حافظه نگه دارید.

اشتباهات امنیتی رایج

  1. کلید امضای ضعیف یا کوتاه — برای HS256 حداقل ۲۵۶ بیت تصادفی، یا RS256 با جفت‌کلید.
  2. غیرفعال کردن اعتبارسنجی زمان، Issuer یا Audience «برای اینکه کار کند».
  3. توکن دسترسی بلندعمر بدون راهکار ابطال.
  4. پذیرفتن alg: none — Handler پیش‌فرض آن را رد می‌کند؛ این رفتار را تغییر ندهید.
  5. اطلاعات حساس در Claimها — Payload قابل خواندن است.
  6. استفاده نکردن از HTTPS — توکن یک مدرک Bearer است؛ هر کس آن را داشته باشد «خود شما» است.

جمع‌بندی

احراز هویت JWT در ASP.NET Core سه قدم دارد: پیکربندی JwtBearer با اعتبارسنجی سخت‌گیرانه، صدور توکن امضاشده با Claimهای معنادار پس از بررسی رمز هش‌شده، و محافظت از Endpointها با [Authorize]، نقش‌ها و Policyها. توکن دسترسی را کوتاه‌عمر نگه دارید، Refresh Token را بچرخانید، کلیدها را امن نگه دارید و همیشه از HTTPS استفاده کنید.

پرسش‌های متداول

JWT بهتر است یا احراز هویت با کوکی؟

برای سایت‌های سمت‌سرور روی یک دامنه، احراز هویت با کوکی ساده‌تر و بسیار امن است. JWT برای APIهایی که SPAها، اپلیکیشن‌های موبایل و سرویس‌های دیگر مصرفشان می‌کنند می‌درخشد.

با JWT چطور کاربر را خارج (Logout) کنیم؟

توکن را در کلاینت حذف و Refresh Token را در سرور باطل کنید. برای ابطال فوری توکن دسترسی، آن را کوتاه‌عمر نگه دارید یا فهرست سیاهی از شناسه‌ی توکن‌ها (jti) داشته باشید.

HS256 یا RS256؟

HS256 از یک کلید مشترک استفاده می‌کند؛ وقتی همان برنامه توکن را صادر و اعتبارسنجی می‌کند مناسب است. RS256 از جفت‌کلید خصوصی/عمومی استفاده می‌کند تا سرویس‌های دیگر بتوانند توکن را اعتبارسنجی کنند بدون اینکه توان صدورش را داشته باشند.