احراز هویت و دسترسیسنجی API: بررسی فنی و عمیق مکانیزمها، مبادلات و حالتهای خطا
هر API به شکلی از احراز هویت مجهز است. اما داشتن احراز هویت با پیادهسازی درست آن دو مقوله کاملاً متفاوت هستند. من سیستمهای پروداکشنی را بررسی کردهام که در آنها توکنهای JWT تاریخ انقضا نداشتند. سیستمهایی که
هر API به شکلی از احراز هویت مجهز است. اما داشتن احراز هویت با پیادهسازی درست آن دو مقوله کاملاً متفاوت هستند.
من سیستمهای پروداکشنی را بررسی کردهام که در آنها توکنهای JWT تاریخ انقضا نداشتند. سیستمهایی که کلیدهای API آنها بهصورت هاردکد در سورسکد قرار داشت و روی مخازن عمومی گیت پوش میشد. سیستمهایی که در آنها URIهای ریدایرکت OAuth از کاراکترهای عام (wildcard) استفاده میکردند. سیستمهایی که برای APIهای مالی از احراز هویت پایه (Basic Auth) روی بستری که قرار بود HTTPS باشد استفاده میکردند، بدون اینکه کسی آن را بررسی کرده باشد.
تکتک این موارد آسیبپذیریهای زندهای بودند که در کمین سوءاستفاده قرار داشتند.
بیشتر این مشکلات ناشی از بیاحتیاطی مهندسان نبود. آنها از مهندسانی سرچشمه میگرفتند که میدانستند چگونه یک مکانیزم را کار بیندازند، اما حالتهای خرابی و خطای آن را درک نمیکردند. هیچکس به آنها نگفته بود وقتی سیستم از کار میافتد چه اتفاقی میافتد. هیچکس استاندارد سازمانی را تعریف نکرده بود. آنها چیزی را که میشناختند انتخاب کردند، آن را به اندازه کافی خوب پیادهسازی کردند تا بررسی کد (code review) را پاس کند و جلو رفتند.
این مقاله درباره تغییر دادن همین روند است. نه فقط نحوه کارکرد هر مکانیزم، بلکه اینکه چه زمانی از آن استفاده کنیم، چه زمانی از آن استفاده نکنیم و دقیقاً چگونه در محیط پروداکشنی با شکست مواجه میشود.
پیش از هر چیز دیگری، بیایید ابهامی را که باعث ایجاد آسیبپذیریهای واقعی میشود برطرف کنیم. احراز هویت پاسخ میدهد: شما چه کسی هستید؟ دسترسیسنجی (Authorization) پاسخ میدهد: چه کارهایی اجازه دارید انجام دهید؟
سیستمی که احراز هویت را به شکل بینقص ولی دسترسیسنجی را به شکل ضعیفی انجام دهد، همچنان دادههای غیرمجاز را ارائه خواهد داد. سیستمی که دسترسیسنجی را عالی ولی احراز هویت را ضعیف انجام دهد، بهسادگی دور زده میشود. هر دو باید به طور مستقل صحیح باشند.
فهرست مطالب
- پیشنیازها
- پایه و اساس: مواردی که پیش از انتخاب مکانیزم باید به درستی رعایت کنید
- ۱. احراز هویت پایه (Basic Authentication)
- ۲. کلیدهای API
- ۳. احراز هویت توکن حامل (Bearer Token)
- ۴. توکن وب JSON - JWT
- ۵. پروتکل OAuth 2.0
- ۶. پروتکل OpenID Connect (OIDC)
- ۷. تیالاس متقابل (mTLS)
- انتخاب مکانیزم مناسب
- نظم سازمانی که همهچیز را در کنار هم نگه میدارد
- نتیجهگیری
پیشنیازها
قبل از خواندن این مقاله، باید با موارد زیر آشنایی راحت داشته باشید:
- اینکه API چیست و درخواستها و پاسخهای HTTP چگونه کار میکنند
- درک اولیهای از اینکه توکن یا نشست (session) چیست
- مفاهیم کلی معماری نرمافزار: اینکه درگاه (gateway) چیست و لایه سرویس (service layer) چه مفهومی دارد
- آشنایی با سینتکس زبانهای دارت (Dart) یا سیشارپ (C#)
شما نیازی به پیشینه امنیتی ندارید. هر مفهومی در اینجا از دیدگاه مهندسی توضیح داده شده است.
پایه و اساس: مواردی که پیش از انتخاب مکانیزم باید به درستی رعایت کنید
پیش از اینکه حتی به این فکر کنید که از کدام مکانیزم استفاده کنید، سه چیز باید برقرار باشد. اگر این موارد وجود نداشته باشند، هیچ مکانیزمی نمیتواند شما را نجات دهد.
۱. پروتکل TLS اختیاری نیست
هر API از طریق HTTPS ارتباط برقرار میکند. تمام نقطهاتصالها (endpoints) و محیطها، نه فقط محیط پروداکشن. نه فقط نقطهاتصالهایی که با شماره کارتها سر و کار دارند. بلکه همه آنها.
بدون TLS، تکتک مکانیزمهای موجود در این مقاله قابل شنود و رهگیری هستند. توکنهای Basic Auth، کلیدهای API، توکنهای حامل و JWT همگی در هدرهای HTTP منتقل میشوند. هدرهای HTTP بدون TLS متن ساده (plaintext) هستند.
زیرساخت شما باید حداقل از TLS 1.2 پشتیبانی و آن را اعمال کند. نسخههای TLS 1.0 و 1.1 دارای آسیبپذیریهای شناختهشدهای هستند. SSLv3 به طرز فاجعهباری شکسته شده است. اگر کلاکری (client) تلاش کند نسخه قدیمی تری از پروتکل را مذاکره کند، اتصال باید در لایه زیرساخت رد شود. این یک تصمیم پیکربندی است، نه چیزی که بخواهید آن را در کد مدیریت کنید.
۲. APIهای پروداکشن نباید بدون کنترلهای مناسب روی ابزارهای عمومی قابل دسترس باشند
یک API پروداکشن که از طریق اینترنت و به کمک ابزارهایی مثل Postman یا Swagger بدون کنترلهای دسترسی مناسب قابل دسترسی باشد، مشکلی است که دیر یا زود رخ خواهد داد.
توسعه و تست باید از محیطهای اختصاصی با مشخصات کاربری مجزا استفاده کنند که هیچگونه دسترسی به دادههای پروداکشن ندارند.
۳. دادههای پروداکشن نباید به محیطهای توسعه یا تست کپی شوند
این فقط یک رویه خوب نیست. طبق قانون NDPA 2023 نیجریه، دادههای شخصی باید صرفاً برای اهداف مشخص، صریح و مشروع پردازش شوند. کپی کردن دادههای شخصی پروداکشن در محیط توسعه، ریسک مستقیم قانونی ایجاد میکند. بر اساس استاندارد PCI-DSS، هر محیطی که دادههای دارنده کارت را ذخیره، پردازش یا منتقل کند، در محدوده ارزیابی انطباق قرار میگیرد.
پاسخ مهندسی این است: دادههای آزمایشی مصنوعی و مجموعه دادههای ناشناسشده در تمام محیطهای غیرتولیدی، همیشه.
با در نظر گرفتن این موارد، در ادامه ۷ مکانیسم آورده شده است.
۱. احراز هویت پایه (Basic Authentication)
نحوه کارکرد
کلاینت یک نام کاربری و رمز عبور را در هر درخواست ارسال میکند. این اطلاعات با فرمت username:password ترکیب شده، در قالب Base64 کدگذاری میشوند و در هدر Authorization قرار میگیرند.
Authorization: Basic am9objpzZWNyZXQxMjM=
آن رشته کدگذاریشده به صورت john:secret123 در Base64 است. Base64 رمزگذاری نیست؛ بلکه یک روش کدگذاری است. هر کسی که آن هدر را رهگیری کند، میتواند با استفاده از هر ابزار کدگشای Base64 که به صورت آنلاین موجود است، آن را در چند ثانیه رمزگشایی کند.
دارت:
// server-side basic auth validation
String? extractBasicAuthCredentials(Request request) {
final authHeader = request.headers['authorization'];
if (authHeader == null || !authHeader.startsWith('Basic ')) return null;
final encoded = authHeader.substring(6);
final decoded = utf8.decode(base64.decode(encoded));
return decoded;
}
Handler basicAuthMiddleware(Handler handler, UserService userService) {
return (Request request) async {
final credentials = extractBasicAuthCredentials(request);
if (credentials == null) {
return Response.unauthorized(
'Missing credentials',
headers: {'WWW-Authenticate': 'Basic realm="API"'},
);
}
final parts = credentials.split(':');
if (parts.length != 2) return Response.unauthorized('Invalid credentials');
final isValid = await userService.validateCredentials(parts[0], parts[1]);
if (!isValid) return Response.unauthorized('Invalid credentials');
return handler(request);
};
}
C#:
public class BasicAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private readonly IUserService _userService;
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.ContainsKey("Authorization"))
return AuthenticateResult.Fail("Missing Authorization header");
var authHeader = Request.Headers["Authorization"].ToString();
if (!authHeader.StartsWith("Basic "))
return AuthenticateResult.Fail("Invalid Authorization scheme");
var encoded = authHeader.Substring(6);
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(encoded));
var parts = decoded.Split(':');
if (parts.Length != 2)
return AuthenticateResult.Fail("Invalid credentials format");
var isValid = await _userService.ValidateCredentials(parts[0], parts[1]);
if (!isValid)
return AuthenticateResult.Fail("Invalid credentials");
var claims = new[] { new Claim(ClaimTypes.Name, parts[0]) };
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
}
مشکل اصلی احراز هویت پایه
اطلاعات ورود در هر درخواست جابهجا میشوند. تنها یک درخواست رهگیریشده، نام کاربری و رمز عبور را برای همیشه در اختیار مهاجم قرار میدهد. هیچ تاریخ انقضایی وجود ندارد. هیچ راهکار ابطالی به جز تغییر رمز عبور وجود ندارد. Base64 هیچ امنیت واقعیای فراهم نمیکند.
نداشتن محدودیت نرخ (Rate Limiting) در یک نقطه پایانی احراز هویت پایه، دعوتی آشکار برای حملات بروتفورس (Brute Force) است. مهاجمی که لیستی از رمزهای عبور رایج را در اختیار دارد، تکتک آنها را به صورت سیستماتیک امتحان خواهد کرد. اگر مانعی وجود نداشته باشد، بالاخره به سیستم نفوذ خواهد کرد.
چه زمانی از آن استفاده کنیم
احراز هویت پایه برای ارتباطات داخلی سرور با سرور در یک محیط کاملاً کنترلشده که در آن اتصال همیشه رمزگذاری شده است و رابط API در معرض اینترنت قرار ندارد، کارآمد است. هرگز از آن برای APIهای سمت کاربر، مواردی که با دادههای حساس سر و کار دارند، یا بدون پروتکل TLS استفاده نکنید.
۲. کلیدهای API (API Keys)
یک کلید API به سادگی یک رشته محرمانه و یکتا است که سرور برای شناسایی کلاینت به آن اختصاص میدهد. وقتی برای استفاده از یک سرویس شخص ثالث مانند درگاه پرداخت یا پلتفرم پیامکی ثبتنام میکنید، به شما یک کلید داده میشود. هر درخواستی که اپلیکیشن شما به آن سرویس ارسال میکند شامل آن کلید است تا سرویس متوجه شود درخواست از سوی شماست، بتواند میزان استفاده شما را ردیابی کند و مجوزها و محدودیتهای نرخ مناسب را روی درخواستهای شما اعمال نماید.
این کلید به یک کاربر خاص گره نخورده است، بلکه به اپلیکیشن شما تعلق دارد. این تفاوت اساسی بین یک کلید API و یک توکن احراز هویت کاربر است.
نحوه کارکرد
یک رشته محرمانه ایستا به کلاینت صادر میشود و در هر درخواست درون یک هدر ارسال میگردد.
X-API-Key: sk_live_abc123xyz
دارت:
class ApiKeyService {
final ApiKeyRepository _repository;
ApiKeyService(this._repository);
Future<Result<ApiKeyContext, AppException>> validateApiKey(
String apiKey,
String callerDomain,
) async {
final keyRecord = await _repository.findByKey(apiKey);
if (keyRecord == null) {
return Result.failure(AppException.unauthorized('Invalid API key'));
}
if (keyRecord.isExpired) {
return Result.failure(AppException.unauthorized('API key has expired'));
}
if (keyRecord.isRevoked) {
return Result.failure(AppException.unauthorized('API key has been revoked'));
}
// validate that the calling domain is allowed for this key
if (!keyRecord.allowedDomains.contains(callerDomain)) {
return Result.failure(
AppException.forbidden('Calling domain not authorized for this API key'),
);
}
return Result.success(ApiKeyContext(
clientId: keyRecord.clientId,
environment: keyRecord.environment,
allowedScopes: keyRecord.allowedScopes,
));
}
}
Handler apiKeyMiddleware(Handler handler, ApiKeyService apiKeyService) {
return (Request request) async {
final apiKey = request.headers['x-api-key'];
if (apiKey == null || apiKey.isEmpty) {
return Response(401, body: jsonEncode({'error': 'API key required'}));
}
final origin = request.headers['origin'] ?? request.headers['host'] ?? '';
final result = await apiKeyService.validateApiKey(apiKey, origin);
if (result.isFailure) {
return Response(403, body: jsonEncode({'error': result.error?.message}));
}
return handler(request);
};
}
C#:
public class ApiKeyMiddleware
{
private readonly RequestDelegate _next;
private readonly IApiKeyService _apiKeyService;
public async Task InvokeAsync(HttpContext context)
{
if (!context.Request.Headers.TryGetValue("X-API-Key", out var apiKey))
{
context.Response.StatusCode = 401;
await context.Response.WriteAsync("API key required");
return;
}
var callerDomain = context.Request.Headers["Origin"].ToString()
?? context.Request.Host.Value;
var result = await _apiKeyService.ValidateApiKey(apiKey, callerDomain);
if (!result.IsSuccess)
{
context.Response.StatusCode = 403;
await context.Response.WriteAsync(result.Error);
return;
}
context.Items["ApiKeyContext"] = result.Value;
await _next(context);
}
}
مشکل اصلی کلیدهای API
کلیدهای API ایستا هستند. آنها به طور خودکار منقضی نمیشوند. کلیدی که در یک مخزن عمومی گیتهاب، یک پیام اسلک یا یک فایل لاگ پیدا شود، یک اعتبارنامه فعال محسوب میشود تا زمانی که کسی متوجه شده و آن را تغییر دهد (چرخش کلید).
یکی از رایجترین کاستیهایی که در سازمانهای بزرگ مشاهده کردهام این است که APIهایی که توسط چندین کلاینت فراخوانی میشوند، هیچ لیست مجاز دامنهای (Allowlisting) ندارند. یک کلید یکسان از هر دامنهای کار میکند. این امر اشتراکگذاری کلیدها را در بین کلاینتها و محیطهای مختلف امکانپذیر میکند؛ به این معنا که یک کلید محیط استیجینگ میتواند محیط پروداکشن را صدا بزند، یک کلید موبایل میتواند از یک کلاینت وب استفاده شود، و هیچ مرز واقعی بین آنها وجود ندارد.
هیچ کلید API نباید برای چندین کلاینت یا چندین محیط کار کند. کلیدها باید به دامنههای مجاز خاص و محیطهای مشخص متصل شوند. این موضوع اختیاری نیست.
هرگز کلیدهای API را به صورت کدگذاریشدهی سخت (Hardcode) در کد منبع قرار ندهید. هرگز آنها را در گیت Commit نکنید. فایل gitignore کافی نیست. کلیدها باید در زمان اجرا از یک مدیریتکننده رمز عبور مطمئن فراخوانی شوند: مانند Vaults، تنظیمات اپلیکیشن آژور (Azure App Configuration) یا مدیریت رازهای AWS. چرخش (Rotation) کلیدهای API باید یک رویه برنامهریزیشده سازمانی باشد، نه واکنشی به یک نشت احتمالی.
چه زمانی از آن استفاده کنیم
از کلیدهای API برای ارتباطات سرور با سرور، APIهای عمومی که در آنها نیاز به شناسایی و اعمال محدودیت نرخ روی فراخوانندگان دارید، و ابزارها و یکپارچهسازیهای توسعهدهندگان استفاده کنید. هر سناریویی که در آن یک کاربر انسانی فراخواننده مستقیم نباشد.
۳. احراز هویت توکن حامل (Bearer Token Authentication)
نحوه کارکرد
کلاینت یک بار از طریق روند ورود به سیستم احراز هویت کرده و یک توکن دریافت میکند. آن توکن در تمامی درخواستهای بعدی در هدر Authorization جابهجا میشود.
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
پس از ورود اولیه، هیچ اطلاعات کاربری دیگری ارسال نمیشود. این توکن است که ارسال میگردد. سرور توکن را در هر درخواست اعتبارسنجی میکند.
دارت:
class BearerTokenMiddleware {
final TokenValidator _validator;
BearerTokenMiddleware(this._validator);
Handler call(Handler handler) {
return (Request request) async {
final authHeader = request.headers['authorization'];
if (authHeader == null || !authHeader.startsWith('Bearer ')) {
return Response(401, body: jsonEncode({'error': 'Bearer token required'}));
}
final token = authHeader.substring(7);
final validationResult = await _validator.validate(token);
if (validationResult.isFailure) {
return Response(401, body: jsonEncode({'error': validationResult.error?.message}));
}
final updatedRequest = request.change(
context: {'auth_claims': validationResult.value},
);
return handler(updatedRequest);
};
}
}
C#:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!)
),
ClockSkew = TimeSpan.Zero
};
});
مشکل اصلی توکنهای حامل
سرقت توکن بزرگترین مشکل است. اگر مهاجمی به یک توکن حامل معتبر دست پیدا کند، میتواند تا زمان انقضا یا ابطال دستی از آن استفاده کند. به همین دلیل است که انقضای توکن یک قابلیت لوکس نیست، بلکه عاملی است که هنگام به خطر افتادن یک توکن، میزان خسارت را محدود میکند. توکنهای دسترسی با عمر کوتاه به همراه چرخش توکنهای بازنشانی (Refresh Token Rotation) الگوی صحیح است.
چه زمانی از آن استفاده کنیم
آنها در اکثر APIهای مدرن وب و موبایل، روندهای احراز هویت کاربر، و هر API که در آن به احراز هویت بدون حالت (Stateless) و مقیاسپذیر نیاز باشد، به خوبی عمل میکنند.
۴. توکن وب جیاسان – JWT (JSON Web Token)
نحوه کارکرد
JSON Web Tokenها فرمت خاصی برای توکنهای حامل هستند، نه یک مکانیسم احراز هویت جداگانه. آنها توکنهای خودمتکی (Self-contained) هستند که ادعاها (Claims) مربوط به کاربر را درون خود توکن حمل میکنند.
یک JWT از سه بخش تشکیل شده است که با نقاط (نقاط چین) از هم جدا شدهاند:
Header.Payload.Signature
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiIxMjMiLCJyb2xlIjoiYWRtaW4ifQ.SIGNATURE
هدر، الگوریتم امضا را مشخص میکند. محتوا (Payload) حامل ادعاها است: شناسه کاربر، نقش، زمان انقضا و زمان صدور. امضا یک هش رمزنگاریشده است که ثابت میکند توکن از سرور شما آمده و دستکاری نشده است.
سرور امضا را به صورت رمزنگاریشده اعتبارسنجی میکند و به ادعاهای درون آن اعتماد میکند. هیچ نیازی به جستجو در پایگاه داده نیست. این همان چیزی است که JWTها را بدون حالت (Stateless) و مقیاسپذیر میکند.
Dart:
class JwtService {
final String _secret;
final String _issuer;
final Duration _accessTokenExpiry;
JwtService({
required String secret,
required String issuer,
Duration accessTokenExpiry = const Duration(minutes: 15),
}) : _secret = secret,
_issuer = issuer,
_accessTokenExpiry = accessTokenExpiry;
String generateAccessToken(User user) {
final now = DateTime.now();
final payload = {
'sub': user.id,
'role': user.role.name,
'iat': now.millisecondsSinceEpoch ~/ 1000,
'exp': now.add(_accessTokenExpiry).millisecondsSinceEpoch ~/ 1000,
'iss': _issuer,
};
return _sign(payload);
}
Result<JwtClaims, AppException> validateToken(String token) {
try {
final claims = _verifyAndDecode(token);
final exp = claims['exp'] as int;
if (DateTime.fromMillisecondsSinceEpoch(exp * 1000).isBefore(DateTime.now())) {
return Result.failure(AppException.unauthorized('Token has expired'));
}
if (claims['iss'] != _issuer) {
return Result.failure(AppException.unauthorized('Invalid token issuer'));
}
return Result.success(JwtClaims.fromMap(claims));
} on SignatureVerificationException {
return Result.failure(AppException.unauthorized('Invalid token signature'));
} catch (e) {
return Result.failure(AppException.unauthorized('Token validation failed'));
}
}
}
C#:
public class JwtService
{
private readonly JwtSettings _settings;
public string GenerateAccessToken(User user)
{
var securityKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_settings.Secret)
);
var credentials = new SigningCredentials(
securityKey,
SecurityAlgorithms.HmacSha256
);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id),
new Claim(ClaimTypes.Role, user.Role.ToString()),
new Claim(JwtRegisteredClaimNames.Iat,
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()),
};
var token = new JwtSecurityToken(
issuer: _settings.Issuer,
audience: _settings.Audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(15),
signingCredentials: credentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
حالتهای خرابی JWT
توکنهای JWT در صورت پیادهسازی درست، فوقالعاده هستند. اما اگر بهدرستی پیادهسازی نشوند، میتوانند فاجعهبار باشند. هر خرابی ناشی از نقص در پیادهسازی است، نه نقص در خود استاندارد. در ادامه مواردی را آوردهام که دیدهام مشکلات واقعی ایجاد میکنند.
۱. حمله سردرگمی الگوریتم (Algorithm Confusion Attack)
هدر JWT مشخص میکند که از چه الگوریتمی برای امضای آن استفاده شده است. اگر سرور شما هر الگوریتمی را که توکن ادعا میکند بپذیرد، یک مهاجم میتواند الگوریتم را روی حالت none تنظیم کند و امضا را بهطور کامل حذف کند. در نتیجه، سرور شما هر توکنی را به عنوان معتبر میپذیرد.
۲. کلیدهای امضای ضعیف
یک کلید (Secret) ضعیف در JWT را میتوان به صورت آفلاین با حمله حدس رمز (Brute-force) شکست. مهاجم نیازی به دسترسی به سرور شما ندارد؛ او توکن را برمیدارد، آن را از طریق یک ابزار کرک عبور میدهد، کلید را کشف میکند و اکنون میتواند هر توکنی را که میخواهد، با هر شناسه کاربری دلخواهی امضا کند.
از کلیدهای رمزنگاری قوی با طول حداقل ۲۵۶ بیت استفاده کنید. برای محیطهای با امنیت بالا، از RS256 یا ES256 با کلیدهای نامتقارن استفاده کنید.
۳. نداشتن تاریخ انقضا
یک توکن JWT بدون ادعای exp هرگز منقضی نمیشود. همیشه یک تاریخ انقضا تعیین کنید. برای توکنهای دسترسی کوتاهمدت، بازه ۱۵ دقیقه تا ۱ ساعت را انتخاب کنید. توکنهای بازخوانی (Refresh tokens) استمرار جلسه را مدیریت میکنند.
۴. دادههای حساس در بدنه پیام (Payload)
بدنه پیام با الگوریتم Base64 کدگذاری شده است، نه رمزنگاری. هرکسی که توکن را به دست آورد میتواند تمام محتویات آن را رمزگشایی و مطالعه کند. هرگز رمزهای عبور، شماره حسابهای کامل، شماره شناسایی یا اطلاعات هویتی حساس (PII) را در بدنه JWT قرار ندهید. به جای آن از شناسهها (Identifiers) استفاده کنید و اجازه دهید سرور دادههای حساس را دریافت کند.
۵. نداشتن استراتژی ابطال (Revocation Strategy)
از آنجا که توکنهای JWT بدون حالت (Stateless) هستند، سرور توکنهای صادرشده را پیگیری نمیکند. یک توکن سرقتشده تا زمان انقضایش معتبر باقی میماند.
راه حل: یک لیست سیاه (Blocklist) برای توکنهایی که صادرشان صراحتاً لغو شده است نگهداری کنید، یا از انقضای کوتاهمدت به همراه چرخش توکنهای بازخوانی (Refresh token rotation) استفاده کنید تا پنجره آسیبرسانی کوچک باشد.
چه زمانی از JWT استفاده کنیم؟
از آنها برای APIهای بدون حالت در مقیاس بزرگ، ریزخدمات (Microservices) که در آنها سرویسها نیاز به تأیید هویت بدون فراخوانی یک سرور احراز هویت مرکزی در هر درخواست دارند، و برنامههای موبایل استفاده کنید. آنها برای هر سیستمی مناسب هستند که مقیاسپذیری احراز هویتِ بدون حالت، بیشتر از پیچیدگی مدیریت ابطال توکن اهمیت دارد.
۵. OAuth 2.0
نحوه کارکرد
پروتکل OAuth 2.0 یک چارچوب مجوزی است، نه یک پروتکل احراز هویت. این پروتکل به کاربر اجازه میدهد بدون به اشتراک گذاشتن رمز عبور خود با یک برنامه شخص ثالث، به آن اجازه دسترسی به منابع خود را بدهد.
شما هر بار که با حساب گوگل خود وارد برنامهای شدهاید یا پیام «به این برنامه اجازه دسترسی به حساب خود را بدهید» را دیدهاید، از این پروتکل استفاده کردهاید.
چهار بازیگر در این فرآیند دخیل هستند: سرور احراز هویت (Authorization Server) که توکنها را صادر میکند، سرور منبع (Resource Server) که میزبان API محافظتشده است، کلاینت (Client) که برنامهای است که درخواست دسترسی دارد، و صاحب منبع (Resource Owner) که همان کاربر است.
جریان کد احراز هویت (Authorization Code Flow) به این صورت کار میکند:
- کلاینت، کاربر را با درخواستی برای دامنههای دسترسی (Scopes) خاص به سرور احراز هویت هدایت میکند
- کاربر هویت خود را تأیید کرده و دامنههای دسترسی درخواستشده را تایید میکند
- سرور احراز هویت به همراه یک کد احراز هویت، کاربر را به کلاینت هدایت مجدد (Redirect) میکند
- کلاینت در سمت سرور، کد را با یک توکن دسترسی مبادله میکند
- کلاینت از توکن دسترسی برای فراخوانی سرور منبع استفاده میکند
Dart:
class OAuthClient {
final String _clientId;
final String _clientSecret;
final String _redirectUri;
final String _authorizationEndpoint;
final String _tokenEndpoint;
OAuthClient({
required String clientId,
required String clientSecret,
required String redirectUri,
required String authorizationEndpoint,
required String tokenEndpoint,
}) : _clientId = clientId,
_clientSecret = clientSecret,
_redirectUri = redirectUri,
_authorizationEndpoint = authorizationEndpoint,
_tokenEndpoint = tokenEndpoint;
String buildAuthorizationUrl(List<String> scopes) {
final state = _generateSecureState();
final params = {
'response_type': 'code',
'client_id': _clientId,
'redirect_uri': _redirectUri,
'scope': scopes.join(' '),
'state': state,
};
final uri = Uri.parse(_authorizationEndpoint)
.replace(queryParameters: params);
return uri.toString();
}
Future<Result<OAuthTokens, AppException>> exchangeCodeForTokens(
String code,
String state,
String expectedState,
) async {
if (state != expectedState) {
return Result.failure(
AppException.unauthorized('Invalid state parameter'),
);
}
final response = await http.post(
Uri.parse(_tokenEndpoint),
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: {
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': _redirectUri,
'client_id': _clientId,
'client_secret': _clientSecret,
},
);
if (response.statusCode != 200) {
return Result.failure(AppException.unauthorized('Token exchange failed'));
}
final tokens = OAuthTokens.fromJson(jsonDecode(response.body));
return Result.success(tokens);
}
String _generateSecureState() {
final bytes = List<int>.generate(32, (_) => Random.secure().nextInt(256));
return base64Url.encode(bytes);
}
}
C#:
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = "OAuth2";
})
.AddCookie()
.AddOAuth("OAuth2", options =>
{
options.ClientId = builder.Configuration["OAuth:ClientId"]!;
options.ClientSecret = builder.Configuration["OAuth:ClientSecret"]!;
options.CallbackPath = "/auth/callback";
options.AuthorizationEndpoint = "https://auth.provider.com/authorize";
options.TokenEndpoint = "https://auth.provider.com/token";
options.SaveTokens = true;
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Events = new OAuthEvents
{
OnCreatingTicket = async context =>
{
var userInfoRequest = new HttpRequestMessage(
HttpMethod.Get,
"https://auth.provider.com/userinfo"
);
userInfoRequest.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", context.AccessToken);
var response = await context.Backchannel.SendAsync(userInfoRequest);
var userInfo = await response.Content.ReadFromJsonAsync<JsonDocument>();
context.Identity!.AddClaim(new Claim(
ClaimTypes.NameIdentifier,
userInfo!.RootElement.GetString("sub")!
));
}
};
});
حالتهای خرابی OAuth 2.0
۱. آدرسهای بازگشت (Redirect URIs) پیکربندیشده به اشتباه
اگر سرور احراز هویت، آدرسهای بازگشت را به شدت اعتبارسنجی نکند، یک مهاجم میتواند آدرس خود را جایگزین کرده و کد احراز هویت را رهگیری کند. برای جلوگیری از این امر، از اعتبارسنجی تطابق دقیق (Exact match validation) استفاده کنید.
۲. توکنها در URLها
توکنهای دسترسی ممکن است در لاگهای سرور، تاریخچه مرورگر، یا هدرهای Referrer ظاهر شوند، زیرا شخصی آنها را به جای هدر Authorization در یک پارامتر کوئری قرار داده است. توکنها متعلق به هدرهای Authorization هستند. URLها در لاگها ثبت میشوند، اما هدرهای احراز هویت اینطور نیستند.
چه زمانی از OAuth 2.0 استفاده کنیم؟
این پروتکل در هر سناریویی که یک شخص ثالث نیاز به دسترسی تفویضشده به منابع کاربر داشته باشد، مانند ورود با شبکههای اجتماعی، ادغامهای API بین سازمانها، یا یکپارچهسازی سیستمهای شریک، به خوبی کار میکند.
۶. پروتکل OpenID Connect (OIDC)
نحوه کارکرد
پروتکل OAuth 2.0 مجوز را مدیریت میکند. OpenID Connect احراز هویت را به OAuth 2.0 اضافه میکند. این پروتکل فقط به شما نمیگوید که کاربر چه چیزی را تأیید کرده است، بلکه به شما میگوید کاربر در واقع چه کسی است.
پروتکل OIDC در کنار توکن دسترسی، یک توکن شناسایی (ID token) نیز صادر میکند. توکن شناسایی یک JWT است که حاوی ادعاهای تأیید شده هویت است: شناسه موضوع (Subject identifier)، ایمیل، نام، تصویر نمایه و زمان انجام احراز هویت.
دارت:
class OidcService {
final String _issuer;
final String _clientId;
final JwtValidator _jwtValidator;
OidcService({
required String issuer,
required String clientId,
required JwtValidator jwtValidator,
}) : _issuer = issuer,
_clientId = clientId,
_jwtValidator = jwtValidator;
Future<Result<UserIdentity, AppException>> validateIdToken(
String idToken,
) async {
final claimsResult = await _jwtValidator.validate(idToken);
if (claimsResult.isFailure) {
return Result.failure(claimsResult.error!);
}
final claims = claimsResult.value!;
if (claims['iss'] != _issuer) {
return Result.failure(AppException.unauthorized('Invalid token issuer'));
}
final aud = claims['aud'];
final audiences = aud is List ? aud : [aud];
if (!audiences.contains(_clientId)) {
return Result.failure(
AppException.unauthorized('Token not intended for this client'),
);
}
return Result.success(UserIdentity(
subject: claims['sub'] as String,
email: claims['email'] as String?,
name: claims['name'] as String?,
emailVerified: claims['email_verified'] as bool? ?? false,
));
}
}
C#:
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = "https://accounts.google.com";
options.ClientId = builder.Configuration["OIDC:ClientId"]!;
options.ClientSecret = builder.Configuration["OIDC:ClientSecret"]!;
options.ResponseType = "code";
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
NameClaimType = "name",
RoleClaimType = "role"
};
});
چه زمانی از OIDC استفاده کنیم
این پروتکل برای هر برنامهای که نیاز دارد هویت کاربر را از طریق یک ارائهدهنده هویت معتبر (مانند SSO سازمانی یا ورود با شبکههای اجتماعی) تأیید کند، مناسب است. این روش برای هر سیستمی که میخواهید در آن تأیید هویت را به یک شخص ثالث مورد اعتماد واگذار کنید، به جای اینکه خودتان مدیریت مشخصات کاربر را بر عهده بگیرید، به خوبی کار میکند.
ورود به سیستم گوگل (Google Sign-In)، مایکروسافت آژور AD (Microsoft Azure AD)، اوکتا (Okta) و Auth0 همگی از OIDC پشتیبانی میکنند. اگر سوال شما این است که «این شخص کیست» و نه صرفاً «آیا این درخواست مجاز است یا خیر»، OIDC چارچوب مناسبی است.
۷. پروتکل TLS متقابل (mTLS)
چگونه کار میکند
در TLS معمولی، کلاینت گواهینامه سرور را تأیید میکند. سرور نیز هر کلاینتی را که بتواند یک اتصال برقرار کند، قابل اعتماد میداند.
در TLS متقابل، هر دو طرف گواهینامه یکدیگر را تأیید میکنند. سرور فقط اتصال کلاینتهایی را میپذیرد که دارای گواهینامهای صادر شده از یک مرجع صدور گواهینامه (CA) معتبر باشند. کلاینت نمیتواند هویت خود را جعل کند زیرا به یک گواهینامه واقعی نیاز دارد.
هیچ توکن حامل (bearer token) یا کلید API وجود ندارد. گواهینامه همان مکانیزم احراز هویت است.
دارت (mTLS سمت کلاینت):
class MtlsHttpClient {
final http.Client _client;
MtlsHttpClient._internal(this._client);
static Future<MtlsHttpClient> create({
required String certificatePath,
required String privateKeyPath,
required String trustedCaPath,
}) async {
final context = SecurityContext(withTrustedRoots: false);
// load the client certificate
context.useCertificateChainBytes(
await File(certificatePath).readAsBytes(),
);
// load the client private key
context.usePrivateKeyBytes(
await File(privateKeyPath).readAsBytes(),
);
// only trust this specific CA
context.setTrustedCertificatesBytes(
await File(trustedCaPath).readAsBytes(),
);
final httpClient = HttpClient(context: context);
final client = IOClient(httpClient);
return MtlsHttpClient._internal(client);
}
Future<http.Response> get(String url, {Map<String, String>? headers}) {
return _client.get(Uri.parse(url), headers: headers);
}
Future<http.Response> post(
String url, {
Map<String, String>? headers,
Object? body,
}) {
return _client.post(Uri.parse(url), headers: headers, body: body);
}
}
C#:
builder.WebHost.ConfigureKestrel(options =>
{
options.ConfigureHttpsDefaults(httpsOptions =>
{
httpsOptions.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
httpsOptions.ClientCertificateValidation = (certificate, chain, errors) =>
{
if (errors != SslPolicyErrors.None)
return false;
var expectedThumbprint = "your-trusted-cert-thumbprint";
return certificate.Thumbprint == expectedThumbprint;
};
});
});
public class MtlsMiddleware
{
private readonly RequestDelegate _next;
public async Task InvokeAsync(HttpContext context)
{
var clientCert = context.Connection.ClientCertificate;
if (clientCert == null)
{
context.Response.StatusCode = 401;
await context.Response.WriteAsync("Client certificate required");
return;
}
if (!IsValidClientCertificate(clientCert))
{
context.Response.StatusCode = 403;
await context.Response.WriteAsync("Invalid client certificate");
return;
}
await _next(context);
}
private bool IsValidClientCertificate(X509Certificate2 cert)
{
if (cert.NotAfter < DateTime.UtcNow) return false;
var trustedThumbprints = new HashSet<string>
{
"THUMBPRINT_SERVICE_A",
"THUMBPRINT_SERVICE_B",
};
return trustedThumbprints.Contains(cert.Thumbprint);
}
}
مشکل واقعی mTLS
مسئله اصلی در اینجا مدیریت گواهینامه است. گواهینامهها منقضی میشوند. اگر فرآیند تمدید (rotation) خودکار نباشد، یک گواهینامه منقضی شده میتواند ارتباط بین سرویسها را در بدترین زمان ممکن مختل کند. زیرساخت CA خودش باید ایمن شود. به خطر افتادن یک CA به این معناست که تمام گواهینامههای صادر شده توسط آن نیز به خطر افتادهاند.
برای تیمهایی که ظرفیت عملیاتی لازم برای مدیریت یک زیرساخت کلید عمومی (PKI) کامل را ندارند، استفاده از کلیدهای API با فهرست مجاز دامنههای سختگیرانه و برنامههای زمانبندیشده برای چرخش کلید میتواند پاسخ کاربردیتری باشد. mTLS معماری درستی است، اما پیادهسازی و مدیریت صحیح آن نیز بیشترین تقاضا و سختی را دارد.
چه زمانی از mTLS استفاده کنیم
از mTLS برای ارتباطات حساس بین سرویسی با امنیت بالا، مانند درگاههای پرداخت یا APIهای بانکی استفاده کنید. این روش همچنین برای ریزسرویسها در محیطهای قانونمند که رعایت مقررات مستلزم اثبات رمزنگاریشده هویت در لایه انتقال است، به خوبی کار میکند. تمام ارتباطات بین سرویسهای داخلی در یک سیستم با امنیت بالا باید به عنوان دادههای حساس در نظر گرفته شوند. mTLS چنین پایهای را فراهم میکند.
انتخاب مکانیزم مناسب
انتخاب یک مکانیزم احراز هویت یک تصمیم معماری است. این چارچوب کار است:
برای برنامههای وب و موبایل مرتبط با کاربر: از توکنهای حامل با JWT استفاده کنید. توکنهای دسترسی با عمر کوتاه به همراه چرخش توکنهای بازنشانی (refresh token). اعمال محدودیت نرخ (Rate limiting) روی تمام نقاط پایانی احراز هویت.
برای دسترسی تفویضشده شخص ثالث: از OAuth 2.0 استفاده کنید. زمانی که علاوه بر آن نیاز به تأیید هویت کاربر دارید، OIDC را در کنار آن اضافه کنید.
برای SSO سازمانی: از OpenID Connect با یک ارائهدهنده هویت سازمانی استفاده کنید.
برای ارتباط سرور به سرور با الزامات امنیتی پایینتر: از کلیدهای API با قابلیت تعیین فهرست مجاز دامنهها، کلیدهای مختص هر محیط و زمانبندی چرخش کلید استفاده کنید.
برای ارتباط بین سرویسها در محیطهای قانونمند با امنیت بالا: از mTLS استفاده کنید.
برای ابزارهای داخلی در محیطهای کاملاً کنترلشده: احراز هویت پایه (Basic Auth) حداقل سطح قابل قبول است، آن هم مشروط بر اینکه امنیت TLS تضمین شده باشد. برای هر چیز دیگری، از روش قویتری استفاده کنید.
شما باید مکانیزم احراز هویت خود را بر اساس اینکه فراخوانکننده کیست، میزان حساسیت دادهها چقدر است و الزامات قانونی چه چیزی را دیکته میکنند، انتخاب کنید؛ نه بر اساس قراردادهای رایج یا روشی که پروژه قبلی استفاده کرده است، و نه بر اساس سادهترین روش برای پیادهسازی.
نظم سازمانی که همهچیز را در کنار هم نگه میدارد
انجام درست پیادهسازی فنی نیمی از کار است. نیم دیگر آن به مسائل سازمانی مربوط میشود.
چرخش کلید API باید زمانبندیشده و پیشگیرانه باشد، نه واکنشی به احتمال نشت اطلاعات. هر کلید دارای یک حداکثر طول عمر مشخص و یک برنامه چرخش است که تیم مهندسی مسئولیت آن را بر عهده دارد.
هیچ کلید API نباید برای چندین کلاینت یا چندین محیط مختلف استفاده شود. کلید محیط استیجینگ مختص همان محیط است و کلید کلاینت موبایل نیز مختص خود آن است. استفاده مجدد از کلید در محیطهای مختلف به معنای فروپاشی مرزهای امنیتی است.
اطلاعات محرمانه و رمزها هرگز نباید در سورسکد، کد برنامه، یا فایلهای پیکربندی commitشده در مخزن کد قرار گیرند. این اطلاعات در زمان اجرا (runtime) از یک مدیریتکننده امن و معتبر رازها (secrets manager) فراخوانی میشوند. این یک استاندارد مهندسی است، نه یک سلیقه شخصی.
الگوریتمهای رمزنگاری باید یک استاندارد سازمانی باشند، نه تصمیمی که توسعهدهنده به صورت پروژه به پروژه بگیرد. شخصی در سطح مهندس ارشد (Staff Engineer) یا معمار، مشخص میکند که سازمان از کدام الگوریتم، کدام حالت (mode) و چه طول کلیدی استفاده کند. هر پروژهای از این استاندارد پیروی میکند و هیچ توسعهدهندهای نباید برای هر پروژه رمزنگاری را از صفر پیادهسازی کند.
سازمانها باید بستههای (packages) مشترک داخلی را برای عملیات رمزنگاری ساخته و نگهداری کنند. توسعهدهندگان صرفاً آن بسته را ایمپورت میکنند و خودشان هرگز کد رمزنگاری نمینویسند. این کار تمام خطاهای پیادهسازی رمزنگاری ناشی از تصمیمات آنی و موردی را از بین میبرد.
لاگبرداری ساختاریافته همراه با ماسک کردن فیلدها (field-level masking) باید در سطح فریمورک اجباری شود. هدرهای احراز هویت، توکنها، رمزهای عبور، شماره حسابها و هر فیلد حساسی نباید تحت هیچ شرایطی در هیچ محیطی (تولید، استیجینگ یا توسعه) در لاگها ثبت شوند. این ماسک کردن در سطح فریمورک لاگبرداری اعمال میشود تا هیچ توسعهدهندهای نتواند بهطور تصادفی دادههای حساس را لاگ کند، حتی اگر تلاش کند.
نتیجهگیری
هر مکانیزمی در این مقاله اگر به درستی پیادهسازی شود، کار میکند. در عین حال، هر مکانیزمی در صورت پیادهسازی نادرست، به روشهای قابل پیشبینی و مستندشدهای شکست میخورد.
حملات سردرگمی الگوریتم JWT باعث دور زدن واقعی احراز هویت شدهاند. URIهای هدایت مجدد (redirect) پیکربندینشده در OAuth منجر به ربایش واقعی حسابهای کاربری شدهاند. پارامترهای state گمشده، حملات واقعی CSRF را امکانپذیر کردهاند. رازهای امضاکننده ضعیف منجر به جعل هویت واقعی در مقیاس وسیع شدهاند. کلیدهای API هاردکدشده در مخازن کد، سیستمهای تولید را در معرض دسترسی غیرمجاز قرار دادهاند.
اینها حالتهای شکست تئوریک نیستند. آنها در سیستمهای پروداکشن (تولید) سازمانهایی در هر ابعادی رخ میدهند. مهندسانی که آن سیستمها را ساخته بودند بیکفایت نبودند، بلکه صرفاً از حالتهای شکست آگاه نشده بودند. هیچکس استاندارد سازمانی را تعریف نکرده بود و آن را در پایپلاین توسعه اجباری نکرده بود.
نظم مهندسی یعنی قبل از نوشتن حتی یک خط کد، دقیقاً بدانید هر مکانیزم چگونه ممکن است شکست بخورد، و پیادهسازی خود را به گونهای بسازید که از همان ابتدا از بروز آن حالتهای شکست جلوگیری کنید. این همان تفاوت میان احراز هویت کارآمد و احراز هوتی است که در شرایط خصمانه و پرخطر دوام میآورد.
کدنویسی لذتبخشی داشته باشید!
نظر مهندس بهمن آبادی: امنیت API فراتر از انتخاب مکانیزم احراز هویت است؛ تفاوت اصلی در درک حالتهای خرابی و اعمال اصول بنیادینی مثل اجباری بودن TLS و مدیریت امن کلیدهاست. پیادهسازی درست، یعنی جداسازی دقیق «احراز هویت» از «درسیسنجی» و مهار آسیبپذیریهای پنهان پیش از رسیدن به پروداکشن.