Swagger update + moved API user/gpg -> gpgkey/

This commit is contained in:
2024-04-22 19:01:04 +02:00
parent 8036edec5a
commit d6c6572989
13 changed files with 336 additions and 286 deletions

View File

@@ -0,0 +1,295 @@
<?php
namespace Core\API {
use Core\Objects\Context;
abstract class GpgKeyAPI extends \Core\API\Request {
public function __construct(Context $context, bool $externalCall = false, array $params = array()) {
parent::__construct($context, $externalCall, $params);
$this->loginRequired = true;
}
}
}
namespace Core\API\GpgKey {
use Core\API\GpgKeyAPI;
use Core\API\Parameter\Parameter;
use Core\API\Parameter\StringType;
use Core\API\Template\Render;
use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Query\Insert;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\GpgKey;
use Core\Objects\DatabaseEntity\User;
use Core\Objects\DatabaseEntity\UserToken;
class Import extends GpgKeyAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [
"pubkey" => new StringType("pubkey")
]);
$this->loginRequired = true;
$this->forbidMethod("GET");
}
private function testKey(string $keyString) {
$res = GpgKey::getKeyInfo($keyString);
if (!$res["success"]) {
return $this->createError($res["error"] ?? $res["msg"]);
}
$keyData = $res["data"];
$keyType = $keyData["type"];
$expires = $keyData["expires"];
if ($keyType === "sec#") {
return self::createError("ATTENTION! It seems like you've imported a PGP PRIVATE KEY instead of a public key.
It is recommended to immediately revoke your private key and create a new key pair.");
} else if ($keyType !== "pub") {
return self::createError("Unknown key type: $keyType");
} else if (isInPast($expires)) {
return self::createError("It seems like the gpg key is already expired.");
} else {
return $keyData;
}
}
public function _execute(): bool {
$currentUser = $this->context->getUser();
$gpgKey = $currentUser->getGPG();
if ($gpgKey) {
return $this->createError("You already added a GPG key to your account.");
} else if (!$currentUser->getEmail()) {
return $this->createError("You do not have an e-mail address");
}
// fix key first, enforce a newline after
$keyString = $this->getParam("pubkey");
$keyString = preg_replace("/(-{2,})\n([^\n])/", "$1\n\n$2", $keyString);
$keyData = $this->testKey($keyString);
if ($keyData === false) {
return false;
}
$res = GpgKey::importKey($keyString);
if (!$res["success"]) {
return $this->createError($res["error"]);
}
$sql = $this->context->getSQL();
$gpgKey = new GpgKey($keyData["fingerprint"], $keyData["algorithm"], $keyData["expires"]);
if (!$gpgKey->save($sql)) {
return $this->createError("Error creating gpg key: " . $sql->getLastError());
}
$token = generateRandomString(36);
$userToken = new UserToken($currentUser, $token, UserToken::TYPE_GPG_CONFIRM, 1);
if (!$userToken->save($sql)) {
return $this->createError("Error saving user token: " . $sql->getLastError());
}
$validHours = 1;
$settings = $this->context->getSettings();
$baseUrl = $settings->getBaseUrl();
$siteName = $settings->getSiteName();
$req = new Render($this->context);
$this->success = $req->execute([
"file" => "mail/gpg_import.twig",
"parameters" => [
"link" => "$baseUrl/resetPassword?token=$token",
"site_name" => $siteName,
"base_url" => $baseUrl,
"username" => $currentUser->getDisplayName(),
"valid_time" => $this->formatDuration($validHours, "hour")
]
]);
$this->lastError = $req->getLastError();
if ($this->success) {
$messageBody = $req->getResult()["html"];
$sendMail = new \Core\API\Mail\Send($this->context);
$this->success = $sendMail->execute(array(
"to" => $currentUser->getEmail(),
"subject" => "[$siteName] Confirm GPG-Key",
"body" => $messageBody,
"gpgFingerprint" => $gpgKey->getFingerprint()
));
$this->lastError = $sendMail->getLastError();
if ($this->success) {
$currentUser->gpgKey = $gpgKey;
if ($currentUser->save($sql, ["gpgKey"])) {
$this->result["gpgKey"] = $gpgKey->jsonSerialize();
} else {
return $this->createError("Error updating user details: " . $sql->getLastError());
}
}
}
return $this->success;
}
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [], "Allows users to import gpg keys for a secure e-mail communication", true);
}
}
class Remove extends GpgKeyAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array(
"password" => new StringType("password")
));
$this->loginRequired = true;
$this->forbidMethod("GET");
}
public function _execute(): bool {
$currentUser = $this->context->getUser();
$gpgKey = $currentUser->getGPG();
if (!$gpgKey) {
return $this->createError("You have not added a GPG public key to your account yet.");
}
$sql = $this->context->getSQL();
$password = $this->getParam("password");
if (!password_verify($password, $currentUser->password)) {
return $this->createError("Incorrect password.");
} else if (!$gpgKey->delete($sql)) {
return $this->createError("Error deleting gpg key: " . $sql->getLastError());
}
return $this->success;
}
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [], "Allows users to unlink gpg keys from their profile", true);
}
}
class Confirm extends GpgKeyAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [
"token" => new StringType("token", 36)
]);
$this->loginRequired = true;
}
public function _execute(): bool {
$currentUser = $this->context->getUser();
$gpgKey = $currentUser->getGPG();
if (!$gpgKey) {
return $this->createError("You have not added a GPG key yet.");
} else if ($gpgKey->isConfirmed()) {
return $this->createError("Your GPG key is already confirmed");
}
$token = $this->getParam("token");
$sql = $this->context->getSQL();
$userToken = UserToken::findBy(UserToken::createBuilder($sql, true)
->whereEq("token", $token)
->where(new Compare("valid_until", $sql->now(), ">="))
->whereEq("user_id", $currentUser->getId())
->whereEq("token_type", UserToken::TYPE_GPG_CONFIRM));
if ($userToken !== false) {
if ($userToken === null) {
return $this->createError("Invalid token");
} else {
if (!$gpgKey->confirm($sql)) {
return $this->createError("Error updating gpg key: " . $sql->getLastError());
}
$userToken->invalidate($sql);
}
} else {
return $this->createError("Error validating token: " . $sql->getLastError());
}
return $this->success;
}
}
class Download extends GpgKeyAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array(
"id" => new Parameter("id", Parameter::TYPE_INT, true, null),
"format" => new StringType("format", 16, true, "ascii")
));
$this->loginRequired = true;
$this->csrfTokenRequired = false;
}
public function _execute(): bool {
$allowedFormats = ["json", "ascii", "gpg"];
$format = $this->getParam("format");
if (!in_array($format, $allowedFormats)) {
return $this->getParam("Invalid requested format. Allowed formats: " . implode(",", $allowedFormats));
}
$currentUser = $this->context->getUser();
$userId = $this->getParam("id");
if ($userId === null || $userId == $currentUser->getId()) {
$gpgKey = $currentUser->getGPG();
if (!$gpgKey) {
return $this->createError("You did not add a gpg key yet.");
}
$email = $currentUser->getEmail();
} else {
$sql = $this->context->getSQL();
$user = User::find($sql, $userId, true);
if ($user === false) {
return $this->createError("Error fetching user details: " . $sql->getLastError());
} else if ($user === null) {
return $this->createError("User not found");
}
$email = $user->getEmail();
$gpgKey = $user->getGPG();
if (!$gpgKey || !$gpgKey->isConfirmed()) {
return $this->createError("This user has not added a gpg key yet or has not confirmed it yet.");
}
}
$res = GpgKey::export($gpgKey->getFingerprint(), $format !== "gpg");
if (!$res["success"]) {
return $this->createError($res["error"]);
}
$key = $res["data"];
if ($format === "json") {
$this->result["key"] = $key;
return true;
} else if ($format === "ascii") {
$contentType = "application/pgp-keys";
$ext = "asc";
} else if ($format === "gpg") {
$contentType = "application/octet-stream";
$ext = "gpg";
} else {
die("Invalid format");
}
$fileName = "$email.$ext";
header("Content-Type: $contentType");
header("Content-Length: " . strlen($key));
header("Content-Disposition: attachment; filename=\"$fileName\"");
die($key);
}
}
}

View File

@@ -604,4 +604,12 @@ abstract class Request {
$currentUser = $this->context->getUser();
return $currentUser ? "userId='" . $currentUser->getId() . "'" : "SYSTEM";
}
protected function formatDuration(int $count, string $string): string {
if ($count === 1) {
return $string;
} else {
return "the next $count {$string}s";
}
}
}

View File

@@ -63,6 +63,9 @@ class Swagger extends Request {
$definitions = [];
$paths = [];
$tags = [];
// TODO: consumes and produces is not always the same, but it's okay for now
foreach (self::getApiEndpoints() as $endpoint => $apiClass) {
$body = null;
$requiredProperties = [];
@@ -72,6 +75,17 @@ class Swagger extends Request {
continue;
}
$tag = null;
if ($apiClass->getParentClass()->getName() !== Request::class) {
$parentClass = $apiClass->getParentClass()->getShortName();
if (endsWith($parentClass, "API")) {
$tag = substr($parentClass, 0, strlen($parentClass) - 3);
if (!in_array($tag, $tags)) {
$tags[] = $tag;
}
}
}
$parameters = $apiObject->getDefaultParams();
if (!empty($parameters)) {
$body = [];
@@ -107,6 +121,7 @@ class Swagger extends Request {
$endPointDefinition = [
"post" => [
"tags" => [$tag ?? "Global"],
"produces" => ["application/json"],
"responses" => [
"200" => ["description" => "OK!"],
@@ -123,7 +138,7 @@ class Swagger extends Request {
}
if ($body) {
$endPointDefinition["post"]["consumes"] = ["application/json"];
$endPointDefinition["post"]["consumes"] = ["application/json", "application/x-www-form-urlencoded"];
$endPointDefinition["post"]["parameters"] = [[
"in" => "body",
"name" => "body",
@@ -149,6 +164,7 @@ class Swagger extends Request {
"host" => $domain,
"basePath" => "/api",
"schemes" => ["$protocol"],
"tags" => $tags,
"paths" => $paths,
"definitions" => $definitions
];

View File

@@ -98,14 +98,6 @@ namespace Core\API {
return password_hash($password, PASSWORD_BCRYPT);
}
protected function formatDuration(int $count, string $string): string {
if ($count === 1) {
return $string;
} else {
return "the next $count {$string}s";
}
}
protected function checkToken(string $token) : UserToken|bool {
$sql = $this->context->getSQL();
$userToken = UserToken::findBy(UserToken::createBuilder($sql, true)
@@ -1244,270 +1236,6 @@ namespace Core\API\User {
}
}
class ImportGPG extends UserAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [
"pubkey" => new StringType("pubkey")
]);
$this->loginRequired = true;
$this->forbidMethod("GET");
}
private function testKey(string $keyString) {
$res = GpgKey::getKeyInfo($keyString);
if (!$res["success"]) {
return $this->createError($res["error"] ?? $res["msg"]);
}
$keyData = $res["data"];
$keyType = $keyData["type"];
$expires = $keyData["expires"];
if ($keyType === "sec#") {
return self::createError("ATTENTION! It seems like you've imported a PGP PRIVATE KEY instead of a public key.
It is recommended to immediately revoke your private key and create a new key pair.");
} else if ($keyType !== "pub") {
return self::createError("Unknown key type: $keyType");
} else if (isInPast($expires)) {
return self::createError("It seems like the gpg key is already expired.");
} else {
return $keyData;
}
}
public function _execute(): bool {
$currentUser = $this->context->getUser();
$gpgKey = $currentUser->getGPG();
if ($gpgKey) {
return $this->createError("You already added a GPG key to your account.");
} else if (!$currentUser->getEmail()) {
return $this->createError("You do not have an e-mail address");
}
// fix key first, enforce a newline after
$keyString = $this->getParam("pubkey");
$keyString = preg_replace("/(-{2,})\n([^\n])/", "$1\n\n$2", $keyString);
$keyData = $this->testKey($keyString);
if ($keyData === false) {
return false;
}
$res = GpgKey::importKey($keyString);
if (!$res["success"]) {
return $this->createError($res["error"]);
}
$sql = $this->context->getSQL();
$gpgKey = new GpgKey($keyData["fingerprint"], $keyData["algorithm"], $keyData["expires"]);
if (!$gpgKey->save($sql)) {
return $this->createError("Error creating gpg key: " . $sql->getLastError());
}
$token = generateRandomString(36);
$userToken = new UserToken($currentUser, $token, UserToken::TYPE_GPG_CONFIRM, 1);
if (!$userToken->save($sql)) {
return $this->createError("Error saving user token: " . $sql->getLastError());
}
$validHours = 1;
$settings = $this->context->getSettings();
$baseUrl = $settings->getBaseUrl();
$siteName = $settings->getSiteName();
$req = new Render($this->context);
$this->success = $req->execute([
"file" => "mail/gpg_import.twig",
"parameters" => [
"link" => "$baseUrl/resetPassword?token=$token",
"site_name" => $siteName,
"base_url" => $baseUrl,
"username" => $currentUser->getDisplayName(),
"valid_time" => $this->formatDuration($validHours, "hour")
]
]);
$this->lastError = $req->getLastError();
if ($this->success) {
$messageBody = $req->getResult()["html"];
$sendMail = new \Core\API\Mail\Send($this->context);
$this->success = $sendMail->execute(array(
"to" => $currentUser->getEmail(),
"subject" => "[$siteName] Confirm GPG-Key",
"body" => $messageBody,
"gpgFingerprint" => $gpgKey->getFingerprint()
));
$this->lastError = $sendMail->getLastError();
if ($this->success) {
$currentUser->gpgKey = $gpgKey;
if ($currentUser->save($sql, ["gpgKey"])) {
$this->result["gpgKey"] = $gpgKey->jsonSerialize();
} else {
return $this->createError("Error updating user details: " . $sql->getLastError());
}
}
}
return $this->success;
}
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [], "Allows users to import gpg keys for a secure e-mail communication", true);
}
}
class RemoveGPG extends UserAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array(
"password" => new StringType("password")
));
$this->loginRequired = true;
$this->forbidMethod("GET");
}
public function _execute(): bool {
$currentUser = $this->context->getUser();
$gpgKey = $currentUser->getGPG();
if (!$gpgKey) {
return $this->createError("You have not added a GPG public key to your account yet.");
}
$sql = $this->context->getSQL();
$password = $this->getParam("password");
if (!password_verify($password, $currentUser->password)) {
return $this->createError("Incorrect password.");
} else if (!$gpgKey->delete($sql)) {
return $this->createError("Error deleting gpg key: " . $sql->getLastError());
}
return $this->success;
}
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [], "Allows users to unlink gpg keys from their profile", true);
}
}
class ConfirmGPG extends UserAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [
"token" => new StringType("token", 36)
]);
$this->loginRequired = true;
}
public function _execute(): bool {
$currentUser = $this->context->getUser();
$gpgKey = $currentUser->getGPG();
if (!$gpgKey) {
return $this->createError("You have not added a GPG key yet.");
} else if ($gpgKey->isConfirmed()) {
return $this->createError("Your GPG key is already confirmed");
}
$token = $this->getParam("token");
$sql = $this->context->getSQL();
$userToken = UserToken::findBy(UserToken::createBuilder($sql, true)
->whereEq("token", $token)
->where(new Compare("valid_until", $sql->now(), ">="))
->whereEq("user_id", $currentUser->getId())
->whereEq("token_type", UserToken::TYPE_GPG_CONFIRM));
if ($userToken !== false) {
if ($userToken === null) {
return $this->createError("Invalid token");
} else {
if (!$gpgKey->confirm($sql)) {
return $this->createError("Error updating gpg key: " . $sql->getLastError());
}
$userToken->invalidate($sql);
}
} else {
return $this->createError("Error validating token: " . $sql->getLastError());
}
return $this->success;
}
}
class DownloadGPG extends UserAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array(
"id" => new Parameter("id", Parameter::TYPE_INT, true, null),
"format" => new StringType("format", 16, true, "ascii")
));
$this->loginRequired = true;
$this->csrfTokenRequired = false;
}
public function _execute(): bool {
$allowedFormats = ["json", "ascii", "gpg"];
$format = $this->getParam("format");
if (!in_array($format, $allowedFormats)) {
return $this->getParam("Invalid requested format. Allowed formats: " . implode(",", $allowedFormats));
}
$currentUser = $this->context->getUser();
$userId = $this->getParam("id");
if ($userId === null || $userId == $currentUser->getId()) {
$gpgKey = $currentUser->getGPG();
if (!$gpgKey) {
return $this->createError("You did not add a gpg key yet.");
}
$email = $currentUser->getEmail();
} else {
$sql = $this->context->getSQL();
$user = User::find($sql, $userId, true);
if ($user === false) {
return $this->createError("Error fetching user details: " . $sql->getLastError());
} else if ($user === null) {
return $this->createError("User not found");
}
$email = $user->getEmail();
$gpgKey = $user->getGPG();
if (!$gpgKey || !$gpgKey->isConfirmed()) {
return $this->createError("This user has not added a gpg key yet or has not confirmed it yet.");
}
}
$res = GpgKey::export($gpgKey->getFingerprint(), $format !== "gpg");
if (!$res["success"]) {
return $this->createError($res["error"]);
}
$key = $res["data"];
if ($format === "json") {
$this->result["key"] = $key;
return true;
} else if ($format === "ascii") {
$contentType = "application/pgp-keys";
$ext = "asc";
} else if ($format === "gpg") {
$contentType = "application/octet-stream";
$ext = "gpg";
} else {
die("Invalid format");
}
$fileName = "$email.$ext";
header("Content-Type: $contentType");
header("Content-Length: " . strlen($key));
header("Content-Disposition: attachment; filename=\"$fileName\"");
die($key);
}
}
class UploadPicture extends UserAPI {
public function __construct(Context $context, bool $externalCall = false) {
// TODO: we should optimize the process here, we need an offset and size parameter to get a quadratic crop of the uploaded image

View File

@@ -45,6 +45,7 @@ class Security extends Document {
"# This project is based on the open-source framework hosted on https://github.com/rhergenreder/web-base",
"# Any non site-specific issues can be reported via the github security reporting feature:",
"# https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability",
"# or by contacting me directly: mail(at)romanh(dot)de",
"",
"Canonical: $baseUrl/.well-known/security.txt",
"Preferred-Languages: $languageCodes",

View File

@@ -62,7 +62,7 @@ class ApiRoute extends Route {
http_response_code(400);
$response = createError("Invalid Method");
} else {
$request = $apiClass->newInstanceArgs(array($router->getContext(), true));
$request = $apiClass->newInstanceArgs([$router->getContext(), true]);
$success = $request->execute();
$response = $request->getResult();
$response["success"] = $success;
@@ -74,6 +74,7 @@ class ApiRoute extends Route {
}
} catch (ReflectionException $e) {
http_response_code(500);
$router->getLogger()->error("Error instantiating class: $e");
$response = createError("Error instantiating class: $e");
}
}

View File

@@ -83,6 +83,7 @@ class DocumentRoute extends Route {
try {
if (!$this->loadClass()) {
$router->getLogger()->warning("Error loading class: $className");
return $router->returnStatusCode(500, [ "message" => "Error loading class: $className"]);
}
@@ -90,6 +91,7 @@ class DocumentRoute extends Route {
$document = $this->reflectionClass->newInstanceArgs($args);
return $document->load($params);
} catch (\ReflectionException $e) {
$router->getLogger()->error("Error loading class: $className: " . $e->getMessage());
return $router->returnStatusCode(500, [ "message" => "Error loading class $className: " . $e->getMessage()]);
}
}

View File

@@ -16,7 +16,7 @@
let token = jsCore.getParameter("token");
let confirmStatus = $("#confirm-status");
if (token) {
jsCore.apiCall("/user/confirmGPG", { token: token, csrfToken: '{{ user.session.csrfToken }}' }, (res) => {
jsCore.apiCall("/gpgKey/confirm", { token: token, csrfToken: '{{ user.session.csrfToken }}' }, (res) => {
confirmStatus.removeClass("alert-info");
if (!res.success) {
confirmStatus.addClass("alert-danger");