Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.83% covered (warning)
68.83%
170 / 247
42.86% covered (danger)
42.86%
9 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
InjectionMiddleware
68.83% covered (warning)
68.83%
170 / 247
42.86% covered (danger)
42.86%
9 / 21
285.71
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 beforeController
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
6.01
 privateValidation
68.42% covered (warning)
68.42%
13 / 19
0.00% covered (danger)
0.00%
0 / 1
11.55
 isLoggedIn
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 isValidationUrlPrivate
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 throwPrivateValidationRedirect
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 handleUuid
50.00% covered (danger)
50.00%
19 / 38
0.00% covered (danger)
0.00%
0 / 1
8.12
 authorizeRequiredUuid
41.67% covered (danger)
41.67%
5 / 12
0.00% covered (danger)
0.00%
0 / 1
13.15
 shouldForcePrivateValidationRedirect
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 getUuidFromRequest
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 maintainSessionConsistencyWithUuid
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 getLoggedIn
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 requireSigner
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 requireSignerUuid
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 requireFileAccess
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
3.00
 redirectSignedToValidationIfNeeded
8.33% covered (danger)
8.33%
2 / 24
0.00% covered (danger)
0.00%
0 / 1
44.74
 requireSetupOk
30.00% covered (danger)
30.00%
3 / 10
0.00% covered (danger)
0.00%
0 / 1
6.09
 afterException
87.72% covered (warning)
87.72%
50 / 57
0.00% covered (danger)
0.00%
0 / 1
15.42
 getRenderAsFromTemplate
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getStatusCodeFromException
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 isJson
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2020-2024 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Middleware;
10
11use OCA\Libresign\AppInfo\Application;
12use OCA\Libresign\Controller\AEnvironmentAwareController;
13use OCA\Libresign\Controller\AEnvironmentPageAwareController;
14use OCA\Libresign\Controller\ISignatureUuid;
15use OCA\Libresign\Db\FileMapper;
16use OCA\Libresign\Db\SignRequestMapper;
17use OCA\Libresign\Enum\SignRequestStatus;
18use OCA\Libresign\Exception\LibresignException;
19use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory;
20use OCA\Libresign\Helper\JSActions;
21use OCA\Libresign\Middleware\Attribute\CanSignRequestUuid;
22use OCA\Libresign\Middleware\Attribute\PrivateValidation;
23use OCA\Libresign\Middleware\Attribute\RequireFileAccess;
24use OCA\Libresign\Middleware\Attribute\RequireManager;
25use OCA\Libresign\Middleware\Attribute\RequireParticipantUuid;
26use OCA\Libresign\Middleware\Attribute\RequireSetupOk;
27use OCA\Libresign\Middleware\Attribute\RequireSigner;
28use OCA\Libresign\Middleware\Attribute\RequireSignerUuid;
29use OCA\Libresign\Middleware\Attribute\RequireSignRequestUuid;
30use OCA\Libresign\Service\FileAccessService;
31use OCA\Libresign\Service\Policy\PolicyService;
32use OCA\Libresign\Service\Policy\Provider\ValidationAccess\ValidationAccessPolicy;
33use OCA\Libresign\Service\SignFileService;
34use OCA\Libresign\Service\UuidResolverService;
35use OCA\Libresign\Service\Validation\SignerValidator;
36use OCA\Libresign\Service\Validation\SigningRequestValidator;
37use OCP\AppFramework\Controller;
38use OCP\AppFramework\Http;
39use OCP\AppFramework\Http\ContentSecurityPolicy;
40use OCP\AppFramework\Http\DataResponse;
41use OCP\AppFramework\Http\JSONResponse;
42use OCP\AppFramework\Http\RedirectResponse;
43use OCP\AppFramework\Http\Response;
44use OCP\AppFramework\Http\TemplateResponse;
45use OCP\AppFramework\Middleware;
46use OCP\AppFramework\Services\IInitialState;
47use OCP\IL10N;
48use OCP\IRequest;
49use OCP\ISession;
50use OCP\IURLGenerator;
51use OCP\IUser;
52use OCP\IUserSession;
53use OCP\Util;
54use Psr\Log\LoggerInterface;
55
56class InjectionMiddleware extends Middleware {
57    private ?bool $isLoggedIn = null;
58    private ?bool $isValidationUrlPrivate = null;
59
60    public function __construct(
61        private IRequest $request,
62        private ISession $session,
63        private IUserSession $userSession,
64        private SigningRequestValidator $signingRequestValidator,
65        private SignerValidator $signerValidator,
66        private SignRequestMapper $signRequestMapper,
67        private CertificateEngineFactory $certificateEngineFactory,
68        private FileMapper $fileMapper,
69        private IInitialState $initialState,
70        private FileAccessService $fileAccessService,
71        private SignFileService $signFileService,
72        private UuidResolverService $uuidResolverService,
73        private PolicyService $policyService,
74        private IL10N $l10n,
75        private IURLGenerator $urlGenerator,
76        private LoggerInterface $logger,
77        protected ?string $userId,
78    ) {
79        $this->request = $request;
80    }
81
82    /**
83     * @param Controller $controller
84     * @param string $methodName
85     * @throws \Exception
86     */
87    #[\Override]
88    public function beforeController(Controller $controller, string $methodName) {
89        $this->isLoggedIn = null;
90        $this->isValidationUrlPrivate = null;
91
92        if ($controller instanceof AEnvironmentAwareController) {
93            $apiVersion = $this->request->getParam('apiVersion');
94            /** @var AEnvironmentAwareController $controller */
95            $controller->setAPIVersion((int)substr((string)$apiVersion, 1));
96        }
97
98        $reflectionMethod = new \ReflectionMethod($controller, $methodName);
99
100        if (!empty($reflectionMethod->getAttributes(RequireManager::class))) {
101            $this->getLoggedIn();
102        }
103
104        if (!empty($reflectionMethod->getAttributes(RequireSigner::class))) {
105            $this->requireSigner();
106        }
107        if (!empty($reflectionMethod->getAttributes(RequireSignerUuid::class))) {
108            $this->requireSignerUuid();
109        }
110        $this->requireSetupOk($reflectionMethod);
111        $this->privateValidation($reflectionMethod);
112
113        if (!empty($reflectionMethod->getAttributes(RequireFileAccess::class))) {
114            $this->requireFileAccess($reflectionMethod);
115        }
116
117        $this->handleUuid($controller, $reflectionMethod);
118    }
119
120    private function privateValidation(\ReflectionMethod $reflectionMethod): void {
121        $attributes = $reflectionMethod->getAttributes(PrivateValidation::class);
122        if (empty($attributes)) {
123            return;
124        }
125
126        if ($this->shouldForcePrivateValidationRedirect($reflectionMethod)) {
127            $this->throwPrivateValidationRedirect($this->request->getRawPathInfo());
128        }
129
130        /** @var PrivateValidation $privateValidation */
131        $privateValidation = current($attributes)->newInstance();
132        if ($this->isLoggedIn() || !$this->isValidationUrlPrivate()) {
133            return;
134        }
135
136        if (!$privateValidation->allowValidSignRequestUuid()) {
137            $this->throwPrivateValidationRedirect($this->request->getRawPathInfo());
138        }
139
140        $uuid = $this->getUuidFromRequest();
141        if ($uuid) {
142            try {
143                $this->signRequestMapper->getByUuid($uuid);
144                return; // sign-request UUID is valid, allow access
145            } catch (\Throwable) {
146            }
147
148            try {
149                $this->fileMapper->getByUuid($uuid);
150                return; // file UUID is valid, allow access
151            } catch (\Throwable) {
152            }
153        }
154        $this->throwPrivateValidationRedirect($this->request->getRawPathInfo());
155    }
156
157    private function isLoggedIn(): bool {
158        if ($this->isLoggedIn === null) {
159            $this->isLoggedIn = $this->userSession->isLoggedIn();
160        }
161        return $this->isLoggedIn;
162    }
163
164    private function isValidationUrlPrivate(): bool {
165        if ($this->isValidationUrlPrivate === null) {
166            $this->isValidationUrlPrivate = $this->policyService
167                ->resolve(ValidationAccessPolicy::KEY)
168                ->getEffectiveValueAsBool();
169        }
170
171        return $this->isValidationUrlPrivate;
172    }
173
174    private function throwPrivateValidationRedirect(?string $redirectUrl): void {
175        throw new LibresignException(json_encode([
176            'action' => JSActions::ACTION_REDIRECT,
177            // TRANSLATORS: Error shown to anonymous users when a private validation URL requires authentication before continuing.
178            'errors' => [$this->l10n->t('You are not logged in. Please log in.')],
179            'redirect' => $this->urlGenerator->linkToRoute('core.login.showLoginForm', [
180                'redirect_url' => $redirectUrl,
181            ]),
182        ]), Http::STATUS_UNAUTHORIZED);
183    }
184
185    private function handleUuid(Controller $controller, \ReflectionMethod $reflectionMethod): void {
186        if (!$controller instanceof ISignatureUuid) {
187            return;
188        }
189
190        $uuid = $this->getUuidFromRequest();
191        $this->maintainSessionConsistencyWithUuid($uuid);
192
193        if (!empty($reflectionMethod->getAttributes(CanSignRequestUuid::class))) {
194            /** @var AEnvironmentPageAwareController $controller */
195            $controller->validateRenewSigner(
196                uuid: $uuid,
197            );
198            /** @var AEnvironmentPageAwareController $controller */
199            $controller->loadNextcloudFileFromUuid(
200                uuid: $uuid,
201            );
202        }
203
204        if (!empty($reflectionMethod->getAttributes(RequireSignRequestUuid::class))) {
205            $attribute = current($reflectionMethod->getAttributes(RequireSignRequestUuid::class));
206            /** @var RequireSignRequestUuid $requirement */
207            $requirement = $attribute->newInstance();
208            $this->redirectSignedToValidationIfNeeded($requirement);
209            $this->authorizeRequiredUuid(
210                controller: $controller,
211                reflectionMethod: $reflectionMethod,
212                uuid: $uuid,
213                skipIfAuthenticated: $requirement->skipIfAuthenticated(),
214                allowIdDocs: $requirement->allowIdDocs(),
215                validateUuid: static function (string $uuid) use ($controller): void {
216                    $controller->validateSignRequestUuid(uuid: $uuid);
217                },
218            );
219        }
220
221        if (!empty($reflectionMethod->getAttributes(RequireParticipantUuid::class))) {
222            $attribute = current($reflectionMethod->getAttributes(RequireParticipantUuid::class));
223            /** @var RequireParticipantUuid $requirement */
224            $requirement = $attribute->newInstance();
225            $this->authorizeRequiredUuid(
226                controller: $controller,
227                reflectionMethod: $reflectionMethod,
228                uuid: $uuid,
229                skipIfAuthenticated: $requirement->skipIfAuthenticated(),
230                allowIdDocs: $requirement->allowIdDocs(),
231                validateUuid: static function (string $uuid) use ($controller): void {
232                    $controller->validateParticipantUuid(uuid: $uuid);
233                },
234            );
235        }
236    }
237
238    /**
239     * @param callable(string): void $validateUuid
240     */
241    private function authorizeRequiredUuid(
242        ISignatureUuid $controller,
243        \ReflectionMethod $reflectionMethod,
244        string $uuid,
245        bool $skipIfAuthenticated,
246        bool $allowIdDocs,
247        callable $validateUuid,
248    ): void {
249        if ($this->shouldForcePrivateValidationRedirect($reflectionMethod)) {
250            $this->throwPrivateValidationRedirect($this->request->getRawPathInfo());
251        }
252
253        $user = $this->userSession->getUser();
254        $isIdDocApproval = $allowIdDocs && $this->request->getParam('idDocApproval') === 'true';
255
256        if ($skipIfAuthenticated && $user instanceof IUser) {
257            return;
258        }
259
260        if ($isIdDocApproval) {
261            $resolution = $this->uuidResolverService->resolveUuidForUser($uuid, $user);
262            /** @var AEnvironmentPageAwareController $controller */
263            $controller->loadIdDocApprovalFromResolution($resolution);
264            return;
265        }
266
267        $validateUuid($uuid);
268        $controller->loadNextcloudFileFromUuid(uuid: $uuid);
269    }
270
271    private function shouldForcePrivateValidationRedirect(\ReflectionMethod $reflectionMethod): bool {
272        $attributes = $reflectionMethod->getAttributes(PrivateValidation::class);
273        if (empty($attributes)) {
274            return false;
275        }
276
277        if ($this->isLoggedIn() || !$this->isValidationUrlPrivate()) {
278            return false;
279        }
280
281        /** @var PrivateValidation $privateValidation */
282        $privateValidation = current($attributes)->newInstance();
283        return !$privateValidation->allowValidSignRequestUuid();
284    }
285
286    private function getUuidFromRequest(): ?string {
287        return $this->request->getParam('uuid', $this->request->getHeader('libresign-sign-request-uuid'));
288    }
289
290    /**
291     * Maintain session consistency when sign request UUID is provided
292     *
293     * For unauthenticated requests with a sign request UUID header,
294     * store the UUID in the session to ensure consistent session ID
295     * across multiple HTTP requests. Each request without authentication
296     * would otherwise create a new session ID, causing data lookup failures.
297     *
298     * @param string|null $uuid The sign request UUID from request header
299     */
300    private function maintainSessionConsistencyWithUuid(?string $uuid): void {
301        if ($uuid && !($this->userSession->getUser() instanceof IUser)) {
302            if ($this->session->get('libresign-uuid') !== $uuid) {
303                $this->session->set('libresign-uuid', $uuid);
304            }
305        }
306    }
307
308    private function getLoggedIn(): void {
309        $user = $this->userSession->getUser();
310        if (!$user instanceof IUser) {
311            // TRANSLATORS: Error shown when an anonymous user tries to create a signature request, an action allowed only for authenticated users with permission.
312            throw new \Exception($this->l10n->t('You are not allowed to create signature requests'), Http::STATUS_UNPROCESSABLE_ENTITY);
313        }
314        $this->signingRequestValidator->canRequestSign($user);
315    }
316
317    private function requireSigner(): void {
318        $uuid = $this->getUuidFromRequest();
319
320        try {
321            $user = $this->userSession->getUser();
322            $isIdDocApproval = $this->request->getParam('idDocApproval') === 'true';
323
324            if ($isIdDocApproval) {
325                $this->uuidResolverService->resolveUuidForUser($uuid, $user);
326            } else {
327                $this->signerValidator->validateSigner($uuid, $user);
328            }
329        } catch (LibresignException $e) {
330            throw new LibresignException($e->getMessage());
331        }
332    }
333
334    private function requireSignerUuid(): void {
335        $uuid = $this->getUuidFromRequest();
336
337        try {
338            $this->signerValidator->validateSignerUuid($uuid);
339        } catch (LibresignException $e) {
340            throw new LibresignException($e->getMessage());
341        }
342    }
343
344    private function requireFileAccess(\ReflectionMethod $reflectionMethod): void {
345        $attributes = $reflectionMethod->getAttributes(RequireFileAccess::class);
346        $attribute = current($attributes);
347        /** @var RequireFileAccess $requirement */
348        $requirement = $attribute->newInstance();
349
350        $identifier = $requirement->getIdentifier();
351
352        try {
353            $hasAccess = match ($identifier) {
354                'nodeId' => $this->fileAccessService->userCanAccessFileByNodeId((int)$this->request->getParam('nodeId', -1)),
355                'fileId' => $this->fileAccessService->userCanAccessFileById((int)$this->request->getParam('fileId', -1)),
356                default => throw new \InvalidArgumentException('Unsupported file access identifier: ' . $identifier),
357            };
358        } catch (\OCP\AppFramework\Db\DoesNotExistException) {
359            throw new LibresignException(json_encode([
360                'action' => JSActions::ACTION_DO_NOTHING,
361                'errors' => [],
362            ]), Http::STATUS_NOT_FOUND);
363        }
364
365        if ($hasAccess) {
366            return;
367        }
368
369        throw new LibresignException(json_encode([]), Http::STATUS_FORBIDDEN);
370    }
371
372    private function redirectSignedToValidationIfNeeded(RequireSignRequestUuid $requirement): void {
373        if (!$requirement->redirectIfSignedToValidation()) {
374            return;
375        }
376
377        $uuid = $this->getUuidFromRequest();
378        if (!$uuid) {
379            return;
380        }
381
382        try {
383            $signRequest = $this->signRequestMapper->getByUuid($uuid);
384            if ($signRequest->getStatusEnum() !== SignRequestStatus::SIGNED && !$signRequest->isObserver()) {
385                return;
386            }
387            $file = $this->fileMapper->getById($signRequest->getFileId());
388            $fileUuid = $file->getUuid();
389        } catch (\Throwable) {
390            return;
391        }
392
393        $path = $this->request->getRawPathInfo() ?? '';
394        $redirectUrl = str_contains($path, '/p/')
395            ? $this->urlGenerator->linkToRouteAbsolute('libresign.page.validationFilePublic', [
396                'uuid' => $fileUuid,
397            ])
398            : $this->urlGenerator->linkToRouteAbsolute('libresign.page.indexFPath', [
399                'path' => 'validation/' . $fileUuid,
400            ]);
401
402        throw new LibresignException(json_encode([
403            'action' => JSActions::ACTION_REDIRECT,
404            'redirect' => $redirectUrl,
405        ]), Http::STATUS_SEE_OTHER);
406    }
407
408    private function requireSetupOk(\ReflectionMethod $reflectionMethod): void {
409        $attribute = $reflectionMethod->getAttributes(RequireSetupOk::class);
410        if (empty($attribute)) {
411            return;
412        }
413        $attribute = current($attribute);
414        if (!$this->certificateEngineFactory->getEngine()->isSetupOk()) {
415            /** @var RequireSetupOk $requirement */
416            $requireSetupOk = $attribute->newInstance();
417            throw new LibresignException(json_encode([
418                'action' => JSActions::ACTION_INCOMPLETE_SETUP,
419                'template' => $requireSetupOk->getTemplate(),
420            ]));
421        }
422    }
423
424    /**
425     * @param Controller $controller
426     * @param string $methodName
427     * @param \Exception $exception
428     * @throws \Exception
429     * @return Response
430     */
431    #[\Override]
432    public function afterException($controller, $methodName, \Exception $exception): Response {
433        if (str_contains($this->request->getHeader('Accept'), 'html')) {
434            if (!$exception instanceof LibresignException) {
435                $this->logger->error('Unexpected exception in LibreSign middleware', [
436                    'exception' => $exception,
437                    'app' => Application::APP_ID,
438                ]);
439            }
440
441            $template = 'external';
442            if ($this->isJson($exception->getMessage())) {
443                $settings = json_decode($exception->getMessage(), true);
444                if (isset($settings['action']) && $settings['action'] === JSActions::ACTION_REDIRECT && isset($settings['redirect'])) {
445                    if (isset($settings['errors'])) {
446                        $this->session->set('loginMessages', [
447                            [], $settings['errors'],
448                        ]);
449                    }
450                    return new RedirectResponse($settings['redirect']);
451                }
452                foreach ($settings as $key => $value) {
453                    if ($key === 'template') {
454                        $template = $value;
455                        continue;
456                    }
457                    $this->initialState->provideInitialState($key, $value);
458                }
459            } else {
460                $this->initialState->provideInitialState('error', ['message' => $exception->getMessage()]);
461            }
462
463            Util::addScript(Application::APP_ID, 'libresign-' . $template);
464            Util::addStyle(Application::APP_ID, 'libresign-' . $template);
465            $response = new TemplateResponse(
466                appName: Application::APP_ID,
467                templateName: $template,
468                renderAs: $this->getRenderAsFromTemplate($template),
469                status: $this->getStatusCodeFromException($exception)
470            );
471
472            $policy = new ContentSecurityPolicy();
473            $policy->addAllowedFrameDomain('\'self\'');
474            $response->setContentSecurityPolicy($policy);
475            return $response;
476        }
477        if ($exception instanceof LibresignException) {
478            if ($this->isJson($exception->getMessage())) {
479                $body = json_decode($exception->getMessage());
480            } else {
481                $body = [
482                    'message' => $exception->getMessage(),
483                ];
484            }
485            if ($controller instanceof \OCP\AppFramework\OCSController) {
486                $format = $this->request->getParam('format');
487
488                // if none is given try the first Accept header
489                if ($format === null) {
490                    $headers = $this->request->getHeader('Accept');
491                    $format = $controller->getResponderByHTTPHeader($headers, 'json');
492                }
493
494                $response = new DataResponse(
495                    data: $body,
496                    statusCode: $this->getStatusCodeFromException($exception)
497                );
498                if ($format !== null) {
499                    $response = $controller->buildResponse($response, $format);
500                } else {
501                    $response = $controller->buildResponse($response);
502                }
503            } else {
504                $response = new JSONResponse(
505                    data: $body,
506                    statusCode: $this->getStatusCodeFromException($exception)
507                );
508            }
509            return $response;
510        }
511
512        throw $exception;
513    }
514
515    private function getRenderAsFromTemplate(string $template): string {
516        if ($template === 'external') {
517            return TemplateResponse::RENDER_AS_BASE;
518        }
519        return TemplateResponse::RENDER_AS_USER;
520    }
521
522    private function getStatusCodeFromException(\Exception $exception): int {
523        if ($exception instanceof LibresignException) {
524            return $exception->getHttpStatus();
525        }
526        if ($exception->getCode() === 0) {
527            return Http::STATUS_UNPROCESSABLE_ENTITY;
528        }
529        return (int)$exception->getCode();
530    }
531
532    protected function isJson(string $string): bool {
533        json_decode($string);
534        return json_last_error() === JSON_ERROR_NONE;
535    }
536}