diff --git a/openapi.yaml b/openapi.yaml index de73f5f9..6cedfdae 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2245,6 +2245,44 @@ paths: '500': { $ref: '#/components/responses/InternalServerError' } # Authentication Endpoints + /auth/limited-backoffice-login-grants/exchange: + post: + tags: + - Authentication + summary: Exchange a one-time limited-backoffice employee login grant + description: Exchanges an unexpired, unrevoked grant exactly once for a regular employee bearer session. The grant is invalidated atomically before the session is returned. + operationId: exchangeLimitedBackofficeEmployeeLoginGrant + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [grant] + properties: + grant: + type: string + pattern: '^lbg_[a-f0-9]{64}$' + writeOnly: true + responses: + '200': + description: Grant exchanged + content: + application/json: + schema: + type: object + required: [employee_id, token] + properties: + employee_id: {type: integer} + token: + type: string + description: Sensitive bearer token returned once by a successful grant exchange. + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' + /auth/login: post: tags: @@ -3120,6 +3158,107 @@ paths: properties: token: {type: string} + /limited-backoffice/employees/{employeeId}/login-grants: + post: + tags: + - Limited Backoffice + summary: Create or preflight a one-time employee login grant + description: Requires limited-backoffice employee-management permissions and access to every department assigned to the employee. The bearer is deterministically derived under the server encryption key so an identical idempotent retry can recover the same unconsumed grant after a lost response; only its digest is stored. + operationId: createLimitedBackofficeEmployeeLoginGrant + parameters: + - name: employeeId + in: path + required: true + schema: {type: integer, minimum: 1} + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + purpose: + type: string + enum: [limited_backoffice_employee_login] + default: limited_backoffice_employee_login + ttl_seconds: + type: integer + minimum: 60 + maximum: 900 + default: 300 + idempotency_key: + type: string + minLength: 16 + maxLength: 128 + writeOnly: true + preflight: + type: boolean + default: false + oneOf: + - required: [idempotency_key] + properties: + preflight: + type: boolean + enum: [false] + - required: [preflight] + properties: + preflight: + type: boolean + enum: [true] + responses: + '200': + description: Grant created, safely replayed for the same idempotency key, or request validated in preflight mode + content: + application/json: + schema: + type: object + required: [employee_id, purpose, ttl_seconds, expires_at, one_time, preflight] + properties: + employee_id: {type: integer} + purpose: {type: string} + ttl_seconds: {type: integer} + expires_at: {type: string, format: date-time} + one_time: {type: boolean} + preflight: {type: boolean} + grant_id: {type: string, pattern: '^[a-f0-9]{32}$'} + login_path: + type: string + description: Sensitive fragment URL returned only for a newly created or safely replayed grant. + exchange_path: {type: string} + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + delete: + tags: + - Limited Backoffice + summary: Revoke active one-time employee login grants + operationId: revokeLimitedBackofficeEmployeeLoginGrants + parameters: + - name: employeeId + in: path + required: true + schema: {type: integer, minimum: 1} + responses: + '200': + description: Active grants revoked + content: + application/json: + schema: + type: object + required: [employee_id, revoked_count] + properties: + employee_id: {type: integer} + revoked_count: {type: integer, minimum: 0} + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /limited-backoffice/employees/{employeeId}/login-link: post: tags: diff --git a/services/nginx/app/classes/account_deletion_service.php b/services/nginx/app/classes/account_deletion_service.php index e313df3b..75805a26 100644 --- a/services/nginx/app/classes/account_deletion_service.php +++ b/services/nginx/app/classes/account_deletion_service.php @@ -450,7 +450,25 @@ class account_deletion_service { global $db; if ($type === 'customer') { + if (self::tableExists('limited_backoffice_employees')) { + $employeeLock = $db->query( + "SELECT user_id FROM limited_backoffice_employees + WHERE user_id = $id LIMIT 1 FOR UPDATE" + ); + if ($employeeLock === false) { + throw new \RuntimeException('Unable to lock deleted employee.'); + } + } $this->updateExistingColumns('users', $id, ['deleted_at' => $now]); + if (self::tableExists('limited_backoffice_login_grants')) { + $this->execute( + "UPDATE limited_backoffice_login_grants + SET revoked_at = COALESCE(revoked_at, " . self::sql($now) . ") + WHERE (target_user_id = $id OR actor_user_id = $id) + AND consumed_at IS NULL", + 'Unable to revoke deleted principal login grants.' + ); + } $this->deleteTokensForPrincipal('customer', $id); } else { $this->updateExistingColumns('subusers', $id, ['suspended_at' => $now, 'deleted_at' => $now]); diff --git a/services/nginx/app/classes/limited_backoffice_login_grant_service.php b/services/nginx/app/classes/limited_backoffice_login_grant_service.php new file mode 100644 index 00000000..b8cdd62d --- /dev/null +++ b/services/nginx/app/classes/limited_backoffice_login_grant_service.php @@ -0,0 +1,579 @@ + $payload + * @return array + */ + public function create(users_o $manager, int $employeeId, array $payload): array + { + (new limited_backoffice_service())->assertEmployeeLoginTarget($manager, $employeeId); + + $purpose = trim((string)($payload['purpose'] ?? self::PURPOSE_EMPLOYEE_DIRECT_LOGIN)); + if ($purpose !== self::PURPOSE_EMPLOYEE_DIRECT_LOGIN) { + throw new limited_backoffice_exception('Unsupported login grant purpose.', 400); + } + + $ttlSeconds = $this->ttlSeconds($payload['ttl_seconds'] ?? self::DEFAULT_TTL_SECONDS); + $expiresAt = time() + $ttlSeconds; + $preflight = ($payload['preflight'] ?? false) === true; + + $base = [ + 'employee_id' => $employeeId, + 'purpose' => $purpose, + 'ttl_seconds' => $ttlSeconds, + 'expires_at' => gmdate('c', $expiresAt), + 'one_time' => true, + ]; + if ($preflight) { + return $base + ['preflight' => true]; + } + + $idempotencyKey = trim((string)($payload['idempotency_key'] ?? '')); + if (strlen($idempotencyKey) < 16 || strlen($idempotencyKey) > 128) { + throw new limited_backoffice_exception( + 'idempotency_key must contain between 16 and 128 characters.', + 400 + ); + } + $idempotencyKeyHash = hash('sha256', $idempotencyKey); + + $grantId = bin2hex(random_bytes(16)); + $bearer = $this->bearerForIdempotency( + (int)$manager->id, + $employeeId, + $purpose, + $ttlSeconds, + $idempotencyKey + ); + $secretHash = hash('sha256', $bearer); + + $mysqli = $this->mysqli(); + for ($attempt = 0; $attempt < 3; $attempt++) { + $mysqli->begin_transaction(); + try { + if (!$this->lockActiveManagedEmployee($employeeId)) { + throw new limited_backoffice_exception( + 'Cannot create a login grant for an inactive employee.', + 409 + ); + } + // Target-first ordering matches employee update/deletion. Two + // cross-managing actors can still form a cycle, so deadlock + // victims are retried below with the same idempotency identity. + $authorizedActor = $this->lockAuthorizedActor($manager); + (new limited_backoffice_service())->assertEmployeeLoginTarget( + $authorizedActor['manager'], + $employeeId, + $authorizedActor['group_id'] + ); + + $existing = $this->findIdempotentGrant( + (int)$manager->id, + $employeeId, + $purpose, + $idempotencyKeyHash + ); + if ($existing !== null) { + if ($this->isReplayableGrant($existing, $secretHash)) { + $mysqli->commit(); + return $this->grantResult($employeeId, $purpose, $bearer, $existing); + } + throw $this->duplicateGrantException($existing); + } + + $statement = $mysqli->prepare( + 'INSERT INTO `limited_backoffice_login_grants` + (`grant_id`, `secret_hash`, `target_user_id`, `actor_user_id`, `purpose`, + `idempotency_key_hash`, `expires_at`) + VALUES (?, ?, ?, ?, ?, ?, ?)' + ); + if ($statement === false) { + throw new limited_backoffice_exception('Unable to prepare login grant.', 500); + } + $actorUserId = (int)$manager->id; + $statement->bind_param( + 'ssiissi', + $grantId, + $secretHash, + $employeeId, + $actorUserId, + $purpose, + $idempotencyKeyHash, + $expiresAt + ); + try { + $statement->execute(); + } finally { + $statement->close(); + } + $mysqli->commit(); + break; + } catch (limited_backoffice_exception $exception) { + $mysqli->rollback(); + throw $exception; + } catch (\mysqli_sql_exception $exception) { + $mysqli->rollback(); + $errorCode = (int)$exception->getCode(); + if (in_array($errorCode, [1205, 1213], true) && $attempt < 2) { + usleep(1000 * ($attempt + 1)); + continue; + } + if ($errorCode === 1062) { + $existing = $this->findIdempotentGrant( + (int)$manager->id, + $employeeId, + $purpose, + $idempotencyKeyHash + ); + if ($existing !== null) { + if ($this->isReplayableGrant($existing, $secretHash)) { + return $this->grantResult($employeeId, $purpose, $bearer, $existing); + } + throw $this->duplicateGrantException($existing); + } + } + throw new limited_backoffice_exception('Unable to create login grant.', 500); + } catch (\Throwable) { + $mysqli->rollback(); + throw new limited_backoffice_exception('Unable to create login grant.', 500); + } + } + + $this->audit( + (int)$manager->id, + 'AUTH_SUCCESS_LIMITED_BACKOFFICE_LOGIN_GRANT_CREATED', + 'Created one-time login grant ' . $grantId . ' for employee: ' . $employeeId + ); + + return $this->grantResult($employeeId, $purpose, $bearer, [ + 'grant_id' => $grantId, + 'expires_at' => $expiresAt, + ]); + } + + /** + * @return array{employee_id:int,token:string} + */ + public function exchange(string $bearer): array + { + if (!preg_match('/^lbg_[a-f0-9]{64}$/', $bearer)) { + throw $this->invalidGrantException(); + } + + $mysqli = $this->mysqli(); + $secretHash = hash('sha256', $bearer); + $mysqli->begin_transaction(); + + try { + // Resolve the target without locking, then lock employee -> grant. Employee + // deactivation uses the same order, preventing a direct-login session from + // surviving a concurrent deactivation and avoiding inverse-order deadlocks. + $targetLookup = $mysqli->prepare( + 'SELECT `target_user_id` + FROM `limited_backoffice_login_grants` + WHERE `secret_hash` = ? + LIMIT 1' + ); + if ($targetLookup === false) { + throw new \RuntimeException('Unable to prepare login grant target lookup.'); + } + $targetLookup->bind_param('s', $secretHash); + $targetLookup->execute(); + $target = $targetLookup->get_result()->fetch_assoc() ?: null; + $targetLookup->close(); + if ($target === null || !$this->lockActiveManagedEmployee((int)$target['target_user_id'])) { + throw $this->invalidGrantException(); + } + + $statement = $mysqli->prepare( + 'SELECT `id`, `grant_id`, `target_user_id`, `purpose`, `expires_at`, + `consumed_at`, `revoked_at` + FROM `limited_backoffice_login_grants` + WHERE `secret_hash` = ? + LIMIT 1 + FOR UPDATE' + ); + if ($statement === false) { + throw new \RuntimeException('Unable to prepare login grant exchange.'); + } + $statement->bind_param('s', $secretHash); + $statement->execute(); + $row = $statement->get_result()->fetch_assoc() ?: null; + $statement->close(); + + if ( + $row === null + || $row['purpose'] !== self::PURPOSE_EMPLOYEE_DIRECT_LOGIN + || $row['consumed_at'] !== null + || $row['revoked_at'] !== null + || (int)$row['expires_at'] <= time() + || (int)$row['target_user_id'] !== (int)$target['target_user_id'] + ) { + throw $this->invalidGrantException(); + } + + $grantRowId = (int)$row['id']; + $consume = $mysqli->prepare( + 'UPDATE `limited_backoffice_login_grants` + SET `consumed_at` = UTC_TIMESTAMP() + WHERE `id` = ? AND `consumed_at` IS NULL AND `revoked_at` IS NULL + LIMIT 1' + ); + if ($consume === false) { + throw new \RuntimeException('Unable to prepare login grant consumption.'); + } + $consume->bind_param('i', $grantRowId); + $consume->execute(); + $affectedRows = $consume->affected_rows; + $consume->close(); + if ($affectedRows !== 1) { + throw $this->invalidGrantException(); + } + + $employeeId = (int)$row['target_user_id']; + $token = (new authentication())->create_employee_token($employeeId); + $mysqli->commit(); + } catch (limited_backoffice_exception $exception) { + $mysqli->rollback(); + throw $exception; + } catch (\Throwable) { + $mysqli->rollback(); + throw new limited_backoffice_exception('Unable to exchange login grant.', 500); + } + + $this->audit( + $employeeId, + 'AUTH_SUCCESS_LIMITED_BACKOFFICE_LOGIN_GRANT_EXCHANGED', + 'Exchanged one-time login grant ' . (string)$row['grant_id'] . ' for employee: ' . $employeeId + ); + + return ['employee_id' => $employeeId, 'token' => $token]; + } + + /** + * @return array{employee_id:int,revoked_count:int} + */ + public function revokeForEmployee(users_o $manager, int $employeeId): array + { + (new limited_backoffice_service())->assertEmployeeLoginTarget($manager, $employeeId); + + $statement = $this->mysqli()->prepare( + 'UPDATE `limited_backoffice_login_grants` + SET `revoked_at` = UTC_TIMESTAMP() + WHERE `target_user_id` = ? + AND `consumed_at` IS NULL + AND `revoked_at` IS NULL + AND `expires_at` >= ?' + ); + if ($statement === false) { + throw new limited_backoffice_exception('Unable to prepare login grant revocation.', 500); + } + $now = time(); + $statement->bind_param('ii', $employeeId, $now); + $statement->execute(); + $revokedCount = $statement->affected_rows; + $statement->close(); + + $this->audit( + (int)$manager->id, + 'AUTH_SUCCESS_LIMITED_BACKOFFICE_LOGIN_GRANTS_REVOKED', + 'Revoked ' . $revokedCount . ' login grants for employee: ' . $employeeId + ); + + return ['employee_id' => $employeeId, 'revoked_count' => $revokedCount]; + } + + private function ttlSeconds(mixed $value): int + { + if (is_string($value) && ctype_digit($value)) { + $value = (int)$value; + } + if (!is_int($value) || $value < self::MIN_TTL_SECONDS || $value > self::MAX_TTL_SECONDS) { + throw new limited_backoffice_exception( + 'ttl_seconds must be between ' . self::MIN_TTL_SECONDS . ' and ' . self::MAX_TTL_SECONDS . '.', + 400 + ); + } + return $value; + } + + /** + * @return array|null + */ + private function findIdempotentGrant( + int $actorUserId, + int $employeeId, + string $purpose, + string $idempotencyKeyHash + ): ?array { + $statement = $this->mysqli()->prepare( + 'SELECT `grant_id`, `secret_hash`, `target_user_id`, `purpose`, `expires_at`, + `consumed_at`, `revoked_at` + FROM `limited_backoffice_login_grants` + WHERE `actor_user_id` = ? + AND `target_user_id` = ? + AND `purpose` = ? + AND `idempotency_key_hash` = ? + LIMIT 1' + ); + if ($statement === false) { + throw new limited_backoffice_exception('Unable to check login grant idempotency.', 500); + } + $statement->bind_param('iiss', $actorUserId, $employeeId, $purpose, $idempotencyKeyHash); + $statement->execute(); + $row = $statement->get_result()->fetch_assoc() ?: null; + $statement->close(); + return $row; + } + + private function bearerForIdempotency( + int $actorUserId, + int $employeeId, + string $purpose, + int $ttlSeconds, + string $idempotencyKey + ): string { + $key = trim((string)($GLOBALS['ENCRYPTION_KEY'] ?? getenv('ENCRYPTION_KEY') ?: '')); + if ($key === '') { + throw new limited_backoffice_exception('Login grant encryption key is unavailable.', 503); + } + return 'lbg_' . hash_hmac( + 'sha256', + $actorUserId . ':' . $employeeId . ':' . $purpose . ':' . $ttlSeconds . ':' . $idempotencyKey, + $key + ); + } + + private function isReplayableGrant(array $row, string $secretHash): bool + { + return hash_equals((string)($row['secret_hash'] ?? ''), $secretHash) + && ($row['consumed_at'] ?? null) === null + && ($row['revoked_at'] ?? null) === null + && (int)($row['expires_at'] ?? 0) > time(); + } + + /** + * @param array $row + * @return array + */ + private function grantResult( + int $employeeId, + string $purpose, + string $bearer, + array $row + ): array { + $expiresAt = (int)$row['expires_at']; + return [ + 'employee_id' => $employeeId, + 'purpose' => $purpose, + 'ttl_seconds' => max(0, $expiresAt - time()), + 'expires_at' => gmdate('c', $expiresAt), + 'one_time' => true, + 'preflight' => false, + 'grant_id' => (string)$row['grant_id'], + // The fragment avoids ingress request logs and Referer propagation. + 'login_path' => '/login/qr#grant=' . rawurlencode($bearer), + 'exchange_path' => '/auth/limited-backoffice-login-grants/exchange', + ]; + } + + private function duplicateGrantException(array $row): limited_backoffice_exception + { + return new limited_backoffice_exception( + 'A login grant already exists for this idempotency key.', + 409, + [ + 'message' => 'A login grant already exists for this idempotency key.', + 'code' => 'LOGIN_GRANT_IDEMPOTENCY_CONFLICT', + 'grant_id' => (string)$row['grant_id'], + 'employee_id' => (int)$row['target_user_id'], + 'purpose' => (string)$row['purpose'], + 'expires_at' => gmdate('c', (int)$row['expires_at']), + 'consumed' => $row['consumed_at'] !== null, + 'revoked' => $row['revoked_at'] !== null, + ] + ); + } + + private function invalidGrantException(): limited_backoffice_exception + { + return new limited_backoffice_exception('Invalid or expired login grant.', 401); + } + + private function lockActiveManagedEmployee(int $employeeId): bool + { + $statement = $this->mysqli()->prepare( + 'SELECT lbe.`user_id`, lbe.`managed_group_id`, u.`group_id` + FROM `limited_backoffice_employees` lbe + INNER JOIN `users` u ON u.`id` = lbe.`user_id` + WHERE lbe.`user_id` = ? AND lbe.`deactivated_at` IS NULL + LIMIT 1 + FOR UPDATE' + ); + if ($statement === false) { + throw new limited_backoffice_exception('Unable to lock login grant employee.', 500); + } + $statement->bind_param('i', $employeeId); + $statement->execute(); + $employee = $statement->get_result()->fetch_assoc() ?: null; + $statement->close(); + if ($employee === null) { + return false; + } + + $groupId = (int)$employee['group_id']; + $managedGroupId = (int)$employee['managed_group_id']; + if ($groupId <= 0 || $groupId === 1 || $managedGroupId !== $groupId) { + throw new limited_backoffice_exception('Login grant target is no longer safe.', 403); + } + + // Lock the complete permission range so role changes cannot add elevated + // capabilities between validation and token creation. + $permissions = $this->mysqli()->prepare( + 'SELECT `permission` + FROM `groups_permissions` + WHERE `group_id` = ? + FOR UPDATE' + ); + if ($permissions === false) { + throw new limited_backoffice_exception('Unable to validate login grant role.', 500); + } + $permissions->bind_param('i', $groupId); + $permissions->execute(); + $result = $permissions->get_result(); + while ($row = $result->fetch_assoc()) { + if ((string)($row['permission'] ?? '') === 'superuser') { + $permissions->close(); + throw new limited_backoffice_exception('Login grant target is no longer safe.', 403); + } + } + $permissions->close(); + + $groupUsers = $this->mysqli()->prepare( + 'SELECT `id` FROM `users` WHERE `group_id` = ? FOR UPDATE' + ); + if ($groupUsers === false) { + throw new limited_backoffice_exception('Unable to validate login grant group.', 500); + } + $groupUsers->bind_param('i', $groupId); + $groupUsers->execute(); + $groupUserResult = $groupUsers->get_result(); + $userCount = 0; + while ($groupUserResult->fetch_assoc() !== null) { + $userCount++; + } + $groupUsers->close(); + if ($userCount !== 1) { + throw new limited_backoffice_exception('Login grant target group is shared.', 403); + } + + return true; + } + + /** + * @return array{manager:users_o,group_id:int} + */ + private function lockAuthorizedActor(users_o $manager): array + { + $actorUserId = (int)$manager->id; + $statement = $this->mysqli()->prepare( + 'SELECT `group_id` FROM `users` WHERE `id` = ? LIMIT 1 FOR UPDATE' + ); + if ($statement === false) { + throw new limited_backoffice_exception('Unable to lock login grant actor.', 500); + } + $statement->bind_param('i', $actorUserId); + $statement->execute(); + $actor = $statement->get_result()->fetch_assoc() ?: null; + $statement->close(); + if ( + $actor === null + || (int)$actor['group_id'] <= 0 + || account_deletion_service::principalIsBlocked('customer', $actorUserId) + ) { + throw new limited_backoffice_exception('Login grant actor is no longer authorized.', 403); + } + + $groupId = (int)$actor['group_id']; + if ($groupId !== 1) { + $requiredPermissions = [ + limited_backoffice_service::PERMISSION_ACCESS, + limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES, + ]; + // Lock the actor's complete permission set, including every department_access_* row + // consumed by assertEmployeeLoginTarget. + // The group_id range lock prevents concurrent role replacement + // from revoking scope between authorization and grant insertion. + $permissions = $this->mysqli()->prepare( + 'SELECT `permission` + FROM `groups_permissions` + WHERE `group_id` = ? + FOR UPDATE' + ); + if ($permissions === false) { + throw new limited_backoffice_exception('Unable to validate login grant actor.', 500); + } + $permissions->bind_param('i', $groupId); + $permissions->execute(); + $result = $permissions->get_result(); + $granted = []; + while ($row = $result->fetch_assoc()) { + $granted[] = (string)$row['permission']; + } + $permissions->close(); + if (array_diff($requiredPermissions, $granted) !== []) { + throw new limited_backoffice_exception( + 'Login grant actor is no longer authorized.', + 403 + ); + } + } + + $currentManager = (new users_o())->getUserById($actorUserId); + if (!$currentManager->exists()) { + throw new limited_backoffice_exception('Login grant actor is no longer authorized.', 403); + } + return [ + 'manager' => $currentManager, + 'group_id' => $groupId, + ]; + } + + private function audit(int $actorUserId, string $event, string $message): void + { + try { + (new logs_o())->add('auth', 'global', 1, $actorUserId, $event, $message); + } catch (\Throwable) { + // Audit logging must not expose a bearer or block the grant lifecycle. + } + } + + private function mysqli(): \mysqli + { + global $db; + return $db->conn(); + } +} diff --git a/services/nginx/app/classes/limited_backoffice_schema_bootstrap.php b/services/nginx/app/classes/limited_backoffice_schema_bootstrap.php index 71b55816..7144d846 100644 --- a/services/nginx/app/classes/limited_backoffice_schema_bootstrap.php +++ b/services/nginx/app/classes/limited_backoffice_schema_bootstrap.php @@ -36,6 +36,45 @@ CREATE TABLE IF NOT EXISTS `limited_backoffice_employees` ( KEY `idx_limited_backoffice_employees_role_key` (`role_key`), KEY `idx_limited_backoffice_employees_deactivated_at` (`deactivated_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL); + + $db->query(<<<'SQL' +CREATE TABLE IF NOT EXISTS `limited_backoffice_login_grants` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `grant_id` CHAR(32) NOT NULL, + `secret_hash` CHAR(64) NOT NULL, + `target_user_id` INT NOT NULL, + `actor_user_id` INT NOT NULL, + `purpose` VARCHAR(64) NOT NULL, + `idempotency_key_hash` CHAR(64) NULL, + `expires_at` BIGINT UNSIGNED NOT NULL, + `consumed_at` DATETIME NULL, + `revoked_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_limited_backoffice_login_grants_grant_id` (`grant_id`), + UNIQUE KEY `uniq_limited_backoffice_login_grants_secret_hash` (`secret_hash`), + UNIQUE KEY `uniq_limited_backoffice_login_grants_idempotency` (`actor_user_id`, `target_user_id`, `purpose`, `idempotency_key_hash`), + KEY `idx_limited_backoffice_login_grants_target` (`target_user_id`, `expires_at`), + KEY `idx_limited_backoffice_login_grants_expiry` (`expires_at`), + KEY `idx_limited_backoffice_login_grants_state` (`consumed_at`, `revoked_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL); + + $db->query(<<<'SQL' +CREATE TABLE IF NOT EXISTS `limited_backoffice_action_idempotency` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `actor_user_id` INT NOT NULL, + `action_type` VARCHAR(64) NOT NULL, + `idempotency_key_hash` CHAR(64) NOT NULL, + `payload_hash` CHAR(64) NOT NULL, + `result_user_id` INT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_limited_backoffice_action_idempotency` + (`actor_user_id`, `action_type`, `idempotency_key_hash`), + KEY `idx_limited_backoffice_action_result` (`result_user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci SQL); self::$initialized = true; diff --git a/services/nginx/app/classes/limited_backoffice_service.php b/services/nginx/app/classes/limited_backoffice_service.php index ec8244f1..7b51a8d3 100644 --- a/services/nginx/app/classes/limited_backoffice_service.php +++ b/services/nginx/app/classes/limited_backoffice_service.php @@ -628,12 +628,26 @@ class limited_backoffice_service } if ($user->hasPermission('superuser')) { - global $db; - $rows = $db->fetch_all($db->query( - 'SELECT `id` FROM `departments` ORDER BY `id` ASC' - )); + return $this->allDepartmentIds(); + } - return array_values(array_map(static fn(array $row): int => (int)$row['id'], $rows)); + return $this->accessibleDepartmentIdsForGroup($groupId); + } + + /** + * Loads department scope from an authoritative group identity rather than + * from user object properties that may be backed by a stale Redis value. + * + * @return array + */ + public function accessibleDepartmentIdsForGroup(int $groupId): array + { + if ($groupId <= 0) { + return []; + } + + if ($groupId === 1) { + return $this->allDepartmentIds(); } global $db; @@ -653,6 +667,9 @@ class limited_backoffice_service $departmentIds = []; foreach ($rows as $row) { $permission = (string)($row['permission'] ?? ''); + if ($permission === 'superuser') { + return $this->allDepartmentIds(); + } if (preg_match('/^department_access_([0-9]+)$/', $permission, $matches) !== 1) { continue; } @@ -666,6 +683,19 @@ class limited_backoffice_service return array_values(array_unique($departmentIds)); } + /** + * @return array + */ + private function allDepartmentIds(): array + { + global $db; + $rows = $db->fetch_all($db->query( + 'SELECT `id` FROM `departments` ORDER BY `id` ASC' + )); + + return array_values(array_map(static fn(array $row): int => (int)$row['id'], $rows)); + } + /** * @return array> */ @@ -899,11 +929,65 @@ class limited_backoffice_service $password = $this->normalizePassword($payload['password'] ?? null, true); $email = $this->normalizeEmail($payload['email'] ?? null, true); $phone = $this->normalizeOptionalPhonePair($payload); + $idempotencyKey = trim((string)($payload['idempotency_key'] ?? '')); + if ($idempotencyKey !== '' && (strlen($idempotencyKey) < 16 || strlen($idempotencyKey) > 128)) { + throw new limited_backoffice_exception( + 'idempotency_key must contain between 16 and 128 characters.', + 400 + ); + } + $idempotencyKeyHash = $idempotencyKey === '' ? null : hash('sha256', $idempotencyKey); + $payloadJson = (string)json_encode([ + 'department_ids' => $departmentIds, + 'role_key' => $roleKey, + 'display_name' => $displayName, + 'password' => $password, + 'email' => $email, + 'phone_country_code' => $phone['phone_country_code'], + 'phone' => $phone['phone'], + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $payloadHash = $idempotencyKeyHash === null + ? null + : hash_hmac('sha256', $payloadJson, $this->idempotencyDigestKey()); $mysqli = $this->mysqli(); $mysqli->begin_transaction(); try { + if ($idempotencyKeyHash !== null) { + $replayedEmployeeId = $this->reserveEmployeeCreateIdempotency( + (int)$manager->id, + $idempotencyKeyHash, + $payloadHash + ); + if ($replayedEmployeeId !== null) { + $employee = $this->loadManagedEmployee($replayedEmployeeId); + if ($employee === null) { + throw new limited_backoffice_exception( + 'Idempotent employee result is unavailable.', + 409 + ); + } + $currentDepartmentIds = $this->decodeDepartmentIds( + (string)$employee['department_ids'] + ); + $this->assertDepartmentSubset($manager, $currentDepartmentIds); + $this->assertManagedTargetIsSafe($employee); + if (!$this->isEmployeeRowActive($employee)) { + throw new limited_backoffice_exception( + 'Idempotent employee result is no longer active.', + 409 + ); + } + $mysqli->commit(); + return $this->formatEmployee( + $employee, + $currentDepartmentIds, + $this->isEmployeeRowActive($employee) + ); + } + } + $groupId = $this->insertManagedGroup($manager, $roleKey, $departmentIds); $customerNumber = self::MANAGED_EMPLOYEE_CUSTOMER_NUMBER; $passwordHash = password_hash($password, PASSWORD_DEFAULT); @@ -950,7 +1034,35 @@ class limited_backoffice_service $statement->execute(); $statement->close(); + if ($idempotencyKeyHash !== null) { + $statement = $mysqli->prepare( + 'UPDATE `limited_backoffice_action_idempotency` + SET `result_user_id` = ? + WHERE `actor_user_id` = ? + AND `action_type` = ? + AND `idempotency_key_hash` = ? + LIMIT 1' + ); + if ($statement === false) { + throw new \RuntimeException('Unable to prepare employee idempotency result.'); + } + $managerId = (int)$manager->id; + $actionType = 'employee.create'; + $statement->bind_param( + 'iiss', + $employeeId, + $managerId, + $actionType, + $idempotencyKeyHash + ); + $statement->execute(); + $statement->close(); + } + $mysqli->commit(); + } catch (limited_backoffice_exception $exception) { + $mysqli->rollback(); + throw $exception; } catch (\Throwable) { $mysqli->rollback(); throw new limited_backoffice_exception('Unable to create employee.', 500); @@ -964,6 +1076,66 @@ class limited_backoffice_service return $this->formatEmployee($employee, $departmentIds, true); } + private function reserveEmployeeCreateIdempotency( + int $actorUserId, + string $keyHash, + string $payloadHash + ): ?int { + $mysqli = $this->mysqli(); + $actionType = 'employee.create'; + $statement = $mysqli->prepare( + 'INSERT INTO `limited_backoffice_action_idempotency` + (`actor_user_id`, `action_type`, `idempotency_key_hash`, `payload_hash`) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE `id` = `id`' + ); + if ($statement === false) { + throw new \RuntimeException('Unable to prepare employee idempotency reservation.'); + } + $statement->bind_param('isss', $actorUserId, $actionType, $keyHash, $payloadHash); + $statement->execute(); + $statement->close(); + + $statement = $mysqli->prepare( + 'SELECT `payload_hash`, `result_user_id` + FROM `limited_backoffice_action_idempotency` + WHERE `actor_user_id` = ? + AND `action_type` = ? + AND `idempotency_key_hash` = ? + LIMIT 1 + FOR UPDATE' + ); + if ($statement === false) { + throw new \RuntimeException('Unable to prepare employee idempotency lookup.'); + } + $statement->bind_param('iss', $actorUserId, $actionType, $keyHash); + $statement->execute(); + $row = $statement->get_result()->fetch_assoc() ?: null; + $statement->close(); + if ($row === null) { + throw new \RuntimeException('Unable to load employee idempotency reservation.'); + } + if (!hash_equals((string)$row['payload_hash'], $payloadHash)) { + throw new limited_backoffice_exception( + 'Idempotency key was already used with a different employee payload.', + 409 + ); + } + return $row['result_user_id'] === null ? null : (int)$row['result_user_id']; + } + + private function idempotencyDigestKey(): string + { + $key = trim((string)($GLOBALS['ENCRYPTION_KEY'] ?? getenv('ENCRYPTION_KEY') ?: '')); + if ($key === '') { + throw new limited_backoffice_exception( + 'Employee idempotency protection is not configured.', + 500 + ); + } + return hash_hmac('sha256', 'limited-backoffice-employee-idempotency-v1', $key, true); + } + /** * @param array $payload * @return array @@ -1082,6 +1254,25 @@ class limited_backoffice_service $mysqli->begin_transaction(); try { + $lock = $mysqli->prepare( + 'SELECT lbe.`user_id` + FROM `limited_backoffice_employees` lbe + INNER JOIN `users` u ON u.`id` = lbe.`user_id` + WHERE lbe.`user_id` = ? AND lbe.`managed_group_id` = ? + LIMIT 1 + FOR UPDATE' + ); + if ($lock === false) { + throw new \RuntimeException('Unable to prepare employee update lock.'); + } + $lock->bind_param('ii', $employeeId, $managedGroupId); + $lock->execute(); + $lockedEmployee = $lock->get_result()->fetch_assoc() ?: null; + $lock->close(); + if ($lockedEmployee === null) { + throw new limited_backoffice_exception('Managed employee changed before update.', 409); + } + if ($active) { $this->replaceGroupPermissions($managedGroupId, $this->permissionsForRoleAndDepartments($manager, $roleKey, $newDepartmentIds)); } @@ -1161,20 +1352,7 @@ class limited_backoffice_service */ public function createEmployeeLoginLink(users_o $manager, int $employeeId): array { - $this->assertNotSelfEdit($manager, $employeeId); - - $employee = $this->loadManagedEmployee($employeeId); - if ($employee === null) { - throw new limited_backoffice_exception('Managed employee not found.', 404); - } - - $departmentIds = $this->decodeDepartmentIds((string)$employee['department_ids']); - $this->assertDepartmentSubset($manager, $departmentIds); - $this->assertManagedTargetIsSafe($employee); - - if (!$this->isEmployeeRowActive($employee)) { - throw new limited_backoffice_exception('Cannot create a login link for an inactive employee.', 409); - } + $this->assertEmployeeLoginTarget($manager, $employeeId); $token = (new authentication())->create_employee_token($employeeId); @@ -1197,6 +1375,31 @@ class limited_backoffice_service ]; } + /** + * Applies the same target and department boundary used by the legacy login-link endpoint. + */ + public function assertEmployeeLoginTarget( + users_o $manager, + int $employeeId, + ?int $authoritativeGroupId = null + ): void + { + $this->assertNotSelfEdit($manager, $employeeId); + + $employee = $this->loadManagedEmployee($employeeId); + if ($employee === null) { + throw new limited_backoffice_exception('Managed employee not found.', 404); + } + + $departmentIds = $this->decodeDepartmentIds((string)$employee['department_ids']); + $this->assertDepartmentSubset($manager, $departmentIds, $authoritativeGroupId); + $this->assertManagedTargetIsSafe($employee); + + if (!$this->isEmployeeRowActive($employee)) { + throw new limited_backoffice_exception('Cannot create a login link for an inactive employee.', 409); + } + } + private function mysqli(): mysqli { global $db; @@ -1452,13 +1655,19 @@ class limited_backoffice_service /** * @param array $departmentIds */ - private function assertDepartmentSubset(users_o $manager, array $departmentIds): void + private function assertDepartmentSubset( + users_o $manager, + array $departmentIds, + ?int $authoritativeGroupId = null + ): void { if ($departmentIds === []) { throw new limited_backoffice_exception('At least one department is required.', 400); } - $managerDepartmentIds = $this->accessibleDepartmentIds($manager); + $managerDepartmentIds = $authoritativeGroupId === null + ? $this->accessibleDepartmentIds($manager) + : $this->accessibleDepartmentIdsForGroup($authoritativeGroupId); $outside = array_values(array_diff($departmentIds, $managerDepartmentIds)); if ($outside !== []) { $permissions = array_map(static fn(int $id): string => 'department_access_' . $id, $outside); @@ -2055,6 +2264,13 @@ class limited_backoffice_service } } $db->query('DELETE FROM `tokens` WHERE `user_id` = ' . (int)$userId); + $db->query( + 'UPDATE `limited_backoffice_login_grants` + SET `revoked_at` = UTC_TIMESTAMP() + WHERE `target_user_id` = ' . (int)$userId . ' + AND `consumed_at` IS NULL + AND `revoked_at` IS NULL' + ); $this->clearUserSessionCache($userId); } diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index ae04a4b6..813157f7 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -2715,6 +2715,44 @@ paths: '500': { $ref: '#/components/responses/InternalServerError' } # Authentication Endpoints + /auth/limited-backoffice-login-grants/exchange: + post: + tags: + - Authentication + summary: Exchange a one-time limited-backoffice employee login grant + description: Exchanges an unexpired, unrevoked grant exactly once for a regular employee bearer session. The grant is invalidated atomically before the session is returned. + operationId: exchangeLimitedBackofficeEmployeeLoginGrant + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [grant] + properties: + grant: + type: string + pattern: '^lbg_[a-f0-9]{64}$' + writeOnly: true + responses: + '200': + description: Grant exchanged + content: + application/json: + schema: + type: object + required: [employee_id, token] + properties: + employee_id: {type: integer} + token: + type: string + description: Sensitive bearer token returned once by a successful grant exchange. + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' + /auth/login: post: tags: @@ -14481,6 +14519,107 @@ paths: '409': $ref: '#/components/responses/Conflict' + /limited-backoffice/employees/{employeeId}/login-grants: + post: + tags: + - Limited Backoffice + summary: Create or preflight a one-time employee login grant + description: Requires limited-backoffice employee-management permissions and access to every department assigned to the employee. The bearer is deterministically derived under the server encryption key so an identical idempotent retry can recover the same unconsumed grant after a lost response; only its digest is stored. + operationId: createLimitedBackofficeEmployeeLoginGrant + parameters: + - name: employeeId + in: path + required: true + schema: {type: integer, minimum: 1} + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + purpose: + type: string + enum: [limited_backoffice_employee_login] + default: limited_backoffice_employee_login + ttl_seconds: + type: integer + minimum: 60 + maximum: 900 + default: 300 + idempotency_key: + type: string + minLength: 16 + maxLength: 128 + writeOnly: true + preflight: + type: boolean + default: false + oneOf: + - required: [idempotency_key] + properties: + preflight: + type: boolean + enum: [false] + - required: [preflight] + properties: + preflight: + type: boolean + enum: [true] + responses: + '200': + description: Grant created, safely replayed for the same idempotency key, or request validated in preflight mode + content: + application/json: + schema: + type: object + required: [employee_id, purpose, ttl_seconds, expires_at, one_time, preflight] + properties: + employee_id: {type: integer} + purpose: {type: string} + ttl_seconds: {type: integer} + expires_at: {type: string, format: date-time} + one_time: {type: boolean} + preflight: {type: boolean} + grant_id: {type: string, pattern: '^[a-f0-9]{32}$'} + login_path: + type: string + description: Sensitive fragment URL returned only for a newly created or safely replayed grant. + exchange_path: {type: string} + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + delete: + tags: + - Limited Backoffice + summary: Revoke active one-time employee login grants + operationId: revokeLimitedBackofficeEmployeeLoginGrants + parameters: + - name: employeeId + in: path + required: true + schema: {type: integer, minimum: 1} + responses: + '200': + description: Active grants revoked + content: + application/json: + schema: + type: object + required: [employee_id, revoked_count] + properties: + employee_id: {type: integer} + revoked_count: {type: integer, minimum: 0} + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /superuser/department/variables: get: tags: diff --git a/services/nginx/app/routes/authRoute.php b/services/nginx/app/routes/authRoute.php index baa4be8c..1d6525b0 100644 --- a/services/nginx/app/routes/authRoute.php +++ b/services/nginx/app/routes/authRoute.php @@ -6,6 +6,8 @@ use classes\authentication; use classes\account_deletion_service; use classes\economic; use classes\email; +use classes\limited_backoffice_exception; +use classes\limited_backoffice_login_grant_service; use classes\release_manager; use classes\recaptcha; use classes\security_policy_service; @@ -24,6 +26,7 @@ use traits\route_t; require_once WD . '/classes/security_policy_service.php'; require_once WD . '/classes/account_deletion_service.php'; +require_once WD . '/classes/limited_backoffice_login_grant_service.php'; class authRoute { @@ -97,6 +100,18 @@ class authRoute public function run(): void { + $this->post('/auth/limited-backoffice-login-grants/exchange', function () { + global $response; + + try { + $payload = json_decode(file_get_contents('php://input'), true); + $grant = is_array($payload) ? trim((string)($payload['grant'] ?? '')) : ''; + $response->success((new limited_backoffice_login_grant_service())->exchange($grant)); + } catch (limited_backoffice_exception $exception) { + $response->error($exception->payload(), $exception->statusCode()); + } + }); + $this->post('/auth/login', function () { // Get the post data global $response; diff --git a/services/nginx/app/routes/limitedBackofficeRoute.php b/services/nginx/app/routes/limitedBackofficeRoute.php index d7cc64e0..ea11024b 100644 --- a/services/nginx/app/routes/limitedBackofficeRoute.php +++ b/services/nginx/app/routes/limitedBackofficeRoute.php @@ -4,9 +4,12 @@ namespace routes; use classes\authentication; use classes\limited_backoffice_exception; +use classes\limited_backoffice_login_grant_service; use classes\limited_backoffice_service; use traits\route_t; +require_once WD . '/classes/limited_backoffice_login_grant_service.php'; + class limitedBackofficeRoute { use route_t; @@ -125,6 +128,35 @@ class limitedBackofficeRoute limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees', ]); + $this->post('/limited-backoffice/employees/{employeeId}/login-grants', function () { + $this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array { + $this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS); + $this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES); + return (new limited_backoffice_login_grant_service())->create( + $user, + $this->routePositiveInt('employeeId'), + $this->requestPayload() + ); + }); + }, [ + limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice', + limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees', + ]); + + $this->delete('/limited-backoffice/employees/{employeeId}/login-grants', function () { + $this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array { + $this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS); + $this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES); + return (new limited_backoffice_login_grant_service())->revokeForEmployee( + $user, + $this->routePositiveInt('employeeId') + ); + }); + }, [ + limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice', + limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees', + ]); + $this->put('/limited-backoffice/employees/{employeeId}', function () { $this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array { $this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS); diff --git a/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php b/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php index 0e914838..d39bce5e 100644 --- a/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php +++ b/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php @@ -144,6 +144,8 @@ function limited_backoffice_cleanup_created_employee(int $employeeId): void ); $groupId = (int)($row['managed_group_id'] ?? 0); + api_fixtures()->cleanupDeleteWhere('limited_backoffice_login_grants', ['target_user_id' => $employeeId]); + api_fixtures()->cleanupDeleteWhere('limited_backoffice_action_idempotency', ['result_user_id' => $employeeId]); api_fixtures()->cleanupDeleteWhere('limited_backoffice_employees', ['user_id' => $employeeId]); api_fixtures()->cleanupDeleteWhere('tokens', ['user_id' => $employeeId]); api_fixtures()->cleanupDeleteById('users', $employeeId); @@ -949,7 +951,8 @@ it('creates updates lists and deactivates scoped employees without exposing raw expect($rolePayloadStrings)->not->toContain($rawPermission); } - $created = api_client()->post('/limited-backoffice/employees', [ + $employeeIdempotencyKey = 'limited-employee-create-' . bin2hex(random_bytes(12)); + $employeePayload = [ 'display_name' => 'Limited Cashier', 'email' => 'limited-cashier@example.test', 'phone_country_code' => 45, @@ -957,7 +960,9 @@ it('creates updates lists and deactivates scoped employees without exposing raw 'password' => 'Secret123!', 'role_key' => 'cashier', 'department_ids' => [(int)$department['id']], - ], $session['headers']); + 'idempotency_key' => $employeeIdempotencyKey, + ]; + $created = api_client()->post('/limited-backoffice/employees', $employeePayload, $session['headers']); $created ->assertStatus(200) @@ -967,6 +972,29 @@ it('creates updates lists and deactivates scoped employees without exposing raw $employeeId = (int)($created->data()['id'] ?? 0); expect($employeeId)->toBeGreaterThan(0); limited_backoffice_cleanup_created_employee($employeeId); + $replayed = api_client()->post( + '/limited-backoffice/employees', + $employeePayload, + $session['headers'] + ); + $replayed->assertStatus(200)->assertSuccess(); + expect($replayed->data()['id'] ?? null)->toBe($employeeId); + + api_client()->post( + '/limited-backoffice/employees', + [...$employeePayload, 'password' => 'A different retry-only password 123!'], + $session['headers'] + ) + ->assertStatus(409) + ->assertSuccess(false); + + api_client()->post( + '/limited-backoffice/employees', + [...$employeePayload, 'display_name' => 'Different Employee'], + $session['headers'] + ) + ->assertStatus(409) + ->assertSuccess(false); expect($created->data()['user_id'] ?? null)->toBe($employeeId); expect($created->data()['customer_number'] ?? null)->toBe(0); expect($created->data()['email'] ?? null)->toBe('limited-cashier@example.test'); @@ -1211,6 +1239,175 @@ it('generates reusable QR login links for active scoped employees', function (): expect($list->body)->not->toContain('login_path'); }); +it('creates, exchanges once, idempotently guards, and revokes scoped employee login grants', function (): void { + api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-grants', 'happy'); + api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-grants', 'idempotency'); + api_test_covers('POST /auth/limited-backoffice-login-grants/exchange', 'happy'); + api_test_covers('POST /auth/limited-backoffice-login-grants/exchange', 'one-time'); + api_test_covers('DELETE /limited-backoffice/employees/{employeeId}/login-grants', 'happy'); + + $department = api_fixtures()->createDepartment(['name' => 'Limited One-time Grant Department']); + $session = limited_backoffice_manager_session([(int)$department['id']]); + + $created = api_client()->post('/limited-backoffice/employees', [ + 'display_name' => 'Limited One-time Grant Employee', + 'email' => 'limited-one-time-grant@example.test', + 'password' => 'Secret123!', + 'role_key' => 'viewer', + 'department_ids' => [(int)$department['id']], + ], $session['headers']); + $created->assertStatus(200)->assertSuccess(); + + $employeeId = (int)($created->data()['id'] ?? 0); + expect($employeeId)->toBeGreaterThan(0); + limited_backoffice_cleanup_created_employee($employeeId); + + $preflight = api_client()->post( + '/limited-backoffice/employees/' . $employeeId . '/login-grants', + [ + 'purpose' => 'limited_backoffice_employee_login', + 'ttl_seconds' => 120, + 'preflight' => true, + ], + $session['headers'] + ); + $preflight + ->assertStatus(200) + ->assertSuccess(); + expect($preflight->data()['preflight'] ?? null)->toBeTrue(); + expect($preflight->data())->not->toHaveKey('login_path'); + + $idempotencyKey = 'limited-grant-test-' . bin2hex(random_bytes(12)); + $grantResponse = api_client()->post( + '/limited-backoffice/employees/' . $employeeId . '/login-grants', + [ + 'purpose' => 'limited_backoffice_employee_login', + 'ttl_seconds' => 120, + 'idempotency_key' => $idempotencyKey, + ], + $session['headers'] + ); + $grantResponse + ->assertStatus(200) + ->assertSuccess(); + + $loginPath = (string)($grantResponse->data()['login_path'] ?? ''); + expect($loginPath)->toMatch('/^\\/login\\/qr#grant=lbg_[a-f0-9]{64}$/'); + parse_str((string)parse_url($loginPath, PHP_URL_FRAGMENT), $query); + $grant = (string)($query['grant'] ?? ''); + expect($grant)->toMatch('/^lbg_[a-f0-9]{64}$/'); + + $grantRow = api_test_runtime()->queryOne( + "SELECT `secret_hash`, `consumed_at`, `revoked_at` + FROM `limited_backoffice_login_grants` + WHERE `grant_id` = '" . + api_test_runtime()->db()->real_escape_string((string)$grantResponse->data()['grant_id']) . + "' LIMIT 1" + ); + expect($grantRow)->not->toBeNull(); + expect($grantRow['secret_hash'] ?? null)->toBe(hash('sha256', $grant)); + expect((string)($grantRow['secret_hash'] ?? ''))->not->toContain($grant); + + $duplicate = api_client()->post( + '/limited-backoffice/employees/' . $employeeId . '/login-grants', + [ + 'purpose' => 'limited_backoffice_employee_login', + 'ttl_seconds' => 120, + 'idempotency_key' => $idempotencyKey, + ], + $session['headers'] + ); + $duplicate + ->assertStatus(200) + ->assertSuccess(); + expect($duplicate->data()['grant_id'] ?? null)->toBe($grantResponse->data()['grant_id'] ?? null); + expect($duplicate->data()['login_path'] ?? null)->toBe($loginPath); + + $differentTtlReplay = api_client()->post( + '/limited-backoffice/employees/' . $employeeId . '/login-grants', + [ + 'purpose' => 'limited_backoffice_employee_login', + 'ttl_seconds' => 180, + 'idempotency_key' => $idempotencyKey, + ], + $session['headers'] + ); + $differentTtlReplay->assertStatus(409)->assertSuccess(false); + expect($differentTtlReplay->data()['code'] ?? null)->toBe('LOGIN_GRANT_IDEMPOTENCY_CONFLICT'); + + $exchange = api_client()->post('/auth/limited-backoffice-login-grants/exchange', ['grant' => $grant]); + $exchange + ->assertStatus(200) + ->assertSuccess(); + $sessionToken = (string)($exchange->data()['token'] ?? ''); + expect($exchange->data()['employee_id'] ?? null)->toBe($employeeId); + expect($sessionToken)->toMatch('/^[a-f0-9]{64}$/'); + + $consumedReplay = api_client()->post( + '/limited-backoffice/employees/' . $employeeId . '/login-grants', + [ + 'purpose' => 'limited_backoffice_employee_login', + 'ttl_seconds' => 120, + 'idempotency_key' => $idempotencyKey, + ], + $session['headers'] + ); + $consumedReplay->assertStatus(409)->assertSuccess(false); + expect($consumedReplay->body)->not->toContain($grant); + expect($consumedReplay->data()['code'] ?? null)->toBe('LOGIN_GRANT_IDEMPOTENCY_CONFLICT'); + + api_client()->post('/auth/limited-backoffice-login-grants/exchange', ['grant' => $grant]) + ->assertStatus(401) + ->assertSuccess(false) + ->assertMessage('Invalid or expired login grant.'); + + $secondGrant = api_client()->post( + '/limited-backoffice/employees/' . $employeeId . '/login-grants', + [ + 'idempotency_key' => 'limited-grant-revoke-' . bin2hex(random_bytes(12)), + ], + $session['headers'] + ); + $secondGrant->assertStatus(200)->assertSuccess(); + parse_str((string)parse_url((string)$secondGrant->data()['login_path'], PHP_URL_FRAGMENT), $secondQuery); + + api_client()->delete( + '/limited-backoffice/employees/' . $employeeId . '/login-grants', + null, + $session['headers'] + ) + ->assertStatus(200) + ->assertSuccess(); + + api_client()->post( + '/auth/limited-backoffice-login-grants/exchange', + ['grant' => (string)($secondQuery['grant'] ?? '')] + ) + ->assertStatus(401) + ->assertSuccess(false); + + $deactivationGrant = api_client()->post( + '/limited-backoffice/employees/' . $employeeId . '/login-grants', + [ + 'idempotency_key' => 'limited-grant-deactivate-' . bin2hex(random_bytes(12)), + ], + $session['headers'] + ); + $deactivationGrant->assertStatus(200)->assertSuccess(); + parse_str((string)parse_url((string)$deactivationGrant->data()['login_path'], PHP_URL_FRAGMENT), $deactivationQuery); + + api_client()->delete('/limited-backoffice/employees/' . $employeeId, null, $session['headers']) + ->assertStatus(200) + ->assertSuccess(); + + api_client()->post( + '/auth/limited-backoffice-login-grants/exchange', + ['grant' => (string)($deactivationQuery['grant'] ?? '')] + ) + ->assertStatus(401) + ->assertSuccess(false); +}); + it('rejects invalid limited backoffice employee QR login link generation', function (): void { api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'auth'); api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'validation'); diff --git a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php index 657f7b4b..8c06a7ef 100644 --- a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php +++ b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php @@ -611,6 +611,43 @@ CREATE TABLE IF NOT EXISTS `limited_backoffice_employees` ( KEY `idx_limited_backoffice_employees_role_key` (`role_key`), KEY `idx_limited_backoffice_employees_deactivated_at` (`deactivated_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'limited_backoffice_login_grants' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `limited_backoffice_login_grants` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `grant_id` CHAR(32) NOT NULL, + `secret_hash` CHAR(64) NOT NULL, + `target_user_id` INT NOT NULL, + `actor_user_id` INT NOT NULL, + `purpose` VARCHAR(64) NOT NULL, + `idempotency_key_hash` CHAR(64) NULL, + `expires_at` BIGINT UNSIGNED NOT NULL, + `consumed_at` DATETIME NULL, + `revoked_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_limited_backoffice_login_grants_grant_id` (`grant_id`), + UNIQUE KEY `uniq_limited_backoffice_login_grants_secret_hash` (`secret_hash`), + UNIQUE KEY `uniq_limited_backoffice_login_grants_idempotency` (`actor_user_id`, `target_user_id`, `purpose`, `idempotency_key_hash`), + KEY `idx_limited_backoffice_login_grants_target` (`target_user_id`, `expires_at`), + KEY `idx_limited_backoffice_login_grants_expiry` (`expires_at`), + KEY `idx_limited_backoffice_login_grants_state` (`consumed_at`, `revoked_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'limited_backoffice_action_idempotency' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `limited_backoffice_action_idempotency` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `actor_user_id` INT NOT NULL, + `action_type` VARCHAR(64) NOT NULL, + `idempotency_key_hash` CHAR(64) NOT NULL, + `payload_hash` CHAR(64) NOT NULL, + `result_user_id` INT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_limited_backoffice_action_idempotency` + (`actor_user_id`, `action_type`, `idempotency_key_hash`), + KEY `idx_limited_backoffice_action_result` (`result_user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci SQL, 'collected_order_invoices' => <<<'SQL' CREATE TABLE IF NOT EXISTS `collected_order_invoices` ( diff --git a/services/nginx/app/tests/Unit/LimitedBackoffice/LoginGrantSecurityContractTest.php b/services/nginx/app/tests/Unit/LimitedBackoffice/LoginGrantSecurityContractTest.php new file mode 100644 index 00000000..2f04903b --- /dev/null +++ b/services/nginx/app/tests/Unit/LimitedBackoffice/LoginGrantSecurityContractTest.php @@ -0,0 +1,78 @@ +toContain("public const MAX_TTL_SECONDS = 900;") + ->toContain("\$secretHash = hash('sha256', \$bearer);") + ->toContain("hash_hmac(") + ->toContain("bearerForIdempotency") + ->toContain("isReplayableGrant") + ->toContain("':' . \$ttlSeconds") + ->toContain("(int)(\$row['expires_at'] ?? 0) > time()") + ->toContain('lock employee -> grant') + ->toContain('lockActiveManagedEmployee') + ->toContain('lockAuthorizedActor') + ->toContain("account_deletion_service::principalIsBlocked('customer', \$actorUserId)") + ->toContain('Login grant actor is no longer authorized.') + ->toContain('limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES') + ->toContain('FROM `groups_permissions`') + ->toContain('including every department_access_* row') + ->toContain("'group_id' => \$groupId") + ->toContain('in_array($errorCode, [1205, 1213], true)') + ->toContain('usleep(1000 * ($attempt + 1))') + ->toContain("=== 'superuser'") + ->toContain('Login grant target group is shared.') + ->toContain('FOR UPDATE') + ->toContain('`consumed_at` = UTC_TIMESTAMP()') + ->toContain('`revoked_at` = UTC_TIMESTAMP()') + ->toContain('LOGIN_GRANT_IDEMPOTENCY_CONFLICT') + ->not->toContain("'Created one-time login grant ' . \$bearer") + ->not->toContain("'Exchanged one-time login grant ' . \$bearer"); + + expect($schema) + ->toContain('`secret_hash` CHAR(64) NOT NULL') + ->not->toContain('`secret` VARCHAR'); + + expect($route) + ->toContain("'/limited-backoffice/employees/{employeeId}/login-grants'") + ->toContain('limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES'); + + expect($accountDeletion) + ->toContain("self::tableExists('limited_backoffice_login_grants')") + ->toContain('(target_user_id = $id OR actor_user_id = $id)') + ->toMatch('/limited_backoffice_employees[\\s\\S]+FOR UPDATE[\\s\\S]+limited_backoffice_login_grants[\\s\\S]+deleteTokensForPrincipal/u') + ->toContain('Unable to revoke deleted principal login grants.'); + + expect($employeeService) + ->toContain("hash_hmac('sha256', \$payloadJson, \$this->idempotencyDigestKey())") + ->toContain('$this->assertDepartmentSubset($manager, $currentDepartmentIds);') + ->toContain('accessibleDepartmentIdsForGroup(int $groupId)') + ->toContain('$this->accessibleDepartmentIdsForGroup($authoritativeGroupId)') + ->toMatch('/accessibleDepartmentIdsForGroup[\\s\\S]+\\$permission === \'superuser\'[\\s\\S]+return \\$this->allDepartmentIds\\(\\)/u') + ->toContain('$this->assertManagedTargetIsSafe($employee);') + ->toMatch('/limited_backoffice_employees[\\s\\S]+FOR UPDATE[\\s\\S]+replaceGroupPermissions/u'); + + foreach ($openApiContracts as $openApi) { + expect($openApi) + ->toContain('/auth/limited-backoffice-login-grants/exchange:') + ->toContain('/limited-backoffice/employees/{employeeId}/login-grants:') + ->toContain('oneOf:') + ->toContain('required: [preflight]') + ->not->toContain('token: {type: string, writeOnly: true}') + ->not->toContain('login_path: {type: string, writeOnly: true}'); + } +});