Skip to content
2 changes: 2 additions & 0 deletions src/api/v2/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
use Hashtopolis\inc\apiv2\model\AgentBinaryAPI;
use Hashtopolis\inc\apiv2\model\AgentErrorAPI;
use Hashtopolis\inc\apiv2\model\AgentStatAPI;
use Hashtopolis\inc\apiv2\model\ApiTokenAPI;
use Hashtopolis\inc\apiv2\model\ChunkAPI;
use Hashtopolis\inc\apiv2\model\ConfigAPI;
use Hashtopolis\inc\apiv2\model\ConfigSectionAPI;
Expand Down Expand Up @@ -234,6 +235,7 @@
AgentBinaryAPI::register($app);
AgentErrorAPI::register($app);
AgentStatAPI::register($app);
ApiTokenAPI::register($app);
Comment thread
jessevz marked this conversation as resolved.
ChunkAPI::register($app);
ConfigAPI::register($app);
ConfigSectionAPI::register($app);
Expand Down
12 changes: 12 additions & 0 deletions src/dba/Factory.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use Hashtopolis\dba\models\HashTypeFactory;
use Hashtopolis\dba\models\HealthCheckFactory;
use Hashtopolis\dba\models\HealthCheckAgentFactory;
use Hashtopolis\dba\models\JwtApiKeyFactory;
use Hashtopolis\dba\models\LogEntryFactory;
use Hashtopolis\dba\models\NotificationSettingFactory;
use Hashtopolis\dba\models\PreprocessorFactory;
Expand Down Expand Up @@ -71,6 +72,7 @@ class Factory {
private static ?HashTypeFactory $hashTypeFactory = null;
private static ?HealthCheckFactory $healthCheckFactory = null;
private static ?HealthCheckAgentFactory $healthCheckAgentFactory = null;
private static ?JwtApiKeyFactory $jwtApiKeyFactory = null;
private static ?LogEntryFactory $logEntryFactory = null;
private static ?NotificationSettingFactory $notificationSettingFactory = null;
private static ?PreprocessorFactory $preprocessorFactory = null;
Expand Down Expand Up @@ -323,6 +325,16 @@ public static function getHealthCheckAgentFactory(): HealthCheckAgentFactory {
}
}

public static function getJwtApiKeyFactory(): JwtApiKeyFactory {
if (self::$jwtApiKeyFactory == null) {
$f = new JwtApiKeyFactory();
self::$jwtApiKeyFactory = $f;
return $f;
} else {
return self::$jwtApiKeyFactory;
}
}

public static function getLogEntryFactory(): LogEntryFactory {
if (self::$logEntryFactory == null) {
$f = new LogEntryFactory();
Expand Down
110 changes: 110 additions & 0 deletions src/dba/models/JwtApiKey.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

namespace Hashtopolis\dba\models;

use Hashtopolis\dba\AbstractModel;

class JwtApiKey extends AbstractModel {
private ?int $jwtApiKeyId;
private ?int $startValid;
private ?int $endValid;
private ?int $userId;
private ?int $isRevoked;

function __construct(?int $jwtApiKeyId, ?int $startValid, ?int $endValid, ?int $userId, ?int $isRevoked) {
$this->jwtApiKeyId = $jwtApiKeyId;
$this->startValid = $startValid;
$this->endValid = $endValid;
$this->userId = $userId;
$this->isRevoked = $isRevoked;
}

function getKeyValueDict(): array {
$dict = array();
$dict['jwtApiKeyId'] = $this->jwtApiKeyId;
$dict['startValid'] = $this->startValid;
$dict['endValid'] = $this->endValid;
$dict['userId'] = $this->userId;
$dict['isRevoked'] = $this->isRevoked;

return $dict;
}

static function getFeatures(): array {
$dict = array();
$dict['jwtApiKeyId'] = ['read_only' => True, "type" => "int", "subtype" => "unset", "choices" => "unset", "null" => False, "pk" => True, "protected" => True, "private" => False, "alias" => "jwtApiKeyId", "public" => False, "dba_mapping" => False];
$dict['startValid'] = ['read_only' => True, "type" => "int64", "subtype" => "unset", "choices" => "unset", "null" => False, "pk" => False, "protected" => False, "private" => False, "alias" => "startValid", "public" => False, "dba_mapping" => False];
$dict['endValid'] = ['read_only' => True, "type" => "int64", "subtype" => "unset", "choices" => "unset", "null" => False, "pk" => False, "protected" => False, "private" => False, "alias" => "endValid", "public" => False, "dba_mapping" => False];
$dict['userId'] = ['read_only' => True, "type" => "int", "subtype" => "unset", "choices" => "unset", "null" => True, "pk" => False, "protected" => False, "private" => False, "alias" => "userId", "public" => False, "dba_mapping" => False];
$dict['isRevoked'] = ['read_only' => False, "type" => "bool", "subtype" => "unset", "choices" => "unset", "null" => True, "pk" => False, "protected" => False, "private" => False, "alias" => "isRevoked", "public" => False, "dba_mapping" => False];

return $dict;
}

function getPrimaryKey(): string {
return "jwtApiKeyId";
}

function getPrimaryKeyValue(): ?int {
return $this->jwtApiKeyId;
}

function getId(): ?int {
return $this->jwtApiKeyId;
}

function setId($id): void {
$this->jwtApiKeyId = $id;
}

/**
* Used to serialize the data contained in the model
* @return array
*/
public function expose(): array {
return get_object_vars($this);
}

function getStartValid(): ?int {
return $this->startValid;
}

function setStartValid(?int $startValid): void {
$this->startValid = $startValid;
}

function getEndValid(): ?int {
return $this->endValid;
}

function setEndValid(?int $endValid): void {
$this->endValid = $endValid;
}

function getUserId(): ?int {
return $this->userId;
}

function setUserId(?int $userId): void {
$this->userId = $userId;
}

function getIsRevoked(): ?int {
return $this->isRevoked;
}

function setIsRevoked(?int $isRevoked): void {
$this->isRevoked = $isRevoked;
}

const JWT_API_KEY_ID = "jwtApiKeyId";
const START_VALID = "startValid";
const END_VALID = "endValid";
const USER_ID = "userId";
const IS_REVOKED = "isRevoked";

const PERM_CREATE = "permJwtApiKeyCreate";
const PERM_READ = "permJwtApiKeyRead";
const PERM_UPDATE = "permJwtApiKeyUpdate";
const PERM_DELETE = "permJwtApiKeyDelete";
}
92 changes: 92 additions & 0 deletions src/dba/models/JwtApiKeyFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

namespace Hashtopolis\dba\models;

use Hashtopolis\dba\AbstractModelFactory;
use Hashtopolis\dba\Util;

class JwtApiKeyFactory extends AbstractModelFactory {
function getModelName(): string {
return "JwtApiKey";
}

function getModelTable(): string {
return "JwtApiKey";
}

function isMapping(): bool {
return False;
}

function isCachable(): bool {
return false;
}

function getCacheValidTime(): int {
return -1;
}

/**
* @return JwtApiKey
*/
function getNullObject(): JwtApiKey {
return new JwtApiKey(-1, null, null, null, null);
}

/**
* @param string $pk
* @param array $dict
* @return JwtApiKey
*/
function createObjectFromDict($pk, $dict): JwtApiKey {
$conv = [];
foreach ($dict as $key => $val) {
$conv[strtolower($key)] = $val;
}
$dict = $conv;
return new JwtApiKey($dict['jwtapikeyid'], $dict['startvalid'], $dict['endvalid'], $dict['userid'], $dict['isrevoked']);
}

/**
* @param array $options
* @param bool $single
* @return JwtApiKey|JwtApiKey[]
*/
function filter(array $options, bool $single = false): JwtApiKey|array|null {
$join = false;
if (array_key_exists('join', $options)) {
$join = true;
}
if ($single) {
if ($join) {
return parent::filter($options, $single);
}
return Util::cast(parent::filter($options, $single), JwtApiKey::class);
}
$objects = parent::filter($options, $single);
if ($join) {
return $objects;
}
$models = array();
foreach ($objects as $object) {
$models[] = Util::cast($object, JwtApiKey::class);
}
return $models;
}

/**
* @param string $pk
* @return ?JwtApiKey
*/
function get($pk): ?JwtApiKey {
return Util::cast(parent::get($pk), JwtApiKey::class);
}

/**
* @param JwtApiKey $model
* @return JwtApiKey
*/
function save($model): JwtApiKey {
return Util::cast(parent::save($model), JwtApiKey::class);
}
}
9 changes: 9 additions & 0 deletions src/dba/models/generator.php
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,15 @@
['name' => 'errors', 'read_only' => True, 'type' => 'str(65535)', 'protected' => True],
],
];
$CONF['JwtApiKey'] = [
'columns' => [
['name' => 'jwtApiKeyId', 'read_only' => True, 'type' => 'int', 'protected' => True],
Comment thread
jessevz marked this conversation as resolved.
['name' => 'startValid', 'read_only' => True, 'type' => 'int64'],
['name' => 'endValid', 'read_only' => True, 'type' => 'int64'],
['name' => 'userId', 'read_only' => True, 'type' => 'int', 'relation' => 'User'],
['name' => 'isRevoked', 'read_only' => False, 'type' => 'bool'],
],
Comment thread
jessevz marked this conversation as resolved.
];
$CONF['LogEntry'] = [
'columns' => [
['name' => 'logEntryId', 'read_only' => True, 'type' => 'int', 'protected' => True],
Expand Down
15 changes: 15 additions & 0 deletions src/inc/apiv2/auth/JWTBeforeHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

namespace Hashtopolis\inc\apiv2\auth;

use Hashtopolis\dba\Factory;
use Hashtopolis\inc\apiv2\error\HttpError;
use Hashtopolis\inc\apiv2\error\HttpForbidden;
use Hashtopolis\inc\apiv2\model\ApiTokenAPI;
use JimTools\JwtAuth\Handlers\BeforeHandlerInterface;
use Psr\Http\Message\ServerRequestInterface;

Expand All @@ -10,6 +14,17 @@ class JWTBeforeHandler implements BeforeHandlerInterface {
* @param array{decoded: array<string, mixed>, token: string} $arguments
*/
public function __invoke(ServerRequestInterface $request, array $arguments): ServerRequestInterface {
if (isset ($arguments["decoded"]["aud"]) && $arguments["decoded"]["aud"] == ApiTokenAPI::API_AUD) {
$apiTokenId = $arguments["decoded"]["jti"];
$token = Factory::getJwtApiKeyFactory()->get($apiTokenId);
if ($token === null) {
// Should not happen
throw new HttpError("Token doesn't exists in the database");
}
if ($token->getIsRevoked() === 1) {
throw new HttpForbidden("Token is revoked");
}
}
// adds the decoded userId and scope to the request attributes
return $request->withAttribute("userId", $arguments["decoded"]["userId"])->withAttribute("scope", $arguments["decoded"]["scope"]);
}
Expand Down
61 changes: 39 additions & 22 deletions src/inc/apiv2/auth/token.routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,34 +13,50 @@
use Hashtopolis\dba\models\User;
use Hashtopolis\dba\Factory;
use Firebase\JWT\JWK;
use Hashtopolis\dba\JoinFilter;
use Hashtopolis\dba\models\RightGroup;
use Hashtopolis\inc\apiv2\error\HttpForbidden;

require_once(dirname(__FILE__) . "/../../startup/include.php");

const USER_AUD = "user_hashtopolis";
function generateTokenForUser(Request $request, string $userName, int $expires) {
$jti = bin2hex(random_bytes(16));

$requested_scopes = $request->getParsedBody() ?: ["todo.all"];

$valid_scopes = [
"todo.create",
"todo.read",
"todo.update",
"todo.delete",
"todo.list",
"todo.all"
];

$scopes = array_filter($requested_scopes, function ($needle) use ($valid_scopes) {
return in_array($needle, $valid_scopes);
});
// FIXME: This is duplicated and should be passed by HttpBasicMiddleware
$filter = new QueryFilter(User::USERNAME, $userName, "=");
$check = Factory::getUserFactory()->filter([Factory::FILTER => $filter]);
$jF = new JoinFilter(Factory::getRightGroupFactory(), User::RIGHT_GROUP_ID, RightGroup::RIGHT_GROUP_ID);
$joined = Factory::getUserFactory()->filter([Factory::FILTER => $filter, Factory::JOIN => $jF]);
/** @var User[] $check */
$check = $joined[Factory::getUserFactory()->getModelName()];
if (count($check) === 0) {
throw new HttpError("No user with this userName in the database");
}
$user = $check[0];
if ($user->getIsValid() !== 1) {
throw new HttpForbidden("User is set to invalid");
}

if (empty($user)) {
throw new HttpError("No user with this userName in the database");
/** @var RightGroup[] $groupArray */
$groupArray = $joined[Factory::getRightGroupFactory()->getModelName()];
if (count($groupArray) === 0) {
throw new HttpError("No rightgroup found for this user");
}
$group = $groupArray[0];
$scopes = $group->getPermissions();

Comment on lines +31 to +46
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generateTokenForUser() now issues tokens for any matching username without checking User::isValid. Since preCommon() blocks invalid users from accessing most API routes, this endpoint should also reject invalid/disabled users to prevent minting tokens that will immediately be forbidden (and to avoid edge cases where other routes might still accept them). Add an isValid check before generating the JWT.

Copilot uses AI. Check for mistakes.
// $requested_scopes = $request->getParsedBody() ?: ["todo.all"];
// $valid_scopes = [
// "todo.create",
// "todo.read",
// "todo.update",
// "todo.delete",
// "todo.list",
// "todo.all"
// ];

// $scopes = array_filter($requested_scopes, function ($needle) use ($valid_scopes) {
// return in_array($needle, $valid_scopes);
// });

$secret = StartupConfig::getInstance()->getPepper(0);
$now = new DateTime();
Expand All @@ -52,7 +68,8 @@ function generateTokenForUser(Request $request, string $userName, int $expires)
"userId" => $user->getId(),
"scope" => $scopes,
"iss" => "Hashtopolis",
"kid" => hash("sha256", $secret)
"kid" => hash("sha256", $secret),
"aud" => USER_AUD
];

$token = JWT::encode($payload, $secret, "HS256");
Expand All @@ -75,8 +92,7 @@ function extractBearerToken(Request $request): ?string {
}

// Exchanges an oauth token for a application JWT token
use Slim\App;
/** @var App $app */
/** @var \Slim\App $app */
$app->group("/api/v2/auth/oauth-token", function (RouteCollectorProxy $group) {

$group->post('', function (Request $request, Response $response, array $args): Response {
Expand Down Expand Up @@ -159,7 +175,8 @@ function extractBearerToken(Request $request): ?string {
"userId" => $request->getAttribute(('userId')),
"scope" => $request->getAttribute("scope"),
"iss" => "Hashtopolis",
"kid" => hash("sha256", $secret)
"kid" => hash("sha256", $secret),
"aud" => USER_AUD
];

$token = JWT::encode($payload, $secret, "HS256");
Expand Down
Loading
Loading