Implement remaining security models #19

Merged
danieljsummers merged 8 commits from security-models into main 2024-04-27 23:14:49 +00:00
20 changed files with 717 additions and 190 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
.idea
src/data/*.db
src/user-config.php

View File

@ -17,7 +17,7 @@ _(More environments will be detailed as part of a later release; an nginx revers
## PHP Requirements
This is written to target PHP 8.3, and requires the `curl`, `DOM`, and `SQLite3` modules. _(FrankenPHP contains these modules as part of its build.)_
This is written to target PHP 8.3, and requires the `curl`, `DOM`, and `SQLite3` modules and the `php-cli` feature. _(FrankenPHP contains all these as part of its build.)_
# Setup and Configuration
@ -27,14 +27,14 @@ The default `Caddyfile` will run the site at `http://localhost:8205`. To have th
## Feed Reader Central Behavior
Within the `/src` directory, there is a file named `user-config.php`. This file is the place for customizations and configuration of the instance's behavior.
Within the `/src` directory, there is a file named `user-config.dist.php`. Rename this file to `user-config.php`; this is where customizations and configuration of the instance's behavior are placed.
### Security Model
There ~~are~~ will be three supported security models, designed around different ways the software may be deployed.
There are three supported security models, designed around different ways the software may be deployed. `SECURITY_MODEL` in `user-config.php` **must be set** to one of these values.
- `Securty::SINGLE_USER` assumes that all connections to the instance are the same person. There is no password required, and no username or e-mail address will be displayed for that user. This is a good setup for a single user on a home intranet. **DO NOT PUT AN INSTANCE WITH THIS CONFIGURATION ON THE PUBLIC INTERNET!** If you do, you deserve what you get.
- `Security::SINGLE_USER_WITH_PASSWORD` _(not yet implemented)_ will be the same as the above, but will require a password. This setup is ideal for intranets where the user does not want any other users ending up marking their feeds as read just by browsing them.
- `Security::MULTI_USER` _(not yet implemented)_ will require a known e-mail address and password be provided to establish the identity of each user. This will be the most appropriate setup for an Internet-facing instance, even if there is only one user.
- `Security::SINGLE_USER_WITH_PASSWORD` is the same as the above but requires a password. This setup is ideal for intranets where the user does not want any other users ending up marking their feeds as read just by browsing them.
- `Security::MULTI_USER` requires a known e-mail address and password be provided to establish the identity of each user. This is the most appropriate setup for an Internet-facing instance, even if there is only one user.
### Database Name

17
src/app-config.php Normal file
View File

@ -0,0 +1,17 @@
<?php
/** The current Feed Reader Central version */
const FRC_VERSION = '1.0.0-alpha4';
spl_autoload_register(function ($class) {
$file = implode(DIRECTORY_SEPARATOR, [__DIR__, 'lib', "$class.php"]);
if (file_exists($file)) {
require $file;
return true;
}
return false;
});
require 'user-config.php';
Data::ensureDb();

44
src/cli-start.php Normal file
View File

@ -0,0 +1,44 @@
<?php
require 'app-config.php';
if (php_sapi_name() != 'cli') {
http_response_code(404);
die('Not Found');
}
/**
* Print a line with a newline character at the end (printf + newline = printfn; surprised this isn't built in!)
*
* @param string $format The format string
* @param mixed ...$values The values for the placeholders in the string
*/
function printfn(string $format, mixed ...$values): void {
printf($format . PHP_EOL, ...$values);
}
/**
* Display a title for a CLI run
*
* @param string $title The title to display on the command line
*/
function cli_title(string $title): void {
$appTitle = 'Feed Reader Central ~ v' . FRC_VERSION;
$dashes = ' +' . str_repeat('-', strlen($title) + 2) . '+' . str_repeat('-', strlen($appTitle) + 2) . '+';
printfn($dashes);
printfn(' | %s | %s |', $title, $appTitle);
printfn($dashes . PHP_EOL);
}
/**
* Capitalize the first letter of the given string
*
* @param string $value The string to be capitalized
* @return string The given string with the first letter capitalized
*/
function init_cap(string $value): string {
return match (strlen($value)) {
0 => "",
1 => strtoupper($value),
default => strtoupper(substr($value, 0, 1)) . substr($value, 1),
};
}

View File

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

View File

@ -22,6 +22,61 @@ class FeedItem {
/** @var string The content for the item */
public string $content = '';
/**
* Construct a feed item from an Atom feed's `<entry>` tag
*
* @param DOMNode $node The XML node from which a feed item should be constructed
* @return FeedItem A feed item constructed from the given node
*/
public static function fromAtom(DOMNode $node): FeedItem {
$guid = Feed::atomValue($node, 'id');
$link = '';
foreach ($node->getElementsByTagName('link') as $linkElt) {
if ($linkElt->hasAttributes()) {
$relAttr = $linkElt->attributes->getNamedItem('rel');
if ($relAttr && $relAttr->value == 'alternate') {
$link = $linkElt->attributes->getNamedItem('href')->value;
break;
}
}
}
if ($link == '' && str_starts_with($guid, 'http')) $link = $guid;
$item = new FeedItem();
$item->guid = $guid;
$item->title = Feed::atomValue($node, 'title');
$item->link = $link;
$item->publishedOn = Data::formatDate(Feed::atomValue($node, 'published'));
$item->updatedOn = Data::formatDate(Feed::atomValue($node, 'updated'));
$item->content = Feed::atomValue($node, 'content');
return $item;
}
/**
* Construct a feed item from an RSS feed's `<item>` tag
*
* @param DOMNode $node The XML node from which a feed item should be constructed
* @return FeedItem A feed item constructed from the given node
*/
public static function fromRSS(DOMNode $node): FeedItem {
$itemGuid = Feed::rssValue($node, 'guid');
$updNodes = $node->getElementsByTagNameNS(Feed::ATOM_NS, 'updated');
$encNodes = $node->getElementsByTagNameNS(Feed::CONTENT_NS, 'encoded');
$item = new FeedItem();
$item->guid = $itemGuid == 'guid not found' ? Feed::rssValue($node, 'link') : $itemGuid;
$item->title = Feed::rssValue($node, 'title');
$item->link = Feed::rssValue($node, 'link');
$item->publishedOn = Data::formatDate(Feed::rssValue($node, 'pubDate'));
$item->updatedOn = Data::formatDate($updNodes->length > 0 ? $updNodes->item(0)->textContent : null);
$item->content = $encNodes->length > 0
? $encNodes->item(0)->textContent
: Feed::rssValue($node, 'description');
return $item;
}
}
/**
@ -87,11 +142,11 @@ class Feed {
/**
* Get the value of a child element by its tag name for an RSS feed
*
* @param DOMElement $element The parent element
* @param DOMNode $element The parent element
* @param string $tagName The name of the tag whose value should be obtained
* @return string The value of the element (or "[element] not found" if that element does not exist)
*/
private static function rssValue(DOMElement $element, string $tagName): string {
public static function rssValue(DOMNode $element, string $tagName): string {
$tags = $element->getElementsByTagName($tagName);
return $tags->length == 0 ? "$tagName not found" : $tags->item(0)->textContent;
}
@ -109,34 +164,19 @@ class Feed {
return ['error' => "Channel element not found ($channel->nodeType)"];
}
$feed = new Feed();
$feed->title = self::rssValue($channel, 'title');
$feed->url = $url;
// The Atom namespace provides a lastBuildDate, which contains the last time an item in the feed was updated; if
// that is not present, use the pubDate element instead
$feed->updatedOn = self::rssValue($channel, 'lastBuildDate');
if ($feed->updatedOn == 'lastBuildDate not found') {
$feed->updatedOn = self::rssValue($channel, 'pubDate');
if ($feed->updatedOn == 'pubDate not found') $feed->updatedOn = null;
$updatedOn = self::rssValue($channel, 'lastBuildDate');
if ($updatedOn == 'lastBuildDate not found') {
$updatedOn = self::rssValue($channel, 'pubDate');
if ($updatedOn == 'pubDate not found') $updatedOn = null;
}
$feed->updatedOn = Data::formatDate($feed->updatedOn);
foreach ($channel->getElementsByTagName('item') as $xmlItem) {
$itemGuid = self::rssValue($xmlItem, 'guid');
$updNodes = $xmlItem->getElementsByTagNameNS(Feed::ATOM_NS, 'updated');
$encNodes = $xmlItem->getElementsByTagNameNS(Feed::CONTENT_NS, 'encoded');
$item = new FeedItem();
$item->guid = $itemGuid == 'guid not found' ? self::rssValue($xmlItem, 'link') : $itemGuid;
$item->title = self::rssValue($xmlItem, 'title');
$item->link = self::rssValue($xmlItem, 'link');
$item->publishedOn = Data::formatDate(self::rssValue($xmlItem, 'pubDate'));
$item->updatedOn = Data::formatDate($updNodes->length > 0 ? $updNodes->item(0)->textContent : null);
$item->content = $encNodes->length > 0
? $encNodes->item(0)->textContent
: self::rssValue($xmlItem, 'description');
$feed->items[] = $item;
}
$feed = new Feed();
$feed->title = self::rssValue($channel, 'title');
$feed->url = $url;
$feed->updatedOn = Data::formatDate($updatedOn);
foreach ($channel->getElementsByTagName('item') as $item) $feed->items[] = FeedItem::fromRSS($item);
return ['ok' => $feed];
}
@ -147,11 +187,11 @@ class Feed {
* (Atom feeds can have type attributes on nearly any value. For our purposes, types "text" and "html" will work as
* regular string values; for "xhtml", though, we will need to get the `<div>` and extract its contents instead.)
*
* @param DOMElement $element The parent element
* @param DOMNode $element The parent element
* @param string $tagName The name of the tag whose value should be obtained
* @return string The value of the element (or "[element] not found" if that element does not exist)
*/
private static function atomValue(DOMElement $element, string $tagName): string {
public static function atomValue(DOMNode $element, string $tagName): string {
$tags = $element->getElementsByTagName($tagName);
if ($tags->length == 0) return "$tagName not found";
$tag = $tags->item(0);
@ -172,39 +212,15 @@ class Feed {
* @return array|Feed[] ['ok' => feed]
*/
private static function fromAtom(DOMDocument $xml, string $url): array {
/** @var DOMElement $root */
$root = $xml->getElementsByTagNameNS(self::ATOM_NS, 'feed')->item(0);
$feed = new Feed();
$feed->title = self::atomValue($root, 'title');
$feed->url = $url;
$root = $xml->getElementsByTagNameNS(self::ATOM_NS, 'feed')->item(0);
$updatedOn = self::atomValue($root, 'updated');
if ($updatedOn == 'pubDate not found') $updatedOn = null;
$feed->updatedOn = self::atomValue($root, 'updated');
if ($feed->updatedOn == 'pubDate not found') $feed->updatedOn = null;
$feed->updatedOn = Data::formatDate($feed->updatedOn);
foreach ($root->getElementsByTagName('entry') as $xmlItem) {
$guid = self::atomValue($xmlItem, 'id');
$link = '';
foreach ($xmlItem->getElementsByTagName('link') as $linkElt) {
if ($linkElt->hasAttributes()) {
$relAttr = $linkElt->attributes->getNamedItem('rel');
if ($relAttr && $relAttr->value == 'alternate') {
$link = $linkElt->attributes->getNamedItem('href')->value;
break;
}
}
}
if ($link == '' && str_starts_with($guid, 'http')) $link = $guid;
$item = new FeedItem();
$item->guid = $guid;
$item->title = self::atomValue($xmlItem, 'title');
$item->link = $link;
$item->publishedOn = Data::formatDate(self::atomValue($xmlItem, 'published'));
$item->updatedOn = Data::formatDate(self::atomValue($xmlItem, 'updated'));
$item->content = self::atomValue($xmlItem, 'content');
$feed->items[] = $item;
}
$feed = new Feed();
$feed->title = self::atomValue($root, 'title');
$feed->url = $url;
$feed->updatedOn = Data::formatDate($updatedOn);
foreach ($root->getElementsByTagName('entry') as $entry) $feed->items[] = FeedItem::fromAtom($entry);
return ['ok' => $feed];
}
@ -229,7 +245,7 @@ class Feed {
$code = curl_getinfo($feedReq, CURLINFO_RESPONSE_CODE);
if ($error) {
$result['error'] = $error;
} else if ($code == 200) {
} elseif ($code == 200) {
$parsed = self::parseFeed($feedContent);
if (array_key_exists('error', $parsed)) {
$result['error'] = $parsed['error'];
@ -341,7 +357,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"];
@ -382,12 +398,12 @@ class Feed {
$feedExtract = self::retrieveFeed($url);
if (array_key_exists('error', $feedExtract)) return $feedExtract;
$feed = $feedExtract['ok'];
$feed = $feedExtract['ok'];
$query = $db->prepare(<<<'SQL'
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', $feed->title);
$query->bindValue(':updated', $feed->updatedOn);
@ -415,7 +431,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);
@ -429,7 +445,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) {

View File

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

View File

@ -14,38 +14,134 @@ 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 */
public const string SINGLE_USER_PASSWORD = 'no-password-required';
/** @var string The password algorithm to use for our passwords */
public const string PW_ALGORITHM = PASSWORD_DEFAULT;
/**
* 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'];
public 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, self::PW_ALGORITHM));
$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'], self::PW_ALGORITHM)) {
$rehash = $db->prepare('UPDATE frc_user SET password = :hash WHERE id = :id');
$rehash->bindValue(':hash', password_hash($password, self::PW_ALGORITHM));
$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 (cannot be the single-user mode 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 {
if (SECURITY_MODEL == self::SINGLE_USER_WITH_PASSWORD) {
$dbEmail = self::SINGLE_USER_EMAIL;
} else {
if ($email == self::SINGLE_USER_EMAIL) {
add_error('Invalid credentials; log on unsuccessful');
return;
}
$dbEmail = $email;
}
$user = self::findUserByEmail($dbEmail, $db);
if ($user) self::verifyPassword($user, $password, $returnTo, $db);
add_error('Invalid credentials; log on unsuccessful');
}
/**
* Update the password for the given user
*
* @param string $email The e-mail address of the user whose password should be updated
* @param string $password The new password for this user
* @param SQLite3 $db The database connection to use in updating the password
*/
public static function updatePassword(string $email, string $password, SQLite3 $db): void {
$query = $db->prepare('UPDATE frc_user SET password = :password WHERE email = :email');
$query->bindValue(':password', password_hash($password, self::PW_ALGORITHM));
$query->bindValue(':email', $email);
$query->execute();
}
/**
* 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();
}
}
}

View File

@ -30,6 +30,12 @@ header {
font-size: 1.5rem;
}
.version {
font-size: .85rem;
padding-left: .5rem;
color: rgba(255, 255, 255, .75);
}
a:link, a:visited {
color: white;
}
@ -55,9 +61,23 @@ article {
border-radius: .5rem;
background-color: white;
padding: .5rem;
img {
max-width: 100%;
object-fit: contain;
height: unset;
width: unset;
}
}
}
input[type=url], input[type=text] {
article.docs {
line-height: 1.4rem;
}
input[type=url],
input[type=text],
input[type=email],
input[type=password] {
width: 50%;
font-size: 1rem;
padding: .25rem;
@ -87,3 +107,9 @@ button:hover,
flex-flow: row nowrap;
justify-content: space-evenly;
}
code {
font-size: .9rem;
}
p.back-link {
margin-top: -1rem;
}

15
src/public/docs/index.php Normal file
View File

@ -0,0 +1,15 @@
<?php
include '../../start.php';
$db = Data::getConnection();
Security::verifyUser($db, redirectIfAnonymous: false);
page_head('Documentation'); ?>
<h1>Documentation Home</h1>
<article>
<p><a href=./the-cli>About the CLI</a> provides orientation on Feed Reader Central&rsquo;s command line interface
<p><a href=./security-modes>Configuring Security Modes</a> describes the three security modes and how to manage each
of them
</article><?php
page_foot();
$db->close();

View File

@ -0,0 +1,62 @@
<?php
include '../../start.php';
$db = Data::getConnection();
Security::verifyUser($db, redirectIfAnonymous: false);
page_head('Security Modes | Documentation'); ?>
<h1>Configuring Security Modes</h1>
<p class=back-link><a href=./>&lang;&lang; Documentation Home</a>
<article class=docs>
<h2>Security Modes</h2>
<p><strong>Single-User</strong> mode assumes that every connection to the application is the same person. It is
designed for one person to use on a trusted internal network; under no circumstances should an instance
configured this way be reachable from the Internet. However, it is a low-friction way to keep up with feeds from
multiple devices on a home network.
<p><strong>Single-User with Password</strong> mode operates the same way as Single-User mode does, but the
application will require a password. Depending on the strength of the password, this model may be appropriate
for Internet access, but its intent is more for keeping other internal network users from accessing the site
and reading the items before its intended user is able to do so. The password should be set using the CLI.
<p><strong>Multi-User</strong> mode requires both an e-mail address and password before allowing the user to
proceed. It is the most appropriate configuration for an Internet-facing instance, and it can also be used to
provide access to multiple users on an internal network. Managing users is performed via the CLI.
<h2 id=manage-users>Managing Users in Multi-User Mode</h2>
<p>Users can be added or deleted, and passwords set, using the <code>user</code> CLI utility.<br><br>
<em>(For all the &ldquo;password&rdquo; parameters, if a character in the password conflicts with a shell escape
character, enclose the password in double-quotes for *sh or single-quotes for PowerShell.)</em>
<h3>Add a User</h3>
<p><code>php-cli utils/user.php add-user alice@example.com AlicesSecur3P4ssword</code>
<p>The utility should respond with the e-mail address and password that were added. If a user with that e-mail
address already exists, the utility will not add it again.
<h3>Set a User&rsquo;s Password</h3>
<p><code>php-cli utils/user.php set-password bob@example.com AN3wPassCauseB0bForg0t1t</code>
<h3>Delete a User</h3>
<p><code>php-cli utils/user.php delete-user carol@example.com</code>
<p>The utility will require confirmation that the user and their feeds should be deleted. Any input that starts with
the letter &ldquo;y&rdquo; will confirm, and any other input will cancel the process.
<h2 id=change-to-multi>Changing from Single-User to Multi-User Mode</h2>
<p>In Single-User mode, the application uses a known e-mail address and password to mimic multi-user mode where that
user is always logged on. If you have been using the application this way, and decide that you want to run in
multi-user mode instead, you will need to update <code>SECURITY_MODEL</code> in <code>user-config.php</code> to
<code>Security::MULTI_USER</code>.
<p>The e-mail address used for Single-User mode is not allowed to log on in Multi-User mode. If you want to preserve
the feeds defined by the single user, use the CLI to replace its e-mail address and password.
<p><code>php-cli utils/user.php migrate-single-user dave@example.com Dav3sPas$wort</code>
<p>If, however, you do not wish to maintain the single user&rsquo;s information at all, delete it.
<p><code>php-cli utils/user.php remove-single-user</code>
<h2 id=change-multi-to-single>Changing from Multi-User to any Single-User Mode</h2>
<p>This scenario is possible, but not really advisable. When the application is in any Single-User mode, it only
displays feeds from the Single-User mode user. The information for the other users remains in the database,
though, so this change is not destructive.
<h2 id=change-single-to-pw>Changing from Single-User to Single-User with Password Mode</h2>
<p>Set <code>SECURITY_MODEL</code> in <code>user-config.php</code> to
<code>Security::SINGLE_USER_WITH_PASSWORD</code>, then use the <code>user</code> CLI utility to set a password.
<p><code>php-cli util/user.php set-single-password aNiceC0mplexPassw0rd</code>
<h2 id=change-pw-to-single>Changing from Single-User with Password to Single-User Mode</h2>
<p>If you decide you do not want to enter a password, but want to maintain single-user mode, set
<code>SECURITY_MODEL</code> in <code>user-config.php</code> to <code>Security::SINGLE_USER</code>, then run the
<code>user</code> CLI utility to reset the single user back to its expected default.
<p><code>php-cli util/user.php reset-single-password</code>
</article><?php
page_foot();
$db->close();

View File

@ -0,0 +1,24 @@
<?php
include '../../start.php';
$db = Data::getConnection();
Security::verifyUser($db, redirectIfAnonymous: false);
page_head('About the CLI | Documentation'); ?>
<h1>About the CLI</h1>
<p class=back-link><a href=./>&lang;&lang; Documentation Home</a>
<article class=docs>
<p>Feed Reader Central&rsquo;s low-friction design includes having many administrative tasks run in a terminal or
shell. &ldquo;CLI&rdquo; is short for &ldquo;Command Line Interface&rdquo;, and refers to commands that are run
via PHP&rsquo;s <code>php-cli</code> command. Ensure that the version of PHP installed also has that feature
enabled. (If you are using the recommended
<a href=https://frankenphp.dev target=_blank rel=noopener>FrankenPHP</a> environment, it has PHP CLI support;
use <code>frankenphp php-cli</code> instead of <code>php-cli</code> when following instructions here.)
<h2>Running CLI Commands</h2>
<p>CLI commands should be run from the same directory with <code>start.php</code>; this will be one directory level
up from <code>/public</code>, the web root directory. CLI utilities are in the <code>/util</code> directory, so
an invocation will follow the pattern:
<p><code>php-cli util/some-process.php command option1 option2</code>
</article><?php
page_foot();
$db->close();

View File

@ -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') {

View File

@ -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);
@ -20,14 +19,17 @@ if (array_key_exists('refresh', $_GET)) {
}
}
$result = $db->query(<<<'SQL'
$query = $db->prepare(<<<'SQL'
SELECT item.id, item.title AS item_title, coalesce(item.updated_on, item.published_on) AS as_of,
feed.title AS feed_title
FROM item
INNER JOIN feed ON feed.id = item.feed_id
WHERE item.is_read = 0
WHERE feed.user_id = :userId
AND item.is_read = 0
ORDER BY coalesce(item.updated_on, item.published_on) DESC
SQL);
$query->bindValue(':userId', $_SESSION[Key::USER_ID]);
$result = $query->execute();
$item = $result ? $result->fetchArray(SQLITE3_ASSOC) : false;
page_head('Welcome'); ?>
@ -35,7 +37,7 @@ page_head('Welcome'); ?>
<article><?php
if ($item) {
while ($item) { ?>
<p><a href=/item?id=<?=$item['id']?>><?=$item['item_title']?></a><br>
<p><a href=/item?id=<?=$item['id']?>><?=strip_tags($item['item_title'])?></a><br>
<?=htmlentities($item['feed_title'])?><br><small><em><?=date_time($item['as_of'])?></em></small><?php
$item = $result->fetchArray(SQLITE3_ASSOC);
}

View File

@ -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;
@ -54,7 +53,7 @@ $updated = isset($item['updated_on']) ? date_time($item['updated_on']) : null;
page_head(htmlentities("{$item['item_title']} | {$item['feed_title']}")); ?>
<h1 class=item_heading>
<a href="<?=$item['item_link']?>" target=_blank rel=noopener><?=$item['item_title']?></a><br>
<a href="<?=$item['item_link']?>" target=_blank rel=noopener><?=strip_tags($item['item_title'])?></a><br>
</h1>
<div class=item_published>
From <strong><?=htmlentities($item['feed_title'])?></strong><br>

View File

@ -0,0 +1,10 @@
<?php
/**
* User Log Off Page
*/
include '../../start.php';
if (array_key_exists(Key::USER_ID, $_SESSION)) session_destroy();
frc_redirect('/');

View File

@ -0,0 +1,39 @@
<?php
include '../../start.php';
$db = Data::getConnection();
Security::verifyUser($db, redirectIfAnonymous: false);
// Users already logged on have no need of this page
if (array_key_exists(Key::USER_ID, $_SESSION)) frc_redirect('/');
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
Security::logOnUser($_POST['email'] ?? '', $_POST['password'], $_POST['returnTo'] ?? null, $db);
// If we're still here, something didn't work; preserve the returnTo parameter
$_GET['returnTo'] = $_POST['returnTo'];
}
$isSingle = SECURITY_MODEL == Security::SINGLE_USER_WITH_PASSWORD;
page_head('Log On'); ?>
<h1>Log On</h1>
<article>
<form method=POST action=/user/log-on hx-post=/user/log-on><?php
if (($_GET['returnTo'] ?? '') != '') { ?>
<input type=hidden name=returnTo value="<?=$_GET['returnTo']?>"><?php
}
if (!$isSingle) { ?>
<label>
E-mail Address
<input type=email name=email required autofocus>
</label><br><?php
} ?>
<label>
Password
<input type=password name=password required<?=$isSingle ? ' autofocus' : ''?>>
</label><br>
<button type=submit>Log On</button>
</form>
</article><?php
page_foot();
$db->close();

View File

@ -1,18 +1,13 @@
<?php
use JetBrains\PhpStorm\NoReturn;
spl_autoload_register(function ($class) {
$file = implode(DIRECTORY_SEPARATOR, [__DIR__, 'lib', "$class.php"]);
if (file_exists($file)) {
require $file;
return true;
}
return false;
});
require 'app-config.php';
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
@ -48,7 +43,12 @@ function add_info(string $message): void {
* @param string $title The title of the page being displayed
*/
function page_head(string $title): void {
?><!DOCTYPE html>
$version = match (true) {
str_ends_with(FRC_VERSION, '.0.0') => substr(FRC_VERSION, 0, strlen(FRC_VERSION) - 4),
str_ends_with(FRC_VERSION, '.0') => substr(FRC_VERSION, 0, strlen(FRC_VERSION) - 2),
default => FRC_VERSION
} ?>
<!DOCTYPE html>
<html lang=en>
<head>
<meta name=viewport content="width=device-width, initial-scale=1">
@ -57,22 +57,22 @@ function page_head(string $title): void {
</head>
<body>
<header>
<a class=title href="/">Feed Reader Central</a>
<div><a class=title href="/">Feed Reader Central</a><span class=version>v<?=$version?></span></div>
<div><?php
if (array_key_exists(Key::USER_ID, $_REQUEST)) {
echo '<a href=/feed?id=new>Add Feed</a>';
if ($_REQUEST[Key::USER_EMAIL] != 'solouser@example.com') echo " | {$_REQUEST[Key::USER_EMAIL]}";
if (array_key_exists(Key::USER_ID, $_SESSION)) {
echo '<a href=/feed?id=new>Add Feed</a> | <a href=/docs/>Docs</a> | <a href=/user/log-off>Log Off</a>';
if ($_SESSION[Key::USER_EMAIL] != Security::SINGLE_USER_EMAIL) echo " | {$_SESSION[Key::USER_EMAIL]}";
} else {
echo '<a href=/user/log-on>Log On</a> | <a href=/docs/>Docs</a>';
} ?>
</div>
</header>
<main hx-target=this><?php
if (array_key_exists(Key::USER_MSG, $_REQUEST)) {
foreach ($_REQUEST[Key::USER_MSG] as $msg) { ?>
<div>
<?=$msg['level'] == 'INFO' ? '' : "<strong>{$msg['level']}</strong><br>"?>
<?=$msg['message']?>
</div><?php
}
foreach ($_REQUEST[Key::USER_MSG] ?? [] as $msg) { ?>
<div>
<?=$msg['level'] == 'INFO' ? '' : "<strong>{$msg['level']}</strong><br>"?>
<?=$msg['message']?>
</div><?php
}
}
@ -80,7 +80,8 @@ function page_head(string $title): void {
* Render the end of the page
*/
function page_foot(): void {
?></main></body></html><?php
echo '</main></body></html>';
session_commit();
}
/**
@ -94,8 +95,8 @@ function frc_redirect(string $value): void {
http_response_code(400);
die();
}
header("Location: $value");
http_response_code(303);
session_commit();
header("Location: $value", true, 303);
die();
}

View File

@ -3,6 +3,8 @@
* USER CONFIGURATION ITEMS
*
* Editing the values here customizes the behavior of Feed Reader Central
*
* On initial installation, rename this file to user-config.php and configure it as desired
*/
@ -11,10 +13,8 @@
* - Security::SINGLE_USER (no e-mail required, does not require a password)
* - Security::SINGLE_USER_WITH_PASSWORD (no e-mail required, does require a password)
* - Security::MULTI_USER (e-mail and password required for all users)
*
* (NOTE THAT ONLY SINGLE_USER IS CURRENTLY IMPLEMENTED)
*/
const SECURITY_MODEL = Security::SINGLE_USER;
const SECURITY_MODEL = 'CONFIGURE_ME';
/** The name of the database file where users and feeds should be kept */
const DATABASE_NAME = 'frc.db';
@ -25,6 +25,3 @@ const DATABASE_NAME = 'frc.db';
* The default, 'F j, Y \a\t g:ia', equates to "August 17, 2023 at 4:45pm"
*/
const DATE_TIME_FORMAT = 'F j, Y \a\t g:ia';
// END USER CONFIGURATION ITEMS
// (editing below this line is not advised)

219
src/util/user.php Normal file
View File

@ -0,0 +1,219 @@
<?php
use JetBrains\PhpStorm\NoReturn;
require __DIR__ . '/../cli-start.php';
cli_title('USER MAINTENANCE');
if ($argc < 2) display_help();
switch ($argv[1]) {
case 'add-user':
if ($argc < 4) {
printfn('Missing parameters: add-user requires e-mail and password');
exit(-1);
}
add_user();
break;
case 'set-password':
if ($argc < 4) {
printfn('Missing parameters: set-password requires e-mail and password');
exit(-1);
}
set_password($argv[2], $argv[3]);
break;
case 'delete-user':
if ($argc < 3) {
printfn('Missing parameters: delete-user requires e-mail address');
exit(-1);
}
delete_user($argv[2]);
break;
case 'set-single-password':
if ($argc < 3) {
printfn('Missing parameters: set-single-password requires a new password');
exit(-1);
}
set_password(Security::SINGLE_USER_EMAIL, $argv[2]);
break;
case 'reset-single-password':
set_password(Security::SINGLE_USER_EMAIL, Security::SINGLE_USER_PASSWORD);
break;
case 'migrate-single-user':
if ($argc < 4) {
printfn('Missing parameters: migrate-single-user requires e-mail and password');
exit(-1);
}
migrate_single_user();
break;
case 'remove-single-user':
delete_user(Security::SINGLE_USER_EMAIL);
break;
default:
printfn('Unrecognized option "%s"', $argv[1]);
display_help();
}
/**
* Display the options for this utility and exit
*/
#[NoReturn]
function display_help(): void {
printfn('Options:');
printfn(' - add-user [e-mail] [password]');
printfn(' Adds a new user to this instance');
printfn(' - set-password [e-mail] [password]');
printfn(' Sets the password for the given user');
printfn(' - delete-user [e-mail]');
printfn(' Deletes a user and all their data' . PHP_EOL);
printfn('To assist with changing from single-user to single-user-with-password mode:');
printfn(' - set-single-password [password]');
printfn(' Sets the password for the single-user mode user');
printfn(' - reset-single-password');
printfn(' Resets the single-user mode user\'s password to its default' . PHP_EOL);
printfn('To assist with changing from single-user to multi-user mode:');
printfn(' - migrate-single-user [e-mail] [password]');
printfn(' Changes the e-mail address and password for the single-user mode user');
printfn(' - remove-single-user');
printfn(' Removes the single-user mode user and its data');
exit(0);
}
/**
* Add a new user
*/
function add_user(): void {
global $argv;
$db = Data::getConnection();
try {
// Ensure there is not already a user with this e-mail address
$user = Security::findUserByEmail($argv[2], $db);
if ($user) {
printfn('A user with e-mail address "%s" already exists', $argv[2]);
return;
}
Security::addUser($argv[2], $argv[3], $db);
printfn('User "%s" with password "%s" added successfully', $argv[2], $argv[3]);
} finally {
$db->close();
}
}
/**
* Get the way we will refer to the user against whom action is being taken
*
* @param string $email The e-mail address of the user
* @return string The string to use when displaying results
*/
function display_user(string $email): string {
return $email == Security::SINGLE_USER_EMAIL ? 'single-user mode user' : "user \"$email\"";
}
/**
* Set a user's password
*/
function set_password(string $email, string $password): void {
$db = Data::getConnection();
try {
$displayUser = display_user($email);
// Ensure this user exists
$user = Security::findUserByEmail($email, $db);
if (!$user) {
printfn('No %s exists', $displayUser);
return;
}
Security::updatePassword($email, $password, $db);
$msg = $email == Security::SINGLE_USER_EMAIL && $password == Security::SINGLE_USER_PASSWORD
? 'reset' : sprintf('set to "%s"', $password);
printfn('%s password %s successfully', init_cap($displayUser), $msg);
} finally {
$db->close();
}
}
/**
* Delete a user
*
* @param string $email The e-mail address of the user to be deleted
*/
function delete_user(string $email): void {
$db = Data::getConnection();
try {
$displayUser = display_user($email);
// Get the ID for the provided e-mail address
$user = Security::findUserByEmail($email, $db);
if (!$user) {
printfn('No %s exists', $displayUser);
return;
}
$feedCountQuery = $db->prepare('SELECT COUNT(*) FROM feed WHERE user_id = :user');
$feedCountQuery->bindValue(':user', $user['id']);
$feedCountResult = $feedCountQuery->execute();
if (!$feedCountResult) {
printfn('SQLite error: %s', $db->lastErrorMsg());
return;
}
$feedCount = $feedCountResult->fetchArray(SQLITE3_NUM);
$proceed = readline("Delete the $displayUser and their $feedCount[0] feed(s)? (y/N)" . PHP_EOL);
if (!$proceed || !str_starts_with(strtolower($proceed), 'y')) {
printfn('Deletion canceled');
return;
}
$itemDelete = $db->prepare('DELETE FROM item WHERE feed_id IN (SELECT id FROM feed WHERE user_id = :user)');
$itemDelete->bindValue(':user', $user['id']);
$itemDelete->execute();
$feedDelete = $db->prepare('DELETE FROM feed WHERE user_id = :user');
$feedDelete->bindValue(':user', $user['id']);
$feedDelete->execute();
$userDelete = $db->prepare('DELETE FROM frc_user WHERE id = :user');
$userDelete->bindValue(':user', $user['id']);
$userDelete->execute();
printfn('%s deleted successfully', init_cap($displayUser));
} finally {
$db->close();
}
}
/**
* Change the single-user mode user to a different e-mail address and password
*/
function migrate_single_user(): void {
global $argv;
$db = Data::getConnection();
try {
$single = Security::findUserByEmail(Security::SINGLE_USER_EMAIL, $db);
if (!$single) {
printfn('There is no single-user mode user to be migrated');
return;
}
$migrateQuery = $db->prepare('UPDATE frc_user SET email = :email, password = :password WHERE id = :id');
$migrateQuery->bindValue(':email', $argv[2]);
$migrateQuery->bindValue(':password', password_hash($argv[3], Security::PW_ALGORITHM));
$migrateQuery->bindValue(':id', $single['id']);
$migrateQuery->execute();
printfn('The single user has been moved to "%s", with password "%s"', $argv[2], $argv[3]);
} finally {
$db->close();
}
}