diff --git a/site/app/Application/Theme.php b/site/app/Application/Theme.php index 89cfa377c..736a2fac2 100644 --- a/site/app/Application/Theme.php +++ b/site/app/Application/Theme.php @@ -3,23 +3,19 @@ namespace MichalSpacekCz\Application; -use MichalSpacekCz\Http\HttpInput; -use Nette\Http\IResponse; -use Nette\Http\Response; +use MichalSpacekCz\Http\Cookies\CookieName; +use MichalSpacekCz\Http\Cookies\Cookies; class Theme { - private const COOKIE = 'future'; - private const DARK = 'dark'; private const LIGHT = 'bright'; public function __construct( - private readonly HttpInput $httpInput, - private readonly IResponse $httpResponse, + private readonly Cookies $cookies, ) { } @@ -38,16 +34,20 @@ public function setLightMode(): void public function isDarkMode(): ?bool { - $cookie = $this->httpInput->getCookieString(self::COOKIE); + $cookie = $this->cookies->getString(CookieName::Theme); return $cookie === self::DARK ? true : ($cookie === self::LIGHT ? false : null); } private function setCookie(string $mode): void { - /** @var Response $response Not IResponse because https://github.com/nette/http/issues/200, can't use instanceof check because it's a different Response in tests */ - $response = $this->httpResponse; - $response->setCookie(self::COOKIE, $mode, '+10 years', null, null, null, null, 'None'); + $this->cookies->set(CookieName::Theme, $mode, $this->getCookieLifetime(), sameSite: 'None'); + } + + + public function getCookieLifetime(): string + { + return '365 days'; } } diff --git a/site/app/DateTime/DateTime.php b/site/app/DateTime/DateTime.php index 7acaa2a9c..6398f20ef 100644 --- a/site/app/DateTime/DateTime.php +++ b/site/app/DateTime/DateTime.php @@ -3,6 +3,10 @@ namespace MichalSpacekCz\DateTime; +use Exception; +use MichalSpacekCz\ShouldNotHappenException; +use Nette\Utils\DateTime as NetteDateTime; + class DateTime { @@ -11,4 +15,20 @@ class DateTime */ public const DATE_RFC3339_MICROSECONDS = 'Y-m-d\TH:i:s.uP'; + + public function getDaysFromString(string $interval): int + { + $now = new NetteDateTime(); + try { + $then = NetteDateTime::from($interval); + } catch (Exception $e) { + throw new ShouldNotHappenException("Cannot create an object from {$interval}", previous: $e); + } + $days = $now->diff($then)->days; + if ($days === false) { + throw new ShouldNotHappenException(sprintf('Cannot diff %s and %s', $now->format(DATE_RFC3339_EXTENDED), $then->format(DATE_RFC3339_EXTENDED))); + } + return $days; + } + } diff --git a/site/app/Http/Cookies/CookieDescription.php b/site/app/Http/Cookies/CookieDescription.php new file mode 100644 index 000000000..0be557340 --- /dev/null +++ b/site/app/Http/Cookies/CookieDescription.php @@ -0,0 +1,43 @@ +name; + } + + + public function isInternal(): bool + { + return $this->internal; + } + + + public function getDescription(): Html + { + return $this->description; + } + + + public function getValidDays(): ?int + { + return $this->validDays; + } + +} diff --git a/site/app/Http/Cookies/CookieDescriptions.php b/site/app/Http/Cookies/CookieDescriptions.php new file mode 100644 index 000000000..a805a9aee --- /dev/null +++ b/site/app/Http/Cookies/CookieDescriptions.php @@ -0,0 +1,72 @@ + + */ + public function get(): array + { + $options = $this->sessionHandler->getOptions(); + $cookieLifetime = $options['cookie_lifetime']; + if (!is_int($cookieLifetime)) { + throw new ShouldNotHappenException("The cookie_lifetime option should be an int, but it's a " . get_debug_type($cookieLifetime)); + } + /** @noinspection PhpInternalEntityUsedInspection */ + return [ + new CookieDescription( + CookieName::PermanentLogin->value, + true, + $this->texyFormatter->translate('messages.cookies.cookie.permanentLogin'), + $this->dateTime->getDaysFromString($this->authenticator->getPermanentLoginCookieLifetime()), + ), + new CookieDescription( + CookieName::ReturningUser->value, + true, + $this->texyFormatter->translate('messages.cookies.cookie.returningUser'), + $this->dateTime->getDaysFromString($this->authenticator->getReturningUserCookieLifetime()), + ), + new CookieDescription( + CookieName::Theme->value, + false, + $this->texyFormatter->translate('messages.cookies.cookie.theme'), + $this->dateTime->getDaysFromString($this->theme->getCookieLifetime()), + ), + new CookieDescription( + $this->sessionHandler->getName(), + false, + $this->texyFormatter->translate('messages.cookies.cookie.netteSession'), + $this->dateTime->getDaysFromString($cookieLifetime . ' seconds'), + ), + new CookieDescription( + Helpers::StrictCookieName, + false, + $this->texyFormatter->translate('messages.cookies.cookie.netteSameSiteCheck'), + null, + ), + ]; + } + +} diff --git a/site/app/Http/Cookies/CookieName.php b/site/app/Http/Cookies/CookieName.php new file mode 100644 index 000000000..23d50b333 --- /dev/null +++ b/site/app/Http/Cookies/CookieName.php @@ -0,0 +1,13 @@ +request->getCookie($name->value); + if (!is_string($cookie)) { + return null; + } + return $cookie; + } + + + public function set( + CookieName $name, + string $value, + DateTimeInterface|int|string $expire, + ?string $path = null, + ?string $domain = null, + ?bool $secure = null, + ?bool $httpOnly = null, + ?string $sameSite = null, + ): void { + /** @var Response $response Not IResponse because https://github.com/nette/http/issues/200, can't use instanceof check because it's a different Response in tests */ + $response = $this->response; + $response->setCookie($name->value, $value, $expire, $path, $domain, $secure, $httpOnly, $sameSite); + } + + + public function delete(CookieName $name, ?string $path = null, ?string $domain = null, ?bool $secure = null): void + { + $this->response->deleteCookie($name->value, $path, $domain, $secure); + } + +} diff --git a/site/app/Http/HttpInput.php b/site/app/Http/HttpInput.php index 2fc62b94b..db8bd13fe 100644 --- a/site/app/Http/HttpInput.php +++ b/site/app/Http/HttpInput.php @@ -14,16 +14,6 @@ public function __construct( } - public function getCookieString(string $key): ?string - { - $cookie = $this->request->getCookie($key); - if (!is_string($cookie)) { - return null; - } - return $cookie; - } - - public function getPostString(string $key): ?string { $data = $this->request->getPost($key); diff --git a/site/app/User/Manager.php b/site/app/User/Manager.php index 0514a4622..f9002f440 100644 --- a/site/app/User/Manager.php +++ b/site/app/User/Manager.php @@ -5,7 +5,8 @@ use DateTimeInterface; use Exception; -use MichalSpacekCz\Http\HttpInput; +use MichalSpacekCz\Http\Cookies\CookieName; +use MichalSpacekCz\Http\Cookies\Cookies; use MichalSpacekCz\User\Exceptions\IdentityException; use MichalSpacekCz\User\Exceptions\IdentityIdNotIntException; use MichalSpacekCz\User\Exceptions\IdentityNotSimpleIdentityException; @@ -16,7 +17,6 @@ use Nette\Database\Row; use Nette\Database\UniqueConstraintViolationException; use Nette\Http\IRequest; -use Nette\Http\Response; use Nette\Http\Url; use Nette\Security\AuthenticationException; use Nette\Security\Authenticator; @@ -45,13 +45,10 @@ class Manager implements Authenticator public function __construct( private readonly Explorer $database, private readonly IRequest $httpRequest, - private readonly Response $httpResponse, // Not IResponse because https://github.com/nette/http/issues/200 - private readonly HttpInput $httpInput, + private readonly Cookies $cookies, private readonly Passwords $passwords, private readonly StaticKey $passwordEncryption, LinkGenerator $linkGenerator, - private readonly string $returningUserCookie, - private readonly string $permanentLoginCookie, private readonly string $permanentLoginInterval, ) { $this->authCookiesPath = (new Url($linkGenerator->link('Admin:Sign:in')))->getPath(); @@ -190,13 +187,13 @@ public function isForbidden(): bool public function setReturningUser(string $value): void { - $this->httpResponse->setCookie($this->returningUserCookie, $value, '+10 years', $this->authCookiesPath, null, null, null, 'Strict'); + $this->cookies->set(CookieName::ReturningUser, $value, $this->getReturningUserCookieLifetime(), $this->authCookiesPath, sameSite: 'Strict'); } public function isReturningUser(): bool { - $cookie = $this->httpInput->getCookieString($this->returningUserCookie); + $cookie = $this->cookies->getString(CookieName::ReturningUser); return ($cookie && $this->verifyReturningUser($cookie)); } @@ -256,7 +253,7 @@ private function insertToken(User $user, int $type): string public function storePermanentLogin(User $user): void { $value = $this->insertToken($user, self::TOKEN_PERMANENT_LOGIN); - $this->httpResponse->setCookie($this->permanentLoginCookie, $value, $this->permanentLoginInterval, $this->authCookiesPath, null, null, null, 'Strict'); + $this->cookies->set(CookieName::PermanentLogin, $value, $this->permanentLoginInterval, $this->authCookiesPath, sameSite: 'Strict'); } @@ -268,7 +265,7 @@ public function storePermanentLogin(User $user): void public function clearPermanentLogin(User $user): void { $this->database->query('DELETE FROM auth_tokens WHERE key_user = ? AND type = ?', $user->getId(), self::TOKEN_PERMANENT_LOGIN); - $this->httpResponse->deleteCookie($this->permanentLoginCookie, $this->authCookiesPath); + $this->cookies->delete(CookieName::PermanentLogin, $this->authCookiesPath); } @@ -292,7 +289,7 @@ public function regeneratePermanentLogin(User $user): void */ public function verifyPermanentLogin(): ?Row { - $cookie = $this->httpInput->getCookieString($this->permanentLoginCookie) ?? ''; + $cookie = $this->cookies->getString(CookieName::PermanentLogin) ?? ''; return $this->verifyToken($cookie, DateTime::from("-{$this->permanentLoginInterval}"), self::TOKEN_PERMANENT_LOGIN); } @@ -354,4 +351,16 @@ private function verifyToken(string $value, DateTimeInterface $validity, int $ty return $result; } + + public function getPermanentLoginCookieLifetime(): string + { + return $this->permanentLoginInterval; + } + + + public function getReturningUserCookieLifetime(): string + { + return '365 days'; + } + } diff --git a/site/app/Www/Presenters/CookiesPresenter.php b/site/app/Www/Presenters/CookiesPresenter.php new file mode 100644 index 000000000..9bf3645cc --- /dev/null +++ b/site/app/Www/Presenters/CookiesPresenter.php @@ -0,0 +1,36 @@ +template->pageTitle = $this->translator->translate('messages.title.cookies'); + $cookies = $this->cookieDescriptions->get(); + $internalCookies = $publicCookies = []; + foreach ($cookies as $cookie) { + if ($cookie->isInternal()) { + $internalCookies[] = $cookie; + } else { + $publicCookies[] = $cookie; + } + } + $this->template->internalCookies = $internalCookies; + $this->template->publicCookies = $publicCookies; + } + +} diff --git a/site/app/Www/Presenters/templates/@layout.latte b/site/app/Www/Presenters/templates/@layout.latte index be97ecb34..087f670d4 100644 --- a/site/app/Www/Presenters/templates/@layout.latte +++ b/site/app/Www/Presenters/templates/@layout.latte @@ -70,15 +70,18 @@
diff --git a/site/app/Www/Presenters/templates/Cookies/default.latte b/site/app/Www/Presenters/templates/Cookies/default.latte new file mode 100644 index 000000000..6793a2ed8 --- /dev/null +++ b/site/app/Www/Presenters/templates/Cookies/default.latte @@ -0,0 +1,27 @@ +{varType MichalSpacekCz\Http\Cookies\CookieDescription[] $internalCookies} +{varType MichalSpacekCz\Http\Cookies\CookieDescription[] $publicCookies} +{define #menu} +» Michal Špaček +{/define} + +{define #cookies MichalSpacekCz\Http\Cookies\CookieDescription $cookie} +{$cookie->getName()}
:
+{$cookie->getDescription()}
+{if $cookie->getValidDays()}
+ {_messages.cookies.expires, $cookie->getValidDays()}
+{else}
+ {_messages.cookies.expiresEndOfSession}
+{/if}
+{/define}
+
+{define #content}
+{_messages.cookies.theseAreUsed|format}
+