بناء نظام تسجيل دخول باستخدام JWT في ASP.NET Core
عندما تبدأ في بناء تطبيق ويب حديث باستخدام ASP.NET Core، ستجد نفسك سريعًا أمام سؤال مهم جدًا: كيف أتعامل مع تسجيل الدخول بطريقة مناسبة وآمنة ومرنة في نفس الوقت؟ وفي المشاريع التي تعتمد على REST APIs أو تطبيقات الواجهة الأمامية المنفصلة مثل React أو Angular أو Flutter أو حتى تطبيقات الموبايل، يصبح JWT واحدًا من أكثر الحلول شيوعًا وملاءمة. الفكرة الأساسية بسيطة جدًا: المستخدم يسجّل الدخول مرة واحدة، ثم يحصل على رمز موقّع يمكنه استخدامه لإثبات هويته في الطلبات التالية. في ASP.NET Core، المصادقة هي عملية تحديد هوية المستخدم، بينما التفويض هو عملية تحديد ما إذا كان هذا المستخدم يملك صلاحية الوصول إلى مورد معيّن أم لا، وهذا الفصل بين المفهومين هو أساس التصميم الجيد للنظام كله.
JWT، أو JSON Web Token، ليس مجرد “سلسلة نصية طويلة” كما يظنه البعض، بل هو حاوية معيارية صغيرة تحمل معلومات عن المستخدم أو الجهة المصدرة أو زمن الصلاحية أو الأدوار أو أي Claims أخرى تحتاجها أنت في تطبيقك. وفي ASP.NET Core، آلية JWT Bearer Authentication تعتمد على استخراج التوكن من ترويسة Authorization ثم التحقق منه قبل السماح بالوصول إلى النقطة المحمية في الـ API. هذا يعني أن كل طلب يمكنه أن يكون مستقلًا بذاته، دون الحاجة إلى جلسة server-side تقليدية في كثير من السيناريوهات.
لكن قبل أن نغوص في الكود، من المفيد جدًا أن نرتب الأفكار. JWT ممتاز عندما تكون لديك APIs أو خدمات منفصلة، أو عندما تريد فصل المصادقة عن الواجهة الأمامية. أما في تطبيقات الويب التقليدية شديدة الاعتماد على المتصفح، فـ Microsoft توضح أن تخزين access tokens على الخادم واستخدام cookie آمن من نوع HttpOnly قد يكون خيارًا أفضل في التطبيقات الآمنة المعتمدة على الويب، خصوصًا عندما يكون لديك Backend يقوم بإدارة التوكنات بدلًا من تركها في المتصفح مباشرة. هذا ليس تفصيلًا ثانويًا، بل نقطة تصميمية مهمة جدًا قبل أن تختار المعمارية المناسبة.
ما الذي سنبنيه بالضبط؟
في هذا المقال سنبني نظام تسجيل دخول عمليًا باستخدام ASP.NET Core Web API وJWT، وسنمر على المسار الكامل: إنشاء المستخدم، التحقق من كلمة المرور، إصدار التوكن، حماية endpoints، قراءة الـ Claims، استخدام [Authorize]، ثم التعامل مع الأدوار والسياسات، وأخيرًا سنختم بأهم الملاحظات الأمنية التي يجب ألا تتجاوزها في بيئة الإنتاج. وبدل أن يبقى الكلام نظريًا، سنكتب أكوادًا واضحة قابلة للتجربة والتطوير، بحيث تخرج من المقال وأنت قادر على تنفيذ النظام بنفسك بثقة.
الفكرة التي سنعتمدها هنا هي الفكرة الأكثر شيوعًا في مشاريع API: المستخدم يرسل بيانات الاعتماد إلى endpoint مثل /api/auth/login، ثم الخادم يتحقق من صحة البيانات ويُصدر JWT موقّعًا باستخدام مفتاح سري، وبعدها يحتفظ العميل بهذا التوكن ويرسله في الطلبات اللاحقة داخل ترويسة Authorization: Bearer <token>. من جهة ASP.NET Core، يتم تفعيل AddJwtBearer(...) حتى يتولى النظام نفسه استخراج التوكن والتحقق منه تلقائيًا قبل وصول الطلب إلى الأكشن المحمي.
لماذا JWT مناسب جدًا لتسجيل الدخول في ASP.NET Core؟
JWT شائع جدًا لأن له طبيعة stateless؛ أي أن الخادم لا يحتاج بالضرورة إلى تخزين جلسة لكل مستخدم في الذاكرة أو قاعدة البيانات في كل طلب. وهذا يفيدك عندما يكون عندك أكثر من instance للتطبيق، أو عندما تريد تشغيل التطبيق خلف load balancer، أو عندما تعمل بأسلوب microservices، أو ببساطة عندما تريد API نظيفة وسهلة الاستهلاك من عدة واجهات مختلفة. في ASP.NET Core، خدمات المصادقة مبنية على middleware ومعالجات تسجيل مخصصة، والتوثيق عبر التوكن ينسجم بشكل ممتاز مع هذا الأسلوب.
لكن يجب أن تبقى الصورة متوازنة. JWT ليس سحرًا، وليس بديلًا عن التفكير الأمني. إذا لم تختر مدة صلاحية مناسبة، وإذا خزّنت التوكن بشكل غير آمن في الواجهة الأمامية، وإذا لم تستخدم HTTPS، وإذا تجاهلت التحقق من التوقيع والـ issuer والـ audience، فكل هذا قد يعرّض تطبيقك للمشكلات. لذلك، نجاح نظام JWT لا يعتمد فقط على “إصدار token”، بل على المنظومة كلها: التحقق، التخزين، الحماية، الصلاحيات، والتجديد. هذه النقطة تتكرر كثيرًا في المشاريع الحقيقية لأن أخطاء المصادقة غالبًا لا تكون في الكود وحده، بل في المعمارية أيضًا.
شكل النظام الذي سنبنيه
سنبني تطبيق ASP.NET Core Web API يحتوي على ثلاثة أجزاء رئيسية. الجزء الأول مسؤول عن تسجيل الدخول وإصدار التوكن. الجزء الثاني مسؤول عن حماية الموارد عبر [Authorize]. والجزء الثالث مسؤول عن التعامل مع الـ Claims مثل NameIdentifier وRole بحيث نستطيع لاحقًا استخدام نفس التوكن لإظهار اسم المستخدم أو التحقق من صلاحياته أو تحديد إن كان يملك دورًا إداريًا أم لا. في ASP.NET Core، التفويض يمكن أن يكون بسيطًا عبر الأدوار أو أكثر تقدمًا عبر السياسات المعتمدة على requirements وhandlers، وهذا يمنحك مرونة كبيرة عند نمو التطبيق.
إنشاء مشروع ASP.NET Core Web API
لنبدأ من الصفر. أنشئ مشروع Web API جديدًا، ثم أضف الحزم اللازمة للتعامل مع JWT Bearer. في أغلب الحالات ستحتاج إلى Microsoft.AspNetCore.Authentication.JwtBearer. بعد ذلك سنضيف الإعدادات اللازمة في Program.cs، ثم نكتب خدمة صغيرة لتوليد التوكن، ثم نكتب Controller للمصادقة.
dotnet new webapi -n JwtAuthDemo
cd JwtAuthDemo
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
بعد تثبيت الحزمة، سننتقل إلى الإعدادات. الفكرة الأساسية هنا أن AddJwtBearer هو ما يفعّل JWT bearer authentication، وهو يقوم باستخراج التوكن من ترويسة Authorization ثم التحقق منه. هذا هو المكان الصحيح لربط الـ issuer والـ audience والمفتاح السري وإعدادات التحقق الأخرى.
إعداد appsettings.json
من الأفضل ألا تضع القيم الحساسة مباشرة داخل الكود. ضعها في appsettings.json أو في User Secrets أثناء التطوير، ثم انقلها إلى متغيرات بيئة أو Secret Manager أو Key Vault في الإنتاج. سنضع هنا إعدادات أساسية مثل المفتاح السري والـ issuer والـ audience ومدة الصلاحية.
{
"Jwt": {
"Key": "THIS_IS_A_DEMO_SECRET_KEY_CHANGE_IT_IN_PRODUCTION_123456789",
"Issuer": "JwtAuthDemo",
"Audience": "JwtAuthDemoClient",
"DurationInMinutes": 60
}
}
طبعًا هذا المثال تعليمي فقط، والمفتاح الحقيقي يجب أن يكون أقوى بكثير وأطول، وألا يُحفظ بشكل مكشوف في المستودع. الفكرة هنا هي فهم البنية، لا نسخ القيم نفسها إلى مشروعك الحقيقي.
إعداد المصادقة في Program.cs
الآن سنفعّل المصادقة والتفويض. لاحظ أن مفهوم المصادقة في ASP.NET Core مرتبط بالخدمة IAuthenticationService والـ middleware المسجّل في الـ pipeline. لذلك الترتيب مهم: نفعّل Authentication ثم Authorization ثم نستخدمهما على الـ endpoints.
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
var jwtSection = builder.Configuration.GetSection("Jwt");
var key = jwtSection["Key"]!;
var issuer = jwtSection["Issuer"]!;
var audience = jwtSection["Audience"]!;
var durationInMinutes = int.Parse(jwtSection["DurationInMinutes"]!);
builder.Services.AddControllers();
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.RequireHttpsMetadata = true;
options.SaveToken = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = issuer,
ValidateAudience = true,
ValidAudience = audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
};
});
builder.Services.AddAuthorization();
builder.Services.AddSingleton<IJwtTokenService>(_ => new JwtTokenService(
key,
issuer,
audience,
durationInMinutes
));
var app = builder.Build();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
هذا الجزء هو قلب النظام. عندما يرسل العميل توكنًا في ترويسة Authorization، فإن JwtBearer middleware سيتولى التحقق من قيمه ويدفع الهوية المصادَق عليها إلى الـ pipeline. وإذا كان التوكن غير صالح أو منتهيًا أو يحمل issuer/audience غير مطابقين، فلن يُعتبر المستخدم authenticated. وهذا يتماشى مع ما توضحه Microsoft بشأن JWT bearer authentication في ASP.NET Core.
خدمة توليد التوكن
سننشئ الآن خدمة مسؤولة عن إنشاء JWT. ستأخذ معلومات المستخدم وتُرجع سلسلة token موقعة. هذه الخدمة هي التي تحدد الـ claims التي سنضعها داخل التوكن، مثل معرف المستخدم والبريد الإلكتروني والدور. ويمكنك لاحقًا توسيعها لتضمّ اسم العرض أو tenant id أو أي بيانات أخرى تحتاجها.
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
public interface IJwtTokenService
{
string GenerateToken(ApplicationUser user);
}
public class JwtTokenService : IJwtTokenService
{
private readonly string _key;
private readonly string _issuer;
private readonly string _audience;
private readonly int _durationInMinutes;
public JwtTokenService(string key, string issuer, string audience, int durationInMinutes)
{
_key = key;
_issuer = issuer;
_audience = audience;
_durationInMinutes = durationInMinutes;
}
public string GenerateToken(ApplicationUser user)
{
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.UserName),
new Claim(ClaimTypes.Email, user.Email ?? string.Empty),
new Claim(ClaimTypes.Role, user.Role)
};
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_key));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _issuer,
audience: _audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_durationInMinutes),
signingCredentials: credentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
هنا نحن نبني التوكن بشكل واضح جدًا. نضع claims الأساسية ثم نوقّعه بمفتاح سري باستخدام HMAC SHA256. بعد ذلك نحوله إلى string يمكن إرساله إلى العميل. من المهم أن تفهم أن JWT ليس مجرد “تخزين معلومات”، بل هو بيانات موقعة، وهذا التوقيع هو ما يجعل التلاعب به مكشوفًا عند التحقق منه في الـ API.
نموذج المستخدم البسيط
لأغراض الشرح، سننشئ نموذج مستخدم بسيطًا. في مشروع حقيقي قد يكون هذا كيانًا مرتبطًا بقاعدة البيانات أو بـ ASP.NET Core Identity، لكننا هنا سنبقيه بسيطًا حتى نركز على منطق JWT نفسه.
public class ApplicationUser
{
public Guid Id { get; set; } = Guid.NewGuid();
public string UserName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public string Role { get; set; } = "User";
}
ولو أردت أن تبني النظام بشكل احترافي أكبر، يمكنك استخدام ASP.NET Core Identity بدلًا من التشفير اليدوي لكلمات المرور وإدارة المستخدمين بنفسك. لكن حتى عندما تستخدم Identity، فإن مبدأ إصدار JWT يبقى قريبًا جدًا: تتحقق من المستخدم، ثم تصدر token موقّعًا يحمل claims مناسبة.
خدمة بسيطة للمستخدمين
سنعمل الآن على خدمة in-memory بسيطة لمحاكاة قاعدة البيانات. الهدف تعليمي، حتى نفهم المسار كاملًا دون إغراق مبكر في تفاصيل ORM وقواعد البيانات.
public interface IUserService
{
ApplicationUser? ValidateUser(string userName, string password);
ApplicationUser Register(string userName, string email, string password, string role = "User");
}
public class UserService : IUserService
{
private readonly List<ApplicationUser> _users = new();
public UserService()
{
Register("admin", "admin@example.com", "Admin@123", "Admin");
Register("hassan", "hassan@example.com", "User@123", "User");
}
public ApplicationUser Register(string userName, string email, string password, string role = "User")
{
var user = new ApplicationUser
{
UserName = userName,
Email = email,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
Role = role
};
_users.Add(user);
return user;
}
public ApplicationUser? ValidateUser(string userName, string password)
{
var user = _users.FirstOrDefault(x =>
x.UserName.Equals(userName, StringComparison.OrdinalIgnoreCase));
if (user == null)
return null;
return BCrypt.Net.BCrypt.Verify(password, user.PasswordHash) ? user : null;
}
}
لاحظ أننا استخدمنا BCrypt بدل تخزين كلمة المرور كنص صريح. هذا مهم جدًا، لأن كلمة المرور لا يجب أن تُخزَّن أبدًا كنص واضح. حتى لو كان هذا المثال تعليميًا، فمن الأفضل منذ البداية أن تتعامل مع كلمات المرور بالطريقة الصحيحة.
ثم نسجل هذه الخدمة في Program.cs:
builder.Services.AddSingleton<IUserService, UserService>();
إنشاء DTOs لعمليات تسجيل الدخول والتسجيل
من الأفضل ألا ترسل الـ entities مباشرة من الواجهة الأمامية إلى الـ API أو العكس. استخدم DTOs واضحة لتسجيل الدخول والتسجيل.
public class RegisterRequest
{
public string UserName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
public class LoginRequest
{
public string UserName { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
public class AuthResponse
{
public string AccessToken { get; set; } = string.Empty;
public DateTime ExpiresAtUtc { get; set; }
public string UserName { get; set; } = string.Empty;
public string Role { get; set; } = string.Empty;
}
إنشاء AuthController
الآن نصل إلى الجزء الذي ينتظره كل من يريد نظام تسجيل دخول حقيقي: الـ Controller الذي يستقبل طلبات التسجيل والدخول ويعيد التوكن.
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
private readonly IUserService _userService;
private readonly IJwtTokenService _jwtTokenService;
public AuthController(IUserService userService, IJwtTokenService jwtTokenService)
{
_userService = userService;
_jwtTokenService = jwtTokenService;
}
[HttpPost("register")]
public IActionResult Register(RegisterRequest request)
{
var user = _userService.Register(request.UserName, request.Email, request.Password);
return Ok(new
{
message = "User registered successfully",
user.Id,
user.UserName,
user.Email,
user.Role
});
}
[HttpPost("login")]
public IActionResult Login(LoginRequest request)
{
var user = _userService.ValidateUser(request.UserName, request.Password);
if (user == null)
return Unauthorized(new { message = "Invalid username or password" });
var token = _jwtTokenService.GenerateToken(user);
return Ok(new AuthResponse
{
AccessToken = token,
ExpiresAtUtc = DateTime.UtcNow.AddMinutes(60),
UserName = user.UserName,
Role = user.Role
});
}
}
عندما ينجح تسجيل الدخول، لا نعيد كلمة المرور ولا أي شيء حساس. نعيد فقط الـ access token وبعض البيانات المفيدة للواجهة الأمامية، مثل اسم المستخدم والدور وتاريخ انتهاء صلاحية الرمز. هذا الأسلوب يوازن بين البساطة والأمان.
حماية endpoint باستخدام [Authorize]
الآن سننشئ Controller محميًا لا يمكن الوصول إليه إلا إذا كان المستخدم يحمل token صالحًا. في ASP.NET Core، تطبيق [Authorize] على controller أو action يقيّد الوصول للمستخدمين المصادق عليهم، بينما AllowAnonymous يسمح بالوصول المفتوح عند الحاجة. وهذا هو السلوك المتوقع في معظم تطبيقات API الحديثة.
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
[ApiController]
[Route("api/[controller]")]
public class ProfileController : ControllerBase
{
[Authorize]
[HttpGet("me")]
public IActionResult Me()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var userName = User.Identity?.Name;
var email = User.FindFirstValue(ClaimTypes.Email);
var role = User.FindFirstValue(ClaimTypes.Role);
return Ok(new
{
userId,
userName,
email,
role,
message = "This endpoint is protected by JWT"
});
}
[Authorize(Roles = "Admin")]
[HttpGet("admin-only")]
public IActionResult AdminOnly()
{
return Ok(new
{
message = "Welcome, admin. You have accessed a role-protected endpoint."
});
}
}
هنا نرى جمال الـ Claims في JWT. بمجرد أن يتم التحقق من التوكن، يمكننا استخراج NameIdentifier وEmail وRole من User. هذا يختصر كثيرًا من التعقيد، ويسمح بتصميم endpoints تتخذ قراراتها بناءً على الهوية والدور.
لماذا الأدوار والسياسات مهمتان جدًا؟
ليس كل شيء يجب أن يُفحص عبر Roles فقط. ASP.NET Core يوفّر نموذجًا غنيًا للتفويض، يشمل الأدوار والسياسات المبنية على requirements وhandlers. هذا مفيد عندما يصبح نظامك أكثر تعقيدًا من مجرد “Admin” و“User”. قد تحتاج مثلًا إلى سياسة تسمح لمن أكمل توثيق البريد الإلكتروني، أو لمن ينتمي إلى tenant معين، أو لمن يملك اشتراكًا مدفوعًا، أو لمن يستوفي شرطًا مركبًا يجمع عدة claims معًا.
لإنشاء سياسة بسيطة:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("RequireAdminOrManager", policy =>
policy.RequireRole("Admin", "Manager"));
});
ثم استخدامها:
[Authorize(Policy = "RequireAdminOrManager")]
[HttpGet("reports")]
public IActionResult Reports()
{
return Ok(new { message = "Reports are available to Admin or Manager only." });
}
في المشاريع الواقعية، السياسات غالبًا أفضل من الاعتماد على الرولات وحدها، لأنها تمنحك تعبيرًا أوضح عن القاعدة الأمنية نفسها. بدل أن تقول “هنا Admin فقط”، قد تقول “هنا يُسمح لمن يملك صفة X وY معًا”، وهذا أكثر مرونة وأدق في المشاريع الكبيرة.
كيف يعمل التوكن من الداخل؟
JWT عادة يتكون من ثلاثة أجزاء: header وpayload وsignature. الـ header يصف نوع الرمز وخوارزمية التوقيع. الـ payload يحمل الـ claims. أما signature فهي التي تضمن أن التوكن لم يتغير. لا حاجة هنا لأن يتحول JWT إلى جلسة server-side كاملة، لأن الخادم يستطيع إعادة التحقق من التوقيع في كل طلب. وهذا يفسر لماذا Authorization: Bearer هو الأسلوب الشائع مع JWT bearer authentication في ASP.NET Core.
لكن يجب أن تتذكر شيئًا مهمًا: JWT ليس مشفّرًا بطبيعته، بل هو موقّع. بمعنى آخر، أي شخص يستطيع فك ترميز الـ payload ورؤية محتواه، لذلك لا تضع داخله معلومات حساسة مثل كلمات المرور أو رموز إعادة التعيين أو بيانات شخصية لا تريد كشفها. ضع فقط ما تحتاجه لتحديد الهوية أو منح التفويض. هذه قاعدة ذهبية في أي مشروع حقيقي.
إعداد التحقق من الصلاحية ووقت الانتهاء
من الأشياء التي يجب ألا تهملها أبدًا هي ValidateLifetime. عندما تضبطه على true، فأنت تقول للنظام: لا تقبل التوكن المنتهي. وإضافة ClockSkew صغيرة، مثل دقيقة واحدة، مفيدة لتخفيف فروقات الوقت بين الأجهزة والخوادم. كثير من المشاكل التي يواجهها المطورون في البداية تأتي من نسيان وقت الانتهاء أو ضبطه بشكل غير منطقي.
في مشروعنا، جعلنا صلاحية التوكن 60 دقيقة. هذا رقم شائع كبداية، لكنه ليس قاعدة ثابتة. بعض الأنظمة تستخدم 15 دقيقة أو 30 دقيقة، ثم تعتمد على refresh token أو آلية تجديد أخرى. اختيار المدة يعتمد على حساسية النظام، وتكرار الاستخدام، وتجربة المستخدم، وسياسة الأمان التي تريدها. المهم أن تكون واعيًا بأن زيادة المدة كثيرًا ترفع المخاطر، وتقليلها جدًا قد يسبب إزعاجًا للمستخدمين.
أين يضع العميل الـ JWT؟
في تطبيقات الـ API، الشائع جدًا هو إرسال JWT داخل ترويسة Authorization باستخدام Bearer. هذا ما يتعامل معه AddJwtBearer مباشرة في ASP.NET Core، حيث يتم استخراج الرمز من هذا الهيدر والتحقق منه.
مثال طلب من الواجهة الأمامية:
GET /api/profile/me HTTP/1.1
Host: localhost:5001
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
إذا كان التوكن صالحًا، سيصل الطلب إلى الـ action المحمي، وإذا لم يكن صالحًا، سيُرفض الطلب تلقائيًا قبل تنفيذ المنطق الداخلي. هذا يخفف الكثير من الكود اليدوي ويجعل الحماية متسقة في كامل التطبيق.
ماذا عن تطبيقات الويب التقليدية؟
هنا تبدأ التفاصيل المهمة التي يقع فيها كثيرون. في التطبيقات التي يعمل فيها المتصفح كواجهة رئيسية، ليس دائمًا من الأفضل تخزين access token في local storage أو session storage، خصوصًا إذا كانت لديك مخاوف من XSS أو رغبت في تقليل مساحة المخاطر. توضح Microsoft في توجيهاتها الخاصة بالتطبيقات الآمنة أن backend يمكنه تخزين access tokens على خادم موثوق، مع مشاركة cookie آمنة HttpOnly مع المتصفح، وهذا ينسجم أكثر مع السيناريوهات التي تعتمد على OIDC بدل إدارة token في المتصفح مباشرة.
هذا لا يعني أن JWT سيئ، بل يعني أن طريقة تخزينه واستخدامه يجب أن تتوافق مع نوع التطبيق. إن كنت تبني API تُستهلك من تطبيقات خارجية أو موبايل، فالبيرير توكن هو الاختيار الطبيعي غالبًا. أما إن كنت تبني web app تقليدية بواجهة تعتمد على المتصفح فقط، فقد تحتاج إلى تقييم خيارات أخرى أيضًا.
مثال على اختبار النظام
بعد تشغيل التطبيق، جرّب تسجيل مستخدم جديد:
POST /api/auth/register
Content-Type: application/json
{
"userName": "mohamed",
"email": "mohamed@example.com",
"password": "Pass@12345"
}
ثم جرّب تسجيل الدخول:
POST /api/auth/login
Content-Type: application/json
{
"userName": "mohamed",
"password": "Pass@12345"
}
ستحصل على استجابة تحتوي على accessToken. انسخ هذا التوكن ثم أرسله إلى endpoint المحمي:
GET /api/profile/me
Authorization: Bearer YOUR_ACCESS_TOKEN
إذا تم إعداد كل شيء بشكل صحيح، ستحصل على بيانات المستخدم من الـ claims، ويمكنك عندها أن تبني باقي المنطق حولها. هذه اللحظة عادة هي أجمل لحظة في التطبيق كله، لأنك ترى النظام يعمل من البداية إلى النهاية بدون تعقيد زائد.
تتبع الأخطاء الشائعة
أحد أكثر الأخطاء شيوعًا هو نسيان استدعاء app.UseAuthentication() قبل app.UseAuthorization() أو وضعهما في ترتيب غير صحيح داخل الـ pipeline. خطأ آخر شائع هو استخدام مفتاح سري قصير جدًا أو تغيير الـ issuer/audience في التوكن عن القيم المستخدمة للتحقق. أيضًا، قد ينسى البعض وضع ValidateIssuerSigningKey = true أو ValidateLifetime = true، فيصبح التحقق ناقصًا أو غير آمن.
خطأ آخر مزعج هو أن تتوقع أن [Authorize] يعمل من تلقاء نفسه بدون إعداد المصادقة. [Authorize] يحدد أن الوصول مقيد، لكنه يعتمد على وجود authentication scheme فعّال ومُعدّ جيدًا. لذلك، لا يكفي أن تضع attribute على الـ controller؛ يجب أن يكون الـ bearer middleware مضبوطًا صحيحًا في البداية. هذا الجزء متسق مع طريقة عمل authentication/authorization في ASP.NET Core نفسها.
هل أستخدم ASP.NET Core Identity أم أكتب النظام بنفسي؟
هذا سؤال مهم جدًا. إن كنت تبني مشروعًا صغيرًا جدًا أو تريد التعلم، فكتابة النظام بنفسك مفيدة جدًا لأنها تمنحك فهمًا عميقًا. أما إذا كنت في مشروع إنتاجي، فغالبًا ستستفيد من ASP.NET Core Identity أو طبقة هوية أكثر نضجًا، ثم تضيف فوقها JWT إن كان السيناريو يتطلب ذلك. Identity يسهّل كثيرًا إدارة المستخدمين، التحقق، إعادة تعيين كلمة المرور، الأدوار، والعديد من التفاصيل الحساسة. ومن ناحية أخرى، JWT يناسبك عندما تكون هناك حاجة واضحة لواجهة API stateless أو لتطبيقات متعددة المستهلكين. Microsoft تضع Identity ضمن منظومة الأمن في ASP.NET Core كخيار أساسي لإدارة الهوية، لذلك من الطبيعي جدًا أن تجد نفسك تمزج بينهما بدل أن تختار أحدهما فقط.
يمكنك مثلًا أن تجعل Identity يتولى تخزين المستخدمين والتحقق من كلمات المرور، ثم بعد نجاح تسجيل الدخول تُصدر JWT موقّعًا يحمل claims مناسبة. هذه من أكثر المعماريات العملية انتشارًا، لأنها تجمع بين إدارة الهوية الجيدة والمرونة العالية في الاستهلاك عبر APIs.
مثال نسخة أكثر واقعية مع refresh token
في المشاريع الجادة، لن تعتمد فقط على access token قصير العمر. غالبًا ستضيف refresh token حتى يتمكن المستخدم من تجديد الجلسة دون إعادة تسجيل الدخول كل مرة. هذا لا يعني أن JWT نفسه فشل، بل يعني أنك تريد فصل “الهوية قصيرة العمر” عن “آلية التجديد طويلة العمر”. يمكنك حينها أن تجعل access token صالحًا لمدة قصيرة، بينما يحتفظ الخادم أو قاعدة البيانات بسجل refresh tokens يمكن إبطالها عند الحاجة.
مثال مبسّط جدًا:
public class RefreshToken
{
public string Token { get; set; } = string.Empty;
public DateTime ExpiresAtUtc { get; set; }
public bool IsRevoked { get; set; }
public string UserId { get; set; } = string.Empty;
}
ثم تضيف endpoint مثل /api/auth/refresh يستقبل refresh token صالحًا ويصدر access token جديدًا. هذه الخطوة تجعل النظام أكثر مرونة، خصوصًا إذا كان لديك تطبيقات تعمل لفترات طويلة. لكن لا تخلط بين access token وrefresh token؛ فلكل واحد دور مختلف تمامًا.
نصائح أمان لا تتنازل عنها
القاعدة الأولى هي HTTPS دائمًا. أي نظام JWT يعمل عبر HTTP غير المشفّر يعرضك لمخاطر كبيرة. القاعدة الثانية هي أن المفتاح السري يجب أن يكون قويًا ومخزنًا خارج الكود المصدر. القاعدة الثالثة هي عدم وضع بيانات حساسة داخل الـ token نفسه. القاعدة الرابعة هي ضبط issuer وaudience والتحقق منهما. والقاعدة الخامسة هي التفكير في الإلغاء والتجديد وعدم الاكتفاء بالتوقيع فقط.
ومن الناحية العملية أيضًا، لا ترسل رسائل خطأ تفصيلية جدًا في الإنتاج. في التطوير قد يكون من المفيد أن تعرف سبب الرفض بالتحديد، لكن في الإنتاج الأفضل أحيانًا أن تُبقي الرسائل عامة نسبيًا، حتى لا تمنح المهاجم معلومات تساعده على استنتاج ما الذي فشل بالضبط. هذه ليست مبالغة، بل جزء من تقليل سطح الهجوم.
كيف أضيف claims مخصصة؟
أحيانًا لا يكفيك الاسم والبريد والدور. قد تحتاج إلى tenantId أو department أو subscriptionLevel أو isVerified. في هذه الحالة أضف claim مخصصة بسهولة داخل التوكن. الفكرة أن الـ claims هي طريقة للتعبير عن الخصائص التي يحتاجها نظامك ليقرر.
claims.Add(new Claim("tenantId", user.TenantId));
claims.Add(new Claim("subscriptionLevel", user.SubscriptionLevel));
ثم يمكنك قراءتها لاحقًا:
var tenantId = User.FindFirst("tenantId")?.Value;
لكن لا تفرط في عدد الـ claims. كل claim تضيفها يجب أن يكون لها سبب واضح. التوازن مهم جدًا بين الفائدة والبساطة.
كيف أتعامل مع أنظمة متعددة المستويات؟
لو كان تطبيقك يحتوي على إدارة، ومستخدمين عاديين، ومراجعين، ودعم فني، وشركاء خارجيين، فمن الأفضل ألا تظل عالقًا في منطق Admin/User فقط. استخدم أدوارًا واضحة، ثم أضف Policies عندما تصبح العلاقات أكثر تعقيدًا. في ASP.NET Core، التفويض policy-based يعطيك وسيلة أنظف وأكثر توسعًا من الحلول اليدوية المتفرقة. هذا ما توضحه Microsoft بشكل مباشر في وثائقها الخاصة بالتفويض.
مثال:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanViewFinancialReports", policy =>
policy.RequireClaim("department", "Finance"));
});
وهكذا يصبح القرار الأمني جزءًا من سياسة صريحة بدل أن يكون شرطًا مبعثرًا داخل الكود.
ماذا يحدث إذا انتهت صلاحية التوكن؟
عندما تنتهي صلاحية JWT، يفشل middleware في اعتماده كمصدر صالح للمصادقة. عندها ستتلقى غالبًا استجابة 401 Unauthorized. هذا السلوك طبيعي ومتوقع. مهم هنا أن تميّز بين 401 و403: الأول يعني أن الهوية غير معروفة أو غير موثوقة، والثاني يعني أن الهوية معروفة لكن لا تملك الصلاحية المطلوبة. هذا الفرق البسيط ظاهريًا مهم جدًا عند تصميم واجهات الإدارة وعمليات الدعم والتعامل مع الرسائل المعروضة للمستخدمين. في ASP.NET Core، من الطبيعي أن يمر الطلب أولًا على المصادقة، ثم على التفويض، ثم يُسمح له بالوصول أو يُرفض بناءً على النتيجة.
ماذا عن تجربة المطور أثناء التطوير المحلي؟
Microsoft توفر أداة dotnet user-jwts لتوليد وإدارة JWTs مخصّصة للمشروع أثناء التطوير المحلي، وهي مفيدة جدًا عندما تريد اختبار endpoints محمية بسرعة من دون بناء كل طبقة المصادقة يدويًا في كل مرة. الأداة تستطيع إنشاء التوكنات، عرضها، حذفها، وإدارة مفاتيح التوقيع الخاصة بالتطوير المحلي. هذه ليست بديلًا عن نظام الإنتاج، لكنها اختصار عملي ممتاز أثناء البناء والاختبار.
في الواقع، هذا النوع من الأدوات يذكّرك بأن مرحلة التطوير لا تحتاج دائمًا إلى كل التعقيد الإنتاجي الكامل. أحيانًا يكفيك نموذج محلي قوي وواضح يسرّع الفهم والتجربة، ثم تنتقل منه إلى البنية النهائية عندما يكتمل النظام.
بناء تجربة تسجيل دخول جيدة لا يعني فقط الكود
الناس كثيرًا ما ينظرون إلى تسجيل الدخول على أنه مجرد endpoint يعيد token. لكن في الحقيقة، تجربة تسجيل الدخول هي جزء من شخصية التطبيق. هل رسائل الأخطاء واضحة؟ هل التحقق سريع؟ هل التوكن قصير بما يكفي للحماية وطويل بما يكفي لراحة المستخدم؟ هل يوجد refresh token؟ هل الأدوار واضحة؟ هل صفحة الإدارة محمية؟ هل logging والتتبع متوفران؟ كل هذه الأسئلة تؤثر في جودة المنتج بقدر تأثير الكود نفسه.
والأجمل في ASP.NET Core أنه يمنحك هذه الأدوات بطريقة متماسكة. المصادقة مبنية على middleware وخدمات مخصصة، والتفويض مبني على claims وroles وpolicies، وJWT bearer authentication ينسجم مع APIs الحديثة بشكل طبيعي. هذا التكامل هو الذي يجعل المنصة مفضلة لدى كثير من المطورين عندما يبنون أنظمة enterprise أو خدمات مستقلة.
نسخة أكثر تنظيمًا للكود
عندما يكبر المشروع، ستلاحظ أن وضع كل شيء داخل Program.cs أو Controller واحد يصبح مزعجًا. في هذه المرحلة من الأفضل تنظيم الكود إلى طبقات: Services لتوليد التوكنات وإدارة المستخدمين، Models للكيانات وDTOs، Controllers للنقاط النهائية، وOptions أو Configuration للإعدادات. هذا التنظيم لا يغير الفكرة الأمنية، لكنه يجعل الصيانة أسهل جدًا ويقلل الأخطاء عند التوسع.
يمكنك أيضًا إضافة middleware مخصص للتسجيل logging، أو فلتر استثنائات، أو حتى طبقة domain validation تمنع البيانات غير الصحيحة من الوصول إلى مرحلة إصدار التوكن أصلًا. وكلما أصبح النظام أكبر، كلما كانت هذه الطبقات الصغيرة هي الفرق بين مشروع منظم ومشروع متعب.
مثال لصفحة أو تطبيق عميل يستهلك التوكن
بعد أن يحصل العميل على token، يخزنه بالطريقة المناسبة للمنصة التي تستخدمها. في تطبيق React مثلًا قد ترسله مع fetch أو axios في الهيدر. وفي تطبيق موبايل قد تحتفظ به في storage آمن خاص بالمنصة. المهم أن يبقى الهيدر بالشكل الصحيح، لأن ASP.NET Core يتوقع التوكن ضمن Authorization ويقوم باستخراجه منه مباشرة عند استخدام AddJwtBearer.
const response = await fetch('/api/profile/me', {
headers: {
Authorization: `Bearer ${token}`
}
});
const data = await response.json();
console.log(data);
لا تنس أن مبدأ الحماية لا ينتهي عند الخادم فقط. العميل نفسه يجب أن يُصمم بحيث لا يعرّض token للخطر أكثر من اللازم.
أخطاء شائعة في المشاريع الواقعية
من الأخطاء التي تتكرر كثيرًا وضع token طويل العمر جدًا دون refresh strategy. أو الاعتماد على role واحدة لكل شيء، ثم الاكتشاف متأخرًا أن النظام يحتاج إلى سياسات أكثر تعبيرًا. أو تخزين token في المكان الخطأ. أو نسيان دعم logout منطقي عبر إبطال refresh token إن كان موجودًا. أو إرسال claims كثيرة جدًا بلا حاجة. أو تجاهل مسألة expiration. أو استخدام مفتاح ضعيف. أو جعل الـ API تعتمد على token production لكن بدون plan واضح للتدوير key rotation. هذه كلها أخطاء معروفة، وتكلفتها ترتفع كلما كبر التطبيق.
بعض المطورين أيضًا يعتقد أن JWT يعني “لا حاجة لأي شيء آخر”. هذا تصور غير دقيق. JWT يحل جانبًا مهمًا جدًا، لكنه لا يلغي الحاجة إلى إدارة المستخدمين، تغيير كلمات المرور، حماية endpoint، مراقبة السجلات، والسيطرة على الأدوار والسياسات. لذلك، انظر إليه كجزء من منظومة الهوية، لا كبديل عنها.
متى يكون JWT خيارًا ممتازًا فعلًا؟
JWT يكون ممتازًا عندما تكون لديك APIs تستهلكها عدة واجهات، أو تطبيقات منفصلة، أو خدمات موزعة، أو عندما تريد فصل الهوية عن الواجهة. كما يكون جيدًا عندما تحتاج إلى نقل claims مع كل طلب بطريقة مستقلة. وفي ASP.NET Core، هذا النمط مدعوم بشكل طبيعي عبر JwtBearer والـ authorization attributes والسياسات.
أما إذا كان تطبيقك موقعًا تقليديًا يعمل فقط في المتصفح، ويحتاج إلى حماية شديدة مع إدارة جلسات تقليدية، فربما يكون النهج القائم على cookies وserver-backed session أو OIDC أنسب. وهذا لا يتعارض مع JWT، بل يوضح أن الحل الصحيح يعتمد على نوع التطبيق وليس على الموضة التقنية.
مثال نهائي متكامل
لنفترض الآن أنك تريد نظرة سريعة تجمع الصورة كاملة. لديك AuthController يستقبل login. إذا كانت البيانات صحيحة، يستدعي JwtTokenService. هذه الخدمة تنشئ token موقّعًا يحتوي على claims مهمة. ثم العميل يرسل هذا token في Authorization. بعدها يتولى AddJwtBearer التحقق من التوكن. إذا نجح التحقق، يستطيع [Authorize] السماح للوصول إلى الأكشن. وإذا كان الدور مناسبًا أو السياسة مستوفاة، يحصل المستخدم على المورد الذي يريده. هذا هو المسار الكامل ببساطة، لكنه في الحقيقة واحد من أنظف أنماط المصادقة في التطبيقات الحديثة.
خاتمة
بناء نظام تسجيل دخول باستخدام JWT في ASP.NET Core ليس مجرد خطوة تقنية، بل هو قرار معماري يؤثر في طريقة بناء التطبيق كله. عندما تفهم الفرق بين authentication وauthorization، وعندما تتقن إعداد AddJwtBearer، وعندما تعرف متى تستخدم [Authorize] ومتى تستخدم roles أو policies، ستصبح قادرًا على بناء نظام هوية متين، واضح، وقابل للتوسع. كما أن انتباهك للتفاصيل الأمنية مثل HTTPS، وتخزين المفاتيح، والتحقق من issuer/audience، وعدم وضع معلومات حساسة داخل التوكن، سيجعل تطبيقك أقرب إلى الممارسات الصحيحة التي تنصح بها وثائق Microsoft الرسمية.
والأهم من كل ذلك: لا تتعامل مع JWT كحيلة سريعة لتجاوز المصادقة، بل كأداة هندسية تحتاج إلى احترام قواعدها. عندما تبنيها بعقلية صحيحة، ستمنحك بساطة كبيرة في الاستهلاك، ومرونة في التوسع، وتجربة جيدة للمطورين والعملاء على حد سواء. أما عندما تُهمل تفاصيلها، فستتحول من حل أنيق إلى مصدر إزعاج يصعب تتبعه. ولهذا السبب تحديدًا، من الجميل أن تبدأ بفهم الأساسيات ثم تبني فوقها بهدوء وثقة، خطوة خطوة، حتى يصبح نظام الدخول لديك جزءًا طبيعيًا ومتينًا من تطبيق ASP.NET Core الخاص بك.