diff --git a/src/lib/Data.php b/src/lib/Data.php index 3f380b0..6713767 100644 --- a/src/lib/Data.php +++ b/src/lib/Data.php @@ -9,7 +9,7 @@ class Data { * @return SQLite3 A new connection to the database */ public static function getConnection(): SQLite3 { - $db = new SQLite3('../data/' . DATABASE_NAME); + $db = new SQLite3(implode(DIRECTORY_SEPARATOR, [__DIR__, '..', 'data', DATABASE_NAME])); $db->exec('PRAGMA foreign_keys = ON;'); return $db; } @@ -65,47 +65,6 @@ class Data { $db->close(); } - /** - * Find a user by their ID - * - * @param string $email The e-mail address of the user to retrieve - * @return array|null The user information, or null if the user is not found - */ - public static function findUserByEmail(string $email): ?array { - $db = self::getConnection(); - try { - $query = $db->prepare('SELECT * FROM frc_user WHERE email = :email'); - $query->bindValue(':email', $email); - $result = $query->execute(); - if ($result) { - $user = $result->fetchArray(SQLITE3_ASSOC); - if ($user) return $user; - return null; - } - return null; - } finally { - $db->close(); - } - } - - /** - * Add a user - * - * @param string $email The e-mail address for the user - * @param string $password The user's password - */ - public static function addUser(string $email, string $password): void { - $db = self::getConnection(); - try { - $query = $db->prepare('INSERT INTO frc_user (email, password) VALUES (:email, :password)'); - $query->bindValue(':email', $email); - $query->bindValue(':password', password_hash($password, PASSWORD_DEFAULT)); - $query->execute(); - } finally { - $db->close(); - } - } - /** * Parse/format a date/time from a string * @@ -132,7 +91,7 @@ class Data { try { $query = $db->prepare('SELECT * FROM feed WHERE id = :id AND user_id = :user'); $query->bindValue(':id', $feedId); - $query->bindValue(':user', $_REQUEST[Key::USER_ID]); + $query->bindValue(':user', $_SESSION[Key::USER_ID]); $result = $query->execute(); return $result ? $result->fetchArray(SQLITE3_ASSOC) : false; } finally { diff --git a/src/lib/Feed.php b/src/lib/Feed.php index 3f2cbbc..79a9859 100644 --- a/src/lib/Feed.php +++ b/src/lib/Feed.php @@ -229,7 +229,7 @@ class Feed { private static function refreshFeed(string $url, SQLite3 $db): array { $feedQuery = $db->prepare('SELECT id FROM feed WHERE url = :url AND user_id = :user'); $feedQuery->bindValue(':url', $url); - $feedQuery->bindValue(':user', $_REQUEST[Key::USER_ID]); + $feedQuery->bindValue(':user', $_SESSION[Key::USER_ID]); $feedResult = $feedQuery->execute(); $feedId = $feedResult ? $feedResult->fetchArray(SQLITE3_NUM)[0] : -1; if ($feedId < 0) return ['error' => "No feed for URL $url found"]; @@ -273,7 +273,7 @@ class Feed { INSERT INTO feed (user_id, url, title, updated_on, checked_on) VALUES (:user, :url, :title, :updated, :checked) SQL); - $query->bindValue(':user', $_REQUEST[Key::USER_ID]); + $query->bindValue(':user', $_SESSION[Key::USER_ID]); $query->bindValue(':url', $feed['url']); $query->bindValue(':title', self::eltValue($feed['channel'], 'title')); $query->bindValue(':updated', $feed['updated']); @@ -300,7 +300,7 @@ class Feed { $query = $db->prepare('UPDATE feed SET url = :url WHERE id = :id AND user_id = :user'); $query->bindValue(':url', $url); $query->bindValue(':id', $existing['id']); - $query->bindValue(':user', $_REQUEST[Key::USER_ID]); + $query->bindValue(':user', $_SESSION[Key::USER_ID]); $query->execute(); return self::refreshFeed($url, $db); @@ -312,7 +312,7 @@ class Feed { */ public static function refreshAll(SQLite3 $db): array { $query = $db->prepare('SELECT url FROM feed WHERE user_id = :user'); - $query->bindValue(':user', $_REQUEST[Key::USER_ID]); + $query->bindValue(':user', $_SESSION[Key::USER_ID]); $result = $query->execute(); $url = $result ? $result->fetchArray(SQLITE3_NUM) : false; if ($url) { diff --git a/src/lib/Key.php b/src/lib/Key.php index 39e7bfd..e1bf82a 100644 --- a/src/lib/Key.php +++ b/src/lib/Key.php @@ -2,10 +2,10 @@ class Key { - /** @var string The $_REQUEST key for teh current user's e-mail address */ + /** @var string The $_SESSION key for the current user's e-mail address */ public const string USER_EMAIL = 'FRC_USER_EMAIL'; - /** @var string The $_REQUEST key for the current user's ID */ + /** @var string The $_SESSION key for the current user's ID */ public const string USER_ID = 'FRC_USER_ID'; /** @var string The $_REQUEST key for the array of user messages to display */ diff --git a/src/lib/Security.php b/src/lib/Security.php index 7b1c7cf..c79ebed 100644 --- a/src/lib/Security.php +++ b/src/lib/Security.php @@ -14,38 +14,108 @@ class Security { /** @var int Require users to provide e-mail address and password */ public const int MULTI_USER = 2; + /** @var string The e-mail address for the single user */ + public const string SINGLE_USER_EMAIL = 'solouser@example.com'; + + /** @var string The password for the single user with no password */ + private const string SINGLE_USER_PASSWORD = 'no-password-required'; + /** - * Verify that user is logged on - * @param bool $redirectIfAnonymous Whether to redirect the request if there is no user logged on + * Find a user by their ID + * + * @param string $email The e-mail address of the user to retrieve + * @param SQLite3 $db The data connection to use to retrieve the user + * @return array|false The user information, or null if the user is not found */ - public static function verifyUser(bool $redirectIfAnonymous = true): void { - switch (SECURITY_MODEL) { - case self::SINGLE_USER: - $user = self::retrieveSingleUser(); - break; - case self::SINGLE_USER_WITH_PASSWORD: - die('Single User w/ Password has not yet been implemented'); - case self::MULTI_USER: - die('Multi-User Mode has not yet been implemented'); - default: - die('Unrecognized security model (' . SECURITY_MODEL . ')'); - } - if (!$user && $redirectIfAnonymous) { - header('/logon?returnTo=' . $_SERVER['REQUEST_URI'], true, HTTP_REDIRECT_TEMP); - die(); - } - $_REQUEST[Key::USER_ID] = $user['id']; - $_REQUEST[Key::USER_EMAIL] = $user['email']; + private static function findUserByEmail(string $email, SQLite3 $db): array|false { + $query = $db->prepare('SELECT * FROM frc_user WHERE email = :email'); + $query->bindValue(':email', $email); + $result = $query->execute(); + return $result ? $result->fetchArray(SQLITE3_ASSOC) : false; } /** - * Retrieve the single user - * @return array The user information for the single user + * Add a user + * + * @param string $email The e-mail address for the user + * @param string $password The user's password + * @param SQLite3 $db The data connection to use to add the user */ - private static function retrieveSingleUser(): array { - $user = Data::findUserByEmail('solouser@example.com'); - if ($user) return $user; - Data::addUser('solouser@example.com', 'no-password-required'); - return Data::findUserByEmail('solouser@example.com'); + public static function addUser(string $email, string $password, SQLite3 $db): void { + $query = $db->prepare('INSERT INTO frc_user (email, password) VALUES (:email, :password)'); + $query->bindValue(':email', $email); + $query->bindValue(':password', password_hash($password, PASSWORD_DEFAULT)); + $query->execute(); + } + + /** + * Verify a user's password + * + * @param array $user The user information retrieved from the database + * @param string $password The password provided by the user + * @param string|null $returnTo The URL to which the user should be redirected + * @param SQLite3 $db The database connection to use to verify the user's credentials + */ + private static function verifyPassword(array $user, string $password, ?string $returnTo, SQLite3 $db): void { + if (password_verify($password, $user['password'])) { + if (password_needs_rehash($user['password'], PASSWORD_DEFAULT)) { + $rehash = $db->prepare('UPDATE frc_user SET password = :hash WHERE id = :id'); + $rehash->bindValue(':hash', password_hash($password, PASSWORD_DEFAULT)); + $rehash->bindValue(':id', $user['id']); + $rehash->execute(); + } + $_SESSION[Key::USER_ID] = $user['id']; + $_SESSION[Key::USER_EMAIL] = $user['email']; + frc_redirect($returnTo ?? '/'); + } + } + + /** + * Log on a user with e-mail address and password + * + * @param string $email The e-mail address for the user + * @param string $password The password provided by the user + * @param string|null $returnTo The URL to which the user should be redirected + * @param SQLite3 $db The database connection to use to verify the user's credentials + */ + public static function logOnUser(string $email, string $password, ?string $returnTo, SQLite3 $db): void { + $user = self::findUserByEmail($email, $db); + if ($user) self::verifyPassword($user, $password, $returnTo, $db); + add_error('Invalid credentials; log on unsuccessful'); + } + + /** + * Log on the single user + * + * @param SQLite3 $db The data connection to use to retrieve the user + */ + private static function logOnSingleUser(SQLite3 $db): void { + $user = self::findUserByEmail(self::SINGLE_USER_EMAIL, $db); + if (!$user) { + self::addUser(self::SINGLE_USER_EMAIL, self::SINGLE_USER_PASSWORD, $db); + $user = self::findUserByEmail(self::SINGLE_USER_EMAIL, $db); + } + self::verifyPassword($user, self::SINGLE_USER_PASSWORD, $_GET['returnTo'], $db); + } + + /** + * Verify that user is logged on + * + * @param SQLite3 $db The data connection to use if required + * @param bool $redirectIfAnonymous Whether to redirect the request if there is no user logged on + */ + public static function verifyUser(SQLite3 $db, bool $redirectIfAnonymous = true): void { + if (array_key_exists(Key::USER_ID, $_SESSION)) return; + + if (SECURITY_MODEL == self::SINGLE_USER) self::logOnSingleUser($db); + + if (SECURITY_MODEL != self::SINGLE_USER_WITH_PASSWORD && SECURITY_MODEL != self::MULTI_USER) { + die('Unrecognized security model (' . SECURITY_MODEL . ')'); + } + + if ($redirectIfAnonymous) { + header("Location: /user/log-on?returnTo={$_SERVER['REQUEST_URI']}", true, 307); + die(); + } } } diff --git a/src/public/assets/style.css b/src/public/assets/style.css index 86bd961..9518ea3 100644 --- a/src/public/assets/style.css +++ b/src/public/assets/style.css @@ -57,7 +57,10 @@ article { padding: .5rem; } } -input[type=url], input[type=text] { +input[type=url], +input[type=text], +input[type=email], +input[type=password] { width: 50%; font-size: 1rem; padding: .25rem; diff --git a/src/public/feed.php b/src/public/feed.php index 85446c2..80f985a 100644 --- a/src/public/feed.php +++ b/src/public/feed.php @@ -7,10 +7,10 @@ include '../start.php'; -Security::verifyUser(); +$db = Data::getConnection(); +Security::verifyUser($db); -$feedId = array_key_exists('id', $_GET) ? $_GET['id'] : ''; -$db = Data::getConnection(); +$feedId = $_GET['id'] ?? ''; if ($_SERVER['REQUEST_METHOD'] == 'POST') { $isNew = $_POST['id'] == 'new'; @@ -31,7 +31,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') { if ($feedId == 'new') { $title = 'Add RSS Feed'; - $feed = [ 'id' => $_GET['id'], 'url' => '' ]; + $feed = [ 'id' => $_GET['id'], 'url' => '']; } else { $title = 'Edit RSS Feed'; if ($feedId == 'error') { diff --git a/src/public/index.php b/src/public/index.php index 6bcb14f..9b75399 100644 --- a/src/public/index.php +++ b/src/public/index.php @@ -7,9 +7,8 @@ include '../start.php'; -Security::verifyUser(); - $db = Data::getConnection(); +Security::verifyUser($db); if (array_key_exists('refresh', $_GET)) { $refreshResult = Feed::refreshAll($db); diff --git a/src/public/item.php b/src/public/item.php index 1e98bd5..ee4a714 100644 --- a/src/public/item.php +++ b/src/public/item.php @@ -8,9 +8,8 @@ include '../start.php'; -Security::verifyUser(); - $db = Data::getConnection(); +Security::verifyUser($db); if ($_SERVER['REQUEST_METHOD'] == 'POST') { // "Keep as New" button sends a POST request to reset the is_read flag before going back to the list of unread items @@ -20,7 +19,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') { WHERE item.id = :id AND feed.user_id = :user SQL); $isValidQuery->bindValue(':id', $_POST['id']); - $isValidQuery->bindValue(':user', $_REQUEST[Key::USER_ID]); + $isValidQuery->bindValue(':user', $_SESSION[Key::USER_ID]); $isValidResult = $isValidQuery->execute(); if ($isValidResult && $isValidResult->fetchArray(SQLITE3_NUM)[0] == 1) { $keepUnread = $db->prepare('UPDATE item SET is_read = 0 WHERE id = :id'); @@ -39,7 +38,7 @@ $query = $db->prepare(<<<'SQL' AND feed.user_id = :user SQL); $query->bindValue(':id', $_GET['id']); -$query->bindValue(':user', $_REQUEST[Key::USER_ID]); +$query->bindValue(':user', $_SESSION[Key::USER_ID]); $result = $query->execute(); $item = $result ? $result->fetchArray(SQLITE3_ASSOC) : false; diff --git a/src/public/user/log-off.php b/src/public/user/log-off.php new file mode 100644 index 0000000..5ba1f32 --- /dev/null +++ b/src/public/user/log-off.php @@ -0,0 +1,10 @@ + +

Log On

+
+
+ +
+
+ +
+
close(); diff --git a/src/start.php b/src/start.php index 48701ab..249c21f 100644 --- a/src/start.php +++ b/src/start.php @@ -14,6 +14,12 @@ require 'user-config.php'; Data::ensureDb(); +session_start([ + 'name' => 'FRCSESSION', + 'use_strict_mode' => true, + 'cookie_httponly' => true, + 'cookie_samesite' => 'Strict']); + /** * Add a message to be displayed at the top of the page * @@ -59,20 +65,20 @@ function page_head(string $title): void {
Feed Reader Central
Add Feed'; - if ($_REQUEST[Key::USER_EMAIL] != 'solouser@example.com') echo " | {$_REQUEST[Key::USER_EMAIL]}"; + if (array_key_exists(Key::USER_ID, $_SESSION)) { + echo 'Add Feed | Log Off'; + if ($_SESSION[Key::USER_EMAIL] != Security::SINGLE_USER_EMAIL) echo " | {$_SESSION[Key::USER_EMAIL]}"; + } else { + echo 'Log On'; } ?>
-
- {$msg['level']}
"?> - -
+
+ {$msg['level']}
"?> + +