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

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

@ -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);
}
}
}

@ -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";
}
}
}

@ -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
];

@ -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

@ -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",

@ -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");
}
}

@ -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()]);
}
}

@ -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");

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -126,7 +126,7 @@ export default function GpgBox(props) {
<Button startIcon={isGpgKeyRemoving ? <CircularProgress size={12} /> : <Remove />}
color="secondary" onClick={onRemoveGpgKey}
variant="outlined" size="small"
disabled={isGpgKeyRemoving || !api.hasPermission("user/removeGPG")}>
disabled={isGpgKeyRemoving || !api.hasPermission("gpgKey/remove")}>
{isGpgKeyRemoving ? L("general.removing") + "…" : L("general.remove")}
</Button>
</Box> :
@ -134,7 +134,7 @@ export default function GpgBox(props) {
<SpacedFormGroup>
<FormLabel>{L("account.gpg_key")}</FormLabel>
<GpgKeyField value={gpgKey} multiline={true} rows={8}
disabled={isGpgKeyUploading || !api.hasPermission("user/importGPG")}
disabled={isGpgKeyUploading || !api.hasPermission("gpgKey/import")}
placeholder={L("account.gpg_key_placeholder_text")}
onChange={e => setGpgKey(e.target.value)}
onDrop={e => {
@ -162,7 +162,7 @@ export default function GpgBox(props) {
<Button startIcon={isGpgKeyUploading ? <CircularProgress size={12} /> : <Upload />}
color="primary" onClick={onUploadGPG}
variant="outlined" size="small"
disabled={isGpgKeyUploading || !api.hasPermission("user/importGPG")}>
disabled={isGpgKeyUploading || !api.hasPermission("gpgKey/import")}>
{isGpgKeyUploading ? L("general.uploading") + "…" : L("general.upload")}
</Button>
</ButtonBar>

@ -387,7 +387,7 @@ export default class API {
/** GPG API **/
async uploadGPG(pubkey) {
let res = await this.apiCall("user/importGPG", { pubkey: pubkey });
let res = await this.apiCall("gpgKey/import", { pubkey: pubkey });
if (res.success) {
this.user.gpgKey = res.gpgKey;
}
@ -396,7 +396,7 @@ export default class API {
}
async confirmGpgToken(token) {
let res = await this.apiCall("user/confirmGPG", { token: token });
let res = await this.apiCall("gpgKey/confirm", { token: token });
if (res.success) {
this.user.gpgKey.confirmed = true;
}
@ -405,7 +405,7 @@ export default class API {
}
async removeGPG(password) {
let res = await this.apiCall("user/removeGPG", { password: password });
let res = await this.apiCall("gpgKey/remove", { password: password });
if (res.success) {
this.user.gpgKey = null;
}
@ -414,7 +414,7 @@ export default class API {
}
async downloadGPG(userId) {
return this.apiCall("user/downloadGPG", { id: userId }, true);
return this.apiCall("gpgKey/download", { id: userId }, true);
}
/** Log API **/