added TOKEN for users,

added user*() method for API,
added check TOKEN for all methods in API
This commit is contained in:
2026-02-12 02:11:07 +01:00
parent 5b93cdd002
commit 2d96baa389
4 changed files with 326 additions and 72 deletions

View File

@ -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=<method_n
- Utility:
- `health`
- Auth / Users:
- `userRegistration(email, password)`
- `userLogin(email, password)`
- `userLogout(token)`
- `userDelete(email, password)`
- Ingredients:
- `ingredientList(user_id, query = "", include_global = true)`
- `ingredientGet(user_id, ingredient_id)`
- `ingredientCreate(user_id, name, protein_g_100, carbs_g_100, sugar_g_100, fat_g_100, fiber_g_100 = 0, kcal_100 = 0)`
- `ingredientUpdate(user_id, ingredient_id, name, protein_g_100, carbs_g_100, sugar_g_100, fat_g_100, fiber_g_100 = 0, kcal_100 = 0)`
- `ingredientDelete(user_id, ingredient_id)`
- `ingredientList(token, query = "", include_global = true)`
- `ingredientGet(token, ingredient_id)`
- `ingredientCreate(token, name, protein_g_100, carbs_g_100, sugar_g_100, fat_g_100, fiber_g_100 = 0, kcal_100 = 0)`
- `ingredientUpdate(token, ingredient_id, name, protein_g_100, carbs_g_100, sugar_g_100, fat_g_100, fiber_g_100 = 0, kcal_100 = 0)`
- `ingredientDelete(token, ingredient_id)`
- Meals:
- `mealList(user_id, meal_type = "", with_items = false, with_totals = false)`
- `mealGet(user_id, meal_id, with_items = true, with_totals = true)`
- `mealCreate(user_id, name, meal_type)`
- `mealUpdate(user_id, meal_id, name, meal_type)`
- `mealDelete(user_id, meal_id)`
- `mealList(token, meal_type = "", with_items = false, with_totals = false)`
- `mealGet(token, meal_id, with_items = true, with_totals = true)`
- `mealCreate(token, name, meal_type)`
- `mealUpdate(token, meal_id, name, meal_type)`
- `mealDelete(token, meal_id)`
- Meal items:
- `mealItemList(user_id, meal_id, with_calculated = true)`
- `mealItemAdd(user_id, meal_id, ingredient_id, grams, position = 1)`
- `mealItemUpdate(user_id, meal_item_id, ingredient_id, grams, position)`
- `mealItemDelete(user_id, meal_item_id)`
- `mealItemReorder(user_id, meal_id, ordered_item_ids)`
- `mealItemList(token, meal_id, with_calculated = true)`
- `mealItemAdd(token, meal_id, ingredient_id, grams, position = 1)`
- `mealItemUpdate(token, meal_item_id, ingredient_id, grams, position)`
- `mealItemDelete(token, meal_item_id)`
- `mealItemReorder(token, meal_id, ordered_item_ids)`
- Calculations:
- `mealTotals(user_id, meal_id)`
- `mealTotals(token, meal_id)`
- Diary:
- `diaryDayGet(user_id, day_date, with_totals = true)`
- `diaryDaySetMeal(user_id, day_date, meal_type, meal_id)`
- `diaryDayUnsetMeal(user_id, day_date, meal_type)`
- `diaryRange(user_id, date_from, date_to)`
- `diaryDayGet(token, day_date, with_totals = true)`
- `diaryDaySetMeal(token, day_date, meal_type, meal_id)`
- `diaryDayUnsetMeal(token, day_date, meal_type)`
- `diaryRange(token, date_from, date_to)`
## Behavior and Validation Rules
@ -96,10 +101,16 @@ All actions are invoked through `backend/public/API.php` with `?action=<method_n
- Nutrition input values are validated as non-negative.
- If `kcal_100` is `0`, API computes kcal by formula:
- `protein*4 + carbs*4 + fat*9`
- Ownership checks are enforced by `user_id`:
- User-bound actions now require `token` and resolve `user_id` from it at method start.
- Token validation path:
- `Users::getUserIDbyToken(token)` -> `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=<method_n
- If someone ran migrations before FK fix, old MySQL state may still be broken.
In that case reset affected table(s) or rebuild DB from clean state.
- Some comments in `Maintenance.php` show encoding artifacts, but SQL structure is valid.
- Authentication is not implemented yet; `user_id` is passed as an action parameter.
- Basic token auth is implemented, but token is still passed as plain API parameter.
- For `array` parameters (for example `ordered_item_ids`), APIlite expects JSON in request payload.
- APIlite wraps responses with a nested `data` object. Keep this in mind on frontend parsing.
@ -131,7 +142,7 @@ Frontend:
## Product Behavior Target (what to build next)
- Implement auth and session handling (replace plain `user_id` input model).
- Harden auth (token transport/header strategy, token revoke strategy, brute-force/rate-limits).
- Build frontend screens for ingredients, meals, meal item editor, diary day, diary range.
- Connect frontend to implemented backend actions.
- Add API tests for validation, ownership checks, and totals calculation consistency.

View File

@ -38,9 +38,101 @@ class API extends APIlite {
);
}
public function ingredientList(int $user_id, string $query = '', bool $include_global = true): array
public function userRegistration(string $email, string $password): array
{
$this->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);

View File

@ -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

View File

@ -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;
}
}
?>