MyÚčto MyÚčto.cz Manuál
Stáhnout PDF Zpět na hlavní stránku

74. Bezpečnost (MFA, passkeys, zámek session, IP allowlist, role, activity log)

Bezpečnost MyÚčto stojí na několika navazujících vrstvách:

  1. Autentizace — heslo (bcrypt + pepper) nebo volitelně passkey bez hesla, brute-force ochrana a CAPTCHA
  2. Silné MFA — passkey nebo TOTP
  3. Síťová izolace — IP allowlist (volitelný, doporučeno v produkci)
  4. Autorizace — databázové role s oprávněními neviditelné / čtení / zápis
  5. Audit — activity log všech mutací
  6. Zámek session — serverové uzamčení PWA po nečinnosti

74.1 Hesla

VrstvaDetail
Algoritmusbcrypt cost 12
PepperSůl z cfg.php → app.pepper (32B base64), neukládá se v DB
Min. délka12 znaků
Max. délkaBez limitu — passphrase je doporučená (20+ znaků)
Kontrola sílyIndikátor v UI (slabé / střední / silné)
Reset heslaOdkaz na 1 hodinu, e-mailem

💡 Passphrase je bezpečnější než krátké složité heslo. „korelace medvědí dýně přístav 2026" má 49 znaků a je odolnější vůči brute-force než „Hu1@n!22".

74.2 Vícefaktorové ověření

MyÚčto podporuje dva silné faktory:

E-mailové OTP je kompatibilní druhý krok pro účet bez silného faktoru, ale nesplňuje povinnou silnou MFA politiku. Důvěryhodné zařízení se týká pouze e-mailového OTP.

39.2.1 Passkeys

Passkey zaregistruješ v Profil → Přístupové klíče. Každý klíč má vlastní název, datum vytvoření a posledního použití. Lze jej přejmenovat nebo odvolat. Aplikace podporuje více klíčů; doporučené jsou dvě passkeys nebo jedna passkey spolu s TOTP.

Passkey se používá:

Systémový dialog může podle zařízení použít otisk, obličej, PIN, gesto, heslo zařízení nebo externí bezpečnostní klíč. MyÚčto konkrétní metodu nezjišťuje, biometrická data neopouštějí zařízení a server ukládá pouze veřejný klíč. Poskytovatel platformy nebo password manager může passkey end-to-end šifrovaně synchronizovat mezi zařízeními.

Passkeys vyžadují stabilní veřejnou URL. V produkci musí app.url obsahovat přesný HTTPS origin, například https://faktury.example.cz. Klíč je svázaný s hostname; po změně domény jej na nové doméně nelze použít. Pro lokální vývoj je podporované http://localhost, nikoli běžný HTTP přístup přes LAN IP.

Přidání a odvolání passkey vyžaduje nové ověření passkey nebo TOTP. U účtu bez dosavadního silného faktoru první registrace vyžádá aktuální heslo. Při povinném MFA nelze odvolat poslední povolený silný faktor.

Pokud správce přechází z TOTP na passkeys a vyřadí TOTP ze seznamu povolených metod, uživatel smí existujícím TOTP potvrdit pouze registraci své první passkey. Přechod je dostupný jen tehdy, když jsou passkeys povolené a účet ještě nemá žádnou aktivní passkey. Stejné omezení platí pro registraci heslem u účtu bez dosavadního silného faktoru. Server pod databázovým zámkem znovu ověří, že jde skutečně o první klíč, takže nelze předem otevřít více registrací a dokončit je až po přidání prvního klíče. Další klíče už vyžadují aktuálně povolený faktor.

TOTP = time-based one-time password (RFC 6238).

39.2.2 Aktivace TOTP

Profil → 2FA / TOTP → Aktivovat.

Aktivace 2FA
Aktivace 2FA
  1. Aplikace ukáže QR kód + textový secret key.
  2. V mobilu otevři autentikátor (Google Authenticator, Authy, Microsoft Authenticator, 1Password, Bitwarden) → Přidat účet → Sken QR kódu.
  3. Aplikace začne generovat 6-cifrené kódy každých 30 sekund.
  4. Zadej aktuální kód do MyÚčto → Potvrdit aktivaci.

⚠️ MyÚčto nepoužívá záložní jednorázové kódy (recovery codes). Při ztrátě autentikátoru použij jinou passkey, nebo CLI rescue: php api/bin/reset-mfa.php <email> — viz § 74.2.4.

74.2.3 Přihlášení s passkey a MFA

Po zadání e-mailu a hesla nabídne aplikace passkey, pokud ji účet má. Je-li aktivní také TOTP, lze explicitně přepnout na šestimístný kód z autentikátoru.

2FA výzva
2FA výzva

Správce může navíc explicitně povolit přihlášení pouze pomocí passkey:

'auth' => [
    'passwordless_login' => [
        'enabled' => true,
    ],
],

Totéž lze nastavit přes ENV:

MYINVOICE_AUTH_PASSWORDLESS_LOGIN=true

Výchozí hodnota je false, takže aktualizace nezmění dosavadní přihlašování. Funkce je dostupná jen tehdy, když auth.allowed_mfa_methods obsahuje passkey a WebAuthn konfigurace je platná. Přihlašovací stránka potom nabídne Přihlásit přístupovým klíčem. Browser zobrazí passkeys pro aktuální doménu a vybraný klíč bezpečně předá identitu účtu; e-mail ani heslo se neposílají. Ověření uživatele na zařízení je povinné a úspěšná passkey rovnou vytvoří silně ověřenou session, bez dalšího TOTP.

Passwordless režim neodstraňuje heslo ani standardní formulář. Ten zůstává fallbackem pro jiné zařízení a cestou k TOTP. Pokud passkey není dostupná, zruš systémový dialog a přihlas se e-mailem a heslem.

Účet s passkey nedostane automatický fallback na e-mailový kód. Pokud passkey na aktuálním zařízení není dostupná, použij jinou passkey, TOTP nebo rescue.

39.2.4 Obnova přístupu

Kde passkey fyzicky leží, rozhoduje o tom, co se stane při ztrátě zařízení:

Kam se klíč uloží, vybírá prohlížeč při registraci; aplikace to neřídí a ani to nezjistí zpětně. Máš-li jediný klíč a ten je vázaný na zařízení, drž si jako zálohu buď druhý klíč, nebo aktivní TOTP.

Nejprve použij jinou zaregistrovanou passkey nebo TOTP. Pokud není dostupný žádný silný faktor, správce může na serveru spustit:

php api/bin/reset-mfa.php tvuj@email.cz

Skript vypne TOTP, odvolá všechny passkeys, zruší důvěryhodná zařízení, čekající OTP, WebAuthn flow a step-up proofy a invaliduje všechny session uživatele. Původní název reset-2fa.php zůstává kompatibilním aliasem.

⚠️ Rescue používej jen z důvěryhodného shellu serveru. Přímý SQL zásah není ekvivalentní: snadno ponechá aktivní session nebo rozpracované ověřovací flow.

74.2.5 Vynucení silného MFA

Pokud chceš, aby každý uživatel měl passkey nebo TOTP, nastav v cfg.php (nebo cfg.local.php):

'auth' => [
    'require_mfa' => true,
    'allowed_mfa_methods' => ['passkey', 'totp'],
],

Stejné lze přepnout přes ENV (Docker / PaaS):

MYINVOICE_AUTH_REQUIRE_MFA=true
MYINVOICE_AUTH_MFA_METHODS=passkey,totp

Chování:

Starší auth.require_totp = true a MYINVOICE_AUTH_REQUIRE_TOTP=true zůstávají podporované jako TOTP-only politika. Pro nové instalace používej obecné MFA nastavení.

allowed_mfa_methods rozhoduje co povinné MFA splní, ne na co se přihlášení zeptá. Zúžení seznamu (typicky na ['passkey'] při přechodu na passkey-only) proto nikdy nezruší faktor, který uživatel reálně má:

Neznámá hodnota v seznamu (například email_otp, které sem nepatří) start aplikace neshodí: použije se výchozí ['passkey', 'totp'] a přihlášený správce uvidí na health endpointu warning mfa_methods_configuration.

⚠️ Povolení TOTP vyžaduje validní app.secret_encryption_key (32B base64). Health endpoint na chybnou konfiguraci upozorní; viz § 99 Řešení problémů.

74.2.6 E-mailové ověření pro účet bez silného faktoru

Pro uživatele, kteří nechtějí (nebo neumí) authenticator aplikaci — typicky externí účetní — lze zapnout e-mailové OTP jako druhý faktor. Kdo nemá aktivní passkey ani TOTP, dostane po zadání hesla 6místný kód na e-mail a musí ho opsat.

Zapnutí v cfg.php (výchozí stav je vypnuto — nejde o breaking change):

'auth' => [
    'email_otp' => [
        'enabled'                 => true,  // kód jen pro účet bez passkey i TOTP
        'code_ttl_minutes'        => 10,    // platnost kódu
        'max_attempts'            => 5,     // pokusů na jeden kód, pak je nutný nový
        'resend_cooldown_seconds' => 60,    // min. prodleva mezi odesláním nového kódu
        'trusted_device_days'     => 30,    // „zapamatovat toto zařízení" na kolik dní
        'trusted_cookie_name'     => '__Host-myinvoice_td',
    ],
],

Chování:

⚠️ Vyžaduje funkční SMTP. Když e-maily nechodí, uživatelé bez TOTP se nepřihlásí — buď oprav SMTP, nebo nastav enabled => false. Nouzově lze uživateli zrušit i důvěryhodná zařízení a čekající kódy: php api/bin/reset-mfa.php <email>.

39.2.7 Serverový zámek session

Automatický zámek browserové a PWA session je ve výchozím stavu vypnutý, aby se po aktualizaci nezměnilo chování existujících instalací. Správce nastavuje výchozí timeout pomocí session.lock_after_minutes nebo MYINVOICE_SESSION_LOCK_AFTER_MINUTES. Hodnota 0 znamená, že správce zámek nevynucuje. Uživatel jej přesto může dobrovolně zapnout v profilu na záložce Zámek aplikace.

Hodnota musí být celé číslo od 0 do 1440; podporovaný je i kanonický numerický řetězec, například "15". Neplatná hodnota nesmí zablokovat start aplikace: výchozí automatický zámek se bezpečně vypne a přihlášený uživatel uvidí upozornění session_lock_configuration na health endpointu. Osobní explicitně nastavené intervaly zůstávají účinné.

Osobní nastavení má tyto hranice:

Ruční Zamknout v uživatelském menu je dostupné bez ohledu na timeout, ale jen pokud má účet alespoň jednu aktivní passkey a instalace ji umí použít. Bez dostupné passkey se tlačítko nezobrazuje a server přímý požadavek odmítne, aby nevznikla session, kterou lze ukončit pouze úplným odhlášením.

Stejnou podmínku má i osobní interval: kladnou hodnotu server uloží jen účtu s použitelnou passkey, jinak vrátí 400 validation_failed. Volba *Použít nastavení správce* zůstává dostupná vždy.

⚠️ Správcovská hodnota session.lock_after_minutes > 0 platí pro všechny účty, i pro ty bez passkey — a ty pak zamčenou session jen odhlásí (rozepsaný formulář se ztratí). Typicky se to týká instalací, kde uživatelé jedou na e-mailovém OTP. Aplikace na to upozorní health warningem session_lock_without_unlock_method; buď uživatelům registruj passkey, nebo nech session.lock_after_minutes = 0 a osobní volbu na nich.

Aktivitu posouvají pouze skutečné vstupy do viditelné soukromé stránky, například kliknutí, dotyk nebo klávesa. Polling, běžné API requesty, focus okna ani service worker timeout neposouvají. Po dosažení limitu backend označí session jako zamčenou a odmítne business API i v případě, že někdo odstraní frontendový overlay.

Odemčení vyžaduje passkey a rotuje session ID i CSRF token, přičemž zachová původní absolutní expiraci. TOTP existující zamčenou session přímo neodemkne; volba Přihlásit se znovu provede bezpečný logout a celý login.

Zámek omezuje náhodný přístup k odloženému odemčenému zařízení. Nechrání data, která už přečetl malware nebo XSS během aktivní session. Webová PWA negarantuje zákaz screenshotu ani skrytí Android Recents. Rozpracovaný formulář zůstane zachovaný jen dokud prohlížeč stránku drží v paměti; po ukončení stránky Androidem se neuložená data ztratí. Offline odemčení není možné, protože server musí vydat a ověřit jednorázovou challenge.

39.2.8 Nasazení změny autentizačního modelu

Aktivní session vytvořené před doplněním autentizačního kontextu se po migraci označí jako legacy; migrace z pouhé existence TOTP neodvozuje, že konkrétní session druhý faktor skutečně ověřila. Pokud instalace vyžaduje MFA, uživatelé s takovou session se proto musí jednou znovu přihlásit. Jde o záměrné fail-closed chování, které brání povýšení staré session bez důkazu o MFA. Přihlašovací endpointy přítomnou starou cookie ignorují, takže stačí dokončit standardní login; cookie není nutné ručně mazat v nastavení prohlížeče.

Browser session a její stav zámku jsou autoritativně uložené v MariaDB. Redis slouží pro rate limiting, brute-force ochranu a best-effort cache; jeho výpadek nesmí obnovit odvolanou, nahrazenou nebo zamčenou session.

Z toho plyne jedna změna configu: session.driver už se nepoužívá. Starší cfg.php ho může dál obsahovat ('auto' / 'redis' / 'db'), hodnota se ale ignoruje — session vždy čte a zapisuje MariaDB. Klíč lze bez náhrady smazat.

Migrace 0145 přestavuje tabulku sessions (dvanáct nových sloupců, backfill a tři indexy), takže po dobu jejího běhu je tabulka zamčená a přihlašování nefunguje. Naměřeno na MariaDB 11.8: ~16 s na 300 000 session, u běžných instalací s jednotkami až stovkami řádků je to pod sekundu. Před upgradem se vyplatí spustit php api/bin/cron-cleanup.php, ať se nepřestavují dávno expirované řádky.

74.3 Brute-force ochrana

Pokusy běhemAkce
5 selhání / 5 minutCAPTCHA (Cloudflare Turnstile)
10 selhání / 15 minutLockout 15 minut (per IP)
30 selhání / 1 hodinuLockout 24 hodin + e-mail uživateli o pokusech

Implementace: Redis pokud běží, jinak MariaDB MEMORY engine fallback.

74.4 IP allowlist (volitelné)

V cfg.php → ip_allowlist.allow můžeš omezit přístup jen na vybrané IP / CIDR rozsahy.

'ip_allowlist' => [
    'enabled' => true,
    'mode' => 'block',           // 'block' = ne-allowlisted IP dostane 403
    'allow' => [
        '127.0.0.1',
        '203.0.113.42',          // tvoje kancelářská WAN (IPv4)
        '2001:db8:1234::/48',    // IPv6 prefix
    ],
],

Doporučení v produkci:

🛈 IP allowlist je v cfg.php (file-based config) → změna vyžaduje SSH / deploy. Není v UI schválně — v případě omylu by ses zablokoval a nemohl si ho přes UI sundat.

74.4.1 Za reverse proxy: trusted_proxies (důležité)

Pokud aplikace běží za reverse proxy (doporučené produkční nasazení — viz kap. 2), vidí všechny požadavky přicházet z IP proxy (např. brána Dockeru 172.x.0.1), ne od reálného klienta. Bez konfigurace pak:

Proto za reverse proxy uveď proxy do trusted_proxies — aplikace pak vezme skutečnou klientskou IP z hlavičky X-Forwarded-For:

'ip_allowlist' => [
    'trusted_proxies' => [
        '172.16.0.0/12',         // Docker bridge sítě
        // '10.0.0.0/8',         // nebo konkrétní IP/rozsah tvé proxy
    ],
    'header' => 'X-Forwarded-For', // výchozí; odkud číst reálnou IP (jen za trusted proxy)
],

⚠️ Do trusted_proxies patří jen IP/rozsahy proxy, kterým věříš — klient za nedůvěryhodnou proxy by jinak mohl X-Forwarded-For podvrhnout. Aplikace hlavičku respektuje pouze tehdy, když REMOTE_ADDR odpovídá trusted_proxies.

74.4.2 Edge proxy MUSÍ X-Forwarded-For přepisovat, ne appendovat

Tohle je nejčastější a nejzávažnější chyba v nasazení za proxy. X-Forwarded-For je obyčejná klientská hlavička — kdokoli ji může poslat s libovolným obsahem:

curl -H 'X-Forwarded-For: 203.0.113.42' https://tvuj-server/api/...

Aplikace chain prochází zprava a odloupává známé trusted hopy, takže podvržené položky *nalevo* jsou neškodné. Ale to je bezpečné jen tehdy, když edge proxy klientskou hodnotu zahodí. Když ji jen appenduje (nebo ji nesahá vůbec), zůstane v chainu obsah od útočníka a ten si může zvolit, jakou IP aplikace uvidí → obejití IP allowlistu, obejití brute-force lockoutu a podvržené auditní logy.

Edge proxy = ta, která jako první přijímá provoz z internetu. Musí být nastavená takto:

ProxySprávně (přepisuje)❌ Špatně (appenduje)
nginxproxy_set_header X-Forwarded-For $remote_addr;proxy_add_x_forwarded_for
Apache mod_proxyRequestHeader set X-Forwarded-For "%{REMOTE_ADDR}s" (před ProxyPass)výchozí chování mod_proxy_http
HAProxyoption forwardfor header X-Forwarded-For if-none + http-request del-header X-Forwarded-For před nímsamotné option forwardfor
TraefikforwardedHeaders.trustedIPs (mimo seznam se hlavička zahazuje)forwardedHeaders.insecure = true
Cloudflarepřepisuje automaticky (nebo použij CF-Connecting-IP)

⚠️ Řetězíš-li víc proxy, tohle pravidlo platí jen pro tu nejkrajnější. Vnitřní hopy smí appendovat — musí ale být všechny uvedené v trusted_proxies, aby je aplikace uměla odloupnout.

Ověření (z internetu, ne z LAN):

curl -H 'X-Forwarded-For: 1.2.3.4' https://tvuj-server/api/health

V audit logu (Systém → Audit log) musí být tvoje reálná IP, ne 1.2.3.4. Pokud vidíš 1.2.3.4, edge proxy hlavičku nepřepisuje a máš otevřený bypass.

Dodávaný Docker image

Image tenhle problém řeší i bez trusted_proxies: nginx uvnitř kontejneru předává PHP skutečnou IP TCP peera v parametru MYUCTO_CLIENT_IP (fastcgi_param bez prefixu HTTP_). Klientské hlavičky se do FastCGI vždy mapují jako HTTP_*, takže tenhle parametr nelze zvenčí podvrhnout a aplikace ho preferuje před X-Forwarded-For.

Když je ale před kontejnerem ještě další proxy, je „TCP peer" právě ona. Pak v docker/nginx.conf odkomentuj blok set_real_ip_from a vyjmenuj rozsahy té proxy — teprve tím se MYUCTO_CLIENT_IP přepočítá na reálného klienta:

set_real_ip_from  173.245.48.0/20;   # rozsahy tvé edge proxy
real_ip_header    X-Forwarded-For;
real_ip_recursive on;

74.5 RBAC (role-based access)

Role se spravují v Systém → Role. Každý modul a významná akce mají jednu ze tří úrovní: neviditelné, pouze čtení nebo zápis. Zápis zahrnuje čtení; chybějící nebo neznámé oprávnění znamená zákaz.

Role typu staff jsou pro interní pracovníky. Role typu client mohou dostat jen katalogem povolené funkce klientského portálu. Systémová role Superadmin stojí mimo matici, má plný přístup ke všem firmám a jako jediná spravuje uživatele, role a globální administraci.

Každý non-superadmin potřebuje explicitní membership firmy. U jedné firmy může mít kompatibilní přepis role; role se nesčítají. Neaktivní role, neplatný přepis nebo prázdný membership jsou vždy fail-closed.

Jak je to vynucené

  1. Backend mapuje každou neveřejnou routu na konkrétní permission klíč a minimální úroveň. Nezmapovaná routa je odmítnuta; stavové, tenant a vlastnické guardy se kontrolují navíc.
  2. API token (PAT) má průnik oprávnění vlastníka pro aktuální firmu a scope tokenu. Scope read nikdy nepovolí zápis; odebrání firmy nebo snížení role se projeví existujícímu tokenu okamžitě.
  3. UI používá stejnou efektivní matici pro menu, přímé URL a skrytí akcí. Po přepnutí firmy stará práva zahodí a před vykreslením načte nová.

74.6 CSRF + Origin check

Každý mutating request (POST / PUT / PATCH / DELETE) musí mít:

  1. Origin header se shodující s app.url v cfg.php
  2. X-CSRF-Token header se shodující s tokenem v session

Bez nich → 403 csrf_failed / origin_mismatch. UI to obsluhuje automaticky (token v Pinia store, header v axios interceptoru).

74.7 Activity log

Každá mutace (vytvoření / změna / vystavení / smazání) se loguje. Záznamy obsahují:

Viz 71. Nastavení pro UI.

74.7.1 Co log NEUKLÁDÁ

74.7.2 Jak se do logu zapisuje IP adresa

Aplikace bere IP klienta z IP síťového spojení (REMOTE_ADDR). Když běží za reverse proxy (Docker, nginx, Cloudflare…), je tím spojením proxy — bez konfigurace by se proto do auditu zapisovala IP proxy, ne reálného klienta (typicky uvidíš pořád stejnou IP, např. bránu Dockeru 172.x.0.1).

Reálnou IP přečte aplikace z hlavičky X-Forwarded-For pouze tehdy, když REMOTE_ADDR odpovídá rozsahu v cfg.ip_allowlist.trusted_proxies (viz § 74.4.1). Z hlavičky se bere první adresa (původní klient). Bez nastavené trusted_proxies se X-Forwarded-For ignoruje (ochrana proti podvržení).

🛈 Stejná logika se zjišťování IP používá i pro brute-force lockout (kap. 20.3). Za reverse proxy bez trusted_proxies proto lockout počítá pokusy podle IP proxy = fakticky globálně. Po nastavení trusted_proxies začnou audit log i lockout pracovat s reálnou klientskou IP.

74.8 DKIM podpis e-mailů

Pro deliverabilitu (aby gmail / o365 / seznam tvé maily nepoznačily jako spam) doporučujeme aktivovat DKIM:

  1. Vygeneruj RSA klíč: openssl genrsa -out private/dkim/myucto.pem 2048
  2. Public key → DNS TXT záznam myucto._domainkey.tvoje-domena.cz
  3. V cfg.php → smtp.dkim.enabled => true
  4. Restart služby

Detaily v README.md v rootu repa.

74.9 Klávesové zkratky

Položka Klávesové zkratky je pátým bodem menu pod jménem uživatele a zároveň pátou záložkou obrazovky Profil. Na mobilu je dostupná ve výběru záložek pod nadpisem Profil. Umožňuje změnit nebo vypnout zkratky pro viditelné položky hlavního menu, rychlé vytváření přes + a globální hledání. Preference se ukládá celosystémově k ID přihlášeného uživatele, nikoli k firmě nebo zařízení.

Formulář nedovolí duplicitní kombinace ani klávesy vyhrazené pro prohlížeč a pevné akce aplikace. Zkratky se nespouštějí při psaní do formuláře, během zamčené relace ani v otevřeném modálním dialogu. Obnovit výchozí odstraní uživatelský přepis a vrátí bezpečné kombinace popsané v Přehledu.

74.10 Tipy

🛈 Vypršení licence tvá data neohrozí. Bezplatné funkce původního MyInvoice zůstávají plně funkční včetně zápisu. Komerční moduly se skryjí i pro čtení a API, jejich data ale zůstávají beze změny ve vlastní databázi a po obnovení licence se znovu zpřístupní. Detail v 77. Licence a aktivace.