diff --git a/db/migrations/20220630135944_AddUserAuthTokenTable.php b/db/migrations/20220630135944_AddUserAuthTokenTable.php new file mode 100644 index 00000000..0686781f --- /dev/null +++ b/db/migrations/20220630135944_AddUserAuthTokenTable.php @@ -0,0 +1,31 @@ +execute( + <<execute( + <<dbConnection->insert( + 'user_auth_token', + [ + 'token' => $token, + 'expiration_date' => (string)$expirationDate, + ] + ); + } + + public function deleteAuthToken(string $token) : void + { + $this->dbConnection->delete( + 'user_auth_token', + [ + 'token' => $token, + ] + ); + } + public function fetchAdminUser() : Entity { $data = $this->dbConnection->fetchAssociative('SELECT * FROM `user` WHERE `id` = ?', [self::ADMIN_USER_IO]); @@ -23,6 +45,17 @@ public function fetchAdminUser() : Entity return Entity::createFromArray($data); } + public function findAuthTokenExpirationDate(string $token) : ?DateTime + { + $expirationDate = $this->dbConnection->fetchOne('SELECT `expiration_date` FROM `user_auth_token` WHERE `token` = ?', [$token]); + + if ($expirationDate === false) { + return null; + } + + return DateTime::createFromString($expirationDate); + } + public function setPlexWebhookId(?string $plexWebhookId) : void { $this->dbConnection->update( diff --git a/src/Application/User/Service/Authentication.php b/src/Application/User/Service/Authentication.php new file mode 100644 index 00000000..cc8cef18 --- /dev/null +++ b/src/Application/User/Service/Authentication.php @@ -0,0 +1,116 @@ +repository->deleteAuthToken($token); + } + + public function isUserAuthenticated() : bool + { + $token = filter_input(INPUT_COOKIE, self::AUTHENTICATION_COOKIE_NAME); + + if (empty($token) === false && $this->isValidToken($token) === true) { + return true; + } + + if (empty($token) === false) { + unset($_COOKIE[self::AUTHENTICATION_COOKIE_NAME]); + setcookie(self::AUTHENTICATION_COOKIE_NAME, '', -1); + } + + return false; + } + + public function login(string $password, bool $rememberMe) : void + { + if ($this->isUserAuthenticated() === true) { + return; + } + + $user = $this->repository->fetchAdminUser(); + + if (password_verify($password, $user->getPasswordHash()) === false) { + throw InvalidPassword::create(); + } + + $authTokenExpirationDate = $this->createExpirationDate(); + $cookieExpiration = 0; + + if ($rememberMe === true) { + $authTokenExpirationDate = $this->createExpirationDate(self::MAX_EXPIRATION_AGE_IN_DAYS); + $cookieExpiration = (int)$authTokenExpirationDate->format('U'); + } + + $token = $this->generateToken(DateTime::createFromString((string)$authTokenExpirationDate)); + + setcookie(self::AUTHENTICATION_COOKIE_NAME, $token, $cookieExpiration); + } + + public function logout() : void + { + $token = filter_input(INPUT_COOKIE, 'id'); + + if ($token !== null) { + $this->deleteToken($token); + unset($_COOKIE[self::AUTHENTICATION_COOKIE_NAME]); + setcookie(self::AUTHENTICATION_COOKIE_NAME, '', -1); + } + + session_regenerate_id(); + } + + private function createExpirationDate(int $days = 1) : DateTime + { + $timestamp = strtotime('+' . $days . ' day'); + + if ($timestamp === false) { + throw new \RuntimeException('Could not generate timestamp for auth token expiration date.'); + } + + return DateTime::createFromString(date('Y-m-d H:i:s', $timestamp)); + } + + private function generateToken(?DateTime $expirationDate = null) : string + { + if ($expirationDate === null) { + $expirationDate = $this->createExpirationDate(); + } + + $token = bin2hex(random_bytes(16)); + + $this->repository->createAuthToken($token, $expirationDate); + + return $token; + } + + private function isValidToken(string $token) : bool + { + $tokenExpirationDate = $this->repository->findAuthTokenExpirationDate($token); + + if ($tokenExpirationDate === null || $tokenExpirationDate->isAfter(DateTime::create()) === false) { + if ($tokenExpirationDate !== null) { + $this->repository->deleteAuthToken($token); + } + + return false; + } + + return true; + } +} diff --git a/src/Application/User/Service/Login.php b/src/Application/User/Service/Login.php deleted file mode 100644 index 4642aac7..00000000 --- a/src/Application/User/Service/Login.php +++ /dev/null @@ -1,40 +0,0 @@ -userRepository = $userRepository; - } - - public function authenticate(string $password, bool $rememberMe) : void - { - $user = $this->userRepository->fetchAdminUser(); - - if (password_verify($password, $user->getPasswordHash()) === false) { - throw InvalidPassword::create(); - } - - if ($rememberMe === true) { - session_destroy(); - ini_set('session.cookie_lifetime', '2419200'); - ini_set('session.gc_maxlifetime', '2419200'); - session_start( - [ - 'cookie_lifetime' => 2419200, - ] - ); - } - - session_regenerate_id(); - - $_SESSION['user']['id'] = $user->getId(); - } -} diff --git a/src/Factory.php b/src/Factory.php index def3ff5e..3dc35a53 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -10,12 +10,9 @@ use Movary\Api\Tmdb; use Movary\Api\Trakt; use Movary\Api\Trakt\Cache\User\Movie\Watched; -use Movary\Application\Movie; -use Movary\Application\Service\Tmdb\SyncMovie; -use Movary\Application\SessionService; use Movary\Application\SyncLog; +use Movary\Application\User\Service\Authentication; use Movary\Command; -use Movary\HttpController\PlexController; use Movary\HttpController\SettingsController; use Movary\ValueObject\Config; use Movary\ValueObject\Http\Request; @@ -96,7 +93,7 @@ public static function createSettingsController(ContainerInterface $container, C return new SettingsController( $container->get(Twig\Environment::class), $container->get(SyncLog\Repository::class), - $container->get(SessionService::class), + $container->get(Authentication::class), $applicationVersion ); } @@ -130,7 +127,7 @@ public static function createTwigEnvironment(ContainerInterface $container) : Tw { $twig = new Twig\Environment($container->get(Twig\Loader\LoaderInterface::class)); - $twig->addGlobal('loggedIn', $container->get(SessionService::class)->isCurrentUserLoggedIn()); + $twig->addGlobal('loggedIn', $container->get(Authentication::class)->isUserAuthenticated()); return $twig; } diff --git a/src/HttpController/AuthenticationController.php b/src/HttpController/AuthenticationController.php index 744e348e..7639bd14 100644 --- a/src/HttpController/AuthenticationController.php +++ b/src/HttpController/AuthenticationController.php @@ -2,6 +2,7 @@ namespace Movary\HttpController; +use Movary\Application\SessionService; use Movary\Application\User\Exception\InvalidPassword; use Movary\Application\User\Service; use Movary\ValueObject\Http\Header; @@ -12,28 +13,16 @@ class AuthenticationController { - private Environment $twig; - - private Service\Login $userLoginService; - - public function __construct(Environment $twig, Service\Login $userLoginService) - { - $this->twig = $twig; - $this->userLoginService = $userLoginService; + public function __construct( + private readonly Environment $twig, + private readonly Service\Authentication $authenticationService, + ) { } public function login(Request $request) : Response { - if (isset($_SESSION['user']) === true) { - return Response::create( - StatusCode::createSeeOther(), - null, - [Header::createLocation($_SERVER['HTTP_REFERER'])] - ); - } - try { - $this->userLoginService->authenticate( + $this->authenticationService->login( $request->getPostParameters()['password'], isset($request->getPostParameters()['rememberMe']) === true ); @@ -50,8 +39,7 @@ public function login(Request $request) : Response public function logout() : Response { - unset($_SESSION['user']); - session_regenerate_id(); + $this->authenticationService->logout(); return Response::create( StatusCode::createSeeOther(), @@ -62,7 +50,7 @@ public function logout() : Response public function renderLoginPage() : Response { - if (isset($_SESSION['user']) === true) { + if ($this->authenticationService->isUserAuthenticated() === true) { return Response::create( StatusCode::createSeeOther(), null, diff --git a/src/HttpController/ExportController.php b/src/HttpController/ExportController.php index de8ab187..a175fe4c 100644 --- a/src/HttpController/ExportController.php +++ b/src/HttpController/ExportController.php @@ -3,21 +3,21 @@ namespace Movary\HttpController; use Movary\Application\ExportService; -use Movary\Application\SessionService; +use Movary\Application\User\Service\Authentication; use Movary\ValueObject\Http\Request; use Movary\ValueObject\Http\Response; class ExportController { public function __construct( - private readonly SessionService $sessionService, + private readonly Authentication $authenticationService, private readonly ExportService $exportService ) { } public function getCsvExport(Request $request) : Response { - if ($this->sessionService->isCurrentUserLoggedIn() === false) { + if ($this->authenticationService->isUserAuthenticated() === false) { return Response::createFoundRedirect('/login'); } diff --git a/src/HttpController/HistoryController.php b/src/HttpController/HistoryController.php index ad7c1353..994e62ec 100644 --- a/src/HttpController/HistoryController.php +++ b/src/HttpController/HistoryController.php @@ -6,7 +6,7 @@ use Movary\Application\Movie; use Movary\Application\Movie\History\Service\Select; use Movary\Application\Service\Tmdb\SyncMovie; -use Movary\Application\SessionService; +use Movary\Application\User\Service\Authentication; use Movary\Util\Json; use Movary\ValueObject\Date; use Movary\ValueObject\DateTime; @@ -27,13 +27,13 @@ public function __construct( private readonly Tmdb\Api $tmdbApi, private readonly Movie\Api $movieApi, private readonly SyncMovie $tmdbMovieSyncService, - private readonly SessionService $sessionService + private readonly Authentication $authenticationService ) { } public function deleteHistoryEntry(Request $request) : Response { - if ($this->sessionService->isCurrentUserLoggedIn() === false) { + if ($this->authenticationService->isUserAuthenticated() === false) { return Response::createFoundRedirect('/'); } @@ -50,7 +50,7 @@ public function deleteHistoryEntry(Request $request) : Response public function logMovie(Request $request) : Response { - if ($this->sessionService->isCurrentUserLoggedIn() === false) { + if ($this->authenticationService->isUserAuthenticated() === false) { return Response::createFoundRedirect('/'); } @@ -106,7 +106,7 @@ public function renderHistory(Request $request) : Response public function renderLogMoviePage(Request $request) : Response { - if ($this->sessionService->isCurrentUserLoggedIn() === false) { + if ($this->authenticationService->isUserAuthenticated() === false) { return Response::createFoundRedirect('/'); } diff --git a/src/HttpController/Letterboxd.php b/src/HttpController/Letterboxd.php index 781d3d88..666395e7 100644 --- a/src/HttpController/Letterboxd.php +++ b/src/HttpController/Letterboxd.php @@ -3,7 +3,7 @@ namespace Movary\HttpController; use Movary\Application\Service\Letterboxd\SyncRatings; -use Movary\Application\SessionService; +use Movary\Application\User\Service\Authentication; use Movary\ValueObject\Http\Header; use Movary\ValueObject\Http\Request; use Movary\ValueObject\Http\Response; @@ -15,13 +15,13 @@ class Letterboxd public function __construct( private readonly SyncRatings $syncRatings, private readonly LoggerInterface $logger, - private readonly SessionService $sessionService, + private readonly Authentication $authenticationService, ) { } public function uploadRatingCsv(Request $httpRequest) : Response { - if ($this->sessionService->isCurrentUserLoggedIn() === false) { + if ($this->authenticationService->isUserAuthenticated() === false) { return Response::createFoundRedirect('/'); } diff --git a/src/HttpController/MovieController.php b/src/HttpController/MovieController.php index 05e69e52..58c1c062 100644 --- a/src/HttpController/MovieController.php +++ b/src/HttpController/MovieController.php @@ -3,7 +3,7 @@ namespace Movary\HttpController; use Movary\Application\Movie; -use Movary\Application\SessionService; +use Movary\Application\User\Service\Authentication; use Movary\ValueObject\Http\Request; use Movary\ValueObject\Http\Response; use Movary\ValueObject\Http\StatusCode; @@ -15,7 +15,7 @@ class MovieController public function __construct( private readonly Environment $twig, private readonly Movie\Api $movieApi, - private readonly SessionService $sessionService + private readonly Authentication $authenticationService ) { } @@ -37,7 +37,7 @@ public function renderPage(Request $request) : Response public function updateRating(Request $request) : Response { - if ($this->sessionService->isCurrentUserLoggedIn() === false) { + if ($this->authenticationService->isUserAuthenticated() === false) { return Response::createFoundRedirect('/'); } diff --git a/src/HttpController/PlexController.php b/src/HttpController/PlexController.php index 54a9f4d1..a69a2755 100644 --- a/src/HttpController/PlexController.php +++ b/src/HttpController/PlexController.php @@ -4,8 +4,8 @@ use Movary\Application\Movie; use Movary\Application\Service\Tmdb\SyncMovie; -use Movary\Application\SessionService; use Movary\Application\User\Api; +use Movary\Application\User\Service\Authentication; use Movary\Util\Json; use Movary\ValueObject\Date; use Movary\ValueObject\Http\Request; @@ -20,13 +20,13 @@ public function __construct( private readonly Movie\Api $movieApi, private readonly SyncMovie $tmdbMovieSyncService, private readonly Api $userApi, - private readonly SessionService $sessionService + private readonly Authentication $authenticationService ) { } public function deletePlexWebhookId() : Response { - if ($this->sessionService->isCurrentUserLoggedIn() === false) { + if ($this->authenticationService->isUserAuthenticated() === false) { return Response::createFoundRedirect('/'); } @@ -37,7 +37,7 @@ public function deletePlexWebhookId() : Response public function getPlexWebhookId() : Response { - if ($this->sessionService->isCurrentUserLoggedIn() === false) { + if ($this->authenticationService->isUserAuthenticated() === false) { return Response::createFoundRedirect('/'); } @@ -91,7 +91,7 @@ public function handlePlexWebhook(Request $request) : Response public function regeneratePlexWebhookId() : Response { - if ($this->sessionService->isCurrentUserLoggedIn() === false) { + if ($this->authenticationService->isUserAuthenticated() === false) { return Response::createFoundRedirect('/'); } diff --git a/src/HttpController/SettingsController.php b/src/HttpController/SettingsController.php index 8939f00c..d902e4b2 100644 --- a/src/HttpController/SettingsController.php +++ b/src/HttpController/SettingsController.php @@ -2,8 +2,8 @@ namespace Movary\HttpController; -use Movary\Application\SessionService; use Movary\Application\SyncLog\Repository; +use Movary\Application\User\Service\Authentication; use Movary\ValueObject\Http\Response; use Movary\ValueObject\Http\StatusCode; use Twig\Environment; @@ -13,14 +13,14 @@ class SettingsController public function __construct( private readonly Environment $twig, private readonly Repository $syncLogRepository, - private readonly SessionService $sessionService, + private readonly Authentication $authenticationService, private readonly ?string $applicationVersion = null, ) { } public function render() : Response { - if ($this->sessionService->isCurrentUserLoggedIn() === false) { + if ($this->authenticationService->isUserAuthenticated() === false) { return Response::createFoundRedirect('/'); } diff --git a/src/HttpController/SyncTmdbController.php b/src/HttpController/SyncTmdbController.php index 76deff52..457fd6b0 100644 --- a/src/HttpController/SyncTmdbController.php +++ b/src/HttpController/SyncTmdbController.php @@ -2,21 +2,19 @@ namespace Movary\HttpController; -use Movary\Application\SessionService; -use Movary\ValueObject\Http\Header; +use Movary\Application\User\Service\Authentication; use Movary\ValueObject\Http\Response; -use Movary\ValueObject\Http\StatusCode; class SyncTmdbController { public function __construct( - private readonly SessionService $sessionService + private readonly Authentication $authenticationService ) { } public function execute() : Response { - if ($this->sessionService->isCurrentUserLoggedIn() === false) { + if ($this->authenticationService->isUserAuthenticated() === false) { return Response::createFoundRedirect('/'); } diff --git a/src/HttpController/SyncTraktController.php b/src/HttpController/SyncTraktController.php index 22491ed2..d8430a08 100644 --- a/src/HttpController/SyncTraktController.php +++ b/src/HttpController/SyncTraktController.php @@ -2,19 +2,19 @@ namespace Movary\HttpController; -use Movary\Application\SessionService; +use Movary\Application\User\Service\Authentication; use Movary\ValueObject\Http\Response; class SyncTraktController { public function __construct( - private readonly SessionService $sessionService + private readonly Authentication $authenticationService ) { } public function execute() : Response { - if ($this->sessionService->isCurrentUserLoggedIn() === false) { + if ($this->authenticationService->isUserAuthenticated() === false) { return Response::createFoundRedirect('/'); } diff --git a/src/ValueObject/DateTime.php b/src/ValueObject/DateTime.php index 483e97b1..7d425891 100644 --- a/src/ValueObject/DateTime.php +++ b/src/ValueObject/DateTime.php @@ -47,6 +47,11 @@ public function format(string $format) : string return (new \DateTime($this->dateTime))->format($format); } + public function isAfter(DateTime $dateTimeToCompare) : bool + { + return $this->dateTime > $dateTimeToCompare->dateTime; + } + public function isEqual(DateTime $lastUpdated) : bool { return (string)$this === (string)$lastUpdated; diff --git a/templates/page/login.html.twig b/templates/page/login.html.twig index 9b740f03..55a4eefc 100644 --- a/templates/page/login.html.twig +++ b/templates/page/login.html.twig @@ -14,12 +14,20 @@ Invalid password {% endif %} + + +
- - -
+ + +
+ +
+
- -
+ + {% endblock %}