mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-18 01:28:58 +00:00
250 lines
8.3 KiB
PHP
250 lines
8.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
namespace OCA\Forum\Middleware;
|
|
|
|
use OCA\Forum\Attribute\RequirePermission;
|
|
use OCA\Forum\Service\PermissionService;
|
|
use OCP\AppFramework\Controller;
|
|
use OCP\AppFramework\Http\Attribute\PublicPage;
|
|
use OCP\AppFramework\Http\Response;
|
|
use OCP\AppFramework\Middleware;
|
|
use OCP\AppFramework\OCS\OCSException;
|
|
use OCP\AppFramework\OCS\OCSForbiddenException;
|
|
use OCP\AppFramework\Services\IAppConfig;
|
|
use OCP\IRequest;
|
|
use OCP\IUserSession;
|
|
use Psr\Log\LoggerInterface;
|
|
use ReflectionMethod;
|
|
|
|
/**
|
|
* Middleware to enforce permission checks based on RequirePermission attributes
|
|
*/
|
|
class PermissionMiddleware extends Middleware {
|
|
public function __construct(
|
|
private IRequest $request,
|
|
private IUserSession $userSession,
|
|
private PermissionService $permissionService,
|
|
private IAppConfig $config,
|
|
private LoggerInterface $logger,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* Check permissions before controller method is called
|
|
*
|
|
* @param Controller $controller
|
|
* @param string $methodName
|
|
* @throws OCSForbiddenException If user lacks required permissions
|
|
*/
|
|
public function beforeController($controller, $methodName): void {
|
|
$reflectionMethod = new ReflectionMethod($controller, $methodName);
|
|
|
|
// Check if this is a public page - allows unauthenticated access but still enforces permissions
|
|
$publicPageAttrs = $reflectionMethod->getAttributes(PublicPage::class);
|
|
$isPublicPage = !empty($publicPageAttrs);
|
|
|
|
$user = $this->userSession->getUser();
|
|
$userId = $user ? $user->getUID() : null;
|
|
$guestAccessEnabled = $this->config->getAppValueBool('allow_guest_access', false, true);
|
|
$isReadOnlyMethod = in_array($this->request->getMethod(), ['GET', 'HEAD', 'OPTIONS']);
|
|
|
|
// If user is not authenticated
|
|
if (!$user) {
|
|
// Allow unauthenticated access for public pages or when guest access is enabled for read-only
|
|
$allowUnauthenticated = $isPublicPage || $guestAccessEnabled;
|
|
|
|
if ($allowUnauthenticated) {
|
|
// Check if there are permission requirements - if so, check guest permissions
|
|
$permissionAttrs = $reflectionMethod->getAttributes(RequirePermission::class);
|
|
|
|
if (!empty($permissionAttrs)) {
|
|
// Check permissions using guest role (null userId)
|
|
$this->logger->debug('Checking permissions for unauthenticated user (public page or guest access)');
|
|
$this->checkPermissions(null, $permissionAttrs);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Guest access not enabled or not a read-only method - deny access
|
|
$this->logger->debug('Permission check failed: User not authenticated');
|
|
throw new OCSForbiddenException('User not authenticated');
|
|
}
|
|
|
|
// User is authenticated - check permissions normally
|
|
$permissionAttrs = $reflectionMethod->getAttributes(RequirePermission::class);
|
|
|
|
if (empty($permissionAttrs)) {
|
|
// No permission requirements, allow access
|
|
return;
|
|
}
|
|
|
|
$this->checkPermissions($userId, $permissionAttrs);
|
|
}
|
|
|
|
/**
|
|
* Check all permission attributes, respecting OR groups.
|
|
*
|
|
* Attributes with the same orGroup are OR'd (any one must pass).
|
|
* Attributes with no orGroup, or with different orGroups, are AND'd together.
|
|
*
|
|
* @param string|null $userId
|
|
* @param \ReflectionAttribute[] $permissionAttrs
|
|
* @throws OCSForbiddenException
|
|
*/
|
|
private function checkPermissions(?string $userId, array $permissionAttrs): void {
|
|
// Separate into OR groups and ungrouped
|
|
$orGroups = [];
|
|
$ungrouped = [];
|
|
|
|
foreach ($permissionAttrs as $attr) {
|
|
/** @var RequirePermission $permission */
|
|
$permission = $attr->newInstance();
|
|
$group = $permission->getOrGroup();
|
|
if ($group !== null) {
|
|
$orGroups[$group][] = $permission;
|
|
} else {
|
|
$ungrouped[] = $permission;
|
|
}
|
|
}
|
|
|
|
// Ungrouped attributes: all must pass (AND)
|
|
foreach ($ungrouped as $permission) {
|
|
$this->checkPermission($userId, $permission);
|
|
}
|
|
|
|
// OR groups: at least one in each group must pass
|
|
foreach ($orGroups as $group => $permissions) {
|
|
$lastException = null;
|
|
$passed = false;
|
|
foreach ($permissions as $permission) {
|
|
try {
|
|
$this->checkPermission($userId, $permission);
|
|
$passed = true;
|
|
break;
|
|
} catch (OCSForbiddenException $e) {
|
|
$lastException = $e;
|
|
}
|
|
}
|
|
if (!$passed && $lastException !== null) {
|
|
throw $lastException;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check a single permission requirement
|
|
*
|
|
* @param string|null $userId Nextcloud user ID (null for guest users)
|
|
* @param RequirePermission $permission Permission requirement to check
|
|
* @throws OCSForbiddenException If permission check fails
|
|
*/
|
|
private function checkPermission(?string $userId, RequirePermission $permission): void {
|
|
$permissionName = $permission->getPermission();
|
|
$resourceType = $permission->getResourceType();
|
|
|
|
// Global permission check
|
|
if ($resourceType === null) {
|
|
if (!$this->permissionService->hasGlobalPermission($userId, $permissionName)) {
|
|
$this->logger->info("User $userId denied access: lacks global permission '$permissionName'");
|
|
throw new OCSForbiddenException("Insufficient permissions: $permissionName");
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Resource-specific permission check
|
|
try {
|
|
$resourceId = $this->resolveResourceId($permission);
|
|
|
|
if ($resourceType === 'category') {
|
|
if (!$this->permissionService->hasCategoryPermission($userId, $resourceId, $permissionName)) {
|
|
$this->logger->info("User $userId denied access: lacks category permission '$permissionName' on category $resourceId");
|
|
throw new OCSForbiddenException("Insufficient category permissions: $permissionName");
|
|
}
|
|
} else {
|
|
$this->logger->warning("Unknown resource type: $resourceType");
|
|
throw new OCSForbiddenException('Invalid permission configuration');
|
|
}
|
|
} catch (\InvalidArgumentException $e) {
|
|
$this->logger->error('Failed to resolve resource ID: ' . $e->getMessage());
|
|
throw new OCSForbiddenException('Invalid request: cannot determine resource');
|
|
} catch (\Exception $e) {
|
|
$this->logger->error('Permission check error: ' . $e->getMessage());
|
|
throw new OCSForbiddenException('Permission check failed');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve resource ID from request based on permission configuration
|
|
*
|
|
* @param RequirePermission $permission Permission configuration
|
|
* @return int Resolved resource ID
|
|
* @throws \InvalidArgumentException If resource ID cannot be resolved
|
|
*/
|
|
private function resolveResourceId(RequirePermission $permission): int {
|
|
// From route parameter (e.g., /api/categories/{id})
|
|
if ($param = $permission->getResourceIdParam()) {
|
|
$value = $this->request->getParam($param);
|
|
if ($value !== null) {
|
|
return (int)$value;
|
|
}
|
|
throw new \InvalidArgumentException("Route parameter '$param' not found");
|
|
}
|
|
|
|
// From request body (e.g., POST {categoryId: 5})
|
|
if ($body = $permission->getResourceIdBody()) {
|
|
$data = $this->request->getParams();
|
|
if (isset($data[$body])) {
|
|
return (int)$data[$body];
|
|
}
|
|
throw new \InvalidArgumentException("Request body parameter '$body' not found");
|
|
}
|
|
|
|
// Derive category ID from thread ID
|
|
if ($threadParam = $permission->getResourceIdFromThreadId()) {
|
|
$threadId = $this->request->getParam($threadParam);
|
|
if ($threadId !== null) {
|
|
return $this->permissionService->getCategoryIdFromThread((int)$threadId);
|
|
}
|
|
throw new \InvalidArgumentException("Thread ID parameter '$threadParam' not found");
|
|
}
|
|
|
|
// Derive category ID from post ID
|
|
if ($postParam = $permission->getResourceIdFromPostId()) {
|
|
$postId = $this->request->getParam($postParam);
|
|
if ($postId !== null) {
|
|
return $this->permissionService->getCategoryIdFromPost((int)$postId);
|
|
}
|
|
throw new \InvalidArgumentException("Post ID parameter '$postParam' not found");
|
|
}
|
|
|
|
throw new \InvalidArgumentException('Cannot resolve resource ID: no valid parameter configuration');
|
|
}
|
|
|
|
/**
|
|
* Handle exceptions thrown in beforeController
|
|
*
|
|
* @param Controller $controller
|
|
* @param string $methodName
|
|
* @param \Exception $exception
|
|
* @return Response
|
|
* @throws \Exception
|
|
*/
|
|
public function afterException($controller, $methodName, \Exception $exception): Response {
|
|
// Re-throw OCS exceptions (they'll be handled by the framework)
|
|
if ($exception instanceof OCSException) {
|
|
throw $exception;
|
|
}
|
|
|
|
// Log and re-throw other exceptions
|
|
$this->logger->error('Unexpected exception in PermissionMiddleware: ' . $exception->getMessage(), [
|
|
'exception' => $exception,
|
|
]);
|
|
throw $exception;
|
|
}
|
|
}
|