08

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

Partea 6 din 6

Parte 6 — Recap, Footguns și Exerciții

Pasul 11 — Structura finală a fișierelor noi

Controllers/
    Api/
        ArticlesApiController.cs   ← GET/POST/PUT/DELETE + ownership
        AuthApiController.cs       ← Login + GenerateJwt
DTOs/
    ArticleDto.cs                  ← record — response
    CreateArticleDto.cs            ← record — POST body
    UpdateArticleDto.cs            ← record — PUT body
    LoginDto.cs                    ← record — login body
Mappings/
    ArticleMappings.cs             ← extension methods ToDto / ToEntity / ApplyTo

Modificate:

Program.cs                         ← AddSwaggerGen, AddJwtBearer, UseSwagger, UseSwaggerUI
appsettings.json                   ← secțiunea Jwt

Fără breaking changes — MVC-ul din Lab 7 continuă să funcționeze identic.


Unde vă puteți arde

Lista scurtă de footgun-uri care fac request-uri să pice silent — verificați aici înainte să rulați /exit:

  1. AddAuthentication(JwtBearerDefaults.AuthenticationScheme) în loc de AddAuthentication(). Reflexul din tutoriale online. Strică Identity (schema default e acum JWT, nu Cookie) → login-ul MVC /Auth/Login nu mai setează cookie → MVC-ul rămâne „rupt" silent. Fix: AddAuthentication() fără argument — Identity a setat deja Cookies ca default.
  2. [Authorize] fără AuthenticationSchemes = ... pe endpoint API. Cu schema default Cookie, API-ul răspunde cu 302 redirect către /Auth/Login în loc de 401 JSON. Clientul Swagger/Postman primește HTML în loc de JSON și nu înțelege ce se întâmplă. Fix: [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] explicit pe POST/PUT/DELETE.
  3. Swashbuckle 10.x în loc de 6.9.0. Dacă rulați dotnet add package Swashbuckle.AspNetCore fără --version 6.9.0 sau dacă adăugați prin NuGet UI și dați pe „Latest", primiți 10.x care a rescris API-ul (Microsoft.OpenApi.Models nu mai există) și laboratorul nu compilează. Fix: --version 6.9.0 explicit — vedeți Pasul 1.
  4. Cheie JWT sub 32 de bytes. SymmetricSecurityKey aruncă ArgumentOutOfRangeException la startup — nici măcar nu pornește aplicația. Mesajul e obscur (IDX10720), ușor de ratat. Fix: numărați caracterele din Jwt:Key, trebuie minim 32.
  5. Copy-paste de pe site în C# cu „" tipografic. Dacă browser-ul normalizează quote-urile tipografice, string-ul se termină prematur și codul nu compilează. Fix: blocurile de cod din acest lab sunt ASCII-only intenționat; dacă vedeți erori de sintaxă pe string-uri, înlocuiți manual /" cu " straight.
  6. [ApiController] uitat. Fără el, 400 Bad Request automat pentru validare nu funcționează, iar binding-ul din body al DTO-ului poate cere [FromBody] explicit. Fix: mereu pe clasa API, deasupra [Route(...)].
  7. Portul 7001 ocupat. Dacă Lab 7 rulează încă într-o altă fereastră, Lab 8 nu pornește (conflict pe HTTPS). Fix: Ctrl+C pe cel vechi, sau schimbați port în Properties/launchSettings.json.
  8. Token expirat în timpul testării. 60 de minute e mult pentru un demo, dar puțin când povestiți. Dacă primiți 401 după ce ați fost autentificat, re-login și copiați token nou.

Test end-to-end — checklist

  1. dotnet run → aplicația pornește fără erori
  2. / (MVC Home) → funcționează cu cookie auth
  3. /swagger → Swagger UI afișează endpoint-urile
  4. GET /api/articlesapi din Swagger → listă JSON de articole
  5. POST /api/authapi/login cu admin@newsportal.com / Admin@123 → primim token
  6. Decodăm token-ul pe jwt.io — vedem name, email, role: Admin
  7. Click „Authorize" în Swagger, lipim token-ul
  8. POST /api/articlesapi cu CreateArticleDto201 Created, header Location
  9. PUT /api/articlesapi/{id} ca user diferit → 403 Forbidden
  10. DELETE /api/articlesapi/{id} fără token → 401 Unauthorized

Exerciții (4p)

Lab-ul e structurat ca patru checkpoints — fiecare exercițiu acoperă o porțiune din parcurs. Parcurgerea lab-ului (Parte 1 → Parte 5) vă duce prin Ex 1–3. Ex 4 e peste lab.

Exercițiul 1 — API read-only + DTOs + Swagger (1p)

Acoperă: Parte 1 (DTOs + Mappings) + Pasul 4 (GET) + Parte 3 (Swagger).

Merg toate simultan:

  • DTOs/ArticleDto.cs, CreateArticleDto.cs, UpdateArticleDto.cs, LoginDto.cs există și sunt records cu sintaxă pozițională.
  • Mappings/ArticleMappings.cs are ToDto(), ToDtoList(), ToEntity(), ApplyTo() — toate extension methods.
  • Controllers/Api/ArticlesApiController.cs are GET /api/articlesapi și GET /api/articlesapi/{id} (fără [Authorize] pe ele — lectură publică).
  • GET /api/articlesapi200 OK + listă de ArticleDto în JSON.
  • GET /api/articlesapi/999 (id inexistent) → 404 Not Found.
  • https://localhost:7001/swagger se deschide și arată endpoint-urile API.

Exercițiul 2 — CRUD complet + JWT Login (1p)

Acoperă: Pasul 5 (POST/PUT/DELETE) + Parte 4 (JWT configurare + AuthApiController).

Merg toate simultan:

  • POST /api/articlesapi, PUT /api/articlesapi/{id}, DELETE /api/articlesapi/{id} există, fiecare cu [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)].
  • appsettings.json are secțiunea Jwt cu Key ≥ 32 caractere, Issuer, Audience, ExpiresInMinutes.
  • Controllers/Api/AuthApiController.cs are POST /api/authapi/login care întoarce { "token": "...", "expiresIn": 3600 }.
  • Program.cs configurează AddAuthentication().AddJwtBearer(...) cu TokenValidationParameters complete (Issuer/Audience/Lifetime/SigningKey).
  • POST /api/articlesapi fără token → 401 Unauthorized.
  • POST /api/articlesapi cu Authorization: Bearer <token> valid → 201 Created + header Location.
  • Token-ul decodat pe jwt.io conține sub, name, email, role.

Exercițiul 3 — Content ownership + Swagger „Authorize" (1p)

Acoperă: IsOwnerOrAdmin din Pasul 5 (testabil abia după Ex 2) + Parte 5 (buton Authorize în Swagger).

Merg toate simultan:

  • IsOwnerOrAdmin(article) există ca helper privat și verifică article.AuthorId == currentUserId || User.IsInRole("Admin").
  • PUT / DELETE ca user non-admin și non-autor → 403 Forbidden.
  • Aceleași request-uri ca Admin (sau ca autorul articolului) → 204 No Content.
  • AddSwaggerGen are AddSecurityDefinition("Bearer", ...) + AddSecurityRequirement(...) — butonul „Authorize" apare în Swagger UI.
  • După click pe „Authorize" și paste token, POST/PUT/DELETE merg direct din Swagger (header-ul e trimis automat).

Exercițiul 4 — Paginare pe API (1p)

Acoperă: muncă peste lab — nu e în walkthrough.

Hint-uri:

  • Nu rescrieți GET-ul existent — adăugați o acțiune nouă sau suportați page/pageSize pe cea curentă, cu default-uri ca să nu spargeți comportamentul GET-all.
  • Refolosiți _articleService.GetPagedAsync(page, pageSize, ct) și _articleService.CountAsync(ct) din Lab 6. Nu scrieți Skip/Take în controller.
  • totalPages = (int)Math.Ceiling((double)totalCount / pageSize).
  • Validare: page < 1 sau pageSize < 1BadRequest. pageSize > 100 → clamp la 100 (anti-DoS prin pageSize=999999).
  • Atenție Task.WhenAll: cele două apeluri folosesc același DbContext (Scoped per request). EF Core aruncă InvalidOperationException: A second operation was started on this context... pe query-uri concurente. Rulați serial, sau injectați IDbContextFactory<AppDbContext> pentru contexte separate. Paragraful din Pasul 10 e valabil pentru HttpClient / contexte separate — nu pentru același DbContext.

Merg toate simultan:

  • DTOs/PagedResultDto.cs există ca record generic:
    public record PagedResultDto<T>(
        IReadOnlyList<T> Items,
        int TotalCount,
        int Page,
        int PageSize,
        int TotalPages);
    
  • GET /api/articlesapi?page=1&pageSize=10200 OK cu body de forma:
    {
      "items": [ /* ArticleDto[] */ ],
      "totalCount": 42,
      "page": 1,
      "pageSize": 10,
      "totalPages": 5
    }
    
  • GET /api/articlesapi?page=0&pageSize=10400 Bad Request (sau clamp la page=1).
  • GET /api/articlesapi?page=1&pageSize=999pageSize clamp la 100 (sau 400).

Total: 4p — 1p pe fiecare exercițiu.

Exercițiile se rezolvă în timpul laboratorului. Fără breaking changes pe partea MVC — dacă ați spart login-ul cu cookie, ați greșit configurarea schemelor.

Referință rapidă — Postman

Înainte de demo: creați un user non-admin din UI la /Auth/Register (ex. user@test.com / User@123). Tokenurile se obțin din pașii 1–2 sau din Swagger la /swagger.


Auth

Login admin → copiați token-ul

POST https://localhost:7001/api/authapi/login
Content-Type: application/json
{
    "email": "admin@newsportal.com",
    "password": "Admin@123"
}

200 OK{ "token": "eyJ...", "expiresIn": 3600 }


Login user non-admin → copiați token-ul

POST https://localhost:7001/api/authapi/login
Content-Type: application/json
{
    "email": "user@test.com",
    "password": "User@123"
}

Articole — read (fără token)

GET https://localhost:7001/api/articlesapi

200 OK

GET https://localhost:7001/api/articlesapi/1

200 OK

GET https://localhost:7001/api/articlesapi/99999

404 Not Found


Articole — create

Fără token

POST https://localhost:7001/api/articlesapi
Content-Type: application/json
{
    "title": "Test fara token",
    "content": "Continut suficient de lung pentru validare",
    "categoryId": 1
}

401 Unauthorized


Cu token admin

POST https://localhost:7001/api/articlesapi
Content-Type: application/json
Authorization: Bearer TOKENUL_ADMINULUI
{
    "title": "Articol din Postman",
    "content": "Continut suficient de lung pentru validare",
    "categoryId": 1
}

201 Created — rețineți id-ul din răspuns


Articole — update

Fără token

PUT https://localhost:7001/api/articlesapi/1
Content-Type: application/json
{
    "title": "Incerc sa modific",
    "content": "Continut suficient de lung pentru validare",
    "categoryId": 1
}

401 Unauthorized


Ca non-owner ← bug Forbid()

PUT https://localhost:7001/api/articlesapi/1
Content-Type: application/json
Authorization: Bearer TOKENUL_USERULUI
{
    "title": "Incerc sa suprascriu",
    "content": "Continut suficient de lung pentru validare",
    "categoryId": 1
}

Așteptat: 403 Forbidden → Primit: 200 OK + HTML (redirect la login)

Forbid() fără argument cade pe schema cookie → redirect 302 → Postman urmează redirect-ul → pagina de login → 200 OK. Fix în Lab 9: Forbid(JwtBearerDefaults.AuthenticationScheme).


Ca owner

PUT https://localhost:7001/api/articlesapi/1
Content-Type: application/json
Authorization: Bearer TOKENUL_ADMINULUI
{
    "title": "Titlu modificat de owner",
    "content": "Continut suficient de lung pentru validare",
    "categoryId": 1
}

204 No Content


Articole — delete

Fără token

DELETE https://localhost:7001/api/articlesapi/1

401 Unauthorized


Ca non-owner ← bug Forbid()

DELETE https://localhost:7001/api/articlesapi/1
Authorization: Bearer TOKENUL_USERULUI

Așteptat: 403 Forbidden → Primit: 200 OK + HTML (același bug)


Ca owner

DELETE https://localhost:7001/api/articlesapi/ID_DIN_PAS_CREATE
Authorization: Bearer TOKENUL_ADMINULUI

204 No Content