diff --git a/README.md b/README.md index 9f022b4e..a83e0b0c 100644 --- a/README.md +++ b/README.md @@ -73,3 +73,13 @@ A clone logs to the console and ships nothing anywhere. The server URL is the on ## Frontend–API communication Typed clients in `EvilCase.Api.Client` are generated at build time from the API controller sources by a source generator, so client and server cannot drift. Controllers are the single source of truth, DTOs are shared through `EvilCase.Api.Contract`, and controller conventions are enforced by analyzers at error severity. The app is served by the API host, so calls are same-origin and no CORS is involved. + +## Integrations over REST + +An integration authenticates with a personal API token. Create one in Nastavení → API tokeny (`/settings/api-tokens`) with a name, a scope (Jen čtení or Čtení i zápis) and an expiry of 30, 90 or 365 days; the token is shown once. Send it on every request: + +``` +curl -H "Authorization: Bearer evc_…" "https://localhost:5000/api/cases?take=20" +``` + +The token acts as its user, tenant included. A read-only token gets 403 on every write, and an unknown, revoked or expired token gets 401. `/api/auth/**` and token management accept the browser session only. `/openapi/v1.json` describes every endpoint and the Bearer scheme, and is anonymous in every environment. diff --git a/docs/design/README.md b/docs/design/README.md index 748c53e7..c8326026 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -29,7 +29,8 @@ Otevři je v prohlížeči přímo ze souboru. | [`hledani.html`](hledani.html) | Hledání v horní liště, otevřená nabídka | všechny obrazovky | | [`kontakty.html`](kontakty.html) | Kontakty | `/contacts` | | [`detail-kontaktu.html`](detail-kontaktu.html) | Detail kontaktu | `/contacts/{id}` | -| [`nastaveni.html`](nastaveni.html) | Nastavení, sekce štítků | `/settings` | +| [`nastaveni.html`](nastaveni.html) | Nastavení, sekce štítků | `/settings/labels` | +| [`nastaveni-api-tokeny.html`](nastaveni-api-tokeny.html) | Nastavení, sekce API tokenů | `/settings/api-tokens` | Text v hranatých závorkách je zástupný: vzorová data pro něj hodnotu nemají. Implementace na jeho místě ukazuje skutečnou hodnotu, nebo prázdný stav podle SDD dané agendy. diff --git a/docs/design/nastaveni-api-tokeny.html b/docs/design/nastaveni-api-tokeny.html new file mode 100644 index 00000000..8f081e17 --- /dev/null +++ b/docs/design/nastaveni-api-tokeny.html @@ -0,0 +1,269 @@ + + + + + +nastaveni-api-tokeny – EvilCase + + + + +
+ +
+ + + + + EvilCase + + +
+ + +
+ +
+

Nastavení

+ +
+ + +
+
+
+

API tokeny 3

+ Přístup pro integrace a AI agenty, kteří jednají vaším jménem +
+
+ +
+
+ NázevOprávněníVytvořenPlatí doNaposledy použit +
+ +
+ Claude Code – pracovní notebook + Čtení i zápis + 30. 10. 2026 + 28. 1. 2027 + 30. 10. 2026 17:42 + + + +
+ +
+ Přehled lhůt do kalendáře + Jen čtení + 2. 9. 2026 + 2. 9. 2027 + Nepoužit + + + +
+ +
+ Zkušební skript + Jen čtení + 1. 7. 2026 + Vypršel + 29. 7. 2026 09:15 + + + +
+ +
Token jedná vaším jménem a vidí totéž co vy. Zrušený token přestane platit okamžitě.
+
+
+ +
+ +
+ Nový token +
+
+

Nový token

+ +
+
+
+ + + Podle čeho token poznáte, nejvýše 64 znaků +
+
+ Oprávnění + + +
+
+ Platnost +
+ + + +
+ Platí do 28. 1. 2027 +
+
+
+ + +
+
+
+ +
+
+ Token vytvořen · zobrazí se jen jednou +
+
+

Token vytvořen

+
+
+
+ + Zkopírujte si token teď. Po zavření okna už ho znovu neuvidíte. +
+
+ +
+ + +
+ Čtení i zápisplatí do 28. 1. 2027 +
+
+
+ +
+
+
+ +
+ Zrušit token +
+
+

Zrušit token

+
+

Opravdu zrušit token Claude Code – pracovní notebook? Integrace, která ho používá, ztratí přístup hned. Zrušení nejde vrátit.

+
+ + +
+
+
+
+ +
+ Prázdný seznam +
+ + + + Zatím tu není žádný token. + +
+
+
+ +
+ Mobil · 390 px +
+

Nastavení

+ +
+
+
+

API tokeny 3

+ Pro integrace a AI agenty +
+ +
+
+
+ Claude Code – pracovní notebook + Čtení i zápisdo 28. 1. 2027 + Použit 30. 10. 2026 17:42 +
+ +
+
+
+ Přehled lhůt do kalendáře + Jen čtenído 2. 9. 2027 + Nepoužit +
+ +
+
+
+ Zkušební skript + Jen čteníVypršel + Použit 29. 7. 2026 09:15 +
+ +
+
Token jedná vaším jménem a vidí totéž co vy. Zrušený token přestane platit okamžitě.
+
+
+
+
+
+ + diff --git a/docs/design/nastaveni.html b/docs/design/nastaveni.html index 6f1bb1fb..3d387095 100644 --- a/docs/design/nastaveni.html +++ b/docs/design/nastaveni.html @@ -10,6 +10,7 @@ a { color: #1C3A9F; text-decoration: none; } a:hover { color: #1F3AA8; } .row:hover { background: #F8F9FB; } .navlink:hover { color: #FFFFFF; background: rgba(255,255,255,0.06); } + .setlink:hover { background: #F1F2F5; } .dark-input::placeholder { color: #A0A6B1; } .iconbtn:hover { background: #F1F2F5; } .iconbtn.danger:hover { background: #FDECEA; } @@ -55,6 +56,12 @@

Štítky + + API tokeny + + + Export +
@@ -216,6 +223,8 @@

Nastaven
diff --git a/docs/sdd/sdd-005-api-konvence.md b/docs/sdd/sdd-005-api-konvence.md index 6c252577..86f85fd7 100644 --- a/docs/sdd/sdd-005-api-konvence.md +++ b/docs/sdd/sdd-005-api-konvence.md @@ -19,12 +19,14 @@ Tvary API a klienta. jako Problem Details, nikdy `index.html` frontendu. - Business služba vrací kontraktní DTO; žádná druhá sada modelů. - Endpoint bez výslovné výjimky vyžaduje přihlášení. Anonymní jsou přihlášení, obnova tokenu, - odhlášení, health checks, upload klientských logů, 404 pod `/api` a v Development `/openapi` - a `/scalar`. + odhlášení, health checks, upload klientských logů, 404 pod `/api`, v každém prostředí + `/openapi/v1.json` a v Development `/scalar`. +- Přihlášení nese cookie, nebo API token v hlavičce `Authorization: Bearer` (SDD-006). OpenAPI + dokument deklaruje schéma Bearer. - Založení odpovídá 201 s hlavičkou `Location` na detail nového záznamu; u souboru a verze míří na - stažení obsahu, detail souboru není. Upload, který nic neuloží, protože se obsah shoduje - s aktuální verzí, odpovídá 200. Komentář odpovídá 204 i na založení. Editace a smazání - odpovídají 204. + stažení obsahu, detail souboru není. Založení API tokenu odpovídá 201 bez `Location`, protože + token nemá detail. Upload, který nic neuloží, protože se obsah shoduje s aktuální verzí, + odpovídá 200. Komentář odpovídá 204 i na založení. Editace a smazání odpovídají 204. - V těle jde hodnota výčtu jako název; číslo ani neznámý název se nepřijme (400). V query stringu ji váže framework, který číslo bere. - Každý výpis bere stránku, směr řazení, svůj řadicí klíč a hledaný text; k tomu zúžení své @@ -40,7 +42,8 @@ Tvary API a klienta. Limitem prochází celé `/api/auth/**`, přihlášené endpointy včetně, a upload klientských logů; partition je adresa volajícího: přihlášení 5 za minutu, obnova tokenu 60, zbytek `/api/auth/**` 10, upload klientských logů 120. -Vše ostatní je bez limitu, health checks včetně. Odmítnutí je 429 s `Retry-After`. +Vše ostatní je bez limitu, health checks a požadavky s API tokenem včetně. Odmítnutí je 429 +s `Retry-After`. ### Zdroje @@ -53,6 +56,7 @@ Vše ostatní je bez limitu, health checks včetně. Odmítnutí je 429 s `Retry | Komentáře | na vlastníku, `…/comments`, `…/comments/{id}` | | Hledání | `/api/search?q=`, viz [SDD-021](sdd-021-vyhledavani.md) | | Štítky | `/api/labels`, `/api/labels/{id}`; přidělení na vlastníku `…/labels` | +| API tokeny | `/api/api-tokens`, zrušení `/api/api-tokens/{id}`; jen přihlášení v prohlížeči (SDD-006) | - Id v routách je `Guid`. - Tenanta i uživatele dodává kontext požadavku; endpoint ani dotaz nikdy neberou id tenanta diff --git a/docs/sdd/sdd-006-tenance-a-ucty.md b/docs/sdd/sdd-006-tenance-a-ucty.md index d6423ed7..66271abd 100644 --- a/docs/sdd/sdd-006-tenance-a-ucty.md +++ b/docs/sdd/sdd-006-tenance-a-ucty.md @@ -2,13 +2,14 @@ - **Stav:** platí - **Milníky:** M2 -- **Související SDD:** [003](sdd-003-testovani.md), [004](sdd-004-validace-a-chyby.md), - [005](sdd-005-api-konvence.md), [007](sdd-007-domenovy-model.md) +- **Související SDD:** [002](sdd-002-logovani-a-observabilita.md), [003](sdd-003-testovani.md), + [004](sdd-004-validace-a-chyby.md), [005](sdd-005-api-konvence.md), + [007](sdd-007-domenovy-model.md), [019](sdd-019-stitky.md) ## Rozsah -Účty, tenanti, uživatelé, izolace dat mezi tenanty a autentizace. Doménové entity popisuje -SDD-007. +Účty, tenanti, uživatelé, izolace dat mezi tenanty, autentizace a API tokeny. Doménové entity +popisuje SDD-007. ## Popis @@ -19,7 +20,8 @@ SDD-007. - **User** — tenantová entita, patří právě jednomu tenantu: e-mail, hash hesla, role, lockout a `TenantId`. -Mimo tenant stojí jen Account, Tenant a refresh token; každá jiná entita nese `TenantId`. +Mimo tenant stojí jen Account, Tenant, refresh token a API token; každá jiná entita nese +`TenantId`. Vlastníka `UserId` nese každá kromě kontaktu; kontakt patří tenantu (SDD-011). Obojí plní zápis, ne volající. Tenanta ani uživatele nelze smazat, dokud drží řádky. @@ -36,8 +38,8 @@ správu účtů, žádná registrace. Data nesmí utéct mezi tenanty; únik je kritická chyba. - Čtení nikdy nevidí řádek cizího tenantu; tenanta i uživatele dodává kontext požadavku. - Výjimka je hledání uživatele, které tenanta ještě znát nemůže — přihlášení, obnova tokenu - a oba seedy. + Výjimka je hledání uživatele, které tenanta ještě znát nemůže — přihlášení, obnova tokenu, + ověření API tokenu a oba seedy. - Access token nese tenant claim i subject claim. - Zápis doplní `TenantId` a `UserId` nové entity z kontextu a odmítne řádek, který jmenuje cizího tenanta nebo cizího uživatele. @@ -47,7 +49,7 @@ Data nesmí utéct mezi tenanty; únik je kritická chyba. přes celé nasazení a ukládá se oříznutý a malými písmeny, takže přihlášení nerozlišuje velikost písmen. - Konvenční test hlídá, že žádná tenantová entita filtr nepostrádá a že mimo tenant stojí jen - Account, Tenant a refresh token (SDD-003). + Account, Tenant, refresh token a API token (SDD-003). ### Autentizace @@ -57,7 +59,7 @@ Heslo je uložené jako hash odolný proti hrubé síle. Access token nese tenan z řádku uživatele při každém vydání, obnovu tokenu včetně. CSRF obranu drží `SameSite=Strict`, same-origin a kontrola `Sec-Fetch-Site` (případně `Origin`) na zápisech do `/api`: zápis mimo vlastní origin odpoví 403. Antiforgery token není. Hlavička `Authorization: Bearer` zůstává -přijatá pro klienty mimo prohlížeč. +přijatá pro klienty mimo prohlížeč; nese access token, nebo API token. Přihlášení vydrží 30 dní od posledního použití a nejdéle 90 dní od přihlášení. Access token platí 15 minut a aplikace ho obnovuje sama; obnova z více záložek téhož prohlížeče uživatele @@ -67,6 +69,28 @@ Pět po sobě jdoucích neúspěšných přihlášení účet na 15 minut uzamkn 423 (SDD-004) a úspěšné přihlášení počitadlo vynuluje. Uzamčení blokuje jen přihlášení heslem; živá přihlášení neukončí a jejich obnova běží dál. +### API tokeny + +Integrace a AI agent se přihlašují osobním API tokenem. Token patří jednomu uživateli a jedná +jako on: tenanta, uživatele i roli bere z řádku uživatele při každém požadavku. Token nese název +(nejvýše 64 znaků), oprávnění „Jen čtení“ nebo „Čtení i zápis“ a povinnou platnost 30, 90 nebo +365 dní. Uživatel drží nejvýše 20 neprošlých tokenů; další založení odpoví 400. + +Token je `evc_` a 256 náhodných bitů v base64url. Ukáže se jednou, při založení; uloží se jen +jeho hash. Posílá se v hlavičce `Authorization: Bearer` a schéma vybere jeho prefix; token +v hlavičce vyhraje nad cookie přihlášení. Neznámý, zrušený nebo prošlý token odpoví 401. Zrušení +token smaže a platí od dalšího požadavku, nic token necachuje. + +- Token jen pro čtení odpoví 403 na každý zápis: POST, PUT, PATCH, DELETE. +- Na `/api/auth/**` a na správu tokenů odpoví token 403; obojí zůstává přihlášení v prohlížeči. +- Uzamčení účtu token nezastaví, stejně jako živé přihlášení. +- Požadavky s tokenem limit požadavků nemají (SDD-005). +- „Naposledy použit“ se zapíše nejvýše jednou za minutu. +- Logovací scope požadavku s tokenem nese id a název tokenu (SDD-002); jiný záznam o změnách + agenta není. Token sám se do logu nedostane nikdy. + +Tokeny spravuje sekce nastavení API tokeny (SDD-019). + ## Rozhodnutí - Uživatelé v tenantu: více uživatelů sdílí tenant / 1 uživatel = 1 tenant. Platí @@ -79,6 +103,10 @@ Pět po sobě jdoucích neúspěšných přihlášení účet na 15 minut uzamkn - Plnění `TenantId` a `UserId`: volající / zápis sám. Platí zápis sám, jen na nové řádce. - Změna a smazání cizího záznamu uvnitř tenantu: jen vlastník / kdokoli z tenantu. Platí kdokoli z tenantu, komentář jen jeho autor (SDD-013). +- Uložení API tokenu: pomalý hash jako u hesla / SHA-256. Platí SHA-256: 256 náhodných bitů nejde + uhodnout, a ověření běží při každém požadavku. +- API token na `/api/auth/**` a na správě tokenů: 401 / 403. Platí 403: token je platný, jen tam + nesmí. ## Dopady diff --git a/docs/sdd/sdd-016-navigace-a-vzhled.md b/docs/sdd/sdd-016-navigace-a-vzhled.md index bd18c6d6..44507576 100644 --- a/docs/sdd/sdd-016-navigace-a-vzhled.md +++ b/docs/sdd/sdd-016-navigace-a-vzhled.md @@ -28,8 +28,10 @@ URL nesou UUID entit, nikdy jejich čísla: | `/acts/{actId}/edit` | editace úkonu | | `/contacts` | kontakty | | `/contacts/{id}` | detail kontaktu | -| `/settings` | nastavení (SDD-019) | -| `/settings/export` | export dat tenanta (SDD-019, SDD-022) | +| `/settings` | přesměruje na `/settings/labels` | +| `/settings/labels` | nastavení, štítky (SDD-019) | +| `/settings/api-tokens` | nastavení, API tokeny (SDD-019) | +| `/settings/export` | nastavení, export dat tenanta (SDD-019, SDD-022) | | `/login` | přihlášení | Neznámá routa vykreslí stav nenalezeno uvnitř aplikace, neznámé id v routě prázdný stav diff --git a/docs/sdd/sdd-019-stitky.md b/docs/sdd/sdd-019-stitky.md index e3c5b866..9517c090 100644 --- a/docs/sdd/sdd-019-stitky.md +++ b/docs/sdd/sdd-019-stitky.md @@ -2,13 +2,14 @@ - **Stav:** platí - **Milníky:** M8 -- **Související SDD:** [007](sdd-007-domenovy-model.md), [009](sdd-009-spisy.md), - [010](sdd-010-ukony.md), [016](sdd-016-navigace-a-vzhled.md), [022](sdd-022-export.md) +- **Související SDD:** [006](sdd-006-tenance-a-ucty.md), [007](sdd-007-domenovy-model.md), + [009](sdd-009-spisy.md), [010](sdd-010-ukony.md), [016](sdd-016-navigace-a-vzhled.md), + [022](sdd-022-export.md) ## Rozsah -Entita štítku, jeho přidělení spisu a úkonu, sekce nastavení a zobrazení štítků. Stránky spisu -drží SDD-009, stránky úkonu SDD-010, routy a menu SDD-016. +Entita štítku, jeho přidělení spisu a úkonu, nastavení a jeho sekce a zobrazení štítků. Stránky +spisu drží SDD-009, stránky úkonu SDD-010, routy a menu SDD-016. ## Popis @@ -25,18 +26,26 @@ ničem. Tutéž dvojici nese nejvýše jednou. Spis ani úkon nemá počet ští ### Nastavení -`/settings` je agenda nastavení tenantu se sekcemi Štítky (`/settings`) a Export -(`/settings/export`). Na desktopu je mezi sekcemi svislý panel, na mobilu řada pilulek. +Nastavení nese sekce Štítky (`/settings/labels`), API tokeny (`/settings/api-tokens`) a Export +(`/settings/export`), každou na vlastní stránce se společnou navigací sekcí. Na desktopu je +navigace svislým panelem, na mobilu řadou pilulek. `/settings` přesměruje na Štítky. + +Sekce Štítky ukazuje štítky seřazené podle názvu, u každého počet spisů a počet úkonů, které ho +nesou, a nechá štítek založit, přejmenovat, přebarvit a smazat. Název, který už nese jiný štítek, +se odmítne 409 (SDD-004). Smazání štítku ho sejme ze všech spisů a úkonů, které ho nesly; spis ani +úkon tím nezaniká. Potvrzuje se jako každé mazání a potvrzení jmenuje, kolik spisů a úkonů štítek +nese. + +Sekce API tokeny ukazuje tokeny přihlášeného uživatele (SDD-006), nejnovější nahoře: název, +oprávnění, kdy token vznikl, do kdy platí a kdy byl naposledy použit. Prošlý token nese +„Vypršel“, dosud nepoužitý „Nepoužit“. „Nový token“ bere název (povinný), oprávnění (výchozí +Jen čtení) a platnost 30, 90 nebo 365 dní (výchozí 90). Po založení se token ukáže jedinkrát, +s tlačítkem pro zkopírování a upozorněním, že ho uživatel znovu neuvidí. „Zrušit“ token zruší po +potvrzení jako každé mazání; zrušený token ze seznamu zmizí. Sekce Export popíše, co archiv nese a co ne, a nese tlačítko Exportovat vše s názvem souboru, pod kterým se archiv stáhne. Export řídí [SDD-022](sdd-022-export.md). -Sekce Štítky ukazuje štítky -seřazené podle názvu, u každého počet spisů a počet úkonů, které ho nesou, a nechá štítek -založit, přejmenovat, přebarvit a smazat. Název, který už nese jiný štítek, se odmítne 409 -(SDD-004). Smazání štítku ho sejme ze všech spisů a úkonů, které ho nesly; spis ani úkon tím -nezaniká. Potvrzuje se jako každé mazání a potvrzení jmenuje, kolik spisů a úkonů štítek nese. - ### Přidělení Štítky se přidělují na dvou místech: @@ -67,7 +76,8 @@ spisu nebo úkonu, na každé šířce. - Smazání štítku, který něco nese: zákaz / smazání i s jeho přiděleními. Platí smazání i s přiděleními; spis ani úkon, který štítek nesl, tím nezaniká. - Zápis přidělení: přidání a odebrání po jednom / celá množina. Platí celá množina. -- Nastavení: vlastní stránka / sekce v profilu uživatele. Platí vlastní stránka `/settings`. +- Nastavení: vlastní stránka / sekce v profilu uživatele. Platí vlastní stránky pod `/settings/`, + jedna na sekci. ## Dopady diff --git a/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenCreateRequest.cs b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenCreateRequest.cs new file mode 100644 index 00000000..32cb4298 --- /dev/null +++ b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenCreateRequest.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using EvilBrains.EvilCase.Domain.ApiTokens; + +namespace EvilBrains.EvilCase.Api.Contract.ApiTokens; + +public sealed record ApiTokenCreateRequest +{ + [Required] + [StringLength(ApiTokenLimits.MaxNameLength)] + public required string Name { get; init; } + + public required ApiTokenScope Scope { get; init; } + + /// + /// One of . + /// + [AllowedValues(30, 90, 365)] + public required int ExpiresInDays { get; init; } +} diff --git a/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenCreated.cs b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenCreated.cs new file mode 100644 index 00000000..df9bff24 --- /dev/null +++ b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenCreated.cs @@ -0,0 +1,11 @@ +namespace EvilBrains.EvilCase.Api.Contract.ApiTokens; + +public sealed record ApiTokenCreated +{ + public required ApiTokenItem Token { get; init; } + + /// + /// The only response that carries the token; the server keeps its hash alone. + /// + public required string Secret { get; init; } +} diff --git a/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenItem.cs b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenItem.cs new file mode 100644 index 00000000..e7da673b --- /dev/null +++ b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenItem.cs @@ -0,0 +1,18 @@ +using EvilBrains.EvilCase.Domain.ApiTokens; + +namespace EvilBrains.EvilCase.Api.Contract.ApiTokens; + +public sealed record ApiTokenItem +{ + public required Guid ApiTokenId { get; init; } + + public required string Name { get; init; } + + public required ApiTokenScope Scope { get; init; } + + public required DateTime Created { get; init; } + + public required DateTime Expires { get; init; } + + public DateTime? LastUsed { get; init; } +} diff --git a/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenLimits.cs b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenLimits.cs new file mode 100644 index 00000000..4b3495fa --- /dev/null +++ b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenLimits.cs @@ -0,0 +1,21 @@ +namespace EvilBrains.EvilCase.Api.Contract.ApiTokens; + +public static class ApiTokenLimits +{ + /// + /// Mirrors ApiToken.Name's MaxLength. + /// + public const int MaxNameLength = 64; + + /// + /// Unexpired tokens one user may hold. + /// + public const int MaxActive = 20; + + public const int DefaultLifetimeDays = 90; + + /// + /// names the same values. + /// + public static readonly IReadOnlyList LifetimeDays = [30, DefaultLifetimeDays, 365]; +} diff --git a/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenListResponse.cs b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenListResponse.cs new file mode 100644 index 00000000..8c274a74 --- /dev/null +++ b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenListResponse.cs @@ -0,0 +1,6 @@ +namespace EvilBrains.EvilCase.Api.Contract.ApiTokens; + +public sealed record ApiTokenListResponse +{ + public required IReadOnlyList Items { get; init; } +} diff --git a/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenProblems.cs b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenProblems.cs new file mode 100644 index 00000000..ccfaef5a --- /dev/null +++ b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenProblems.cs @@ -0,0 +1,8 @@ +namespace EvilBrains.EvilCase.Api.Contract.ApiTokens; + +public static class ApiTokenProblems +{ + public const string NotFound = "API token not found"; + + public const string LimitReached = "API token limit reached"; +} diff --git a/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenRoute.cs b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenRoute.cs new file mode 100644 index 00000000..b1afd16a --- /dev/null +++ b/src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenRoute.cs @@ -0,0 +1,14 @@ +namespace EvilBrains.EvilCase.Api.Contract.ApiTokens; + +/// +/// Shared with the API token boundary, which keeps a token out of its own management. +/// +public static class ApiTokenRoute +{ + /// + /// Relative, as the client generator requires. + /// + public const string Template = "api/api-tokens"; + + public const string Path = "/" + Template; +} diff --git a/src/Api/EvilCase.Api.Contract/User/AuthClaims.cs b/src/Api/EvilCase.Api.Contract/User/AuthClaims.cs index 85f91674..87a04f1f 100644 --- a/src/Api/EvilCase.Api.Contract/User/AuthClaims.cs +++ b/src/Api/EvilCase.Api.Contract/User/AuthClaims.cs @@ -17,4 +17,13 @@ public static class AuthClaims public const string AuthSessionId = "sid"; public const string Tenant = "tenant"; + + /// + /// Only a principal an API token authenticated carries this and the two below. + /// + public const string ApiTokenId = "api_token_id"; + + public const string ApiTokenName = "api_token_name"; + + public const string ApiTokenScope = "api_token_scope"; } diff --git a/src/Api/EvilCase.Api/Bootstrap.cs b/src/Api/EvilCase.Api/Bootstrap.cs index 30730a5a..eff2a580 100644 --- a/src/Api/EvilCase.Api/Bootstrap.cs +++ b/src/Api/EvilCase.Api/Bootstrap.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.OpenApi; using Scalar.AspNetCore; namespace EvilBrains.EvilCase.Api; @@ -20,6 +21,8 @@ public static class Bootstrap // The name browser logs are recorded under; it identifies the deployment, not the library. private const string ClientSourceContext = "EvilBrains.EvilCase.App.Client"; + private const string BearerSecurityScheme = "Bearer"; + // A RequestDelegate: a minimal-API handler would have to bind the unused {**path}. private static readonly RequestDelegate NotFound = static async context => { @@ -60,6 +63,17 @@ public static IServiceCollection ConfigureServices(this IServiceCollection servi options.AddDocumentTransformer(static async (document, _, _) => { document.Info.Title = "EvilCase API"; + + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes ??= new Dictionary(StringComparer.Ordinal); + document.Components.SecuritySchemes[BearerSecurityScheme] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", + Description = "A personal API token (evc_…) created in Nastavení → API tokeny.", + }; + + document.Security = [new OpenApiSecurityRequirement { [new OpenApiSecuritySchemeReference(BearerSecurityScheme, document)] = [] }]; }); }); @@ -84,6 +98,11 @@ public static IEndpointRouteBuilder MapEvilCaseApi(this IEndpointRouteBuilder en { endpoints.MapControllers(); + // Anonymous in every environment, so an integration reads the contract before it holds a token. + endpoints + .MapOpenApi() + .AllowAnonymous(); + // Liveness runs no check: the process answering is the signal. A database check here would // restart every instance at once on a brief outage. endpoints @@ -114,10 +133,6 @@ public static IEndpointRouteBuilder MapEvilCaseApi(this IEndpointRouteBuilder en public static IEndpointRouteBuilder MapEvilCaseApiReference(this IEndpointRouteBuilder endpoints) { // Anonymous, so mapped in Development only. - endpoints - .MapOpenApi() - .AllowAnonymous(); - endpoints .MapScalarApiReference() .AllowAnonymous(); diff --git a/src/Api/EvilCase.Api/Controllers/ApiTokensController.cs b/src/Api/EvilCase.Api/Controllers/ApiTokensController.cs new file mode 100644 index 00000000..945f5bdb --- /dev/null +++ b/src/Api/EvilCase.Api/Controllers/ApiTokensController.cs @@ -0,0 +1,65 @@ +using System.Diagnostics; +using EvilBrains.ApiClient; +using EvilBrains.EvilCase.Api.Contract.ApiTokens; +using EvilBrains.EvilCase.Auth; +using EvilBrains.EvilCase.Domain.Users; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace EvilBrains.EvilCase.Api.Controllers; + +/// +/// A signed-in session manages its own tokens; a token never reaches here (SDD-006). +/// +[ApiController] +[GenerateApiClient] +[Route(ApiTokenRoute.Template)] +public class ApiTokensController : ControllerBase +{ + [HttpGet("")] + public async Task ListApiTokens( + [FromServices] IApiTokenService apiTokens, [FromServices] IUserContext userContext, CancellationToken token) + { + var items = await apiTokens.ListApiTokens(userContext.UserId, token); + + return new ApiTokenListResponse { Items = items }; + } + + [HttpPost("")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> CreateApiToken( + [FromServices] IApiTokenService apiTokens, + [FromServices] IUserContext userContext, + [FromBody] ApiTokenCreateRequest request, + CancellationToken token) + { + var result = await apiTokens.CreateApiToken(userContext.UserId, request, token); + + return result.Outcome switch + { + ApiTokenCreateOutcome.Created => this.StatusCode(StatusCodes.Status201Created, result.Created), + ApiTokenCreateOutcome.LimitReached => this.Problem( + detail: $"At most {ApiTokenLimits.MaxActive} unexpired tokens per user.", + statusCode: StatusCodes.Status400BadRequest, + title: ApiTokenProblems.LimitReached), + _ => throw new UnreachableException(), + }; + } + + [HttpDelete("{apiTokenId:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task RevokeApiToken( + [FromServices] IApiTokenService apiTokens, [FromServices] IUserContext userContext, [FromRoute] Guid apiTokenId, CancellationToken token) + { + var outcome = await apiTokens.RevokeApiToken(userContext.UserId, apiTokenId, token); + + return outcome switch + { + ApiTokenRevokeOutcome.Revoked => this.NoContent(), + ApiTokenRevokeOutcome.NotFound => this.Problem(statusCode: StatusCodes.Status404NotFound, title: ApiTokenProblems.NotFound), + _ => throw new UnreachableException(), + }; + } +} diff --git a/src/App/EvilCase.App/Components/ApiTokenCreatedModal.razor b/src/App/EvilCase.App/Components/ApiTokenCreatedModal.razor new file mode 100644 index 00000000..35db3e58 --- /dev/null +++ b/src/App/EvilCase.App/Components/ApiTokenCreatedModal.razor @@ -0,0 +1,73 @@ +@using EvilBrains.EvilCase.Api.Contract.ApiTokens +@using Microsoft.JSInterop +@inject IClipboardWriter Clipboard +@inject ILogger Logger + + + +
+
+ +

Zkopírujte si token teď. Po zavření okna už ho znovu neuvidíte.

+
+ + +
+ + @(this.copied ? "Zkopírováno" : "Kopírovat") +
+
+
+
+
+ Hotovo +
+
+ +@code { + private static readonly TimeSpan CopiedShown = TimeSpan.FromSeconds(2); + + [Parameter, EditorRequired] + public required ApiTokenCreated Created { get; set; } + + [Parameter] + public bool Open { get; set; } + + [Parameter] + public EventCallback OpenChanged { get; set; } + + private bool copied; + + private int round; + + private async Task Done() + { + await this.OpenChanged.InvokeAsync(false); + } + + private async Task Copy() + { + try + { + await this.Clipboard.WriteText(this.Created.Secret, CancellationToken.None); + } + catch (JSException exception) + { + this.Logger.LogWarning(exception, "Copying the API token to the clipboard failed"); + + return; + } + + // A later copy owns the label; an earlier copy's delay must not reset it. + var current = ++this.round; + this.copied = true; + this.StateHasChanged(); + + await Task.Delay(CopiedShown); + + if (current == this.round) + this.copied = false; + } +} diff --git a/src/App/EvilCase.App/Components/ApiTokenFormModal.razor b/src/App/EvilCase.App/Components/ApiTokenFormModal.razor new file mode 100644 index 00000000..0d2a1fd7 --- /dev/null +++ b/src/App/EvilCase.App/Components/ApiTokenFormModal.razor @@ -0,0 +1,120 @@ +@using System.Net +@using EvilBrains.ApiClient +@using EvilBrains.EvilCase.Api.Client +@using EvilBrains.EvilCase.Api.Contract.ApiTokens +@using EvilBrains.EvilCase.Domain.ApiTokens +@inject IApiTokensClient ApiTokensClient +@inject ILogger Logger + + + + + + +
+ + + + + + + +
+ Oprávnění + @foreach (var scope in Enum.GetValues()) + { + + } +
+ + +
+ @foreach (var days in ApiTokenLimits.LifetimeDays) + { + + } +
+
+ + @if (this.error is not null) + { + + } +
+
+
+
+ Zrušit + Vytvořit token +
+
+ +@code { + [Parameter] + public bool Open { get; set; } + + [Parameter] + public EventCallback OpenChanged { get; set; } + + [Parameter] + public EventCallback OnCreated { get; set; } + + private readonly ApiTokenEditModel model = new(); + + private EditContext editContext = null!; + + private string? error; + + private bool busy; + + protected override void OnInitialized() + { + this.editContext = new EditContext(this.model); + } + + private async Task Cancel() + { + await this.OpenChanged.InvokeAsync(false); + } + + private async Task CreateToken() + { + if (!this.editContext.Validate()) + return; + + this.busy = true; + this.error = null; + + try + { + var request = new ApiTokenCreateRequest { Name = this.model.Name, Scope = this.model.Scope, ExpiresInDays = this.model.ExpiresInDays }; + var created = await this.ApiTokensClient.CreateApiToken(request, CancellationToken.None); + + await this.OnCreated.InvokeAsync(created); + } + catch (ApiException exception) when (exception.StatusCode == HttpStatusCode.BadRequest) + { + this.Logger.LogWarning(exception, "Creating the API token was refused"); + + this.error = $"Token se nepodařilo vytvořit. Platných tokenů můžete mít nejvýše {ApiTokenLimits.MaxActive}; nejdřív některý zrušte."; + } + catch (Exception exception) when (exception is ApiException or HttpRequestException) + { + this.Logger.LogWarning(exception, "Creating the API token failed"); + + this.error = "Token se nepodařilo vytvořit. Zkuste to za chvíli znovu."; + } + finally + { + this.busy = false; + } + } +} diff --git a/src/App/EvilCase.App/Components/ApiTokenRevokeModal.razor b/src/App/EvilCase.App/Components/ApiTokenRevokeModal.razor new file mode 100644 index 00000000..7ef53d87 --- /dev/null +++ b/src/App/EvilCase.App/Components/ApiTokenRevokeModal.razor @@ -0,0 +1,74 @@ +@using System.Net +@using EvilBrains.ApiClient +@using EvilBrains.EvilCase.Api.Client +@using EvilBrains.EvilCase.Api.Contract.ApiTokens +@inject IApiTokensClient ApiTokensClient +@inject ILogger Logger + + + +

+ Opravdu zrušit token @this.Token.Name? Integrace, která ho používá, ztratí přístup hned. + Zrušení nejde vrátit. +

+ @if (this.error is not null) + { + + } +
+
+ Ponechat + Zrušit token +
+
+ +@code { + [Parameter, EditorRequired] + public required ApiTokenItem Token { get; set; } + + [Parameter] + public bool Open { get; set; } + + [Parameter] + public EventCallback OpenChanged { get; set; } + + [Parameter] + public EventCallback OnRevoked { get; set; } + + private string? error; + + private bool busy; + + private async Task Cancel() + { + await this.OpenChanged.InvokeAsync(false); + } + + private async Task RevokeToken() + { + this.busy = true; + this.error = null; + + try + { + await this.ApiTokensClient.RevokeApiToken(this.Token.ApiTokenId, CancellationToken.None); + + await this.OnRevoked.InvokeAsync(); + } + catch (ApiException exception) when (exception.StatusCode == HttpStatusCode.NotFound) + { + this.error = "Token už neexistuje."; + } + catch (Exception exception) when (exception is ApiException or HttpRequestException) + { + this.Logger.LogWarning(exception, "Revoking the API token failed"); + + this.error = "Token se nepodařilo zrušit. Zkuste to za chvíli znovu."; + } + finally + { + this.busy = false; + } + } +} diff --git a/src/App/EvilCase.App/Components/LabelField.razor b/src/App/EvilCase.App/Components/LabelField.razor index 82837af5..bf16d8bb 100644 --- a/src/App/EvilCase.App/Components/LabelField.razor +++ b/src/App/EvilCase.App/Components/LabelField.razor @@ -98,7 +98,7 @@
↑↓ pohyb · Enter přidat · Esc zavřít - Spravovat + Spravovat
} diff --git a/src/App/EvilCase.App/Components/SettingsNav.razor b/src/App/EvilCase.App/Components/SettingsNav.razor deleted file mode 100644 index c6a61087..00000000 --- a/src/App/EvilCase.App/Components/SettingsNav.razor +++ /dev/null @@ -1,26 +0,0 @@ -@inject NavigationManager Navigation - - - -@code { - private static readonly (string Href, string Text, string Icon)[] Items = - [ - ("/settings", "Štítky", AppIcons.Tag), - ("/settings/export", "Export", AppIcons.ArrowBarToDown), - ]; - - private bool IsCurrent(string href) - { - var path = "/" + this.Navigation.ToBaseRelativePath(this.Navigation.Uri).Split('?', '#')[0].TrimEnd('/'); - - return string.Equals(path, href, StringComparison.OrdinalIgnoreCase); - } -} diff --git a/src/App/EvilCase.App/Icons/AppIcons.cs b/src/App/EvilCase.App/Icons/AppIcons.cs index 4241692c..73a2885b 100644 --- a/src/App/EvilCase.App/Icons/AppIcons.cs +++ b/src/App/EvilCase.App/Icons/AppIcons.cs @@ -221,6 +221,13 @@ public static class AppIcons + "" + ""; + public const string Key = "" + + "" + + ""; + public const string Link = "" + "" + "" diff --git a/src/App/EvilCase.App/Layout/SettingsLayout.razor b/src/App/EvilCase.App/Layout/SettingsLayout.razor new file mode 100644 index 00000000..0326f49b --- /dev/null +++ b/src/App/EvilCase.App/Layout/SettingsLayout.razor @@ -0,0 +1,37 @@ +@inherits LayoutComponentBase +@layout MainLayout +@inject NavigationManager Navigation + +@* Every settings section is a page under /settings/ with this layout and one entry below. *@ +
+ + +
+ + + @this.Body +
+
+ +@code { + private static readonly (string Href, string Text, string Icon)[] Sections = + [ + ("/settings/labels", "Štítky", AppIcons.Tag), + ("/settings/api-tokens", "API tokeny", AppIcons.Key), + ("/settings/export", "Export", AppIcons.ArrowBarToDown), + ]; + + private bool IsCurrent(string href) + { + var path = "/" + this.Navigation.ToBaseRelativePath(this.Navigation.Uri).Split('?', '#')[0].TrimEnd('/'); + + return string.Equals(path, href, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/App/EvilCase.App/Models/ApiTokenDisplay.cs b/src/App/EvilCase.App/Models/ApiTokenDisplay.cs new file mode 100644 index 00000000..c3da8617 --- /dev/null +++ b/src/App/EvilCase.App/Models/ApiTokenDisplay.cs @@ -0,0 +1,50 @@ +using System.Diagnostics; +using EvilBrains.EvilCase.Domain.ApiTokens; + +namespace EvilBrains.EvilCase.App.Models; + +public static class ApiTokenDisplay +{ + public static string ScopeText(ApiTokenScope scope) + { + return scope switch + { + ApiTokenScope.ReadOnly => "Jen čtení", + ApiTokenScope.ReadWrite => "Čtení i zápis", + _ => throw new UnreachableException(), + }; + } + + public static string ScopeHint(ApiTokenScope scope) + { + return scope switch + { + ApiTokenScope.ReadOnly => "Čte spisy, úkony, kontakty, štítky a soubory", + ApiTokenScope.ReadWrite => "Navíc zakládá, mění a maže", + _ => throw new UnreachableException(), + }; + } + + public static string ScopeCss(ApiTokenScope scope) + { + return scope switch + { + ApiTokenScope.ReadOnly => "ec-token-chip ec-token-scope-read", + ApiTokenScope.ReadWrite => "ec-token-chip ec-token-scope-write", + _ => throw new UnreachableException(), + }; + } + + /// + /// The day of a moment in the browser's time zone. + /// + public static string DayText(in DateTime moment) + { + return DateDisplay.Text(DateOnly.FromDateTime(moment.ToLocalTime())); + } + + public static string LastUsedText(DateTime? lastUsed) + { + return lastUsed is { } moment ? MomentDisplay.Text(moment) : "Nepoužit"; + } +} diff --git a/src/App/EvilCase.App/Models/ApiTokenEditModel.cs b/src/App/EvilCase.App/Models/ApiTokenEditModel.cs new file mode 100644 index 00000000..f472d7a0 --- /dev/null +++ b/src/App/EvilCase.App/Models/ApiTokenEditModel.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; +using EvilBrains.EvilCase.Api.Contract.ApiTokens; +using EvilBrains.EvilCase.Domain.ApiTokens; + +namespace EvilBrains.EvilCase.App.Models; + +internal sealed class ApiTokenEditModel +{ + [Required(ErrorMessage = "Zadejte název tokenu")] + [StringLength(ApiTokenLimits.MaxNameLength, ErrorMessage = "Název může mít nejvýše 64 znaků.")] + public string Name { get; set; } = ""; + + public ApiTokenScope Scope { get; set; } = ApiTokenScope.ReadOnly; + + public int ExpiresInDays { get; set; } = ApiTokenLimits.DefaultLifetimeDays; +} diff --git a/src/App/EvilCase.App/Pages/ApiTokenSettings.razor b/src/App/EvilCase.App/Pages/ApiTokenSettings.razor new file mode 100644 index 00000000..24f4769a --- /dev/null +++ b/src/App/EvilCase.App/Pages/ApiTokenSettings.razor @@ -0,0 +1,212 @@ +@page "/settings/api-tokens" +@layout SettingsLayout +@using EvilBrains.ApiClient +@using EvilBrains.EvilCase.Api.Client +@using EvilBrains.EvilCase.Api.Contract.ApiTokens +@inject IApiTokensClient ApiTokensClient +@inject ILogger Logger + +API tokeny – Nastavení – EvilCase + + +
+
+

+ API tokeny + @if (this.items.Count > 0) + { + @this.items.Count + } +

+ + Přístup pro integrace a AI agenty, kteří jednají vaším jménem + Pro integrace a AI agenty + +
+
+ Nový token +
+
+ + @if (this.loading) + { +

Načítám…

+ } + else if (this.listFailure is not null) + { +
+ + Zkusit znovu +
+ } + else if (this.items.Count == 0) + { + + + Vytvořit token + + + } + else + { + +
+ @foreach (var item in this.items) + { + var expired = item.Expires <= this.now; + +
+ + @item.Name + + + @ApiTokenDisplay.ScopeText(item.Scope) + @if (expired) + { + Vypršel + } + else + { + do @ApiTokenDisplay.DayText(item.Expires) + } + + @(item.LastUsed is { } used ? $"Použit {MomentDisplay.Text(used)}" : "Nepoužit") + + + @ApiTokenDisplay.ScopeText(item.Scope) + @ApiTokenDisplay.DayText(item.Created) + + @if (expired) + { + Vypršel + } + else + { + @ApiTokenDisplay.DayText(item.Expires) + } + + @ApiTokenDisplay.LastUsedText(item.LastUsed) + + Zrušit + +
+ } +
+ } +
+
+ Token jedná vaším jménem a vidí totéž co vy. Zrušený token přestane platit okamžitě. +
+
+ +@if (this.formOpen) +{ + +} + +@if (this.created is not null) +{ + +} + +@if (this.revoking is not null) +{ + +} + +@code { + private IReadOnlyList items = []; + + private ApiTokenItem? revoking; + + // Held only while its modal is open; the secret is shown once. + private ApiTokenCreated? created; + + private bool formOpen; + + private bool loading = true; + + private string? listFailure; + + private DateTime now = DateTime.UtcNow; + + protected override async Task OnInitializedAsync() + { + await this.ReloadTokens(); + this.loading = false; + } + + private async Task ReloadTokens() + { + this.listFailure = null; + + try + { + var response = await this.ApiTokensClient.ListApiTokens(CancellationToken.None); + + this.items = response.Items; + this.now = DateTime.UtcNow; + } + catch (Exception exception) when (exception is ApiException or HttpRequestException) + { + this.Logger.LogWarning(exception, "Loading the API token list failed"); + + this.items = []; + this.listFailure = "Seznam tokenů se nepodařilo načíst. Zkuste to za chvíli znovu."; + } + } + + private void StartCreating() + { + this.formOpen = true; + } + + private void StartRevoking(ApiTokenItem item) + { + this.revoking = item; + } + + private void CloseForm(bool open) + { + if (!open) + this.formOpen = false; + } + + private async Task CloseCreated(bool open) + { + if (open) + return; + + this.created = null; + + await this.ReloadTokens(); + } + + private void CloseRevoke(bool open) + { + if (!open) + this.revoking = null; + } + + private void TokenCreated(ApiTokenCreated token) + { + this.formOpen = false; + this.created = token; + } + + private async Task TokenRevoked() + { + this.revoking = null; + + await this.ReloadTokens(); + } +} diff --git a/src/App/EvilCase.App/Pages/LabelSettings.razor b/src/App/EvilCase.App/Pages/LabelSettings.razor new file mode 100644 index 00000000..0a0aeb69 --- /dev/null +++ b/src/App/EvilCase.App/Pages/LabelSettings.razor @@ -0,0 +1,190 @@ +@page "/settings/labels" +@layout SettingsLayout +@using EvilBrains.ApiClient +@using EvilBrains.EvilCase.Api.Client +@using EvilBrains.EvilCase.Api.Contract.Labels +@inject ILabelsClient LabelsClient +@inject ILogger Logger + +Štítky – Nastavení – EvilCase + + +
+
+

+ Štítky + @if (this.items.Count > 0) + { + @this.items.Count + } +

+ Jeden číselník pro spisy i úkony +
+
+ Nový štítek +
+
+ + @if (this.loading) + { +

Načítám…

+ } + else if (this.listFailure is not null) + { +
+ + Zkusit znovu +
+ } + else if (this.items.Count == 0) + { + + + Založit štítek + + + } + else + { + +
+ @foreach (var item in this.items) + { +
+ + + @item.Name + + + + @LabelColorDisplay.Text(item.Color) + @CountText(item.CaseCount) + @CountText(item.ActCount) + + + + +
+ } +
+ } +
+
+ Smazání štítku ho sejme ze všech spisů a úkonů, které ho nesou. +
+
+ +@if (this.formOpen) +{ + +} + +@if (this.deleting is not null) +{ + +} + +@code { + private IReadOnlyList items = []; + + private LabelItem? editing; + + private LabelListItem? deleting; + + private bool formOpen; + + private bool loading = true; + + private string? listFailure; + + protected override async Task OnInitializedAsync() + { + await this.ReloadLabels(); + this.loading = false; + } + + private async Task ReloadLabels() + { + this.listFailure = null; + + try + { + var response = await this.LabelsClient.ListLabels(CancellationToken.None); + + this.items = response.Items; + } + catch (Exception exception) when (exception is ApiException or HttpRequestException) + { + this.Logger.LogWarning(exception, "Loading the label list failed"); + + this.items = []; + this.listFailure = "Seznam štítků se nepodařilo načíst. Zkuste to za chvíli znovu."; + } + } + + private void StartCreating() + { + this.editing = null; + this.formOpen = true; + } + + private static string CountClass(int count) + { + return count == 0 ? "ec-settings-row-count ec-settings-row-count-zero" : "ec-settings-row-count"; + } + + private static string CountText(int count) + { + return count == 0 ? "—" : count.ToString(CultureInfo.CurrentCulture); + } + + private void StartEditing(LabelListItem label) + { + this.editing = new LabelItem { LabelId = label.LabelId, Name = label.Name, Color = label.Color }; + this.formOpen = true; + } + + private void StartDeleting(LabelListItem label) + { + this.deleting = label; + } + + private void CloseForm(bool open) + { + if (open) + return; + + this.formOpen = false; + this.editing = null; + } + + private void CloseDelete(bool open) + { + if (!open) + this.deleting = null; + } + + private async Task LabelSaved() + { + this.formOpen = false; + this.editing = null; + + await this.ReloadLabels(); + } + + private async Task LabelDeleted() + { + this.deleting = null; + + await this.ReloadLabels(); + } +} diff --git a/src/App/EvilCase.App/Pages/Settings.razor b/src/App/EvilCase.App/Pages/Settings.razor index 857c4d67..9a4ca5f1 100644 --- a/src/App/EvilCase.App/Pages/Settings.razor +++ b/src/App/EvilCase.App/Pages/Settings.razor @@ -1,197 +1,9 @@ @page "/settings" -@using EvilBrains.ApiClient -@using EvilBrains.EvilCase.Api.Client -@using EvilBrains.EvilCase.Api.Contract.Labels -@inject ILabelsClient LabelsClient -@inject ILogger Logger - -Nastavení – EvilCase - -
- - -
- - - -
-
-

- Štítky - @if (this.items.Count > 0) - { - @this.items.Count - } -

- Jeden číselník pro spisy i úkony -
-
- Nový štítek -
-
- - @if (this.loading) - { -

Načítám…

- } - else if (this.listFailure is not null) - { -
- - Zkusit znovu -
- } - else if (this.items.Count == 0) - { - - - Založit štítek - - - } - else - { - -
- @foreach (var item in this.items) - { -
- - - @item.Name - - - - @LabelColorDisplay.Text(item.Color) - @CountText(item.CaseCount) - @CountText(item.ActCount) - - - - -
- } -
- } -
-
- Smazání štítku ho sejme ze všech spisů a úkonů, které ho nesou. -
-
-
-
- -@if (this.formOpen) -{ - -} - -@if (this.deleting is not null) -{ - -} +@inject NavigationManager Navigation @code { - private IReadOnlyList items = []; - - private LabelItem? editing; - - private LabelListItem? deleting; - - private bool formOpen; - - private bool loading = true; - - private string? listFailure; - - protected override async Task OnInitializedAsync() - { - await this.ReloadLabels(); - this.loading = false; - } - - private async Task ReloadLabels() - { - this.listFailure = null; - - try - { - var response = await this.LabelsClient.ListLabels(CancellationToken.None); - - this.items = response.Items; - } - catch (Exception exception) when (exception is ApiException or HttpRequestException) - { - this.Logger.LogWarning(exception, "Loading the label list failed"); - - this.items = []; - this.listFailure = "Seznam štítků se nepodařilo načíst. Zkuste to za chvíli znovu."; - } - } - - private void StartCreating() - { - this.editing = null; - this.formOpen = true; - } - - private static string CountClass(int count) - { - return count == 0 ? "ec-settings-row-count ec-settings-row-count-zero" : "ec-settings-row-count"; - } - - private static string CountText(int count) - { - return count == 0 ? "—" : count.ToString(CultureInfo.CurrentCulture); - } - - private void StartEditing(LabelListItem label) - { - this.editing = new LabelItem { LabelId = label.LabelId, Name = label.Name, Color = label.Color }; - this.formOpen = true; - } - - private void StartDeleting(LabelListItem label) - { - this.deleting = label; - } - - private void CloseForm(bool open) + protected override void OnInitialized() { - if (open) - return; - - this.formOpen = false; - this.editing = null; - } - - private void CloseDelete(bool open) - { - if (!open) - this.deleting = null; - } - - private async Task LabelSaved() - { - this.formOpen = false; - this.editing = null; - - await this.ReloadLabels(); - } - - private async Task LabelDeleted() - { - this.deleting = null; - - await this.ReloadLabels(); + this.Navigation.NavigateTo("/settings/labels", replace: true); } } diff --git a/src/App/EvilCase.App/Pages/SettingsExport.razor b/src/App/EvilCase.App/Pages/SettingsExport.razor index 0b877f52..5aedbe62 100644 --- a/src/App/EvilCase.App/Pages/SettingsExport.razor +++ b/src/App/EvilCase.App/Pages/SettingsExport.razor @@ -1,40 +1,33 @@ @page "/settings/export" +@layout SettingsLayout @inject IFileDownloader Downloader -Export – EvilCase +Export – Nastavení – EvilCase -
- - -
- - -
- -
-
-

Export

- Všechna data v jednom archivu ZIP -
-
- -
-

- Archiv obsahuje spisy s podspisy a úkony, komentáře, soubory se všemi verzemi, kontakty a štítky. - Každý spis, úkon a kontakt má vlastní soubor JSON, takže se archiv dá číst i bez aplikace. -

-

- Historie změn, smazané záznamy a uživatelé se neexportují. Export zachytí stav ve chvíli, kdy začne stahování. -

-
- Exportovat vše - Stáhne se jako @this.FileName -
-
-
-
-
-
+
+ +
+
+

Export

+ Všechna data v jednom archivu ZIP +
+
+ +
+

+ Archiv obsahuje spisy s podspisy a úkony, komentáře, soubory se všemi verzemi, kontakty a štítky. + Každý spis, úkon a kontakt má vlastní soubor JSON, takže se archiv dá číst i bez aplikace. +

+

+ Historie změn, smazané záznamy a uživatelé se neexportují. Export zachytí stav ve chvíli, kdy začne stahování. +

+
+ Exportovat vše + Stáhne se jako @this.FileName +
+
+
+
@code { diff --git a/src/App/EvilCase.App/wwwroot/css/ec-components.css b/src/App/EvilCase.App/wwwroot/css/ec-components.css index 0689a5d8..9b98797c 100644 --- a/src/App/EvilCase.App/wwwroot/css/ec-components.css +++ b/src/App/EvilCase.App/wwwroot/css/ec-components.css @@ -3210,7 +3210,7 @@ html.ec-scroll-locked { .ec-settings-nav-item { display: flex; align-items: center; - gap: var(--ec-space-3); + gap: var(--ec-settings-nav-gap); height: var(--ec-control-lg); padding: 0 var(--ec-space-4); border-radius: var(--ec-radius-lg); @@ -3364,6 +3364,173 @@ html.ec-scroll-locked { height: var(--ec-control-md); } +.ec-token-row { + grid-template-columns: minmax(0, 1fr) var(--ec-token-col-scope) var(--ec-token-col-created) var(--ec-token-col-expires) var(--ec-token-col-used) var(--ec-token-col-actions); +} + +.ec-settings-row.ec-token-row { + min-height: var(--ec-token-row-height); +} + +.ec-token-main { + display: flex; + flex-direction: column; + gap: var(--ec-space-1); + min-width: 0; +} + +.ec-token-name { + overflow: hidden; + font-size: var(--ec-text-md); + font-weight: var(--ec-weight-semibold); + text-overflow: ellipsis; + white-space: nowrap; +} + +.ec-token-name-expired { + color: var(--ec-text-muted); +} + +.ec-token-meta { + display: none; + flex-direction: column; + gap: var(--ec-space-1); + font-size: var(--ec-text-xs); + color: var(--ec-text-muted); +} + +.ec-token-meta-line { + display: flex; + align-items: center; + gap: var(--ec-space-2); +} + +.ec-token-cell { + font-size: var(--ec-text-md); + font-variant-numeric: tabular-nums; + color: var(--ec-text-soft); +} + +.ec-token-cell-unused { + color: var(--ec-token-unused-text); +} + +.ec-token-chip { + display: inline-flex; + align-items: center; + gap: var(--ec-space-2); + height: var(--ec-token-chip-height); + padding: 0 var(--ec-space-3); + border-radius: var(--ec-radius-sm); + font-size: var(--ec-text-xs); + font-weight: var(--ec-weight-medium); + white-space: nowrap; +} + +.ec-token-scope-read { + background: var(--ec-token-read-bg); + color: var(--ec-token-read-text); +} + +.ec-token-scope-write { + background: var(--ec-token-write-bg); + color: var(--ec-token-write-text); +} + +.ec-token-expired { + background: var(--ec-token-expired-bg); + color: var(--ec-token-expired-text); + font-weight: var(--ec-weight-semibold); +} + +.ec-token-expired::before { + content: ""; + width: var(--ec-space-3); + height: var(--ec-space-3); + border-radius: var(--ec-radius-pill); + background: currentColor; +} + +.ec-token-subtitle-short { + display: none; +} + +.ec-token-scopes { + display: flex; + flex-direction: column; + gap: var(--ec-space-3); + margin: 0; + padding: 0; + border: 0; +} + +.ec-token-scopes > legend { + margin-bottom: var(--ec-space-3); + padding: 0; +} + +.ec-token-scope-option { + display: flex; + align-items: flex-start; + gap: var(--ec-space-4); + padding: var(--ec-space-4) var(--ec-token-option-pad); + border: 1px solid var(--ec-border-strong); + border-radius: var(--ec-radius-lg); + cursor: pointer; +} + +.ec-token-scope-option:has(input:checked) { + padding: calc(var(--ec-space-4) - 1px) calc(var(--ec-token-option-pad) - 1px); + border: 2px solid var(--ec-accent); + background: var(--ec-token-option-checked-bg); +} + +.ec-token-scope-option input { + width: var(--ec-icon-md); + height: var(--ec-icon-md); + margin: var(--ec-space-1) 0 0; + accent-color: var(--ec-accent); +} + +.ec-token-scope-copy { + display: flex; + flex-direction: column; + gap: calc(var(--ec-space-1) / 2); +} + +.ec-token-scope-title { + font-size: var(--ec-text-md); + font-weight: var(--ec-weight-semibold); +} + +.ec-token-scope-hint { + font-size: var(--ec-text-sm); + color: var(--ec-text-muted); +} + +.ec-segmented-inline { + display: inline-flex; + align-self: flex-start; +} + +.ec-segmented-inline .ec-segment { + flex: 0 0 auto; +} + +.ec-token-secret { + display: flex; + gap: var(--ec-space-3); +} + +.ec-token-secret .ec-input { + flex: 1 1 auto; + min-width: 0; +} + +.ec-token-secret .ec-button { + flex-shrink: 0; +} + .ec-button-quiet { background: transparent; color: var(--ec-text-soft); @@ -4392,6 +4559,7 @@ html.ec-scroll-locked { height: var(--ec-touch-min); border: 1px solid var(--ec-border); border-radius: var(--ec-radius-pill); + white-space: nowrap; background: var(--ec-surface); } @@ -4409,6 +4577,33 @@ html.ec-scroll-locked { min-height: var(--ec-touch-min); } + .ec-settings-nav-item .ec-icon, + .ec-token-cell-wide, + .ec-token-subtitle-long { + display: none; + } + + .ec-token-subtitle-short { + display: inline; + } + + .ec-token-meta { + display: flex; + } + + .ec-token-meta .ec-token-chip { + height: var(--ec-token-chip-sm-height); + padding: 0 var(--ec-space-2); + } + + .ec-token-meta .ec-token-expired::before { + display: none; + } + + .ec-token-row .ec-settings-row-actions .ec-button { + height: var(--ec-touch-min); + } + .ec-settings-layout .ec-card-heading { flex: 1 1 auto; min-width: 0; diff --git a/src/App/EvilCase.App/wwwroot/css/ec-tokens.css b/src/App/EvilCase.App/wwwroot/css/ec-tokens.css index ac6538c6..8fe9d17e 100644 --- a/src/App/EvilCase.App/wwwroot/css/ec-tokens.css +++ b/src/App/EvilCase.App/wwwroot/css/ec-tokens.css @@ -257,6 +257,26 @@ --ec-settings-col-color: 150px; --ec-settings-col-count: 90px; --ec-settings-col-actions: 96px; + --ec-settings-nav-gap: 10px; + + /* API tokeny (docs/design/nastaveni-api-tokeny.html) */ + --ec-token-col-scope: 140px; + --ec-token-col-created: 120px; + --ec-token-col-expires: 130px; + --ec-token-col-used: 160px; + --ec-token-col-actions: 88px; + --ec-token-row-height: 52px; + --ec-token-chip-height: 24px; + --ec-token-chip-sm-height: 20px; + --ec-token-option-pad: 14px; + --ec-token-read-bg: #EEF0F3; + --ec-token-read-text: var(--ec-text-soft); + --ec-token-write-bg: var(--ec-accent-soft); + --ec-token-write-text: #2A48B8; + --ec-token-expired-bg: #FDE4E1; + --ec-token-expired-text: var(--ec-danger-text); + --ec-token-unused-text: #9AA0AB; + --ec-token-option-checked-bg: #F5F7FD; /* Detail spisu (docs/design/detail-spisu.html) */ --ec-case-act-col-date: 96px; diff --git a/src/Business/EvilCase.Domain/ApiTokens/ApiTokenScope.cs b/src/Business/EvilCase.Domain/ApiTokens/ApiTokenScope.cs new file mode 100644 index 00000000..15fe9027 --- /dev/null +++ b/src/Business/EvilCase.Domain/ApiTokens/ApiTokenScope.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; +using EvilBrains.EvilCase.Domain.Json; + +namespace EvilBrains.EvilCase.Domain.ApiTokens; + +[JsonConverter(typeof(StrictJsonStringEnumConverter))] +public enum ApiTokenScope +{ + ReadOnly = 0, + + ReadWrite = 1, +} diff --git a/src/Common/EvilCase.Auth/ApiTokenAuthenticationHandler.cs b/src/Common/EvilCase.Auth/ApiTokenAuthenticationHandler.cs new file mode 100644 index 00000000..bbaa434d --- /dev/null +++ b/src/Common/EvilCase.Auth/ApiTokenAuthenticationHandler.cs @@ -0,0 +1,53 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using EvilBrains.EvilCase.Api.Contract.User; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace EvilBrains.EvilCase.Auth; + +/// +/// Reads the token and its user from the database on every request, so a revocation holds from the +/// next request and tenant and role follow the user row. A lockout does not stop it. +/// +internal sealed class ApiTokenAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory loggerFactory, + UrlEncoder encoder, + IApiTokenStore apiTokenStore, + TimeProvider timeProvider) : AuthenticationHandler(options, loggerFactory, encoder) +{ + public const string SchemeName = "ApiToken"; + + private static readonly TimeSpan LastUsedResolution = TimeSpan.FromMinutes(1); + + protected override async Task HandleAuthenticateAsync() + { + if (ApiTokenValue.Read(this.Request) is not { } presented) + return AuthenticateResult.NoResult(); + + var now = timeProvider.GetUtcNow().UtcDateTime; + var stored = await apiTokenStore.FindApiToken(ApiTokenValue.Hash(presented), this.Context.RequestAborted); + + if (stored?.User is not { } user || stored.Expires <= now) + return AuthenticateResult.Fail("The API token is unknown, revoked or expired."); + + await apiTokenStore.TouchApiToken(stored.Id, now, now - LastUsedResolution, this.Context.RequestAborted); + + Claim[] claims = + [ + new(AuthClaims.Subject, user.Id.ToString("D", CultureInfo.InvariantCulture)), + new(AuthClaims.Email, user.Email), + new(AuthClaims.Role, user.Role.ToString()), + new(AuthClaims.Tenant, user.TenantId.ToString("D", CultureInfo.InvariantCulture)), + new(AuthClaims.ApiTokenId, stored.Id.ToString("D", CultureInfo.InvariantCulture)), + new(AuthClaims.ApiTokenName, stored.Name), + new(AuthClaims.ApiTokenScope, stored.Scope.ToString()), + ]; + + var identity = new ClaimsIdentity(claims, this.Scheme.Name, AuthClaims.Email, AuthClaims.Role); + + return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), this.Scheme.Name)); + } +} diff --git a/src/Common/EvilCase.Auth/ApiTokenBoundaryMiddleware.cs b/src/Common/EvilCase.Auth/ApiTokenBoundaryMiddleware.cs new file mode 100644 index 00000000..ca56b8cf --- /dev/null +++ b/src/Common/EvilCase.Auth/ApiTokenBoundaryMiddleware.cs @@ -0,0 +1,71 @@ +using System.Security.Claims; +using EvilBrains.EvilCase.Api.Contract.ApiTokens; +using EvilBrains.EvilCase.Api.Contract.User; +using EvilBrains.EvilCase.Domain.ApiTokens; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace EvilBrains.EvilCase.Auth; + +/// +/// Runs after authentication on every path, anonymous endpoints included: a token never reaches sign-in or +/// its own management, and a read-only token never writes. Everything a token's request logs carries the +/// token's id and name. +/// +internal sealed class ApiTokenBoundaryMiddleware(RequestDelegate next, ILogger logger) +{ + public async Task Invoke(HttpContext context) + { + var user = context.User; + + if (user.FindFirstValue(AuthClaims.ApiTokenId) is not { } apiTokenId) + { + await next(context); + return; + } + + using var scope = logger.BeginScope(new Dictionary(StringComparer.Ordinal) + { + ["ApiTokenId"] = apiTokenId, + ["ApiTokenName"] = user.FindFirstValue(AuthClaims.ApiTokenName), + }); + + if (Refusal(context.Request, user) is not { } detail) + { + await next(context); + return; + } + + context.Response.StatusCode = StatusCodes.Status403Forbidden; + + var problemDetails = context.RequestServices.GetRequiredService(); + + await problemDetails.TryWriteAsync(new() + { + HttpContext = context, + ProblemDetails = + { + Status = StatusCodes.Status403Forbidden, + Title = "Forbidden", + Detail = detail, + }, + }); + } + + private static string? Refusal(HttpRequest request, ClaimsPrincipal user) + { + if (request.Path.StartsWithSegments(AuthRoute.Path, StringComparison.OrdinalIgnoreCase) + || request.Path.StartsWithSegments(ApiTokenRoute.Path, StringComparison.OrdinalIgnoreCase)) + { + return "An API token cannot reach sign-in, sessions or API token management."; + } + + var write = !HttpMethods.IsGet(request.Method) && !HttpMethods.IsHead(request.Method) && !HttpMethods.IsOptions(request.Method); + + if (write && !string.Equals(user.FindFirstValue(AuthClaims.ApiTokenScope), nameof(ApiTokenScope.ReadWrite), StringComparison.Ordinal)) + return "A read-only API token cannot write."; + + return null; + } +} diff --git a/src/Common/EvilCase.Auth/ApiTokenCreateOutcome.cs b/src/Common/EvilCase.Auth/ApiTokenCreateOutcome.cs new file mode 100644 index 00000000..18626e44 --- /dev/null +++ b/src/Common/EvilCase.Auth/ApiTokenCreateOutcome.cs @@ -0,0 +1,8 @@ +namespace EvilBrains.EvilCase.Auth; + +public enum ApiTokenCreateOutcome +{ + Created = 0, + + LimitReached = 1, +} diff --git a/src/Common/EvilCase.Auth/ApiTokenCreateResult.cs b/src/Common/EvilCase.Auth/ApiTokenCreateResult.cs new file mode 100644 index 00000000..16ca1a32 --- /dev/null +++ b/src/Common/EvilCase.Auth/ApiTokenCreateResult.cs @@ -0,0 +1,10 @@ +using EvilBrains.EvilCase.Api.Contract.ApiTokens; + +namespace EvilBrains.EvilCase.Auth; + +public sealed record ApiTokenCreateResult +{ + public required ApiTokenCreateOutcome Outcome { get; init; } + + public ApiTokenCreated? Created { get; init; } +} diff --git a/src/Common/EvilCase.Auth/ApiTokenRevokeOutcome.cs b/src/Common/EvilCase.Auth/ApiTokenRevokeOutcome.cs new file mode 100644 index 00000000..28f9d9b2 --- /dev/null +++ b/src/Common/EvilCase.Auth/ApiTokenRevokeOutcome.cs @@ -0,0 +1,8 @@ +namespace EvilBrains.EvilCase.Auth; + +public enum ApiTokenRevokeOutcome +{ + Revoked = 0, + + NotFound = 1, +} diff --git a/src/Common/EvilCase.Auth/ApiTokenService.cs b/src/Common/EvilCase.Auth/ApiTokenService.cs new file mode 100644 index 00000000..a592effa --- /dev/null +++ b/src/Common/EvilCase.Auth/ApiTokenService.cs @@ -0,0 +1,64 @@ +using EvilBrains.EvilCase.Api.Contract.ApiTokens; +using EvilBrains.EvilCase.Data.Entities; +using Microsoft.Extensions.Logging; + +namespace EvilBrains.EvilCase.Auth; + +internal sealed class ApiTokenService(IApiTokenStore apiTokenStore, TimeProvider timeProvider, ILogger logger) : IApiTokenService +{ + public async Task> ListApiTokens(Guid userId, CancellationToken token) + { + var apiTokens = await apiTokenStore.ListApiTokens(userId, token); + + return [.. apiTokens.Select(Describe)]; + } + + public async Task CreateApiToken(Guid userId, ApiTokenCreateRequest request, CancellationToken token) + { + var now = timeProvider.GetUtcNow().UtcDateTime; + var secret = ApiTokenValue.Create(); + + var apiToken = new ApiToken + { + UserId = userId, + Name = request.Name.Trim(), + Scope = request.Scope, + TokenHash = ApiTokenValue.Hash(secret), + Expires = now.AddDays(request.ExpiresInDays), + }; + + if (!await apiTokenStore.AddApiToken(apiToken, now, ApiTokenLimits.MaxActive, token)) + return new() { Outcome = ApiTokenCreateOutcome.LimitReached }; + + logger.LogInformation("API token {ApiTokenId} was created for user {UserId} with scope {ApiTokenScope}", apiToken.Id, userId, apiToken.Scope); + + return new() + { + Outcome = ApiTokenCreateOutcome.Created, + Created = new() { Token = Describe(apiToken), Secret = secret }, + }; + } + + public async Task RevokeApiToken(Guid userId, Guid apiTokenId, CancellationToken token) + { + if (!await apiTokenStore.DeleteApiToken(userId, apiTokenId, token)) + return ApiTokenRevokeOutcome.NotFound; + + logger.LogInformation("API token {ApiTokenId} of user {UserId} was revoked", apiTokenId, userId); + + return ApiTokenRevokeOutcome.Revoked; + } + + private static ApiTokenItem Describe(ApiToken apiToken) + { + return new() + { + ApiTokenId = apiToken.Id, + Name = apiToken.Name, + Scope = apiToken.Scope, + Created = apiToken.Created, + Expires = apiToken.Expires, + LastUsed = apiToken.LastUsed, + }; + } +} diff --git a/src/Common/EvilCase.Auth/ApiTokenStore.cs b/src/Common/EvilCase.Auth/ApiTokenStore.cs new file mode 100644 index 00000000..110c2037 --- /dev/null +++ b/src/Common/EvilCase.Auth/ApiTokenStore.cs @@ -0,0 +1,71 @@ +using EvilBrains.EvilCase.Data.DbContexts; +using EvilBrains.EvilCase.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace EvilBrains.EvilCase.Auth; + +internal sealed class ApiTokenStore(IDbSession dbSession) : IApiTokenStore +{ + public async Task AddApiToken(ApiToken apiToken, DateTime now, int maxActive, CancellationToken token) + { + var context = dbSession.Current; + + await using var transaction = await dbSession.BeginTransaction(token); + + // The user row's lock settles two creations at once: the second counts after the first has written. + await context.Users + .FromSql($"""SELECT * FROM "Users" WHERE "Id" = {apiToken.UserId} FOR UPDATE""") + .IgnoreQueryFilters() + .Select(static user => user.Id) + .SingleAsync(token); + + var active = await context.ApiTokens + .Where(stored => stored.UserId == apiToken.UserId) + .Where(stored => stored.Expires > now) + .CountAsync(token); + + if (active >= maxActive) + return false; + + context.ApiTokens.Add(apiToken); + await context.SaveChangesAsync(token); + + await transaction.CommitAsync(token); + + return true; + } + + public async Task> ListApiTokens(Guid userId, CancellationToken token) + { + return await dbSession.Current.ApiTokens + .Where(stored => stored.UserId == userId) + .OrderByDescending(static stored => stored.Created) + .ToListAsync(token); + } + + // The user carries the tenant filter, and no tenant is known yet. + public async Task FindApiToken(string tokenHash, CancellationToken token) + { + return await dbSession.Current.ApiTokens + .IgnoreQueryFilters() + .Include(static stored => stored.User) + .SingleOrDefaultAsync(stored => stored.TokenHash == tokenHash, token); + } + + public async Task DeleteApiToken(Guid userId, Guid apiTokenId, CancellationToken token) + { + return await dbSession.Current.ApiTokens + .Where(stored => stored.Id == apiTokenId) + .Where(stored => stored.UserId == userId) + .ExecuteDeleteAsync(token) > 0; + } + + // The condition sits in the statement, so concurrent requests write at most once per window. + public async Task TouchApiToken(Guid apiTokenId, DateTime now, DateTime staleBefore, CancellationToken token) + { + await dbSession.Current.ApiTokens + .Where(stored => stored.Id == apiTokenId) + .Where(stored => stored.LastUsed == null || stored.LastUsed < staleBefore) + .ExecuteUpdateAsync(setters => setters.SetProperty(static stored => stored.LastUsed, now), token); + } +} diff --git a/src/Common/EvilCase.Auth/ApiTokenValue.cs b/src/Common/EvilCase.Auth/ApiTokenValue.cs new file mode 100644 index 00000000..b84321e2 --- /dev/null +++ b/src/Common/EvilCase.Auth/ApiTokenValue.cs @@ -0,0 +1,46 @@ +using System.Buffers.Text; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Http; + +namespace EvilBrains.EvilCase.Auth; + +/// +/// SHA-256, not a KDF, for the same reason as . The prefix picks the +/// authentication scheme and lets a secret scanner recognise a leaked token. +/// +internal static class ApiTokenValue +{ + public const string Prefix = "evc_"; + + private const int SizeInBytes = 32; + + public static string Create() + { + return Prefix + Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(SizeInBytes)); + } + + public static string Hash(string token) + { + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); + } + + /// + /// The API token a single Authorization: Bearer header carries; any other header is the JWT + /// scheme's to judge. + /// + public static string? Read(HttpRequest request) + { + var headers = request.Headers.Authorization; + + if (headers.Count != 1 || !AuthenticationHeaderValue.TryParse(headers[0], out var header)) + return null; + + return string.Equals(header.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase) + && header.Parameter is { } value + && value.StartsWith(Prefix, StringComparison.Ordinal) + ? value + : null; + } +} diff --git a/src/Common/EvilCase.Auth/Bootstrap.cs b/src/Common/EvilCase.Auth/Bootstrap.cs index fe125d37..0ffc7b21 100644 --- a/src/Common/EvilCase.Auth/Bootstrap.cs +++ b/src/Common/EvilCase.Auth/Bootstrap.cs @@ -1,8 +1,10 @@ using System.Text; using EvilBrains.EvilCase.Api.Contract.User; using EvilBrains.EvilCase.Data.DbContexts; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; @@ -13,6 +15,8 @@ namespace EvilBrains.EvilCase.Auth; public static class Bootstrap { + private const string SelectorScheme = "EvilCase"; + public static IServiceCollection AddEvilCaseAuth(this IServiceCollection services) { services @@ -29,39 +33,25 @@ public static IServiceCollection AddEvilCaseAuth(this IServiceCollection service services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + // An API token goes to its own handler, anything else to the JWT, which also reads the access + // cookie; a token beside the cookie therefore wins. services - .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(); + .AddAuthentication(SelectorScheme) + .AddPolicyScheme( + SelectorScheme, + displayName: null, + static options => options.ForwardDefaultSelector = static context => ApiTokenValue.Read(context.Request) is null + ? JwtBearerDefaults.AuthenticationScheme + : ApiTokenAuthenticationHandler.SchemeName) + .AddJwtBearer() + .AddScheme(ApiTokenAuthenticationHandler.SchemeName, configureOptions: null); services .AddOptions(JwtBearerDefaults.AuthenticationScheme) - .Configure>(static (options, authSettings) => - { - var jwt = authSettings.Value.Jwt; - - // Off, or "sub" and "role" arrive under WS-Federation URIs. - options.MapInboundClaims = false; - - options.TokenValidationParameters.ValidateIssuer = true; - options.TokenValidationParameters.ValidateAudience = true; - options.TokenValidationParameters.ValidateLifetime = true; - options.TokenValidationParameters.ValidateIssuerSigningKey = true; - options.TokenValidationParameters.ValidIssuer = jwt.Issuer; - options.TokenValidationParameters.ValidAudience = jwt.Audience; - options.TokenValidationParameters.IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Key)); - options.TokenValidationParameters.ClockSkew = TimeSpan.Zero; - - // Without these the principal has no name and no roles at all, because mapping is off. - options.TokenValidationParameters.NameClaimType = AuthClaims.Email; - options.TokenValidationParameters.RoleClaimType = AuthClaims.Role; - - // Tokens are signed with HS256; without this a caller could pick the algorithm. - options.TokenValidationParameters.ValidAlgorithms = [SecurityAlgorithms.HmacSha256]; - - // The browser sends the token as a cookie; a caller with an Authorization header keeps using it. - options.Events = new() { OnMessageReceived = ReadAccessCookie }; - }); + .Configure>(ConfigureJwtBearer); // Default deny: an unattributed endpoint fails closed; open ones say [AllowAnonymous]. services @@ -71,6 +61,41 @@ public static IServiceCollection AddEvilCaseAuth(this IServiceCollection service return services; } + /// + /// Between authentication and authorization. + /// + public static IApplicationBuilder UseEvilCaseApiTokenBoundary(this IApplicationBuilder app) + { + return app.UseMiddleware(); + } + + private static void ConfigureJwtBearer(JwtBearerOptions options, IOptions authSettings) + { + var jwt = authSettings.Value.Jwt; + + // Off, or "sub" and "role" arrive under WS-Federation URIs. + options.MapInboundClaims = false; + + options.TokenValidationParameters.ValidateIssuer = true; + options.TokenValidationParameters.ValidateAudience = true; + options.TokenValidationParameters.ValidateLifetime = true; + options.TokenValidationParameters.ValidateIssuerSigningKey = true; + options.TokenValidationParameters.ValidIssuer = jwt.Issuer; + options.TokenValidationParameters.ValidAudience = jwt.Audience; + options.TokenValidationParameters.IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Key)); + options.TokenValidationParameters.ClockSkew = TimeSpan.Zero; + + // Without these the principal has no name and no roles at all, because mapping is off. + options.TokenValidationParameters.NameClaimType = AuthClaims.Email; + options.TokenValidationParameters.RoleClaimType = AuthClaims.Role; + + // Tokens are signed with HS256; without this a caller could pick the algorithm. + options.TokenValidationParameters.ValidAlgorithms = [SecurityAlgorithms.HmacSha256]; + + // The browser sends the token as a cookie; a caller with an Authorization header keeps using it. + options.Events = new() { OnMessageReceived = ReadAccessCookie }; + } + private static Task ReadAccessCookie(MessageReceivedContext context) { if (string.IsNullOrEmpty(context.Request.Headers.Authorization) diff --git a/src/Common/EvilCase.Auth/IApiTokenService.cs b/src/Common/EvilCase.Auth/IApiTokenService.cs new file mode 100644 index 00000000..749327cb --- /dev/null +++ b/src/Common/EvilCase.Auth/IApiTokenService.cs @@ -0,0 +1,12 @@ +using EvilBrains.EvilCase.Api.Contract.ApiTokens; + +namespace EvilBrains.EvilCase.Auth; + +public interface IApiTokenService +{ + public Task> ListApiTokens(Guid userId, CancellationToken token); + + public Task CreateApiToken(Guid userId, ApiTokenCreateRequest request, CancellationToken token); + + public Task RevokeApiToken(Guid userId, Guid apiTokenId, CancellationToken token); +} diff --git a/src/Common/EvilCase.Auth/IApiTokenStore.cs b/src/Common/EvilCase.Auth/IApiTokenStore.cs new file mode 100644 index 00000000..708be46b --- /dev/null +++ b/src/Common/EvilCase.Auth/IApiTokenStore.cs @@ -0,0 +1,32 @@ +using EvilBrains.EvilCase.Data.Entities; + +namespace EvilBrains.EvilCase.Auth; + +internal interface IApiTokenStore +{ + /// + /// False where the user already holds tokens that have not expired at + /// ; nothing is written then. + /// + public Task AddApiToken(ApiToken apiToken, DateTime now, int maxActive, CancellationToken token); + + /// + /// Newest first, expired ones included. + /// + public Task> ListApiTokens(Guid userId, CancellationToken token); + + /// + /// With its user, whatever the tenant: a token is presented before a tenant is known. + /// + public Task FindApiToken(string tokenHash, CancellationToken token); + + /// + /// False where the user holds no such token. + /// + public Task DeleteApiToken(Guid userId, Guid apiTokenId, CancellationToken token); + + /// + /// Stamps as the last use unless a use after is already stamped. + /// + public Task TouchApiToken(Guid apiTokenId, DateTime now, DateTime staleBefore, CancellationToken token); +} diff --git a/src/Data/EvilCase.Data.Migrations/Migrations/20261030183953_ApiTokens.Designer.cs b/src/Data/EvilCase.Data.Migrations/Migrations/20261030183953_ApiTokens.Designer.cs new file mode 100644 index 00000000..ad909e37 --- /dev/null +++ b/src/Data/EvilCase.Data.Migrations/Migrations/20261030183953_ApiTokens.Designer.cs @@ -0,0 +1,914 @@ +// +using System; +using EvilBrains.EvilCase.Data.DbContexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EvilBrains.EvilCase.Data.Migrations.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20261030183953_ApiTokens")] + partial class ApiTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm"); + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "unaccent"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Account", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Act", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActNumber") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CaseId") + .HasColumnType("uuid"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Direction") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("ExternalActNumber") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CaseId"); + + b.HasIndex("ContactId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "ActNumber") + .IsUnique(); + + b.ToTable("Acts", t => + { + t.HasCheckConstraint("CK_Acts_DirectionWithContact", "(\"Direction\" IS NULL) = (\"ContactId\" IS NULL)"); + }); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.ApiToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsed") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("ApiTokens"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Case", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CaseNumber") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExternalCaseNumber") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParentCaseId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(18) + .HasColumnType("character varying(18)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("ParentCaseId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "CaseNumber") + .IsUnique(); + + b.ToTable("Cases"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.CaseAncestor", b => + { + b.Property("CaseId") + .HasColumnType("uuid"); + + b.Property("Depth") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("CaseId"); + + b.ToTable((string)null); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Comment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("CaseId") + .HasColumnType("uuid"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ActId"); + + b.HasIndex("CaseId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.ToTable("Comments", t => + { + t.HasCheckConstraint("CK_Comments_OnACaseOrAnAct", "(\"CaseId\" IS NULL) <> (\"ActId\" IS NULL)"); + }); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Contact", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Address") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("DataBoxId") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.FileAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActId") + .HasColumnType("uuid"); + + b.Property("CaseId") + .HasColumnType("uuid"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ActId"); + + b.HasIndex("CaseId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.ToTable("FileAssets", t => + { + t.HasCheckConstraint("CK_FileAssets_OnACaseOrAnAct", "(\"CaseId\" IS NULL) <> (\"ActId\" IS NULL)"); + }); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.FileVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("FileAssetId") + .HasColumnType("uuid"); + + b.Property("MediaType") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FileAssetId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "FileAssetId", "Number") + .IsUnique(); + + b.ToTable("FileVersions"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Label", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(6) + .HasColumnType("character varying(6)"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Labels"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.LabelAssignment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActId") + .HasColumnType("uuid"); + + b.Property("CaseId") + .HasColumnType("uuid"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("LabelId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ActId"); + + b.HasIndex("CaseId"); + + b.HasIndex("LabelId"); + + b.HasIndex("TenantId", "LabelId", "ActId") + .IsUnique(); + + b.HasIndex("TenantId", "LabelId", "CaseId") + .IsUnique(); + + b.ToTable("LabelAssignments", t => + { + t.HasCheckConstraint("CK_LabelAssignments_OnACaseOrAnAct", "(\"CaseId\" IS NULL) <> (\"ActId\" IS NULL)"); + }); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AuthSessionId") + .HasColumnType("uuid"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByIp") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsed") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SessionExpires") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("UserAgent") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AuthSessionId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Tenant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.User", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("FailedLoginAttempts") + .HasColumnType("integer"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Act", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.Case", "Case") + .WithMany("Acts") + .HasForeignKey("CaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Contact", "Contact") + .WithMany("Acts") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Case"); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.ApiToken", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Case", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.Contact", "Contact") + .WithMany("Cases") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Case", "ParentCase") + .WithMany("ChildCases") + .HasForeignKey("ParentCaseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + + b.Navigation("ParentCase"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Comment", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.Act", "Act") + .WithMany("Comments") + .HasForeignKey("ActId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Case", "Case") + .WithMany("Comments") + .HasForeignKey("CaseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Act"); + + b.Navigation("Case"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Contact", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.FileAsset", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.Act", "Act") + .WithMany("Files") + .HasForeignKey("ActId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Case", "Case") + .WithMany("Files") + .HasForeignKey("CaseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Act"); + + b.Navigation("Case"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.FileVersion", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.FileAsset", "FileAsset") + .WithMany("Versions") + .HasForeignKey("FileAssetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("FileAsset"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Label", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.LabelAssignment", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.Act", "Act") + .WithMany("Labels") + .HasForeignKey("ActId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Case", "Case") + .WithMany("Labels") + .HasForeignKey("CaseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Label", "Label") + .WithMany("Assignments") + .HasForeignKey("LabelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Act"); + + b.Navigation("Case"); + + b.Navigation("Label"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.RefreshToken", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Tenant", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.User", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Act", b => + { + b.Navigation("Comments"); + + b.Navigation("Files"); + + b.Navigation("Labels"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Case", b => + { + b.Navigation("Acts"); + + b.Navigation("ChildCases"); + + b.Navigation("Comments"); + + b.Navigation("Files"); + + b.Navigation("Labels"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Contact", b => + { + b.Navigation("Acts"); + + b.Navigation("Cases"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.FileAsset", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Label", b => + { + b.Navigation("Assignments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Data/EvilCase.Data.Migrations/Migrations/20261030183953_ApiTokens.cs b/src/Data/EvilCase.Data.Migrations/Migrations/20261030183953_ApiTokens.cs new file mode 100644 index 00000000..92909fa7 --- /dev/null +++ b/src/Data/EvilCase.Data.Migrations/Migrations/20261030183953_ApiTokens.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EvilBrains.EvilCase.Data.Migrations.Migrations; + +// Outside the history like RefreshTokens: the rows carry the token hash. +public partial class ApiTokens : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ApiTokens", + columns: static table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Scope = table.Column(type: "character varying(9)", maxLength: 9, nullable: false), + TokenHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Created = table.Column(type: "timestamp with time zone", nullable: false), + Updated = table.Column(type: "timestamp with time zone", nullable: true), + Expires = table.Column(type: "timestamp with time zone", nullable: false), + LastUsed = table.Column(type: "timestamp with time zone", nullable: true), + }, + constraints: static table => + { + table.PrimaryKey("PK_ApiTokens", static x => x.Id); + table.ForeignKey( + name: "FK_ApiTokens_Users_UserId", + column: static x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ApiTokens_TokenHash", + table: "ApiTokens", + column: "TokenHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ApiTokens_UserId", + table: "ApiTokens", + column: "UserId"); + + migrationBuilder.Sql( + """CREATE TRIGGER stamp_timestamps BEFORE INSERT OR UPDATE ON "ApiTokens" FOR EACH ROW EXECUTE FUNCTION stamp_timestamps();"""); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "ApiTokens"); + } +} diff --git a/src/Data/EvilCase.Data.Migrations/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Data/EvilCase.Data.Migrations/Migrations/ApplicationDbContextModelSnapshot.cs index 70a63d5c..cd8155c8 100644 --- a/src/Data/EvilCase.Data.Migrations/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Data/EvilCase.Data.Migrations/Migrations/ApplicationDbContextModelSnapshot.cs @@ -113,6 +113,53 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.ApiToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsed") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Updated") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("ApiTokens"); + }); + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Case", b => { b.Property("Id") @@ -621,6 +668,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Contact"); }); + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.ApiToken", b => + { + b.HasOne("EvilBrains.EvilCase.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("EvilBrains.EvilCase.Data.Entities.Case", b => { b.HasOne("EvilBrains.EvilCase.Data.Entities.Contact", "Contact") diff --git a/src/Data/EvilCase.Data/DbContexts/ApplicationDbContext.cs b/src/Data/EvilCase.Data/DbContexts/ApplicationDbContext.cs index f6915d37..2d7ed462 100644 --- a/src/Data/EvilCase.Data/DbContexts/ApplicationDbContext.cs +++ b/src/Data/EvilCase.Data/DbContexts/ApplicationDbContext.cs @@ -17,6 +17,8 @@ public class ApplicationDbContext(DbContextOptions options public DbSet RefreshTokens => this.Set(); + public DbSet ApiTokens => this.Set(); + public DbSet Contacts => this.Set(); public DbSet Cases => this.Set(); @@ -171,6 +173,12 @@ private static void ConfigureAccounts(ModelBuilder modelBuilder) .WithMany() .HasForeignKey(static token => token.UserId) .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(static token => token.User) + .WithMany() + .HasForeignKey(static token => token.UserId) + .OnDelete(DeleteBehavior.Cascade); } private static void ConfigureCases(ModelBuilder modelBuilder) diff --git a/src/Data/EvilCase.Data/Entities/ApiToken.cs b/src/Data/EvilCase.Data/Entities/ApiToken.cs new file mode 100644 index 00000000..b707bbbc --- /dev/null +++ b/src/Data/EvilCase.Data/Entities/ApiToken.cs @@ -0,0 +1,40 @@ +using System.ComponentModel.DataAnnotations; +using EvilBrains.EvilCase.Domain.ApiTokens; +using Microsoft.EntityFrameworkCore; + +namespace EvilBrains.EvilCase.Data.Entities; + +/// +/// Outside the tenant like : authentication finds it before a tenant is known. +/// Revocation deletes the row. +/// +[Index(nameof(TokenHash), IsUnique = true)] +[Index(nameof(UserId))] +public sealed record ApiToken : IEntity +{ + [Key] + public Guid Id { get; init; } = Guid.CreateVersion7(); + + public required Guid UserId { get; init; } + + [MaxLength(64)] + public required string Name { get; init; } + + public required ApiTokenScope Scope { get; init; } + + /// + /// SHA-256 of the whole token, hex. + /// + [MaxLength(64)] + public required string TokenHash { get; init; } + + public DateTime Created { get; init; } + + public DateTime? Updated { get; init; } + + public required DateTime Expires { get; init; } + + public DateTime? LastUsed { get; init; } + + public User? User { get; init; } +} diff --git a/src/EvilCase.Host/Program.cs b/src/EvilCase.Host/Program.cs index 09f5385e..05450495 100644 --- a/src/EvilCase.Host/Program.cs +++ b/src/EvilCase.Host/Program.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using System.Threading.RateLimiting; using DotNetEnv; using EvilBrains.Collections; @@ -187,6 +188,23 @@ static async (context, next) => // Explicit: the builder's implicit UseAuthentication runs ahead of the whole pipeline, // before forwarded headers and request logging. app.UseAuthentication(); + +// The request completion event is written outside the token's log scope, so it takes the token here. +app.Use( + static async (context, next) => + { + if (context.User.FindFirstValue(AuthClaims.ApiTokenId) is { } apiTokenId) + { + var diagnosticContext = context.RequestServices.GetRequiredService(); + + diagnosticContext.Set("ApiTokenId", apiTokenId); + diagnosticContext.Set("ApiTokenName", context.User.FindFirstValue(AuthClaims.ApiTokenName)); + } + + await next(context); + }); + +app.UseEvilCaseApiTokenBoundary(); app.UseAuthorization(); // Replaces UseBlazorFrameworkFiles, whose /_framework branch would terminate ahead of it. diff --git a/src/Tests/EvilCase.Tests/Auth/ApiTokenServiceTests.cs b/src/Tests/EvilCase.Tests/Auth/ApiTokenServiceTests.cs new file mode 100644 index 00000000..05125f00 --- /dev/null +++ b/src/Tests/EvilCase.Tests/Auth/ApiTokenServiceTests.cs @@ -0,0 +1,105 @@ +using System.Security.Cryptography; +using System.Text; +using EvilBrains.EvilCase.Api.Contract.ApiTokens; +using EvilBrains.EvilCase.Auth; +using EvilBrains.EvilCase.Data.Entities; +using EvilBrains.EvilCase.Domain.ApiTokens; +using EvilBrains.EvilCase.Tests.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace EvilBrains.EvilCase.Tests.Auth; + +public class ApiTokenServiceTests : TenantFixture +{ + private static readonly ApiTokenCreateRequest Request = new() { Name = " Claude Code ", Scope = ApiTokenScope.ReadWrite, ExpiresInDays = 30 }; + + private ApiTokenService service = null!; + + [SetUp] + public void SetUpService() + { + var store = new ApiTokenStore(new TransactionalDbSession(this.Tenant.Context)); + + this.service = new ApiTokenService(store, TimeProvider.System, NullLogger.Instance); + } + + [Test] + public async Task TheSecretTravelsOnceAndOnlyItsHashIsStored() + { + var result = await this.service.CreateApiToken(this.Tenant.UserId, Request, CancellationToken.None); + var secret = result.Created!.Secret; + + var stored = await this.Tenant.Context.ApiTokens + .AsNoTracking() + .SingleAsync(apiToken => apiToken.Id == result.Created.Token.ApiTokenId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(secret, Does.Match("^evc_[A-Za-z0-9_-]{43}$"), "a fixed prefix and 256 random bits, base64url"); + Assert.That(stored.TokenHash, Is.EqualTo(Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(secret))))); + Assert.That(stored.Name, Is.EqualTo("Claude Code")); + Assert.That(stored.Expires, Is.EqualTo(DateTime.UtcNow.AddDays(30)).Within(TimeSpan.FromMinutes(1))); + } + } + + [Test] + public async Task TheTokenPastTheLimitIsRefused() + { + for (var index = 0; index < ApiTokenLimits.MaxActive; index++) + await this.service.CreateApiToken(this.Tenant.UserId, Request, CancellationToken.None); + + var past = await this.service.CreateApiToken(this.Tenant.UserId, Request, CancellationToken.None); + var count = await this.Tenant.Context.ApiTokens.CountAsync(apiToken => apiToken.UserId == this.Tenant.UserId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(past.Outcome, Is.EqualTo(ApiTokenCreateOutcome.LimitReached)); + Assert.That(count, Is.EqualTo(ApiTokenLimits.MaxActive), "the refused token writes nothing"); + } + } + + [Test] + public async Task AnExpiredTokenLeavesRoomUnderTheLimit() + { + for (var index = 0; index < ApiTokenLimits.MaxActive; index++) + { + this.Tenant.Context.ApiTokens.Add(new ApiToken + { + UserId = this.Tenant.UserId, + Name = "expired", + Scope = ApiTokenScope.ReadOnly, + TokenHash = ApiTokenValue.Hash(ApiTokenValue.Create()), + Expires = DateTime.UtcNow.AddMinutes(-1), + }); + } + + await this.Tenant.Context.SaveChangesAsync(); + + var result = await this.service.CreateApiToken(this.Tenant.UserId, Request, CancellationToken.None); + + Assert.That(result.Outcome, Is.EqualTo(ApiTokenCreateOutcome.Created), "the limit counts unexpired tokens only"); + } + + [Test] + public async Task ConcurrentCreationsStopAtTheLimit() + { + for (var index = 0; index < ApiTokenLimits.MaxActive - 1; index++) + await this.service.CreateApiToken(this.Tenant.UserId, Request, CancellationToken.None); + + await using var otherContext = TestDatabase.CreateMigrated(this.Tenant.UserContext); + var other = new ApiTokenService(new ApiTokenStore(new TransactionalDbSession(otherContext)), TimeProvider.System, NullLogger.Instance); + + var results = await Task.WhenAll( + this.service.CreateApiToken(this.Tenant.UserId, Request, CancellationToken.None), + other.CreateApiToken(this.Tenant.UserId, Request, CancellationToken.None)); + + var count = await this.Tenant.Context.ApiTokens.CountAsync(apiToken => apiToken.UserId == this.Tenant.UserId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(results.Select(static result => result.Outcome), Is.EquivalentTo([ApiTokenCreateOutcome.Created, ApiTokenCreateOutcome.LimitReached])); + Assert.That(count, Is.EqualTo(ApiTokenLimits.MaxActive), "the second creation counts after the first has written"); + } + } +} diff --git a/src/Tests/EvilCase.Tests/Auth/ApiTokenStoreTests.cs b/src/Tests/EvilCase.Tests/Auth/ApiTokenStoreTests.cs new file mode 100644 index 00000000..ffdcc5fa --- /dev/null +++ b/src/Tests/EvilCase.Tests/Auth/ApiTokenStoreTests.cs @@ -0,0 +1,99 @@ +using EvilBrains.EvilCase.Auth; +using EvilBrains.EvilCase.Data.Entities; +using EvilBrains.EvilCase.Domain.ApiTokens; +using EvilBrains.EvilCase.Tests.Data; +using Microsoft.EntityFrameworkCore; + +namespace EvilBrains.EvilCase.Tests.Auth; + +public class ApiTokenStoreTests : TenantFixture +{ + private static readonly DateTime Now = new(2026, 10, 30, 12, 0, 0, DateTimeKind.Utc); + + private static readonly TimeSpan Resolution = TimeSpan.FromMinutes(1); + + [Test] + public async Task TheLastUseIsStampedAtMostOnceAMinute() + { + var store = new ApiTokenStore(new FixedDbSession(this.Tenant.Context)); + var apiToken = await this.AddApiToken(this.Tenant.UserId); + + await Touch(store, apiToken, Now); + await Touch(store, apiToken, Now.AddSeconds(59)); + var withinTheMinute = await this.LastUsed(apiToken); + + await Touch(store, apiToken, Now.AddSeconds(61)); + var afterTheMinute = await this.LastUsed(apiToken); + + using (Assert.EnterMultipleScope()) + { + Assert.That(withinTheMinute, Is.EqualTo(Now), "a use within a minute of the stamped one writes nothing"); + Assert.That(afterTheMinute, Is.EqualTo(Now.AddSeconds(61)), "a use a minute after the stamped one moves it"); + } + } + + [Test] + public async Task ATokenIsFoundWithItsUserBeforeATenantIsKnown() + { + var apiToken = await this.AddApiToken(this.Tenant.UserId); + + await using var anonymous = TestDatabase.CreateMigrated(new StubUserContext()); + var store = new ApiTokenStore(new FixedDbSession(anonymous)); + + var found = await store.FindApiToken(apiToken.TokenHash, CancellationToken.None); + + using (Assert.EnterMultipleScope()) + { + Assert.That(found?.Id, Is.EqualTo(apiToken.Id), "authentication runs before any tenant is known"); + Assert.That(found?.User?.Id, Is.EqualTo(this.Tenant.UserId), "the tenant comes from the user row, so the user is read with the token"); + } + } + + [Test] + public async Task AUserRevokesOnlyTheirOwnToken() + { + var other = await this.Tenant.AddUser(); + var apiToken = await this.AddApiToken(this.Tenant.UserId); + var store = new ApiTokenStore(new FixedDbSession(this.Tenant.Context)); + + var byOther = await store.DeleteApiToken(other.Id, apiToken.Id, CancellationToken.None); + var stillThere = await this.Tenant.Context.ApiTokens.AnyAsync(stored => stored.Id == apiToken.Id); + + using (Assert.EnterMultipleScope()) + { + Assert.That(byOther, Is.False); + Assert.That(stillThere, Is.True, "another user of the same tenant cannot revoke the token"); + } + } + + private static async Task Touch(ApiTokenStore store, ApiToken apiToken, DateTime now) + { + await store.TouchApiToken(apiToken.Id, now, now - Resolution, CancellationToken.None); + } + + private async Task AddApiToken(Guid userId) + { + var apiToken = new ApiToken + { + UserId = userId, + Name = "agent", + Scope = ApiTokenScope.ReadOnly, + TokenHash = ApiTokenValue.Hash(ApiTokenValue.Create()), + Expires = Now.AddDays(90), + }; + + this.Tenant.Context.ApiTokens.Add(apiToken); + await this.Tenant.Context.SaveChangesAsync(); + + return apiToken; + } + + private async Task LastUsed(ApiToken apiToken) + { + return await this.Tenant.Context.ApiTokens + .AsNoTracking() + .Where(stored => stored.Id == apiToken.Id) + .Select(static stored => stored.LastUsed) + .SingleAsync(); + } +} diff --git a/src/Tests/EvilCase.Tests/Auth/FakeApiTokenStore.cs b/src/Tests/EvilCase.Tests/Auth/FakeApiTokenStore.cs new file mode 100644 index 00000000..b4e94c04 --- /dev/null +++ b/src/Tests/EvilCase.Tests/Auth/FakeApiTokenStore.cs @@ -0,0 +1,74 @@ +using EvilBrains.EvilCase.Auth; +using EvilBrains.EvilCase.Data.Entities; +using EvilBrains.EvilCase.Domain.ApiTokens; + +namespace EvilBrains.EvilCase.Tests.Auth; + +internal sealed class FakeApiTokenStore : IApiTokenStore +{ + private readonly Lock writes = new(); + + private readonly List tokens = []; + + /// + /// Returns the secret, which only the caller of a real creation would see. + /// + public string Issue(User user, ApiTokenScope scope, DateTime? expires = null) + { + var secret = ApiTokenValue.Create(); + + lock (this.writes) + { + this.tokens.Add(new ApiToken + { + UserId = user.Id, + User = user, + Name = $"token-{this.tokens.Count}", + Scope = scope, + TokenHash = ApiTokenValue.Hash(secret), + Expires = expires ?? DateTime.UtcNow.AddDays(90), + }); + } + + return secret; + } + + public void Forget(string secret) + { + var hash = ApiTokenValue.Hash(secret); + + lock (this.writes) + this.tokens.RemoveAll(stored => string.Equals(stored.TokenHash, hash, StringComparison.Ordinal)); + } + + public async Task AddApiToken(ApiToken apiToken, DateTime now, int maxActive, CancellationToken token) + { + lock (this.writes) + this.tokens.Add(apiToken with { Created = now }); + + return true; + } + + public async Task> ListApiTokens(Guid userId, CancellationToken token) + { + lock (this.writes) + return [.. this.tokens.Where(stored => stored.UserId == userId)]; + } + + public async Task FindApiToken(string tokenHash, CancellationToken token) + { + lock (this.writes) + return this.tokens.Find(stored => string.Equals(stored.TokenHash, tokenHash, StringComparison.Ordinal)); + } + + public async Task DeleteApiToken(Guid userId, Guid apiTokenId, CancellationToken token) + { + lock (this.writes) + return this.tokens.RemoveAll(stored => stored.Id == apiTokenId && stored.UserId == userId) > 0; + } + + public Task TouchApiToken(Guid apiTokenId, DateTime now, DateTime staleBefore, CancellationToken token) + { + return Task.CompletedTask; + } +} diff --git a/src/Tests/EvilCase.Tests/Data/DatabaseHistoryTests.cs b/src/Tests/EvilCase.Tests/Data/DatabaseHistoryTests.cs index df47b530..7e433594 100644 --- a/src/Tests/EvilCase.Tests/Data/DatabaseHistoryTests.cs +++ b/src/Tests/EvilCase.Tests/Data/DatabaseHistoryTests.cs @@ -10,6 +10,9 @@ namespace EvilBrains.EvilCase.Tests.Data; /// public class DatabaseHistoryTests { + // Both carry a token hash and change on every use. + private static readonly string[] Unhistorized = ["RefreshTokens", "ApiTokens"]; + [Test] public async Task InsertUpdateAndDeleteWriteThreeHistoryRowsInOrder() { @@ -66,7 +69,7 @@ await context.Accounts } [Test] - public async Task EveryMappedTableExceptRefreshTokensCarriesTheRecordTrigger() + public async Task EveryMappedTableExceptTheTokenTablesCarriesTheRecordTrigger() { await using var context = TestDatabase.CreateMigrated(); @@ -74,7 +77,7 @@ public async Task EveryMappedTableExceptRefreshTokensCarriesTheRecordTrigger() .Select(static entityType => entityType.GetTableName()) .OfType() .Distinct(StringComparer.Ordinal) - .Where(static table => !string.Equals(table, "RefreshTokens", StringComparison.Ordinal)) + .Except(Unhistorized, StringComparer.Ordinal) .ToList(); var triggered = await context.Database @@ -91,13 +94,13 @@ FROM pg_trigger t using (Assert.EnterMultipleScope()) { Assert.That(historized, Is.Not.Empty, "the model maps entities carrying history at all, or this test passes vacuously"); - Assert.That(triggered, Is.SupersetOf(historized), "a mapped table other than RefreshTokens has no record_history trigger, so its rows would be written with no history"); - Assert.That(triggered, Does.Not.Contain("RefreshTokens"), "RefreshTokens rotates on every refresh and carries the token hash, so it stays out of history"); + Assert.That(triggered, Is.SupersetOf(historized), "a mapped table other than the token tables has no record_history trigger, so its rows would be written with no history"); + Assert.That(triggered, Has.None.AnyOf(Unhistorized), "the token tables carry the token hash and change on every use, so they stay out of history"); } } [Test] - public async Task RefreshTokensHasNoMirrorTable() + public async Task TheTokenTablesHaveNoMirrorTable() { await using var context = TestDatabase.CreateMigrated(); @@ -111,7 +114,7 @@ FROM pg_class c """) .ToListAsync(); - Assert.That(mirrors, Does.Not.Contain("RefreshTokens"), "RefreshTokens rotates on every refresh and carries the token hash, so it carries no mirror table"); + Assert.That(mirrors, Has.None.AnyOf(Unhistorized), "the token tables carry the token hash and change on every use, so they carry no mirror table"); } [Test] @@ -122,7 +125,7 @@ public async Task EveryHistoryTableCarriesAtLeastEveryColumnOfItsTable() var historized = context.Model.GetEntityTypes() .Select(static entityType => entityType.GetTableName()!) .Distinct(StringComparer.Ordinal) - .Where(static table => !string.Equals(table, "RefreshTokens", StringComparison.Ordinal)) + .Except(Unhistorized, StringComparer.Ordinal) .ToList(); using (Assert.EnterMultipleScope()) diff --git a/src/Tests/EvilCase.Tests/Data/Model/TenantIsolationTests.cs b/src/Tests/EvilCase.Tests/Data/Model/TenantIsolationTests.cs index 0a6a4f2d..726dbb6e 100644 --- a/src/Tests/EvilCase.Tests/Data/Model/TenantIsolationTests.cs +++ b/src/Tests/EvilCase.Tests/Data/Model/TenantIsolationTests.cs @@ -106,7 +106,7 @@ public void TheOnlyRowsOutsideATenantAreAccountsTenantsAndTokens() Assert.That( untenanted, - Is.EquivalentTo(["Account", "Tenant", "RefreshToken", "CaseAncestor"]), + Is.EquivalentTo(["Account", "Tenant", "RefreshToken", "ApiToken", "CaseAncestor"]), "a new entity outside the tenant is a leak until someone says otherwise"); } diff --git a/src/Tests/EvilCase.Tests/Frontend/ApiTokenSettingsRenderTests.cs b/src/Tests/EvilCase.Tests/Frontend/ApiTokenSettingsRenderTests.cs new file mode 100644 index 00000000..f2bfe027 --- /dev/null +++ b/src/Tests/EvilCase.Tests/Frontend/ApiTokenSettingsRenderTests.cs @@ -0,0 +1,105 @@ +using Bunit; +using EvilBrains.EvilCase.Api.Client; +using EvilBrains.EvilCase.Api.Contract.ApiTokens; +using EvilBrains.EvilCase.App.Clipboard; +using EvilBrains.EvilCase.App.Pages; +using EvilBrains.EvilCase.Domain.ApiTokens; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.DependencyInjection; + +namespace EvilBrains.EvilCase.Tests.Frontend; + +public class ApiTokenSettingsRenderTests +{ + private const string Secret = "evc_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + + private static readonly ApiTokenItem Live = new() + { + ApiTokenId = Guid.CreateVersion7(), + Name = "Claude Code", + Scope = ApiTokenScope.ReadWrite, + Created = DateTime.UtcNow.AddDays(-1), + Expires = DateTime.UtcNow.AddDays(89), + LastUsed = DateTime.UtcNow.AddMinutes(-5), + }; + + private static readonly ApiTokenItem Expired = new() + { + ApiTokenId = Guid.CreateVersion7(), + Name = "Zkušební skript", + Scope = ApiTokenScope.ReadOnly, + Created = DateTime.UtcNow.AddDays(-40), + Expires = DateTime.UtcNow.AddDays(-10), + }; + + [Test] + public void TheApiTokenSettingsRender() + { + using var ctx = Context(out _); + + var component = ctx.Render(); + + component.WaitForAssertion(() => Assert.That(component.FindAll(".ec-token-row"), Has.Count.EqualTo(3))); + } + + [Test] + public void OnlyAnExpiredTokenIsMarkedExpired() + { + using var ctx = Context(out _); + + var component = ctx.Render(); + component.WaitForAssertion(() => Assert.That(component.FindAll(".ec-settings-list .ec-token-row"), Has.Count.EqualTo(2))); + + var rows = component.FindAll(".ec-settings-list .ec-token-row"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(rows[0].QuerySelectorAll(".ec-token-expired"), Is.Empty, "a token before its expiry is live"); + Assert.That(rows[1].QuerySelectorAll(".ec-token-expired"), Is.Not.Empty, "a token past its expiry shows Vypršel"); + } + } + + [Test] + public async Task TheCreatedTokenIsShownUntilTheWindowCloses() + { + await using var ctx = Context(out var client); + + client + .CreateApiToken(Arg.Any(), Arg.Any()) + .Returns(new ApiTokenCreated { Token = Live, Secret = Secret }); + + var component = ctx.Render(); + await component.WaitForAssertionAsync(() => Assert.That(component.FindAll(".ec-token-row"), Has.Count.EqualTo(3))); + + await component.Find(".ec-card-header-actions .ec-button").ClickAsync(new MouseEventArgs()); + await component.Find("#api-token-name").ChangeAsync(new ChangeEventArgs { Value = "Claude Code" }); + await component.FindAll(".ec-modal-footer .ec-button")[^1].ClickAsync(new MouseEventArgs()); + + var shown = component.Find("#api-token-secret").GetAttribute("value"); + + await component.FindAll(".ec-modal-footer .ec-button")[^1].ClickAsync(new MouseEventArgs()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(shown, Is.EqualTo(Secret)); + Assert.That(component.Markup, Does.Not.Contain(Secret), "the secret is gone once its window closes"); + } + } + + private static BunitContext Context(out IApiTokensClient client) + { + var ctx = new BunitContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + client = Substitute.For(); + client + .ListApiTokens(Arg.Any()) + .Returns(new ApiTokenListResponse { Items = [Live, Expired] }); + + ctx.Services.AddSingleton(client); + ctx.Services.AddSingleton(new StubClipboardWriter()); + + return ctx; + } +} diff --git a/src/Tests/EvilCase.Tests/Frontend/SettingsRenderTests.cs b/src/Tests/EvilCase.Tests/Frontend/LabelSettingsRenderTests.cs similarity index 91% rename from src/Tests/EvilCase.Tests/Frontend/SettingsRenderTests.cs rename to src/Tests/EvilCase.Tests/Frontend/LabelSettingsRenderTests.cs index 9ac04543..d2d4d699 100644 --- a/src/Tests/EvilCase.Tests/Frontend/SettingsRenderTests.cs +++ b/src/Tests/EvilCase.Tests/Frontend/LabelSettingsRenderTests.cs @@ -8,7 +8,7 @@ namespace EvilBrains.EvilCase.Tests.Frontend; -public class SettingsRenderTests +public class LabelSettingsRenderTests { [Test] public void TheLabelSettingsRender() @@ -25,7 +25,7 @@ public void TheLabelSettingsRender() ctx.Services.AddSingleton(labelsClient); - var component = ctx.Render(); + var component = ctx.Render(); component.WaitForAssertion(() => Assert.That(component.FindComponents(), Has.Count.EqualTo(1))); } diff --git a/src/Tests/EvilCase.Tests/Frontend/SettingsExportRenderTests.cs b/src/Tests/EvilCase.Tests/Frontend/SettingsExportRenderTests.cs index a4419b5b..7fc9bf33 100644 --- a/src/Tests/EvilCase.Tests/Frontend/SettingsExportRenderTests.cs +++ b/src/Tests/EvilCase.Tests/Frontend/SettingsExportRenderTests.cs @@ -1,5 +1,6 @@ using Bunit; using EvilBrains.EvilCase.App.Files; +using EvilBrains.EvilCase.App.Layout; using EvilBrains.EvilCase.App.Pages; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; @@ -16,8 +17,18 @@ public void TheExportSectionRendersAsTheCurrentSection() ctx.Services.AddSingleton(new StubFileDownloader()); ctx.Services.GetRequiredService().NavigateTo("/settings/export"); - var component = ctx.Render(); + var component = ctx.Render(static parameters => parameters.Add( + static layout => layout.Body, + static builder => + { + builder.OpenComponent(0); + builder.CloseComponent(); + })); - Assert.That(component.Find("[aria-current=page]").TextContent.Trim(), Is.EqualTo("Export"), "the nav marks only the section shown"); + using (Assert.EnterMultipleScope()) + { + Assert.That(component.Find("[aria-current=page]").TextContent.Trim(), Is.EqualTo("Export"), "the nav marks only the section shown"); + Assert.That(component.FindAll(".ec-export-action .ec-button"), Has.Count.EqualTo(1)); + } } } diff --git a/src/Tests/EvilCase.Tests/Frontend/SettingsRedirectTests.cs b/src/Tests/EvilCase.Tests/Frontend/SettingsRedirectTests.cs new file mode 100644 index 00000000..71051984 --- /dev/null +++ b/src/Tests/EvilCase.Tests/Frontend/SettingsRedirectTests.cs @@ -0,0 +1,21 @@ +using Bunit; +using EvilBrains.EvilCase.App.Pages; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; + +namespace EvilBrains.EvilCase.Tests.Frontend; + +public class SettingsRedirectTests +{ + [Test] + public void TheSettingsRootOpensTheLabels() + { + using var ctx = new BunitContext(); + + ctx.Render(); + + var navigation = ctx.Services.GetRequiredService(); + + Assert.That(navigation.ToBaseRelativePath(navigation.Uri), Is.EqualTo("settings/labels")); + } +} diff --git a/src/Tests/EvilCase.Tests/Hosting/ApiTokenAuthenticationTests.cs b/src/Tests/EvilCase.Tests/Hosting/ApiTokenAuthenticationTests.cs new file mode 100644 index 00000000..c90ff8b0 --- /dev/null +++ b/src/Tests/EvilCase.Tests/Hosting/ApiTokenAuthenticationTests.cs @@ -0,0 +1,214 @@ +using System.Net; +using System.Net.Http.Headers; +using EvilBrains.EvilCase.Api.Contract.Cases; +using EvilBrains.EvilCase.Api.Contract.User; +using EvilBrains.EvilCase.Auth; +using EvilBrains.EvilCase.Business.Cases; +using EvilBrains.EvilCase.Business.Entities; +using EvilBrains.EvilCase.Business.Labels; +using EvilBrains.EvilCase.Data.Entities; +using EvilBrains.EvilCase.Domain.ApiTokens; +using EvilBrains.EvilCase.Domain.Users; +using EvilBrains.EvilCase.Tests.Auth; +using Microsoft.Extensions.DependencyInjection; + +namespace EvilBrains.EvilCase.Tests.Hosting; + +/// +/// The store is a fake; the scheme selection, the claims, the boundary and the authorization run as in production. +/// +public class ApiTokenAuthenticationTests +{ + private const string CasesPath = "/api/cases?take=10"; + + private readonly FakeApiTokenStore store = new(); + + private readonly List tenantsSeen = []; + + private readonly User user = new() + { + Id = Guid.CreateVersion7(), + TenantId = Guid.CreateVersion7(), + Email = "agent@evilcase.test", + PasswordHash = "not-verified-here", + Role = UserRole.User, + }; + + private EvilCaseHost host = null!; + + private HttpClient client = null!; + + [OneTimeSetUp] + public void SetUp() + { + var labels = Substitute.For(); + labels + .DeleteLabel(Arg.Any(), Arg.Any()) + .Returns(DeleteOutcome.Deleted); + + this.host = new EvilCaseHost(configureServices: services => + { + services.AddSingleton(this.store); + services.AddSingleton(labels); + + // Records the tenant the request runs under when the reader is asked. + services.AddScoped(provider => + { + var userContext = provider.GetRequiredService(); + var reader = Substitute.For(); + + reader + .ListCases(Arg.Any(), Arg.Any()) + .Returns(_ => + { + this.tenantsSeen.Add(userContext.TenantId); + + return new CaseListResponse { Items = [], TotalCount = 0 }; + }); + + return reader; + }); + }); + + this.client = this.host.CreateClient(); + } + + [OneTimeTearDown] + public void TearDown() + { + this.client.Dispose(); + this.host.Dispose(); + } + + [Test] + public async Task ARequestRunsUnderTheTenantOfTheTokensUser() + { + var secret = this.store.Issue(this.user, ApiTokenScope.ReadOnly); + this.tenantsSeen.Clear(); + + using var response = await this.Send(HttpMethod.Get, CasesPath, secret); + + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(this.tenantsSeen, Is.EqualTo([this.user.TenantId]), "a token acts as its user, tenant included"); + } + } + + [Test] + public async Task AReadOnlyTokenReadsButNeverWrites() + { + var secret = this.store.Issue(this.user, ApiTokenScope.ReadOnly); + + using var read = await this.Send(HttpMethod.Get, CasesPath, secret); + using var write = await this.Send(HttpMethod.Delete, $"/api/labels/{Guid.CreateVersion7()}", secret); + + using (Assert.EnterMultipleScope()) + { + Assert.That(read.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(write.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden), "a read-only token gets 403 on every write"); + } + } + + [Test] + public async Task AReadWriteTokenWrites() + { + var secret = this.store.Issue(this.user, ApiTokenScope.ReadWrite); + + using var write = await this.Send(HttpMethod.Delete, $"/api/labels/{Guid.CreateVersion7()}", secret); + + Assert.That(write.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); + } + + [Test] + public async Task AnExpiredTokenIsUnauthorized() + { + var secret = this.store.Issue(this.user, ApiTokenScope.ReadWrite, expires: DateTime.UtcNow.AddSeconds(-1)); + + using var response = await this.Send(HttpMethod.Get, CasesPath, secret); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } + + [Test] + public async Task AnUnknownTokenIsUnauthorized() + { + using var response = await this.Send(HttpMethod.Get, CasesPath, ApiTokenValue.Create()); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } + + [Test] + public async Task ARevokedTokenStopsOnTheNextRequest() + { + var secret = this.store.Issue(this.user, ApiTokenScope.ReadOnly); + + using var before = await this.Send(HttpMethod.Get, CasesPath, secret); + this.store.Forget(secret); + using var after = await this.Send(HttpMethod.Get, CasesPath, secret); + + using (Assert.EnterMultipleScope()) + { + Assert.That(before.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(after.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized), "nothing caches a token, so the revocation holds on the very next request"); + } + } + + [Test] + public async Task ALockedOutUserKeepsTheirToken() + { + var locked = this.user with { Id = Guid.CreateVersion7(), Email = "locked@evilcase.test", LockoutEnd = DateTime.UtcNow.AddMinutes(15) }; + var secret = this.store.Issue(locked, ApiTokenScope.ReadOnly); + + using var response = await this.Send(HttpMethod.Get, CasesPath, secret); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), "the lockout blocks the password sign-in alone"); + } + + [TestCase("GET", "/api/api-tokens")] + [TestCase("POST", "/api/api-tokens")] + [TestCase("DELETE", "/api/api-tokens/0199a1b2-0000-7000-8000-000000000000")] + [TestCase("GET", "/api/auth/user-info")] + [TestCase("GET", "/api/auth/sessions")] + [TestCase("POST", "/api/auth/logout-all")] + [TestCase("POST", "/api/auth/logout")] + public async Task ATokenCannotReachSignInOrTokenManagement(string method, string path) + { + var secret = this.store.Issue(this.user, ApiTokenScope.ReadWrite); + + using var response = await this.Send(new HttpMethod(method), path, secret); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden), "token management and /api/auth stay cookie sessions only"); + } + + [Test] + public async Task ATokenBesideTheCookieAuthenticatesTheRequest() + { + var secret = this.store.Issue(this.user, ApiTokenScope.ReadWrite); + var cookie = TestTokens.TokenFrom(this.host); + + using var cookieOnly = await this.Send(HttpMethod.Get, "/api/api-tokens", secret: null, cookie); + using var both = await this.Send(HttpMethod.Get, "/api/api-tokens", secret, cookie); + using var unknownBeside = await this.Send(HttpMethod.Get, "/api/api-tokens", ApiTokenValue.Create(), cookie); + + using (Assert.EnterMultipleScope()) + { + Assert.That(cookieOnly.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(both.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden), "the token wins over the cookie, and a token never manages tokens"); + Assert.That(unknownBeside.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized), "a refused token never falls back to the cookie"); + } + } + + private async Task Send(HttpMethod method, string path, string? secret, string? cookie = null) + { + using var request = new HttpRequestMessage(method, new Uri(path, UriKind.Relative)); + + if (secret is not null) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secret); + + if (cookie is not null) + request.Headers.Add("Cookie", $"{AccessCookie.Name}={cookie}"); + + return await this.client.SendAsync(request); + } +} diff --git a/src/Tests/EvilCase.Tests/Hosting/OpenApiDocumentTests.cs b/src/Tests/EvilCase.Tests/Hosting/OpenApiDocumentTests.cs new file mode 100644 index 00000000..fa5670c1 --- /dev/null +++ b/src/Tests/EvilCase.Tests/Hosting/OpenApiDocumentTests.cs @@ -0,0 +1,29 @@ +using System.Net; +using System.Text.Json; + +namespace EvilBrains.EvilCase.Tests.Hosting; + +/// +/// The host runs as Testing, so this is the document outside Development. +/// +public class OpenApiDocumentTests +{ + [Test] + public async Task TheDocumentIsAnonymousAndDeclaresTheBearerScheme() + { + await using var host = new EvilCaseHost(); + using var client = host.CreateClient(); + + using var response = await client.GetAsync(new Uri("/openapi/v1.json", UriKind.Relative)); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + + var scheme = document.RootElement.GetProperty("components").GetProperty("securitySchemes").GetProperty("Bearer"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), "an integration reads the contract before it holds a token"); + Assert.That(scheme.GetProperty("type").GetString(), Is.EqualTo("http")); + Assert.That(scheme.GetProperty("scheme").GetString(), Is.EqualTo("bearer")); + } + } +}