Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
23.53% covered (danger)
23.53%
16 / 68
25.00% covered (danger)
25.00%
1 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
RegistrationHelper
23.53% covered (danger)
23.53%
16 / 68
25.00% covered (danger)
25.00%
1 / 4
259.56
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
 createUser
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
182
 buildRegistrationResponse
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 isDomainAllowed
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
8.02
1<?php
2
3/**
4 * Helper class for registrations
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @author    Anatoliy Belsky <anatoliy.belsky@mayflower.de>
13 * @copyright 2020-2026 phpMyFAQ Team
14 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
15 * @link      https://www.phpmyfaq.de
16 * @since     2020-10-28
17 */
18
19declare(strict_types=1);
20
21namespace phpMyFAQ\Helper;
22
23use phpMyFAQ\Configuration;
24use phpMyFAQ\Core\Exception;
25use phpMyFAQ\Mail;
26use phpMyFAQ\Strings;
27use phpMyFAQ\Translation;
28use phpMyFAQ\User;
29use phpMyFAQ\User\UserData;
30use phpMyFAQ\Utils;
31use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
32
33/**
34 * Class RegistrationHelper
35 *
36 * @package phpMyFAQ\Helper
37 */
38class RegistrationHelper extends AbstractHelper
39{
40    /**
41     * RegistrationHelper constructor.
42     */
43    public function __construct(Configuration $configuration)
44    {
45        $this->configuration = $configuration;
46    }
47
48    /**
49     * Creates a new user account and saves the user data.
50     * If user generation was successful, account activation is sent via an email
51     * error message as an array.
52     * The password will be automatically generated and sent by email
53     * as soon if admin switches user to "active"
54     *
55     * @throws Exception|TransportExceptionInterface
56     */
57    public function createUser(string $userName, string $fullName, string $email, bool $isVisible): array
58    {
59        $user = new User($this->configuration);
60
61        // Account enumeration guard: a duplicate e-mail address or login name must be
62        // indistinguishable from a fresh registration. In those cases no account is created and
63        // the same generic "thank you" response is returned, so an attacker cannot probe which
64        // e-mails/logins already exist.
65        if ($email !== '' && $email !== '0') {
66            if (!$user->userdata instanceof UserData) {
67                $user->userdata = new UserData($this->configuration);
68            }
69
70            if ($user->userdata->emailExists($email)) {
71                return $this->buildRegistrationResponse();
72            }
73        }
74
75        try {
76            $created = $user->createUser($userName, '');
77        } catch (\Exception $exception) {
78            if (
79                $exception->getMessage() === User::ERROR_USER_LOGIN_NOT_UNIQUE
80                || $exception->getMessage() === User::ERROR_USER_EMAIL_NOT_UNIQUE
81            ) {
82                return $this->buildRegistrationResponse();
83            }
84
85            throw $exception;
86        }
87
88        if (!$created) {
89            return [
90                'registered' => false,
91                'error' => $user->error(),
92            ];
93        }
94
95        $user->userData()->set(['display_name', 'email', 'is_visible'], [$fullName, $email, $isVisible ? 1 : 0]);
96        $user->setStatus('blocked');
97
98        $isNowActive = !$this->configuration->get(item: 'spam.manualActivation') && $user->activateUser();
99        $adminMessage = 'To activate this user please use';
100        if ($isNowActive) {
101            // @todo add translation strings
102            $adminMessage =
103                'This user has been automatically activated, you can still'
104                . ' modify the users permissions or decline membership by visiting the admin section';
105        }
106
107        $text = sprintf(
108            'A new user has been registered:<br><br>Name: %s<br>Login name: %s<br><br>%s the administration at %s.',
109            $fullName,
110            $userName,
111            $adminMessage,
112            $this->configuration->getDefaultUrl() . '/admin/',
113        );
114        $mail = new Mail($this->configuration);
115        $mail->setReplyTo($email, $fullName);
116        $mail->addTo($this->configuration->getAdminEmail());
117
118        $emailRegSubject = Translation::get(key: 'emailRegSubject');
119        $mail->subject = Utils::resolveMarkers(
120            is_string($emailRegSubject) ? $emailRegSubject : '',
121            $this->configuration,
122        );
123        $mail->message = $text;
124        $mail->send();
125        unset($mail);
126
127        return $this->buildRegistrationResponse();
128    }
129
130    /**
131     * Builds the generic registration response. The same payload is returned for a real
132     * registration and for a suppressed duplicate, so the two cannot be told apart.
133     *
134     * @return array{registered: bool, success: string}
135     */
136    private function buildRegistrationResponse(): array
137    {
138        return [
139            'registered' => true,
140            'success' =>
141                trim(Translation::getString(key: 'successMessage'))
142                    . ' '
143                    . trim(Translation::getString(key: 'msgRegThankYou')),
144        ];
145    }
146
147    /**
148     * Returns true if the hostname of the given email address is allowed.
149     * otherwise false.
150     */
151    public function isDomainAllowed(string $email): bool
152    {
153        $whitelistedDomains = $this->configuration->get(item: 'security.domainWhiteListForRegistrations');
154
155        if ($whitelistedDomains === null || Strings::strlen(trim((string) $whitelistedDomains)) === 0) {
156            return true;
157        }
158
159        $whitelistedDomainList = explode(',', (string) $whitelistedDomains);
160
161        // Robust: validate email and extract domain safely; invalid emails are not allowed
162        $email = trim($email);
163        if ($email === '' || filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
164            return false;
165        }
166
167        $atPos = strrpos(haystack: $email, needle: '@');
168        if ($atPos === false) {
169            return false; // should not happen after filter_var, but double-check
170        }
171
172        $hostnameToCheck = trim(substr($email, $atPos + 1));
173
174        foreach ($whitelistedDomainList as $hostname) {
175            if ($hostnameToCheck !== trim($hostname)) {
176                continue;
177            }
178
179            return true;
180        }
181
182        return false;
183    }
184}