From a3d7b3c40f9bc94db283aa05c1dc51f7479015b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 18:57:33 +0000 Subject: [PATCH 1/6] Add the API token settings design Exported from the approved canvas artboard; the settings navigation of nastaveni.html gains the API tokeny entry. Export follows with #674. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UovdganawgyqDHGsank6jS --- docs/design/README.md | 3 +- docs/design/nastaveni-api-tokeny.html | 265 ++++++++++++++++++++++++++ docs/design/nastaveni.html | 5 + 3 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 docs/design/nastaveni-api-tokeny.html 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..29cd24e4 --- /dev/null +++ b/docs/design/nastaveni-api-tokeny.html @@ -0,0 +1,265 @@ + + + + + +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..47771f1c 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,9 @@

Štítky + + API tokeny +
@@ -216,6 +220,7 @@

Nastaven
From 2a0200b98754f73200a8b289ea26cc42964961d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 18:57:42 +0000 Subject: [PATCH 2/6] Authenticate integrations with personal API tokens A token belongs to one user and acts as that user, tenant and role read from the user row on every request. It is evc_ plus 256 random bits, stored as its SHA-256 and found by a unique index, sent as a Bearer header; a policy scheme picks its handler by the prefix, so a token beside the access cookie wins. A boundary after authentication keeps a token out of /api/auth/** and token management (403) and refuses every write of a read-only token (403). An unknown, revoked or expired token is 401. Revocation deletes the row. The last use is stamped by a conditional update at most once a minute. Creation locks the user row, so two at once cannot pass the limit of 20 unexpired tokens. The OpenAPI document is anonymous in every environment and declares the Bearer scheme; Scalar stays Development only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UovdganawgyqDHGsank6jS --- README.md | 10 + docs/sdd/sdd-005-api-konvence.md | 10 +- docs/sdd/sdd-006-tenance-a-ucty.md | 46 +- .../ApiTokens/ApiTokenCreateRequest.cs | 19 + .../ApiTokens/ApiTokenCreated.cs | 11 + .../ApiTokens/ApiTokenItem.cs | 18 + .../ApiTokens/ApiTokenLimits.cs | 21 + .../ApiTokens/ApiTokenListResponse.cs | 6 + .../ApiTokens/ApiTokenProblems.cs | 8 + .../ApiTokens/ApiTokenRoute.cs | 14 + .../EvilCase.Api.Contract/User/AuthClaims.cs | 9 + src/Api/EvilCase.Api/Bootstrap.cs | 23 +- .../Controllers/ApiTokensController.cs | 65 ++ .../ApiTokens/ApiTokenScope.cs | 12 + .../ApiTokenAuthenticationHandler.cs | 53 + .../ApiTokenBoundaryMiddleware.cs | 71 ++ .../EvilCase.Auth/ApiTokenCreateOutcome.cs | 8 + .../EvilCase.Auth/ApiTokenCreateResult.cs | 10 + .../EvilCase.Auth/ApiTokenRevokeOutcome.cs | 8 + src/Common/EvilCase.Auth/ApiTokenService.cs | 64 ++ src/Common/EvilCase.Auth/ApiTokenStore.cs | 71 ++ src/Common/EvilCase.Auth/ApiTokenValue.cs | 46 + src/Common/EvilCase.Auth/Bootstrap.cs | 81 +- src/Common/EvilCase.Auth/IApiTokenService.cs | 12 + src/Common/EvilCase.Auth/IApiTokenStore.cs | 32 + .../20261030183953_ApiTokens.Designer.cs | 914 ++++++++++++++++++ .../Migrations/20261030183953_ApiTokens.cs | 56 ++ .../ApplicationDbContextModelSnapshot.cs | 58 ++ .../DbContexts/ApplicationDbContext.cs | 8 + src/Data/EvilCase.Data/Entities/ApiToken.cs | 40 + src/EvilCase.Host/Program.cs | 1 + .../Auth/ApiTokenServiceTests.cs | 105 ++ .../EvilCase.Tests/Auth/ApiTokenStoreTests.cs | 99 ++ .../EvilCase.Tests/Auth/FakeApiTokenStore.cs | 74 ++ .../Data/Model/TenantIsolationTests.cs | 2 +- .../Hosting/ApiTokenAuthenticationTests.cs | 214 ++++ .../Hosting/OpenApiDocumentTests.cs | 29 + 37 files changed, 2283 insertions(+), 45 deletions(-) create mode 100644 src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenCreateRequest.cs create mode 100644 src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenCreated.cs create mode 100644 src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenItem.cs create mode 100644 src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenLimits.cs create mode 100644 src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenListResponse.cs create mode 100644 src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenProblems.cs create mode 100644 src/Api/EvilCase.Api.Contract/ApiTokens/ApiTokenRoute.cs create mode 100644 src/Api/EvilCase.Api/Controllers/ApiTokensController.cs create mode 100644 src/Business/EvilCase.Domain/ApiTokens/ApiTokenScope.cs create mode 100644 src/Common/EvilCase.Auth/ApiTokenAuthenticationHandler.cs create mode 100644 src/Common/EvilCase.Auth/ApiTokenBoundaryMiddleware.cs create mode 100644 src/Common/EvilCase.Auth/ApiTokenCreateOutcome.cs create mode 100644 src/Common/EvilCase.Auth/ApiTokenCreateResult.cs create mode 100644 src/Common/EvilCase.Auth/ApiTokenRevokeOutcome.cs create mode 100644 src/Common/EvilCase.Auth/ApiTokenService.cs create mode 100644 src/Common/EvilCase.Auth/ApiTokenStore.cs create mode 100644 src/Common/EvilCase.Auth/ApiTokenValue.cs create mode 100644 src/Common/EvilCase.Auth/IApiTokenService.cs create mode 100644 src/Common/EvilCase.Auth/IApiTokenStore.cs create mode 100644 src/Data/EvilCase.Data.Migrations/Migrations/20261030183953_ApiTokens.Designer.cs create mode 100644 src/Data/EvilCase.Data.Migrations/Migrations/20261030183953_ApiTokens.cs create mode 100644 src/Data/EvilCase.Data/Entities/ApiToken.cs create mode 100644 src/Tests/EvilCase.Tests/Auth/ApiTokenServiceTests.cs create mode 100644 src/Tests/EvilCase.Tests/Auth/ApiTokenStoreTests.cs create mode 100644 src/Tests/EvilCase.Tests/Auth/FakeApiTokenStore.cs create mode 100644 src/Tests/EvilCase.Tests/Hosting/ApiTokenAuthenticationTests.cs create mode 100644 src/Tests/EvilCase.Tests/Hosting/OpenApiDocumentTests.cs 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/sdd/sdd-005-api-konvence.md b/docs/sdd/sdd-005-api-konvence.md index 6c252577..78884e02 100644 --- a/docs/sdd/sdd-005-api-konvence.md +++ b/docs/sdd/sdd-005-api-konvence.md @@ -19,8 +19,10 @@ 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í @@ -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/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..1a0a1f7b --- /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.CreatedAtAction(nameof(this.ListApiTokens), routeValues: null, 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/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..ef521833 100644 --- a/src/EvilCase.Host/Program.cs +++ b/src/EvilCase.Host/Program.cs @@ -187,6 +187,7 @@ static async (context, next) => // Explicit: the builder's implicit UseAuthentication runs ahead of the whole pipeline, // before forwarded headers and request logging. app.UseAuthentication(); +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/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/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")); + } + } +} From 8f90a9a0bd767cee4b353223bad27feef70511b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 18:57:59 +0000 Subject: [PATCH 3/6] Split the settings into a page per section and add API tokens Labels move to /settings/labels and the new API token section is /settings/api-tokens; /settings redirects to the labels. Both pages share SettingsLayout, whose navigation lists the sections, so a later section is one entry and one page. The token section lists name, scope, created, expiry and last use, marks an expired token, creates a token with a name, a scope and a lifetime of 30, 90 or 365 days, shows the new token once with a copy button, and revokes a token through the confirmation modal. A new token defaults to read only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UovdganawgyqDHGsank6jS --- docs/design/nastaveni-api-tokeny.html | 4 + docs/design/nastaveni.html | 4 + docs/sdd/sdd-016-navigace-a-vzhled.md | 6 +- docs/sdd/sdd-019-stitky.md | 36 +-- .../Components/ApiTokenCreatedModal.razor | 73 ++++++ .../Components/ApiTokenFormModal.razor | 120 ++++++++++ .../Components/ApiTokenRevokeModal.razor | 74 ++++++ .../EvilCase.App/Components/LabelField.razor | 2 +- .../EvilCase.App/Components/SettingsNav.razor | 26 --- src/App/EvilCase.App/Icons/AppIcons.cs | 7 + .../EvilCase.App/Layout/SettingsLayout.razor | 37 +++ .../EvilCase.App/Models/ApiTokenDisplay.cs | 50 +++++ .../EvilCase.App/Models/ApiTokenEditModel.cs | 16 ++ .../EvilCase.App/Pages/ApiTokenSettings.razor | 212 ++++++++++++++++++ .../EvilCase.App/Pages/LabelSettings.razor | 190 ++++++++++++++++ src/App/EvilCase.App/Pages/Settings.razor | 194 +--------------- .../EvilCase.App/Pages/SettingsExport.razor | 59 +++-- .../wwwroot/css/ec-components.css | 197 +++++++++++++++- .../EvilCase.App/wwwroot/css/ec-tokens.css | 20 ++ .../Frontend/ApiTokenSettingsRenderTests.cs | 105 +++++++++ ...erTests.cs => LabelSettingsRenderTests.cs} | 4 +- .../Frontend/SettingsExportRenderTests.cs | 15 +- .../Frontend/SettingsRedirectTests.cs | 21 ++ 23 files changed, 1201 insertions(+), 271 deletions(-) create mode 100644 src/App/EvilCase.App/Components/ApiTokenCreatedModal.razor create mode 100644 src/App/EvilCase.App/Components/ApiTokenFormModal.razor create mode 100644 src/App/EvilCase.App/Components/ApiTokenRevokeModal.razor delete mode 100644 src/App/EvilCase.App/Components/SettingsNav.razor create mode 100644 src/App/EvilCase.App/Layout/SettingsLayout.razor create mode 100644 src/App/EvilCase.App/Models/ApiTokenDisplay.cs create mode 100644 src/App/EvilCase.App/Models/ApiTokenEditModel.cs create mode 100644 src/App/EvilCase.App/Pages/ApiTokenSettings.razor create mode 100644 src/App/EvilCase.App/Pages/LabelSettings.razor create mode 100644 src/Tests/EvilCase.Tests/Frontend/ApiTokenSettingsRenderTests.cs rename src/Tests/EvilCase.Tests/Frontend/{SettingsRenderTests.cs => LabelSettingsRenderTests.cs} (91%) create mode 100644 src/Tests/EvilCase.Tests/Frontend/SettingsRedirectTests.cs diff --git a/docs/design/nastaveni-api-tokeny.html b/docs/design/nastaveni-api-tokeny.html index 29cd24e4..8f081e17 100644 --- a/docs/design/nastaveni-api-tokeny.html +++ b/docs/design/nastaveni-api-tokeny.html @@ -58,6 +58,9 @@

API tokeny + + Export +
@@ -220,6 +223,7 @@

Nastavení

diff --git a/docs/design/nastaveni.html b/docs/design/nastaveni.html index 47771f1c..3d387095 100644 --- a/docs/design/nastaveni.html +++ b/docs/design/nastaveni.html @@ -59,6 +59,9 @@

API tokeny + + Export +
@@ -221,6 +224,7 @@

Nastaven
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/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/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")); + } +} From 8bb6cb64ce22778ab3d2ab73d776c0c1f4fdcd2a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 19:10:30 +0000 Subject: [PATCH 4/6] Keep ApiTokens out of the history convention tests ApiTokens carries the token hash and its last use moves every minute, so its migration gives it no history trigger; the convention tests still named RefreshTokens alone and failed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UovdganawgyqDHGsank6jS --- .../EvilCase.Tests/Data/DatabaseHistoryTests.cs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) 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()) From f597391e2af65f88059562d19aa1a8f9f852b67e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 19:10:33 +0000 Subject: [PATCH 5/6] Carry the API token on the request completion event Serilog writes the completion event outside the token's log scope, so the request's own record named no token. The diagnostic context adds the token id and name to it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UovdganawgyqDHGsank6jS --- src/EvilCase.Host/Program.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/EvilCase.Host/Program.cs b/src/EvilCase.Host/Program.cs index ef521833..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,22 @@ 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(); From 55940a8f3b35a81850ee2bdc93b17a09b4e82546 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 20:01:08 +0000 Subject: [PATCH 6/6] Answer token creation with 201 without Location A token has no detail endpoint, so the created response carries no Location; SDD-005 names the exception. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UovdganawgyqDHGsank6jS --- docs/sdd/sdd-005-api-konvence.md | 6 +++--- src/Api/EvilCase.Api/Controllers/ApiTokensController.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/sdd/sdd-005-api-konvence.md b/docs/sdd/sdd-005-api-konvence.md index 78884e02..86f85fd7 100644 --- a/docs/sdd/sdd-005-api-konvence.md +++ b/docs/sdd/sdd-005-api-konvence.md @@ -24,9 +24,9 @@ Tvary API a klienta. - 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é diff --git a/src/Api/EvilCase.Api/Controllers/ApiTokensController.cs b/src/Api/EvilCase.Api/Controllers/ApiTokensController.cs index 1a0a1f7b..945f5bdb 100644 --- a/src/Api/EvilCase.Api/Controllers/ApiTokensController.cs +++ b/src/Api/EvilCase.Api/Controllers/ApiTokensController.cs @@ -38,7 +38,7 @@ public async Task> CreateApiToken( return result.Outcome switch { - ApiTokenCreateOutcome.Created => this.CreatedAtAction(nameof(this.ListApiTokens), routeValues: null, result.Created), + ApiTokenCreateOutcome.Created => this.StatusCode(StatusCodes.Status201Created, result.Created), ApiTokenCreateOutcome.LimitReached => this.Problem( detail: $"At most {ApiTokenLimits.MaxActive} unexpired tokens per user.", statusCode: StatusCodes.Status400BadRequest,