Skip to content

אסימוני JWT החתומים בתעודה: אימות ומיפוי זהות מהימן

האיור אינו זמין: איור תעודה דיגיטלית (מקור התמונה המקורי).

כל הקוד נועד להמחשה ולא הורץ.

מתחילים במודל אמון

לקוח חותם על JWT קצר־חיים באמצעות המפתח הפרטי התואם לתעודה הציבורית הרשומה שלו. התעודה עצמה אינה סוד החתימה, וזה אינו אימות תעודת לקוח במסגרת TLS. ככל האפשר, עדיף להשתמש בזרימת client assertion תקנית של OAuth. גם אסימון חתום שהתקבל עלול להישלח שוב; הוא אינו מוכיח ששולח כל בקשת HTTP מחזיק במפתח הפרטי ברגע הבקשה.

יש לרשום תעודות בתהליך ניהולי מהימן. קושרים כל מזהה מפתח מורשה למנפיק, לנושא, לחשבון מקומי ולתפקידים מאושרים צפויים. אין להעניק תפקידים רק מפני שבתעודה כלשהי מופיע ערך O או CN. השדה kid הוא רמז חיפוש לא מהימן, לא נתיב קובץ ולא החלטת אמון.

קודם מאמתים, אחר כך ממפים

מתודת ההרחבה להמחשה עבור ASP.NET Core מקבלת אוסף רישום שבשליטת מנהל וקהל יעד נדרש. היא מאפשרת רק RS256, מאמתת חתימה, מנפיק, קהל יעד ותפוגה, קושרת את המפתח שאומת למנפיק ולנושא המדויקים, ואז מחליפה את טענות ההרשאה מהאסימון בטענות שבשליטת השרת. היא מגבילה את יתרת חיי האסימון לחמש דקות ומאפשרת סטיית שעון של 30 שניות בבדיקת התפוגה.

היישום חייב לרשום מימוש אמיתי של IAssertionReplayStore, עם אחסון משותף ופעולה אטומית. לממשק שלהלן אין במכוון חלופה שמאשרת בקשות. אין להשתמש בו כסכמת bearer רגילה לאסימון גישה רב־פעמי: אותו assertion מתקבל לכל היותר פעם אחת. זהו מאמת assertions להמחשה במערכת סגורה, לא שרת הרשאות OAuth מלא.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;

public sealed record EnrolledClient(string KeyId, X509Certificate2 Certificate,
    string Issuer, string Subject, string AccountId, string[] Roles);

// Implement with a shared, atomic store. Return false for an already-used
// (keyId, jti). Retain entries until expiresUtc plus accepted clock skew.
// Store errors MUST fail closed. Do not use a non-atomic check-then-add.
public interface IAssertionReplayStore
{
    Task<bool> TryUseAsync(string keyId, string jti,
        DateTimeOffset expiresUtc, CancellationToken cancellationToken);
}

public static class AssertionAuthentication
{
    public static IServiceCollection AddCertificateAssertions(
        this IServiceCollection services,
        IReadOnlyCollection<EnrolledClient> enrolled,
        string audience)
    {
        if (string.IsNullOrWhiteSpace(audience) || enrolled.Count == 0)
            throw new ArgumentException("Audience and enrolled clients are required.");
        var clients = enrolled.ToDictionary(c => c.KeyId, StringComparer.Ordinal);
        foreach (var c in clients.Values)
        {
            if (string.IsNullOrWhiteSpace(c.KeyId) || string.IsNullOrWhiteSpace(c.Issuer)
                || string.IsNullOrWhiteSpace(c.Subject) || string.IsNullOrWhiteSpace(c.AccountId))
                throw new ArgumentException("Incomplete enrollment.");
            using RSA? rsa = c.Certificate.GetRSAPublicKey();
            if (rsa is null || rsa.KeySize < 2048)
                throw new ArgumentException("An enrolled RSA key of at least 2048 bits is required.");
        }
        var keys = clients.ToDictionary(p => p.Key,
            p => new X509SecurityKey(p.Value.Certificate) { KeyId = p.Key },
            StringComparer.Ordinal);

        services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(options =>
            {
                options.MapInboundClaims = false;
                options.IncludeErrorDetails = false;
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    RequireSignedTokens = true,
                    ValidateIssuerSigningKey = true,
                    ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 },
                    ValidateIssuer = true,
                    ValidIssuers = clients.Values.Select(c => c.Issuer).Distinct(),
                    ValidateAudience = true,
                    ValidAudience = audience,
                    ValidateLifetime = true,
                    RequireExpirationTime = true,
                    ClockSkew = TimeSpan.FromSeconds(30),
                    IssuerSigningKeyResolver = (_, _, kid, _) =>
                        kid is not null && keys.TryGetValue(kid, out var key)
                            ? new SecurityKey[] { key } : Array.Empty<SecurityKey>()
                };
                options.Events = new JwtBearerEvents
                {
                    OnTokenValidated = async context =>
                    {
                        var keyId = context.SecurityToken.SigningKey?.KeyId;
                        if (keyId is null || !clients.TryGetValue(keyId, out var client))
                        { context.Fail("Unknown signing key."); return; }
                        var p = context.Principal!;
                        var now = DateTimeOffset.UtcNow;
                        string? jti = p.FindFirst("jti")?.Value;
                        if (p.FindFirst("iss")?.Value != client.Issuer
                            || p.FindFirst("sub")?.Value != client.Subject
                            || string.IsNullOrWhiteSpace(jti) || jti.Length > 128
                            || context.SecurityToken.ValidTo > now.UtcDateTime.AddMinutes(5)
                            || client.Certificate.NotBefore.ToUniversalTime() > now.UtcDateTime
                            || client.Certificate.NotAfter.ToUniversalTime() <= now.UtcDateTime)
                        { context.Fail("Invalid assertion binding or lifetime."); return; }
                        var store = context.HttpContext.RequestServices
                            .GetRequiredService<IAssertionReplayStore>();
                        bool fresh = await store.TryUseAsync(keyId, jti,
                            new DateTimeOffset(context.SecurityToken.ValidTo.ToUniversalTime())
                                .AddSeconds(30), context.HttpContext.RequestAborted);
                        if (!fresh) { context.Fail("Replayed assertion."); return; }
                        var claims = new List<Claim> { new(ClaimTypes.NameIdentifier,
                            client.AccountId), new(ClaimTypes.Name, client.AccountId) };
                        claims.AddRange(client.Roles.Select(r => new Claim(ClaimTypes.Role, r)));
                        context.Principal = new ClaimsPrincipal(new ClaimsIdentity(
                            claims, JwtBearerDefaults.AuthenticationScheme,
                            ClaimTypes.Name, ClaimTypes.Role));
                    }
                };
            });
        services.AddAuthorization();
        return services;
    }
}

גבולות הפריסה

יש להשאיר את מפתחות הרישום זמינים לאורך חיי שירות האימות, לטעון רק תעודות ציבוריות מאושרות ולנהל בנפרד הסרה, תגובה לפשרה והחלפה של מפתחות. קיבוע תעודה הוא מדיניות אמון מפורשת; הדוגמה אינה מממשת בניית שרשרת PKI או בדיקת ביטול מקוונת. אם המדיניות תלויה ברשות אישורים, יש לממש גם אימות שרשרת, שימוש מיועד וביטול. השתמשו ב־HTTPS, הפעילו UseAuthentication() לפני UseAuthorization(), וחייבו הרשאה בנקודות הקצה המוגנות.

אין מתעלמים מתוכן ה־JWT: המנפיק, הנושא, קהל היעד, התפוגה ומזהה מניעת השימוש החוזר משפיעים כולם על הקבלה. לא בוצעו עבור מאמר זה התקנת תעודה, שינוי חשבון, הידור או בדיקת זמן ריצה.

מקורות ראשוניים

המאמר המקורי באנגלית: Certificate authentication with JWT. תאריך הפרסום המקורי: 2024-02-01T08:34:00.006+02:00.

Post a Comment

Previous Post Next Post