Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
74.36% covered (warning)
74.36%
116 / 156
34.78% covered (danger)
34.78%
8 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
SignerValidator
74.36% covered (warning)
74.36%
116 / 156
34.78% covered (danger)
34.78%
8 / 23
210.68
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
 validateIdentifySigners
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
9.19
 normalizeSignerIdentifyMethods
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
 normalizeRequestSigners
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 validateSigner
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 validateSignerUuid
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validateRenewSigner
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 validateUuidFormat
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 validateCredentials
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 validateIfIdentifyMethodExists
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 validateSignerStatus
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
 validateIdentifyMethod
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 validateSignerUuidExists
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
2.26
 validateIdentifyMethodForRequest
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 validateSigningParticipantsRequired
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
6.22
 validateParticipantRole
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
5.93
 getExistingRequestFile
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
4.59
 validateDocMdpPdfRestrictions
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 resolveIdentifyMethod
53.33% covered (warning)
53.33%
8 / 15
0.00% covered (danger)
0.00%
0 / 1
9.66
 searchMethodByNameAndValue
53.85% covered (warning)
53.85%
7 / 13
0.00% covered (danger)
0.00%
0 / 1
11.82
 getIdentifyMethodByNameAndValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFirstAvailableMethod
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 throwSignerActionError
100.00% covered (success)
100.00%
4 / 4
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\Service\Validation;
10
11use OCA\Libresign\Db\File;
12use OCA\Libresign\Db\FileMapper;
13use OCA\Libresign\Db\IdDocsMapper;
14use OCA\Libresign\Db\SignRequest;
15use OCA\Libresign\Db\SignRequestMapper;
16use OCA\Libresign\Enum\FileStatus;
17use OCA\Libresign\Enum\ParticipantRole;
18use OCA\Libresign\Enum\SignRequestStatus;
19use OCA\Libresign\Exception\LibresignException;
20use OCA\Libresign\Helper\JSActions;
21use OCA\Libresign\Service\DocMdp\Validator as DocMdpValidator;
22use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod;
23use OCA\Libresign\Service\IdentifyMethod\RuntimeRequirementValidator;
24use OCA\Libresign\Service\IdentifyMethodService;
25use OCA\Libresign\Service\Policy\Provider\ObserverProfile\ObserverProfilePolicyService;
26use OCA\Libresign\Service\SequentialSigningService;
27use OCP\AppFramework\Db\DoesNotExistException;
28use OCP\AppFramework\Http;
29use OCP\IL10N;
30use OCP\IUser;
31
32class SignerValidator {
33    public function __construct(
34        private IL10N $l10n,
35        private SignRequestMapper $signRequestMapper,
36        private FileMapper $fileMapper,
37        private IdDocsMapper $idDocsMapper,
38        private IdentifyMethodService $identifyMethodService,
39        private SequentialSigningService $sequentialSigningService,
40        private DocMdpValidator $docMdpValidator,
41        private RuntimeRequirementValidator $runtimeRequirementValidator,
42        private ObserverProfilePolicyService $observerProfilePolicyService,
43    ) {
44    }
45
46    public function validateIdentifySigners(array $data): void {
47        if (empty($data['signers'])) {
48            return;
49        }
50        if (!is_array($data['signers'])) {
51            throw new LibresignException($this->l10n->t('No signers'));
52        }
53        $this->validateSigningParticipantsRequired($data);
54        $this->docMdpValidator->validateSignersCount($data);
55        $this->validateDocMdpPdfRestrictions($data);
56        foreach ($data['signers'] as $signer) {
57            if (!is_array($signer) || empty($signer)) {
58                throw new LibresignException($this->l10n->t('No signers'));
59            }
60            if (isset($signer['displayName']) && strlen($signer['displayName']) > 64) {
61                throw new LibresignException('Display name must not be longer than 64 characters');
62            }
63            $this->validateParticipantRole($signer, $data);
64            foreach ($this->normalizeSignerIdentifyMethods($signer) as $method) {
65                $this->validateIdentifyMethodForRequest($method['name'], $method['value']);
66            }
67        }
68    }
69
70    /** @return list<array{name: string, value: string}> */
71    public function normalizeSignerIdentifyMethods(array $signer): array {
72        if (empty($signer['identifyMethods']) || !is_array($signer['identifyMethods'])) {
73            throw new LibresignException('No identify methods for signer');
74        }
75        $normalizedMethods = [];
76        foreach ($signer['identifyMethods'] as $data) {
77            if (!is_array($data) || !array_key_exists('method', $data) || !array_key_exists('value', $data)) {
78                throw new LibresignException('Invalid identify method structure');
79            }
80            $normalizedMethods[] = ['name' => $data['method'], 'value' => $data['value']];
81        }
82        return $normalizedMethods;
83    }
84
85    /**
86     * @param list<array<string, mixed>> $signers
87     * @return list<array<string, mixed>>
88     */
89    public function normalizeRequestSigners(array $signers): array {
90        $normalizedSigners = [];
91        foreach ($signers as $signer) {
92            if (!is_array($signer)) {
93                throw new LibresignException($this->l10n->t('No signers'));
94            }
95            $normalizedMethods = array_map(
96                fn (array $method): array => ['method' => $method['name'], 'value' => $method['value']],
97                $this->normalizeSignerIdentifyMethods($signer),
98            );
99            $normalizedSigners[] = [...$signer, 'identifyMethods' => $normalizedMethods];
100        }
101        return $normalizedSigners;
102    }
103
104    public function validateSigner(string $uuid, ?IUser $user = null): void {
105        $this->validateSignerUuidExists($uuid);
106        $this->validateSignerStatus($uuid);
107        $this->validateIdentifyMethod($uuid);
108    }
109
110    public function validateSignerUuid(string $uuid): void {
111        $this->validateSignerUuidExists($uuid);
112    }
113
114    public function validateRenewSigner(string $uuid, ?IUser $user = null): void {
115        $this->validateSignerUuidExists($uuid);
116        $signRequest = $this->signRequestMapper->getByUuid($uuid);
117        $identifyMethods = $this->identifyMethodService->getIdentifyMethodsFromSignRequestId($signRequest->getId());
118        foreach ($identifyMethods as $methods) {
119            foreach ($methods as $identifyMethod) {
120                $identifyMethod->validateToRenew($user);
121            }
122        }
123    }
124
125    public function validateUuidFormat(string $uuid): void {
126        if (!$uuid || !preg_match('/^[a-f\d]{8}(-[a-f\d]{4}){4}[a-f\d]{8}$/i', $uuid)) {
127            throw new LibresignException(json_encode([
128                'action' => JSActions::ACTION_DO_NOTHING,
129                'errors' => [['message' => $this->l10n->t('Invalid UUID')]],
130            ]), Http::STATUS_NOT_FOUND);
131        }
132    }
133
134    public function validateCredentials(SignRequest $signRequest, string $identifyMethodName, string $identifyValue, string $token): void {
135        if ($identifyMethodName === IdentifyMethodService::IDENTIFY_PASSWORD && $token === '') {
136            throw new LibresignException($this->l10n->t('libresign', 'Invalid password'));
137        }
138        $this->validateIfIdentifyMethodExists($identifyMethodName);
139        if ($signRequest->getSigned()) {
140            throw new LibresignException($this->l10n->t('File already signed.'));
141        }
142        $identifyMethod = $this->resolveIdentifyMethod($signRequest, $identifyMethodName, $identifyValue);
143        $identifyMethod->setCodeSentByUser($token);
144        $identifyMethod->validateToSign();
145        $this->runtimeRequirementValidator->validate($signRequest);
146    }
147
148    public function validateIfIdentifyMethodExists(string $identifyMethod): void {
149        if (!$this->identifyMethodService->exists($identifyMethod)) {
150            throw new LibresignException($this->l10n->t('Invalid identification method'));
151        }
152    }
153
154    private function validateSignerStatus(string $uuid): void {
155        $signRequest = $this->signRequestMapper->getByUuid($uuid);
156
157        if (!$signRequest->getParticipantRoleEnum()->canSign()) {
158            // TRANSLATORS Validation error when an observer tries to sign a document.
159            $this->throwSignerActionError($this->l10n->t('Observers cannot sign this document'));
160        }
161
162        $status = $signRequest->getStatusEnum();
163        $file = $this->fileMapper->getById($signRequest->getFileId());
164        $this->sequentialSigningService->setFile($file);
165
166        if ($status === SignRequestStatus::DRAFT) {
167            try {
168                if ($this->idDocsMapper->getByFileId($signRequest->getFileId())) {
169                    return;
170                }
171            } catch (\Throwable) {
172            }
173            $this->throwSignerActionError($this->l10n->t('You are not allowed to sign this document yet'));
174        }
175        if ($status === SignRequestStatus::SIGNED) {
176            $this->throwSignerActionError($this->l10n->t('Document already signed'));
177        }
178        if (
179            $this->sequentialSigningService->isOrderedNumericFlow()
180            && $this->sequentialSigningService->hasPendingLowerOrderSigners($signRequest->getFileId(), $signRequest->getSigningOrder())
181        ) {
182            $this->throwSignerActionError($this->l10n->t('You are not allowed to sign this document yet'));
183        }
184    }
185
186    private function validateIdentifyMethod(string $uuid): void {
187        $signRequest = $this->signRequestMapper->getByUuid($uuid);
188        foreach ($this->identifyMethodService->getIdentifyMethodsFromSignRequestId($signRequest->getId()) as $methods) {
189            foreach ($methods as $identifyMethod) {
190                $identifyMethod->validateToIdentify();
191            }
192        }
193    }
194
195    private function validateSignerUuidExists(string $uuid): void {
196        $this->validateUuidFormat($uuid);
197        try {
198            $signRequest = $this->signRequestMapper->getByUuid($uuid);
199            $this->fileMapper->getById($signRequest->getFileId());
200        } catch (DoesNotExistException) {
201            $this->throwSignerActionError($this->l10n->t('Invalid UUID'));
202        }
203    }
204
205    private function validateIdentifyMethodForRequest(string $name, string $identifyValue): void {
206        $identifyMethod = $this->identifyMethodService->getInstanceOfIdentifyMethod($name, $identifyValue);
207        $identifyMethod->validateToRequest();
208        if (empty($identifyMethod->getSignatureMethods())) {
209            throw new LibresignException('No signature methods for identify method ' . $name);
210        }
211    }
212
213    private function validateSigningParticipantsRequired(array $data): void {
214        if (($data['status'] ?? FileStatus::DRAFT->value) === FileStatus::DRAFT->value) {
215            return;
216        }
217
218        if (!is_array($data['signers'])) {
219            return;
220        }
221
222        foreach ($data['signers'] as $signer) {
223            if (!is_array($signer)) {
224                continue;
225            }
226
227            $role = ParticipantRole::fromNullable($signer['participantRole'] ?? null);
228            if ($role->canSign()) {
229                return;
230            }
231        }
232
233        // TRANSLATORS Validation error when requesting signatures without any signing participants.
234        throw new LibresignException($this->l10n->t('At least one signer is required'));
235    }
236
237    private function validateParticipantRole(array $signer, array $data): void {
238        $roleValue = $signer['participantRole'] ?? ParticipantRole::SIGNER->value;
239        if (!is_string($roleValue)) {
240            throw new LibresignException('Invalid participant role');
241        }
242
243        try {
244            $role = ParticipantRole::from($roleValue);
245        } catch (\ValueError) {
246            throw new LibresignException('Invalid participant role');
247        }
248
249        if ($role === ParticipantRole::OBSERVER
250            && !$this->observerProfilePolicyService->isEnabled($this->getExistingRequestFile($data))
251        ) {
252            // TRANSLATORS Validation error when observer participants are submitted while the feature is disabled by policy.
253            throw new LibresignException($this->l10n->t('Observer participants are not enabled'));
254        }
255    }
256
257    private function getExistingRequestFile(array $data): ?File {
258        $uuid = $data['uuid'] ?? null;
259        if (!is_string($uuid) || $uuid === '') {
260            return null;
261        }
262
263        try {
264            return $this->fileMapper->getByUuid($uuid);
265        } catch (DoesNotExistException) {
266            return null;
267        }
268    }
269
270    private function validateDocMdpPdfRestrictions(array $data): void {
271        if (empty($data['uuid']) || empty($data['signers'])) {
272            return;
273        }
274        try {
275            $this->docMdpValidator->validatePdfRestrictions($this->fileMapper->getByUuid($data['uuid']));
276        } catch (DoesNotExistException) {
277        }
278    }
279
280    private function resolveIdentifyMethod(SignRequest $signRequest, string $methodName, ?string $identifyValue): IIdentifyMethod {
281        if (!$signRequest->getId()) {
282            return $this->identifyMethodService->setCurrentIdentifyMethod()->getInstanceOfIdentifyMethod($methodName, $identifyValue);
283        }
284        $methodsList = $this->identifyMethodService->getIdentifyMethodsFromSignRequestId($signRequest->getId());
285        $identifyMethod = $this->searchMethodByNameAndValue($methodsList, $methodName, $identifyValue);
286        if ($identifyMethod) {
287            return $identifyMethod;
288        }
289        $signMethods = $this->identifyMethodService->getSignMethodsOfIdentifiedFactors($signRequest->getId());
290        $identifyMethod = $this->searchMethodByNameAndValue($signMethods, $methodName, $identifyValue);
291        if ($identifyMethod) {
292            return $identifyMethod;
293        }
294        if (!empty($methodsList)) {
295            return $this->getFirstAvailableMethod($methodsList);
296        }
297        if (!empty($signMethods)) {
298            return $this->getFirstAvailableMethod($signMethods);
299        }
300        throw new LibresignException($this->l10n->t('Invalid identification method'));
301    }
302
303    private function searchMethodByNameAndValue(array $methods, string $methodName, ?string $identifyValue): ?IIdentifyMethod {
304        if (!isset($methods[$methodName])) {
305            return null;
306        }
307        if ($identifyValue) {
308            foreach ($methods[$methodName] as $identifyMethod) {
309                if (!$identifyMethod instanceof IIdentifyMethod) {
310                    $identifyMethod = $this->getIdentifyMethodByNameAndValue($methodName, $identifyValue);
311                }
312                if ($identifyMethod->getEntity()->getIdentifierValue() === $identifyValue) {
313                    return $identifyMethod;
314                }
315            }
316            return null;
317        }
318        $identifyMethod = current($methods[$methodName]);
319        if (!$identifyMethod instanceof IIdentifyMethod) {
320            $identifyMethod = $this->getIdentifyMethodByNameAndValue($methodName, $identifyValue);
321        }
322        return $identifyMethod;
323    }
324
325    private function getIdentifyMethodByNameAndValue(string $identifyMethodName, ?string $identifyValue): IIdentifyMethod {
326        return $this->identifyMethodService->setCurrentIdentifyMethod()->getInstanceOfIdentifyMethod($identifyMethodName, $identifyValue);
327    }
328
329    private function getFirstAvailableMethod(array $methods): IIdentifyMethod {
330        foreach ($methods as $methodGroup) {
331            if (!empty($methodGroup)) {
332                return current($methodGroup);
333            }
334        }
335        throw new LibresignException($this->l10n->t('Invalid identification method'));
336    }
337
338    private function throwSignerActionError(string $message): never {
339        throw new LibresignException(json_encode([
340            'action' => JSActions::ACTION_DO_NOTHING,
341            'errors' => [['message' => $message]],
342        ]));
343    }
344}