Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.00% covered (success)
90.00%
207 / 230
63.16% covered (warning)
63.16%
24 / 38
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractIdentifyMethod
90.00% covered (success)
90.00%
207 / 230
63.16% covered (warning)
63.16%
24 / 38
87.56
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFriendlyName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setFriendlyName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCodeSentByUser
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 cleanEntity
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setEntity
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getEntity
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 signatureMethodsToArray
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAvailableSignatureMethods
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getEmptyInstanceOfSignatureMethodByName
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
3.14
 getSignatureMethods
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSettings
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getDefaultSettings
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 notify
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 willNotifyUser
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateToRequest
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validateToCreateAccount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validateToIdentify
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validateToSign
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 throwIfFileNotFound
81.25% covered (warning)
81.25%
26 / 32
0.00% covered (danger)
0.00%
0 / 1
6.24
 throwIfMaximumValidityExpired
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 throwIfInvalidToken
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
6.10
 renewSession
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 updateIdentifiedAt
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 throwIfRenewalIntervalExpired
94.44% covered (success)
94.44%
34 / 36
0.00% covered (danger)
0.00%
0 / 1
5.00
 getRenewAction
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getRuntimeConfigInt
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 throwIfAlreadySigned
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 getSettingsFromDatabase
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
3.01
 overrideImmutable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadSavedSettings
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
10
 applyDefault
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 save
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 delete
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 removeKeysThatDontExists
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 validateToRenew
33.33% covered (danger)
33.33%
1 / 3
0.00% covered (danger)
0.00%
0 / 1
1.30
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\Service\IdentifyMethod;
10
11use DateTime;
12use InvalidArgumentException;
13use OC\AppFramework\Http as AppFrameworkHttp;
14use OCA\Libresign\Db\IdentifyMethod;
15use OCA\Libresign\Enum\FileStatus;
16use OCA\Libresign\Enum\IdentifyMethodRequirement;
17use OCA\Libresign\Events\SendSignNotificationEvent;
18use OCA\Libresign\Exception\LibresignException;
19use OCA\Libresign\Helper\JSActions;
20use OCA\Libresign\Service\IdentifyMethod\SignatureMethod\AbstractSignatureMethod;
21use OCA\Libresign\Service\Policy\Provider\ExpirationRules\ExpirationRulesPolicy;
22use OCA\Libresign\Service\SessionService;
23use OCA\Libresign\Vendor\Wobeto\EmailBlur\Blur;
24use OCP\Files\NotFoundException;
25use OCP\IUser;
26
27abstract class AbstractIdentifyMethod implements IIdentifyMethod {
28    /**
29     * Semantic error code of the invalid verification code error: the closest
30     * HTTP status with one extra digit (401 -> 4010), so it is not mistaken
31     * for an HTTP status code.
32     */
33    public const int CODE_INVALID_TOKEN = 4010;
34
35    protected IdentifyMethod $entity;
36    protected string $name;
37    protected string $friendlyName;
38    protected ?IUser $user = null;
39    protected string $codeSentByUser = '';
40    protected array $settings = [];
41    protected bool $willNotify = true;
42    /**
43     * @var string[]
44     */
45    public array $availableSignatureMethods = [];
46    protected string $defaultSignatureMethod = '';
47    /**
48     * @var AbstractSignatureMethod[]
49     */
50    protected array $signatureMethods = [];
51    public function __construct(
52        protected IdentifyService $identifyService,
53    ) {
54        $className = (new \ReflectionClass($this))->getShortName();
55        $this->name = lcfirst($className);
56        $this->cleanEntity();
57    }
58
59    #[\Override]
60    public static function getId(): string {
61        $id = lcfirst(substr(strrchr(static::class, '\\'), 1));
62        return $id;
63    }
64
65    #[\Override]
66    public function getName(): string {
67        return $this->name;
68    }
69
70    #[\Override]
71    public function getFriendlyName(): string {
72        return $this->friendlyName;
73    }
74
75    #[\Override]
76    public function setFriendlyName(string $friendlyName): void {
77        $this->friendlyName = $friendlyName;
78    }
79
80    #[\Override]
81    public function setCodeSentByUser(string $code): void {
82        $this->codeSentByUser = $code;
83    }
84
85    #[\Override]
86    public function cleanEntity(): void {
87        $this->entity = new IdentifyMethod();
88        $this->entity->setIdentifierKey($this->name);
89    }
90
91    #[\Override]
92    public function setEntity(IdentifyMethod $entity): void {
93        $this->entity = $entity;
94    }
95
96    #[\Override]
97    public function getEntity(): IdentifyMethod {
98        return $this->entity;
99    }
100
101    #[\Override]
102    public function signatureMethodsToArray(): array {
103        return array_map(fn (AbstractSignatureMethod $method) => $method->toArray(), $this->signatureMethods);
104    }
105
106    public function getAvailableSignatureMethods(): array {
107        return $this->availableSignatureMethods;
108    }
109
110    #[\Override]
111    public function getEmptyInstanceOfSignatureMethodByName(string $name): AbstractSignatureMethod {
112        if (!in_array($name, $this->getAvailableSignatureMethods())) {
113            throw new InvalidArgumentException(sprintf('%s is not a valid signature method of identify method %s', $name, $this->getName()));
114        }
115        $className = 'OCA\Libresign\Service\IdentifyMethod\\SignatureMethod\\' . ucfirst($name);
116        if (!class_exists($className)) {
117            throw new InvalidArgumentException('Invalid signature method. Set at identify method the list  of available signature methdos with right values.');
118        }
119        /** @var AbstractSignatureMethod */
120        $signatureMethod = clone \OCP\Server::get($className);
121        $signatureMethod->cleanEntity();
122        return $signatureMethod;
123    }
124
125    /**
126     * @return AbstractSignatureMethod[]
127     */
128    #[\Override]
129    public function getSignatureMethods(): array {
130        return $this->signatureMethods;
131    }
132
133    #[\Override]
134    public function getSettings(): array {
135        $this->getSettingsFromDatabase();
136        return $this->settings;
137    }
138
139    #[\Override]
140    public function getDefaultSettings(): array {
141        $this->signatureMethods = [];
142        foreach ($this->availableSignatureMethods as $signatureMethodName) {
143            $signatureMethod = $this->getEmptyInstanceOfSignatureMethodByName($signatureMethodName);
144            if ($signatureMethodName === $this->defaultSignatureMethod) {
145                $signatureMethod->enable();
146            }
147            $this->signatureMethods[$signatureMethodName] = $signatureMethod;
148        }
149
150        return [
151            'name' => $this->name,
152            'friendly_name' => $this->getFriendlyName(),
153            'enabled' => true,
154            'requirement' => IdentifyMethodRequirement::REQUIRED->value,
155            'signatureMethods' => $this->signatureMethodsToArray(),
156        ];
157    }
158
159    #[\Override]
160    public function notify(?string $deliveryEventId = null): bool {
161        if (!$this->willNotify) {
162            return false;
163        }
164        $signRequest = $this->identifyService->getSignRequestMapper()->getById($this->getEntity()->getSignRequestId());
165        $libresignFile = $this->identifyService->getFileMapper()->getById($signRequest->getFileId());
166        $this->identifyService->getEventDispatcher()->dispatchTyped(new SendSignNotificationEvent(
167            $signRequest,
168            $libresignFile,
169            $this,
170            $deliveryEventId,
171        ));
172        return true;
173    }
174
175    #[\Override]
176    public function willNotifyUser(bool $willNotify): void {
177        $this->willNotify = $willNotify;
178    }
179
180    #[\Override]
181    public function validateToRequest(): void {
182    }
183
184    #[\Override]
185    public function validateToCreateAccount(string $value): void {
186    }
187
188    #[\Override]
189    public function validateToIdentify(): void {
190    }
191
192    #[\Override]
193    public function validateToSign(): void {
194    }
195
196    protected function throwIfFileNotFound(): void {
197        $signRequest = $this->identifyService->getSignRequestMapper()->getById($this->getEntity()->getSignRequestId());
198        $fileEntity = $this->identifyService->getFileMapper()->getById($signRequest->getFileId());
199
200        $filesToCheck = [];
201
202        if ($fileEntity->getNodeType() === 'envelope') {
203            $children = $this->identifyService->getFileMapper()->getChildrenFiles($fileEntity->getId());
204            foreach ($children as $child) {
205                $filesToCheck[] = [
206                    'uuid' => $child->getUuid(),
207                    'nodeId' => $child->getNodeId(),
208                ];
209            }
210        } else {
211            $filesToCheck[] = [
212                'uuid' => $fileEntity->getUuid(),
213                'nodeId' => $fileEntity->getNodeId(),
214            ];
215        }
216
217        foreach ($filesToCheck as $fileInfo) {
218            if (!is_int($fileInfo['nodeId'])) {
219                throw new LibresignException(json_encode([
220                    'action' => JSActions::ACTION_DO_NOTHING,
221                    // TRANSLATORS Error shown during signer identification when the related document cannot be found.
222                    'errors' => [['message' => $this->identifyService->getL10n()->t('File not found')]],
223                ]), AppFrameworkHttp::STATUS_NOT_FOUND);
224            }
225
226            $storageUserId = $this->identifyService->getFileMapper()
227                ->getStorageUserIdByUuid($fileInfo['uuid']);
228            $folderService = $this->identifyService->getFolderService();
229            $previousUserId = $folderService->getUserId();
230            $folderService->setUserId($storageUserId);
231            try {
232                $folderService->getFileByNodeId($fileInfo['nodeId']);
233            } catch (NotFoundException) {
234                throw new LibresignException(json_encode([
235                    'action' => JSActions::ACTION_DO_NOTHING,
236                    // TRANSLATORS Error shown during signer identification when the related document cannot be found.
237                    'errors' => [['message' => $this->identifyService->getL10n()->t('File not found')]],
238                ]), AppFrameworkHttp::STATUS_NOT_FOUND);
239            } finally {
240                $folderService->setUserId($previousUserId);
241            }
242        }
243    }
244
245    protected function throwIfMaximumValidityExpired(): void {
246        $maximumValidity = $this->getRuntimeConfigInt(ExpirationRulesPolicy::KEY_MAXIMUM_VALIDITY, SessionService::NO_MAXIMUM_VALIDITY);
247        if ($maximumValidity <= 0) {
248            return;
249        }
250        $signRequest = $this->identifyService->getSignRequestMapper()->getById($this->getEntity()->getSignRequestId());
251        $now = $this->identifyService->getTimeFactory()->getDateTime();
252        $expirationDate = (clone $signRequest->getCreatedAt())
253            ->add(new \DateInterval('PT' . $maximumValidity . 'S'));
254        if ($expirationDate < $now) {
255            throw new LibresignException(json_encode([
256                'action' => JSActions::ACTION_DO_NOTHING,
257                // TRANSLATORS Error shown when the signing request has reached its maximum validity and can no longer be renewed.
258                'errors' => [['message' => $this->identifyService->getL10n()->t('This signing request has expired and can no longer be used.')]],
259            ]));
260        }
261    }
262
263    protected function throwIfInvalidToken(): void {
264        $code = $this->getEntity()->getCode();
265        if ($code === null || $code === '') {
266            if ($this->codeSentByUser !== null) {
267                // TRANSLATORS Error shown when the verification code entered by the signer is incorrect.
268                throw new LibresignException($this->identifyService->getL10n()->t('Invalid code.'), self::CODE_INVALID_TOKEN);
269            }
270            return;
271        }
272        if (empty($this->codeSentByUser) || !$this->identifyService->getHasher()->verify($this->codeSentByUser, $code)) {
273            // TRANSLATORS Error shown when the verification code entered by the signer is incorrect.
274            throw new LibresignException($this->identifyService->getL10n()->t('Invalid code.'), self::CODE_INVALID_TOKEN);
275        }
276    }
277
278    protected function renewSession(): void {
279        $this->identifyService->getSessionService()->setIdentifyMethodId($this->getEntity()->getId());
280        $renewalInterval = $this->getRuntimeConfigInt(ExpirationRulesPolicy::KEY_RENEWAL_INTERVAL, SessionService::NO_RENEWAL_INTERVAL);
281        if ($renewalInterval <= 0) {
282            return;
283        }
284        $this->identifyService->getSessionService()->resetDurationOfSignPage($renewalInterval);
285    }
286
287    protected function updateIdentifiedAt(): void {
288        if ($this->getEntity()->getCode() && !$this->getEntity()->getIdentifiedAtDate()) {
289            return;
290        }
291        $this->getEntity()->setIdentifiedAtDate($this->identifyService->getTimeFactory()->getDateTime());
292        $this->willNotify = false;
293        $this->identifyService->save($this->getEntity());
294        $this->notify();
295    }
296
297    protected function throwIfRenewalIntervalExpired(): void {
298        $renewalInterval = $this->getRuntimeConfigInt(ExpirationRulesPolicy::KEY_RENEWAL_INTERVAL, SessionService::NO_RENEWAL_INTERVAL);
299        if ($renewalInterval <= 0) {
300            return;
301        }
302        $signRequest = $this->identifyService->getSignRequestMapper()->getById($this->getEntity()->getSignRequestId());
303        $startTime = $this->identifyService->getSessionService()->getSignStartTime();
304        if ($startTime > 0) {
305            $startTime = new DateTime('@' . $startTime, new \DateTimeZone('UTC'));
306        } else {
307            $startTime = null;
308        }
309        $createdAt = $signRequest->getCreatedAt();
310        $lastAttempt = $this->getEntity()->getLastAttemptDate();
311        $identifiedAt = $this->getEntity()->getIdentifiedAtDate();
312        $lastActionDate = max(
313            $startTime,
314            $createdAt,
315            $lastAttempt,
316            $identifiedAt,
317        );
318        $now = $this->identifyService->getTimeFactory()->getDateTime();
319        $endRenewal = (clone $lastActionDate)
320            ->add(new \DateInterval('PT' . $renewalInterval . 'S'));
321        if ($endRenewal < $now) {
322            if ($this->getName() === 'email') {
323                $blur = new Blur($this->getEntity()->getIdentifierValue());
324                throw new LibresignException(json_encode([
325                    'action' => $this->getRenewAction(),
326                    // TRANSLATORS title that is displayed at screen to notify the signer that the link to sign the document expired
327                    'title' => $this->identifyService->getL10n()->t('Link expired'),
328                    'body' => $this->identifyService->getL10n()->t(
329                        "The link to sign the document has expired.\n"
330                        . 'We will send a new link to the email %1$s.' . "\n"
331                        . 'Click below to receive the new link and be able to sign the document.',
332                        [$blur->make()]
333                    ),
334                    'uuid' => $signRequest->getUuid(),
335                    // TRANSLATORS Button to renew the link to sign the document. Renew is the action to generate a new sign link when the link expired.
336                    'renewButton' => $this->identifyService->getL10n()->t('Renew'),
337                ]));
338            }
339            $this->validateToRenew($this->user);
340        }
341    }
342
343    private function getRenewAction(): int {
344        return match ($this->name) {
345            'email' => JSActions::ACTION_RENEW_EMAIL,
346            default => throw new InvalidArgumentException('Invalid identify method name'),
347        };
348    }
349
350    private function getRuntimeConfigInt(string $key, int $default): int {
351        $resolved = $this->identifyService->getPolicyService()->resolve($key);
352        $value = $resolved->getEffectiveValue();
353        return $value !== null ? (int)$value : $default;
354    }
355
356    protected function throwIfAlreadySigned(): void {
357        $signRequest = $this->identifyService->getSignRequestMapper()->getById($this->getEntity()->getSignRequestId());
358        $fileEntity = $this->identifyService->getFileMapper()->getById($signRequest->getFileId());
359        if ($fileEntity->getStatus() === FileStatus::SIGNED->value
360            || $signRequest->getSigned()
361        ) {
362            throw new LibresignException(json_encode([
363                'action' => JSActions::ACTION_REDIRECT,
364                // TRANSLATORS Error shown when the signer tries to sign a document that is already signed.
365                'errors' => [['message' => $this->identifyService->getL10n()->t('File already signed.')]],
366                'redirect' => $this->identifyService->getUrlGenerator()->linkToRoute(
367                    'libresign.page.validationFilePublic',
368                    ['uuid' => $signRequest->getUuid()]
369                ),
370            ]));
371        }
372    }
373
374    protected function getSettingsFromDatabase(array $default = [], array $immutable = []): array {
375        if ($this->settings) {
376            return $this->settings;
377        }
378        $this->loadSavedSettings();
379        $default = array_merge(
380            [
381                'name' => $this->name,
382                'friendly_name' => $this->getFriendlyName(),
383                'enabled' => true,
384                'requirement' => IdentifyMethodRequirement::REQUIRED->value,
385                'signatureMethods' => $this->signatureMethodsToArray(),
386            ],
387            $default
388        );
389        $this->removeKeysThatDontExists($default);
390        $this->overrideImmutable($immutable);
391        $this->settings = $this->applyDefault($this->settings, $default);
392        if (!isset($this->settings['requirement'])) {
393            $this->settings['requirement'] = IdentifyMethodRequirement::REQUIRED->value;
394        }
395        return $this->settings;
396    }
397
398    private function overrideImmutable(array $immutable): void {
399        $this->settings = array_merge($this->settings, $immutable);
400    }
401
402    private function loadSavedSettings(): void {
403        $config = $this->identifyService->getSavedSettings();
404        $this->settings = array_reduce($config, function ($carry, $config) {
405            if ($config['name'] === $this->name) {
406                return $config;
407            }
408            return $carry;
409        }, []);
410        $enabled = false;
411        $availableSignatureMethods = $this->getAvailableSignatureMethods();
412        foreach ($availableSignatureMethods as $signatureMethodName) {
413            $this->signatureMethods[$signatureMethodName]
414                = $this->getEmptyInstanceOfSignatureMethodByName($signatureMethodName);
415            if (isset($this->settings['signatureMethods'][$signatureMethodName]['enabled'])
416                && $this->settings['signatureMethods'][$signatureMethodName]['enabled']
417            ) {
418                $this->signatureMethods[$signatureMethodName]->enable();
419                $enabled = true;
420            }
421        }
422        if (isset($this->settings['signatureMethods'])) {
423            foreach (array_keys($this->settings['signatureMethods']) as $signatureMethodName) {
424                if (!in_array($signatureMethodName, $availableSignatureMethods, true)) {
425                    unset($this->settings['signatureMethods'][$signatureMethodName]);
426                }
427            }
428        }
429        if (!$enabled && $this->defaultSignatureMethod) {
430            $this->signatureMethods[$this->defaultSignatureMethod]->enable();
431        }
432    }
433
434    private function applyDefault(array $customConfig, array $default): array {
435        foreach ($default as $key => $value) {
436            if (!isset($customConfig[$key])) {
437                $customConfig[$key] = $value;
438            } elseif (gettype($value) !== gettype($customConfig[$key])) {
439                $customConfig[$key] = $value;
440            } elseif (gettype($value) === 'array') {
441                $customConfig[$key] = $this->applyDefault($customConfig[$key], $value);
442            }
443        }
444        return $customConfig;
445    }
446
447    #[\Override]
448    public function save(?string $deliveryEventId = null): void {
449        $this->identifyService->save($this->getEntity());
450        $this->notify($deliveryEventId);
451    }
452
453    #[\Override]
454    public function delete(): void {
455        $this->identifyService->delete($this->getEntity());
456    }
457
458    private function removeKeysThatDontExists(array $default): void {
459        $diff = array_diff_key($this->settings, $default);
460        foreach (array_keys($diff) as $invalidKey) {
461            unset($this->settings[$invalidKey]);
462        }
463    }
464
465    #[\Override]
466    public function validateToRenew(?IUser $user = null): void {
467        $this->throwIfMaximumValidityExpired();
468        $this->throwIfAlreadySigned();
469        $this->throwIfFileNotFound();
470    }
471}