Completed
Push — master ( 171fb5...488962 )
by Andreas
25:09
created

org_openpsa_user_accounthelper   F

Complexity

Total Complexity 74

Size/Duplication

Total Lines 523
Duplicated Lines 0 %

Test Coverage

Coverage 90.31%

Importance

Changes 3
Bugs 2 Features 0
Metric Value
eloc 211
dl 0
loc 523
ccs 205
cts 227
cp 0.9031
rs 2.48
c 3
b 2
f 0
wmc 74

20 Methods

Rating   Name   Duplication   Size   Complexity  
A save_old_password() 0 16 3
A get_old_passwords() 0 14 3
C create_account() 0 79 12
A generate_safe_password() 0 6 2
A check_password_reuse() 0 23 6
A prepare_mail() 0 6 1
A generate_password() 0 15 2
A set_account() 0 32 6
A __construct() 0 6 2
A get_account() 0 6 2
A count_unique_characters() 0 4 1
A delete_account() 0 3 1
A disable_account() 0 31 4
A is_blocked() 0 3 1
B check_password_strength() 0 29 7
B check_login_attempts() 0 41 9
A check_password_age() 0 14 3
A reopen_account() 0 14 3
A get_person_by_formdata() 0 17 4
A close_account() 0 12 2

How to fix   Complexity   

Complex Class

Complex classes like org_openpsa_user_accounthelper often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use org_openpsa_user_accounthelper, and based on these observations, apply Extract Interface, too.

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 extends midcom_baseclasses_components_purecode
15
{
16
    /**
17
     * The person we're working on
18
     *
19
     * @var midcom_db_person
20
     */
21
    protected $person;
22
23
    /**
24
     * The account we're working on
25
     *
26
     * @var midcom_core_account
27
     */
28
    private $account;
29
30
    public $errstr;
31
32
    /**
33
     * @param midcom_db_person $person
34
     */
35 14
    public function __construct(midcom_db_person $person = null)
36
    {
37 14
        if (null !== $person) {
38 10
            $this->person = $person;
39
        }
40 14
        parent::__construct();
41 14
    }
42
43
    /**
44
     * @return midcom_core_account
45
     */
46 10
    protected function get_account()
47
    {
48 10
        if ($this->account === null) {
49 10
            $this->account = new midcom_core_account($this->person);
50
        }
51 10
        return $this->account;
52
    }
53
54
    /**
55
     * can be called by various handlers
56
     *
57
     * @param string $person_guid
58
     * @param string $username
59
     * @param string $usermail
60
     * @param string $password password: leave blank for auto generated
61
     * @param boolean $send_welcome_mail
62
     * @return boolean
63
     */
64 2
    public function create_account($person_guid, $username, $usermail, $password = "", $send_welcome_mail = false)
65
    {
66 2
        $this->errstr = ""; // start fresh
67
68
        // quick validation
69 2
        if (empty($person_guid)) {
70 1
            $this->errstr = "Unable to identify user: no guid given";
71 1
            return false;
72
        }
73
74 2
        if (empty($username)) {
75 1
            $this->errstr = "Unable to create account: no username given";
76 1
            return false;
77
        }
78
79 2
        if ($send_welcome_mail && empty($usermail)) {
80 1
            $this->errstr = "Unable to deliver welcome mail: no usermail address given";
81 1
            return false;
82
        }
83
84
        // Check if we get the person
85 2
        $this->person = new midcom_db_person($person_guid);
86 2
        $this->person->require_do('midgard:update');
87
88
        //need to generate password?
89 2
        if (empty($password)) {
90 1
            $generated_password = true;
91 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

91
            $password = $this->generate_safe_password(/** @scrutinizer ignore-type */ $this->_config->get("min_password_length"));
Loading history...
92
        } else {
93 2
            $generated_password = false;
94
        }
95
96 2
        $account = $this->get_account();
97
98
        //an account already existing?
99 2
        if ($account->get_password()) {
100 1
            $this->errstr = "Creating new account for existing account is not possible";
101 1
            return false;
102
        }
103
104
        //try creating
105 2
        if (!$this->set_account($username, $password)) {
106
            $this->errstr = "Could not set account, reason: " . midcom_connection::get_error_string();
107
            return false;
108
        }
109
110
        //send welcome mail?
111 2
        if ($send_welcome_mail) {
112 1
            $mail = new org_openpsa_mail();
113 1
            $mail->to = $usermail;
114
115
            // Make replacements to body
116 1
            $mail->parameters = [
117 1
                "USERNAME" => $username,
118 1
                "PASSWORD" => $password,
119
            ];
120
121 1
            $this->prepare_mail($mail);
122
123 1
            if (!$mail->send()) {
124
                $this->errstr = "Unable to deliver welcome mail: " . $mail->get_error_message();
0 ignored issues
show
Bug introduced by
Are you sure $mail->get_error_message() of type false|mixed|string can be used in concatenation? ( Ignorable by Annotation )

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

124
                $this->errstr = "Unable to deliver welcome mail: " . /** @scrutinizer ignore-type */ $mail->get_error_message();
Loading history...
125
                $this->delete_account();
126 1
                return false;
127
            }
128 1
        } elseif ($generated_password) {
129
            /*
130
             * no welcome mail was sent:
131
             * if the password was auto generated show it in an ui message
132
             */
133 1
            midcom::get()->uimessages->add(
134 1
                $this->_l10n->get('org.openpsa.user'),
135 1
                sprintf($this->_l10n->get("account_creation_success"), $username, $password), 'ok');
136
        }
137
138 2
        if (!empty($this->errstr)) {
139
            throw new midcom_error('Could not create account: ' . $this->errstr);
140
        }
141
142 2
        return true;
143
    }
144
145
    /**
146
     * Prepare the welcome mail for the user.
147
     *
148
     * The essential data (recipient, username, password) is already filled in
149
     * at this point. You can override this function in subclasses if you want
150
     * to customize the mail further
151
     *
152
     * @param org_openpsa_mail $mail
153
     */
154 1
    protected function prepare_mail(org_openpsa_mail $mail)
155
    {
156 1
        $mail->from = $this->_config->get('welcome_mail_from_address');
157 1
        $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...
158 1
        $mail->body = $this->_config->get('welcome_mail_body');
0 ignored issues
show
Documentation Bug introduced by
It seems like $this->_config->get('welcome_mail_body') can also be of type false. However, the property $body 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...
159 1
        $mail->parameters["SITE_URL"] = midcom::get()->config->get('midcom_site_url');
160 1
    }
161
162
    /**
163
     * Sets username and password for person
164
     *
165
     * @param string $username Contains username
166
     * @param string $new_password Contains the new password to set
167
     */
168 3
    public function set_account($username, $new_password)
169
    {
170 3
        $account = $this->get_account();
171 3
        if (!empty($new_password)) {
172
            //check if the new encrypted password was already used
173 3
            if (   !$this->check_password_reuse($new_password, true)
174 3
                || !$this->check_password_strength($new_password, true)) {
175
                $this->errstr = "password strength too low";
176
                return false;
177
            }
178 3
            $this->save_old_password();
179 3
            $account->set_password($new_password);
180
        }
181
182 3
        $account->set_username($username);
183
184
        // probably username not unique
185 3
        if (!$account->save()) {
186
            $this->errstr = "Failed to save account, reason: " . midcom_connection::get_error_string();
187
            return false;
188
        }
189
190 3
        if (!empty($new_password)) {
191
            // add timestamp of password-change
192 3
            $this->person->set_parameter("org_openpsa_user_password", "last_change", time());
193
        }
194
        // sets privilege
195 3
        midcom::get()->auth->request_sudo($this->_component);
196 3
        $this->person->set_privilege('midgard:owner', "user:" . $this->person->guid);
197 3
        midcom::get()->auth->drop_sudo();
198
199 3
        return true;
200
    }
201
202
    /**
203
     * Returns an auto generated password of variable length
204
     *
205
     * @param int $length The number of chars the password will contain
206
     * @return string The generated password
207
     */
208 8
    public static function generate_password($length = 0)
209
    {
210
        // Safety
211 8
        if ($length == 0) {
212 7
            $length = 8;
213
        }
214
        // Valid characters for default password (PONDER: make configurable ?)
215 8
        $passwdchars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,-*!:+=()/&%$<>?#@';
216 8
        $first_last_chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
217
218
        //make sure password doesn't begin or end in punctuation character
219 8
        $password = midcom_helper_misc::random_string(1, $first_last_chars);
220 8
        $password .= midcom_helper_misc::random_string($length - 2, $passwdchars);
221 8
        $password .= midcom_helper_misc::random_string(1, $first_last_chars);
222 8
        return $password;
223
    }
224
225
    /**
226
     * Returns an auto generated password which will pass the persons check_password_strength test
227
     *
228
     * @param int $length The number of chars the password will contain
229
     * @return string The generated password
230
     */
231 6
    public function generate_safe_password($length = 0)
232
    {
233
        do {
234 6
            $password = self::generate_password($length);
235 6
        } while (!$this->check_password_strength($password));
236 6
        return $password;
237
    }
238
239
    /**
240
     * Function to check if passed password was already used
241
     *
242
     * @param string $password Password to check
243
     * @return bool returns true if password wasn't used already
244
     */
245 5
    public function check_password_reuse($password, $show_ui_message = false)
246
    {
247
        // check current password
248 5
        if (midcom_connection::verify_password($password, $this->get_account()->get_password())) {
249 1
            if ($show_ui_message) {
250
                midcom::get()->uimessages->add($this->_l10n->get('org.openpsa.user'), $this->_l10n->get('password is the same as the current one'), 'error');
251
            }
252 1
            return false;
253
        }
254
255
        // get last passwords
256 5
        $old_passwords = $this->get_old_passwords();
257
258
        // check last passwords
259 5
        foreach ($old_passwords as $old) {
260 1
            if (midcom_connection::verify_password($password, $old)) {
261 1
                if ($show_ui_message) {
262
                    midcom::get()->uimessages->add($this->_l10n->get('org.openpsa.user'), $this->_l10n->get('password was already used'), 'error');
263
                }
264 1
                return false;
265
            }
266
        }
267 5
        return true;
268
    }
269
270
    /**
271
     * Function to add current password to parameter old passwords - does not update()
272
     */
273 3
    private function save_old_password()
274
    {
275 3
        $max_old_passwords = $this->_config->get('max_old_passwords');
276 3
        if ($max_old_passwords < 1) {
277
            return;
278
        }
279 3
        $old_passwords_array = $this->get_old_passwords();
280 3
        array_unshift($old_passwords_array, $this->get_account()->get_password());
281
282 3
        if (count($old_passwords_array) > $max_old_passwords) {
283
            array_pop($old_passwords_array);
284
        }
285
286 3
        $new_passwords_string = serialize($old_passwords_array);
287
288 3
        $this->person->set_parameter("org_openpsa_user_password", "old_passwords", $new_passwords_string);
289 3
    }
290
291
    /**
292
     * Function get old passwords
293
     *
294
     * @return array - Array with old passwords - empty if there aren't any old passwords
295
     */
296 5
    private function get_old_passwords()
297
    {
298 5
        $old_passwords_string = $this->person->get_parameter("org_openpsa_user_password", "old_passwords");
299 5
        if (!empty($old_passwords_string)) {
300 1
            $old_passwords_array = unserialize($old_passwords_string);
301 1
            $count = count($old_passwords_array);
302 1
            $max = (int) $this->_config->get('max_old_passwords');
303 1
            if ($count > $max) {
304 1
                $old_passwords_array = array_slice($old_passwords_array, 0, $max);
305
            }
306
        } else {
307 4
            $old_passwords_array = [];
308
        }
309 5
        return $old_passwords_array;
310
    }
311
312
    /**
313
     * Function to check strength of passed password
314
     *
315
     * @param string $password Contains password to check
316
     */
317 8
    public function check_password_strength($password, $show_ui_message = false)
318
    {
319 8
        $password_length = mb_strlen($password);
320
321 8
        if ($password_length < $this->_config->get('min_password_length')) {
322 2
            if ($show_ui_message){
323
                midcom::get()->uimessages->add($this->_l10n->get('org.openpsa.user'), $this->_l10n->get('password too short'), 'error');
324
            }
325 2
            return false;
326
        }
327
328
        // score for length & repetition
329 7
        $score = $this->count_unique_characters($password) * 4;
330
331
        //check $password with rules
332 7
        $rules = $this->_config->get('password_score_rules');
333 7
        foreach ($rules as $rule) {
334 7
            if (preg_match($rule['match'], $password) > 0) {
335 7
                $score += $rule['score'];
336
            }
337
        }
338
339 7
        if ($score < $this->_config->get('min_password_score')) {
340 2
            if ($show_ui_message){
341
                midcom::get()->uimessages->add($this->_l10n->get('org.openpsa.user'), $this->_l10n->get('password weak'), 'error');
342
            }
343 2
            return false;
344
        }
345 7
        return true;
346
    }
347
348 7
    private function count_unique_characters(string $password) : int
349
    {
350
        // Split into individual (multibyte) characters, flip to filter out duplicates, and then count
351 7
        return count(array_flip(preg_split('//u', $password, null, PREG_SPLIT_NO_EMPTY)));
0 ignored issues
show
Bug introduced by
It seems like preg_split('//u', $passw...l, PREG_SPLIT_NO_EMPTY) can also be of type false; however, parameter $array of array_flip() does only seem to accept array, 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

351
        return count(array_flip(/** @scrutinizer ignore-type */ preg_split('//u', $password, null, PREG_SPLIT_NO_EMPTY)));
Loading history...
352
    }
353
354
    /**
355
     * Function to check password age for this user (age is taken from config)
356
     *
357
     * @return boolean - true indicates password is ok - false password is to old
358
     */
359 1
    public function check_password_age()
360
    {
361 1
        $max_age_days = $this->_config->get('password_max_age_days');
362 1
        if ($max_age_days == 0) {
363
            return true;
364
        }
365 1
        $max_timeframe = time() - ($max_age_days * 24 * 60 * 60);
366 1
        $last_change = $this->person->get_parameter("org_openpsa_user_password", "last_change");
367
368 1
        if (empty($last_change)) {
369 1
            return false;
370
        }
371
372 1
        return $max_timeframe < $last_change;
373
    }
374
375
    /**
376
     * Function to disable account for time period given in config
377
     *
378
     * @return boolean - indicates success
379
     */
380 2
    public function disable_account()
381
    {
382 2
        $account = $this->get_account();
383
384 2
        $timeframe_minutes = $this->_config->get('password_block_timeframe_min');
385
386 2
        if ($timeframe_minutes == 0) {
387
            return false;
388
        }
389 2
        $release_time = time() + ($timeframe_minutes * 60);
390
        $args = [
391 2
            'guid' => $this->person->guid,
392 2
            'parameter_name' => 'org_openpsa_user_blocked_account',
393 2
            'password' => 'account_password',
394
        ];
395
396 2
        $qb = midcom_services_at_entry_dba::new_query_builder();
397 2
        $qb->add_constraint('argumentsstore', '=', serialize($args));
398 2
        $qb->add_constraint('status', '=', midcom_services_at_entry_dba::SCHEDULED);
399 2
        if ($entry = $qb->get_result(0)) {
400
            //the account is already blocked, so we just extend the block's duration
401
            $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...
402
            return $entry->update();
403
        }
404
405 2
        if (!midcom_services_at_interface::register($release_time, 'org.openpsa.user', 'reopen_account', $args)) {
406
            throw new midcom_error("Failed to register interface for re_open the user account, last Midgard error was: " . midcom_connection::get_error_string());
407
        }
408 2
        $this->person->set_parameter("org_openpsa_user_blocked_account", "account_password", $account->get_password());
409 2
        $account->set_password('', false);
410 2
        return $account->save();
411
    }
412
413
    /**
414
     * Function to delete account
415
     *
416
     * @return boolean indicates success
417
     */
418 1
    public function delete_account()
419
    {
420 1
        return $this->get_account()->delete();
421
    }
422
423
    /**
424
     * Permanently disable an user account
425
     *
426
     * @return boolean - indicates success
427
     */
428 2
    public function close_account()
429
    {
430 2
        $account = $this->get_account();
431
432 2
        if (!$account->get_password()) {
433
            // the account is already blocked, so skip the rest
434 1
            return true;
435
        }
436
437 2
        $this->person->set_parameter("org_openpsa_user_blocked_account", "account_password", $account->get_password());
438 2
        $account->set_password('', false);
439 2
        return $account->save();
440
    }
441
442
    /**
443
     * Reopen a blocked account.
444
     *
445
     * This will fail if someone set a new password on the account while it was blocked
446
     */
447 1
    public function reopen_account()
448
    {
449 1
        $account = new midcom_core_account($this->person);
450 1
        if ($account->get_password()) {
451 1
            $this->person->set_parameter('org_openpsa_user_blocked_account', 'account_password', "");
452 1
            $msg = 'Account for person #' . $this->person->id . ' does have a password already';
453 1
            throw new midcom_error($msg);
454
        }
455
456 1
        $account->set_password($this->person->get_parameter('org_openpsa_user_blocked_account', 'account_password'), false);
457 1
        if (!$account->save()) {
458
            throw new midcom_error('Failed to save account: ' . midcom_connection::get_error_string());
459
        }
460 1
        $this->person->delete_parameter('org_openpsa_user_blocked_account', 'account_password');
461 1
    }
462
463
    /**
464
     * Determine if an account is blocked
465
     */
466 4
    public function is_blocked()
467
    {
468 4
        return !empty($this->person->get_parameter("org_openpsa_user_blocked_account", "account_password"));
469
    }
470
471 1
    public static function get_person_by_formdata($data)
472
    {
473 1
        if (   empty($data['username'])
474 1
            || empty($data['password'])) {
475 1
            return false;
476
        }
477
478 1
        midcom::get()->auth->request_sudo('org.openpsa.user');
479 1
        $qb = midcom_db_person::new_query_builder();
480 1
        midcom_core_account::add_username_constraint($qb, '=', $data['username']);
481 1
        $results = $qb->execute();
482 1
        midcom::get()->auth->drop_sudo();
483
484 1
        if (count($results) != 1) {
485 1
            return false;
486
        }
487 1
        return $results[0];
488
    }
489
490
    /**
491
     * Record failed login attempts and disable account is necessary
492
     *
493
     * @param string $component the component we take the config values from
494
     * @return boolean True if further login attempts are allowed, false otherwise
495
     */
496 1
    public function check_login_attempts($component = null)
497
    {
498 1
        $stat = true;
499 1
        $component = $component ?: "org.openpsa.user";
500
501
        //max-attempts allowed & timeframe
502 1
        $max_attempts = midcom_baseclasses_components_configuration::get($component, 'config')->get('max_password_attempts');
503 1
        $timeframe = midcom_baseclasses_components_configuration::get($component, 'config')->get('password_block_timeframe_min');
504
505 1
        if (   $max_attempts == 0
506 1
            || $timeframe == 0) {
507
            return $stat;
508
        }
509
510 1
        midcom::get()->auth->request_sudo('org.openpsa.user');
511
512 1
        if ($attempts = $this->person->get_parameter("org_openpsa_user_password", "attempts")) {
513 1
            $attempts = unserialize($attempts);
514 1
            if (is_array($attempts)) {
515 1
                $attempts = array_slice($attempts, 0, ($max_attempts - 1));
516
            }
517
        }
518 1
        if (!is_array($attempts)) {
519 1
            $attempts = [];
520
        }
521 1
        array_unshift($attempts, time());
522
523
        /*
524
         * If the maximum number of attempts is reached and the oldest attempt
525
         * on the stack is within our defined timeframe, we block the account
526
         */
527 1
        if (   count($attempts) >= $max_attempts
528 1
            && $attempts[$max_attempts - 1] >= (time() - ($timeframe * 60))) {
529 1
            $this->disable_account();
530 1
            $stat = false;
531
        }
532
533 1
        $attempts = serialize($attempts);
534 1
        $this->person->set_parameter("org_openpsa_user_password", "attempts", $attempts);
535 1
        midcom::get()->auth->drop_sudo();
536 1
        return $stat;
537
    }
538
}
539