org_openpsa_user_accounthelper::welcome_email()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 5
c 0
b 0
f 0
nc 1
nop 0
dl 0
loc 9
ccs 6
cts 6
cp 1
crap 1
rs 10
1
<?php
2
/**
3
 * @author CONTENT CONTROL http://www.contentcontrol-berlin.de/
4
 * @copyright CONTENT CONTROL http://www.contentcontrol-berlin.de/
5
 * @license http://www.gnu.org/licenses/gpl.html GNU General Public License
6
 * @package org.openpsa.user
7
 */
8
9
/**
10
 * Helper class for creating a new account for an existing person
11
 *
12
 * @package org.openpsa.user
13
 */
14
class org_openpsa_user_accounthelper
15
{
16
    use midcom_baseclasses_components_base;
0 ignored issues
show
introduced by
The trait midcom_baseclasses_components_base requires some properties which are not provided by org_openpsa_user_accounthelper: $i18n, $head
Loading history...
17
18
    protected ?midcom_db_person $person = null;
19
20
    private ?midcom_core_account $account = null;
21
22
    public string $errstr;
23
24 16
    public function __construct(?midcom_db_person $person = null)
25
    {
26 16
        $this->_component = 'org.openpsa.user';
27 16
        if (null !== $person) {
28 12
            $this->person = $person;
29
        }
30
    }
31
32 12
    protected function get_account() : midcom_core_account
33
    {
34 12
        return $this->account ??= new midcom_core_account($this->person);
0 ignored issues
show
Bug introduced by
It seems like $this->person can also be of type null; however, parameter $person of midcom_core_account::__construct() does only seem to accept object, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

34
        return $this->account ??= new midcom_core_account(/** @scrutinizer ignore-type */ $this->person);
Loading history...
35
    }
36
37
    /**
38
     * can be called by various handlers
39
     *
40
     * @param string $password password: leave blank for auto generated
41
     */
42 2
    public function create_account(string $person_guid, string $username, string $usermail, string $password = "", bool $send_welcome_mail = false) : bool
43
    {
44 2
        $this->errstr = ""; // start fresh
45
46
        // quick validation
47 2
        if (empty($person_guid)) {
48 1
            $this->errstr = "Unable to identify user: no guid given";
49 1
            return false;
50
        }
51
52 2
        if (empty($username)) {
53 1
            $this->errstr = "Unable to create account: no username given";
54 1
            return false;
55
        }
56
57 2
        if ($send_welcome_mail && empty($usermail)) {
58 1
            $this->errstr = "Unable to deliver welcome mail: no usermail address given";
59 1
            return false;
60
        }
61
62
        // Check if we get the person
63 2
        $this->person = new midcom_db_person($person_guid);
64 2
        $this->person->require_do('midgard:update');
65
66
        //need to generate password?
67 2
        if (empty($password)) {
68 1
            $generated_password = true;
69 1
            $password = $this->generate_safe_password($this->_config->get("min_password_length"));
0 ignored issues
show
Bug introduced by
It seems like $this->_config->get('min_password_length') can also be of type false; however, parameter $length of org_openpsa_user_account...enerate_safe_password() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

69
            $password = $this->generate_safe_password(/** @scrutinizer ignore-type */ $this->_config->get("min_password_length"));
Loading history...
70
        } else {
71 2
            $generated_password = false;
72
        }
73
74 2
        $account = $this->get_account();
75
76
        //an account already existing?
77 2
        if ($account->get_password()) {
78 1
            $this->errstr = "Creating new account for existing account is not possible";
79 1
            return false;
80
        }
81
82
        //try creating
83 2
        if (!$this->set_account($username, $password)) {
84
            $this->errstr = "Could not set account, reason: " . $this->errstr;
85
            return false;
86
        }
87
88
        //send welcome mail?
89 2
        if ($send_welcome_mail) {
90 1
            if (!$this->send_mail($username, $usermail, $password)) {
91
                $this->delete_account();
92 1
                return false;
93
            }
94 1
        } elseif ($generated_password) {
95
            /*
96
             * no welcome mail was sent:
97
             * if the password was auto generated show it in an ui message
98
             */
99 1
            midcom::get()->uimessages->add(
100 1
                $this->_l10n->get($this->_component),
101 1
                sprintf($this->_l10n->get("account_creation_success"), $username, $password), 'ok');
102
        }
103
104 2
        return true;
105
    }
106
107
    /**
108
     * Prepare the welcome mail for the user.
109
     *
110
     * The essential data (recipient, username, password) is already filled in
111
     * at this point. You can override this function in subclasses if you want
112
     * to customize the mail further
113
     */
114 3
    protected function prepare_mail(org_openpsa_mail $mail)
115
    {
116 3
        $mail->from = $this->_config->get('welcome_mail_from_address');
117 3
        $mail->subject = $this->_config->get('welcome_mail_title');
0 ignored issues
show
Documentation Bug introduced by
It seems like $this->_config->get('welcome_mail_title') can also be of type false. However, the property $subject is declared as type string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
118 3
        $mail->body = $this->_config->get('welcome_mail_body');
119 3
        $mail->parameters["SITE_URL"] = midcom::get()->config->get('midcom_site_url');
120
    }
121
122
    /**
123
     * Send mail
124
     */
125 3
    private function send_mail(string $username, string $usermail, string $password) : bool
126
    {
127 3
        $mail = new org_openpsa_mail();
128 3
        $mail->to = $usermail;
129
130
        // Make replacements to body
131 3
        $mail->parameters = [
132 3
            "USERNAME" => $username,
133 3
            "PASSWORD" => $password,
134 3
        ];
135
136 3
        $this->prepare_mail($mail);
137 3
        if (!$mail->send()) {
138
            $this->errstr = "Unable to deliver welcome mail: " . $mail->get_error_message();
139
            return false;
140
        }
141 3
        return true;
142
    }
143
144
    /**
145
     * Sets username and password for person
146
     *
147
     * @param string $new_password Contains the new password to set
148
     */
149 6
    public function set_account(string $username, $new_password) : bool
150
    {
151 6
        $account = $this->get_account();
152 6
        if (!empty($new_password)) {
153
            // check if the new encrypted password was already used
154 6
            if (   !$this->check_password_reuse($new_password)
155 6
                || !$this->check_password_strength($new_password)) {
156
                return false;
157
            }
158 6
            $this->save_old_password();
159 6
            $account->set_password($new_password);
160
        }
161
162 6
        $account->set_username($username);
163
164
        // probably username not unique
165 6
        if (!$account->save()) {
166
            $this->errstr = "Failed to save account, reason: " . midcom_connection::get_error_string();
167
            return false;
168
        }
169
170 6
        if (!empty($new_password)) {
171
            // add timestamp of password-change
172 6
            $this->person->set_parameter("org_openpsa_user_password", "last_change", time());
0 ignored issues
show
Bug introduced by
The method set_parameter() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

172
            $this->person->/** @scrutinizer ignore-call */ 
173
                           set_parameter("org_openpsa_user_password", "last_change", time());

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
173
        }
174
        // sets privilege
175 6
        midcom::get()->auth->request_sudo($this->_component);
176 6
        $this->person->set_privilege('midgard:owner', "user:" . $this->person->guid);
177 6
        midcom::get()->auth->drop_sudo();
178
179 6
        return true;
180
    }
181
182
    /**
183
     * Returns an auto generated password which will pass the persons check_password_strength test
184
     */
185 8
    public function generate_safe_password(int $length = 0) : string
186
    {
187
        do {
188 8
            $password = midgard_admin_user_plugin::generate_password($length);
189 8
        } while (!$this->check_password_strength($password));
190 8
        return $password;
191
    }
192
193
    /**
194
     * Function to check if passed password was already used
195
     *
196
     * @return bool returns true if password wasn't used already
197
     */
198 7
    public function check_password_reuse(string $password) : bool
199
    {
200
        // check current password
201 7
        if (midcom_connection::verify_password($password, $this->get_account()->get_password())) {
202 1
            $this->errstr = $this->_l10n->get('password is the same as the current one');
203 1
            return false;
204
        }
205
206
        // get last passwords
207 7
        $old_passwords = $this->get_old_passwords();
208
209
        // check last passwords
210 7
        foreach ($old_passwords as $old) {
211 1
            if (midcom_connection::verify_password($password, $old)) {
212 1
                $this->errstr = $this->_l10n->get('password was already used');
213 1
                return false;
214
            }
215
        }
216 7
        return true;
217
    }
218
219
    /**
220
     * Function to add current password to parameter old passwords - does not update()
221
     */
222 6
    private function save_old_password()
223
    {
224 6
        $max_old_passwords = $this->_config->get('max_old_passwords');
225 6
        if ($max_old_passwords < 1) {
226
            return;
227
        }
228 6
        $old_passwords_array = $this->get_old_passwords();
229 6
        array_unshift($old_passwords_array, $this->get_account()->get_password());
230
231 6
        if (count($old_passwords_array) > $max_old_passwords) {
232
            array_pop($old_passwords_array);
233
        }
234
235 6
        $new_passwords_string = serialize($old_passwords_array);
236
237 6
        $this->person->set_parameter("org_openpsa_user_password", "old_passwords", $new_passwords_string);
238
    }
239
240
    /**
241
     * Function get old passwords
242
     *
243
     * @return array - Array with old passwords - empty if there aren't any old passwords
244
     */
245 7
    private function get_old_passwords() : array
246
    {
247 7
        if ($old_passwords_string = $this->person->get_parameter("org_openpsa_user_password", "old_passwords")) {
248 1
            $old_passwords_array = unserialize($old_passwords_string);
249 1
            $count = count($old_passwords_array);
250 1
            $max = (int) $this->_config->get('max_old_passwords');
251 1
            if ($count > $max) {
252 1
                $old_passwords_array = array_slice($old_passwords_array, 0, $max);
253
            }
254
        } else {
255 7
            $old_passwords_array = [];
256
        }
257 7
        return $old_passwords_array;
258
    }
259
260
    /**
261
     * Function to check strength of passed password
262
     */
263 10
    public function check_password_strength(string $password) : bool
264
    {
265 10
        $password_length = mb_strlen($password);
266
267 10
        if ($password_length < $this->_config->get('min_password_length')) {
268 2
            $this->errstr = $this->_l10n->get('password too short');
269 2
            return false;
270
        }
271
272
        // score for length & repetition
273 9
        $score = $this->count_unique_characters($password) * $this->_config->get('unique_character_score');
274
275
        //check $password with rules
276 9
        $rules = $this->_config->get_array('password_score_rules');
277 9
        foreach ($rules as $rule) {
278 9
            if (preg_match($rule['match'], $password) > 0) {
279 9
                $score += $rule['score'];
280
            }
281
        }
282
283 9
        if ($score < $this->_config->get('min_password_score')) {
284 3
            $this->errstr = $this->_l10n->get('password weak');
285 3
            return false;
286
        }
287 9
        return true;
288
    }
289
290 9
    private function count_unique_characters(string $password) : int
291
    {
292
        // Split into individual (multibyte) characters, flip to filter out duplicates, and then count
293 9
        return count(array_flip(preg_split('//u', $password, -1, PREG_SPLIT_NO_EMPTY)));
294
    }
295
296
    /**
297
     * Function to check password age for this user (age is taken from config)
298
     *
299
     * @return boolean - true indicates password is ok - false password is to old
300
     */
301 1
    public function check_password_age() : bool
302
    {
303 1
        $max_age_days = $this->_config->get('password_max_age_days');
304 1
        if ($max_age_days == 0) {
305
            return true;
306
        }
307
308 1
        if ($last_change = $this->person->get_parameter("org_openpsa_user_password", "last_change")) {
309 1
            $max_timeframe = time() - ($max_age_days * 24 * 60 * 60);
310 1
            return $max_timeframe < $last_change;
311
        }
312 1
        return false;
313
    }
314
315
    /**
316
     * Function sends an email to the user with username and password
317
     *
318
     * @return boolean indicates success
319
     */
320 2
    public function welcome_email() : bool
321
    {
322 2
        $username = $this->get_account()->get_username();
323 2
        $email = $this->person->email;
324
325
        //Resets Password
326 2
        $password = $this->generate_safe_password($this->_config->get("min_password_length"));
0 ignored issues
show
Bug introduced by
It seems like $this->_config->get('min_password_length') can also be of type false; however, parameter $length of org_openpsa_user_account...enerate_safe_password() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

326
        $password = $this->generate_safe_password(/** @scrutinizer ignore-type */ $this->_config->get("min_password_length"));
Loading history...
327 2
        $this->set_account($username, $password);
328 2
        return $this->send_mail($username, $email, $password);
329
    }
330
331
    /**
332
     * Function to disable account for time period given in config
333
     *
334
     * @return boolean - indicates success
335
     */
336 2
    public function disable_account() : bool
337
    {
338 2
        $account = $this->get_account();
339
340 2
        $timeframe_minutes = $this->_config->get('password_block_timeframe_min');
341
342 2
        if ($timeframe_minutes == 0) {
343
            return false;
344
        }
345 2
        $release_time = time() + ($timeframe_minutes * 60);
346 2
        $args = [
347 2
            'guid' => $this->person->guid,
348 2
            'parameter_name' => 'org_openpsa_user_blocked_account',
349 2
            'password' => 'account_password',
350 2
        ];
351
352 2
        $qb = midcom_services_at_entry_dba::new_query_builder();
353 2
        $qb->add_constraint('argumentsstore', '=', serialize($args));
354 2
        $qb->add_constraint('status', '=', midcom_services_at_entry_dba::SCHEDULED);
355 2
        if ($entry = $qb->get_result(0)) {
356
            //the account is already blocked, so we just extend the block's duration
357
            $entry->start = $release_time;
0 ignored issues
show
Bug Best Practice introduced by
The property start does not exist on midcom_core_dbaobject. Since you implemented __set, consider adding a @property annotation.
Loading history...
358
            return $entry->update();
359
        }
360
361 2
        if (!midcom_services_at_interface::register($release_time, $this->_component, 'reopen_account', $args)) {
362
            throw new midcom_error("Failed to register interface for re_open the user account, last Midgard error was: " . midcom_connection::get_error_string());
363
        }
364 2
        $this->person->set_parameter("org_openpsa_user_blocked_account", "account_password", $account->get_password());
365 2
        $account->set_password('', false);
366 2
        return $account->save();
367
    }
368
369
    /**
370
     * Function to delete account
371
     *
372
     * @return boolean indicates success
373
     */
374 1
    public function delete_account() : bool
375
    {
376 1
        return $this->get_account()->delete();
377
    }
378
379
    /**
380
     * Permanently disable an user account
381
     *
382
     * @return boolean - indicates success
383
     */
384 2
    public function close_account() : bool
385
    {
386 2
        $account = $this->get_account();
387
388 2
        if (!$account->get_password()) {
389
            // the account is already blocked, so skip the rest
390 1
            return true;
391
        }
392
393 2
        $this->person->set_parameter("org_openpsa_user_blocked_account", "account_password", $account->get_password());
394 2
        $account->set_password('', false);
395 2
        return $account->save();
396
    }
397
398
    /**
399
     * Reopen a blocked account.
400
     */
401 1
    public function reopen_account()
402
    {
403 1
        $account = new midcom_core_account($this->person);
0 ignored issues
show
Bug introduced by
It seems like $this->person can also be of type null; however, parameter $person of midcom_core_account::__construct() does only seem to accept object, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

403
        $account = new midcom_core_account(/** @scrutinizer ignore-type */ $this->person);
Loading history...
404 1
        if ($account->get_password()) {
405 1
            debug_add('Account for person #' . $this->person->id . ' does have a password already');
406
        } else {
407 1
            $account->set_password($this->person->get_parameter('org_openpsa_user_blocked_account', 'account_password'), false);
0 ignored issues
show
Bug introduced by
It seems like $this->person->get_param...t', 'account_password') can also be of type null; however, parameter $password of midcom_core_account::set_password() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

407
            $account->set_password(/** @scrutinizer ignore-type */ $this->person->get_parameter('org_openpsa_user_blocked_account', 'account_password'), false);
Loading history...
408 1
            if (!$account->save()) {
409
                throw new midcom_error('Failed to save account: ' . midcom_connection::get_error_string());
410
            }
411
        }
412
413 1
        $this->person->delete_parameter('org_openpsa_user_blocked_account', 'account_password');
414
    }
415
416
    /**
417
     * Determine if an account is blocked
418
     */
419 4
    public function is_blocked() : bool
420
    {
421 4
        return !empty($this->person->get_parameter("org_openpsa_user_blocked_account", "account_password"));
422
    }
423
424 1
    public static function get_person_by_formdata(array $data) : ?midcom_db_person
425
    {
426 1
        if (   empty($data['username'])
427 1
            || empty($data['password'])) {
428 1
            return null;
429
        }
430
431 1
        midcom::get()->auth->request_sudo('org.openpsa.user');
432 1
        $qb = midcom_db_person::new_query_builder();
433 1
        midcom_core_account::add_username_constraint($qb, '=', $data['username']);
434 1
        $results = $qb->execute();
435 1
        midcom::get()->auth->drop_sudo();
436
437 1
        if (count($results) != 1) {
438 1
            return null;
439
        }
440 1
        return $results[0];
441
    }
442
443
    /**
444
     * Record failed login attempts and disable account is necessary
445
     */
446 1
    public function check_login_attempts() : bool
447
    {
448 1
        $stat = true;
449
450
        //max-attempts allowed & timeframe
451 1
        $max_attempts = $this->_config->get('max_password_attempts');
452 1
        $timeframe = $this->_config->get('password_block_timeframe_min');
453
454 1
        if (!$max_attempts || !$timeframe) {
455
            return $stat;
456
        }
457
458 1
        midcom::get()->auth->request_sudo($this->_component);
459
460 1
        if ($attempts = $this->person->get_parameter("org_openpsa_user_password", "attempts")) {
461 1
            $attempts = unserialize($attempts);
462 1
            if (is_array($attempts)) {
463 1
                $attempts = array_slice($attempts, 0, ($max_attempts - 1));
464
            }
465
        }
466 1
        if (!is_array($attempts)) {
467 1
            $attempts = [];
468
        }
469 1
        array_unshift($attempts, time());
470
471
        /*
472
         * If the maximum number of attempts is reached and the oldest attempt
473
         * on the stack is within our defined timeframe, we block the account
474
         */
475 1
        if (   count($attempts) >= $max_attempts
476 1
            && $attempts[$max_attempts - 1] >= (time() - ($timeframe * 60))) {
477 1
            $this->disable_account();
478 1
            $stat = false;
479
        }
480
481 1
        $attempts = serialize($attempts);
482 1
        $this->person->set_parameter("org_openpsa_user_password", "attempts", $attempts);
483 1
        midcom::get()->auth->drop_sudo();
484 1
        return $stat;
485
    }
486
}
487