diff --git a/db/migrations/mysql/20230814190244_add_totp_secret_column.php b/db/migrations/mysql/20230814190244_add_totp_secret_column.php deleted file mode 100644 index ab40bd01..00000000 --- a/db/migrations/mysql/20230814190244_add_totp_secret_column.php +++ /dev/null @@ -1,25 +0,0 @@ -execute( - <<execute( - <<execute( - <<execute( - 'INSERT INTO `tmp_user` ( - `id`, - `email`, - `name`, - `password`, - `is_admin`, - `dashboard_visible_rows`, - `dashboard_extended_rows`, - `dashboard_order_rows`, - `jellyfin_access_token`, - `jellyfin_user_id`, - `jellyfin_server_url`, - `privacy_level`, - `date_format_id`, - `trakt_user_name`, - `plex_webhook_uuid`, - `jellyfin_webhook_uuid`, - `emby_webhook_uuid`, - `trakt_client_id`, - `plex_client_id`, - `plex_client_temporary_code`, - `plex_access_token`, - `plex_account_id`, - `plex_server_url`, - `jellyfin_scrobble_views`, - `emby_scrobble_views`, - `plex_scrobble_views`, - `plex_scrobble_ratings`, - `watchlist_automatic_removal_enabled`, - `country`, - `core_account_changes_disabled`, - `created_at` - ) SELECT - `id`, - `email`, - `name`, - `password`, - `is_admin`, - `dashboard_visible_rows`, - `dashboard_extended_rows`, - `dashboard_order_rows`, - `jellyfin_access_token`, - `jellyfin_user_id`, - `jellyfin_server_url`, - `privacy_level`, - `date_format_id`, - `trakt_user_name`, - `plex_webhook_uuid`, - `jellyfin_webhook_uuid`, - `emby_webhook_uuid`, - `trakt_client_id`, - `plex_client_id`, - `plex_client_temporary_code`, - `plex_access_token`, - `plex_account_id`, - `plex_server_url`, - `jellyfin_scrobble_views`, - `emby_scrobble_views`, - `plex_scrobble_views`, - `plex_scrobble_ratings`, - `watchlist_automatic_removal_enabled`, - `country`, - `core_account_changes_disabled`, - `created_at` FROM user', - ); - $this->execute('DROP TABLE `user`'); - $this->execute('ALTER TABLE `tmp_user` RENAME TO `user`'); - } - - public function up() : void - { - $this->execute( - <<execute( - 'INSERT INTO `tmp_user` ( - `id`, - `email`, - `name`, - `password`, - `is_admin`, - `dashboard_visible_rows`, - `dashboard_extended_rows`, - `dashboard_order_rows`, - `jellyfin_access_token`, - `jellyfin_user_id`, - `jellyfin_server_url`, - `jellyfin_sync_enabled`, - `privacy_level`, - `date_format_id`, - `trakt_user_name`, - `plex_webhook_uuid`, - `jellyfin_webhook_uuid`, - `emby_webhook_uuid`, - `trakt_client_id`, - `plex_client_id`, - `plex_client_temporary_code`, - `plex_access_token`, - `plex_account_id`, - `plex_server_url`, - `jellyfin_scrobble_views`, - `emby_scrobble_views`, - `plex_scrobble_views`, - `plex_scrobble_ratings`, - `watchlist_automatic_removal_enabled`, - `country`, - `core_account_changes_disabled`, - `created_at` - ) SELECT * FROM user', - ); - $this->execute('DROP TABLE `user`'); - $this->execute('ALTER TABLE `tmp_user` RENAME TO `user`'); - } -} diff --git a/public/js/settings-account-general.js b/public/js/settings-account-general.js index 2b7684de..35af5974 100644 --- a/public/js/settings-account-general.js +++ b/public/js/settings-account-general.js @@ -53,3 +53,75 @@ function updateGeneral(dateFormat, username, privacyLevel, enableAutomaticWatchl }) }) } + +function deleteApiToken() { + if (confirm('Do you really want to delete the api token?') === false) { + return + } + + removeAlert('alertApiTokenDiv') + + deleteApiTokenRequest().then(() => { + setApiToken('') + addAlert('alertApiTokenDiv', 'Deleted api token', 'success') + }).catch((error) => { + console.log(error) + addAlert('alertApiTokenDiv', 'Could not delete api token', 'danger') + }) +} + +function regenerateApiToken() { + if (confirm('Do you really want to regenerate the api token?') === false) { + return + } + + removeAlert('alertApiTokenDiv') + + regenerateApiTokenRequest().then(response => { + setApiToken(response.token) + addAlert('alertApiTokenDiv', 'Generated new api token', 'success') + }).catch((error) => { + console.log(error) + addAlert('alertApiTokenDiv', 'Could not generate api token', 'danger') + }) +} + +async function deleteApiTokenRequest() { + const response = await fetch('/settings/account/general/api-token', {'method': 'delete'}) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } +} + +async function regenerateApiTokenRequest() { + const response = await fetch('/settings/account/general/api-token', {'method': 'put'}) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + return await response.json() +} + +function setApiToken(apiToken) { + document.getElementById('apiToken').value = apiToken + + document.getElementById('deleteApiTokenButton').disabled = apiToken === null || apiToken.length === 0 +} + +fetch( + '/settings/account/general/api-token' +).then(async function (response) { + if (response.status === 200) { + const responseData = await response.json(); + + setApiToken(responseData.token) + return + } + + addAlert('alertApiTokenDiv', 'Could not load api token', 'danger') +}).catch(function (error) { + console.log(error) + addAlert('alertApiTokenDiv', 'Could not load api token', 'danger') +}) diff --git a/settings/routes.php b/settings/routes.php index 1c8b2798..9e00bdc8 100644 --- a/settings/routes.php +++ b/settings/routes.php @@ -118,6 +118,21 @@ '/settings/account/general', [\Movary\HttpController\Web\SettingsController::class, 'renderGeneralAccountPage'], ); + $routeCollector->addRoute( + 'GET', + '/settings/account/general/api-token', + [\Movary\HttpController\Web\SettingsController::class, 'getApiToken'], + ); + $routeCollector->addRoute( + 'DELETE', + '/settings/account/general/api-token', + [\Movary\HttpController\Web\SettingsController::class, 'deleteApiToken'], + ); + $routeCollector->addRoute( + 'PUT', + '/settings/account/general/api-token', + [\Movary\HttpController\Web\SettingsController::class, 'regenerateApiToken'], + ); $routeCollector->addRoute( 'GET', '/settings/account/dashboard', diff --git a/src/Domain/User/UserApi.php b/src/Domain/User/UserApi.php index 738b906b..8b27d108 100644 --- a/src/Domain/User/UserApi.php +++ b/src/Domain/User/UserApi.php @@ -29,6 +29,11 @@ public function createUser(string $email, string $password, string $name, bool $ $this->repository->createUser($email, password_hash($password, PASSWORD_DEFAULT), $name, $isAdmin); } + public function deleteApiToken(int $userId) : void + { + $this->repository->deleteApiToken($userId); + } + public function deleteEmbyWebhookId(int $userId) : void { $this->repository->setEmbyWebhookId($userId, null); @@ -94,37 +99,31 @@ public function fetchAllPublicVisibleUsernames() : array return $this->repository->fetchAllPublicVisibleUsernames(); } - public function fetchUser(int $userId) : UserEntity + public function fetchJellyfinUserId(int $userId) : JellyfinUserId { - $user = $this->repository->findUserById($userId); + $jellyfinUserId = $this->repository->findJellyfinUserId($userId); - if ($user === null) { - throw new RuntimeException('User does not exist with id : ' . $userId); + if ($jellyfinUserId === null) { + throw new \RuntimeException('Missing jellyfin user id.'); } - return $user; + return JellyfinUserId::create($jellyfinUserId); } - public function findJellyfinAccessToken(int $userId) : ?JellyfinAccessToken + public function fetchUser(int $userId) : UserEntity { - $jellyfinAccessToken = $this->repository->findJellyfinAccessToken($userId); + $user = $this->repository->findUserById($userId); - if ($jellyfinAccessToken === null) { - return null; + if ($user === null) { + throw new RuntimeException('User does not exist with id : ' . $userId); } - return JellyfinAccessToken::create($jellyfinAccessToken); + return $user; } - public function fetchJellyfinUserId(int $userId) : JellyfinUserId + public function findApiToken(int $userId) : ?string { - $jellyfinUserId = $this->repository->findJellyfinUserId($userId); - - if ($jellyfinUserId === null) { - throw new \RuntimeException('Missing jellyfin user id.'); - } - - return JellyfinUserId::create($jellyfinUserId); + return $this->repository->findApiToken($userId); } public function findJellyfinAuthentication(int $userId) : ?JellyfinAuthenticationData @@ -217,6 +216,13 @@ public function findUserIdByPlexWebhookId(string $webhookId) : ?int return $this->repository->findUserIdByPlexWebhookId($webhookId); } + public function generateApiToken(int $userId) : void + { + $token = bin2hex(random_bytes(16)); + + $this->repository->createApiToken($userId, $token); + } + public function hasUsers() : bool { return $this->repository->getCountOfUsers() > 0; diff --git a/src/Domain/User/UserRepository.php b/src/Domain/User/UserRepository.php index 4227637e..13ff3ae9 100644 --- a/src/Domain/User/UserRepository.php +++ b/src/Domain/User/UserRepository.php @@ -14,6 +14,18 @@ public function __construct(private readonly Connection $dbConnection) { } + public function createApiToken(int $userId, string $token) : void + { + $this->dbConnection->insert( + 'user_api_token', + [ + 'user_id' => $userId, + 'token' => $token, + 'created_at' => (string)DateTime::create(), + ], + ); + } + public function createAuthToken(int $userId, string $token, DateTime $expirationDate) : void { $this->dbConnection->insert( @@ -41,6 +53,16 @@ public function createUser(string $email, string $passwordHash, string $name, bo ); } + public function deleteApiToken(int $userId) : void + { + $this->dbConnection->delete( + 'user_api_token', + [ + 'user_id' => $userId, + ], + ); + } + public function deleteAuthToken(string $token) : void { $this->dbConnection->delete( @@ -158,6 +180,16 @@ public function fetchAllPublicVisibleUsernames() : array ); } + public function findApiToken(int $userId) : ?string + { + return $this->dbConnection->fetchFirstColumn( + 'SELECT token + FROM `user_api_token` + WHERE user_id = ?', + [$userId], + )[0] ?? null; + } + public function findAuthTokenExpirationDate(string $token) : ?DateTime { $expirationDate = $this->dbConnection->fetchOne('SELECT `expiration_date` FROM `user_auth_token` WHERE `token` = ?', [$token]); @@ -195,17 +227,6 @@ public function findJellyfinServerUrl(int $userId) : ?string return $JellyfinServerUrl; } - public function findJellyfinAccessToken(int $userId) : ?string - { - $jellyfinAccessToken = $this->dbConnection->fetchOne('SELECT `jellyfin_access_token` FROM `user` WHERE `id` = ?', [$userId]); - - if ($jellyfinAccessToken === false) { - return null; - } - - return $jellyfinAccessToken; - } - public function findJellyfinUserId(int $userId) : ?string { $jellyfinUserId = $this->dbConnection->fetchOne('SELECT `jellyfin_user_id` FROM `user` WHERE `id` = ?', [$userId]); @@ -250,26 +271,26 @@ public function findPlexServerUrl(int $userId) : ?string return $plexServerUrl; } - public function findTemporaryPlexCode(int $userId) : ?string + public function findTOTPUri(int $userId) : ?string { - $plexCode = $this->dbConnection->fetchOne('SELECT `plex_client_temporary_code` FROM `user` WHERE `id` = ?', [$userId]); + $totpUri = $this->dbConnection->fetchOne('SELECT `totp_uri` FROM `user` WHERE `id` = ?', [$userId]); - if ($plexCode === false) { + if ($totpUri === false) { return null; } - return $plexCode; + return $totpUri; } - public function findTOTPUri(int $userId) : ?string + public function findTemporaryPlexCode(int $userId) : ?string { - $totpUri = $this->dbConnection->fetchOne('SELECT `totp_uri` FROM `user` WHERE `id` = ?', [$userId]); + $plexCode = $this->dbConnection->fetchOne('SELECT `plex_client_temporary_code` FROM `user` WHERE `id` = ?', [$userId]); - if ($totpUri === false) { + if ($plexCode === false) { return null; } - return $totpUri; + return $plexCode; } public function findTraktClientId(int $userId) : ?string diff --git a/src/HttpController/Web/SettingsController.php b/src/HttpController/Web/SettingsController.php index b38ebaa5..8f477f67 100644 --- a/src/HttpController/Web/SettingsController.php +++ b/src/HttpController/Web/SettingsController.php @@ -81,6 +81,17 @@ public function deleteAccount() : Response ); } + public function deleteApiToken() : Response + { + if ($this->authenticationService->isUserAuthenticated() === false) { + return Response::createSeeOther('/'); + } + + $this->userApi->deleteApiToken($this->authenticationService->getCurrentUserId()); + + return Response::createOk(); + } + public function deleteHistory() : Response { if ($this->authenticationService->isUserAuthenticated() === false) { @@ -139,6 +150,31 @@ public function generateLetterboxdExportData() : Response return Response::createOk(); } + public function getApiToken() : Response + { + if ($this->authenticationService->isUserAuthenticated() === false) { + return Response::createSeeOther('/'); + } + + $userId = $this->authenticationService->getCurrentUserId(); + + return Response::createJson(Json::encode(['token' => $this->userApi->findApiToken($userId)])); + } + + public function regenerateApiToken() : Response + { + if ($this->authenticationService->isUserAuthenticated() === false) { + return Response::createSeeOther('/'); + } + + $userId = $this->authenticationService->getCurrentUserId(); + + $this->userApi->deleteApiToken($userId); + $this->userApi->generateApiToken($userId); + + return Response::createJson(Json::encode(['token' => $this->userApi->findApiToken($userId)])); + } + public function renderAppPage() : Response { if ($this->authenticationService->isUserAuthenticated() === false) { @@ -261,6 +297,7 @@ public function renderGeneralAccountPage() : Response 'enableAutomaticWatchlistRemoval' => $user->hasWatchlistAutomaticRemovalEnabled(), 'countries' => $this->countryCache->fetchAll(), 'userCountry' => $user->getCountry(), + 'apiToken' => $this->userApi->findApiToken($user->getId()), ]), ); } @@ -348,35 +385,6 @@ public function renderNetflixPage() : Response ); } - public function renderSecurityAccountPage() : Response - { - if ($this->authenticationService->isUserAuthenticated() === false) { - return Response::createSeeOther('/'); - } - - $user = $this->authenticationService->getCurrentUser(); - - $totpEnabled = $this->twoFactorAuthenticationService->findTotpUri($user->getId()) === null ? false : true; - - $twoFactorAuthenticationEnabled = $this->sessionWrapper->find('twoFactorAuthenticationEnabled'); - $twoFactorAuthenticationDisabled = $this->sessionWrapper->find('twoFactorAuthenticationDisabled'); - - $this->sessionWrapper->unset( - 'twoFactorAuthenticationDisabled', - 'twoFactorAuthenticationEnabled' - ); - - return Response::create( - StatusCode::createOk(), - $this->twig->render('page/settings-account-security.html.twig', [ - 'coreAccountChangesDisabled' => $user->hasCoreAccountChangesDisabled(), - 'totpEnabled' => $totpEnabled, - 'twoFactorAuthenticationEnabled' => $twoFactorAuthenticationEnabled, - 'twoFactorAuthenticationDisabled' => $twoFactorAuthenticationDisabled - ]), - ); - } - public function renderPlexPage() : Response { if ($this->authenticationService->isUserAuthenticated() === false) { @@ -423,6 +431,35 @@ public function renderPlexPage() : Response ); } + public function renderSecurityAccountPage() : Response + { + if ($this->authenticationService->isUserAuthenticated() === false) { + return Response::createSeeOther('/'); + } + + $user = $this->authenticationService->getCurrentUser(); + + $totpEnabled = $this->twoFactorAuthenticationService->findTotpUri($user->getId()) === null ? false : true; + + $twoFactorAuthenticationEnabled = $this->sessionWrapper->find('twoFactorAuthenticationEnabled'); + $twoFactorAuthenticationDisabled = $this->sessionWrapper->find('twoFactorAuthenticationDisabled'); + + $this->sessionWrapper->unset( + 'twoFactorAuthenticationDisabled', + 'twoFactorAuthenticationEnabled', + ); + + return Response::create( + StatusCode::createOk(), + $this->twig->render('page/settings-account-security.html.twig', [ + 'coreAccountChangesDisabled' => $user->hasCoreAccountChangesDisabled(), + 'totpEnabled' => $totpEnabled, + 'twoFactorAuthenticationEnabled' => $twoFactorAuthenticationEnabled, + 'twoFactorAuthenticationDisabled' => $twoFactorAuthenticationDisabled + ]), + ); + } + public function renderServerEmailPage() : Response { if ($this->authenticationService->isUserAuthenticated() === false) { diff --git a/templates/page/settings-account-general.html.twig b/templates/page/settings-account-general.html.twig index 03afaa6a..5def8639 100644 --- a/templates/page/settings-account-general.html.twig +++ b/templates/page/settings-account-general.html.twig @@ -21,15 +21,6 @@
General account settings
-
- - -
Format used when displaying dates
-
@@ -45,6 +36,16 @@
Must consist of only letters and numbers
+
+ + +
Format used when displaying dates
+
+
+
+ +
+ + + +