Divertissement con il jwt token

di Andrea Zani, in .NET,

Solo per mio divertimento voglio creare un Jwt Token che venga visto come valido su jwt.io.

Prima di ottenere questo faccio un passo indietro: come creare un jwt token in .net core. La prima decisione da prendere a riguardo è come poterlo firmare, e qui si può scegliere la strada della classica stringa alfanumerica che farà da chiave o l'uso di un certificato con chiave asimmetrica che permetterà l'utilizzo della chiave pubblica per verificarne la validità - ovviamente devo scegliere la seconda.

E' quindi giunto il momento di creare questo certificato. E' possibile usare anche comandi esterni come openssl, ma lascerò fare tutto a .net core:

var rsa = RSA.Create(); // generate asymmetric key pair

var req = new CertificateRequest("cn=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var cert = req.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(1));

// Create PFX (PKCS #12) with private key
System.IO.File.WriteAllBytes("mycert.pfx", cert.Export(X509ContentType.Pfx, "P@55w0rd"));

// Create Base 64 encoded CER (public key only)
System.IO.File.WriteAllText("mycert.cer",
  "-----BEGIN CERTIFICATE-----\r\n"
  + Convert.ToBase64String(cert.Export(X509ContentType.Cert), Base64FormattingOptions.InsertLineBreaks)
  + "\r\n-----END CERTIFICATE-----");

Con il codice qui sopra vengono creati i due file, uno di tipo PKCS#12 che contiene la chiave privata e il file mycert.cer che contiene la chiave pubblica. Ora posso creare il Jwt Token super sicuro(!?!?):

    static string GenerateToken()
    {
      var tokenHandler = new JwtSecurityTokenHandler();
      var certificate = new X509Certificate2("mycert.pfx", "P@55w0rd");
      var securityKey = new X509SecurityKey(certificate);

      var claims = new Dictionary<string, object>()
      {
        { ClaimTypes.Email, "a@a.it" },
        { ClaimTypes.NameIdentifier, "az" }
      };

      var tokenDescriptor = new SecurityTokenDescriptor
      {
        Claims = claims,
        Subject = new ClaimsIdentity(),
        Issuer = "Self",
        IssuedAt = DateTime.Now,
        Audience = "Others",
        Expires = DateTime.Now.AddYears(1),
        SigningCredentials = new SigningCredentials(
          securityKey,
          SecurityAlgorithms.RsaSha256Signature)
      };

      var token = tokenHandler.CreateToken(tokenDescriptor);
      return tokenHandler.WriteToken(token);
    }

Che, eseguito, ritornerà il jwt token:

eyJhbGciOiJSUzI1NiIsImtpZCI6IkQwNTcwRkQwNEFDRjZBQzFGNEE5NkYxQUY5OUU1NzE5NTQ3Q0ZGNTAiLCJ0eXAiOiJKV1QifQ.eyJlbWFpbCI6ImFAYS5pdCIsIm5hbWVpZCI6ImF6IiwibmJmIjoxNjM1MzQ3MDE4LCJleHAiOjE2NjY4ODMwMTgsImlhdCI6MTYzNTM0NzAxOCwiaXNzIjoiU2VsZiIsImF1ZCI6Ik90aGVycyJ9.ahmGAI0dmVEcZaSBKs1Ff7ID-4r8-0_ITAXiifXyN-7T0_23_-njAea4ftcrf96dcrPW2Y8r1iv_TDAjZmqbn6EUbd-gGYJ1ggOWPEf44nQetJwUdkPKo_3MddlXcloRMaRcxK6YCxZ2uys-I0qqy-Y5RiewtOAzHetG7i3_VOr6KqtDKNb5oKYxtOQlovFxbMFp-DdsoHD3C5v8RPqdWlpBfvKVrZEb1XrJHJ2Mod6GYLQlE-zO_vrkt-MGb7ExEl1b34eACHGGjbm-FTVEZQEwPE5xqHa-8qdFRFRnGurnJpie4Mn0AHHep4loPlz5l-G6XQg3kL2IkBOSvWKkHw

In jwt.io:

Per poterlo validare da codice devo avere il file con la chiave pubblica (nel mio esempio mycert.cer), e scrivere questo codice:

    static bool ValidateToken(string token)
    {
      var tokenHandler = new JwtSecurityTokenHandler();
      byte[] certContent2 = System.IO.File.ReadAllBytes(@"mycert.cer");
      var certificate = new X509Certificate2(certContent2);
      var securityKey = new X509SecurityKey(certificate);

      var validationParameters = new TokenValidationParameters
      {
        ValidAudience = "Others",
        ValidIssuer = "Self",
        IssuerSigningKey = securityKey
      };

      var principal = tokenHandler.ValidateToken(token, validationParameters, out SecurityToken securityToken);
      if (principal == null)
        return false;
      if (securityToken == null)
        return false;

      return true;
    }

Questa funzione ritorna true se il certificato è valido. Ma il problema rimane, perché se controllo meglio in jwt.io la validità di questo token, vedrò:

Ovviamente non può essere validato perché jwt.io non sa dove prendersi la chiave pubblica e, sempre ovviamente, c'è il modo per dirgli dove prenderla e lo si può fare anche senza leggere una riga della documentazione ufficiale su questo tipo di token. E' sufficiente aprire la console di Chrome e vedere che cosa accade quando inserisco il mio token:

Viene fatta una chiamata ad un link come il seguente:

https://jwt.io/Self/.well-known/openid-configuration

In questo caso chiama se stesso ma solo perché nel mio token non è presente il dominio. Ma non è la sola chiamata che sarà fatta, perché dal link qui sopra sarà estratta come informazione l'url da dove prelevare la chiave pubblica. C'è una RFC a riguardo e se voglio fare in modo che il mio certificato sia validato, devo convertire il mio esempio in una web application che esponga quelle chiamate.

A questo link sono presenti la due web application di esempio, la prima per la creazione del jwt token, e la seconda è un'altra web application che, come da esempio, espone due API Rest di cui una pubblica e la seconda che risponderà solo nel caso il bearer token passato nell'header in Authorization sia valido. Ma prima di arrivare a tanto devo mettere a disposizione quell'url visto prima e lo faccio con questo codice:

[Route(".well-known/openid-configuration")]
[ApiController]
public class OpenIdController : ControllerBase
{
  ...
  [EnableCors("mycors")]
  public IActionResult Get()
  {
    var obj = new OpenIdConfiguration
    {
      Issuer = _configuration.Issuer, // <- url di questa webapplication, http://localhost:40200
      AuthorizationEndpoint = null,
      TokenEndpoint = null,
      DeviceAuthorizationEndpoint = null,
      UserinfoEndpoint = null,
      MfaChallengeEndpoint = null,
      JwksUri = _configuration.Issuer + ".well-known/jwks.json",
      RegistrationEndpoint = null,
      RevocationEndpoint = null,
      ScopesSupported = new[] { "openid", "profile" },
      ResponseTypesSupported = new[] { "code", "token", "code token" },
      CodeChallengeMethodsSupported = new[] { "S256", "plain" },
      ResponseModesSupported = new[] { "query", "fragment", "form_post" },
      SubjectTypesSupported = new[] { "public" },
      IdTokenSigningAlgValuesSupported = new[] { "RS256" },
      TokenEndpointAuthMethodsSupported = new[] { "client_secret_post" },
      ClaimsSupported = new[] { "aud", "auth_time", "created_at", "email", "email_verified", "exp", "family_name", "given_name", "iat", "identities", "iss", "name", "nickname", "phone_number", "picture", "sub" },
      RequestUriParameterSupported = false
    };

    return Ok(obj);
  }
}

Ovviamente queste informazioni le ho prese da una chiamata funzionante e molte di esse non sono utili per il risultato che voglio. A parte i valori statici inseriti solo due sono dinamici: Issuer e JwksUri; il primo è dove inserirò l'url (con solo il dominio) del server di autenticazione, e il secondo parametro è l'url dove prelevare le informazioni per validare il Jwt token.

Qui le informazioni non si possono copiare come sopra, e seguendo sempre la RFC, utilizzando come base il codice visto prima, prelevo altre informazioni dal certificato:

public CertificateInfo GenerateCertificate()
{
  var rsa = RSA.Create(_configuration.RsaKeySize); // generate asymmetric key pair

  var req = new CertificateRequest("cn=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
  var cert = req.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(1));
  var securityKey = new X509SecurityKey(cert);

  string modulus = WebEncoders.Base64UrlEncode(rsa.ExportParameters(false).Modulus);
  string x5t = securityKey.X5t;
  string keyId = securityKey.KeyId;
  byte[] pfx = cert.Export(X509ContentType.Pfx, _configuration.PfxPassword);
  string publicCertficateBase64 = Convert.ToBase64String(cert.Export(X509ContentType.Cert), Base64FormattingOptions.None);

  return new CertificateInfo
  {
    Pfx = pfx,
    PublicCertificateBase64 = publicCertficateBase64,
    Modulus = modulus,
    X5t = x5t,
    KeyId = keyId
  };
}

Come da documentazione inserisco:

  • Alg: il sistema di crittografazione usato.
  • Kty: qui è la "famiglia" dell'algoritmo di crittografia utilizzato (normalmente per questo caso "RSA").
  • Use: accetta solo due valori: sig (signature) e enc (encryption).
  • N: in formato Base64urlUint è il valore del modulo per la chiave pubblica.
  • E: valore dell'esponente per la chiave pubblica (viene sempre usata la stringa "AQAB").
  • Kid: chiave utilizzata per proteggere la firma del token.
  • X5t: è l'hash del certificato x509.

Quattro informazioni sono stringhe statiche, le restanti vengono prese durante la creazione del token, come visto sopra. Ora ho tutto per la creazione dell'API:

[Route(".well-known/jwks.json")]
[ApiController]
public class JwksController : ControllerBase
{
  ...
  [EnableCors("mycors")]
  public async Task<IActionResult> GetAsync()
  {
    var certificateInfo = await _helper.GetCertificateInfoFromDiskAsync();

    var obj = new KeyJwtClass
    {
      Keys = new[]
      {
        new JwksClass
        {
          Alg = "RS256",
          Kty = "RSA",
          Use = "sig",
          N = certificateInfo.Modulus,
          E = "AQAB",
          Kid= certificateInfo.KeyId,
          X5t = certificateInfo.X5t,
          X5c = new [] { certificateInfo.PublicCertificateBase64 }
        }
      }
    };

    return Ok(obj);
  }

Dopo aver inserito una ulteriore API Rest per farmi restituire il token:

        <a href="http://localhost:40200/api/token">http://localhost:40200/api/token</a>

Risposta:

eyJhbGciOiJSUzI1NiIsImtpZCI6IjYzRThDMDJCMkE1NjExN0Q0RUNENjQyRURBM0RDNkM1QjJBQTk5NjIiLCJ0eXAiOiJKV1QifQ.eyJlbWFpbCI6ImF4MkBheDIuaXQiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6InhhIiwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSByZWFkOm1lc3NhZ2VzIiwibmJmIjoxNjM1MzQ5NDk2LCJleHAiOjE2MzUzNDk3OTYsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6NDAyMDAvIiwiYXVkIjoiczZCaGRSa3F0MyJ9.21EzfTSrJxSyqL8ubk0IECBY88AeYZ4HF4HDAs6GfXDhDaYZWu1FeA8HH6GMOhJkUwY8iD5R5aCE_b3VXczsQKGR7ziB79GdLj88ouOfFxNogwKFYRrtgBOh2CH5KW2YL7MHSvXKC73dRjq9GvE5-B5doDPHEaRvfg19-kN58R3q0pzUFg11dXU6ikrPFwLTOgE-dpCIba1wIat1Xk2zhbEd6OXv-y7L2E8ewgJBltPdMfgXCkcuf_HEx8IFKOrIGSjj8sRseVIfudLruaNLq4RGq9EB-aI3oqOvDNc94q2DMyca_T8rPUkt22t1SaMpxeUUTgbEKD8-RL_cB6VX4w

E inserito in jwt.io:

Ha funzionato. E se faccio il controllo delle richieste di jwt.io vedo che sono effettuate le richieste alla mia webapplication che ha creato il token e rende disponibile gli endpoint con le informazioni per la sua verifica:

test in jwt.io

E l'utilità di tutto questo? Ora posso utilizzare questo token anche da altre applicazioni con il minimo sforzo e senza dover distribuire manualmente la chiave pubblica dove ne ho bisogno.

In Visual Studio creo la classica web api application e aggiungo queste poche righe di codice:

public void ConfigureServices(IServiceCollection services)
{
  services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
      options.Authority = $"http://{Configuration["Configuration:Domain"]}/";
      options.Audience = Configuration["Configuration:Audience"];
      options.IncludeErrorDetails = true;
      options.RequireHttpsMetadata = false;
    });

  services.AddControllers();
}

I parametri sono solo due, il dominio del generatore di token e l'Audiance, che sarà utilizzato per verificare che il valore presente per quella chiave nel token sia valida per l'applicazione. Nel mio caso ho inserito una sequenza alfanumerica casuale, ma è possibile utilizzare anche il dominio della api che vogliamo utilizzare per esempio.

Infine aggiungo alla funzione Configure il controllo sull'autenticazione (quella sull'autorizzazione è già presente normalmente di default) - app.UseAuthentication() - e creo le due API Rest:

[Route("api")]
[ApiController]
public class ApiController : ControllerBase
{
    [HttpGet("public")]
    public IActionResult Public()
    {
        return Ok(new
        {
            Message = "Hello from a public endpoint! You don't need to be authenticated to see this."
        });
    }

    [HttpGet("private")]
    [Authorize]
    public IActionResult Private()
    {
        return Ok(new
        {
            Message = "Hello from a private endpoint! You need to be authenticated to see this."
        });
    }
}

Ovviamente la prima API sarà richiamabile anche da browser, la seconda da curl o Postman (o da codice) con il token visto prima senza il quale si avrà il classico errore 401.

curl --location --request GET 'http://localhost:40201/api/private' --header 'Authorization: Bearer [token]'

La comodità di tutto questo è che non ho dovuto far altro che aggiungere il modulo AddJwtBearer all'autenticazione e lui ha fatto tutto da solo: per la verifica del token ha richiamato in modo autonomo le api openid-configuration e jwks.json (l'esempio è preso da auth0).

Fine. Per ora basta, e se volessi creare una web application con tanto di identity server/authentication server per sherare l'autenticazione? Forse a breve...

Commenti

Visualizza/aggiungi commenti

| Condividi su: Twitter, Facebook, LinkedIn

Per inserire un commento, devi avere un account.

Fai il login e torna a questa pagina, oppure registrati alla nostra community.

Nella stessa categoria
I più letti del mese