Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
71.74% covered (warning)
71.74%
99 / 138
66.67% covered (warning)
66.67%
8 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Email
71.74% covered (warning)
71.74%
99 / 138
66.67% covered (warning)
66.67%
8 / 12
57.58
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 validateToRequest
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateToIdentify
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 validateToSign
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 throwIfNeedToCreateAccount
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
7
 throwIfIsAuthenticatedWithDifferentAccount
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
6
 throwIfAccountAlreadyExists
32.00% covered (danger)
32.00%
8 / 25
0.00% covered (danger)
0.00%
0 / 1
17.32
 validateToCreateAccount
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 throwIfNotAllowedToCreateAccount
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 throwIfInvalidEmail
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getSettings
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 getDefaultSettings
100.00% covered (success)
100.00%
5 / 5
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\IdentifyMethod;
10
11use OCA\Libresign\Db\FileElementMapper;
12use OCA\Libresign\Db\IdentifyMethodMapper;
13use OCA\Libresign\Exception\LibresignException;
14use OCA\Libresign\Helper\JSActions;
15use OCA\Libresign\Service\IdentifyMethod\SignatureMethod\ISignatureMethod;
16use OCA\Libresign\Service\SessionService;
17use OCP\AppFramework\Utility\ITimeFactory;
18use OCP\Files\IRootFolder;
19use OCP\IUser;
20use OCP\IUserSession;
21use Psr\Log\LoggerInterface;
22
23class Email extends AbstractIdentifyMethod {
24    public array $availableSignatureMethods = [
25        ISignatureMethod::SIGNATURE_METHOD_CLICK_TO_SIGN,
26        ISignatureMethod::SIGNATURE_METHOD_EMAIL_TOKEN,
27    ];
28    public string $defaultSignatureMethod = ISignatureMethod::SIGNATURE_METHOD_EMAIL_TOKEN;
29    public function __construct(
30        protected IdentifyService $identifyService,
31        private IdentifyMethodMapper $identifyMethodMapper,
32        private IRootFolder $root,
33        private ITimeFactory $timeFactory,
34        private SessionService $sessionService,
35        private FileElementMapper $fileElementMapper,
36        private IUserSession $userSession,
37        private LoggerInterface $logger,
38    ) {
39        // TRANSLATORS Name of possible authenticator method. This signalize that the signer could be identified by email
40        $this->setFriendlyName($this->identifyService->getL10n()->t('Email'));
41        parent::__construct(
42            $identifyService,
43        );
44    }
45
46    #[\Override]
47    public function validateToRequest(): void {
48        $this->throwIfInvalidEmail();
49    }
50
51    #[\Override]
52    public function validateToIdentify(): void {
53        $this->throwIfAccountAlreadyExists();
54        $this->throwIfIsAuthenticatedWithDifferentAccount();
55        $this->throwIfMaximumValidityExpired();
56        $this->throwIfRenewalIntervalExpired();
57        $this->throwIfNeedToCreateAccount();
58        $this->throwIfFileNotFound();
59        $this->throwIfAlreadySigned();
60        $this->renewSession();
61        $this->updateIdentifiedAt();
62    }
63
64    #[\Override]
65    public function validateToSign(): void {
66        $this->throwIfAccountAlreadyExists();
67        $this->throwIfIsAuthenticatedWithDifferentAccount();
68        $this->throwIfInvalidToken();
69        $this->throwIfMaximumValidityExpired();
70        $this->throwIfRenewalIntervalExpired();
71        $this->throwIfNeedToCreateAccount();
72        $this->throwIfFileNotFound();
73        $this->throwIfAlreadySigned();
74        $this->renewSession();
75        $this->updateIdentifiedAt();
76    }
77
78    protected function throwIfNeedToCreateAccount(): void {
79        $user = $this->userSession->getUser();
80        if ($user instanceof IUser) {
81            return;
82        }
83        $settings = $this->getSettings();
84        if (!$settings['enabled']) {
85            $this->logger->debug('Email identification method is disabled');
86            throw new LibresignException(json_encode([
87                'action' => JSActions::ACTION_SHOW_ERROR,
88                // TRANSLATORS This signalize that the identification method by email is disabled and the signer can't be identified
89                'errors' => [['message' => $this->identifyService->getL10n()->t('Invalid identification method')]],
90            ]));
91            return;
92        }
93        if (!$settings['can_create_account']) {
94            return;
95        }
96        if ($this->identifyService->getSessionService()->getSignStartTime()) {
97            return;
98        }
99        $email = $this->getEntity()->getIdentifierValue();
100
101        $signer = $this->identifyService->getUserManager()->getByEmail($email);
102        if (!$signer) {
103            throw new LibresignException(json_encode([
104                'action' => JSActions::ACTION_CREATE_ACCOUNT,
105                'settings' => ['accountHash' => md5($email)],
106                // TRANSLATORS Message inviting a guest signer identified by email to create a Nextcloud account before signing.
107                'message' => $this->identifyService->getL10n()->t('You need to create an account to sign this file.'),
108            ]));
109        }
110        $signRequest = $this->identifyService->getSignRequestMapper()->getById($this->getEntity()->getSignRequestId());
111        // TRANSLATORS Error shown when the email already belongs to an existing account and the guest must log in to sign.
112        $errors = [$this->identifyService->getL10n()->t('User already exists. Please login.')];
113        if ($this->userSession->isLoggedIn()) {
114            // TRANSLATORS Error shown when the logged-in user tries to sign a document that was not requested for them.
115            $errors[] = $this->identifyService->getL10n()->t('This is not your file');
116            $this->userSession->logout();
117        }
118        throw new LibresignException(json_encode([
119            'action' => JSActions::ACTION_REDIRECT,
120            'errors' => $errors,
121            'redirect' => $this->identifyService->getUrlGenerator()->linkToRoute('core.login.showLoginForm', [
122                'redirect_url' => $this->identifyService->getUrlGenerator()->linkToRoute(
123                    'libresign.page.sign',
124                    ['uuid' => $signRequest->getUuid()]
125                ),
126            ]),
127        ]));
128    }
129
130    private function throwIfIsAuthenticatedWithDifferentAccount(): void {
131        $user = $this->userSession->getUser();
132        if (!$user instanceof IUser) {
133            return;
134        }
135        $email = $this->entity->getIdentifierValue();
136        if (!empty($user->getEMailAddress()) && $user->getEMailAddress() === $email) {
137            return;
138        }
139        if ($this->getEntity()->getCode() && !$this->getEntity()->getIdentifiedAtDate()) {
140            return;
141        }
142        throw new LibresignException(json_encode([
143            'action' => JSActions::ACTION_DO_NOTHING,
144            'page_state' => 'authentication_required',
145            'page_state_data' => [
146                // TRANSLATORS Title shown when a signing link is opened while a different authenticated Nextcloud session is active. This is an expected security check, not a system failure.
147                'title' => $this->identifyService->getL10n()->t('Authentication required'),
148                // TRANSLATORS Description shown when the current authenticated Nextcloud session cannot be used to sign a document because it does not match the authorized signer.
149                'description' => $this->identifyService->getL10n()->t('The current authenticated session cannot be used to sign this document.'),
150                'noteType' => 'info',
151                'icon' => 'info',
152            ],
153            'errors' => [[
154                // TRANSLATORS Guidance shown when the current authenticated Nextcloud session cannot be used to sign a document because it does not match the authorized signer. Keep it generic and do not mention emails or delivery channels.
155                'message' => $this->identifyService->getL10n()->t('To continue, sign out from the current account and open the signing link again, or open the signing link in a browser session where this account is not active.'),
156            ]],
157        ]));
158    }
159
160    private function throwIfAccountAlreadyExists(): void {
161        $user = $this->userSession->getUser();
162        if (!$user instanceof IUser) {
163            return;
164        }
165        $email = $this->entity->getIdentifierValue();
166        $signer = $this->identifyService->getUserManager()->getByEmail($email);
167        if (!$signer) {
168            return;
169        }
170        foreach ($signer as $s) {
171            if ($s->getUID() === $user->getUID()) {
172                return;
173            }
174        }
175        $signRequest = $this->identifyService->getSignRequestMapper()->getById($this->getEntity()->getSignRequestId());
176        // TRANSLATORS Error shown when the email already belongs to an existing account and the guest must log in to sign.
177        $errors = [$this->identifyService->getL10n()->t('User already exists. Please login.')];
178        if ($this->userSession->isLoggedIn()) {
179            // TRANSLATORS Error shown when the logged-in user tries to sign a document that was not requested for them.
180            $errors[] = $this->identifyService->getL10n()->t('This is not your file');
181            $this->userSession->logout();
182        }
183        throw new LibresignException(json_encode([
184            'action' => JSActions::ACTION_REDIRECT,
185            'errors' => $errors,
186            'redirect' => $this->identifyService->getUrlGenerator()->linkToRoute('core.login.showLoginForm', [
187                'redirect_url' => $this->identifyService->getUrlGenerator()->linkToRoute(
188                    'libresign.page.sign',
189                    ['uuid' => $signRequest->getUuid()]
190                ),
191            ]),
192        ]));
193    }
194
195    #[\Override]
196    public function validateToCreateAccount(string $value): void {
197        $this->throwIfInvalidEmail();
198        $this->throwIfNotAllowedToCreateAccount();
199        if ($this->identifyService->getUserManager()->userExists($value)) {
200            // TRANSLATORS Error shown when creating a guest account for signing and that email is already registered.
201            throw new LibresignException($this->identifyService->getL10n()->t('User already exists'));
202        }
203        if ($this->getEntity()->getIdentifierValue() !== $value) {
204            // TRANSLATORS Error shown when the logged-in user tries to sign a document that was not requested for them.
205            throw new LibresignException($this->identifyService->getL10n()->t('This is not your file'));
206        }
207    }
208
209    private function throwIfNotAllowedToCreateAccount(): void {
210        $settings = $this->getSettings();
211        if (!$settings['can_create_account']) {
212            throw new LibresignException(json_encode([
213                'action' => JSActions::ACTION_SHOW_ERROR,
214                // TRANSLATORS Error shown when guest account creation is disabled and the signer cannot register to sign.
215                'errors' => [['message' => $this->identifyService->getL10n()->t('It is not possible to create new accounts.')]],
216            ]));
217        }
218    }
219
220    private function throwIfInvalidEmail(): void {
221        if (!filter_var($this->entity->getIdentifierValue(), FILTER_VALIDATE_EMAIL)) {
222            // TRANSLATORS Error shown when the email address used to identify the signer is invalid.
223            throw new LibresignException($this->identifyService->getL10n()->t('Invalid email'));
224        }
225    }
226
227    #[\Override]
228    public function getSettings(): array {
229        if (!empty($this->settings)) {
230            return $this->settings;
231        }
232        $this->settings = parent::getSettingsFromDatabase(
233            default: [
234                'enabled' => false,
235                'can_create_account' => false,
236            ],
237            immutable: [
238                'test_url' => $this->identifyService->getUrlGenerator()->linkToRoute('settings.MailSettings.sendTestMail'),
239            ]
240        );
241        return $this->settings;
242    }
243
244    #[\Override]
245    public function getDefaultSettings(): array {
246        $settings = parent::getDefaultSettings();
247        $settings['enabled'] = false;
248        $settings['can_create_account'] = false;
249        $settings['test_url'] = $this->identifyService->getUrlGenerator()->linkToRoute('settings.MailSettings.sendTestMail');
250        return $settings;
251    }
252}