UserToken / UserAPI

This commit is contained in:
Roman 2022-11-19 01:15:34 +01:00
parent f6bae08c05
commit b5b8f9b856
21 changed files with 496 additions and 613 deletions

@ -32,6 +32,7 @@ namespace Core\API {
$connectionData->setProperty("from", $settings["mail_from"] ?? "");
$connectionData->setProperty("last_sync", $settings["mail_last_sync"] ?? "");
$connectionData->setProperty("mail_footer", $settings["mail_footer"] ?? "");
$connectionData->setProperty("mail_async", $settings["mail_async"] ?? false);
return $connectionData;
}
@ -89,7 +90,7 @@ namespace Core\API\Mail {
'replyTo' => new Parameter('replyTo', Parameter::TYPE_EMAIL, true, null),
'replyName' => new StringType('replyName', 32, true, ""),
'gpgFingerprint' => new StringType("gpgFingerprint", 64, true, null),
'async' => new Parameter("async", Parameter::TYPE_BOOLEAN, true, true)
'async' => new Parameter("async", Parameter::TYPE_BOOLEAN, true, null)
));
$this->isPublic = false;
}
@ -110,7 +111,13 @@ namespace Core\API\Mail {
$body = $this->getParam('body');
$gpgFingerprint = $this->getParam("gpgFingerprint");
if ($this->getParam("async")) {
$mailAsync = $this->getParam("async");
if ($mailAsync === null) {
// not set? grab from settings
$mailAsync = $mailConfig->getProperty("mail_async", false);
}
if ($mailAsync) {
$sql = $this->context->getSQL();
$this->success = $sql->insert("MailQueue", ["from", "to", "subject", "body",
"replyTo", "replyName", "gpgFingerprint"])

@ -223,7 +223,7 @@ abstract class Request {
}
// Check for permission
if (!($this instanceof \API\Permission\Save)) {
if (!($this instanceof \Core\API\Permission\Save)) {
$req = new \Core\API\Permission\Check($this->context);
$this->success = $req->execute(array("method" => $this->getMethod()));
$this->lastError = $req->getLastError();
@ -242,8 +242,8 @@ abstract class Request {
}
$sql = $this->context->getSQL();
if (!$sql->isConnected()) {
$this->lastError = $sql->getLastError();
if ($sql === null || !$sql->isConnected()) {
$this->lastError = $sql ? $sql->getLastError() : "Database not connected yet.";
return false;
}
@ -265,8 +265,8 @@ abstract class Request {
return false;
}
protected function getParam($name, $obj = NULL) {
// i don't know why phpstorm
protected function getParam($name, $obj = NULL): mixed {
// I don't know why phpstorm
if ($obj === NULL) {
$obj = $this->params;
}

@ -45,16 +45,23 @@ namespace Core\API\Template {
return $this->createError("Invalid template file extension. Allowed: " . implode(",", $allowedExtensions));
}
$templateDir = WEBROOT . "/Core/Templates/";
$templateCache = WEBROOT . "/Core/Cache/Templates/";
$path = realpath($templateDir . $templateFile);
if (!startsWith($path, realpath($templateDir))) {
return $this->createError("Template file not in template directory");
} else if (!is_file($path)) {
return $this->createError("Template file not found");
$baseDirs = ["Site", "Core"];
$valid = false;
foreach ($baseDirs as $baseDir) {
$path = realpath(implode("/", [WEBROOT, $baseDir, "Templates", $templateFile]));
if ($path && is_file($path)) {
$valid = true;
break;
}
}
$twigLoader = new FilesystemLoader($templateDir);
if (!$valid) {
return $this->createError("Template file not found or not inside template directory");
}
$twigLoader = new FilesystemLoader(dirname($path));
$twigEnvironment = new Environment($twigLoader, [
'cache' => $templateCache,
'auto_reload' => true

@ -123,10 +123,11 @@ namespace Core\API\TFA {
if ($this->success) {
$body = $req->getResult()["html"];
$gpg = $currentUser->getGPG();
$siteName = $settings->getSiteName();
$req = new \Core\API\Mail\Send($this->context);
$this->success = $req->execute([
"to" => $currentUser->getEmail(),
"subject" => "[Security Lab] 2FA-Authentication removed",
"subject" => "[$siteName] 2FA-Authentication removed",
"body" => $body,
"gpgFingerprint" => $gpg?->getFingerprint()
]);

File diff suppressed because it is too large Load Diff

@ -19,14 +19,6 @@ class CreateDatabase extends DatabaseScript {
->addRow("en_US", 'American English')
->addRow("de_DE", 'Deutsch Standard');
$queries[] = $sql->createTable("UserToken")
->addInt("user_id")
->addString("token", 36)
->addEnum("token_type", array("password_reset", "email_confirm", "invite", "gpg_confirm"))
->addDateTime("valid_until")
->addBool("used", false)
->foreignKey("user_id", "User", "id", new CascadeStrategy());
$queries[] = $sql->insert("Group", array("name", "color"))
->addRow(USER_GROUP_MODERATOR_NAME, "#007bff")
->addRow(USER_GROUP_SUPPORT_NAME, "#28a745")

@ -15,20 +15,28 @@ class Settings {
//
private bool $installationComplete;
// settings
// general settings
private string $siteName;
private string $baseUrl;
private bool $registrationAllowed;
private array $allowedExtensions;
private string $timeZone;
// jwt
private ?string $jwtPublicKey;
private ?string $jwtSecretKey;
private string $jwtAlgorithm;
private bool $registrationAllowed;
// recaptcha
private bool $recaptchaEnabled;
private bool $mailEnabled;
private string $recaptchaPublicKey;
private string $recaptchaPrivateKey;
// mail
private bool $mailEnabled;
private string $mailSender;
private string $mailFooter;
private array $allowedExtensions;
private bool $mailAsync;
//
private Logger $logger;
@ -55,7 +63,11 @@ class Settings {
}
public static function loadDefaults(): Settings {
$hostname = $_SERVER["SERVER_NAME"] ?? "localhost";
$hostname = $_SERVER["SERVER_NAME"];
if (empty($hostname)) {
$hostname = "localhost";
}
$protocol = getProtocol();
$settings = new Settings();
@ -65,6 +77,7 @@ class Settings {
$settings->allowedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'htm', 'html'];
$settings->installationComplete = false;
$settings->registrationAllowed = false;
$settings->timeZone = date_default_timezone_get();
// JWT
$settings->jwtSecretKey = null;
@ -80,7 +93,7 @@ class Settings {
$settings->mailEnabled = false;
$settings->mailSender = "webmaster@localhost";
$settings->mailFooter = "";
$settings->mailAsync = false;
return $settings;
}
@ -118,7 +131,7 @@ class Settings {
return in_array(strtoupper($algorithm), ["HS256", "HS384", "HS512", "RS256", "RS384", "RS512", "EDDSA"]);
}
public function saveJwtKey(Context $context) {
public function saveJwtKey(Context $context): \Core\API\Settings\Set {
$req = new \Core\API\Settings\Set($context);
$req->execute(array("settings" => array(
"jwt_secret_key" => $this->jwtSecretKey,
@ -140,6 +153,7 @@ class Settings {
$this->baseUrl = $result["base_url"] ?? $this->baseUrl;
$this->registrationAllowed = $result["user_registration_enabled"] ?? $this->registrationAllowed;
$this->installationComplete = $result["installation_completed"] ?? $this->installationComplete;
$this->timeZone = $result["time_zone"] ?? $this->timeZone;
$this->jwtSecretKey = $result["jwt_secret_key"] ?? $this->jwtSecretKey;
$this->jwtPublicKey = $result["jwt_public_key"] ?? $this->jwtPublicKey;
$this->jwtAlgorithm = $result["jwt_algorithm"] ?? $this->jwtAlgorithm;
@ -149,6 +163,7 @@ class Settings {
$this->mailEnabled = $result["mail_enabled"] ?? $this->mailEnabled;
$this->mailSender = $result["mail_from"] ?? $this->mailSender;
$this->mailFooter = $result["mail_footer"] ?? $this->mailFooter;
$this->mailAsync = $result["mail_async"] ?? $this->mailAsync;
$this->allowedExtensions = explode(",", $result["allowed_extensions"] ?? strtolower(implode(",", $this->allowedExtensions)));
if (!isset($result["jwt_secret_key"])) {
@ -156,16 +171,19 @@ class Settings {
$this->saveJwtKey($context);
}
}
date_default_timezone_set($this->timeZone);
}
return false;
}
public function addRows(Insert $query) {
public function addRows(Insert $query): void {
$query->addRow("site_name", $this->siteName, false, false)
->addRow("base_url", $this->baseUrl, false, false)
->addRow("user_registration_enabled", $this->registrationAllowed ? "1" : "0", false, false)
->addRow("installation_completed", $this->installationComplete ? "1" : "0", true, true)
->addRow("time_zone", $this->timeZone, false, false)
->addRow("jwt_secret_key", $this->jwtSecretKey, true, false)
->addRow("jwt_public_key", $this->jwtPublicKey, false, false)
->addRow("jwt_algorithm", $this->jwtAlgorithm, false, false)
@ -179,6 +197,14 @@ class Settings {
return $this->siteName;
}
public function getTimeZone(): string {
return $this->timeZone;
}
public function setTimeZone(string $tz) {
$this->timeZone = $tz;
}
public function getBaseUrl(): string {
return $this->baseUrl;
}
@ -203,6 +229,10 @@ class Settings {
return $this->mailEnabled;
}
public function isMailAsync(): bool {
return $this->mailAsync;
}
public function getMailSender(): string {
return $this->mailSender;
}

@ -235,7 +235,7 @@ namespace Documents\Install {
$username = posix_getpwuid($userId)['name'];
$failedRequirements[] = sprintf("<b>%s</b> is not owned by current user: $username ($userId). " .
"Try running <b>chown -R $userId %s</b> or give the required directories write permissions: " .
"<b>core/Configuration</b>, <b>core/Cache</b>, <b>core/External</b>",
"<b>Site/Configuration</b>, <b>Core/Cache</b>, <b>Core/External</b>",
WEBROOT, WEBROOT);
$success = false;
}

@ -72,7 +72,7 @@ class Logger {
$module = preg_replace("/[^a-zA-Z0-9-]/", "-", $this->module);
$date = (\DateTime::createFromFormat('U.u', microtime(true)))->format(self::LOG_FILE_DATE_FORMAT);
$logFile = implode("_", [$module, $severity, $date]) . ".log";
$logPath = implode(DIRECTORY_SEPARATOR, [WEBROOT, "core", "Logs", $logFile]);
$logPath = implode(DIRECTORY_SEPARATOR, [WEBROOT, "Core", "Logs", $logFile]);
@file_put_contents($logPath, $message);
}

@ -31,7 +31,7 @@ class TemplateDocument extends Document {
$this->parameters = $params;
$this->twigLoader = new FilesystemLoader(self::TEMPLATE_PATH);
$this->twigEnvironment = new Environment($this->twigLoader, [
'cache' => WEBROOT . '/core/Cache/Templates/',
'cache' => WEBROOT . '/Core/Cache/Templates/',
'auto_reload' => true
]);
$this->twigEnvironment->addExtension(new CustomTwigFunctions());

@ -7,6 +7,7 @@ use Core\Configuration\Settings;
use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\CondLike;
use Core\Driver\SQL\Condition\CondOr;
use Core\Driver\SQL\Join;
use Core\Driver\SQL\SQL;
use Firebase\JWT\JWT;
use Core\Objects\DatabaseEntity\Language;
@ -92,6 +93,9 @@ class Context {
private function loadSession(int $userId, int $sessionId) {
$this->session = Session::init($this, $userId, $sessionId);
$this->user = $this->session?->getUser();
if ($this->user) {
$this->user->session = $this->session;
}
}
public function parseCookies() {
@ -173,7 +177,7 @@ class Context {
public function loadApiKey(string $apiKey): bool {
$this->user = User::findBuilder($this->sql)
->addJoin(new \Driver\SQL\Join("INNER","ApiKey", "ApiKey.user_id", "User.id"))
->addJoin(new Join("INNER","ApiKey", "ApiKey.user_id", "User.id"))
->where(new Compare("ApiKey.api_key", $apiKey))
->where(new Compare("valid_until", $this->sql->currentTimestamp(), ">"))
->where(new Compare("ApiKey.active", true))
@ -184,19 +188,18 @@ class Context {
return $this->user !== null;
}
public function createSession(int $userId, bool $stayLoggedIn): ?Session {
$this->user = User::find($this->sql, $userId);
if ($this->user) {
$this->session = new Session($this, $this->user);
$this->session->stayLoggedIn = $stayLoggedIn;
if ($this->session->update()) {
return $this->session;
}
public function createSession(User $user, bool $stayLoggedIn): ?Session {
$this->user = $user;
$this->session = new Session($this, $this->user);
$this->session->stayLoggedIn = $stayLoggedIn;
if ($this->session->update()) {
$user->session = $this->session;
return $this->session;
} else {
$this->user = null;
$this->session = null;
return null;
}
$this->user = null;
$this->session = null;
return null;
}
public function getLanguage(): Language {

@ -0,0 +1,12 @@
<?php
namespace Core\Objects\DatabaseEntity\Attribute;
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class EnumArr extends Enum {
public function __construct(array $values) {
parent::__construct(...$values);
}
}

@ -322,7 +322,7 @@ class DatabaseEntityHandler {
if ($property->isInitialized($entity)) {
$value = $property->getValue($entity);
if (isset($this->relations[$propertyName])) {
$value = $value->getId();
$value = $value?->getId();
}
} else if (!$this->columns[$propertyName]->notNull()) {
$value = null;
@ -411,4 +411,8 @@ class DatabaseEntityHandler {
$this->logger->error($message);
throw new Exception($message);
}
public function getSQL(): SQL {
return $this->sql;
}
}

@ -2,6 +2,7 @@
namespace Core\Objects\DatabaseEntity;
use Core\Driver\Logger\Logger;
use Core\Driver\SQL\Condition\Condition;
use Core\Driver\SQL\Join;
use Core\Driver\SQL\Query\Select;
@ -13,20 +14,29 @@ use Core\Driver\SQL\SQL;
*/
class DatabaseEntityQuery {
private Logger $logger;
private DatabaseEntityHandler $handler;
private Select $selectQuery;
private int $resultType;
private bool $logVerbose;
private function __construct(DatabaseEntityHandler $handler, int $resultType) {
$this->handler = $handler;
$this->selectQuery = $handler->getSelectQuery();
$this->logger = new Logger("DB-EntityQuery", $handler->getSQL());
$this->resultType = $resultType;
$this->logVerbose = false;
if ($this->resultType === SQL::FETCH_ONE) {
$this->selectQuery->first();
}
}
public function debug(): DatabaseEntityQuery {
$this->logVerbose = true;
return $this;
}
public static function fetchAll(DatabaseEntityHandler $handler): DatabaseEntityQuery {
return new DatabaseEntityQuery($handler, SQL::FETCH_ALL);
}
@ -106,6 +116,13 @@ class DatabaseEntityQuery {
}
public function execute(): DatabaseEntity|array|null {
if ($this->logVerbose) {
$params = [];
$query = $this->selectQuery->build($params);
$this->logger->debug("QUERY: $query\nARGS: " . print_r($params, true));
}
$res = $this->selectQuery->execute();
if ($res === null || $res === false) {
return null;

@ -3,6 +3,7 @@
namespace Core\Objects\DatabaseEntity;
use Core\Driver\SQL\Expression\CurrentTimeStamp;
use Core\Driver\SQL\SQL;
use Core\Objects\DatabaseEntity\Attribute\MaxLength;
use Core\Objects\DatabaseEntity\Attribute\DefaultValue;
@ -16,12 +17,13 @@ class GpgKey extends DatabaseEntity {
private \DateTime $expires;
#[DefaultValue(CurrentTimeStamp::class)] private \DateTime $added;
public function __construct(int $id, bool $confirmed, string $fingerprint, string $algorithm, string $expires) {
parent::__construct($id);
$this->confirmed = $confirmed;
public function __construct(string $fingerprint, string $algorithm, \DateTime $expires) {
parent::__construct();
$this->confirmed = false;
$this->fingerprint = $fingerprint;
$this->algorithm = $algorithm;
$this->expires = new \DateTime($expires);
$this->expires = $expires;
$this->added = new \DateTime();
}
public static function encrypt(string $body, string $gpgFingerprint): array {
@ -130,4 +132,9 @@ class GpgKey extends DatabaseEntity {
"confirmed" => $this->confirmed
];
}
public function confirm(SQL $sql): bool {
$this->confirmed = true;
return $this->save($sql);
}
}

@ -96,7 +96,7 @@ class Session extends DatabaseEntity {
return array(
'id' => $this->getId(),
'active' => $this->active,
'expires' => $this->expires,
'expires' => $this->expires->getTimestamp(),
'ipAddress' => $this->ipAddress,
'os' => $this->os,
'browser' => $this->browser,

@ -17,12 +17,12 @@ class User extends DatabaseEntity {
#[MaxLength(128)] public string $password;
#[MaxLength(64)] public string $fullName;
#[MaxLength(64)] #[Unique] public ?string $email;
#[MaxLength(64)] private ?string $profilePicture;
#[MaxLength(64)] public ?string $profilePicture;
private ?\DateTime $lastOnline;
#[DefaultValue(CurrentTimeStamp::class)] public \DateTime $registeredAt;
public bool $confirmed;
#[DefaultValue(1)] public Language $language;
private ?GpgKey $gpgKey;
public ?GpgKey $gpgKey;
private ?TwoFactorToken $twoFactorToken;
#[Transient] private array $groups;
@ -37,7 +37,6 @@ class User extends DatabaseEntity {
$this->groups = [];
$groups = Group::findAllBuilder($sql)
->fetchEntities()
->addJoin(new Join("INNER", "UserGroup", "UserGroup.group_id", "Group.id"))
->where(new Compare("UserGroup.user_id", $this->id))
->execute();
@ -99,6 +98,9 @@ class User extends DatabaseEntity {
'session' => (isset($this->session) ? $this->session->jsonSerialize() : null),
"gpg" => (isset($this->gpgKey) ? $this->gpgKey->jsonSerialize() : null),
"2fa" => (isset($this->twoFactorToken) ? $this->twoFactorToken->jsonSerialize() : null),
"reqisteredAt" => $this->registeredAt->getTimestamp(),
"lastOnline" => $this->lastOnline->getTimestamp(),
"confirmed" => $this->confirmed
];
}

@ -0,0 +1,72 @@
<?php
namespace Core\Objects\DatabaseEntity;
use Core\Driver\SQL\SQL;
use Core\Objects\DatabaseEntity\Attribute\DefaultValue;
use Core\Objects\DatabaseEntity\Attribute\EnumArr;
use Core\Objects\DatabaseEntity\Attribute\MaxLength;
class UserToken extends DatabaseEntity {
const TYPE_PASSWORD_RESET = "password_reset";
const TYPE_EMAIL_CONFIRM = "email_confirm";
const TYPE_INVITE = "invite";
const TYPE_GPG_CONFIRM = "gpg_confirm";
const TOKEN_TYPES = [
self::TYPE_PASSWORD_RESET, self::TYPE_EMAIL_CONFIRM,
self::TYPE_INVITE, self::TYPE_GPG_CONFIRM
];
#[MaxLength(36)]
private string $token;
#[EnumArr(self::TOKEN_TYPES)]
private string $tokenType;
private User $user;
private \DateTime $validUntil;
#[DefaultValue(false)]
private bool $used;
public function __construct(User $user, string $token, string $type, int $validHours) {
parent::__construct();
$this->user = $user;
$this->token = $token;
$this->tokenType = $type;
$this->validUntil = (new \DateTime())->modify("+$validHours HOUR");
$this->used = false;
}
public function jsonSerialize(): array {
return [
"id" => $this->getId(),
"token" => $this->token,
"tokenType" => $this->tokenType
];
}
public function getType(): string {
return $this->tokenType;
}
public function invalidate(SQL $sql): bool {
$this->used = true;
return $this->save($sql);
}
public function getUser(): User {
return $this->user;
}
public function updateDurability(SQL $sql, int $validHours): bool {
$this->validUntil = (new \DateTime())->modify("+$validHours HOURS");
return $this->save($sql);
}
public function getToken(): string {
return $this->token;
}
}

0
Site/Templates/.gitkeep Normal file

25
cli.php

@ -48,17 +48,6 @@ if ($database !== null && $database->getProperty("isDocker", false) && !is_file(
}
}
/*function getUser(): ?User {
global $config;
$user = new User($config);
if (!$user->getSQL() || !$user->getSQL()->isConnected()) {
printLine("Could not establish database connection");
return null;
}
return $user;
}*/
function connectSQL(): ?SQL {
global $context;
$sql = $context->initSQL();
@ -76,7 +65,7 @@ function printHelp() {
function applyPatch(\Core\Driver\SQL\SQL $sql, string $patchName): bool {
$class = str_replace('/', '\\', $patchName);
$className = "\\Configuration\\$class";
$className = "\\Core\\Configuration\\$class";
$classPath = getClassPath($className);
if (!file_exists($classPath) || !is_readable($classPath)) {
printLine("Database script file does not exist or is not readable");
@ -282,7 +271,7 @@ function onMaintenance(array $argv) {
_exit("Maintenance disabled");
} else if ($action === "update") {
$oldPatchFiles = glob('core/Configuration/Patch/*.php');
$oldPatchFiles = glob('Core/Configuration/Patch/*.php');
printLine("$ git remote -v");
exec("git remote -v", $gitRemote, $ret);
if ($ret !== 0) {
@ -339,14 +328,15 @@ function onMaintenance(array $argv) {
die();
}
$newPatchFiles = glob('core/Configuration/Patch/*.php');
// TODO: also collect patches from Site/Configuration/Patch ... and what about database entities?
$newPatchFiles = glob('Core/Configuration/Patch/*.php');
$newPatchFiles = array_diff($newPatchFiles, $oldPatchFiles);
if (count($newPatchFiles) > 0) {
printLine("Applying new database patches");
$sql = connectSQL();
if ($sql) {
foreach ($newPatchFiles as $patchFile) {
if (preg_match("/core\/Configuration\/(Patch\/.*)\.class\.php/", $patchFile, $match)) {
if (preg_match("/Core\/Configuration\/(Patch\/.*)\.class\.php/", $patchFile, $match)) {
$patchName = $match[1];
applyPatch($sql, $patchName);
}
@ -415,7 +405,7 @@ function printTable(array $head, array $body) {
function onSettings(array $argv) {
global $context;
$sql = connectSQL() or die();
connectSQL() or die();
$action = $argv[2] ?? "list";
if ($action === "list" || $action === "get") {
@ -461,7 +451,7 @@ function onSettings(array $argv) {
function onRoutes(array $argv) {
global $context;
$sql = connectSQL() or die();
connectSQL() or die();
$action = $argv[2] ?? "list";
if ($action === "list") {
@ -607,6 +597,7 @@ function onMail($argv) {
global $context;
$action = $argv[2] ?? null;
if ($action === "send_queue") {
connectSQL() or die();
$req = new \Core\API\Mail\SendQueue($context);
$debug = in_array("debug", $argv);
if (!$req->execute(["debug" => $debug])) {

@ -1,11 +1,14 @@
FROM composer:latest AS composer
FROM php:8.0-fpm
WORKDIR "/application"
RUN mkdir -p /application/core/Configuration
RUN chown -R www-data:www-data /application
RUN mkdir -p /application/core/Configuration /var/www/.gnupg && \
chown -R www-data:www-data /application /var/www/ && \
chmod 700 /var/www/.gnupg
# YAML + dev dependencies
RUN apt-get update -y && apt-get install libyaml-dev libzip-dev libgmp-dev -y && apt-get clean && \
RUN apt-get update -y && \
apt-get install -y libyaml-dev libzip-dev libgmp-dev gnupg2 && \
apt-get clean && \
pecl install yaml && docker-php-ext-enable yaml
# Runkit (no stable release available)