From cf0b9f036b3e3eb218610e7eeececda1320d9f50 Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Thu, 26 May 2022 21:18:29 +0300 Subject: auth --- localwebsite/classes/User.php | 11 + localwebsite/classes/auth.php | 66 ++++++ localwebsite/classes/config.php | 10 + localwebsite/classes/users.php | 39 ++++ localwebsite/config.php | 8 +- localwebsite/engine/database.php | 51 +++++ localwebsite/engine/model.php | 243 ++++++++++++++++++++++ localwebsite/functions.php | 24 ++- localwebsite/handlers/AuthHandler.php | 36 ++++ localwebsite/handlers/MiscHandler.php | 15 +- localwebsite/handlers/RequestHandler.php | 11 + localwebsite/htdocs/index.php | 4 + localwebsite/templates-web/auth.twig | 24 +++ localwebsite/templates-web/bc.twig | 12 ++ localwebsite/templates-web/cams.twig | 11 +- localwebsite/templates-web/index.twig | 6 + localwebsite/templates-web/inverter_page.twig | 11 +- localwebsite/templates-web/modem_status_page.twig | 11 +- localwebsite/templates-web/pump.twig | 11 +- localwebsite/templates-web/routing_header.twig | 11 +- localwebsite/templates-web/sensors.twig | 11 +- localwebsite/templates-web/sms_page.twig | 11 +- localwebsite/utils.php | 66 ++++++ 23 files changed, 649 insertions(+), 54 deletions(-) create mode 100644 localwebsite/classes/User.php create mode 100644 localwebsite/classes/auth.php create mode 100644 localwebsite/classes/config.php create mode 100644 localwebsite/classes/users.php create mode 100644 localwebsite/engine/model.php create mode 100644 localwebsite/handlers/AuthHandler.php create mode 100644 localwebsite/templates-web/auth.twig create mode 100644 localwebsite/templates-web/bc.twig create mode 100755 localwebsite/utils.php (limited to 'localwebsite') diff --git a/localwebsite/classes/User.php b/localwebsite/classes/User.php new file mode 100644 index 0000000..9019082 --- /dev/null +++ b/localwebsite/classes/User.php @@ -0,0 +1,11 @@ +id; + } + + public static function check(?string $pwhash = null): bool { + if (self::$authorizedUser !== null) + return true; + + // get auth token + if (!$pwhash) + $pwhash = self::getToken(); + + if (!is_string($pwhash)) + return false; + + // find session by given token + $user = users::getUserByPwhash($pwhash); + if (is_null($user)) { + self::resetToken(); + return false; + } + + self::$authorizedUser = $user; + + return true; + } + + public static function logout() { + self::resetToken(); + self::$authorizedUser = null; + } + +} \ No newline at end of file diff --git a/localwebsite/classes/config.php b/localwebsite/classes/config.php new file mode 100644 index 0000000..87ecf1c --- /dev/null +++ b/localwebsite/classes/config.php @@ -0,0 +1,10 @@ +insert('users', [ + 'username' => $username, + 'password' => pwhash($password) + ]); + return $db->insertId(); + } + + public static function exists(string $username): bool { + $db = getDB(); + $count = (int)$db->querySingle("SELECT COUNT(*) FROM users WHERE username=?", $username); + return $count > 0; + } + + public static function validatePassword(string $username, string $password): bool { + $db = getDB(); + $row = $db->querySingleRow("SELECT * FROM users WHERE username=?", $username); + if (!$row) + return false; + + return $row['password'] == pwhash($password); + } + + public static function getUserByPwhash(string $pwhash): ?User { + $db = getDB(); + $data = $db->querySingleRow("SELECT * FROM users WHERE password=?", $pwhash); + return $data ? new User($data) : null; + } + + public static function setPassword(int $id, string $new_password) { + getDB()->exec("UPDATE users SET password=? WHERE id=?", pwhash($new_password), $id); + } + +} \ No newline at end of file diff --git a/localwebsite/config.php b/localwebsite/config.php index 7ce281d..f5f8c80 100644 --- a/localwebsite/config.php +++ b/localwebsite/config.php @@ -57,8 +57,8 @@ return [ ], 'cam_hls_access_key' => '', - 'cam_hls_proto' => 'http', - 'cam_hls_host' => '192.168.1.1', + 'cam_hls_proto' => 'http', // bool|callable + 'cam_hls_host' => '192.168.1.1', // bool|callable 'cam_list' => [ // fill me with names ], @@ -70,4 +70,8 @@ return [ ], 'database_path' => getenv('HOME').'/.config/homekit.localwebsite.sqlite3', + + 'auth_cookie_host' => '', + 'auth_need' => false, // bool|callable + 'auth_pw_salt' => '', ]; diff --git a/localwebsite/engine/database.php b/localwebsite/engine/database.php index 186d2ef..33f36cf 100644 --- a/localwebsite/engine/database.php +++ b/localwebsite/engine/database.php @@ -7,13 +7,19 @@ class database { protected SQLite3 $link; public function __construct(string $db_path) { + $will_create = !file_exists($db_path); $this->link = new SQLite3($db_path); + if ($will_create) + setperm($db_path); $this->link->enableExceptions(true); $this->upgradeSchema(); } protected function upgradeSchema() { $cur = $this->getSchemaVersion(); + if ($cur == self::SCHEMA_VERSION) + return; + if ($cur < 1) { $this->link->exec("CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -77,4 +83,49 @@ class database { return $this->link->querySingle($this->prepareQuery($sql, ...$params), true); } + protected function performInsert(string $command, string $table, array $fields): SQLite3Result { + $names = []; + $values = []; + $count = 0; + foreach ($fields as $k => $v) { + $names[] = $k; + $values[] = $v; + $count++; + } + + $sql = "{$command} INTO `{$table}` (`" . implode('`, `', $names) . "`) VALUES (" . implode(', ', array_fill(0, $count, '?')) . ")"; + array_unshift($values, $sql); + + return call_user_func_array([$this, 'query'], $values); + } + + public function insert(string $table, array $fields): SQLite3Result { + return $this->performInsert('INSERT', $table, $fields); + } + + public function replace(string $table, array $fields): SQLite3Result { + return $this->performInsert('REPLACE', $table, $fields); + } + + public function insertId(): int { + return $this->link->lastInsertRowID(); + } + + public function update($table, $rows, ...$cond): SQLite3Result { + $fields = []; + $args = []; + foreach ($rows as $row_name => $row_value) { + $fields[] = "`{$row_name}`=?"; + $args[] = $row_value; + } + $sql = "UPDATE `$table` SET " . implode(', ', $fields); + if (!empty($cond)) { + $sql .= " WHERE " . $cond[0]; + if (count($cond) > 1) + $args = array_merge($args, array_slice($cond, 1)); + } + return $this->query($sql, ...$args); + } + + } \ No newline at end of file diff --git a/localwebsite/engine/model.php b/localwebsite/engine/model.php new file mode 100644 index 0000000..4dd981c --- /dev/null +++ b/localwebsite/engine/model.php @@ -0,0 +1,243 @@ + $fields, + 'model_name_map' => $model_name_map, + 'db_name_map' => $db_name_map + ]; + } + + foreach (self::$SpecCache[static::class]['fields'] as $field) + $this->{$field['model_name']} = self::cast_to_type($field['type'], $raw[$field['db_name']]); + + if (is_null(static::DB_TABLE)) + trigger_error('class '.get_class($this).' doesn\'t have DB_TABLE defined'); + } + + /** + * @param $fields + * + * TODO: support adding or subtracting (SET value=value+1) + */ + public function edit($fields) { + $db = getDB(); + + $model_upd = []; + $db_upd = []; + + foreach ($fields as $name => $value) { + $index = self::$SpecCache[static::class]['db_name_map'][$name] ?? null; + if (is_null($index)) { + debugError(__METHOD__.': field `'.$name.'` not found in '.static::class); + continue; + } + + $field = self::$SpecCache[static::class]['fields'][$index]; + switch ($field['type']) { + case self::ARRAY: + if (is_array($value)) { + $db_upd[$name] = implode(',', $value); + $model_upd[$field['model_name']] = $value; + } else { + debugError(__METHOD__.': field `'.$name.'` is expected to be array. skipping.'); + } + break; + + case self::INTEGER: + $value = (int)$value; + $db_upd[$name] = $value; + $model_upd[$field['model_name']] = $value; + break; + + case self::FLOAT: + $value = (float)$value; + $db_upd[$name] = $value; + $model_upd[$field['model_name']] = $value; + break; + + case self::BOOLEAN: + $db_upd[$name] = $value ? 1 : 0; + $model_upd[$field['model_name']] = $value; + break; + + case self::JSON: + $db_upd[$name] = jsonEncode($value); + $model_upd[$field['model_name']] = $value; + break; + + case self::SERIALIZED: + $db_upd[$name] = serialize($value); + $model_upd[$field['model_name']] = $value; + break; + + default: + $value = (string)$value; + $db_upd[$name] = $value; + $model_upd[$field['model_name']] = $value; + break; + } + } + + if (!empty($db_upd) && !$db->update(static::DB_TABLE, $db_upd, static::DB_KEY."=?", $this->get_id())) { + debugError(__METHOD__.': failed to update database'); + return; + } + + if (!empty($model_upd)) { + foreach ($model_upd as $name => $value) + $this->{$name} = $value; + } + } + + public function get_id() { + return $this->{to_camel_case(static::DB_KEY)}; + } + + public function as_array(array $fields = [], array $custom_getters = []): array { + if (empty($fields)) + $fields = array_keys(static::$SpecCache[static::class]['db_name_map']); + + $array = []; + foreach ($fields as $field) { + if (isset($custom_getters[$field]) && is_callable($custom_getters[$field])) { + $array[$field] = $custom_getters[$field](); + } else { + $array[$field] = $this->{to_camel_case($field)}; + } + } + + return $array; + } + + protected static function cast_to_type(int $type, $value) { + switch ($type) { + case self::BOOLEAN: + return (bool)$value; + + case self::INTEGER: + return (int)$value; + + case self::FLOAT: + return (float)$value; + + case self::ARRAY: + return array_filter(explode(',', $value)); + + case self::JSON: + $val = jsonDecode($value); + if (!$val) + $val = null; + return $val; + + case self::SERIALIZED: + $val = unserialize($value); + if ($val === false) + $val = null; + return $val; + + default: + return (string)$value; + } + } + + protected static function get_spec(): array { + $rc = new ReflectionClass(static::class); + $props = $rc->getProperties(ReflectionProperty::IS_PUBLIC); + + $list = []; + $index = 0; + + $model_name_map = []; + $db_name_map = []; + + foreach ($props as $prop) { + if ($prop->isStatic()) + continue; + + $name = $prop->getName(); + if (startsWith($name, '_')) + continue; + + $type = $prop->getType(); + $phpdoc = $prop->getDocComment(); + + $mytype = null; + if (!$prop->hasType() && !$phpdoc) + $mytype = self::STRING; + else { + $typename = $type->getName(); + switch ($typename) { + case 'string': + $mytype = self::STRING; + break; + case 'int': + $mytype = self::INTEGER; + break; + case 'float': + $mytype = self::FLOAT; + break; + case 'array': + $mytype = self::ARRAY; + break; + case 'bool': + $mytype = self::BOOLEAN; + break; + } + + if ($phpdoc != '') { + $pos = strpos($phpdoc, '@'); + if ($pos === false) + continue; + + if (substr($phpdoc, $pos+1, 4) == 'json') + $mytype = self::JSON; + else if (substr($phpdoc, $pos+1, 5) == 'array') + $mytype = self::ARRAY; + else if (substr($phpdoc, $pos+1, 10) == 'serialized') + $mytype = self::SERIALIZED; + } + } + + if (is_null($mytype)) + debugError(__METHOD__.": ".$name." is still null in ".static::class); + + $dbname = from_camel_case($name); + $list[] = [ + 'type' => $mytype, + 'model_name' => $name, + 'db_name' => $dbname + ]; + + $model_name_map[$name] = $index; + $db_name_map[$dbname] = $index; + + $index++; + } + + return [$list, $model_name_map, $db_name_map]; + } + +} diff --git a/localwebsite/functions.php b/localwebsite/functions.php index f46a534..4757765 100644 --- a/localwebsite/functions.php +++ b/localwebsite/functions.php @@ -242,6 +242,10 @@ function bytesToUnitsLabel(GMP $b): string { return gmp_strval($b); } +function pwhash(string $s): string { + return hash('sha256', config::get('auth_pw_salt').'|'.$s); +} + $ShutdownFunctions = []; function append_shutdown_function(callable $f) { @@ -255,11 +259,27 @@ function prepend_shutdown_function(callable $f) { } function getDB(): database { - global $config; static $link = null; if (is_null($link)) - $link = new database($config['database_path']); + $link = new database(config::get('database_path')); return $link; +} + +function to_camel_case(string $input, string $separator = '_'): string { + return lcfirst(str_replace($separator, '', ucwords($input, $separator))); +} + +function from_camel_case(string $s): string { + $buf = ''; + $len = strlen($s); + for ($i = 0; $i < $len; $i++) { + if (!ctype_upper($s[$i])) { + $buf .= $s[$i]; + } else { + $buf .= '_'.strtolower($s[$i]); + } + } + return $buf; } \ No newline at end of file diff --git a/localwebsite/handlers/AuthHandler.php b/localwebsite/handlers/AuthHandler.php new file mode 100644 index 0000000..971f850 --- /dev/null +++ b/localwebsite/handlers/AuthHandler.php @@ -0,0 +1,36 @@ +input('error'); + $this->tpl->set(['error' => $error]); + $this->tpl->set_title('Авторизация'); + $this->tpl->render_page('auth.twig'); + } + + public function POST_auth() { + list($username, $password) = $this->input('username, password'); + + $result = users::validatePassword($username, $password); + if (!$result) { + debugError('invalid login attempt: '.$_SERVER['REMOTE_ADDR'].', '.$_SERVER['HTTP_USER_AGENT'].", username=$username, password=$password"); + redirect('/auth/?error='.urlencode('неверный логин или пароль')); + } + + auth::setToken(pwhash($password)); + redirect('/'); + } + + public function GET_deauth() { + if (auth::id()) + auth::logout(); + + redirect('/'); + } + +} diff --git a/localwebsite/handlers/MiscHandler.php b/localwebsite/handlers/MiscHandler.php index ef4d8ef..b7c312a 100644 --- a/localwebsite/handlers/MiscHandler.php +++ b/localwebsite/handlers/MiscHandler.php @@ -8,11 +8,6 @@ class MiscHandler extends RequestHandler $this->tpl->render_page('index.twig'); } - public function GET_phpinfo() { - phpinfo(); - exit; - } - public function GET_sensors_page() { global $config; @@ -68,9 +63,9 @@ class MiscHandler extends RequestHandler $hls_opts['debug'] = true; $this->tpl->add_external_static('js', 'https://cdn.jsdelivr.net/npm/hls.js@latest'); - - $hls_host = is_callable($config['cam_hls_host']) ? $config['cam_hls_host']() : $config['cam_hls_host']; - $hls_proto = is_callable($config['cam_hls_proto']) ? $config['cam_hls_proto']() : $config['cam_hls_proto']; + + $hls_host = config::get('cam_hls_host'); + $hls_proto = config::get('cam_hls_proto'); $this->tpl->set([ 'hls_host' => $hls_host, @@ -89,4 +84,8 @@ class MiscHandler extends RequestHandler print_r($_SERVER); } + public function GET_phpinfo() { + phpinfo(); + } + } \ No newline at end of file diff --git a/localwebsite/handlers/RequestHandler.php b/localwebsite/handlers/RequestHandler.php index 2fffdc0..136a23e 100644 --- a/localwebsite/handlers/RequestHandler.php +++ b/localwebsite/handlers/RequestHandler.php @@ -15,6 +15,12 @@ class RequestHandler extends request_handler { $this->tpl->add_static('polyfills.js'); $this->tpl->add_static('app.js'); $this->tpl->add_static('app.css'); + + if (auth::id()) { + $this->tpl->set_global([ + 'auth_user' => auth::$authorizedUser + ]); + } } public function dispatch(string $act) { @@ -38,4 +44,9 @@ class RequestHandler extends request_handler { ajax_error('unknown act "'.$act.'"', 404); } + + protected function before_dispatch(string $method, string $act) { + if (config::get('auth_need') && !auth::id()) + redirect('/auth/'); + } } \ No newline at end of file diff --git a/localwebsite/htdocs/index.php b/localwebsite/htdocs/index.php index 9ce8324..8c9d092 100644 --- a/localwebsite/htdocs/index.php +++ b/localwebsite/htdocs/index.php @@ -28,6 +28,10 @@ $router->add('phpinfo/', 'Misc phpinfo'); $router->add('cams/', 'Misc cams'); $router->add('debug/', 'Misc debug'); +// auth +$router->add('auth/', 'Auth auth'); +$router->add('deauth/', 'Auth deauth'); + $route = routerFind($router); if ($route === false) diff --git a/localwebsite/templates-web/auth.twig b/localwebsite/templates-web/auth.twig new file mode 100644 index 0000000..a0107b3 --- /dev/null +++ b/localwebsite/templates-web/auth.twig @@ -0,0 +1,24 @@ +{% include 'bc.twig' with { + history: [ + {text: "Авторизация" } + ] +} %} + +{% if error %} +
Ошибка: {{ error }}
+{% endif %} + + +
+
+ +
+ +
+ +
+ +
+ +
+
\ No newline at end of file diff --git a/localwebsite/templates-web/bc.twig b/localwebsite/templates-web/bc.twig new file mode 100644 index 0000000..b74ad40 --- /dev/null +++ b/localwebsite/templates-web/bc.twig @@ -0,0 +1,12 @@ + \ No newline at end of file diff --git a/localwebsite/templates-web/cams.twig b/localwebsite/templates-web/cams.twig index eb9e6d0..4fc815d 100644 --- a/localwebsite/templates-web/cams.twig +++ b/localwebsite/templates-web/cams.twig @@ -1,9 +1,8 @@ - +{% include 'bc.twig' with { + history: [ + {text: "Камеры" } + ] +} %}
diff --git a/localwebsite/templates-web/index.twig b/localwebsite/templates-web/index.twig index 1635459..8a0bdaf 100644 --- a/localwebsite/templates-web/index.twig +++ b/localwebsite/templates-web/index.twig @@ -5,6 +5,12 @@ + {% if auth_user %} +
+ Вы авторизованы как {{ auth_user.username }}. Выйти +
+ {% endif %} +
Интернет