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: Migration creates these tables:
- `options` (`key`, `value`) for internal settings, including DB version. - `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`: - `ingredients`:
- `ingredient_id`, `user_id`, `name` - `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` - 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: - Utility:
- `health` - `health`
- Auth / Users:
- `userRegistration(email, password)`
- `userLogin(email, password)`
- `userLogout(token)`
- `userDelete(email, password)`
- Ingredients: - Ingredients:
- `ingredientList(user_id, query = "", include_global = true)` - `ingredientList(token, query = "", include_global = true)`
- `ingredientGet(user_id, ingredient_id)` - `ingredientGet(token, 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)` - `ingredientCreate(token, 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)` - `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(user_id, ingredient_id)` - `ingredientDelete(token, ingredient_id)`
- Meals: - Meals:
- `mealList(user_id, meal_type = "", with_items = false, with_totals = false)` - `mealList(token, meal_type = "", with_items = false, with_totals = false)`
- `mealGet(user_id, meal_id, with_items = true, with_totals = true)` - `mealGet(token, meal_id, with_items = true, with_totals = true)`
- `mealCreate(user_id, name, meal_type)` - `mealCreate(token, name, meal_type)`
- `mealUpdate(user_id, meal_id, name, meal_type)` - `mealUpdate(token, meal_id, name, meal_type)`
- `mealDelete(user_id, meal_id)` - `mealDelete(token, meal_id)`
- Meal items: - Meal items:
- `mealItemList(user_id, meal_id, with_calculated = true)` - `mealItemList(token, meal_id, with_calculated = true)`
- `mealItemAdd(user_id, meal_id, ingredient_id, grams, position = 1)` - `mealItemAdd(token, meal_id, ingredient_id, grams, position = 1)`
- `mealItemUpdate(user_id, meal_item_id, ingredient_id, grams, position)` - `mealItemUpdate(token, meal_item_id, ingredient_id, grams, position)`
- `mealItemDelete(user_id, meal_item_id)` - `mealItemDelete(token, meal_item_id)`
- `mealItemReorder(user_id, meal_id, ordered_item_ids)` - `mealItemReorder(token, meal_id, ordered_item_ids)`
- Calculations: - Calculations:
- `mealTotals(user_id, meal_id)` - `mealTotals(token, meal_id)`
- Diary: - Diary:
- `diaryDayGet(user_id, day_date, with_totals = true)` - `diaryDayGet(token, day_date, with_totals = true)`
- `diaryDaySetMeal(user_id, day_date, meal_type, meal_id)` - `diaryDaySetMeal(token, day_date, meal_type, meal_id)`
- `diaryDayUnsetMeal(user_id, day_date, meal_type)` - `diaryDayUnsetMeal(token, day_date, meal_type)`
- `diaryRange(user_id, date_from, date_to)` - `diaryRange(token, date_from, date_to)`
## Behavior and Validation Rules ## 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. - Nutrition input values are validated as non-negative.
- If `kcal_100` is `0`, API computes kcal by formula: - If `kcal_100` is `0`, API computes kcal by formula:
- `protein*4 + carbs*4 + fat*9` - `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 - meals and meal items must belong to the user
- ingredients can be user-owned or global (`user_id = null`) for read/select - 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 ## 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. - 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. 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. - 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. - 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. - 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) ## 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. - Build frontend screens for ingredients, meals, meal item editor, diary day, diary range.
- Connect frontend to implemented backend actions. - Connect frontend to implemented backend actions.
- Add API tests for validation, ownership checks, and totals calculation consistency. - 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( $search = array(
'user_id' => $include_global ? array($user_id, null) : $user_id 'user_id' => $include_global ? array($user_id, null) : $user_id
); );
@ -57,14 +149,14 @@ class API extends APIlite {
return $result; 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)); return $this->mapIngredient($this->getIngredientAccessible($user_id, $ingredient_id));
} }
public function ingredientCreate( public function ingredientCreate(
int $user_id, string $token,
string $name, string $name,
float $protein_g_100, float $protein_g_100,
float $carbs_g_100, float $carbs_g_100,
@ -73,7 +165,7 @@ class API extends APIlite {
float $fiber_g_100 = 0, float $fiber_g_100 = 0,
float $kcal_100 = 0 float $kcal_100 = 0
): array { ): array {
$this->assertUserExists($user_id); $user_id = $this->requireUserIDbyToken($token);
$name = $this->normalizeName($name); $name = $this->normalizeName($name);
$this->assertNonNegative('protein_g_100', $protein_g_100); $this->assertNonNegative('protein_g_100', $protein_g_100);
$this->assertNonNegative('carbs_g_100', $carbs_g_100); $this->assertNonNegative('carbs_g_100', $carbs_g_100);
@ -108,7 +200,7 @@ class API extends APIlite {
} }
public function ingredientUpdate( public function ingredientUpdate(
int $user_id, string $token,
int $ingredient_id, int $ingredient_id,
string $name, string $name,
float $protein_g_100, float $protein_g_100,
@ -118,7 +210,7 @@ class API extends APIlite {
float $fiber_g_100 = 0, float $fiber_g_100 = 0,
float $kcal_100 = 0 float $kcal_100 = 0
): array { ): array {
$this->assertUserExists($user_id); $user_id = $this->requireUserIDbyToken($token);
$this->getIngredientOwned($user_id, $ingredient_id); $this->getIngredientOwned($user_id, $ingredient_id);
$name = $this->normalizeName($name); $name = $this->normalizeName($name);
$this->assertNonNegative('protein_g_100', $protein_g_100); $this->assertNonNegative('protein_g_100', $protein_g_100);
@ -151,9 +243,9 @@ class API extends APIlite {
return $this->mapIngredient($row); 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); $this->getIngredientOwned($user_id, $ingredient_id);
$deleted = $this->ingredients()->ingredient($ingredient_id, null); $deleted = $this->ingredients()->ingredient($ingredient_id, null);
if ($deleted === false) { 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); $search = array('user_id' => $user_id);
$meal_type = trim($meal_type); $meal_type = trim($meal_type);
if (strlen($meal_type) > 0) { if (strlen($meal_type) > 0) {
@ -196,9 +288,9 @@ class API extends APIlite {
return $result; 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)); $meal = $this->mapMeal($this->getMealOwned($user_id, $meal_id));
$calculatedItems = null; $calculatedItems = null;
if ($with_items) { if ($with_items) {
@ -215,9 +307,9 @@ class API extends APIlite {
return $meal; 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); $name = $this->normalizeName($name);
$this->assertMealType($meal_type); $this->assertMealType($meal_type);
$mealId = $this->meals()->mealSave(array( $mealId = $this->meals()->mealSave(array(
@ -229,12 +321,12 @@ class API extends APIlite {
if ($mealId === false) { if ($mealId === false) {
throw new \Exception('Failed to create meal'); 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); $this->getMealOwned($user_id, $meal_id);
$name = $this->normalizeName($name); $name = $this->normalizeName($name);
$this->assertMealType($meal_type); $this->assertMealType($meal_type);
@ -245,12 +337,12 @@ class API extends APIlite {
if ($updated === false) { if ($updated === false) {
throw new \Exception('Failed to update meal'); 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); $this->getMealOwned($user_id, $meal_id);
$deleted = $this->meals()->meal($meal_id, null); $deleted = $this->meals()->meal($meal_id, null);
if ($deleted === false) { 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); $this->getMealOwned($user_id, $meal_id);
return array( return array(
'meal_id' => $meal_id, '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->getMealOwned($user_id, $meal_id);
$this->assertPositive('grams', $grams); $this->assertPositive('grams', $grams);
if ($position < 1) { if ($position < 1) {
@ -294,9 +386,9 @@ class API extends APIlite {
return $this->enrichMealItem($item); 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); $item = $this->getMealItemOwned($user_id, $meal_item_id);
$this->assertPositive('grams', $grams); $this->assertPositive('grams', $grams);
if ($position < 1) { if ($position < 1) {
@ -315,9 +407,9 @@ class API extends APIlite {
return $this->enrichMealItem($item); 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); $item = $this->getMealItemOwned($user_id, $meal_item_id);
$deleted = $this->mealItems()->mealItem($meal_item_id, null); $deleted = $this->mealItems()->mealItem($meal_item_id, null);
if ($deleted === false) { 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); $this->getMealOwned($user_id, $meal_id);
if (count($ordered_item_ids) <= 0) { if (count($ordered_item_ids) <= 0) {
throw new \Exception('ordered_item_ids cannot be empty'); 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); $this->getMealOwned($user_id, $meal_id);
return array( return array(
'meal_id' => $meal_id, '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); $this->assertDate($day_date);
return $this->buildDiaryDay($user_id, $day_date, $with_totals); 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->assertDate($day_date);
$this->assertMealType($meal_type); $this->assertMealType($meal_type);
$meal = $this->getMealOwned($user_id, $meal_id); $meal = $this->getMealOwned($user_id, $meal_id);
@ -415,9 +507,9 @@ class API extends APIlite {
return $this->buildDiaryDay($user_id, $day_date, true); 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->assertDate($day_date);
$this->assertMealType($meal_type); $this->assertMealType($meal_type);
$day = $this->getDiaryDay($user_id, $day_date); $day = $this->getDiaryDay($user_id, $day_date);
@ -439,9 +531,9 @@ class API extends APIlite {
return $this->buildDiaryDay($user_id, $day_date, true); 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_from);
$this->assertDate($date_to); $this->assertDate($date_to);
if ($date_from > $date_to) { if ($date_from > $date_to) {
@ -528,14 +620,17 @@ class API extends APIlite {
return $this->optionsModel; return $this->optionsModel;
} }
private function assertUserExists(int $user_id): void private function requireUserIDbyToken(string $token): int
{ {
if ($user_id <= 0) { $token = trim($token);
throw new \Exception('user_id must be > 0'); if (strlen($token) <= 0) {
throw new \Exception('token is required');
} }
if (!$this->users()->exist($user_id)) { $user_id = $this->users()->getUserIDbyToken($token);
throw new \Exception('User not found'); 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 private function assertMealType(string $meal_type): void
@ -580,6 +675,49 @@ class API extends APIlite {
return $name; 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 private function round2(float $value): float
{ {
return round($value, 2); return round($value, 2);

View File

@ -107,6 +107,12 @@ class Maintenance extends \TPsoft\DBmodel\Maintenance
$this->dbver(7); $this->dbver(7);
$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 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( 'allow_attributes' => array(
'email' => 'varchar(255)', 'email' => 'varchar(255)',
'password_hash' => 'varchar(255)', 'password_hash' => 'varchar(255)',
'token' => 'varchar(255)',
'token_expires' => 'datetime',
'created_at' => 'datetime' 'created_at' => 'datetime'
) )
), ),
@ -76,6 +78,103 @@ class Users extends \TPsoft\DBmodel\DBmodel {
->toCombo($col_key, $col_value, $add_empty); ->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;
}
} }
?> ?>