diff options
Diffstat (limited to 'inc')
-rw-r--r-- | inc/crypto.inc.php | 6 | ||||
-rw-r--r-- | inc/database.inc.php | 422 | ||||
-rw-r--r-- | inc/image.inc.php | 7 | ||||
-rw-r--r-- | inc/message.inc.php | 24 | ||||
-rw-r--r-- | inc/render.inc.php | 21 | ||||
-rw-r--r-- | inc/session.inc.php | 43 | ||||
-rw-r--r-- | inc/shibauth.inc.php | 202 | ||||
-rw-r--r-- | inc/user.inc.php | 109 | ||||
-rw-r--r-- | inc/util.inc.php | 40 |
9 files changed, 717 insertions, 157 deletions
diff --git a/inc/crypto.inc.php b/inc/crypto.inc.php index 56f5073..75a0a01 100644 --- a/inc/crypto.inc.php +++ b/inc/crypto.inc.php @@ -8,7 +8,7 @@ class Crypto * which translates to ~130 bit salt * and 5000 rounds of hashing with SHA-512. */ - public static function hash6($password) + public static function hash6(string $password): string { $salt = substr(str_replace('+', '.', base64_encode(pack('N4', mt_rand(), mt_rand(), mt_rand(), mt_rand()))), 0, 16); $hash = crypt($password, '$6$' . $salt); @@ -17,10 +17,10 @@ class Crypto } /** - * Check if the given password matches the given cryp hash. + * Check if the given password matches the given crypt hash. * Useful for checking a hashed password. */ - public static function verify($password, $hash) + public static function verify(string $password, string $hash): bool { return crypt($password, $hash) === $hash; } diff --git a/inc/database.inc.php b/inc/database.inc.php index f76c9e7..6518631 100644 --- a/inc/database.inc.php +++ b/inc/database.inc.php @@ -8,28 +8,43 @@ class Database { /** - * * @var \PDO Database handle */ - private static $dbh = false; - private static $statements = array(); - - - /** + private static ?PDO $dbh = null; + + private static bool $returnErrors; + private static ?string $lastError = null; + private static array $explainList = array(); + private static int $queryCount = 0; + private static float $queryTime = 0; + + /** * Connect to the DB if not already connected. */ - private static function init() + public static function init(bool $returnErrors = false): bool { - if (self::$dbh !== false) - return; + if (self::$dbh !== null) + return true; + self::$returnErrors = $returnErrors; try { - if (CONFIG_SQL_FORCE_UTF8) - self::$dbh = new PDO(CONFIG_SQL_DSN, CONFIG_SQL_USER, CONFIG_SQL_PASS, array(PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8")); - else - self::$dbh = new PDO(CONFIG_SQL_DSN, CONFIG_SQL_USER, CONFIG_SQL_PASS); + self::$dbh = new PDO(CONFIG_SQL_DSN, CONFIG_SQL_USER, CONFIG_SQL_PASS, [ + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => true, + PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4', // Somehow needed, even if charset=utf8mb4 is in DSN? + ]); } catch (PDOException $e) { + if (self::$returnErrors) + return false; Util::traceError('Connecting to the local database failed: ' . $e->getMessage()); } + if (CONFIG_DEBUG) { + Database::exec("SET SESSION sql_mode='STRICT_ALL_TABLES,NO_ENGINE_SUBSTITUTION,ERROR_FOR_DIVISION_BY_ZERO'"); + Database::exec("SET SESSION innodb_strict_mode=ON"); + register_shutdown_function(function() { + self::examineLoggedQueries(); + }); + } + return true; } /** @@ -37,25 +52,100 @@ class Database * * @return array|boolean Associative array representing row, or false if no row matches the query */ - public static function queryFirst($query, $args = array(), $ignoreError = false) + public static function queryFirst(string $query, array $args = [], bool $ignoreError = null) + { + $res = self::simpleQuery($query, $args, $ignoreError); + if ($res === false) + return false; + return $res->fetch(); + } + + /** + * If you need all rows for a query as plain array you can use this. + * Don't use this if you want to do further processing of the data, to save some + * memory. + * + * @return array|bool List of associative arrays representing rows, or false on error + */ + public static function queryAll(string $query, array $args = [], bool $ignoreError = null) + { + $res = self::simpleQuery($query, $args, $ignoreError); + if ($res === false) + return false; + return $res->fetchAll(); + } + + /** + * Fetch the first column of the query as a plain list-of-values array. + * + * @return array|bool List of values representing first column of query + */ + public static function queryColumnArray(string $query, array $args = [], bool $ignoreError = null) + { + $res = self::simpleQuery($query, $args, $ignoreError); + if ($res === false) + return false; + return $res->fetchAll(PDO::FETCH_COLUMN, 0); + } + + /** + * Fetch two columns as key => value list. + * + * @return array|bool Associative array, first column is key, second column is value + */ + public static function queryKeyValueList(string $query, array $args = [], bool $ignoreError = null) { $res = self::simpleQuery($query, $args, $ignoreError); if ($res === false) return false; - return $res->fetch(PDO::FETCH_ASSOC); + return $res->fetchAll(PDO::FETCH_KEY_PAIR); } + /** + * Fetch and group by first column. First column is key, value is a list of rows with remaining columns. + * [ + * col1 => [ + * [col2, col3], + * [col2, col3], + * ], + * ..., + * ] + * + * @return array|bool Associative array, first column is key, remaining columns are array values + */ + public static function queryGroupList(string $query, array $args = [], bool $ignoreError = null) + { + $res = self::simpleQuery($query, $args, $ignoreError); + if ($res === false) + return false; + return $res->fetchAll(PDO::FETCH_GROUP); + } + + /** + * Fetch and use first column as key of returned array. + * This is like queryGroup list, but it is assumed that the first column is unique, so + * the remaining columns won't be wrapped in another array. + * + * @return array|bool Associative array, first column is key, remaining columns are array values + */ + public static function queryIndexedList(string $query, array $args = [], bool $ignoreError = null) + { + $res = self::simpleQuery($query, $args, $ignoreError); + if ($res === false) + return false; + return $res->fetchAll(PDO::FETCH_GROUP | PDO::FETCH_UNIQUE); + } /** * Execute the given query and return the number of rows affected. * Mostly useful for UPDATEs or INSERTs - * + * * @param string $query Query to run * @param array $args Arguments to query - * @param boolean $ignoreError Ignore query errors and just return false + * @param ?bool $ignoreError Ignore query errors and just return false * @return int|boolean Number of rows affected, or false on error */ - public static function exec($query, $args = array(), $ignoreError = false) + public static function exec(string $query, array $args = [], bool $ignoreError = null) { $res = self::simpleQuery($query, $args, $ignoreError); if ($res === false) @@ -64,41 +154,194 @@ class Database } /** - * Get id (promary key) of last row inserted. - * + * Get id (primary key) of last row inserted. + * * @return int the id */ - public static function lastInsertId() + public static function lastInsertId(): int { return self::$dbh->lastInsertId(); } /** + * @return ?string return last error returned by query + */ + public static function lastError(): ?string + { + return self::$lastError; + } + + /** * Execute the given query and return the corresponding PDOStatement object * Note that this will re-use PDOStatements, so if you run the same * query again with different params, do not rely on the first PDOStatement * still being valid. If you need to do something fancy, use Database::prepare - * @return \PDOStatement The query result object + * + * @return \PDOStatement|false The query result object */ - public static function simpleQuery($query, $args = array(), $ignoreError = false) + public static function simpleQuery(string $query, array $args = [], bool $ignoreError = null) { self::init(); - try { - if (!isset(self::$statements[$query])) { - self::$statements[$query] = self::$dbh->prepare($query); - } else { - self::$statements[$query]->closeCursor(); + if (CONFIG_DEBUG && !isset(self::$explainList[$query]) && preg_match('/^\s*SELECT/is', $query)) { + self::$explainList[$query] = [$args]; + } + // Support passing nested arrays for IN statements, automagically refactor + $oquery = $query; + self::handleArrayArgument($query, $args); + // Now turn any bools into 0 or 1, since PDO unfortunately only does (string)<bool>, which + // results in an empty string for false + foreach ($args as &$arg) { + if ($arg === false) { + $arg = '0'; + } elseif ($arg === true) { + $arg = '1'; } - if (self::$statements[$query]->execute($args) === false) { - if ($ignoreError) + } + try { + $stmt = self::$dbh->prepare($query); + $start = microtime(true); + if ($stmt->execute($args) === false) { + self::$lastError = implode("\n", $stmt->errorInfo()); + if ($ignoreError === true || ($ignoreError === null && self::$returnErrors)) return false; - Util::traceError("Database Error: \n" . implode("\n", self::$statements[$query]->errorInfo())); + Util::traceError("Database Error: \n" . self::$lastError); } - return self::$statements[$query]; + if (CONFIG_DEBUG) { + $duration = microtime(true) - $start; + self::$queryTime += $duration; + $duration = round($duration, 3); + if (isset(self::$explainList[$oquery])) { + self::$explainList[$oquery][] = $duration; + } elseif ($duration > 0.1) { + error_log('SLOW ****** ' . $duration . "s *********\n" . $query); + } + self::$queryCount += 1; + } + return $stmt; } catch (Exception $e) { - if ($ignoreError) - return false; - Util::traceError("Database Error: \n" . $e->getMessage()); + self::$lastError = '(' . $e->getCode() . ') ' . $e->getMessage(); + if ($ignoreError === true || ($ignoreError === null && self::$returnErrors)) + return false; + Util::traceError("Database Error: \n" . self::$lastError); + } + return false; + } + + public static function examineLoggedQueries() + { + foreach (self::$explainList as $q => $a) { + self::explainQuery($q, $a); + } + } + + private static function explainQuery(string $query, array $data) + { + $args = array_shift($data); + $slow = false; + $veryslow = false; + foreach ($data as &$ts) { + if ($ts > 0.004) { + $slow = true; + if ($ts > 0.015) { + $ts = "[$ts]"; + $veryslow = true; + } + } + } + if (!$slow) + return; + unset($ts); + $res = self::simpleQuery('EXPLAIN ' . $query, $args, true); + if ($res === false) + return; + $rows = $res->fetchAll(); + if (empty($rows)) + return; + $log = $veryslow; + $lens = array(); + foreach (array_keys($rows[0]) as $key) { + $lens[$key] = strlen($key); + } + foreach ($rows as $row) { + if (!$log && $row['rows'] > 20 && preg_match('/filesort|temporary/i', $row['Extra'])) { + $log = true; + } + foreach ($row as $key => $col) { + $l = strlen($col); + if ($l > $lens[$key]) { + $lens[$key] = $l; + } + } + } + if (!$log) + return; + error_log('Possible slow query: ' . $query); + error_log('Times: ' . implode(', ', $data)); + $border = $head = ''; + foreach ($lens as $key => $len) { + $border .= '+' . str_repeat('-', $len + 2); + $head .= '| ' . str_pad($key, $len) . ' '; + } + $border .= '+'; + $head .= '|'; + error_log("\n" . $border . "\n" . $head . "\n" . $border); + foreach ($rows as $row) { + $line = ''; + foreach ($lens as $key => $len) { + $line .= '| '. str_pad($row[$key], $len) . ' '; + } + error_log($line . "|"); + } + error_log($border); + } + + /** + * Convert nested array argument to multiple arguments. + * If you have: + * $query = 'SELECT * FROM tbl WHERE bcol = :bool AND col IN (:list) + * $args = ( 'bool' => 1, 'list' => ('foo', 'bar') ) + * it results in: + * $query = '...WHERE bcol = :bool AND col IN (:list_0, :list_1) + * $args = ( 'bool' => 1, 'list_0' => 'foo', 'list_1' => 'bar' ) + * + * @param string $query sql query string + * @param array $args query arguments + * @return void + */ + private static function handleArrayArgument(string &$query, array &$args) + { + $again = false; + foreach (array_keys($args) as $key) { + if (is_numeric($key) || $key === '?') + continue; + if (is_array($args[$key])) { + if (empty($args[$key])) { + // Empty list - what to do? We try to generate a query string that will not yield any result + $args[$key] = 'asdf' . mt_rand(0,PHP_INT_MAX) . mt_rand(0,PHP_INT_MAX) + . mt_rand(0,PHP_INT_MAX) . '@' . microtime(true); + continue; + } + $newkey = $key; + if ($newkey[0] !== ':') { + $newkey = ":$newkey"; + } + $new = array(); + foreach ($args[$key] as $subIndex => $sub) { + if (is_array($sub)) { + $new[] = '(' . $newkey . '_' . $subIndex . ')'; + $again = true; + } else { + $new[] = $newkey . '_' . $subIndex; + } + $args[$newkey . '_' . $subIndex] = $sub; + } + unset($args[$key]); + $new = implode(',', $new); + $query = preg_replace('/' . $newkey . '\b/', $new, $query); + } + } + if ($again) { + self::handleArrayArgument($query, $args); } } @@ -106,10 +349,115 @@ class Database * Simply calls PDO::prepare and returns the PDOStatement. * You must call PDOStatement::execute manually on it. */ - public static function prepare($query) + public static function prepare(string $query) { - self:init(); + self::init(); + self::$queryCount += 1; // Cannot know actual count return self::$dbh->prepare($query); } + /** + * Insert row into table, returning the generated key. + * This requires the table to have an AUTO_INCREMENT column and + * usually requires the given $uniqueValues to span across a UNIQUE index. + * The code first tries to SELECT the key for the given values without + * inserting first. This means this function is best used for cases + * where you expect that the entry already exists in the table, so + * only one SELECT will run. For all the entries that do not exist, + * an INSERT or INSERT IGNORE is run, depending on whether $additionalValues + * is empty or not. Another reason we don't run the INSERT (IGNORE) first + * is that it will increase the AUTO_INCREMENT value on InnoDB, even when + * no INSERT took place. So if you expect a lot of collisions you might + * use this function to prevent your A_I value from counting up too + * quickly. + * Other than that, this is just a dumb version of running INSERT and then + * getting the LAST_INSERT_ID(), or doing a query for the existing ID in + * case of a key collision. + * + * @param string $table table to insert into + * @param string $aiKey name of the AUTO_INCREMENT column + * @param array $uniqueValues assoc array containing columnName => value mapping + * @param ?array $additionalValues assoc array containing columnName => value mapping + * @return int AUTO_INCREMENT value matching the given unique values entry + */ + public static function insertIgnore(string $table, string $aiKey, array $uniqueValues, array $additionalValues = null): int + { + // Sanity checks + if (array_key_exists($aiKey, $uniqueValues)) { + Util::traceError("$aiKey must not be in \$uniqueValues"); + } + if (is_array($additionalValues) && array_key_exists($aiKey, $additionalValues)) { + Util::traceError("$aiKey must not be in \$additionalValues"); + } + // Simple SELECT first + $selectSql = 'SELECT ' . $aiKey . ' FROM ' . $table . ' WHERE 1'; + foreach ($uniqueValues as $key => $value) { + $selectSql .= ' AND ' . $key . ' = :' . $key; + } + $selectSql .= ' LIMIT 1'; + $res = self::queryFirst($selectSql, $uniqueValues); + if ($res !== false) { + // Exists + if (!empty($additionalValues)) { + // Simulate ON DUPLICATE KEY UPDATE ... + $updateSql = 'UPDATE ' . $table . ' SET '; + $first = true; + foreach ($additionalValues as $key => $value) { + if ($first) { + $first = false; + } else { + $updateSql .= ', '; + } + $updateSql .= $key . ' = :' . $key; + } + $updateSql .= ' WHERE ' . $aiKey . ' = :' . $aiKey; + $additionalValues[$aiKey] = $res[$aiKey]; + Database::exec($updateSql, $additionalValues); + } + return $res[$aiKey]; + } + // Does not exist: + if (empty($additionalValues)) { + $combined =& $uniqueValues; + } else { + $combined = $uniqueValues + $additionalValues; + } + // Aight, try INSERT or INSERT IGNORE + $insertSql = 'INTO ' . $table . ' (' . implode(', ', array_keys($combined)) + . ') VALUES (:' . implode(', :', array_keys($combined)) . ')'; + if (empty($additionalValues)) { + // Simple INSERT IGNORE + $insertSql = 'INSERT IGNORE ' . $insertSql; + } else { + // INSERT ... ON DUPLICATE (in case we have a race) + $insertSql = 'INSERT ' . $insertSql . ' ON DUPLICATE KEY UPDATE '; + $first = true; + foreach ($additionalValues as $key => $value) { + if ($first) { + $first = false; + } else { + $insertSql .= ', '; + } + $insertSql .= $key . ' = VALUES(' . $key . ')'; + } + } + self::exec($insertSql, $combined); + // Insert done, retrieve key again + $res = self::queryFirst($selectSql, $uniqueValues); + if ($res === false) { + Util::traceError('Could not find value in table ' . $table . ' that was just inserted'); + } + return $res[$aiKey]; + } + + public static function getQueryCount(): int + { + return self::$queryCount; + } + + public static function getQueryTime(): int + { + return self::$queryTime; + } + } diff --git a/inc/image.inc.php b/inc/image.inc.php index 1bad04f..03af811 100644 --- a/inc/image.inc.php +++ b/inc/image.inc.php @@ -3,21 +3,22 @@ class Image { - public static function deleteOwnedBy($userid) + public static function deleteOwnedBy(string $userid): bool { if ($userid === false || !is_numeric($userid)) return false; //return Database::exec('DELETE FROM image WHERE ownerid = :userid', array('userid' => $userid)); // TODO + return false; } - public static function getImageCount($login) + public static function getImageCount(string $login): int { $ret = Database::queryFirst('SELECT Count(*) AS cnt FROM imagebase ' . ' WHERE imagebase.ownerid = :userid', array('userid' => $login)); if ($ret === false) return 0; - return $ret['cnt']; + return (int)$ret['cnt']; } } diff --git a/inc/message.inc.php b/inc/message.inc.php index 7decc12..67f7b08 100644 --- a/inc/message.inc.php +++ b/inc/message.inc.php @@ -2,31 +2,31 @@ class Message { - private static $list = array(); - private static $alreadyDisplayed = array(); - private static $flushed = false; + private static array $list = array(); + private static array $alreadyDisplayed = array(); + private static bool $flushed = false; /** * Add error message to page. If messages have not been flushed * yet, it will be added to the queue, otherwise it will be added * in place during rendering. */ - public static function addError($id) + public static function addError(string $id): void { self::add('error', $id, func_get_args()); } - public static function addWarning($id) + public static function addWarning(string $id): void { self::add('warning', $id, func_get_args()); } - public static function addInfo($id) + public static function addInfo(string $id): void { self::add('info', $id, func_get_args()); } - public static function addSuccess($id) + public static function addSuccess(string $id): void { self::add('success', $id, func_get_args()); } @@ -35,7 +35,7 @@ class Message * Internal function that adds a message. Used by * addError/Success/Info/... above. */ - private static function add($type, $id, $params) + private static function add(string $type, string $id, array $params): void { self::$list[] = array( 'type' => $type, @@ -50,7 +50,7 @@ class Message * After calling this, any further calls to add* will be rendered in * place in the current page output. */ - public static function renderList() + public static function renderList(): void { // Non-Ajax foreach (self::$list as $item) { @@ -69,7 +69,7 @@ class Message * Get all queued messages, flushing the queue. * Useful in api/ajax mode. */ - public static function asString() + public static function asString(): string { $return = ''; foreach (self::$list as $item) { @@ -88,7 +88,7 @@ class Message * Deserialize any messages from the current HTTP request and * place them in the message queue. */ - public static function fromRequest() + public static function fromRequest(): void { $messages = is_array($_REQUEST['message']) ? $_REQUEST['message'] : array($_REQUEST['message']); foreach ($messages as $message) { @@ -102,7 +102,7 @@ class Message * Turn the current message queue into a serialized version, * suitable for appending to a GET or POST request */ - public static function toRequest() + public static function toRequest(): string { $parts = array(); foreach (array_merge(self::$list, self::$alreadyDisplayed) as $item) { diff --git a/inc/render.inc.php b/inc/render.inc.php index 0147709..6a22d3f 100644 --- a/inc/render.inc.php +++ b/inc/render.inc.php @@ -14,7 +14,7 @@ Render::init(); class Render { - private static $mustache = false; + private static ?Mustache_Engine $mustache = null; private static $body = ''; private static $header = ''; private static $footer = ''; @@ -24,7 +24,7 @@ class Render public static function init() { - if (self::$mustache !== false) + if (self::$mustache !== null) Util::traceError('Called Render::init() twice!'); self::$mustache = new Mustache_Engine; } @@ -32,15 +32,13 @@ class Render /** * Output the buffered, generated page */ - public static function output() + public static function output(): void { Header('Content-Type: text/html; charset=utf-8'); - $zip = isset($_SERVER['HTTP_ACCEPT_ENCODING']) && (strpos($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip') !== false); - if ($zip) - ob_start(); + ob_start('ob_gzhandler'); echo '<!DOCTYPE html> - <html> + <html lang="de"> <head> <title>', RENDER_DEFAULT_TITLE, self::$title, '</title> <meta charset="utf-8"> @@ -69,14 +67,7 @@ class Render '</body> </html>' ; - if ($zip) { - Header('Content-Encoding: gzip'); - ob_implicit_flush(false); - $gzip_contents = ob_get_contents(); - ob_end_clean(); - echo "\x1f\x8b\x08\x00\x00\x00\x00\x00"; - echo substr(gzcompress($gzip_contents, 5), 0, -4); - } + ob_end_flush(); } /** diff --git a/inc/session.inc.php b/inc/session.inc.php index 17e3184..f89d87e 100644 --- a/inc/session.inc.php +++ b/inc/session.inc.php @@ -3,13 +3,13 @@ class Session { - private static $sid = false; - private static $data = false; - private static $needUpdate = true; + private static ?string $sid = null; + private static ?array $data = null; + private static bool $needUpdate = true; - private static function generateSessionId() + private static function generateSessionId(): void { - if (self::$sid !== false) + if (self::$sid !== null) Util::traceError('Error: Asked to generate session id when already set.'); self::$sid = sha1( mt_rand(0, 65535) @@ -24,13 +24,13 @@ class Session ); } - public static function create() + public static function create(): void { self::generateSessionId(); self::$data = array(); } - public static function load() + public static function load(): bool { // Try to load session id from cookie if (!self::loadSessionId()) return false; @@ -46,7 +46,7 @@ class Session return self::get('uid'); } - public static function setUid($value) + public static function setUid($value): void { if (strlen($value) < 5) Util::traceError('Invalid user id: ' . $value); @@ -60,7 +60,7 @@ class Session return false; } - public static function set($key, $value) + public static function set(string $key, $value): void { if (!is_array(self::$data)) Util::traceError('Tried to set session data with no active session'); @@ -70,9 +70,9 @@ class Session self::$needUpdate = true; } - private static function loadSessionId() + private static function loadSessionId(): bool { - if (self::$sid !== false) + if (self::$sid !== null) Util::traceError('Error: Asked to load session id when already set.'); if (empty($_COOKIE['sid'])) return false; @@ -83,18 +83,18 @@ class Session return true; } - public static function delete() + public static function delete(): void { - if (self::$sid === false) return; + if (self::$sid === null) return; Database::exec('DELETE FROM websession WHERE sid = :sid', array('sid' => self::$sid)); setcookie('sid', '', time() - CONFIG_SESSION_TIMEOUT, null, null, !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off', true); - self::$sid = false; - self::$data = false; + self::$sid = null; + self::$data = null; } - public static function save() + public static function save(): void { - if (self::$sid === false || self::$data === false || !self::$needUpdate) + if (self::$sid === null || self::$data === null || !self::$needUpdate) return; $data = json_encode(self::$data); $ret = Database::exec('INSERT INTO websession (sid, dateline, data) ' @@ -108,9 +108,9 @@ class Session Util::traceError('Error: Could not set Cookie for Client (headers already sent)'); } - public static function readSessionData() + public static function readSessionData(): bool { - if (self::$sid === false || self::$data !== false) + if (self::$sid === null || self::$data !== null) Util::traceError('Tried to readSessionData on an active session!'); $data = Database::queryFirst('SELECT dateline, data FROM websession WHERE sid = :sid LIMIT 1', array('sid' => self::$sid)); if ($data === false) { @@ -121,9 +121,8 @@ class Session return false; } self::$needUpdate = ($data['dateline'] + 3600 < time()); - self::$data = @json_decode($data['data'], true); - if (!is_array(self::$data)) - self::$data = array(); + $data = @json_decode($data['data'], true); + self::$data = is_array($data) ? $data : []; return true; } diff --git a/inc/shibauth.inc.php b/inc/shibauth.inc.php new file mode 100644 index 0000000..6ae3a89 --- /dev/null +++ b/inc/shibauth.inc.php @@ -0,0 +1,202 @@ +<?php + +class ShibAuth +{ + + /** + * Log user into master-server using the data provided by the current shibboleth session + * @param ?string $accessCode optional one-time access code to retreive session data via thrift + * @return array{status: string, firstName: string, lastName: string, mail: string, token: string, sessionId: string, userId: string, organizationId: string, url: string, error: string} + */ + private static function loginInternal(?string $accessCode = null): array + { + if ($accessCode !== null) { + $entrop = strlen(count_chars($accessCode, 3)); + if (strlen($accessCode) < 32 || strlen($accessCode) > 64 || $entrop < 10) { + return ['status' => 'error', 'error' => 'Malformed accessCode']; + } + } + // Handle + if (empty($_SERVER['persistent-id'])) { + // No persistent id given, should not happen! + file_put_contents('/tmp/shib-nopid-' . time() . '-' . $_SERVER['REMOTE_ADDR'] . '.txt', print_r($_SERVER, true)); + return ['status' => 'error', 'error' => 'Shibboleth metadata missing!']; + } + // Query database for user + // First, use persistent-id as-is + $shibId = [ md5($_SERVER['persistent-id']) ]; + // ... but we might have multiple persistent IDs, split + if (strpos($_SERVER['persistent-id'], ';') !== false) { + foreach (explode(';', $_SERVER['persistent-id']) as $s) { + if (empty($s)) + continue; + $shibId[] = md5($s); + } + } + // Figure out role + if (strpos(";{$_SERVER['entitlement']};", CONFIG_ENTITLEMENT) !== false) { + $role = 'TUTOR'; + } else if (strpos(";{$_SERVER[CONFIG_SCOPED_AFFILIATION]};", ';employee@') !== false + || strpos(";{$_SERVER[CONFIG_SCOPED_AFFILIATION]};", ';staff@') !== false + || strpos(";{$_SERVER[CONFIG_SCOPED_AFFILIATION]};", ';faculty@') !== false) { + $role = 'TUTOR'; + } else { + file_put_contents('/tmp/shib-student-' . time() . '-' . $_SERVER['REMOTE_ADDR'] . '.txt', print_r($_SERVER, true)); + $role = 'STUDENT'; + // NEW: Ignore students for now + return [ + 'status' => 'error', + 'error' => "Sie wurden als Student eingestuft und können sich daher nicht an der " . CONFIG_SUITE . "-Suite anmelden." + . "\nFalls Ihr Nutzerkonto kein Studentenkonto ist stellen Sie sicher, dass Ihr IdP für berechtigte" + . "\nAccounts entweder das " . CONFIG_SUITE . "-Entitlement ausliefert, oder das Attribut " . CONFIG_SCOPED_AFFILIATION + . "\nausgeliefert wird, und es entweder 'employee@..', 'staff@..' oder 'faculty@..' enthält." + . "\n\nMehr Informationen finden Sie unter " . CONFIG_HELPURL + ]; + // end IGNORE STUDENTS + } + // Now we have an array of all persistent-ids, plus the raw string; see if any of those match + $user = Database::queryFirst("SELECT user.userid, user.organizationid, user.firstname, user.lastname, user.email " + . " FROM user " + . " INNER JOIN organization USING (organizationid) " + . " WHERE user.shibid IN (:shibid) LIMIT 1", array('shibid' => $shibId)); + if ($user === false) { + // Not found, so we don't know which satellite to use + if ($role === 'STUDENT') { + $response['status'] = 'ok'; + $response['firstName'] = $_SERVER['givenName'] ?? 'Anonymous'; + $response['lastName'] = $_SERVER[CONFIG_SURNAME] ?? 'Student'; + $response['mail'] = $_SERVER['mail'] ?? 'void@none.invalid'; + $response['userId'] = $shibId; + // Try to figure out orgId + if (!isset($response['organizationId']) && isset($_SERVER[CONFIG_EPPN])) { + if (preg_match('/@(.+)$/', $_SERVER[CONFIG_EPPN], $out)) { + $out = Database::queryFirst("SELECT organizationid FROM organization_suffix WHERE suffix = :suffix", [ + 'suffix' => $out[1] + ]); + if ($out !== false) { + $response['organizationId'] = $out['organizationid']; + } + } + } + if (!isset($response['organizationId']) && isset($_SERVER[CONFIG_SCOPED_AFFILIATION])) { + if (preg_match('/(^|;)[^@]+@([^;]+)/', $_SERVER[CONFIG_SCOPED_AFFILIATION], $out)) { + $out = Database::queryFirst("SELECT organizationid FROM organization_suffix WHERE suffix = :suffix", [ + 'suffix' => $out[2] + ]); + if ($out !== false) { + $response['organizationId'] = $out['organizationid']; + } + } + } + // This one we send to the running master server handler + $rpc = $response; + $rpc['role'] = $role; + if (isset($response['organizationId']) && $accessCode === null) { + $response['satellites2'] = self::getSatelliteList($response['organizationId']); + } + } else { + $response['status'] = 'unregistered'; + $response['error'] = 'Sie müssen sich erst für die Nutzung von ' . CONFIG_SUITE . ' registrieren'; + } + $response['id'] = $shibId; + $response['url'] = CONFIG_MASTERWEBIF; + file_put_contents('/tmp/shib-unreg-' . time() . '-' . $_SERVER['REMOTE_ADDR'] . '.txt', print_r($_SERVER, true)); + } else { + /* + if (in_array($shibId, unserialize(CONFIG_ADMINS), true) || $shibId === '2fa5c3e020a5aca0cbf9a562268d5173-') { + $role = 'STUDENT'; + } + */ + // Found, see if we got personal information, either temporarily through metadata, or from database + $firstName = $user['firstname']; + $lastName = $user['lastname']; + $mail = $user['email']; + if (empty($firstName) && isset($_SERVER['givenName'])) + $firstName = trim($_SERVER['givenName']); + if (empty($lastName) && isset($_SERVER[CONFIG_SURNAME])) + $lastName = trim($_SERVER[CONFIG_SURNAME]); + if (empty($mail) && isset($_SERVER['mail'])) + $mail = trim($_SERVER['mail']); + // + $login = (empty($user['userid']) ? $shibId : $user['userid'] ); + if (empty($firstName) || empty($lastName) || empty($login)) { + // This means the user did not provide personal information on signup, nor does the IdP send them + $response['status'] = 'anonymous'; + $response['error'] = "Ihr IdP liefert nicht die erforderlichen Attribute\n" + . CONFIG_SURNAME . ', ' . 'givenName' . ', ' . 'email'; + } else { + // Seems ok! + // + $response['status'] = 'ok'; + $response['firstName'] = $firstName; + $response['lastName'] = $lastName; + $response['mail'] = $mail; + $response['userId'] = $user['userid']; + $response['organizationId'] = $user['organizationid']; + // This one we send to the running master server handler + $rpc = $response; + $rpc['userId'] = $login; + $rpc['role'] = $role; + // This one we only send to the user + $response['satellites2'] = self::getSatelliteList($user['organizationid']); + } + } + + if (isset($rpc)) { + if ($accessCode !== null) { + $rpc['accessCode'] = $accessCode; + } + $reply = RPC::submit($rpc); + if (preg_match('/^TOKEN:(\w+) SESSIONID:(\w+)$/', $reply, $out)) { + // For talking to the sat server, also referred to as userToken in Java + $response['token'] = $out[1]; + // For talking to the master server - not known by satellite server, referred to as masterToken in Java + $response['sessionId'] = $out[2]; + } else { + if (empty($rpc['mail'])) { + $reply .= ' (No email given)'; + } + if (empty($rpc['firstName'])) { + $reply .= ' (No first name given)'; + } + if (empty($rpc['lastName'])) { + $reply .= ' (No last name given)'; + } + if (empty($rpc['organizationId'])) { + $reply .= ' (No organization id found)'; + } + $response['error'] = $reply; + $response['status'] = 'error'; + } + } + return $response; + } + + public static function login(?string $accessCode = null): array + { + $res = self::loginInternal($accessCode); + if ($res['status'] !== 'ok' && isset($res['error']) && $accessCode !== null) { + RPC::submit(['status' => 'error', 'error' => $res['error'], 'accessCode' => $accessCode]); + } + return $res; + } + + private static function getSatelliteList($orgId) + { + // Determine satellite(s) + $res = Database::simpleQuery("SELECT satellitename, addresses, certsha256 FROM satellite" + . " WHERE organizationid = :organizationid AND userid IS NULL", array('organizationid' => $orgId)); + $sat2 = array(); + while ($row = $res->fetch(PDO::FETCH_ASSOC)) { + $addrs = json_decode($row['addresses'], true); + if (!is_array($addrs) || empty($addrs)) + continue; + $sat2[$row['satellitename']] = array( + 'addresses' => $addrs, + 'certHash' => $row['certsha256'] + ); + } + return $sat2; + } + +}
\ No newline at end of file diff --git a/inc/user.inc.php b/inc/user.inc.php index 6e3c06d..bc07f5d 100644 --- a/inc/user.inc.php +++ b/inc/user.inc.php @@ -3,15 +3,15 @@ class User { - private static $user = false; - private static $organization = NULL; - private static $isShib = false; - private static $isInDb = false; - private static $isAnonymous = false; + private static ?array $user = null; + private static ?array $organization = NULL; + private static bool $isShib = false; + private static bool $isInDb = false; + private static bool $isAnonymous = false; - public static function isLoggedIn() + public static function isLoggedIn(): bool { - return self::$user !== false; + return self::$user !== null; } public static function isShibbolethAuth() @@ -26,7 +26,7 @@ class User public static function isLocalOnly() { - return self::$user !== false && self::$isShib === false; + return self::$user !== null && self::$isShib === false; } public static function isAnonymous() @@ -39,44 +39,44 @@ class User return self::$user; } - public static function getId() + public static function getId(): ?string { if (!isset(self::$user['userid'])) - return false; + return null; return self::$user['userid']; } - public static function getMail() + public static function getMail(): ?string { if (!isset(self::$user['email'])) - return false; + return null; return self::$user['email']; } - public static function getName() + public static function getName(): ?string { if (!self::isLoggedIn()) - return false; + return null; return self::$user['firstname'] . ' ' . self::$user['lastname']; } - public static function getFirstName() + public static function getFirstName(): ?string { if (!self::isLoggedIn()) - return false; + return null; return self::$user['firstname']; } - public static function getLastName() + public static function getLastName(): ?string { if (!self::isLoggedIn()) - return false; + return null; return self::$user['lastname']; } - public static function hasFullName() + public static function hasFullName(): bool { - return self::$user !== false && !empty(self::$user['firstname']) && !empty(self::$user['lastname']); + return self::$user !== null && !empty(self::$user['firstname']) && !empty(self::$user['lastname']); } public static function isTutor() @@ -84,7 +84,7 @@ class User return isset(self::$user['role']) && self::$user['role'] === 'TUTOR'; } - public static function isAdmin() + public static function isAdmin(): bool { // TODO: per Institution... return in_array(self::getShibId(), unserialize(CONFIG_ADMINS), true); @@ -95,19 +95,19 @@ class User * * @return string */ - public static function getOrganizationId() + public static function getOrganizationId(): ?string { $org = self::getOrganization(); if (!isset($org['organizationid'])) - return false; + return null; return $org['organizationid']; } - public static function getOrganizationName() + public static function getOrganizationName(): ?string { $org = self::getOrganization(); if (!isset($org['name'])) - return false; + return null; return $org['name']; } @@ -116,21 +116,26 @@ class User * * @return string */ - public static function getRemoteOrganizationId() + public static function getRemoteOrganizationId(): ?string { if (empty(self::$user['organization'])) - return false; + return null; return self::$user['organization']; } - public static function getOrganization() + /** + * Return user's organization, or null if not known in our DB. + * @return ?array{organizationid: string, name: string} + */ + public static function getOrganization(): ?array { if (!self::isLoggedIn()) - return false; + return null; if (is_null(self::$organization)) { - self::$organization = Database::queryFirst('SELECT organizationid, name FROM organization_suffix ' + $org = Database::queryFirst('SELECT organizationid, name FROM organization_suffix ' . ' INNER JOIN organization USING (organizationid) ' . ' WHERE suffix = :org LIMIT 1', array('org' => self::$user['organization'])); + self::$organization = $org !== false ? $org : null; } return self::$organization; } @@ -159,8 +164,10 @@ class User return false; } // Try user from local DB - self::$user = Database::queryFirst('SELECT userid, shibid, organizationid AS organization, firstname, lastname, email FROM user WHERE userid = :uid LIMIT 1', array('uid' => Session::getUid())); - self::$isInDb = self::$user !== false; + $usr = Database::queryFirst('SELECT userid, shibid, organizationid AS organization, firstname, lastname, email + FROM user WHERE userid = :uid LIMIT 1', ['uid' => Session::getUid()]); + self::$user = $usr !== false ? $usr : null; + self::$isInDb = self::$user !== null; if (!self::$isInDb) { Session::delete(); } @@ -181,38 +188,45 @@ class User Util::redirect('?do=Main&force-cookie=true.dat'); } self::$isShib = true; - if (!isset($_SERVER['sn'])) - $_SERVER['sn'] = ''; + if (!isset($_SERVER[CONFIG_SURNAME])) + $_SERVER[CONFIG_SURNAME] = ''; if (!isset($_SERVER['givenName'])) $_SERVER['givenName'] = ''; if (!isset($_SERVER['mail'])) $_SERVER['mail'] = ''; - $shibId = md5($_SERVER['persistent-id']); + $shibId = []; + if (strpos($_SERVER['persistent-id'], ';') !== false) { + foreach (explode(';', $_SERVER['persistent-id']) as $s) { + $shibId[] = md5($s); + } + } + $shibId[] = md5($_SERVER['persistent-id']); self::$user = array( 'userid' => NULL, - 'shibid' => $shibId, + 'shibid' => $shibId[0], 'firstname' => $_SERVER['givenName'], - 'lastname' => $_SERVER['sn'], + 'lastname' => $_SERVER[CONFIG_SURNAME], 'email' => $_SERVER['mail'], ); // Figure out whether the user should be considered a tutor - if (isset($_SERVER['affiliation']) && (strpos(";{$_SERVER['affiliation']}", ';employee@') !== false - || strpos(";{$_SERVER['affiliation']}", ';staff@') !== false - || strpos(";{$_SERVER['affiliation']}", ';faculty@') !== false)) + if (isset($_SERVER[CONFIG_SCOPED_AFFILIATION]) && (strpos(";{$_SERVER[CONFIG_SCOPED_AFFILIATION]}", ';employee@') !== false + || strpos(";{$_SERVER[CONFIG_SCOPED_AFFILIATION]}", ';staff@') !== false + || strpos(";{$_SERVER[CONFIG_SCOPED_AFFILIATION]}", ';faculty@') !== false)) self::$user['role'] = 'TUTOR'; elseif (isset($_SERVER['entitlement']) && strpos(";{$_SERVER['entitlement']};", CONFIG_ENTITLEMENT) !== false) self::$user['role'] = 'TUTOR'; else self::$user['role'] = 'STUDENT'; // Try to figure out organization - if (isset($_SERVER['eppn']) && preg_match('/@([0-9a-zA-Z\-\._]+)$/', $_SERVER['eppn'], $out)) { + if (isset($_SERVER[CONFIG_EPPN]) && preg_match('/@([0-9a-zA-Z\-._]+)$/', $_SERVER[CONFIG_EPPN], $out)) { self::$user['organization'] = $out[1]; } - if (!isset(self::$user['organization']) && isset($_SERVER['affiliation']) && preg_match('/@([0-9a-zA-Z\-\._]+)(;|$)/', $_SERVER['affiliation'], $out)) { + if (!isset(self::$user['organization']) && isset($_SERVER[CONFIG_SCOPED_AFFILIATION]) && preg_match('/@([0-9a-zA-Z\-._]+)(;|$)/', $_SERVER[CONFIG_SCOPED_AFFILIATION], $out)) { self::$user['organization'] = $out[1]; } // Get matching db entry if any - $user = Database::queryFirst('SELECT userid, firstname, lastname, email, fixedname FROM user WHERE shibid = :shibid LIMIT 1', array('shibid' => $shibId)); + $user = Database::queryFirst('SELECT userid, firstname, lastname, email, fixedname FROM user + WHERE shibid IN (:shibid) LIMIT 1', ['shibid' => $shibId]); if ($user === false) { // No match in database, user is not signed up return true; @@ -232,11 +246,16 @@ class User return true; } - public static function deploy($anonymous, $existingLogin = false) + public static function deploy(bool $anonymous, $existingLogin = false): bool { if (empty(self::$user['shibid'])) Util::traceError('NO SHIBID'); + if (self::getOrganizationId() === null) { + Message::addError('Your home organization ID {{0}} is not known to this server', self::getRemoteOrganizationId()); + Util::redirect('?do=Main'); + } + // Merging with test-account: if (!empty($existingLogin)) { if ($anonymous) { @@ -300,7 +319,7 @@ class User 'mail' => $mail, 'user' => self::getId() )); - return $ret == 1 || $mail === self::get('email'); + return $ret == 1 || $mail === self::$user['email']; } public static function login($user, $pass) diff --git a/inc/util.inc.php b/inc/util.inc.php index aaf46c6..0e06e6a 100644 --- a/inc/util.inc.php +++ b/inc/util.inc.php @@ -59,7 +59,7 @@ SADFACE; public static function redirect($location = false) { if ($location === false) { - $location = preg_replace('/(&|\?)message\[\]\=[^&]*/', '\1', $_SERVER['REQUEST_URI']); + $location = preg_replace('/([&?])message\[]=[^&]*/', '\1', $_SERVER['REQUEST_URI']); } Session::save(); $messages = Message::toRequest(); @@ -113,9 +113,9 @@ SADFACE; public static function markup($string) { $string = htmlspecialchars($string); - $string = preg_replace('#(^|[\n \-_/\.])\*(.+?)\*($|[ \-_/\.\!\?,:])#is', '$1<b>$2</b>$3', $string); - $string = preg_replace('#(^|[\n \-\*/\.])_(.+?)_($|[ \-\*/\.\!\?,:])#is', '$1<u>$2</u>$3', $string); - $string = preg_replace('#(^|[\n \-_\*\.])/(.+?)/($|[ \-_\*\.\!\?,:])#is', '$1<i>$2</i>$3', $string); + $string = preg_replace('#(^|[\n \-_/.])\*(.+?)\*($|[ \-_/.!?,:])#is', '$1<b>$2</b>$3', $string); + $string = preg_replace('#(^|[\n \-*/.])_(.+?)_($|[ \-*/.!?,:])#is', '$1<u>$2</u>$3', $string); + $string = preg_replace('#(^|[\n \-_*.])/(.+?)/($|[ \-_*.!?,:])#is', '$1<i>$2</i>$3', $string); return nl2br($string); } @@ -123,11 +123,11 @@ SADFACE; * Convert given number to human readable file size string. * Will append Bytes, KiB, etc. depending on magnitude of number. * - * @param type $bytes numeric value of the filesize to make readable - * @param type $decimals number of decimals to show, -1 for automatic - * @return type human readable string representing the given filesize + * @param int $bytes numeric value of the filesize to make readable + * @param int $decimals number of decimals to show, -1 for automatic + * @return string human-readable string representing the given filesize */ - public static function readableFileSize($bytes, $decimals = -1) + public static function readableFileSize(int $bytes, int $decimals = -1): string { static $sz = array('Byte', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'); $factor = floor((strlen($bytes) - 1) / 3); @@ -139,20 +139,20 @@ SADFACE; return sprintf("%.{$decimals}f ", $bytes / pow(1024, $factor)) . $sz[$factor]; } - public static function sanitizeFilename($name) + public static function sanitizeFilename(string $name): string { return preg_replace('/[^a-zA-Z0-9_\-]+/', '_', $name); } - public static function safePath($path, $prefix = '') + public static function safePath(string $path, string $prefix = ''): ?string { if (empty($path)) - return false; + return null; $path = trim($path); - if ($path{0} == '/' || preg_match('/[\x00-\x19\?\*]/', $path)) - return false; + if ($path[0] == '/' || preg_match('/[\x00-\x19?*]/', $path)) + return null; if (strpos($path, '..') !== false) - return false; + return null; if (substr($path, 0, 2) !== './') $path = "./$path"; if (empty($prefix)) @@ -160,7 +160,7 @@ SADFACE; if (substr($prefix, 0, 2) !== './') $prefix = "./$prefix"; if (substr($path, 0, strlen($prefix)) !== $prefix) - return false; + return null; return $path; } @@ -249,14 +249,14 @@ SADFACE; /** * Send a file to user for download. * - * @param type $file path of local file - * @param type $name name of file to send to user agent - * @param type $delete delete the file when done? - * @return boolean false: file could not be opened. + * @param string $file path of local file + * @param string $name name of file to send to user agent + * @param bool $delete delete the file when done? + * @return bool false: file could not be opened. * true: error while reading the file * - on success, the function does not return */ - public static function sendFile($file, $name, $delete = false) + public static function sendFile(string $file, string $name, bool $delete = false): bool { while ((@ob_get_level()) > 0) @ob_end_clean(); |