Modul Accounts

Uživatelské účty a role – registrace, přihlášení, role User/Admin, kdo smí co, první admin, správa uživatelů a audit. Data v users.db (migrace).

Na této stránce

V jedné větě: Accounts ví, kdo je přihlášený a co smí: uživatel se zaregistruje e-mailem a heslem, při přihlášení dostane zašifrovanou cookie se svými rolemi a Web podle policies rozhodne, kam ho pustí. Běží ve službě Web, data má v users.db.

Modul vzniká postupně podle plánu Uživatelské účty a role. Hotové jsou kroky 1 (registrace, přihlášení), 2 (role, oprávnění, první admin), 3 (vlastník objednávky – zákazník vidí jen své, viz Ordering), 5 (e-maily), 6 (správa uživatelů a audit), 8 (zpevnění) a 9 (otevřený web); krok 7 zabezpečil sklad (Inventory).

Role a oprávnění

Kde Nepřihlášený User (zákazník) Admin
Úvod, Dokumentace, Přihlášení, Registrace, Ochrana osobních údajů, /health ✅ ✅ ✅
Objednávky – seznam a detail → přihlášení jen své (cizí = 404) všechny
Objednávky – založit (stránka i POST /api/orders) ❌ ✅ jen s potvrzeným e-mailem (jméno a e-mail z účtu) ❌
Smazat vlastní účet ❌ ✅ (bez nevyřízených objednávek) ❌ (nejdřív vrátit na usera)
Objednávky – zrušit (potvrzenou nebo balenou, e-shop krok 6) ❌ jen svou jakoukoli
Expedice – balit, odeslat, doručeno (/Admin/Fulfilment, e-shop krok 6), obnovit katalog ❌ ❌ ✅
Produkty – přidat, naskladnit, upravit, smazat ❌ ❌ ✅
Průběh a přehrání objednávky (stránky i GET /api/journeys/…) ❌ ❌ ✅
Změna hesla ❌ ✅ ✅
Uživatelé – přehled, detail, povýšit / vrátit na usera, zablokovat / odblokovat (/Admin/…) ❌ ❌ ✅ (ne sám sobě)

Pravidla jsou pojmenovaná na jednom místě – policies RequireAdmin a RequireCustomer v AuthorizationPolicies.cs, registrované v AccountsServiceCollectionExtensions.cs. Celou tabulku ověřuje test AuthorizationMatrixTests skutečnými HTTP požadavky proti celému Webu.

Jak se rozhoduje

flowchart TD
    req(["Požadavek"]) --> auth["UseAuthentication<br/>cookie → User (ID, jméno, role)"]
    auth --> must{"musí změnit heslo?"}
    must -->|ano| change["→ /Account/ChangePassword<br/>(API: 403)"]
    must -->|ne| endpoint{"stránka / endpoint má…"}
    endpoint -->|"[AllowAnonymous]"| ok(["pustit"])
    endpoint -->|"[Authorize(Policy)]"| policy{"splňuje policy?"}
    endpoint -->|"nic"| fallback{"přihlášený?<br/>(fallback policy)"}
    fallback -->|ano| ok
    fallback -->|ne| login["stránka: → /Account/Login<br/>API: 401"]
    policy -->|ano| ok
    policy -->|"ne, nepřihlášený"| login
    policy -->|"ne, přihlášený"| denied["stránka: → /Account/AccessDenied<br/>API: 403"]
  • Secure by default (fallback policy): co není výslovně [AllowAnonymous], vyžaduje přihlášení. Nová stránka je zamčená, i když na ni autor zapomene.
  • Stránka Objednávky má víc akcí (handlerů) s různými pravidly – Razor Pages umí [Authorize] jen na celou stránku, proto handlery volají IAuthorizationService se stejnými policies a bez oprávnění vrátí Forbid().
  • Skrytí v UI je jen pohodlí (menu Produkty a Expedice, košík a pokladna, tlačítko Zrušit). Chrání server.

První admin

Admina jmenuje admin – kdo ale jmenuje prvního? (Problém slepice a vejce.)

  • V produkci se první admin zakládá příkazem přímo v běžícím kontejneru:
    sudo -u deploy docker compose exec web dotnet MiniShop.Web.dll create-admin --email <e-mail> --name "<jméno>"
    
    Heslo se píše skrytě do terminálu – nikdy neleží v GitHubu, v .env ani v historii příkazů. Příkaz projde, jen když žádný admin není. Kód: CreateAdminCommand.cs.
  • Ve vývoji ho při startu vytvoří seed z AdminBootstrap v appsettings.Development.json (admin@minishop.local). Heslo je v repozitáři, proto má příznak „musí změnit heslo“ – dokud ho nezmění, přesměruje ho každá stránka na změnu hesla (MustChangePasswordMiddleware.cs).
  • Existující účet se adminem nikdy nestane. Bez potvrzení e-mailu by si kdokoli mohl adminův e-mail zaregistrovat dřív a seed by mu práva admina dal.
  • Účty z kroku 1 (bez role) dostanou při startu roli User. Kód: AccountsSeeder.cs.
  • Další admini vznikají povýšením v aplikaci (níže). Nový admin se nejdřív sám zaregistruje – heslo tak zná jen on.

Správa uživatelů

Menu Uživatelé (jen admin): přehled všech účtů s rolí, stavem, počtem objednávek a útratou, detail s objednávkami a historií změn. Na detailu jsou tlačítka Povýšit na admina / Vrátit na usera a Zablokovat / Odblokovat.

flowchart TD
    act(["Admin klikne na akci"]) --> tx["transakce v users.db"]
    tx --> actor{"je volající pořád<br/>aktivní admin? (databáze)"}
    actor -->|ne| fail(["chyba – nic se nezmění"])
    actor -->|ano| last{"zůstane aspoň jeden<br/>jiný aktivní admin?"}
    last -->|"ne (vrátit / zablokovat posledního)"| fail
    last -->|ano| self{"jde o vlastní účet?"}
    self -->|"ano (vrátit / zablokovat)"| fail
    self -->|ne| change["změna role User ↔ Admin<br/>nebo zámek účtu"]
    change --> stamp["nový security stamp"]
    stamp --> audit["záznam do AccountAuditEntries<br/>kdo, komu, co, kdy"]
    audit --> commit(["commit"])
  • Rozhoduje databáze, ne cookie. Admin, kterému jiný admin právě roli odebral, má možná ještě starou cookie – akce mu přesto neprojde.
  • Vždy zůstane aktivní admin a nikdo si nemůže sám odebrat roli ani se zablokovat. Kontrola i změna jsou v jedné transakci: když dva admini ve stejnou chvíli odebírají jeden druhého, SQLite druhou změnu nepustí.
  • Zablokování = zámek účtu Identity „navždy“ (do odblokování). Přihlášení skončí stejnou hláškou jako špatné heslo – jiná by prozradila, že účet existuje.
  • Odhlášení do minuty: každá změna vymění security stamp (otisk „verze“ účtu). Web cookie každou minutu porovná s databází (SecurityStampValidator, interval v AccountsServiceCollectionExtensions.cs); nesedí-li, cookie zahodí. Přirovnání: přenastavený zámek – staré klíče přestanou fungovat.
  • Audit jen přibývá (žádná úprava ani mazání), e-maily v něm jsou kopie z doby akce.
  • Objednávky dodá Ordering (IOrderService.GetCustomerSummariesAsync, detail přes OrderAccess.Customer(id)) – moduly se neznají, spojí je až stránka ve Webu přes ID uživatele.

Kód: AccountAdministration.cs, stránky Admin/Users a Detail. Celá složka /Admin je navíc zamčená konvencí AuthorizeFolder v Program.cs.

Zpevnění

Účty krok 8 přidaly několik vrstev obrany (defense in depth) – každá chytí něco, co ostatní propustí.

flowchart LR
    att(["Útočník zkouší hesla"]) --> rl{"Rate limiting<br/>max. 10 odeslání formuláře<br/>za minutu z jedné IP"}
    rl -->|"víc"| r429(["429 – Příliš mnoho pokusů"])
    rl -->|"projde"| lock{"Zámek účtu<br/>5 špatných hesel za sebou"}
    lock -->|"5×"| locked(["účet zamčený 15 minut<br/>(stejná hláška jako špatné heslo)"])
    lock -->|"méně"| pwd{"heslo"}
    pwd -->|"špatné"| audit["log: ID účtu, kolikátý pokus<br/>(nikdy e-mail ani heslo)"]
    pwd -->|"správné"| ok(["přihlášen, počítadlo = 0"])
Vrstva Co dělá Kód
Zámek účtu 5 špatných hesel za sebou → 15 minut. Admin zámek vidí v přehledu („zamčený do 15:42“) a může ho zrušit. Dočasný zámek adminovi práva nebere – útočník by jinak zkoušením hesel „odstavil“ všechny adminy. AccountsInfrastructure.cs, Login.cshtml.cs
Rate limiting Formuláře přihlášení, registrace, zapomenutého a nového hesla, Poslat znovu a změny hesla: z jedné IP nejvýš 10 odeslání za minutu, pak 429. Brzdí hádání hesel, zkoušení uniklých hesel z jiných webů (credential stuffing) i zahlcení Gmailu. IP za Caddy z X-Forwarded-For. AccountRateLimiting.cs
Bezpečnostní hlavičky CSP (skripty jen ze souborů MiniShopu – žádné inline <script>, Bootstrap lokálně), HSTS (jen HTTPS, 1 rok, bez preload), X-Frame-Options: DENY + frame-ancestors 'none' (proti clickjackingu), nosniff, Referrer-Policy (odkaz s tokenem neodejde jinému webu) SecurityHeaders.cs
Logy a trasy Do trasy jen enduser.id (ID účtu); v logu nikdy e-mail ani heslo (hlídá test). Serilog loguje cestu bez query stringu, OpenTelemetry query string maskuje. SecurityHeaders.cs (UseEndUserTracing)

Přirovnání: rate limiting je vrátný, který ke dveřím pustí jen pár lidí za minutu; zámek účtu je platební karta, která po třech špatných PINech na chvíli přestane fungovat; CSP je provozní řád, podle kterého prohlížeč nespustí nic, co nepřišlo z MiniShopu.

Otevřený web a osobní údaje

Od účtů kroku 9 je web veřejný – Caddy už nechce heslo (basic auth zůstal jen před Aspire dashboardem). Prohlížet může každý, ale:

  • Objednávat jde až s potvrzeným e-mailem. Policy RequireCustomer = role User a claim minishop:email_confirmed. Robot s vymyšlenou adresou nic neobjedná a e-maily o objednávkách nechodí cizím lidem.
  • Smazání účtu (právo být zapomenut, GDPR): jméno v hlavičce → Smazat účet → znovu heslo.
sequenceDiagram
    autonumber
    actor U as Uživatel
    participant P as Account/Delete (Web)
    participant O as Ordering (ordering.db)
    participant A as AccountDeletion (users.db)

    U->>P: heslo
    P->>P: CheckPasswordSignInAsync (počítá se do zámku účtu)
    P->>O: AnonymizeCustomerAsync(ID)
    alt nevyřízená objednávka (Pending, Confirmed)
        O-->>P: DomainException – nic se nezmění
        P-->>U: „Účet nejde smazat, dokud…“
    else
        O->>O: objednávky: jméno → „Smazaný zákazník“, e-mail → prázdný
    O->>O: doručovací adresy smazat (e-shop krok 4)
    O->>O: košík zákazníka smazat (e-shop krok 3, stejná transakce)
        P->>A: DeleteAsync(user)
        A->>A: audit: e-mail → „smazaný účet“, smazat účet (role, claimy cascade)
        P->>P: odhlásit
        P-->>U: „Účet je smazaný“
    end
  • Pořadí: nejdřív objednávky, pak účet. Když smazání účtu selže, uživatel to zkusí znovu (anonymizace podruhé nic nepokazí). Obráceně by mohl zůstat smazaný účet s objednávkami, na kterých je pořád jeho e-mail.
  • Objednávky zůstanou (položky, ceny, stav – účetnictví a statistiky), CustomerId také: po smazání účtu sám nikoho neidentifikuje. E-mail o objednávce se na prázdnou adresu neposílá.
  • Admin si účet smazat nemůže (pravidlo „vždy zůstane admin“) – jiný admin ho nejdřív vrátí na usera.
  • Stránka Ochrana osobních údajů (odkaz v patičce a u registrace): co MiniShop ukládá, proč, jak dlouho (zálohy 7 dní), kdo zpracovává (Oracle Cloud, Gmail), jen nezbytné cookies.

Kód: Delete.cshtml.cs, AccountDeletion.cs, Order.Anonymize v Order.cs.

Databáze a migrace

users.db jako první databáze přešla na EF Core migrace (účty krok 6 – jsou v ní účty, nesmí se mazat; ostatní databáze ji následovaly v e-shop kroku 1) (Accounts/Migrations). Při startu Webu (i příkazu create-admin) AccountsDatabaseInitializer spustí migrace přes sdílený DatabaseMigrator – ten doplní jen chybějící migrace a zapamatuje si je v tabulce __EFMigrationsHistory.

  • Databáze z kroků 1–5 (vytvořená přes EnsureCreated) historii nemá – migrátor ji pozná podle tabulky AspNetUsers, zapíše do ní první migraci InitialAccounts jako provedenou (schéma odpovídá) a pak se doplní jen novější.
  • Nová migrace (z kořene repozitáře, nástroj dotnet-ef z dotnet-tools.json – poprvé dotnet tool restore):
    dotnet ef migrations add <Nazev> --project src/MiniShop.Infrastructure --context AccountsDbContext --output-dir Accounts/Migrations
    

Komponentní diagram

flowchart LR
    browser(["Prohlížeč<br/>cookie .MiniShop.Auth"])

    subgraph web["Web"]
        pages["Account/Register, Login, Logout,<br/>ChangePassword, AccessDenied"]
        cookie["Cookie middleware<br/>UseAuthentication"]
        authz["UseAuthorization<br/>policies RequireAdmin, RequireCustomer"]
        signin["SignInManager<br/>vydá a smaže cookie"]
        users["UserManager<br/>pravidla hesla, hash, role"]
        factory["AccountClaimsPrincipalFactory<br/>ID, e-mail, jméno, role do cookie"]
        seeder["AccountsSeeder<br/>role, první admin"]
        adminPages["Admin/Users, Detail"]
        administration["AccountAdministration<br/>role, blokace, audit"]
        stamp["SecurityStampValidator<br/>cookie ↔ databáze každou minutu"]
        db[("users.db<br/>AspNetUsers, AspNetRoles,<br/>AspNetUserRoles, AspNetUserClaims,<br/>AccountAuditEntries")]
    end

    browser -->|"POST formulář"| pages
    pages --> signin
    pages --> users
    signin --> factory
    users --> db
    seeder --> db
    adminPages --> administration
    administration --> users
    administration --> db
    cookie --> stamp
    stamp --> db
    browser -->|"každý požadavek s cookie"| cookie
    cookie --> authz

Registrace a přihlášení

sequenceDiagram
    autonumber
    actor U as Uživatel
    participant P as Account/Register, Login
    participant UM as UserManager
    participant SM as SignInManager
    participant DB as users.db

    U->>P: jméno, e-mail, heslo
    P->>UM: CreateAsync(user, heslo) + role User
    UM->>UM: pravidla hesla (≥ 10 znaků), unikátní e-mail
    UM->>DB: uloží uživatele + hash hesla se solí
    P->>SM: SignInAsync → cookie s identitou a rolí
    Note over U,P: Další požadavky: cookie middleware cookie dešifruje,<br/>do databáze se nedívá (User = přihlášený uživatel)
    U->>P: přihlášení e-mailem a heslem
    P->>SM: PasswordSignInAsync
    SM->>DB: najde uživatele, porovná hash
    SM-->>U: cookie (nebo „Neplatný e-mail nebo heslo.“)

Komponenty

Komponenta K čemu slouží Kód
ApplicationUser Účet: e-mail (= přihlašovací jméno), hash hesla, zobrazované jméno. Business pravidla nemá, proto nežije v doméně. ApplicationUser.cs
AccountRoles Názvy rolí User a Admin. AccountRoles.cs
AccountsDbContext Tabulky Identity v users.db. AccountsDbContext.cs
AccountsInfrastructure Jádro Identity a pravidla: heslo ≥ 10 znaků bez povinných speciálních znaků (NIST), unikátní e-mail. AccountsInfrastructure.cs
AccountsSeeder Při startu: role, role User pro účty bez role, první admin z konfigurace; totéž pro příkaz create-admin. AccountsSeeder.cs
AccountsDatabaseInitializer Migrace users.db (i staré databáze bez historie migrací) a seed. AccountsDatabaseInitializer.cs
AccountAdministration Správa uživatelů adminem: role, blokace, pravidla (poslední admin, sám sebe), security stamp, audit. AccountAdministration.cs
AccountAuditEntry Auditní záznam: kdo, komu, co, kdy (tabulka AccountAuditEntries). AccountAuditEntry.cs
AccountDeletion Smazání vlastního účtu (účty krok 9): admin ne, e-mail z auditu pryč, účet i role a claimy smazané. AccountDeletion.cs
CzechIdentityErrorDescriber České chybové hlášky Identity. CzechIdentityErrorDescriber.cs
AccountClaimsPrincipalFactory Co se uloží do cookie – navíc zobrazované jméno (role a claimy uživatele přidá Identity). AccountClaimsPrincipalFactory.cs
AddAccounts Ve Webu: SignInManager, cookie, policies, fallback policy; /api/* dostane 401/403 místo přesměrování. AccountsServiceCollectionExtensions.cs
Stránky účtu Registrace, přihlášení, odhlášení (jen POST), změna hesla, přístup odepřen. Register.cshtml.cs, Login.cshtml.cs, ChangePassword.cshtml.cs

Zajímavá rozhodnutí

  • Žádná vlastní kryptografie. Hashování hesel (PBKDF2 se solí), security stamp, role i budoucí zámek účtu a přihlášení přes Google dodává ASP.NET Core Identity.
  • Cookie je HttpOnly (JavaScript ji nepřečte), Secure (jen HTTPS) a SameSite=Lax (cizí web s ní nepošle formulář). Šifruje se klíči Data Protection – v cloudu leží na volume, takže přihlášení přežije nasazení.
  • Role jsou v cookie. Proto každá změna role nebo blokace vymění security stamp a Web cookie každou minutu porovná s databází – dotyčný je do minuty odhlášený a novou roli dostane při dalším přihlášení.
  • Příznak „musí změnit heslo“ je claim uživatele (tabulka AspNetUserClaims) – žádný nový sloupec, users.db se nemaže.
  • Stejná hláška pro neznámý e-mail i špatné heslo, návrat po přihlášení jen na adresu v MiniShopu (Url.IsLocalUrl – ochrana proti „open redirect“), do logu jen ID uživatele.
  • Ostatní moduly o Accounts nevědí. Ordering bude znát jen ID uživatele (hlídá test hranic modulů).
  • Accounts zatím neposílá zprávy, proto AccountsDbContext (jako Journeys) nedědí ze společného ModuleDbContext.
  • Migrace jen u users.db. Ostatní databáze jde při změně schématu smazat (data jsou cvičná nebo se obnoví), účty ne.

Dál