Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
73.91% covered (warning)
73.91%
85 / 115
61.54% covered (warning)
61.54%
8 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Account
73.91% covered (warning)
73.91%
85 / 115
61.54% covered (warning)
61.54%
8 / 13
80.95
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%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 validateSignatureMethodsForRequest
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 validateEmailForEmailToken
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 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
 getSigner
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 resolveSignerUser
77.78% covered (warning)
77.78%
14 / 18
0.00% covered (danger)
0.00%
0 / 1
14.85
 userMatchesIdentifier
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 authenticatedUserIsTheSigner
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 throwIfNotAuthenticated
8.33% covered (danger)
8.33%
1 / 12
0.00% covered (danger)
0.00%
0 / 1
5.08
 getSettings
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 isEnabledByDefault
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
7.02
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\IdentifyMethodMapper;
12use OCA\Libresign\Exception\LibresignException;
13use OCA\Libresign\Helper\JSActions;
14use OCA\Libresign\Service\IdentifyMethod\SignatureMethod\ISignatureMethod;
15use OCA\Libresign\Service\MailService;
16use OCA\Libresign\Service\SessionService;
17use OCP\AppFramework\Utility\ITimeFactory;
18use OCP\EventDispatcher\IEventDispatcher;
19use OCP\Files\IRootFolder;
20use OCP\IURLGenerator;
21use OCP\IUser;
22use OCP\IUserManager;
23use OCP\IUserSession;
24use OCP\Security\IHasher;
25use Psr\Log\LoggerInterface;
26
27class Account extends AbstractIdentifyMethod {
28    public array $availableSignatureMethods = [
29        ISignatureMethod::SIGNATURE_METHOD_CLICK_TO_SIGN,
30        ISignatureMethod::SIGNATURE_METHOD_EMAIL_TOKEN,
31        ISignatureMethod::SIGNATURE_METHOD_PASSWORD,
32    ];
33    public string $defaultSignatureMethod = ISignatureMethod::SIGNATURE_METHOD_PASSWORD;
34    public function __construct(
35        protected IdentifyService $identifyService,
36        private IUserManager $userManager,
37        private IEventDispatcher $eventDispatcher,
38        private IdentifyMethodMapper $identifyMethodMapper,
39        private IUserSession $userSession,
40        private IURLGenerator $urlGenerator,
41        private IRootFolder $root,
42        private IHasher $hasher,
43        private ITimeFactory $timeFactory,
44        private LoggerInterface $logger,
45        private SessionService $sessionService,
46        private MailService $mail,
47    ) {
48        // TRANSLATORS Name of possible authenticator method. This signalize that the signer could be identified by Nextcloud acccount
49        $this->setFriendlyName($this->identifyService->getL10n()->t('Account'));
50        parent::__construct(
51            $identifyService,
52        );
53    }
54
55    #[\Override]
56    public function validateToRequest(): void {
57        $signer = $this->getSigner();
58        $this->validateSignatureMethodsForRequest($signer);
59    }
60
61    private function validateSignatureMethodsForRequest(IUser $signer): void {
62        foreach ($this->getSignatureMethods() as $signatureMethod) {
63            if (!$signatureMethod->isEnabled()) {
64                continue;
65            }
66            if ($signatureMethod->getName() === ISignatureMethod::SIGNATURE_METHOD_EMAIL_TOKEN) {
67                $this->validateEmailForEmailToken($signer);
68            }
69        }
70    }
71
72    private function validateEmailForEmailToken(IUser $signer): void {
73        $email = $signer->getEMailAddress();
74        if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
75            throw new LibresignException(
76                // TRANSLATORS Error shown when a signer identified by Nextcloud account has no valid email address for notifications.
77                $this->identifyService->getL10n()->t('Signer without valid email address')
78            );
79        }
80    }
81
82    #[\Override]
83    public function validateToIdentify(): void {
84        $signer = $this->getSigner();
85        $this->throwIfNotAuthenticated();
86        $this->authenticatedUserIsTheSigner($signer);
87        $this->throwIfMaximumValidityExpired();
88        $this->throwIfRenewalIntervalExpired();
89        $this->throwIfAlreadySigned();
90        $this->throwIfFileNotFound();
91        $this->renewSession();
92        $this->updateIdentifiedAt();
93    }
94
95    #[\Override]
96    public function validateToSign(): void {
97        $signer = $this->getSigner();
98        $this->throwIfNotAuthenticated();
99        $this->authenticatedUserIsTheSigner($signer);
100        $this->throwIfInvalidToken();
101        $this->throwIfMaximumValidityExpired();
102        $this->throwIfRenewalIntervalExpired();
103        $this->throwIfAlreadySigned();
104        $this->throwIfFileNotFound();
105        $this->renewSession();
106        $this->updateIdentifiedAt();
107    }
108
109    private function getSigner(): IUser {
110        $identifierValue = $this->entity->getIdentifierValue();
111        $signer = $this->resolveSignerUser($identifierValue);
112        if (!$signer instanceof IUser) {
113            $this->logger->warning('Account identify could not resolve signer user', [
114                'identifier' => $identifierValue,
115                'signRequestId' => $this->entity->getSignRequestId(),
116            ]);
117            throw new LibresignException(json_encode([
118                'action' => JSActions::ACTION_DO_NOTHING,
119                // TRANSLATORS Error shown when the Nextcloud account used to identify the signer is invalid.
120                'errors' => [['message' => $this->identifyService->getL10n()->t('Invalid user')]],
121            ]));
122        }
123        return $signer;
124    }
125
126    private function resolveSignerUser(?string $identifierValue): ?IUser {
127        if ($identifierValue === null || $identifierValue === '') {
128            return null;
129        }
130
131        $signer = $this->userManager->get($identifierValue);
132        if ($signer instanceof IUser) {
133            return $signer;
134        }
135
136        $byEmail = $this->userManager->getByEmail($identifierValue);
137        if (is_array($byEmail)) {
138            if (count($byEmail) === 1 && $byEmail[0] instanceof IUser) {
139                return $byEmail[0];
140            }
141            if (count($byEmail) > 1) {
142                // Shared emails cannot identify a unique Nextcloud account signer.
143                return null;
144            }
145        }
146
147        // Some backends can miss a direct get() while still returning the exact
148        // UID through search. Never collapse case variants here: distinct
149        // case-sensitive backend accounts must remain distinct identities.
150        foreach ($this->userManager->search($identifierValue) as $candidate) {
151            if ($candidate instanceof IUser && $candidate->getUID() === $identifierValue) {
152                return $candidate;
153            }
154        }
155
156        // Last resort when UserManager cannot resolve the identifier but the
157        // active session already is that account (UID or unique email value).
158        $sessionUser = $this->userSession->getUser();
159        if ($sessionUser instanceof IUser && $this->userMatchesIdentifier($sessionUser, $identifierValue)) {
160            return $sessionUser;
161        }
162
163        return null;
164    }
165
166    private function userMatchesIdentifier(IUser $user, string $identifierValue): bool {
167        if ($user->getUID() === $identifierValue) {
168            return true;
169        }
170        $email = $user->getEMailAddress();
171        return is_string($email) && $email !== '' && strcasecmp($email, $identifierValue) === 0;
172    }
173
174    private function authenticatedUserIsTheSigner(IUser $signer): void {
175        $user = $this->userSession->getUser();
176        // Compare UIDs only: UserManager/session may return distinct IUser
177        // instances for the same account. Never authorize by shared email â€”
178        // multiple accounts can use the same address while account identify
179        // still targets a single UID.
180        if ($user instanceof IUser && $user->getUID() === $signer->getUID()) {
181            return;
182        }
183
184        $this->logger->warning('Account identify session does not match signer', [
185            'sessionUid' => $user instanceof IUser ? $user->getUID() : null,
186            'signerUid' => $signer->getUID(),
187            'identifier' => $this->entity->getIdentifierValue(),
188            'signRequestId' => $this->entity->getSignRequestId(),
189        ]);
190        throw new LibresignException(json_encode([
191            'action' => JSActions::ACTION_DO_NOTHING,
192            // TRANSLATORS Error shown when the Nextcloud account used to identify the signer is invalid.
193            'errors' => [['message' => $this->identifyService->getL10n()->t('Invalid user')]],
194        ]));
195    }
196
197    private function throwIfNotAuthenticated(): void {
198        if (!$this->userSession->getUser() instanceof IUser) {
199            $signRequest = $this->identifyService->getSignRequestMapper()->getById($this->getEntity()->getSignRequestId());
200            throw new LibresignException(json_encode([
201                'action' => JSActions::ACTION_REDIRECT,
202                // TRANSLATORS Error shown when signing requires a logged-in Nextcloud account and the visitor is anonymous.
203                'errors' => [$this->identifyService->getL10n()->t('You are not logged in. Please log in.')],
204                'redirect' => $this->urlGenerator->linkToRoute('core.login.showLoginForm', [
205                    'redirect_url' => $this->urlGenerator->linkToRoute(
206                        'libresign.page.sign',
207                        ['uuid' => $signRequest->getUuid()]
208                    ),
209                ]),
210            ]));
211        }
212    }
213
214    #[\Override]
215    public function getSettings(): array {
216        if (!empty($this->settings)) {
217            return $this->settings;
218        }
219        $this->settings = $this->getSettingsFromDatabase(
220            default: [
221                'enabled' => $this->isEnabledByDefault(),
222            ]
223        );
224        return $this->settings;
225    }
226
227    private function isEnabledByDefault(): bool {
228        $config = $this->identifyService->getSavedSettings();
229
230        // Remove not enabled
231        $config = array_filter($config, fn ($i) => isset($i['enabled']) && $i['enabled'] ? true : false);
232
233        $current = array_reduce($config, function ($carry, $config) {
234            if ($config['name'] === $this->name) {
235                return $config;
236            }
237            return $carry;
238        }, []);
239
240        $total = count($config);
241
242        if ($total === 0) {
243            return true;
244        }
245
246        if ($total === 1 && !empty($current)) {
247            return true;
248        }
249        return false;
250    }
251}