08

Lab 08 - Web API, DTOs, Swagger si JWT Authentication

Partea 4 din 6

Parte 4 — JWT Authentication

Pasul 7 — Configurare JWT

appsettings.json — adăugați secțiunea Jwt

Nu rescrieți fișierul. Păstrați ConnectionStrings, Logging, AllowedHosts așa cum sunt. Adăugați doar blocul Jwt, la nivelul rădăcinii JSON-ului (frați cu ConnectionStrings):

"Jwt": {
  "Key": "SuperSecretKeyThatIsAtLeast32BytesLong!!!!",
  "Issuer": "NewsPortal",
  "Audience": "NewsPortalUsers",
  "ExpiresInMinutes": 60
}

Rezultatul final (exemplu pe Lab08_start):

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "DefaultConnection": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=Lab08_start;..."
  },
  "Jwt": {
    "Key": "SuperSecretKeyThatIsAtLeast32BytesLong!!!!",
    "Issuer": "NewsPortal",
    "Audience": "NewsPortalUsers",
    "ExpiresInMinutes": 60
  }
}

Cheia trebuie să aibă minim 32 bytes (256 bits) pentru HMAC-SHA256. Dacă e mai scurtă, SymmetricSecurityKey aruncă o excepție la startup.

Atenție în producție: cheia nu se ține în appsettings.json urcat pe git. În dezvoltare e ok. În producție — user secrets, variable de mediu sau Azure Key Vault / Vault / etc.

Controllers/Api/AuthApiController.cs — fișier nou

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Lab08.DTOs;
using Lab08.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;

namespace Lab08.Controllers.Api;

[ApiController]
[Route("api/[controller]")]
public class AuthApiController : ControllerBase
{
    private readonly UserManager<ApplicationUser> _userManager;
    private readonly SignInManager<ApplicationUser> _signInManager;
    private readonly IConfiguration _configuration;

    public AuthApiController(
        UserManager<ApplicationUser> userManager,
        SignInManager<ApplicationUser> signInManager,
        IConfiguration configuration)
    {
        _userManager = userManager;
        _signInManager = signInManager;
        _configuration = configuration;
    }

    // POST: /api/authapi/login
    [HttpPost("login")]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
    public async Task<IActionResult> Login(LoginDto dto)
    {
        var user = await _userManager.FindByEmailAsync(dto.Email);
        if (user == null)
            return Unauthorized(new { message = "Email sau parolă incorectă" });

        // IMPORTANT: CheckPasswordSignInAsync — NU setează cookie, doar verifică parola
        var result = await _signInManager.CheckPasswordSignInAsync(user, dto.Password, lockoutOnFailure: false);
        if (!result.Succeeded)
            return Unauthorized(new { message = "Email sau parolă incorectă" });

        var token = await GenerateJwtAsync(user);
        return Ok(new { token, expiresIn = _configuration.GetValue<int>("Jwt:ExpiresInMinutes") * 60 });
    }

    private async Task<string> GenerateJwtAsync(ApplicationUser user)
    {
        var claims = new List<Claim>
        {
            new(ClaimTypes.NameIdentifier, user.Id),
            new(ClaimTypes.Name, user.UserName!),
            new(ClaimTypes.Email, user.Email!)
        };

        // Adaugă rolurile ca și claims — pentru [Authorize(Roles = "Admin")]
        var roles = await _userManager.GetRolesAsync(user);
        foreach (var role in roles)
            claims.Add(new Claim(ClaimTypes.Role, role));

        var key = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(_configuration["Jwt:Key"]!));
        var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var token = new JwtSecurityToken(
            issuer: _configuration["Jwt:Issuer"],
            audience: _configuration["Jwt:Audience"],
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(
                _configuration.GetValue<int>("Jwt:ExpiresInMinutes")),
            signingCredentials: credentials);

        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}

Ce face CheckPasswordSignInAsync vs PasswordSignInAsync:

Metodă Efect
PasswordSignInAsync Verifică parola și setează cookie-ul de sesiune
CheckPasswordSignInAsync Doar verifică parola — nu setează nimic

Pentru API vrem varianta a doua — nu ne interesează cookies, generăm manual JWT-ul.

Configurare JWT în Program.cs

Adăugați using-urile în capul fișierului:

using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;

Și după blocul AddIdentity (care rămâne neschimbat), adăugați schema JWT:

// JWT Bearer — pentru API
builder.Services.AddAuthentication()
    .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:Key"]!))
        };
    });

Atenție — AddAuthentication() fără argument! Identity a setat deja schema default (Cookies). Dacă dați AddAuthentication(JwtBearerDefaults.AuthenticationScheme), anulați Identity și MVC-ul încetează să mai meargă.

[Authorize] pe API cu schema JWT

Atributul [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] l-am pus deja pe POST, PUT, DELETE la Pasul 5. Acum, că schema JWT e înregistrată în Program.cs, endpoint-urile protejate vor accepta token în header-ul Authorization: Bearer <token> și vor întoarce 401 când lipsește.

GET-urile (GetAll, GetById) rămân fără [Authorize] — lectură publică.

Alternativă stilistică — atribut pe clasă: dacă toate endpoint-urile protejate folosesc aceeași schemă, puteți scoate [Authorize(...)] de pe fiecare acțiune și să-l puneți o singură dată pe clasă, cu [AllowAnonymous] pe acțiunile publice:

[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public class ArticlesApiController : ControllerBase
{
    [HttpGet]
    [AllowAnonymous]
    public async Task<ActionResult<List<ArticleDto>>> GetAll(...) { ... }

    // POST, PUT, DELETE — moștenesc [Authorize] de pe clasă
}

Pentru lab rămânem pe varianta per-acțiune din Pasul 5 — mai explicit, mai ușor de urmărit.


Pasul 8 — JWT Deep Dive + jwt.io

Structura unui JWT

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJuYW1lIjoiSm9obiJ9.SflKxwRJSMeKKF2Q...
└─────────── Header ───────────┘.└──────── Payload ────────┘.└──── Signature ────┘

Trei părți, separate prin ., fiecare Base64Url-encoded:

  1. Header{ "alg": "HS256", "typ": "JWT" } — algoritmul de semnare
  2. Payload — claim-urile — sub, name, email, role, exp, iss, aud
  3. SignatureHMAC-SHA256(base64(header) + "." + base64(payload), secretKey)

Întrebări cheie

E JWT-ul criptat? Nu. E semnat. Base64 nu e criptare — oricine poate decoda payload-ul. Deschideți jwt.io, lipiți token-ul, vedeți payload-ul în clar. Semnătura garantează că nu a fost modificat — dacă cineva schimbă un claim, signature-ul nu mai corespunde și serverul respinge token-ul.

Ce nu punem niciodată în JWT? Parole, date sensibile, numere de card, CNP-uri. Payload-ul e public.

HMAC-SHA256 e simetric sau asimetric? Simetric — aceeași cheie la semnare și la verificare. Funcționează aici pentru că un singur server generează și verifică. Dacă am avea mai multe servicii care verifică (dar doar unul semnează), am folosi RS256 (asimetric — cheie privată pentru semnare, publică pentru verificare).

Unde e stocat token-ul pe client? localStorage, sessionStorage sau cookie HttpOnly. Fiecare cu trade-off-urile lui legate de XSS și CSRF. localStorage e cel mai comun în SPA-uri (Angular, React).

Ce face clientul când expiră token-ul? Primește 401, redirecționează la login sau folosește un refresh token (pattern avansat — nu la acest lab).

Testare end-to-end în Postman

  1. Creez collection „News Portal API"
  2. POST /api/authapi/login — body JSON:
    { "email": "admin@newsportal.com", "password": "Admin@123" }
    
    Primesc:
    { "token": "eyJhbGciOi...", "expiresIn": 3600 }
    
  3. Copiez token-ul și îl lipesc pe jwt.io:
    • Header: { "alg": "HS256", "typ": "JWT" }
    • Payload: claims (sub, name, email, role, exp, iss, aud)
    • Signature: marcată roșie până lipesc și cheia secretă
  4. GET /api/articlesapi — funcționează fără token (read public).
  5. POST /api/articlesapi fără token → 401 Unauthorized.
  6. POST /api/articlesapi cu header Authorization: Bearer <token>201 Created.
  7. PUT /api/articlesapi/1 ca alt user → 403 Forbidden (content ownership).

Tip Postman: la Collection level, în tab-ul „Variables", definiți {{token}}. Apoi fiecare request autentificat folosește Authorization: Bearer {{token}}. După login, copiați token-ul în variabilă o singură dată.