Files
api/services/nginx/app/routes/subusersRoute.php
T
Jeppe Bundgaard eb0f4ca38b Enhance 2FA handling and subuser session management
- Add `two_factor_enabled` property for subuser responses in routes and OpenAPI specs.
- Improve subuser session resolution by checking token `id` for validity.
- Adjust authentication flow to prevent operations on nonexistent users in 2FA logic.
- Update OpenAPI request/response schema to better represent token/session objects.
2026-02-23 21:44:11 +01:00

676 lines
31 KiB
PHP

<?php
namespace routes;
use classes\authentication;
use classes\economic;
use classes\gatewayapi;
use classes\response;
use classes\virkdata;
use Exception;
use modules\virkdata\helpers\virkdata_response;
use objects\logs_o;
use objects\subuser_grants_o;
use objects\subusers_o;
use objects\users_o;
use modules\subusers\permissions\subusers_permission_nodes_bookings;
use modules\subusers\permissions\subusers_permission_nodes_orders;
use modules\subusers\permissions\subusers_permission_nodes_selfserve;
use modules\subusers\permissions\subusers_permission_nodes_subusers;
use modules\subusers\permissions\subusers_permission_nodes_vehicles;
use modules\subusers\helpers\subusers_permission_node_key;
use traits\route_t;
class subusersRoute
{
use route_t;
private static function requireRegex(string $input, string $regex, string $error_message): void
{
if (!preg_match($regex, $input)) {
global $response;
$response->error($error_message, 400);
}
}
public function run(): void
{
// =============================
// Subuser Grant Management
// =============================
$this->get('/subusers/grants', function () {
global $response;
/** Permissions (subuser-aware) */
$permission_own = self::definePermission('list_own_subuser_grants', subusers_permission_node_key::SUBUSERS_LIST);
$permission_other = self::definePermission('list_subuser_grants');
$has_permission_other = self::hasPermission($permission_other);
// Optional filters: customer_number, subuser_id
$filters = ['deleted_at' => null];
$hasFilter = false;
$requestedCustomer = null;
if (self::isParametersSet(['customer_number'])) {
$customer_number = (int)self::getParameter('customer_number');
self::requireType($customer_number, self::type_int());
$filters['billing_customer_number'] = $customer_number;
$requestedCustomer = (int)$customer_number;
$hasFilter = true;
}
if (self::isParametersSet(['subuser_id'])) {
$subuser_id = (int)self::getParameter('subuser_id');
self::requireType($subuser_id, self::type_int());
$filters['subuser'] = $subuser_id;
$hasFilter = true;
}
// Effective customer context (classic or subuser header)
$effectiveCustomer = self::resolveEffectiveCustomerNumber();
// Allow via own-scope or admin-scope
self::allowOwnOrDepartmentAccess(
$permission_own,
$permission_other,
$requestedCustomer !== null ? (int)$requestedCustomer : ($effectiveCustomer !== null ? (int)$effectiveCustomer : null),
null,
null,
'You do not have permission to list subuser grants.'
);
// If not admin/department permission, force restrict to effective customer
if (!$has_permission_other) {
if ($effectiveCustomer === null) {
$response->error('Missing customer context. Subusers must provide X-Customer-Number header.', 403);
}
$filters['billing_customer_number'] = (int)$effectiveCustomer;
$hasFilter = true; // ensure we don't fail below
}
// Fallback: try to infer from session for classic users
if (!$hasFilter) {
$user = (new authentication())->get_user();
if ($user && !empty($user->customer_number->value())) {
$filters['billing_customer_number'] = (int)$user->customer_number->value();
$hasFilter = true;
}
}
if (!$hasFilter) {
$response->error('You must provide at least one of: customer_number or subuser_id', 400);
}
$paginatedResponse = (new subuser_grants_o())->listObjectsWithPaginationIfSet(function ($o) {
$o = (object)$o;
$subuser = (new subusers_o())->select((int)$o->subuser);
return [
'id' => $o->id,
'billing_customer_number' => $o->billing_customer_number,
'subuser' => $o->subuser,
'name' => $subuser->name->value(),
'enabled' => $o->enabled,
'note' => $o->note,
'permissions' => json_decode($o->permissions, true) ?: [],
'created_at' => $o->created_at,
'updated_at' => $o->updated_at,
];
}, $filters);
$response->success($paginatedResponse);
}, [
'list_subuser_grants' => 'List subuser grants for any customer (admin).',
'list_own_subuser_grants' => 'List subuser grants for the effective customer. Subusers require node: SUBUSERS_LIST and X-Customer-Number header.'
]);
$this->post('/subusers/grants', function () {
global $response;
/** Permissions (subuser-aware) */
$permission_own = self::definePermission('add_own_subusers', subusers_permission_node_key::SUBUSERS_ADD);
$permission_other = self::definePermission('manage_subuser_grants');
self::requireParameters(['customer_number', 'subuser_id']);
$customer_number = (int)self::getParameter('customer_number');
$subuser_id = (int)self::getParameter('subuser_id');
self::requireType($customer_number, self::type_int());
self::requireType($subuser_id, self::type_int());
// Enforce own-vs-admin access using target customer number for context
self::allowOwnOrDepartmentAccess(
$permission_own,
$permission_other,
(int)$customer_number,
null,
null,
'You do not have permission to create subuser grants for this customer.'
);
$enabled = true;
if (self::isParametersSet(['enabled'])) {
$enabled = (bool)filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($enabled === null) { $enabled = true; }
}
$note = null;
if (self::isParametersSet(['note'])) {
$note = (string)self::getParameter('note');
self::requireType($note, self::type_string());
self::requireMaxLength('note', 65535);
}
$permissions = null;
if (self::isParametersSet(['permissions'])) {
$raw = self::getParameter('permissions');
// Expect array (already parsed) or JSON string
if (is_string($raw)) {
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
$response->error('Invalid permissions payload', 400);
}
$permissions = $decoded;
} elseif (is_array($raw)) {
$permissions = $raw;
} else {
$response->error('Invalid permissions type', 400);
}
// Validate each permission is a known key
foreach ($permissions as $perm) {
if (!is_string($perm) || subusers_permission_node_key::tryFrom($perm) === null) {
$response->error('Unknown permission key: ' . (string)$perm, 400);
}
}
}
try {
$grant = (new subuser_grants_o())->add($customer_number, $subuser_id, (bool)$enabled, $note, $permissions);
$response->success(['grant' => $grant->asArray()]);
} catch (Exception $e) {
$response->error('Failed to add subuser grant', 500);
}
}, [
'manage_subuser_grants' => 'Create subuser grants for any customer (admin).',
'add_own_subusers' => 'Create subuser grants for own customer. Subusers require node: SUBUSERS_ADD and X-Customer-Number header.'
]);
$this->put('/subusers/grants', function () {
global $response;
/** Permissions (subuser-aware) */
$permission_own = self::definePermission('edit_own_subusers', subusers_permission_node_key::SUBUSERS_EDIT);
$permission_other = self::definePermission('manage_subuser_grants');
self::requireParameters(['id']);
self::requireType(self::getParameter('id'), self::type_int());
$grant = (new subuser_grants_o())->select((int)self::getParameter('id'));
if (!$grant->exists()) {
$response->error('Grant not found', 404);
}
// Enforce own-vs-admin using the grant's customer number
$targetCustomer = (int)$grant->billing_customer_number->value();
self::allowOwnOrDepartmentAccess(
$permission_own,
$permission_other,
$targetCustomer,
null,
null,
'You do not have permission to modify this subuser grant.'
);
// Update fields provided in the request
if (self::isParametersSet(['enabled'])) {
$enabled = (bool)filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($enabled === null) { $enabled = false; }
$grant->enabled->set($enabled);
}
if (self::isParametersSet(['note'])) {
$note = (string)self::getParameter('note');
self::requireType($note, self::type_string());
self::requireMaxLength('note', 65535);
$grant->note->set($note);
}
if (self::isParametersSet(['permissions'])) {
$raw = self::getParameter('permissions');
// Expect array (already parsed) or JSON string
if (is_string($raw)) {
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
$response->error('Invalid permissions payload', 400);
}
$permissions = $decoded;
} elseif (is_array($raw)) {
$permissions = $raw;
} else {
$response->error('Invalid permissions type', 400);
}
// Validate each permission is a known key
foreach ($permissions as $perm) {
if (!is_string($perm) || subusers_permission_node_key::tryFrom($perm) === null) {
$response->error('Unknown permission key: ' . (string)$perm, 400);
}
}
$grant->permissions->set($permissions);
}
$response->success($grant->asArray());
},
[
'manage_subuser_grants' => 'Edit subuser grants for any customer (admin).',
'edit_own_subusers' => 'Edit subuser grants for own customer. Subusers require node: SUBUSERS_EDIT and X-Customer-Number header.'
]
);
$this->get('/subusers/permission-nodes', function () {
global $response;
// Build groups
$groups = [
new subusers_permission_nodes_bookings(),
new subusers_permission_nodes_orders(),
new subusers_permission_nodes_selfserve(),
new subusers_permission_nodes_subusers(),
new subusers_permission_nodes_vehicles(),
];
$out = [];
foreach ($groups as $group) {
$nodes = [];
foreach ($group->nodes as $key => $node) {
$nodes[] = [
'key' => $key,
'name' => $node->name,
'description' => $node->description,
'type' => $node->type->name,
'default' => (bool)($node->value ?? false),
];
}
$out[] = [
'group' => $group->group_name,
'description' => $group->description,
'nodes' => $nodes,
];
}
$response->success($out);
}, []);
$this->post('/subusers', function () {
global /** @var response $response */
$response;
self::requireParameters([
'cvr',
'phone_country_code',
'phone'
]);
$cvr = (int)self::getParameter('cvr');
$phone_country_code =(int) self::getParameter('phone_country_code');
$phone = (int)self::getParameter('phone');
/** Validation */
// Check if the CVR number is valid (8 digits)
self::requireType($cvr, self::type_int());
self::requireMinLength('cvr', 8);
self::requireMaxLength('cvr', 8);
// Check if the phone country code is valid (1-3 digits)
self::requireType($phone_country_code, self::type_int());
self::requireMinLength('phone_country_code', 1);
self::requireMaxLength('phone_country_code', 3);
// Check if the phone number is valid (4-15 digits)
self::requireType($phone, self::type_int());
self::requireMinLength('phone', 4);
self::requireMaxLength('phone', 15);
// Check if the CVR is registered to a company in the database
$economic = new economic();
$results = $economic->customers->customers->search([
'corporateIdentificationNumber' => (string)$cvr,
],[
'skipPages' => 0,
'pageSize' => 1000, // Since the limit is 1000, we need to set the page size to 1000.
])->collection;
if (count($results) === 0) {
$response->error('Customer not found', 404);
}
/** At this point, we know that the CVR is valid and that the company exists in the database. */
$company_user = (new users_o())->getUserByCustomerNumber((int)$results[0]->customerNumber);
$company_user->requireSelected();
/** We can now check if a subuser already exists with the same phone number. */
$subuser = (new subusers_o())->getSubuserByPhone($phone_country_code, $phone);
if ($subuser !== null) {
$response->error('Account already exists with this phone number', 400);
}
/** We can now create the subuser and send a request to the company user to link the subuser to the company. */
$subuser = (new subusers_o())->add(
null,
null,
null,
null,
(int)$phone_country_code,
(int)$phone
);
// Send an SMS with a link to complete the registration process.
$gatewayAPI = new gatewayapi();
if ($gatewayAPI->isEnabled()) {
$token = $subuser->generateSetupToken();
$link = 'https://truckwash.io/complete-registration?token=' . $token;
$message = 'Tak for din oprettelse af chaufførkonto hos Truck Wash! Klik på linket for at fuldføre registreringen: ' . $link;
$phone_number_array = [(string)$phone_country_code . (string)$phone];
$gatewayAPI->send($phone_number_array, $message);
}
// Add the grant request
$subuser_grants_o = new subuser_grants_o();
try {
$subuser_grants_o->add($results[0]->customerNumber, $subuser->id, false, null);
} catch (Exception $e) {
$response->error('Failed to add subuser grant', 500);
}
$response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber]);
// Code for creating a new subuser would go here
});
$this->get('/subusers/setup', function () {
// Require the user to be logged in
global $response;
self::requireParameters(['token']);
$token = self::getParameter('token');
// Get the subuser with the setup token
$subuser = (new subusers_o())->getSubuserBySetupToken($token);
if ($subuser === null) {
$response->error('Invalid or expired token', 400);
}
$response->success(['message' => 'Token is valid', 'subuser_id' => $subuser->id]);
});
$this->post('/subusers/setup', function () {
// Require the user to be logged in
global $response;
self::requireParameters(['token', 'password', 'name']);
$token = (string)self::getParameter('token');
$password = (string)self::getParameter('password');
$name = (string)self::getParameter('name');
self::requireType($password, self::type_string());
self::requireMinLength('password', 8);
self::requireMaxLength('password', 255);
self::requireRegex($password, '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/', 'Password must contain at least one uppercase letter, one lowercase letter, and one number');
self::requireType($name, self::type_string());
self::requireMinLength('name', 3);
self::requireMaxLength('name', 255);
/**
* Optional fields:
* - username: string, 3-255 characters
* - email: string, valid email format, 3-255 characters
*/
$username = null;
$email = null;
if (self::isParametersSet(['username'])) {
$username = (string)self::getParameter('username');
self::requireType($username, self::type_string());
self::requireMinLength('username', 3);
self::requireMaxLength('username', 255);
}
if (self::isParametersSet(['email'])) {
$email = (string)self::getParameter('email');
self::requireType($email, self::type_string());
self::requireMinLength('email', 3);
self::requireMaxLength('email', 255);
if(!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$response->error('Invalid email format', 400);
}
}
// Get the subuser with the setup token
$subuser = (new subusers_o())->getSubuserBySetupToken($token);
if ($subuser === null) {
$response->error('Invalid or expired token', 400);
}
// Set the password for the subuser
try {
$subuser->setPassword($password);
if (!empty($username) || !empty($email) || !empty($name)) {
$subuser->update([
...(!empty($username) ? ['username' => $username] : []),
...(!empty($email) ? ['email' => $email] : []),
...(!empty($name) ? ['name' => $name] : []),
]);
}
// Invalidate the setup token
$subuser->invalidateSetupToken($token);
$response->success(['message' => 'Complete registration successful']);
} catch (Exception $e) {
$response->error($e->getMessage(), 400);
}
});
$this->post('/subusers/auth/password', function () {
global $response;
/**
* Username types:
* 1. Phone number & country code
* 2. Subuser id
* 3. Username
*/
$phone_country_code = null;
$phone = null;
$subuser_id = null;
$username = null;
if (self::isParametersSet(['phone_country_code', 'phone'])) {
$phone_country_code = (int)self::getParameter('phone_country_code');
$phone = (int)self::getParameter('phone');
self::requireType($phone_country_code, self::type_int());
self::requireMinLength('phone_country_code', 1);
self::requireMaxLength('phone_country_code', 3);
self::requireType($phone, self::type_int());
self::requireMinLength('phone', 4);
self::requireMaxLength('phone', 15);
} elseif (self::isParametersSet(['subuser_id'])) {
$subuser_id = (int)self::getParameter('subuser_id');
self::requireType($subuser_id, self::type_int());
} elseif (self::isParametersSet(['username'])) {
$username = (string)self::getParameter('username');
self::requireType($username, self::type_string());
self::requireMinLength('username', 3);
self::requireMaxLength('username', 255);
} else {
$response->error('You must provide either phone_country_code & phone, subuser_id or username', 400);
}
// Get the subuser based on the provided username type
$subuser = null;
if ($phone_country_code !== null && $phone !== null) {
$subuser = (new subusers_o())->getSubuserByPhone($phone_country_code, $phone);
} elseif ($subuser_id !== null) {
$subuser = (new subusers_o())->select($subuser_id);
} elseif ($username !== null) {
$subuser = (new subusers_o())->getSubuserByUsername($username);
}
if ($subuser === null) {
$response->error('Subuser not found', 404);
}
self::requireParameters(['password']);
$password = (string)self::getParameter('password');
self::requireType($password, self::type_string());
self::requireMinLength('password', 8);
self::requireMaxLength('password', 255);
try {
if (password_verify($password, $subuser->password->value())) {
if ($subuser->isTwoFactorEnabled()) {
$token = (new authentication())->create_2fa_token($subuser->id, '2FA_VERIFICATION_SUBUSER');
$response->success(['2fa_required' => true, '2fa_token' => $token]);
}
// Generate a new session for the subuser
$session = $subuser->generateSession();
$response->success(['session' => $session]);
} else {
$response->error('Invalid password', 400);
}
} catch (Exception $e) {
$response->error($e->getMessage(), 500);
}
});
// =============================
// Subusers - List & Get (with grant visibility)
// =============================
$this->get('/subusers', function () {
global $response;
// Require authenticated principal (user or subuser)
$auth = new authentication();
$user = $auth->get_user();
$subuser = $auth->get_subuser();
if ($user === false && $subuser === false) {
$response->error('Unauthorized', 401);
}
// Link route permission to Subusers node so subusers can be constrained by grants.
// For classic users we keep current behavior (no extra user permission enforced here).
$permission_list = self::definePermission('list_own_subusers', subusers_permission_node_key::SUBUSERS_LIST);
if ($subuser !== false) {
// Only enforce for subuser principals; classic users are governed by existing user ACLs elsewhere.
self::requirePermission($permission_list);
}
// Determine effective customer number (user's customer number or subuser's target header)
if ($user !== false) {
$customerNumber = (int)$user->customer_number->value();
} else {
$customerNumber = (int)$auth->get_subuser_customer_number_target();
}
if (empty($customerNumber)) {
$response->error('Unauthorized', 401);
}
// Optional: include_non_enabled (boolean) — when true, include subusers that only have non-enabled grants
$includeNonEnabled = false;
if (self::isParametersSet(['include_non_enabled'])) {
$tmp = filter_var(self::getParameter('include_non_enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
$includeNonEnabled = $tmp === null ? false : (bool)$tmp;
}
// Only list subusers that have an enabled grant for the caller's customer number
$existsClause = sprintf(
"EXISTS (SELECT 1 FROM `subuser_grants` sg WHERE sg.`subuser` = `subusers`.`id` AND %ssg.`deleted_at` IS NULL AND sg.`billing_customer_number` = %d)",
$includeNonEnabled ? '' : 'sg.`enabled` = 1 AND ',
$customerNumber
);
// Use pagination helper with additional where
$objects = (new subusers_o())
->listObjectsWithPaginationIfSet(function ($o) use ($customerNumber) {
$o = (object)$o;
$permissions = (new subuser_grants_o())
->getGrantsForSubuserAndCustomer((int)$o->id, $customerNumber);
return [
'id' => (int)$o->id,
'username' => $o->username,
'name' => $o->name,
'email' => $o->email,
'phone_country_code' => isset($o->phone_country_code) ? (int)$o->phone_country_code : null,
'phone' => isset($o->phone) ? (int)$o->phone : null,
'created_at' => $o->created_at ?? null,
'updated_at' => $o->updated_at ?? null,
'suspended_at' => $o->suspended_at ?? null,
'permissions' => $permissions,
'two_factor_enabled' => $o->isTwoFactorEnabled(),
];
}, null, [], $existsClause);
$response->success($objects);
}, []);
$this->get('/subusers/me', function () {
global $response;
$subuser = (new authentication())->get_subuser();
if ($subuser === false) {
$response->error('Unauthorized', 401);
}
$grants = (new subuser_grants_o())->getFieldsWhere([
'subuser' => $subuser->id,
'enabled' => 1,
'deleted_at' => null,
], ['permissions', 'billing_customer_number']);
$result = [
"id" => (int)$subuser->id,
"username" => $subuser->username->value(),
"name" => $subuser->name->value(),
"email" => $subuser->email->value(),
"phone_country_code" => $subuser->phone_country_code->value() !== null ? (int)$subuser->phone_country_code->value() : null,
"phone" => $subuser->phone->value() !== null ? (int)$subuser->phone->value() : null,
"grants" => array_map(function ($grant) {
return [
'name' => (new users_o())->getCustomerName((int)$grant['billing_customer_number']),
'billing_customer_number' => (int)$grant['billing_customer_number'],
'permissions' => json_decode($grant['permissions'], true) ?: [],
];
}, $grants),
"created_at" => $subuser->created_at->value() ?? null,
"updated_at" => $subuser->updated_at->value() ?? null,
"suspended_at" => $subuser->suspended_at->value() ?? null,
"two_factor_enabled" => $subuser->isTwoFactorEnabled(),
];
$response->success($result);
}, []);
// Public registration endpoint (alias of POST /subusers) matching OpenAPI: POST /subusers/me
$this->post('/subusers/me', function () {
global /** @var response $response */
$response;
// Required input
self::requireParameters([
'cvr',
'phone_country_code',
'phone'
]);
$cvr = (int)self::getParameter('cvr');
$phone_country_code = (int)self::getParameter('phone_country_code');
$phone = (int)self::getParameter('phone');
// Validation
self::requireType($cvr, self::type_int());
self::requireMinLength('cvr', 8);
self::requireMaxLength('cvr', 8);
self::requireType($phone_country_code, self::type_int());
self::requireMinLength('phone_country_code', 1);
self::requireMaxLength('phone_country_code', 3);
self::requireType($phone, self::type_int());
self::requireMinLength('phone', 4);
self::requireMaxLength('phone', 15);
// Look up company by CVR in e-conomic
$economic = new economic();
$results = $economic->customers->customers->search([
'corporateIdentificationNumber' => (string)$cvr,
], [
'skipPages' => 0,
'pageSize' => 1000,
])->collection;
if (count($results) === 0) {
$response->error('Customer not found', 404);
}
// Ensure company user exists (sanity) and phone is not already registered
$company_user = (new users_o())->getUserByCustomerNumber((int)$results[0]->customerNumber);
$company_user->requireSelected();
$existing = (new subusers_o())->getSubuserByPhone($phone_country_code, $phone);
if ($existing !== null) {
$response->error('Account already exists with this phone number', 400);
}
// Create subuser skeleton
$subuser = (new subusers_o())->add(
null,
null,
null,
null,
(int)$phone_country_code,
(int)$phone
);
// Optionally send SMS with setup link
$gatewayAPI = new gatewayapi();
if ($gatewayAPI->isEnabled()) {
$token = $subuser->generateSetupToken();
$link = 'https://truckwash.io/complete-registration?token=' . $token;
$message = 'Tak for din oprettelse af chaufførkonto hos Truck Wash! Klik på linket for at fuldføre registreringen: ' . $link;
$phone_number_array = [(string)$phone_country_code . (string)$phone];
$gatewayAPI->send($phone_number_array, $message);
}
// Create a pending grant request for the company
$subuser_grants_o = new subuser_grants_o();
try {
$subuser_grants_o->add($results[0]->customerNumber, $subuser->id, false, null);
} catch (Exception $e) {
$response->error('Failed to add subuser grant', 500);
}
$response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber]);
});
}
}