108 lines
3.8 KiB
PHP
108 lines
3.8 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
/**
|
||
* The accounts, as one JSON file: `data/users.json` holding `{username, password}` per user,
|
||
* where `password` is a hash from `password_hash` – the plain one is never stored.
|
||
*
|
||
* Accounts are made from the command line (`tools/make-user.php`), not through the API: signing
|
||
* up is a follow-up of its own, and until it exists nobody may create an account over the wire.
|
||
*/
|
||
final class UserStore
|
||
{
|
||
public function __construct(private readonly string $file) {}
|
||
|
||
public static function isValidName(string $name): bool
|
||
{
|
||
return (bool) preg_match('/^[a-z0-9][a-z0-9._-]{1,31}$/i', $name);
|
||
}
|
||
|
||
/** The name as stored, or null when the pair does not match an account. */
|
||
public function verify(string $username, string $password): ?string
|
||
{
|
||
$user = $this->find($username);
|
||
if ($user === null || !password_verify($password, $user['password'])) {
|
||
return null;
|
||
}
|
||
return $user['username'];
|
||
}
|
||
|
||
public function exists(string $username): bool
|
||
{
|
||
return $this->find($username) !== null;
|
||
}
|
||
|
||
/** @return list<string> */
|
||
public function names(): array
|
||
{
|
||
return array_map(static fn(array $u): string => $u['username'], $this->all());
|
||
}
|
||
|
||
/** Adds the account or replaces the password of one that is already there. */
|
||
public function put(string $username, string $password): void
|
||
{
|
||
if (!self::isValidName($username)) {
|
||
throw new InvalidArgumentException("Invalid user name: {$username}");
|
||
}
|
||
$users = array_values(
|
||
array_filter(
|
||
$this->all(),
|
||
static fn(array $u): bool => strcasecmp($u['username'], $username) !== 0,
|
||
),
|
||
);
|
||
$users[] = ['username' => $username, 'password' => password_hash($password, PASSWORD_DEFAULT)];
|
||
usort($users, static fn(array $a, array $b): int => strcmp($a['username'], $b['username']));
|
||
$this->write($users);
|
||
}
|
||
|
||
/** @return array{username:string,password:string}|null */
|
||
private function find(string $username): ?array
|
||
{
|
||
foreach ($this->all() as $user) {
|
||
if (strcasecmp($user['username'], $username) === 0) {
|
||
return $user;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** @return list<array{username:string,password:string}> */
|
||
private function all(): array
|
||
{
|
||
if (!is_file($this->file)) {
|
||
return [];
|
||
}
|
||
$raw = file_get_contents($this->file);
|
||
$data = $raw === false ? null : json_decode($raw, true);
|
||
if (!is_array($data) || !is_array($data['users'] ?? null)) {
|
||
return [];
|
||
}
|
||
$users = [];
|
||
foreach ($data['users'] as $user) {
|
||
if (is_array($user) && is_string($user['username'] ?? null) && is_string($user['password'] ?? null)) {
|
||
$users[] = ['username' => $user['username'], 'password' => $user['password']];
|
||
}
|
||
}
|
||
return $users;
|
||
}
|
||
|
||
/** @param list<array{username:string,password:string}> $users */
|
||
private function write(array $users): void
|
||
{
|
||
$dir = dirname($this->file);
|
||
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||
throw new RuntimeException("Cannot create data directory {$dir}");
|
||
}
|
||
$json = json_encode(['users' => $users], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||
if ($json === false) {
|
||
throw new RuntimeException('Cannot encode users');
|
||
}
|
||
$tmp = $this->file . '.' . bin2hex(random_bytes(4)) . '.tmp';
|
||
if (file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $this->file)) {
|
||
@unlink($tmp);
|
||
throw new RuntimeException('Cannot write users file');
|
||
}
|
||
@chmod($this->file, 0640);
|
||
}
|
||
}
|