From 2d96baa3894d809623ad916f06714a6dd7053a98 Mon Sep 17 00:00:00 2001 From: igor Date: Thu, 12 Feb 2026 02:11:07 +0100 Subject: [PATCH] added TOKEN for users, added user*() method for API, added check TOKEN for all methods in API --- AGENTS.md | 61 +++++---- backend/src/API.php | 232 ++++++++++++++++++++++++++++------- backend/src/Maintenance.php | 6 + backend/src/Models/Users.php | 99 +++++++++++++++ 4 files changed, 326 insertions(+), 72 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9e71148..21936d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ It describes what the project is, what is already implemented, and what still ne Migration creates these tables: - `options` (`key`, `value`) for internal settings, including DB version. -- `users` (`user_id`, `email`, `password_hash`, `created_at`). +- `users` (`user_id`, `email`, `password_hash`, `token`, `token_expires`, `created_at`). - `ingredients`: - `ingredient_id`, `user_id`, `name` - per-100g values: `protein_g_100`, `carbs_g_100`, `sugar_g_100`, `fat_g_100`, `fiber_g_100`, `kcal_100` @@ -62,31 +62,36 @@ All actions are invoked through `backend/public/API.php` with `?action= `Users::verifyToken(user_id, token)` + - valid token refreshes `token_expires` + - expired token clears `token` and `token_expires` to `NULL` +- Ownership checks are enforced by resolved `user_id`: - meals and meal items must belong to the user - ingredients can be user-owned or global (`user_id = null`) for read/select -- API currently requires an existing `users` record for almost all actions. +- Registration/login generate and store user token in DB. +- `userLogout(token)` invalidates session by setting `token` and `token_expires` to `NULL`. ## Known Pitfalls and Notes @@ -108,7 +119,7 @@ All actions are invoked through `backend/public/API.php` with `?action=assertUserExists($user_id); + $email = $this->normalizeEmail($email); + $password = $this->normalizePassword($password); + $existing = $this->users()->userBy('email', $email); + if (is_array($existing)) { + throw new \Exception('User with this email already exists'); + } + $userId = $this->users()->userSave(array( + 'email' => $email, + 'password_hash' => $this->users()->hashString($password), + 'token' => null, + 'token_expires' => null, + 'created_at' => '`NOW`' + )); + if ($userId === false) { + throw new \Exception('Failed to register user'); + } + $userId = (int) $userId; + $this->users()->generateToken($userId); + $user = $this->users()->user($userId); + if (!is_array($user)) { + throw new \Exception('Failed to load registered user'); + } + return array( + 'registered' => true, + 'user' => $this->mapAuthUser($user) + ); + } + + public function userLogin(string $email, string $password): array + { + $email = $this->normalizeEmail($email); + $password = $this->normalizePassword($password); + if (!$this->users()->verifyUser($email, $password)) { + throw new \Exception('Invalid email or password'); + } + $user = $this->users()->userBy('email', $email); + if (!is_array($user) || !isset($user['user_id'])) { + throw new \Exception('User not found'); + } + $userId = (int) $user['user_id']; + $this->users()->generateToken($userId); + $user = $this->users()->user($userId); + if (!is_array($user)) { + throw new \Exception('Failed to load logged user'); + } + return array( + 'logged_in' => true, + 'user' => $this->mapAuthUser($user) + ); + } + + public function userDelete(string $email, string $password): array + { + $email = $this->normalizeEmail($email); + $password = $this->normalizePassword($password); + if (!$this->users()->verifyUser($email, $password)) { + throw new \Exception('Invalid email or password'); + } + $user = $this->users()->userBy('email', $email); + if (!is_array($user) || !isset($user['user_id'])) { + throw new \Exception('User not found'); + } + $userId = (int) $user['user_id']; + $deleted = $this->users()->user($userId, null); + if ($deleted === false) { + throw new \Exception('Failed to delete user'); + } + return array( + 'deleted' => true, + 'user_id' => $userId, + 'email' => $email + ); + } + + public function userLogout(string $token): array + { + $user_id = $this->requireUserIDbyToken($token); + $updated = $this->users()->user($user_id, array( + 'token' => null, + 'token_expires' => null + )); + if ($updated === false) { + throw new \Exception('Failed to logout user'); + } + return array( + 'logged_out' => true, + 'user_id' => $user_id + ); + } + + public function ingredientList(string $token, string $query = '', bool $include_global = true): array + { + $user_id = $this->requireUserIDbyToken($token); $search = array( 'user_id' => $include_global ? array($user_id, null) : $user_id ); @@ -57,14 +149,14 @@ class API extends APIlite { return $result; } - public function ingredientGet(int $user_id, int $ingredient_id): array + public function ingredientGet(string $token, int $ingredient_id): array { - $this->assertUserExists($user_id); + $user_id = $this->requireUserIDbyToken($token); return $this->mapIngredient($this->getIngredientAccessible($user_id, $ingredient_id)); } public function ingredientCreate( - int $user_id, + string $token, string $name, float $protein_g_100, float $carbs_g_100, @@ -73,7 +165,7 @@ class API extends APIlite { float $fiber_g_100 = 0, float $kcal_100 = 0 ): array { - $this->assertUserExists($user_id); + $user_id = $this->requireUserIDbyToken($token); $name = $this->normalizeName($name); $this->assertNonNegative('protein_g_100', $protein_g_100); $this->assertNonNegative('carbs_g_100', $carbs_g_100); @@ -108,7 +200,7 @@ class API extends APIlite { } public function ingredientUpdate( - int $user_id, + string $token, int $ingredient_id, string $name, float $protein_g_100, @@ -118,7 +210,7 @@ class API extends APIlite { float $fiber_g_100 = 0, float $kcal_100 = 0 ): array { - $this->assertUserExists($user_id); + $user_id = $this->requireUserIDbyToken($token); $this->getIngredientOwned($user_id, $ingredient_id); $name = $this->normalizeName($name); $this->assertNonNegative('protein_g_100', $protein_g_100); @@ -151,9 +243,9 @@ class API extends APIlite { return $this->mapIngredient($row); } - public function ingredientDelete(int $user_id, int $ingredient_id): array + public function ingredientDelete(string $token, int $ingredient_id): array { - $this->assertUserExists($user_id); + $user_id = $this->requireUserIDbyToken($token); $this->getIngredientOwned($user_id, $ingredient_id); $deleted = $this->ingredients()->ingredient($ingredient_id, null); if ($deleted === false) { @@ -165,9 +257,9 @@ class API extends APIlite { ); } - public function mealList(int $user_id, string $meal_type = '', bool $with_items = false, bool $with_totals = false): array + public function mealList(string $token, string $meal_type = '', bool $with_items = false, bool $with_totals = false): array { - $this->assertUserExists($user_id); + $user_id = $this->requireUserIDbyToken($token); $search = array('user_id' => $user_id); $meal_type = trim($meal_type); if (strlen($meal_type) > 0) { @@ -196,9 +288,9 @@ class API extends APIlite { return $result; } - public function mealGet(int $user_id, int $meal_id, bool $with_items = true, bool $with_totals = true): array + public function mealGet(string $token, int $meal_id, bool $with_items = true, bool $with_totals = true): array { - $this->assertUserExists($user_id); + $user_id = $this->requireUserIDbyToken($token); $meal = $this->mapMeal($this->getMealOwned($user_id, $meal_id)); $calculatedItems = null; if ($with_items) { @@ -215,9 +307,9 @@ class API extends APIlite { return $meal; } - public function mealCreate(int $user_id, string $name, string $meal_type): array + public function mealCreate(string $token, string $name, string $meal_type): array { - $this->assertUserExists($user_id); + $user_id = $this->requireUserIDbyToken($token); $name = $this->normalizeName($name); $this->assertMealType($meal_type); $mealId = $this->meals()->mealSave(array( @@ -229,12 +321,12 @@ class API extends APIlite { if ($mealId === false) { throw new \Exception('Failed to create meal'); } - return $this->mealGet($user_id, (int) $mealId, false, false); + return $this->mealGet($token, (int) $mealId, false, false); } - public function mealUpdate(int $user_id, int $meal_id, string $name, string $meal_type): array + public function mealUpdate(string $token, int $meal_id, string $name, string $meal_type): array { - $this->assertUserExists($user_id); + $user_id = $this->requireUserIDbyToken($token); $this->getMealOwned($user_id, $meal_id); $name = $this->normalizeName($name); $this->assertMealType($meal_type); @@ -245,12 +337,12 @@ class API extends APIlite { if ($updated === false) { throw new \Exception('Failed to update meal'); } - return $this->mealGet($user_id, $meal_id, false, false); + return $this->mealGet($token, $meal_id, false, false); } - public function mealDelete(int $user_id, int $meal_id): array + public function mealDelete(string $token, int $meal_id): array { - $this->assertUserExists($user_id); + $user_id = $this->requireUserIDbyToken($token); $this->getMealOwned($user_id, $meal_id); $deleted = $this->meals()->meal($meal_id, null); if ($deleted === false) { @@ -262,9 +354,9 @@ class API extends APIlite { ); } - public function mealItemList(int $user_id, int $meal_id, bool $with_calculated = true): array + public function mealItemList(string $token, int $meal_id, bool $with_calculated = true): array { - $this->assertUserExists($user_id); + $user_id = $this->requireUserIDbyToken($token); $this->getMealOwned($user_id, $meal_id); return array( 'meal_id' => $meal_id, @@ -272,9 +364,9 @@ class API extends APIlite { ); } - public function mealItemAdd(int $user_id, int $meal_id, int $ingredient_id, float $grams, int $position = 1): array + public function mealItemAdd(string $token, int $meal_id, int $ingredient_id, float $grams, int $position = 1): array { - $this->assertUserExists($user_id); + $user_id = $this->requireUserIDbyToken($token); $this->getMealOwned($user_id, $meal_id); $this->assertPositive('grams', $grams); if ($position < 1) { @@ -294,9 +386,9 @@ class API extends APIlite { return $this->enrichMealItem($item); } - public function mealItemUpdate(int $user_id, int $meal_item_id, int $ingredient_id, float $grams, int $position): array + public function mealItemUpdate(string $token, int $meal_item_id, int $ingredient_id, float $grams, int $position): array { - $this->assertUserExists($user_id); + $user_id = $this->requireUserIDbyToken($token); $item = $this->getMealItemOwned($user_id, $meal_item_id); $this->assertPositive('grams', $grams); if ($position < 1) { @@ -315,9 +407,9 @@ class API extends APIlite { return $this->enrichMealItem($item); } - public function mealItemDelete(int $user_id, int $meal_item_id): array + public function mealItemDelete(string $token, int $meal_item_id): array { - $this->assertUserExists($user_id); + $user_id = $this->requireUserIDbyToken($token); $item = $this->getMealItemOwned($user_id, $meal_item_id); $deleted = $this->mealItems()->mealItem($meal_item_id, null); if ($deleted === false) { @@ -330,9 +422,9 @@ class API extends APIlite { ); } - public function mealItemReorder(int $user_id, int $meal_id, array $ordered_item_ids): array + public function mealItemReorder(string $token, int $meal_id, array $ordered_item_ids): array { - $this->assertUserExists($user_id); + $user_id = $this->requireUserIDbyToken($token); $this->getMealOwned($user_id, $meal_id); if (count($ordered_item_ids) <= 0) { throw new \Exception('ordered_item_ids cannot be empty'); @@ -361,9 +453,9 @@ class API extends APIlite { ); } - public function mealTotals(int $user_id, int $meal_id): array + public function mealTotals(string $token, int $meal_id): array { - $this->assertUserExists($user_id); + $user_id = $this->requireUserIDbyToken($token); $this->getMealOwned($user_id, $meal_id); return array( 'meal_id' => $meal_id, @@ -371,16 +463,16 @@ class API extends APIlite { ); } - public function diaryDayGet(int $user_id, string $day_date, bool $with_totals = true): array + public function diaryDayGet(string $token, string $day_date, bool $with_totals = true): array { - $this->assertUserExists($user_id); + $user_id = $this->requireUserIDbyToken($token); $this->assertDate($day_date); return $this->buildDiaryDay($user_id, $day_date, $with_totals); } - public function diaryDaySetMeal(int $user_id, string $day_date, string $meal_type, int $meal_id): array + public function diaryDaySetMeal(string $token, string $day_date, string $meal_type, int $meal_id): array { - $this->assertUserExists($user_id); + $user_id = $this->requireUserIDbyToken($token); $this->assertDate($day_date); $this->assertMealType($meal_type); $meal = $this->getMealOwned($user_id, $meal_id); @@ -415,9 +507,9 @@ class API extends APIlite { return $this->buildDiaryDay($user_id, $day_date, true); } - public function diaryDayUnsetMeal(int $user_id, string $day_date, string $meal_type): array + public function diaryDayUnsetMeal(string $token, string $day_date, string $meal_type): array { - $this->assertUserExists($user_id); + $user_id = $this->requireUserIDbyToken($token); $this->assertDate($day_date); $this->assertMealType($meal_type); $day = $this->getDiaryDay($user_id, $day_date); @@ -439,9 +531,9 @@ class API extends APIlite { return $this->buildDiaryDay($user_id, $day_date, true); } - public function diaryRange(int $user_id, string $date_from, string $date_to): array + public function diaryRange(string $token, string $date_from, string $date_to): array { - $this->assertUserExists($user_id); + $user_id = $this->requireUserIDbyToken($token); $this->assertDate($date_from); $this->assertDate($date_to); if ($date_from > $date_to) { @@ -528,14 +620,17 @@ class API extends APIlite { return $this->optionsModel; } - private function assertUserExists(int $user_id): void + private function requireUserIDbyToken(string $token): int { - if ($user_id <= 0) { - throw new \Exception('user_id must be > 0'); + $token = trim($token); + if (strlen($token) <= 0) { + throw new \Exception('token is required'); } - if (!$this->users()->exist($user_id)) { - throw new \Exception('User not found'); + $user_id = $this->users()->getUserIDbyToken($token); + if ($user_id === false || (int) $user_id <= 0) { + throw new \Exception('Invalid or expired token'); } + return (int) $user_id; } private function assertMealType(string $meal_type): void @@ -580,6 +675,49 @@ class API extends APIlite { return $name; } + private function normalizeEmail(string $email): string + { + $email = trim(strtolower($email)); + if (strlen($email) <= 0) { + throw new \Exception('email is required'); + } + if (strlen($email) > 255) { + throw new \Exception('email is too long'); + } + if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) { + throw new \Exception('Invalid email format'); + } + return $email; + } + + private function normalizePassword(string $password): string + { + if (strlen($password) <= 0) { + throw new \Exception('password is required'); + } + if (strlen($password) > 1024) { + throw new \Exception('password is too long'); + } + return $password; + } + + private function mapAuthUser(array $user): array + { + $token = isset($user['token']) && strlen((string) $user['token']) > 0 + ? (string) $user['token'] + : null; + $tokenExpires = isset($user['token_expires']) && strlen((string) $user['token_expires']) > 0 + ? (string) $user['token_expires'] + : null; + return array( + 'user_id' => (int) $user['user_id'], + 'email' => (string) $user['email'], + 'token' => $token, + 'token_expires' => $tokenExpires, + 'created_at' => isset($user['created_at']) ? (string) $user['created_at'] : null + ); + } + private function round2(float $value): float { return round($value, 2); diff --git a/backend/src/Maintenance.php b/backend/src/Maintenance.php index 7da6ba9..1107979 100644 --- a/backend/src/Maintenance.php +++ b/backend/src/Maintenance.php @@ -107,6 +107,12 @@ class Maintenance extends \TPsoft\DBmodel\Maintenance $this->dbver(7); $dbver = 7; } + if ($dbver == 7) { + $this->checkDBAdd('users', 'token', 'VARCHAR(255) DEFAULT NULL AFTER `password_hash`'); + $this->checkDBAdd('users', 'token_expires', 'DATETIME DEFAULT NULL AFTER `token`'); + $this->dbver(8); + $dbver = 8; + } } protected function settings(string $key, ?string $value = null): string|false diff --git a/backend/src/Models/Users.php b/backend/src/Models/Users.php index 7b78441..d9c9ea9 100644 --- a/backend/src/Models/Users.php +++ b/backend/src/Models/Users.php @@ -18,6 +18,8 @@ class Users extends \TPsoft\DBmodel\DBmodel { 'allow_attributes' => array( 'email' => 'varchar(255)', 'password_hash' => 'varchar(255)', + 'token' => 'varchar(255)', + 'token_expires' => 'datetime', 'created_at' => 'datetime' ) ), @@ -76,6 +78,103 @@ class Users extends \TPsoft\DBmodel\DBmodel { ->toCombo($col_key, $col_value, $add_empty); } + public function hashString(string $input): string { + if (strlen($input) <= 0) { + throw new \Exception('Input string is required'); + } + $hash = password_hash($input, PASSWORD_DEFAULT); + if ($hash === false) { + throw new \Exception('Failed to create hash'); + } + return $hash; + } + + public function verifyUser(string $email, string $password): bool { + $email = trim($email); + if (strlen($email) <= 0 || strlen($password) <= 0) { + return false; + } + $user = $this->userBy('email', $email); + if (!is_array($user) || !isset($user['password_hash'])) { + return false; + } + $password_hash = (string) $user['password_hash']; + if (strlen($password_hash) <= 0) { + return false; + } + return password_verify($password, $password_hash); + } + + public function generateToken(int $user_id, int $ttl_seconds = 3600): string { + if ($user_id <= 0) { + throw new \Exception('Invalid user_id'); + } + if ($ttl_seconds <= 0) { + throw new \Exception('ttl_seconds must be > 0'); + } + $user = $this->user($user_id); + if (!is_array($user)) { + throw new \Exception('User not found'); + } + $token = bin2hex(random_bytes(32)); + $token_expires = date('Y-m-d H:i:s', time() + $ttl_seconds); + $updated = $this->user($user_id, array( + 'token' => $token, + 'token_expires' => $token_expires + )); + if ($updated === false) { + throw new \Exception('Failed to save token'); + } + return $token; + } + + public function verifyToken(int $user_id, string $token): bool { + if ($user_id <= 0 || strlen($token) <= 0) { + return false; + } + $user = $this->user($user_id); + if (!is_array($user)) { + return false; + } + $stored_token = isset($user['token']) ? (string) $user['token'] : ''; + $stored_expires = isset($user['token_expires']) ? (string) $user['token_expires'] : ''; + if (strlen($stored_token) <= 0 || strlen($stored_expires) <= 0) { + return false; + } + $expires_ts = strtotime($stored_expires); + if ($expires_ts === false || $expires_ts <= time()) { + $this->user($user_id, array( + 'token' => null, + 'token_expires' => null + )); + return false; + } + if (!hash_equals($stored_token, $token)) { + return false; + } + $refresh_expires = date('Y-m-d H:i:s', time() + 3600); + $updated = $this->user($user_id, array( + 'token_expires' => $refresh_expires + )); + return $updated !== false; + } + + public function getUserIDbyToken(string $token): int|false { + $token = trim($token); + if (strlen($token) <= 0) { + return false; + } + $user = $this->userBy('token', $token); + if (!is_array($user) || !isset($user['user_id'])) { + return false; + } + $user_id = (int) $user['user_id']; + if (!$this->verifyToken($user_id, $token)) { + return false; + } + return $user_id; + } + } ?>