Add single-user handling (#9)

- Disallow log on for single-user mode user
- Improve CLI header display
This commit is contained in:
Daniel J. Summers 2024-04-27 13:54:09 -04:00
parent c1790b58fd
commit bf6b2a0ffa
3 changed files with 70 additions and 44 deletions

View File

@ -22,5 +22,9 @@ function printfn(string $format, mixed ...$values): void {
* @param string $title The title to display on the command line * @param string $title The title to display on the command line
*/ */
function cli_title(string $title): void { function cli_title(string $title): void {
printfn("$title | Feed Reader Central v%s" . PHP_EOL, FRC_VERSION); $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);
} }

View File

@ -20,6 +20,9 @@ class Security {
/** @var string The password for the single user with no password */ /** @var string The password for the single user with no password */
private const string SINGLE_USER_PASSWORD = 'no-password-required'; private 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;
/** /**
* Find a user by their ID * Find a user by their ID
* *
@ -27,7 +30,7 @@ class Security {
* @param SQLite3 $db The data connection to use to retrieve the user * @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 * @return array|false The user information, or null if the user is not found
*/ */
private static function findUserByEmail(string $email, SQLite3 $db): array|false { public static function findUserByEmail(string $email, SQLite3 $db): array|false {
$query = $db->prepare('SELECT * FROM frc_user WHERE email = :email'); $query = $db->prepare('SELECT * FROM frc_user WHERE email = :email');
$query->bindValue(':email', $email); $query->bindValue(':email', $email);
$result = $query->execute(); $result = $query->execute();
@ -44,7 +47,7 @@ class Security {
public static function addUser(string $email, string $password, SQLite3 $db): void { public static function addUser(string $email, string $password, SQLite3 $db): void {
$query = $db->prepare('INSERT INTO frc_user (email, password) VALUES (:email, :password)'); $query = $db->prepare('INSERT INTO frc_user (email, password) VALUES (:email, :password)');
$query->bindValue(':email', $email); $query->bindValue(':email', $email);
$query->bindValue(':password', password_hash($password, PASSWORD_DEFAULT)); $query->bindValue(':password', password_hash($password, self::PW_ALGORITHM));
$query->execute(); $query->execute();
} }
@ -58,9 +61,9 @@ class Security {
*/ */
private static function verifyPassword(array $user, string $password, ?string $returnTo, SQLite3 $db): void { private static function verifyPassword(array $user, string $password, ?string $returnTo, SQLite3 $db): void {
if (password_verify($password, $user['password'])) { if (password_verify($password, $user['password'])) {
if (password_needs_rehash($user['password'], PASSWORD_DEFAULT)) { if (password_needs_rehash($user['password'], self::PW_ALGORITHM)) {
$rehash = $db->prepare('UPDATE frc_user SET password = :hash WHERE id = :id'); $rehash = $db->prepare('UPDATE frc_user SET password = :hash WHERE id = :id');
$rehash->bindValue(':hash', password_hash($password, PASSWORD_DEFAULT)); $rehash->bindValue(':hash', password_hash($password, self::PW_ALGORITHM));
$rehash->bindValue(':id', $user['id']); $rehash->bindValue(':id', $user['id']);
$rehash->execute(); $rehash->execute();
} }
@ -73,12 +76,16 @@ class Security {
/** /**
* Log on a user with e-mail address and password * Log on a user with e-mail address and password
* *
* @param string $email The e-mail address for the user * @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 $password The password provided by the user
* @param string|null $returnTo The URL to which the user should be redirected * @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 * @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 { public static function logOnUser(string $email, string $password, ?string $returnTo, SQLite3 $db): void {
if ($email == self::SINGLE_USER_EMAIL) {
add_error('Invalid credentials; log on unsuccessful');
return;
}
$user = self::findUserByEmail($email, $db); $user = self::findUserByEmail($email, $db);
if ($user) self::verifyPassword($user, $password, $returnTo, $db); if ($user) self::verifyPassword($user, $password, $returnTo, $db);
add_error('Invalid credentials; log on unsuccessful'); add_error('Invalid credentials; log on unsuccessful');
@ -93,7 +100,7 @@ class Security {
*/ */
public static function updatePassword(string $email, string $password, SQLite3 $db): void { public static function updatePassword(string $email, string $password, SQLite3 $db): void {
$query = $db->prepare('UPDATE frc_user SET password = :password WHERE email = :email'); $query = $db->prepare('UPDATE frc_user SET password = :password WHERE email = :email');
$query->bindValue(':password', password_hash($password, PASSWORD_DEFAULT)); $query->bindValue(':password', password_hash($password, self::PW_ALGORITHM));
$query->bindValue(':email', $email); $query->bindValue(':email', $email);
$query->execute(); $query->execute();
} }

View File

@ -47,13 +47,17 @@ switch ($argv[1]) {
printfn('Missing parameters: delete-user requires e-mail address'); printfn('Missing parameters: delete-user requires e-mail address');
exit(-1); exit(-1);
} }
delete_user(); delete_user($argv[2]);
break; break;
case 'migrate-single-user': case 'migrate-single-user':
printfn('TODO: single-user migration'); if ($argc < 4) {
printfn('Missing parameters: migrate-single-user requires e-mail and password');
exit(-1);
}
migrate_single_user();
break; break;
case 'remove-single-user': case 'remove-single-user':
printfn('TODO: single-user removal'); delete_user(Security::SINGLE_USER_EMAIL);
break; break;
default: default:
printfn('Unrecognized option "%s"', $argv[1]); printfn('Unrecognized option "%s"', $argv[1]);
@ -70,14 +74,8 @@ function add_user(): void {
try { try {
// Ensure there is not already a user with this e-mail address // Ensure there is not already a user with this e-mail address
$existsQuery = $db->prepare('SELECT COUNT(*) FROM frc_user WHERE email = :email'); $user = Security::findUserByEmail($argv[2], $db);
$existsQuery->bindValue(':email', $argv[2]); if ($user) {
$existsResult = $existsQuery->execute();
if (!$existsResult) {
printfn('SQLite error: %s', $db->lastErrorMsg());
return;
}
if ($existsResult->fetchArray(SQLITE3_NUM)[0] != 0) {
printfn('A user with e-mail address "%s" already exists', $argv[2]); printfn('A user with e-mail address "%s" already exists', $argv[2]);
return; return;
} }
@ -100,14 +98,8 @@ function set_password(): void {
try { try {
// Ensure this user exists // Ensure this user exists
$existsQuery = $db->prepare('SELECT COUNT(*) FROM frc_user WHERE email = :email'); $user = Security::findUserByEmail($argv[2], $db);
$existsQuery->bindValue(':email', $argv[2]); if (!$user) {
$existsResult = $existsQuery->execute();
if (!$existsResult) {
printfn('SQLite error: %s', $db->lastErrorMsg());
return;
}
if ($existsResult->fetchArray(SQLITE3_NUM)[0] == 0) {
printfn('No user exists with e-mail address "%s"', $argv[2]); printfn('No user exists with e-mail address "%s"', $argv[2]);
return; return;
} }
@ -122,29 +114,25 @@ function set_password(): void {
/** /**
* Delete a user * Delete a user
*
* @param string $email The e-mail address of the user to be deleted
*/ */
function delete_user(): void { function delete_user(string $email): void {
global $argv;
$db = Data::getConnection(); $db = Data::getConnection();
try { try {
$displayUser = $email == Security::SINGLE_USER_EMAIL ? 'single-user mode user' : "user \"$email\"";
// Get the ID for the provided e-mail address // Get the ID for the provided e-mail address
$idQuery = $db->prepare('SELECT id FROM frc_user WHERE email = :email'); $user = Security::findUserByEmail($email, $db);
$idQuery->bindValue(':email', $argv[2]); if (!$user) {
$idResult = $idQuery->execute(); printfn('No %s exists', $displayUser);
if (!$idResult) {
printfn('SQLite error: %s', $db->lastErrorMsg());
return;
}
$id = $idResult->fetchArray(SQLITE3_NUM);
if (!$id) {
printfn('No user exists with e-mail address "%s"', $argv[2]);
return; return;
} }
$feedCountQuery = $db->prepare('SELECT COUNT(*) FROM feed WHERE user_id = :user'); $feedCountQuery = $db->prepare('SELECT COUNT(*) FROM feed WHERE user_id = :user');
$feedCountQuery->bindValue(':user', $id[0]); $feedCountQuery->bindValue(':user', $user['id']);
$feedCountResult = $feedCountQuery->execute(); $feedCountResult = $feedCountQuery->execute();
if (!$feedCountResult) { if (!$feedCountResult) {
printfn('SQLite error: %s', $db->lastErrorMsg()); printfn('SQLite error: %s', $db->lastErrorMsg());
@ -152,25 +140,52 @@ function delete_user(): void {
} }
$feedCount = $feedCountResult->fetchArray(SQLITE3_NUM); $feedCount = $feedCountResult->fetchArray(SQLITE3_NUM);
$proceed = readline("Delete user \"$argv[2]\" and their $feedCount[0] feed(s)? (y/N)" . PHP_EOL); $proceed = readline("Delete the $displayUser and their $feedCount[0] feed(s)? (y/N)" . PHP_EOL);
if (!$proceed || !str_starts_with(strtolower($proceed), 'y')) { if (!$proceed || !str_starts_with(strtolower($proceed), 'y')) {
printfn('Deletion canceled'); printfn('Deletion canceled');
return; return;
} }
$itemDelete = $db->prepare('DELETE FROM item WHERE feed_id IN (SELECT id FROM feed WHERE user_id = :user)'); $itemDelete = $db->prepare('DELETE FROM item WHERE feed_id IN (SELECT id FROM feed WHERE user_id = :user)');
$itemDelete->bindValue(':user', $id[0]); $itemDelete->bindValue(':user', $user['id']);
$itemDelete->execute(); $itemDelete->execute();
$feedDelete = $db->prepare('DELETE FROM feed WHERE user_id = :user'); $feedDelete = $db->prepare('DELETE FROM feed WHERE user_id = :user');
$feedDelete->bindValue(':user', $id[0]); $feedDelete->bindValue(':user', $user['id']);
$feedDelete->execute(); $feedDelete->execute();
$userDelete = $db->prepare('DELETE FROM frc_user WHERE id = :user'); $userDelete = $db->prepare('DELETE FROM frc_user WHERE id = :user');
$userDelete->bindValue(':user', $id[0]); $userDelete->bindValue(':user', $user['id']);
$userDelete->execute(); $userDelete->execute();
printfn('User "%s" deleted successfully', $argv[2]); printfn(strtoupper(substr($displayUser, 0, 1)) . substr($displayUser, 1) . ' deleted successfully');
} 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 { } finally {
$db->close(); $db->close();
} }