Completed
Push — master ( 720dc5...a999b2 )
by Andreas
20:28
created

check_login_attempts()   B

Complexity

Conditions 9
Paths 13

Size

Total Lines 41
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 23
CRAP Score 9.0058

Importance

Changes 0
Metric Value
cc 9
eloc 23
nc 13
nop 1
dl 0
loc 41
ccs 23
cts 24
cp 0.9583
crap 9.0058
rs 8.0555
c 0
b 0
f 0
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 14
    public function __construct(midcom_db_person $person = null)
33
    {
34 14
        if (null !== $person) {
35 10
            $this->person = $person;
36
        }
37 14
        parent::__construct();
38 14
    }
39
40 10
    protected function get_account() : midcom_core_account
41
    {
42 10
        if ($this->account === null) {
43 10
            $this->account = new midcom_core_account($this->person);
44
        }
45 10
        return $this->account;
46
    }
47
48
    /**
49
     * can be called by various handlers
50
     *
51
     * @param string $person_guid
52
     * @param string $username
53
     * @param string $usermail
54
     * @param string $password password: leave blank for auto generated
55
     * @param boolean $send_welcome_mail
56
     */
57 2
    public function create_account($person_guid, $username, $usermail, $password = "", $send_welcome_mail = false) : bool
58
    {
59 2
        $this->errstr = ""; // start fresh
60
61
        // quick validation
62 2
        if (empty($person_guid)) {
63 1
            $this->errstr = "Unable to identify user: no guid given";
64 1
            return false;
65
        }
66
67 2
        if (empty($username)) {
68 1
            $this->errstr = "Unable to create account: no username given";
69 1
            return false;
70
        }
71
72 2
        if ($send_welcome_mail && empty($usermail)) {
73 1
            $this->errstr = "Unable to deliver welcome mail: no usermail address given";
74 1
            return false;
75
        }
76
77
        // Check if we get the person
78 2
        $this->person = new midcom_db_person($person_guid);
79 2
        $this->person->require_do('midgard:update');
80
81
        //need to generate password?
82 2
        if (empty($password)) {
83 1
            $generated_password = true;
84 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

84
            $password = $this->generate_safe_password(/** @scrutinizer ignore-type */ $this->_config->get("min_password_length"));
Loading history...
85
        } else {
86 2
            $generated_password = false;
87
        }
88
89 2
        $account = $this->get_account();
90
91
        //an account already existing?
92 2
        if ($account->get_password()) {
93 1
            $this->errstr = "Creating new account for existing account is not possible";
94 1
            return false;
95
        }
96
97
        //try creating
98 2
        if (!$this->set_account($username, $password)) {
99
            $this->errstr = "Could not set account, reason: " . midcom_connection::get_error_string();
100
            return false;
101
        }
102
103
        //send welcome mail?
104 2
        if ($send_welcome_mail) {
105 1
            $mail = new org_openpsa_mail();
106 1
            $mail->to = $usermail;
107
108
            // Make replacements to body
109 1
            $mail->parameters = [
110 1
                "USERNAME" => $username,
111 1
                "PASSWORD" => $password,
112
            ];
113
114 1
            $this->prepare_mail($mail);
115
116 1
            if (!$mail->send()) {
117
                $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

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

341
        return count(array_flip(/** @scrutinizer ignore-type */ preg_split('//u', $password, null, PREG_SPLIT_NO_EMPTY)));
Loading history...
342
    }
343
344
    /**
345
     * Function to check password age for this user (age is taken from config)
346
     *
347
     * @return boolean - true indicates password is ok - false password is to old
348
     */
349 1
    public function check_password_age() : bool
350
    {
351 1
        $max_age_days = $this->_config->get('password_max_age_days');
352 1
        if ($max_age_days == 0) {
353
            return true;
354
        }
355
356 1
        if ($last_change = $this->person->get_parameter("org_openpsa_user_password", "last_change")) {
357 1
            $max_timeframe = time() - ($max_age_days * 24 * 60 * 60);
358 1
            return $max_timeframe < $last_change;
359
        }
360 1
        return false;
361
    }
362
363
    /**
364
     * Function to disable account for time period given in config
365
     *
366
     * @return boolean - indicates success
367
     */
368 2
    public function disable_account() : bool
369
    {
370 2
        $account = $this->get_account();
371
372 2
        $timeframe_minutes = $this->_config->get('password_block_timeframe_min');
373
374 2
        if ($timeframe_minutes == 0) {
375
            return false;
376
        }
377 2
        $release_time = time() + ($timeframe_minutes * 60);
378
        $args = [
379 2
            'guid' => $this->person->guid,
380 2
            'parameter_name' => 'org_openpsa_user_blocked_account',
381 2
            'password' => 'account_password',
382
        ];
383
384 2
        $qb = midcom_services_at_entry_dba::new_query_builder();
385 2
        $qb->add_constraint('argumentsstore', '=', serialize($args));
386 2
        $qb->add_constraint('status', '=', midcom_services_at_entry_dba::SCHEDULED);
387 2
        if ($entry = $qb->get_result(0)) {
388
            //the account is already blocked, so we just extend the block's duration
389
            $entry->start = $release_time;
390
            return $entry->update();
391
        }
392
393 2
        if (!midcom_services_at_interface::register($release_time, 'org.openpsa.user', 'reopen_account', $args)) {
394
            throw new midcom_error("Failed to register interface for re_open the user account, last Midgard error was: " . midcom_connection::get_error_string());
395
        }
396 2
        $this->person->set_parameter("org_openpsa_user_blocked_account", "account_password", $account->get_password());
397 2
        $account->set_password('', false);
398 2
        return $account->save();
399
    }
400
401
    /**
402
     * Function to delete account
403
     *
404
     * @return boolean indicates success
405
     */
406 1
    public function delete_account() : bool
407
    {
408 1
        return $this->get_account()->delete();
409
    }
410
411
    /**
412
     * Permanently disable an user account
413
     *
414
     * @return boolean - indicates success
415
     */
416 2
    public function close_account() : bool
417
    {
418 2
        $account = $this->get_account();
419
420 2
        if (!$account->get_password()) {
421
            // the account is already blocked, so skip the rest
422 1
            return true;
423
        }
424
425 2
        $this->person->set_parameter("org_openpsa_user_blocked_account", "account_password", $account->get_password());
426 2
        $account->set_password('', false);
427 2
        return $account->save();
428
    }
429
430
    /**
431
     * Reopen a blocked account.
432
     */
433 1
    public function reopen_account()
434
    {
435 1
        $account = new midcom_core_account($this->person);
436 1
        if ($account->get_password()) {
437 1
            debug_add('Account for person #' . $this->person->id . ' does have a password already');
438
        } else {
439 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

439
            $account->set_password(/** @scrutinizer ignore-type */ $this->person->get_parameter('org_openpsa_user_blocked_account', 'account_password'), false);
Loading history...
440 1
            if (!$account->save()) {
441
                throw new midcom_error('Failed to save account: ' . midcom_connection::get_error_string());
442
            }
443
        }
444
445 1
        $this->person->delete_parameter('org_openpsa_user_blocked_account', 'account_password');
446 1
    }
447
448
    /**
449
     * Determine if an account is blocked
450
     */
451 4
    public function is_blocked() : bool
452
    {
453 4
        return !empty($this->person->get_parameter("org_openpsa_user_blocked_account", "account_password"));
454
    }
455
456 1
    public static function get_person_by_formdata($data)
457
    {
458 1
        if (   empty($data['username'])
459 1
            || empty($data['password'])) {
460 1
            return false;
461
        }
462
463 1
        midcom::get()->auth->request_sudo('org.openpsa.user');
464 1
        $qb = midcom_db_person::new_query_builder();
465 1
        midcom_core_account::add_username_constraint($qb, '=', $data['username']);
466 1
        $results = $qb->execute();
467 1
        midcom::get()->auth->drop_sudo();
468
469 1
        if (count($results) != 1) {
470 1
            return false;
471
        }
472 1
        return $results[0];
473
    }
474
475
    /**
476
     * Record failed login attempts and disable account is necessary
477
     *
478
     * @param string $component the component we take the config values from
479
     * @return boolean True if further login attempts are allowed, false otherwise
480
     */
481 1
    public function check_login_attempts($component = null) : bool
482
    {
483 1
        $stat = true;
484 1
        $component = $component ?: "org.openpsa.user";
485
486
        //max-attempts allowed & timeframe
487 1
        $max_attempts = midcom_baseclasses_components_configuration::get($component, 'config')->get('max_password_attempts');
488 1
        $timeframe = midcom_baseclasses_components_configuration::get($component, 'config')->get('password_block_timeframe_min');
489
490 1
        if (   $max_attempts == 0
491 1
            || $timeframe == 0) {
492
            return $stat;
493
        }
494
495 1
        midcom::get()->auth->request_sudo('org.openpsa.user');
496
497 1
        if ($attempts = $this->person->get_parameter("org_openpsa_user_password", "attempts")) {
498 1
            $attempts = unserialize($attempts);
499 1
            if (is_array($attempts)) {
500 1
                $attempts = array_slice($attempts, 0, ($max_attempts - 1));
501
            }
502
        }
503 1
        if (!is_array($attempts)) {
504 1
            $attempts = [];
505
        }
506 1
        array_unshift($attempts, time());
507
508
        /*
509
         * If the maximum number of attempts is reached and the oldest attempt
510
         * on the stack is within our defined timeframe, we block the account
511
         */
512 1
        if (   count($attempts) >= $max_attempts
513 1
            && $attempts[$max_attempts - 1] >= (time() - ($timeframe * 60))) {
514 1
            $this->disable_account();
515 1
            $stat = false;
516
        }
517
518 1
        $attempts = serialize($attempts);
519 1
        $this->person->set_parameter("org_openpsa_user_password", "attempts", $attempts);
520 1
        midcom::get()->auth->drop_sudo();
521 1
        return $stat;
522
    }
523
}
524