• Eduard Stefanescu

Autentificare JWT utilizând criptarea simetrică în ASP.NET Core


Photo by Andre Manik on Unsplash


Autentificarea JWT devine unul dintre cele mai utilizate tipuri de autentificare în aplicațiile sau serviciile web moderne. În acest articol se va descrie autentificarea JWT cu o cheie simetrică în ASP.NET Core. Prima parte, cuprinde o introducere în ceea ce reprezintă cheia simetrică, iar a doua parte conține condițiile prealabile pentru acest proiect și implementarea efectivă a acestui tip de autentificare. Acest articol este primul articol dintr-o serie de două, al doilea urmând să conțină autentificarea cu o cheie asimetrică folosind un certificat.


Introducere

JWT Token este un mod obișnuit de a crea tokenuri de acces care pot conține mai multe informații (claims) despre cel care trimite requestul, de exemplu, numele de utilizator sau rolurile acestuia. JWT înseamnă JSON (JavaScript Object Notation) Web Token. Fiecare token JWT are următoarea structură:

  • Antetul sau Header, conținând algoritmul de criptare;

  • Sarcina utilă sau Payload, conține informațiile (claims) personalizate. Aceasta conține și informații necesare, care sunt adăugate tokenului în momentul creării:

  • “exp” reprezintă timpul de expirare când tokenul va deveni indisponibil;

  • “iat” sau “Emis la timpul”, reprezintă momentul când a fost creat tokenul; Orele sunt formatate folosind formatul Unix Timestamp (de exemplu 1582784721).

  • Semnătura sau Signature, reprezintă antetul codificat, payload-ul, plus o cheie secretă. Antetul și payload-ul fiind separate printr-un punct. Rezultatul concatenat va fi executat prin algoritmul de criptare specificat în antet pentru a valida tokenul.

Dacă vreți să citiți mai multe despre JWT Token, această lucrare cuprinde toate conceptele: https://tools.ietf.org/html/rfc7519.


Cheia simetrică

Cheia simetrică este utilizată atât pentru semnare, cât și pentru validare. De exemplu, să presupunem că John vrea să împărtășească un secret cu Jane. Când secretul este spus, John îi spune acesteia și o parolă, adică cheia, pentru că secretul să fie înțeles. În acest fel, John - furnizorul de identitate sau serviciul - se asigură că secretul său este bine păstrat prin utilizarea parolei date. Acest exemplu poate fi mai bine înțeles în Imaginea 1 de mai jos, unde este reprezentat conceptul chei simetrice.


Setup

ASP.NET Core 3.1 va fi utilizat pentru acest proiect. Microsoft oferă, de asemenea, un pachet care include tot ceea ce este necesar pentru a crea o autentificare bazată pe token JWT. Pachetul se numește Microsoft.AspNetCore.Authentication.JwtBearer, acesta este singurul pachet de care are nevoie proiectul și poate fi găsit în repository-ul nuget la următoarea adresa:

https://www.nuget.org/packages/Microsoft.AspNetCore.Authentication.JwtBearer.


Crearea unei chei secrete

Cheia de semnare și validare va fi o cheie secretă pentru utilizator. ASP.NET oferă funcția de cheie secretă a utilizatorului pentru a stoca toate datele confidențiale care nu trebuie să fie afișate sau partajate în afara mediului utilizatorului sau dezvoltatorului. Pentru mediile de producție sau testare, cheile trebuie să fie stocate într-un loc sigur în cloud. Acest serviciu este oferit de Microsoft Azure prin Key Vault - https://azure.microsoft.com/en-us/services/key-vault/ -, dar acest lucru va fi un subiect pentru un alt articol.

În primul rând, proiectul trebuie inițializat pentru utilizarea secretelor utilizatorului (user secrets), executând următoarea comandă din Imaginea 2 în folderul proiectului:

dotnet user-secrets init

Imaginea 2. Inițializarea secretelor utilizatorului.

Apoi se adaugă cheia secretă a utilizatorului, folosind următoarea comandă din Imaginea 3:

dotnet user-secrets set "AppSettings:EncryptionKey" "POWERFULENCRYPTIONKEY"

Imaginea 3. Adăugarea chei secrete pentru criptarea ulterioară.

Această comandă va adăuga în fișierul secrets.json cheia AppSettings: EncryptionKey cu valoarea POWERFULENCRYPTIONKEY.

Dacă există mai multe valori pentru AppSettings, atunci această cheie poate deveni mai lizibilă utilizând un format JSON, după cum se poate observa în Imaginea 4:

"AppSettings": {
  "EncryptionKey": "POWERFULENCRYPTIONKEY",
  "Key2": "Value2" 
}

Imaginea 4. Ilustrarea mai multor valori aparținând de cheia AppSettings.

Cheia de criptare POWERFULENCRYPTIONKEY va fi codificată într-o matrice de octeți și apoi acest binar va fi codificat în Base64, acest lucru este necesar atât pentru semnare, cât și pentru validare.


Startup

În metoda ConfigureServices din clasa Startup, trebuie citită secțiunea din fișierul AppSettings descrisă mai sus. Pentru a citi un tip din fișierul de configurare, trebuie creată o clasă, deci pentru secțiunea AppSettings trebuie să existe o clasă echivalentă, așa cum este reprezentat în secțiunea de cod de mai jos. Această clasă, din Imaginea 5, poate fi văzută ca un obiect de transfer de date (DTO), care conține proprietăți simple.

public class AppSettings
{
    public string EncryptionKey { get; set; }
}

Imaginea 6. Clasa UserCredentials.


AuthenticationService

Clasa AuthenticationService este utilizată că un middleware care primește UserCredentials de la Controller, le validează folosind UserService, iar dacă credențialele sunt valide, creează un token folosind TokenService. Atât serviciul User, cât și serviciile token sunt injectate în constructor. Așa cum se poate observa în Imaginea 7, unde se află implementare metodei de autentificare a utilizatorului.

public string Authenticate(UserCredentials userCredentials)
{
    userService.ValidateCredentials(userCredentials);
    string securityToken = tokenService.GetToken();

    return securityToken;
}

Imaginea 7. Metoda de autentificare a utilizatorului, primind că parametru credentialele acestuia.


UserService

De dragul acestui exemplu, UserService conține o listă de utilizatori creați în interiorul constructorului, precum în Imaginea 8. Într-un scenariu din viața reală, acest lucru va fi citit dintr-un spațiu de stocare sau dintr-un serviciu.

public class UserService
{
    private readonly IEnumerable<UserCredentials> users;

    public UserService()
    {
        users = new List<UserCredentials>
        {
            new UserCredentials
            {
                Username = "john.doe",
                Password = "john.password"
            }
        };
    }
...

Imaginea 8. Crearea utilizatorului în constructor.

Acesta seamănă mai mult cu un serviciu UserValidation, dar pentru a ilustra mai bine că citește și utilizatorii, numele UserService va fi păstrat.

...
    public void ValidateCredentials(UserCredentials userCredentials)
    {
        bool isValid =
            users.Any(u =>
                u.Username == userCredentials.Username &&
                u.Password == userCredentials.Password);

        if (!isValid)
        {
            throw new InvalidCredentialsException();
        }
    }
}

Imaginea 9. Metoda pentru validarea credențialelor.

Metoda ValidateCredentials din Imaginea 9, verifică dacă există perechea nume de utilizator și parolă, iar dacă această condiție nu este îndeplinită, va arunca excepția InvalidCredentialsException care va fi prinsă mai apoi în Controller.


TokenService

TokenService primește pe constructor AppSettings, care va fi utilizat în metoda GetTokenDescriptor pentru a configura tokenul, așa cum este reprezentat in Imaginea 10.

public class TokenService
{
    private readonly AppSettings appSettings;

    public TokenService(IOptions<AppSettings> options)
    {
        appSettings = options.Value;
    }
...

Imaginea 10. Clasa TokenService, care incapsuleaza crearea descrierii tokenului.


Metoda publică GetToken, din Imaginea 11, este utilizată pentru a obține descrierea tokenului, pentru a crea tokenul și a-l scrie într-un șir de caractere (string), ca mai apoi sa fie returnat serviciului care îl apelează. În acest caz va fi apelat de AuthenticationService.

public string GetToken()
{
    SecurityTokenDescriptor tokenDescriptor = GetTokenDescriptor();
    var tokenHandler = new JwtSecurityTokenHandler();
    SecurityToken securityToken = tokenHandler.CreateToken(tokenDescriptor);
    string token = tokenHandler.WriteToken(securityToken);

    return token;
}

Imaginea 11. Metoda care va crea tokenul.


În metoda GetTokenDescriptor, este construit tokenul. În această metodă, din Imaginea 12, sunt setate ExpirationTime și SigningCredentials. Deoarece informațiile din token (claims) nu se află în centrul principal al acestui articol, voi crea altul, în care voi explica cum pot fi setate informații suplimentare (claims) pe token, dar și cum acestea pot fi utilizate.

private SecurityTokenDescriptor GetTokenDescriptor()
{
    const int expiringDays = 7;

    byte[] securityKey = Encoding.UTF8.GetBytes(appSettings.EncryptionKey);
    var symmetricSecurityKey = new SymmetricSecurityKey(securityKey);

    var tokenDescriptor = new SecurityTokenDescriptor
    {
        Expires = DateTime.UtcNow.AddDays(expiringDays),
        SigningCredentials = new SigningCredentials(symmetricSecurityKey, SecurityAlgorithms.HmacSha256Signature)
    };

    return tokenDescriptor;
}

Imaginea 12. Metoda utilizată pentru obținerea descrierii tokenului.


Toți descriptorii pentru token pot fi găsiți pe site-ul web Microsoft:

https://docs.microsoft.com/en-us/dotnet/api/system.identitymodel.tokens.securitytokendescriptor.


AuthenticationController

Acum, tot ce trebuie să facem este să creăm AuthenticationController care primește UserCredentials și utilizează AuthenticationService creat anterior.

Pe constructor se injectează AuthenticationService, pentru a fi utilizat de endpointul pentru autentificare, după cum se poate observa în Imaginea 13.

[Route("identity/[controller]")]
public class AuthenticationController : ControllerBase
{
    private readonly AuthenticationService authenticationService;

    public AuthenticationController(AuthenticationService authenticationService)
    {
        this.authenticationService = authenticationService;
    }
...

Imaginea 13. Prima partea a clasei AuthenticationController, în care este reprezentat constructorul.


Endpointul pentru autentificare, din Imaginea 14, acceptă requestul HTTP Post, primește UserCredentials așa cum s-a menționat anterior și folosește AuthenticationService pentru autentificarea și crearea tokenului.

...
    [HttpPost]
    public IActionResult Authenticate([FromBody] UserCredentials userCredentials)
    {
        try
        {
            string token = authenticationService.Authenticate(userCredentials);
            return Ok(token);
        }
        catch (InvalidCredentialsException)
        {
            return Unauthorized();
        }
    }
}

Imaginea 14. Endpointul utilizat pentru autentificarea utilizatorului.


Dacă credentialele sunt valide, atunci endpointul va returna codul HTTP, OK și tokenul generat. În caz contrar, dacă este prinsă excepția InvalidCredentialsException, se returnează codul de stare HTTP Unauthorized.


ValidationController

Scopul clasei ValidationController, din Imaginea 15, este de a verifica dacă procesul de semnare funcționează, pentru a valida tokenul.

Route("identity/[controller]")]
public class ValidationController : ControllerBase
{
    [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
    public IActionResult Validate()
    {
        return Ok();
    }
}

Imaginea 15. Clasa Validation Controller și metoda de validare a tokenului.


Se poate observa că endpointul Validate are AuthorizeAttribute avand pe constructor aceleași AuthenticationSchemes așa cum a fost setat în serviciul Authentication. Acest aspect este important, pentru că ambele clase trebuie sa fie sub aceeași schema, astfel încât autentificarea și validarea să funcționeze.


Rezultatul

Prima dată se va testa AuthenticationController, așa că se va oferi combinația corectă de nume de utilizator și parolă, pentru a primi tokenul, precum în Imaginea 16.

Imaginea 16. Requestul pentru autentificarea utilizatorului și răspunsul primit în Postman.


Iar în Imaginea 17, se va testa același endpoint, dar se vor trimite credențialele incorecte. În acest caz răspunsul ar trebui să fie Unauthorized.

Imaginea 17. Requestul neautorizat pentru autentificare furnizând credențiale incorecte.


În al doilea caz de testare, din Imaginea 18, se va verifica endpointul Validate. Primul test va fi cu tokenul generat, pentru a valida că acesta este corect generat, iar API-ul îl accepta.

Imaginea 18. Requestul acceptat pentru validarea tokenului.


Iar în al doilea test, pentru acest caz, se va testa un token care nu a fost generat de API. Acesta nerecunoscandu-l, va întoarce codul HTTP Unauthorized, așa cum se poate observa în Imaginea 19.

Imaginea 19. Requestul pentru validare, conținând un token care nu a fost generat de serviciu.


Codul sursă din acest articol poate fi găsit pe contul meu GitHub: StefanescuEduard/JwtAuthentication.


Îți mulțumim că ai citit acest articol, dacă ți se pare interesant, te rugăm să-l distribui colegilor și prietenilor. Dacă găsești ceva care poate fi îmbunătățit, suntem deschiși ideilor care pot crește calitatea acestui articol, dar în același timp și al comunității.